iOS 메모리에 대해 심층 분석을 해보자.
해당 글의 원본 내용은 WWDC18 - iOS Memory Deep Dive 입니다.
이번에 WWDC 18 세션 중 iOS Memory Deep Dive 세션을 보면서 공부한 내용들을 정리해보기 위해 이 글을 적었다.
이 글은 총 3개의 글로 이루어질 예정이다.
Part 1은 iOS 메모리 심층 분석에 초점을 맞추었고,
Part 2는 footprint를 프로파일링 하는 방법에 대해 소개,
Part 3는 알아본 내용으로 실제 메모리 관리, 프로파일링을 해보는 것으로
글을 마무리 짓겠다.
메모리 프로파일링을 하는 도구는 몇가지가 존재한다.
우리가 가장 많이 사용하는 프로파일링 도구는 Xcode 메모리 게이지 이다.디버그 탐색기에 표시되고, 앱의 메모리 사용량을 빠르게 확인할 수 있는 좋은 방법이다.
다른 도구는 어떤것들이 있을까?
Instruments이다.
Instruments는 앱의 footprint를 조사하는 여러 가지 방법을 제공한다.
다들 Allocations(할당)와 Leaks(누수)는 이미 익숙하다고 느낄 것이다.
Allocations은 앱에서 만든 힙 할당을 프로파일링하고, Leaks는 시간이 지남에 따라서 프로세스에서 메모리 누수를 확인한다.
그러나 VM Tracker나 Virtual memory trace는 익숙하지 않을 수 있다.
Part 1에서 iOS의 메모리 구조를 살펴봤을 때 Dirty Memory와 Compressed Memory에 대해 얘기 했었다. VM Tracker는 이것들을 프로파일링 하는 방법을 제공한다.
사진에서 볼 수 있듯, Dirty 와 Swap에 대한 별도의 트랙이 존재하며 (iOS 에서는 Swap이 Compressed Memory이다.) 상주하고 있는 메모리의 정보또한 알려준다.
마지막으로, Instruments에는 VM Memory Trace 기능이 있다. 이 기능은 앱과 관련하여 가상 메모리 시스템 성능에 대한 상세 보기 기능을 제공한다.
사진이 작아서 잘 안보일 수도 있지만, 리스트에는 가상 메모리에 대한 Cache Hit 라던지, COW, Zero fill 등의 정보를 확인할 수 있다.
Zero Fill?
Zero Fill을 알기 위해서는 COW를 알아야 하는데, 간단히 말해 COW(Copy on Write)는 자식 프로세스를 생성하면 부모 프로세스가 가지고 있던 Page table을 그대로 물려받는다.
보통 이 상황에서는 주소만 공유하고 복사가 된 상황이 아닌데, 자식 프로세스에 Write 작업을 요청하면 page를 메모리에 복사해 새로 올리게 된다.
이 때, 자식 프로세스가 새로 복사하고 메모리에 올리는 page는 0으로 초기화 된 상태로 복사하게 되는데, 이유는 보안 이슈 때문이다. 자식 프로세스의 page를 보고 부모 프로세스가 어디에 접근했는지와 같은 정보를 읽을 수 있기 때문이다.
이렇게 0으로 초기화 해주는 것을 zero-fill-on-demend-page라고 한다.
또한 Part 1 에서 메모리 Limit에 도달하면 EXC_RESOURCE 예외처리를 받게 된다고 말했다. 따라서 Xcode 에서는 예외가 발생했을 경우 Break Point로 일시중지되어 상태를 알려준다.
내부적으로 Xcode는 Memgraph 파일 형식을 사용하여 앱의 메모리 사용에 대한 정보를 저장한다. 우리가 모를 수도 있던 것은 Memgraphs를 여러 Commend Line에서도 사용할 수 있다는 것이다.
먼저 Xcode에서 Memgraph를 내보내야 한다. 방법은 간단하다.
Xcode -> File -> Export Memgraph 버튼을 클릭하고 저장하면 된다.
그런 다음 해당 Memgraph를 Commend Line Tool에 전달할 수 있다.
Xcode에서 메모리 이슈로 인해 예외가 발생했고, 더 자세히 보고 싶기에 Memgraph를 가져와서 살펴보고 싶다. 어떻게 하면 될까?
터미널에서 vmmap을 이용하면 된다.
vmmap App.memgraph
vmmap --summary App.memgraph
프로세스에 할당된 VM 영역을 print하여 앱의 메모리 사용에 대한 상세한 분석을 제공한다.
region 메모리의 크기, Dirty Memory의 Size, Compressed Size 등 메모리 Size에 대한 세부 정보를 보여준다. 여기서 정말 중요한 데이터는 Dirty Size와 Swap Size 임을 기억하고 있어야 한다.
한 가지 중요한 점은 Swap Size는 데이터가 압축된 크기가 아니라 미리 압축된 데이터의 크기를 예상하여 제공한다는 점이다.
더 Deep 하게 파고들고 싶을 때는 Memgraph에 대해 vmmap을 실행하면 모든 region의 Content를 얻을 수 있다.
프로그램의 Text나 실행한 코드들과 같은 Write 행위가 불가능한 region이나, Data 섹션같은 Write 행위가 가능한 곳들을 한 곳에서 보여준다.
위 사진에서 보이는 곳은 프로세스의 힙이 있는 곳이다.
다음으로 장점인 부분은 이런 Commend 들이 command-line 유틸리티를 포함하여 작동한다는 점이다. 예를 들어, 얼마전 VM Tracker에서 내 앱을 프로파일링 했는데, Dirty Memory 양이 증가하는 것을 보았다.
그래서 Memgraph를 가져왔고, 파헤쳐 보기로 했다.
vmmap -pages PlanetPics.mamgraph | grep '.dylib' | awk '{ sum += $6 } END { print "Total Dirty Pages: " sum '
코드를 하나씩 살펴보자.
vmmap -pages PlanetPics.mamgraph
pageFlag
vmmap은 bytes 형식 대신 페이지 수를 보여준다.
grep '.dylib'
grep
그런 다음 grep 으로 dylib를 검색한다.
awk '{ sum += $6 } END { print "Total Dirty Pages: " sum '
마지막으로 Dirty Page num을 합하고 print 하는 awk 스크립트로 파이프한다.
따라서 결과값으로 우리가 원하는 Dirty Page 값이 나온다.
Total Dirty Pages: 152.27
또다른 commend-line 유틸리티는 leaks 이다. 런타임에 root가 없는 오브젝트를 추적한다. 오브젝트가 leaks 상태라면 절대 해제할 수 없는 Dirty Memory 라는 걸 알아둬야 한다.
위 사진으로 Leaks 상태인 오브젝트를 설명할 수 있는데, 여기에는 모두 서로에 대한 강력한 참조로 가지고 있는 3개의 객체가 있어 어느 하나라도 해제가 될 수 없는 상태로 유지가 된다.
동일한 상황을 leaks Tool에서 확인해보자.
로그를 살펴보면, leaks 상황의 객체들이 속한 Retain Cycle도 표시되고, 프로세스에서 malloc 스택 로깅이 활성화된 경우 Root node에 대한 역추적 또한 제공한다.
또한 Heap에 대한 정보를 확인할 수 있는 command-line 유틸리티도 존재한다.
heap App.memgraph
위 커맨드를 입력하면 각 오브젝트의 클래스 이름, 오브젝트 수, 해당 오브젝트 클래스의 평균 크기 및 전체 크기에 대한 정보들이 나온다.
기본적으로 Heap은 개수별로 정렬된다. 하지만 크기 대로 보고 싶다면 sortBySize
플래그를 힙에 전달하면 크기대로 보여진다.
위 사진에서 뭔가 용량이 비정상적으로 큰 오브젝트를 발견했다. 그런데 용량 말고는 이게 어떤 오브젝트인지 명확히 보여지지 않는다.
이 오브젝트의 주소를 알고 싶다면
heap App.memgraph -addresses all | NSSConcreteData PlanetPics.memgraph
이렇게 Commned-line에 입력하면 된다.
클래스 이름과 함께 힙에 전달하면 힙의 각 인스턴스에 대한 주소가 제공된다. 이제 주소를 알게 됐고, 이들 중 하나가 어디에서 왔는지 알아낼 수 있다.
이건 malloc 스택 로깅을 허용하는 옵션이다. 이 옵션이 활성화 되면 시스템은 각 할당에 대한 역추적을 기록한다. 이런 로그들은 Memgraph를 기록할 때 캡쳐가 된다.
역추적을 허용하였으니 할당에 대한 역추적을 해보자.
malloc_history App.memgraph [address]
아까 NSSConcreteData 의 역추적을 했고, 백트레이스를 얻었다. 그리고 중간에 NoirFilter에서 용량이 증가하는 것을 확인할 수 있다.
앞서 소개한 방법들은 앱의 동작을 심층적으로 조사할 수 있는 몇가지 방법에 불과하다.
메모리 문제에 직면했을 때 사용할 수 있는 도구들은 3가지로 구분 지을 수 있다.
메모리의 오브젝트 또는 주소를 참조하는 것이 어떤 것인지 확인하고 싶다거나, 주소에 대한 역추적을 원한다면 malloc_history
를 사용하고
메모리에서 오브젝트를 참조하는 항목을 확인하려는 경우 leaks
를 사용하고
reigon이나 인스턴스의 크기를 확인하고 싶다면 vmmap이나 heap이 도움이 될 것이다.
이제는 iOS앱에서 가장 큰 오브젝트가 될 수 있는 Image에 대해 얘기 해보자.
잠깐 이미지에 대해 얘기하기 전에 기억해야 할 가장 중요한 점은 메모리 사용이 파일 크기가 아니라 이미지의 크기와 관련이 있다는 것이다.
이게 무슨 말인가 할 수 있다.
예를 들어보자.
iPad 배경화면으로 사용하고 싶은 풍경 사진이 있다.
이미지의 크기는 2048 x 1536 이고, 파일 사이즈는 590 KB이다.
근데, 실제로 메모리는 얼마나 사용할까?
무려 10MB나 사용한다.
너비와 높이에 픽셀당 4바이트를 곱하면 약 10MB가 되기 때문이다.
그럼 왜 이렇게 크게 될까? 이것을 이해하려면 iOS에서 이미지가 작동하는 방식에 대해 알아야 한다.
iOS에서 이미지를 처리하는 방식은 Load -> Decode -> Render 순으로 이루어진다.
Load 단계에서는 압축된 590 KB 짜리 JPEG 파일을 메모리에 로드한다.
디코딩은 해당 JPEG 파일을 GPU가 읽을 수 있는 형식으로 변환한다. 이때 압축을 해제해야 하므로 10MB가 된다.
일단 디코딩이 되면 마음대로 렌더링 할 수 있다.
이제 픽셀당 4바이트를 SRGB 형식으로 얻었다. SRGB는 일반적으로 그래픽의 이미지에서 가장 일반적인 형식이다. 픽셀당 8Bit이므로 Red 1Byte, Green 1Byte, Blue 1Byte 그리고 Alpha 구성 요소가 존재한다.
놀랍게도 이것보다 더 높게 갈 수 있다.
iOS 하드웨어는 Wide Format을 렌더링할 수 있다. 이제 Wide Format은 표현력 있는 색상을 얻으려면 픽셀당 2Byte가 필요하므로 이미지의 크기를 두 배로 늘린다.
(WWDC 18 당시 최신 기기 기준) iPhone 7, 8, X, iPad Pro 의 카메라는 이 고화질 컨텐츠를 Capturing 하는데 적합하다.
보통 스포츠 고로 등과 같은 매우 정확한 색상에서도 사용이 가능하다.
그러나 이 옵션은 Wide Format 디스플레이에서만 유용하지 다른 곳에서는 이 옵션을 사용하고 싶지 않을 수 있다.
그래서 반대로 우리는 더 작아질 수도 있다.
이제 휘도와 Alpha 8 포맷만 존재한다. 이 옵션은 일반적으로 Metal 앱과 같은 shaders에서 사용된다. 우리가 사용하는 것과는 약간 다른 느낌이다.
여기서 더 작아질 수도 있다.
이 형식을 Alpha 8 이라고 부른다. 픽셀당 1Byte, 1개의 채널만 존재한다. SRGB보다 75% 작다. 보통 이 형식은 mask 나 단색 텍스트에 적합하다.
따라서 정리해보면, Alpha 8의 픽셀당 1Byte에서 Wide Format의 픽셀당 최대 8Byte까지 갈 수 있는 것이다.
범위가 엄청나게 큰데, 우리는 올바르게 선택하는 방법밖에는 없다. 그럼 어떻게 올바르게 선택할까?
사실 아무것도 선택하지 않는게 방법일 수도 있다.
형식 자체가 사진을 선택할 수 있게 해야 한다.
iOS가 시작될 때부터 있었던 UIGraphics BeginImageContext WithOptions API 사용하지 않고 대신 UIGraphics ImageRenderer 형식으로 변경하면 용량을 줄일 수 있다.
이유는 항상 UIGraphics BeginImageContext WithOptions는 4바이트로 구성하기 때문에 항상 SRGB 포맷으로 진행하기 때문에 SGRB가 필요하지 않은 데이터라도 SRGB 포맷으로 진행하여 데이터를 낭비할 수 있다.
대신, iOS 12부터 포함된 UIGraphics ImageRenderer API를 사용하면 자동으로 최상의 그래픽 형식을 선택한다.
예시를 들어보자.
마스크에 대한 원을 그리고 있다고 가정해보자.
검은색 원을 그리기 위해서 픽셀 당 4Byte 형식의 데이터를 얻는다.
대신 새로운 API로 전환하게 될 경우 동일한 코드로 픽셀 당 1Byte 형식의 데이터를 얻을 수 있다.
추가적으로 이 마스크의 색상을 변경하려는 경우 UIImageView로 추가하여 tintColor 만 변경해주면 된다. 이렇게 될 경우 따로 메모리를 할당할 필요가 없이 layer를 업데이트 쳐주기만 하면 된다.
일반적으로 이미지의 용량을 낮추는 또 다른 작업은 이미지를 다운샘플링하는 것이다. 보통 썸네일 같은 것을 만들고 싶을 때 축소하고 싶어 한다.
우리가 하고 싶지 않은 것은 축소를 위해서 UIImage를 사용하는 것이다. 실제로 UIImage를 사용하여 그리는 경우 내부 좌표 공간 변환으로 인해 성능이 약간 떨어진다.
그리고 앞에서 본 것처럼 메모리의 전체 이미지 압축을 풀게 된다.
대신 ImageIO 프레임워크가 존재한다.
ImageIO는 실제로 이미지를 다운샘플링할 수 있으면 결과 이미지의 Dirty Memory만 할당하도록 되어있다.
예를 들어보자.
디스크에 파일을 가져오는 코드가 있다.
그리고 UIImage를 이용하여 더 작은 사각형을 그린다.
이 액션은 큰 스파이크를 가지게 될 것이다. (메모리 증폭 말하는 것 같다.)
위 코드는 ImageIO를 이용하여 이미지를 구성하는 코드이다.
이미지가 얼마나 커야 하는지 알려주는 Low Level API 이기 때문에 몇 가지 파라미터를 제공해주고 난 다음 CGImageSourceCreateThumbnailAtIndex로 이미지를 생성하도록 요청한다.
결과는 훨씬 작은 이미지가 나오고, 이전 코드보다 약 50% 더 빠르다.
마지막으로 얘기 하고 싶은 것은 백그라운드에서 최적화 하는 방법이다.
보통 Foreground 상황에서는 이미지를 로드하고 이미지를 보고 있지 않은 상황에서는 이미지를 언로드 하는게 맞다.
앱의 라이프 사이클을 이용하여 Background진입시 이미지를 unLoad 하는 것 또한 메모리를 낭비하지 않는 방법 중 하나다.
Part 2 에서는 footprint를 프로파일링 하는 방법을 알아보았다.
malloc_history
를 통해서 주소의 역추적을 통한 메모리 사용량을 볼 수 있었고, 메모리의 강한 참조 이슈를 확인하고자 할때에는 leaks
를, reigon이나 인스턴스의 크기를 확인하고 싶다면 vmmap
이나 heap
을 사용하여 프로파일링을 진행할 수 있었다.
또한 Part 2 에서는 iOS가 이미지를 어떻게 처리하는지, 왜 파일 사이즈와는 다른 크기의 이미지가 로드되는지를 알아보았다.
이미지는 파일 사이즈가 아닌, 이미지 사이즈로 결정이 된다는 점, SRGB, Alpha 8, Wide Format 등 다양한 포맷이 존재한다는 점, UIImage보다 ImageIO로 다운샘플링을 하면 속도도 빠르고 용량도 줄어든다는 점을 알 수 있었다.
다음 시간에는 이렇게 배운것들을 가지고 실제 프로젝트에 사용을 해볼 예정이다.