Demo
우리는 NASA에서 얻은 고해상도의 태양계 이미지에 필터를 적용하는 앱을 개발하고 있었다.
근데 James가 앱의 메모리 사용량을 분석한 결과를 보여주었다. 위 사진이다.
여기서 아직 게이지가 'red' 영역에 도달하지 않았기 때문에 앱에 충분한 사용가능 메모리가 있다고 생각할 수 있다.
하지만 좋은 생각이 아니다. 왜냐하면 모든 장치가 2GB의 메모리를 가지고 있는 것이 아니기 때문이다. 즉, 이 앱이 1GB의 메모리를 갖는 장치에서 실행된다면, 운영체제에 의해 앱은 이미 종료되었을 가능성이 크다.
두 번째, 운영체제는 앱이 사용하는 메모리 양뿐만 아니라 운영체제에서 발생하는 다른 활동들도 고려하여 앱을 종료할지 결정한다. 따라서 'red' 영역에 도달하지 않았다고 해서 앱이 종료되지 않을 위험이 없는 것이 아니다.
세 번째, 이런 상황은 사용자에게 최악의 경험을 제공한다. 앱이 너무 많은 메모리를 사용하여 다른 프로세스들이 모두 종료되어야 했기 때문이다. 이로 인해 사용자가 다른 앱을 실행하려고 하면, 해당 앱은 처음부터 로딩되어야 하는 문제가 발생한다.
따라서 메모리를 적게 사용할 수 있도록 우리가 무엇을 할 수 있는지 알아보자.
Memgraph 확인(메모리 누수)
Memgraph 파일은 앱의 메모리 사용 패턴을 시각화하여 보여주는 도구이다.
먼저 Memgraph 파일에서 메모리 누수(leaks)를 찾는 것으로 시작한다.
하단의 filter toolbar를 클릭하면 Memgraph 파일에 있는 누수만 표시된다.
확인해보면, Memgraph 파일 분석에 누수는 발견되지 않는다.
객체 인스턴스 수 확인
다음으로 확인할 것은 메모리에 존재하는 객체 인스턴스의 수이다.
만약 예상보다 많은 객체 인스턴스가 메모리에 존재한다면, 이는 앱의 메모리 사용량에 영향을 미칠 수 있다.
위 사진을 보면 여러 뷰컨트롤러와 'NoirFilter'가 메모리에 존재하는 것을 확인할 수 있다.
하지만, 이 부분에서도 문제가 발견되지 않는다.
이제 이 메모리 내의 특정 객체가 큰 메모리를 할당받고 있는지 확인하기 위해 Memory inspector를 사용해보자.
Memory inspector 확인
오른쪽 상단에 있는 inspector를 클릭하여 각 객체의 크기를 확인할 수 있다.
Appdelegate = 32bytes
DataViewcontrollers = 1536bytes
등의 정보를 확인할 수 있다.
그러나 객체들 중 어느 것도 앱이 사용하고 있는 1GB라는 메모리에 대해 명확한 책임을 가지고 있지 않다.
이로써 Xcode의 Memgraph를 이용한 분석 방법으로는 더 이상 확인할 방법이 없다.
so where do i go now?
vmmap 사용
vmmap은 프로세스에 할당된 가상 메모리 영역을 출력함으로써 앱의 메모리 사용량에 대한 고수준의 분석을 제공한다.
summary flag를 사용하고, Memgraph를 전달한다.
이를 통해 메모리 사용량을 파악해보자.
출력 결과
출력된 결과를 살펴보면, 여러 정보들이 있는데, 우리가 주목해야 할 부분은 바로 'Dirty Size'와 'Swapped Size' 다.
'Virtual Size' 는 앱이 요청했지만, 실제로 사용하고 있지 않은 메모리를 나타내므로 무시해도 좋다.
'Dirty Size'는 앱이 실제로 사용하고 있는 메모리를 나타낸다. 이 열에 큰 값이 나타나면 앱이 많은 메모리를 사용하고 있음을 의미한다.
'Swapped Size'는 iOS에서 Compressed Memory를 나타낸다. 운영 체제는 앱이 사용하는 메모리를 결정할 때, 'Dirty Size'와 'Swapped Size'를 합한 값을 사용한다.
우리는 이 부분에서 큰 숫자를 찾는다. 큰 숫자는 더 많은 메모리의 사용량을 의미한다.
'CGImage' 부분을 보면 'Dirty Size'와 'Swapped Size' 가 큰 메모리를 사용하고 있음을 확인할 수 있고, 이것은 메모리 사용에 있어 문제가 될 수 있다.
'IOSurface'는 큰 'Dirty Size'를 가지고 있지만, 'Swapped Size'는 없다.
'MALLOC_LARGE'도 큰 'Dirty Size'를 가지고 있지만, 상대적으로 작은 'Swapped Size'를 가지고 있다.
우리는 여기서 본 내용을 바탕으로 'CGImage VM 영역' 에 집중하고자 한다.
CGImage VM Region 파악하기
vmmap을 다시 사용하여 Memgraph를 전달하고, 우리가 원하는 'CGImage' 와 관련된 메모리 정보만 얻기 위해
'grep' 명령어를 사용하여 코드를 작성한다.
출력 결과로 CGImage와 관련된 3줄의 정보를 얻는다. 여기에는 두 개의 가상 메모리 영역이 표시되며, 각 영역의 시작 주소와 끝 주소도 표시된다.
[] 배열 안에 들어가있는 것들은 순서대로 'Virtual', 'Resident', 'Dirty', 'Compressed' 메모리에 대한 정보이다.
마지막 줄은 위 정보들을 모두 요약해놓은 데이터이다.
두 가상 메모리 영역 중 하나는 매우 작고, 하나는 매우 크다. 우리는 큰 영역이 메모리 사용량에 큰 영향을 미칠 것으로 보이므로, 이 부분에 대해 좀 더 알아보고자 한다.
'vmmap --verbose PlanetPics.memgraph | grep "CG image"'
'verbose' 플래그는 이름에서 알 수 있듯이 더 많은 정보를 출력해준다.
'verbose' 플래그와 함께 Memgraph 파일을 전달하고, 다시 'CGImage' 영역에 관한 정보만 필터링 하기 위해 'grep' 명령어를 사용한다.
'verbose' 플래그를 사용하니 더 많은 가상 메모리 영역들이 표시된다. 이전에는 적은 수의 영역만 표시되었는데, vmmap이 기본적으로 연속된 영역들을 하나로 합쳐서 보여주는 것이다.
출력 결과에서 각 영역의 'Dirty' 메모리 크기와 'Compressed' 메모리 크기가 다르다는 것을 확인한다.
이를 통해 무엇에 초점을 맞춰야 할지 아이디어를 얻는다.
하지만 우리는 다른 전략을 사용할 것이다. 일반적으로 앱의 수명주기에 늦게 생성된 VM 영역일수록 운영 체제에 의해 늦게 생성된다. 그리고 이 Memgraph 파일은 메모리 사용량이 급증하는 동안 캡쳐되었으므로, 나중에 생성된 영역들이 이 급증과 더 밀접하게 연관되어 있을 가능성이 높다.
따라서, 큰 'Dirty', 'Compressed' 메모리 크기를 가진 영역을 찾는 대신, 출력의 끝에서 시작하여 뒤로 거슬러 올라가며 분석해보자.
*메모리 사용량이 급증하는 문제는 앱의 수명주기 동안 어느 시점에서 발생한 것이다.
이 시점에 생성된 VM 영역들은 문제와 더 밀접한 관련이 있을 수 있다.
따라서 문제를 해결하기 위해 분석에 집중해야 하는 것은 이 시점에 생성된 VM 영역인 것이다.
leaks 사용하기
우리는 메모리 프로파일링에서 'heap' 도구를 사용하는 것에 대해 배웠다.
하지만 heap은 힙 메모리에 있는 객체들에 관한 정보를 제공하고, 여기서는 가상 메모리 영역을 다루고 있기 때문에 도움이 되지 않는다.
또한 'leaks' 도구를 배웠다.
일반적으로 'leaks' 는 메모리 누수를 찾는데 사용된다. 하지만 이미 Memgraph 파일을 통해 메모리 누수가 없음을 확인했다. 그래서 'leaks' 도구가 적절하지 않아 보일 수 있지만, 'leaks' 는 힙에 있는 객체 또는 가상 메모리 영역에 대한 참조를 알려줄 수도 있다.
따라서 'leaks'를 사용하여 분석을 진행한다. 위 명령 코드를 보면 'traceTree' flag를 사용하여 우리가 분석하기로 했던 마지막 주소를 전달하는데, 이를 통해 해당 주소에 대한 참조를 트리 구조로 볼 수 있다. 이 트리 구조를 통해 해당 가상 메모리 영역을 참조하는 것들과 그들 사이의 관계를 살펴볼 수 있다.
위 사진과 같이 우리는 VM 영역과 CGImage 영역을 볼 수 있으며, 어떤 것들이 해당 영역을 참조하고 있는지, 그리고 참조 사이의 관계를 확인할 수 있다.
Xcode로 돌아가서, 우리의 Memgraph 파일을 통해서도 해당 주소로 필터링을 하면, 트리로 표현한 노드를 볼 수 있다.
하지만 모든 노드의 세부 사항을 살펴보는 것은 시간이 오래걸린다.
'leaks' 를 이용한 output이 좋은 점은 빠르게 스캔할 수 있을 뿐만 아니라, 원한다면 검색하거나 필터링 할 수도 있고, Bug report나 이메일에 넣을 수도 있다는 것이다. Xcode에 있는 Graphical View로는 할 수 없음.
우리가 이 output에서 찾고 있는 것은 무엇이죠?
우리는 앱 내에서 직접 사용한 Class들이 메모리에 문제를 발생시키는지 확인해보았고, 문제가 발생하지 않는다는 것을 확인했다.
그렇다면 다음으로 앱이 사용하는 프레임워크 클래스를 조사해보자.
앱이 UIViews, UIImages 및 필터링을 위한 Core Image 클래스를 사용하고 있다.
따라서 출력 결과를 쭉 찾아본다.
하지만 유용한 정보를 찾지 못하기 때문에 다음 단계로 넘어가야 한다.
malloc_history 사용
'mallo_history' 는 해당 인스턴스에 대한 백트레이스가 캡쳐되었다면, 이를 통해 해당 메모리 주소에 할당된 객체가 어떤 경로로 생성되었는지를 파악할 수 있다.
다행히도, 이 Memgraph를 캡쳐할 때, 'memory-backed trace recording' 과 'allocation-backed trace recording' 을 활성화해놓았기 때문에 객체 생성의 backtrace 정보를 볼 수 있다.
mallo_history를 사용하여 memgraph를 전달하고, 'fullStacks' 플래그를 사용하고, vm 영역의 메모리 주소를 전달한다.
'fullStacks' 플래그는 각 프레임을 별도의 줄에 출력하여 읽기 쉽게 해준다.
해당 명령을 실행하면 위와 같은 backtrace 정보가 출력된다.
6번부터 9번까지는 우리의 앱 코드로부터 나온 것이고, 특히 6번에서 'Noirfilter'의 'apply' 함수가 해당 vm 영역을 생성하는데 책임이 있는 것으로 나타난다. 이것은 앱에서 메모리를 많이 사용하는 원인을 찾는 데 있어 중요한 단서가 된다.
실제로 Memgraph 파일로 돌아가서 확인해보면, 이 backtrace 정보가 Xcode에서도 나타나는 것을 확인할 수 있다.
'NoriFilter' 의 'apply' 메소드도 볼 수 있다. 실시간으로 디버깅을 하는 것이 아니기 때문에 일반적으로 역추적 뷰에서 보는 강조 표시는 보이지 않지만, 'malloc_history' 에서 얻은 출력과 동일한 것을 확인할 수 있다.
우리는 지금 CGImage 영역의 끝 주소에 대해 backtrace를 확인해보았다.
분석을 더 확실하게 하기 위해 CGImage의 vm 영역 목록의 아래에서 2번째에 있는 주소(방금 확인한 끝 주소의 위에 위치)를 살펴보자.
이 주소에 대해 backtrace 정보를 확인해보면, 앞서 살펴본 부분과 동일한 backtrace 정보가 나타난다.
이는 동일한 코드 경로가 해당 영역을 생성하는데 책임이 있다는 것을 의미한다.
이외에 여러 VM 영역을 확인해본 결과 동일한 backtrace 정보를 사용하는 것으로 나타난다.
앱 내에서 해당 VM 영역을 생성하고 많은 메모리를 사용하는 코드 부분을 파악하는데 진전이 보였다.
이제 무엇을 해야 할까?
Xcode 코드 확인
우리는 NoirFilter의 apply 메서드가 해당 영역을 생성하고 있는 것을 보았기 때문에
앱의 해당 코드를 살펴본다. 그리고 문제를 발견한다.
'UIGraphicsBeginImageContextWithOptions', 'UIGraphicsEndImageContext' 를 사용하는 부분인데,
이미 우리는 위에서 이것을 사용하지 않고, 더 나은 API를 사용하는 방법에 대해서 알아보았다.
하지만 우리에게 먼저 필요한 것은 기준점이다. 즉, 앱이 얼마나 많은 메모리를 사용하는지 알아야, 변경 사항이 실제로 차이를 만들고 있는지 알 수 있다. 따라서 먼저 디버그 네이게이터를 통해 메모리 보고서를 살펴보자.
그니까 문제가 있어보여서 무조건 고치기 보다는 먼저 해당 메모리가 얼마만큼 쓰이는지 확인하고(기준점을 잡고), 고치고 난 후 얼마만큼의 성과를 얻었는지 확인해보자는 뜻!
디버그 네비게이터를 보면서, 앱을 시작시키고, 해당 apply 메서드가 실행되는 filter 기능을 실행해보니 메모리 사용량이 급격하게 증가하는 것을 확인할 수 있다. 메모리 사용량이 1GB ~ 7GB로 늘어난다. 이것은 굉장히 문제가 되는 상황이며, 실제 디바이스에서 이런 메모리 사용량은 허용되지 않을 것이다.
이렇게 시뮬레이터는 디버깅과 테스트 변경에 유용하지만, 실제 디바이스에서도 모든 것을 검증해봐야 한다.
왜냐하면 시뮬레이터는 메모리가 무한정 있어서 아무리 과다한 메모리를 사용해도, 앱이 강제 종료되지 않기 때문이다.
반면에 디바이스에서는 메모리 사용량이 한계를 넘어서면 앱이 강제로 종료된다.
따라서 실제 디바이스에서 종료되는 경우에 시뮬레이터를 통해 확인하는 것은 문제 발생 상황을 파악하는데 도움이 될 수 있다.
또한 해당 메모리 사용량 최고치를 기록해주는 기능을 보면, 앱의 메모리 사용량이 7.7GB까지 올라간 것을 확인할 수 있다.
이것은 매우 안좋은 상황이다.
BreakPoint를 통해 이미지 크기 확인하기.
다시 'apply' 메서드로 돌아가서, 'UIGraphicsBeginImageContextWithOptions' 메서드에 대해 살펴보고 싶지만, 이보다 먼저 확인할 것이 있다.
이미지를 처리할 때, 메모리 사용과 관련하여 가장 중요한 것은 이미지 크기이기 때문에 이에 대해 먼저 확인해본다.
필터를 다시 적용해보고, 디버거에서 중지되면, 이미지의 크기를 확인한다.
이미지의 크기는 15750 x 13717이다. 이것은 Points 단위로 표시되며, 디바이스 스케일(2x, 3x)에 따라 실제 픽셀 수가 더 많을 수 있다. 이 이미지의 크기는 메모리를 상당히 많이 차지한다.
그 다음으로는 실제 메모리 사용량을 계산해본다. 이미지 크기, iPhone X가 3x 디바이스인 점을 통해 폭과 높이에 3을 곱하고, 픽셀 당 4bytes를 곱한다. (p 15750 * 13717 * 3 * 3 * 4)
계산 결과는 '7777539000' 으로 이전에 본 메모리 사용량과 매우 유사하다.
이를 통해 앱에서 7.7GB의 메모리를 사용하는 주범이 이미지 크기라는 것을 확신할 수 있다.
따라서 'UIGraphicsBeginImageContextWithOptions' 메서드(이놈은 포맷 문제임.) 때문이 아니라 이미지 크기 때문인거죠.
이미지를 이렇게 크게 할 필요가 없으며, View와 같은 크기로 이미지를 축소하면 훨씬 적은 메모리를 사용할 수 있다.
*우리는 Images 파트에서
1. 포맷을 최적화하기 위한 'UIGraphicsImageRenderer' API 사용하기.
2. 다운샘플링을 위한 'ImageIO' 프레임워크 사용하기('CGImageSourceCreateWithURL', 'CGImageSourceCreateThumbnailAtIndex' 사용.)
3. 백그라운드 언로드 처리
이렇게 3가지 방법을 통해 이미지 메모리를 절약하는 방법에 대해서 배웠죠?
지금부터 이 방법을 하나씩 적용하면서 우리의 문제를 해결해나가는 것을 볼거에요!
다운샘플링을 위한 'ImageIO' 프레임워크 사용하기
우리는 이미지 크기가 문제라는 것을 파악했고, 이미지를 가져오는 부분의 코드를 수정하면 된다.
코드는 번들로부터 URL을 가져오고, 해당 URL에서 데이터를 로드하여 UIImage로 변환한 뒤 필터로 전달하는 방식이다.
이 방법은 이미지를 필터에 전달하기 전에 이미지의 크기를 축소하려고 한다.
우리는 UIImage를 통해 크기 조절을 하면 안된다는 것을 학습했다. UIImage에서 크기를 조절하면 결국 전체 이미지를 메모리에 로드해야 되기 때문이다.
따라서 우리는 학습했던 것을 사용하여 'CGImageSourceCreateWithURL'을 호출하여 이미지에 대한 참조를 가져온 다음, 이를 'CGImageSourceCreateThumbnailAtIndex'에 전달한다. 이를 통해 전체 이미지를 메모리에 로드하지 않고도 원하는 크기로 이미지를 조정할 수 있다.
변경한 코드를 실행
앱의 최고 메모리 사용량은 93.5MB로, 변경 전에는 7.5GB였던 메모리 사용량에 비해 매우 개선이 되었다.
박수 짝짝짝!
포맷을 최적화하기 위한 'UIGraphicsImageRenderer' API 사용하기.
'UIGraphicsBeginImageContextWithOptions' 메서드와 ''UIGraphicsEndImageContext'를 사용하는 기존 코드를 삭제하고, 새로운 필터 코드를 추가한다.
기존 메서드는 모든 이미지의 포맷이 SRGB를 사용하게 한다. 이것은 필요에 따라 최적의 이미지 포맷을 사용하는 것이 아님!
'UIGraphicsImageRenderer' 를 통해 객체를 생성하여 이미지를 그린다.
그 다음, 'UIGraphicsImageRenderer'의 렌더링 블록 내부에서 'CIFilter'를 사용하여 원하는 이미지 필터 효과를 적용한다. 이로써 최종적으로 필터가 적용된 이미지를 얻을 수 있다.
이렇게 변경한 코드는 어떤 효과가 있는지 확인해본다.
확인 결과, 메모리 사용량은 이전과 동일하게 나타난다.
이유가 무엇일까?
'UIGraphicsImageRenderer' API는 최적의 포맷을 적용해주는 것이죠?
하지만 해당 이미지 처리에서는 이미 SRGB 포맷을 사용해야 했고, 이것이 최적의 포맷이기 때문에 이전과 메모리 사용량이 동일한 것이다.
그렇다고 전혀 도움이 되지 않은 것도 아니다.
왜냐하면 필요에 따라 메모리를 더 효율적으로 관리할 수 있는 기회가 제공되기 때문이다.
백그라운드 언로드 처리
우리는 앱이 백그라운드로 이동할 때, 이미지를 언로드하여 사용되지 않는 이미지를 메모리에서 제거할 수 있다.
또한 화면에 표시되지 않는 뷰에서 이미지를 숨기고, 이를 통해 불필요한 그래픽 렌더링을 줄일 수 있다.
하지만 우리가 개선한 지금 상황에 만족하고, 해결한 결과에 대해 스크린샷을 찍어 팀원에게 보내준다.
Summary
Memory is a finite and shared resource
메모리는 한정되어 있으며, 앱이 많은 메모리를 사용할수록 시스템과 다른 앱이 사용할 수 있는 메모리가 줄어든다.
따라서, 메모리 사용을 신중하게 관리하고, 필요한 만큼만 사용할 수 있도록 하자.
Monitor memory use when running from Xcode
디버깅 과정에서 Xcode의 메모리 리포트 기능은 매우 중요하다.
앱이 실행되는 동안 메모리 사용량을 모니터링하면, 디버깅 과정에서 메모리 사용량이 증가하는 문제를 발견하는데 도움이 된다.
Let iOS pick your image formats
'UIGraphicsImageRenderer' API를 사용하여 이미지 포맷을 최적화하면 메모리 사용량을 크게 줄일 수 있다.
특히, masks와 text 처리에 유용하다.
Use ImageIO for downsampling images
'ImageIO' 프레임워크를 사용하여 이미지를 다운샘플링하면 메모리 사용량이 급증하는 것을 방지하고, 'UIImage'를 더 작은 컨텍스트에 그리는 것보다 빠르게 처리할 수 있다.
Unload large resources that are off-screen
사용자가 볼 수 없는 이미지나 리소스는 메모리에 로드할 필요가 없으므로, 이들을 언로드하는 것이 좋다.
Use memory graphs to further understand and reduce memory footprint
Memgraph를 사용하여 앱의 메모리 사용패턴을 더 잘 이해하고 메모리 사용량을 줄일 수 있다.
malloc history와 결합하여 메모리가 어떻게 사용되고 있는지에 대해 통찰력을 가질 수 있다.
그리고 이것을 추천한다!
Reference
'강의 정리' 카테고리의 다른 글
WWDC 2019: Advances in UI Data Sources에 관하여 (0) | 2023.08.25 |
---|---|
WWDC 2018: iOS Memory Deep Dive(이미지)에 관하여 (0) | 2023.06.21 |
WWDC 2018: iOS Memory Deep Dive(메모리 프로파일링)에 관하여 (0) | 2023.06.19 |
WWDC 2016 - Protocol and Value Oriented Programming in UIKit Apps 세션에 관하여 (0) | 2023.06.08 |
WWDC2021 - ARC in Swift - Basics and beyond 에 관하여. (0) | 2023.05.31 |