Breadcrumbs
좋은 사용자 경험을 제공하기 위해서 메모리 관리는 아주 중요합니다. iOS의 메모리는 한정된 자원이며, 여러 프로세스가 공유하는 공유 자원입니다. 만약 메모리 누수가 계속 일어나서 현재 앱이 차지하는 메모리가 아주 커지거나, 불필요한 메모리 할당을 하게 되면, 실행 도중 백그라운드에 있던 앱이 종료되거나, 최악의 경우 현재 앱이 강제 종료될 수도 있습니다. 그렇기 때문에 메모리 릭이 일어나는 지를 알아내고 해결하는 등 메모리 이슈를 해결하는 것은 중요합니다.
이번 포스트에서는 iOS의 프로세스 메모리에 대해, 그리고 Xcode를 이용한 메모리 프로파일링에 대해 알아보도록 하겠습니다.
iOS의 메모리 구조를 이해하기 위해선 먼저 가상 메모리와 페이징에 대한 이해가 필요합니다.
프로그램이 동작하려면 메모리 영역이 필요한데, 이는 운영체제에서 할당해 줍니다. 이 때, 실제 물리적인 RAM의 주소를 알려주는 것이 아니라, 가상의 메모리 주소를 알려줍니다. 이렇게 각 프로그램에 실제 메모리 주소가 아닌 가상의 메모리 주소를 주는 방식을 가상 메모리 방식이라고 합니다.
실제 RAM에 접근하는 일은 운영 체제에서 담당하기 때문에, 프로그램에서는 실제 메모리 주소를 의식할 필요 없이 가상 공간 상에서 동작하면 됩니다.
그러면 실제 RAM에는 프로세스에게 할당된 공간이 어떻게 존재할까요? 가상 공간에서는 연속된 메모리 공간처럼 생각하지만, 실제로는 여러 군데에 나누어 존재합니다. 페이징 방식은 이렇게 가상 메모리 공간을 일정한 크기로 분할하여 각 공간을 각각 다른 물리적인 메모리 공간에 매핑하는 방식을 말합니다.
위 그림처럼 일정한 크기로 나눈 가상 메모리 블록을 Page라고 하고, 같은 크기의 실제 메모리 블록은 Frame이라고 합니다.
각 프로세스에는 어떤 페이지가 어떤 물리 주소에 매핑되어야 하는지를 저장하고 있는 페이지 테이블이 존재합니다. 페이지 테이블에는 페이지 테이블 엔트리(PTE)들이 존재하고, 각 PTE에는 프레임 번호와 함께 추가적인 상태 비트가 저장됩니다.
다음은 iOS 앱의 메모리에 대해 알아보겠습니다.
앱에서 메모리가 필요하면, 시스템에서 메모리 페이지를 줍니다. 이 페이지에는 여러 객체들이 저장됩니다. 큰 용량의 객체는 여러 페이지에 걸쳐서 존재할 수 있습니다. 한 페이지는 16KB 정도이고, 각 페이지는 write한 적이 있는지에 따라서 Clean 또는 Dirty 메모리로 나뉩니다.
한 앱의 전체 메모리 사이즈는 다음 그림과 같이 계산할 수 있습니다.
일반적인 앱의 메모리는 다음 그림과 같은 세 부분으로 나뉘어집니다. Clean 메모리는 이미지, 프레임워크 데이터 등을 포함하며, Dirty 메모리는 객체 등 앱에서 수정한 데이터들과, 프레임워크 dirty 메모리 등을 포함합니다.
Compressed memory는 문자 그대로 압축된 메모리를 의미하는데, 일정 기간 동안 특정 메모리 영역에 접근하지 않으면, 시스템이 해당 메모리 페이지들을 압축하고, 다시 접근할 때 압축 해제합니다. 이런 압축된 영역을 Compressed memory라고 합니다. iOS는 Memory compressor를 이용하여 이런 압축 또는 압축 해제 작업들을 수행합니다.
또한 앱이 할당 받을 수 있는 메모리 Footprint에는 제한 한도가 존재하며, 이 한도치를 넘어가면 EXC_RESOURCE_EXCEPTION
익셉션이 발생합니다.
다음은 Xcode의 Memory Graph Debugger를 이용하여 실제 앱에서의 메모리를 분석해 보도록 하겠습니다.
먼저 Xcode Debug Navigator의 Memory를 선택하면, 아래 이미지처럼 현재 앱의 메모리 Footprint를 빠르게 볼 수 있습니다. 이는 위에서 언급한 앱의 전체 메모리 사이즈에 대한 그래프를 실시간으로 보여줍니다.
또한 Xcode의 이 버튼을 누르면,
현재 상태에서 메모리 snapshot을 찍은 뒤, 이 snapshot의 메모리 정보를 보여주는 Memory Graph Debugger가 나타납니다.
왼쪽 패널에서는 현재 메모리에 적재되어 있는 객체들과 해당 클래스의 인스턴스 숫자들, 각 인스턴스들의 주소 목록을 보여줍니다.
왼쪽 패널에서 객체들 중 하나를 선택하면, 해당 객체를 메모리에 유지되도록 하는 레퍼런스들, 즉 해당 객체를 가리키고 있는 객체들이 그래프로 나타납니다.
위 그림에서 검정 선은 strong 레퍼런스를 나타내고, 회색 선은 unknown(weak 또는 strong) 레퍼런스를 나타냅니다. 하지만 이 그래프에서는 선택된 객체가 어떤 객체를 가리키고 있는지는 보여주지 않습니다.
메모리 그래프 디버거는 간단한 메모리 릭은 자동으로 탐지할 수 있습니다. 메모리 릭이 발견되면 보라색 !
마크로 경고를 띄워 줍니다. 클릭하면 메모리 릭이 일어난 인스턴스를 확인할 수 있습니다.
하지만 Xcode에서 모든 메모리 릭을 알려주는 것이 아닙니다. 보통 직접 찾아내야 하는 경우가 더 많습니다.
다음으로 Command Line Tool을 활용할 수도 있습니다.
먼저 메모리 그래프 디버거를 킨 상태에서 File > Export Memory Graph… 를 선택하여 현재의 메모리 스냅샷을 파일로 저장합니다.
메모리 프로파일링과 관련된 커맨드 라인 툴들은 명령어와 함께 메모리 그래프 파일을 넘겨주는 방식입니다. 먼저 vmmap
명령어를 --summary
플래그와 함께 사용하면, 현재 프로세스에 할당된 가상 메모리 공간을 보여줍니다.
vmmap --summary App.memgraph
가상 메모리 공간 사이즈, Dirty인 영역 등을 나누어 출력해 줍니다. 여기서 Swapped Size는 iOS에서는 Compressed Size를 의미하며, 주의할 점은 Memory compressor에 의해 압축되기 전의 사이즈를 의미한다는 것입니다.
--summary
플래그 없이 vmmap
명령어를 사용하면 프로그램의 텍스트와 실행 코드 영역, 힙 영역 등 모든 가상 메모리 영역의 컨텐츠를 보여줍니다.
다음으로 leaks
는 힙의 객체들을 추적하여 retain cycle등을 잡아내며, heap
은 힙 영역의 객체들을 보여줍니다. 기본적으로는 개수가 많은 순서로 출력하며, --sortBySize
플래그를 이용하면 크기가 큰 순서로 출력합니다.
leak App.memgraph
heap App.memgraph
heap --sortBySize App.memgraph
Xcode의 Scheme editor에서 Malloc Stack Logging에 체크하면, 메모리가 할당될 때마다 기록합니다.
이 Malloc Stack Logging이 Enable되어 있을 경우, malloc_history
명령어를 사용하여 특정 인스턴스가 언제 어떤 메서드에 의해 할당되었는지 backtrace를 알 수 있습니다.
malloc_history App.memgraph [address]
지금까지의 내용들을 활용하여 실제 메모리 릭이 일어났을 때의 경우를 확인해 보도록 하겠습니다.
먼저 두 개의 뷰 컨트롤러들을 만들고, 서로 간의 화면 전환을 구현하였습니다. MainViewController
에서는 LeakyModalViewController
를 모달로 띄웁니다. LeakyModalViewController
의 X 버튼을 누르면 뷰 컨트롤러가 dismiss됩니다.
그 다음, LeakyModalViewController
에 메모리 릭이 일어나는 코드를 작성하였습니다. 흔히 사용하는 커스텀 델리게이트 패턴을 이용하였지만 delegate
를 weak
레퍼런스로 선언하지 않았기 때문에 retain cycle이 발생하여 메모리 릭이 발생합니다.
import UIKit
protocol DelegatingViewDelegate {
func delegatingMethod()
}
class DelegatingView: UIView {
var delegate: DelegatingViewDelegate? // strong reference
}
class LeakyModalViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
let delegatingView = DelegatingView()
delegatingView.delegate = self
view.addSubview(delegatingView)
}
// ...
}
extension LeakyModalViewController: DelegatingViewDelegate {
func delegatingMethod() {
print("delegatingMethod")
}
}
앱의 메모리 릭을 캐치하는 좋은 접근 방법은, 앱의 특정 flow를 여러번 반복하면서 반복할 때마다 메모리 그래프 디버거를 이용해 메모리 스냅샷을 찍어 비교하는 것 입니다. 여기서는 present modal 동작과 dismiss 동작을 5회 반복할 때마다의 메모리 스냅샷을 비교해 보겠습니다.
초록색으로 표시한 부분을 보면, 반복할 때마다 특정 인스턴스의 개수가 점점 늘어나는 것을 확인할 수 있습니다. LeakyModalViewController
와 DelegatingView
의 인스턴스들이 화면에서 사라져도 메모리에서 해제되지 않기 때문입니다.
추가적으로, present modal 동작과 dismiss 동작을 연속으로 20회정도 반복하는 동안의 그래프입니다. 메모리 사이즈가 점점 증가하는 것을 확인할 수 있습니다.
레퍼런스 관계도를 보면 DelegatingView
의 인스턴스가 LeakyModalViewController
의 인스턴스를 가리키고 있는 것을 확인할 수 있습니다. DelegatingView
의 인스턴스는 addSubView()
메서드를 이용해 LeakyModalViewController
의 view
의 subviews
배열에 추가되었었습니다. 컬렉션 타입은 자신의 요소들을 strong 레퍼런스로 가리키고 있으므로 retain cycle이 발생한 것입니다.
주의할 점은, 위에서도 언급한 내용이지만 메모리 릭이 일어난다고 해서 항상 경고가 뜨진 않는다는 것입니다. 클로저를 이용하여 retain cycle 만들기 등 몇가지 실험을 해 봤지만 경고가 뜨는 경우는 거의 없었습니다. 지금 이 경우에도 보라색 !
경고가 뜨지 않았습니다. retain cycle이나 메모리 릭을 방지하는 방법을 익히고 항상 염두에 두어야 하며, 테스팅 과정도 꼭 필요합니다.
이 경우에서 메모리 릭을 해결하는 방법은 delegate
를 weak로 선언하는 것입니다. 이 때 프로토콜에는 class
제약을 걸어야 합니다.
protocol DelegatingViewDelegate: class {
func delegatingMethod()
}
class DelegatingView: UIView {
weak var delegate: DelegatingViewDelegate?
}
다시 메모리 그래프 디버거를 이용하여 확인해 보면, 여러 번 화면 전환을 반복해도 MainViewController
에 있는 상태에서는 LeakyModalViewController
와 DelegatingView
의 인스턴스가 메모리에서 해제된 것을 볼 수 있습니다.
메모리 릭이 해결된 상태에서 다시 present modal 동작과 dismiss 동작을 연속으로 20회정도 반복하며 메모리 그래프를 확인해 보면, 메모리 사이즈가 유지되는 것을 확인할 수 있습니다.
이렇게 기초 운영체제 개념부터, iOS의 메모리 구조, 그리고 이 개념들을 기초로 실제 개발 시의 메모리 프로파일링과 메모리 이슈 디버깅 방법에 대해 정리해 보았습니다.
전체 소스 코드는 이곳의 MemoryProfiling 프로젝트에서 확인하실 수 있습니다.