WWDC/iOS

[iOS] WWDC18 - iOS Memory Deep Dive

유정주 2022. 10. 19. 19:09
반응형

서론

메모리 공부를 하다 WWDC18의 iOS Memory Deep Dive까지 도달했습니다.

제 실력은 파도풀인데 다이빙하다 머리 깨지지 않을까 걱정했지만 일단 들어봤습니다 ㅎㅎ

내용도 어렵고 말도 너무 빨라 역대급으로 힘들었네요 ㅋㅋ ㅠ

 

천천히 하나씩 정리해 봅시다.

(중간에 Tool에 대한 내용은 다루지 않았습니다.

지금 Xcode 버전으로 다루는 것이 좋을 거 같아서 나중에 따로 포스팅하겠습니다.)

 

Why Reduce Memory

메모리를 줄이는 이유는 뭘까요?

바로 사용자가 더 나은 경험을 할 수 있기 때문입니다.

앱 실행 속도가 빨라지고 시스템이 더 잘 수행됩니다.

앱이 메모리에 더 오래 유지될 수 있고, 그러다보면 다른 앱도 더 오래 메모리에 남아 있을 수 있습니다.

최종적으론 사용자의 더 나은 경험으로 이어지죠.

 

Memory Footprint

메모리 최소 단위는 페이지(Page)입니다.

페이지는 일반적으로 16KB이고, 앱의 실제 메모리 사용량은 (페이지의 수 * 페이지의 크기)입니다.

 

Clean & Dirty 페이지

페이지는 Clean 하거나 Dirty 할 수 있습니다.

Clean과 Dirty는 페이지가 Write 되었는지 안 되었는지에 따라 구분됩니다.

2만 개의 정수 배열로 Clean과 Dirty에 대해 알아보겠습니다.

2만 개의 Int를 메모리에 할당하면 6개의 페이지를 할당 받습니다.

처음 메모리에 할당될 때는 모두 Clean 페이지입니다. (파란색)

2만 개의 배열의 0번과 19999번에 32와 64를 Write하면 첫 번째와 마지막 페이지는 Dirty 페이지가 됩니다. (빨간색)

 

Memory Mapped 파일

Memory Mapped 파일은 파일이 디스크에 있으면서 메모리에도 로드된 파일입니다.

읽기 전용 파일을 메모리에 로드되면 그 메모리는 Clean한 페이지가 됩니다.

 

예를 들어, 50KB짜리 JPEG 파일을 메모리에 올리면 4개의 Clean 페이지를 할당 받습니다.

이미지 파일은 50KB인데 4페이지는 16 * 4 = 64KB이기 때문에 마지막 페이지는 꽉 차지 않습니다.

마지막 페이지의 남은 메모리 공간을 다른 용도로 사용할 수 있습니다.

 

Memory Profile

이렇게 메모리는 Clean, Dirty, Compressed 영역으로 나눌 수 있습니다.

하나씩 살펴보도록 합시다!

 

Clean 메모리는 기록 가능한 메모리를 말합니다.

Clean 메모리는 위에서 말한 Memory Mapped 파일, 이미지, data, training 모델 등이 있습니다.

프레임워크도 Clean 메모리 중 하나기 때문에 모든 프레임워크에는 _DATA_CONST 영역이 있습니다.

이 영역은 일반적으로 Clean 하지만 Swizzling 같은 런타임 동작을 수행하면 Dirty 메모리로 바뀔 수 있습니다.

 

Dirty 메모리는 앱에 의해 데이터가 Write된 메모리입니다.

사용자가 메모리에 데이터를 기록했기 때문에 메모리와 디스크 데이터가 다를 수 있습니다.

 

Dirty 메모리에는 String, Array, NSCache, UIViews 등 malloc 된 모든 객체가 있을 수 있습니다.

디코딩된 이미지 버퍼나 프레임워크도 Dirty 메모리에 포함됩니다.

 

프레임워크는 Clean 메모리와 Dirty 메모리를 둘 다 사용합니다.

이는 링킹(linking)에 필수적인 부분이기 때문입니다.

만약 자체적으로 프레임워크를 관리한다면 싱글톤, 전역 이니셜라이저를 이용해 Dirty 메모리를 줄일 수 있습니다.

싱글톤은 계속 메모리에 남아 있고, 전역 이니셜라이저는 Lazy하게 실행되서 프레임워크가 링크될 때 수행되기 때문입니다.

 

마지막으로 compressed 메모리입니다.

iOS는 디스크 스왑 시스템이 없습니다.

대신 iOS 7부터 도입된 메모리 압축(Memory Compressor)이라는 시스템을 사용합니다.

메모리 압축은 접근하지 않은 페이지를 압축해서 더 많은 메모리 공간을 만들 수 있습니다.

페이지에 접근하면 압축을 해제해서 메모리에 읽을 수 있게 합니다.

 

딕셔너리로 예를 들어보겠습니다.

위 사진의 딕셔너리는 3 페이지를 차지하고 있습니다.

딕셔너리의 접근 되지 않은 페이지를 압축하면,

 

이렇게 1개의 페이지로 압축할 수 있습니다.

그러면 두 개의 여유 페이지가 생성이 됩니다.

추후 딕셔너리에 접근하면 압축이 해제가 되서 메모리에 접근할 수 있게 됩니다.

 

(추가)

메모리 압축 방식과 페이지 스왑의 차이가 궁금해서 찾아봤는데요.

메모리 압축 방식은 페이지 스왑보다 성능 효율이 좋다고 합니다.

페이지를 복사해서 옮기는 효율보다 메모리를 압축하는 효율이 더 좋기 때문입니다.

하지만 메모리 압축이 CPU를 사용하는 작업이기 때문에 윈도우의 경우 이 옵션을 끄는 사람도 많다고 하네요 ㅎ

 

Memory Warning

시스템은 사용 가능한 메모리가 부족해지면, 앱의 메모리를 정리하고

앱에 didReceiveMemoryWarning이라는 Notification을 전달합니다.

 

참고로 이 메모리 경고는 앱이 원인이 아닐 수 있습니다.

예를 들어, 메모리가 부족한 상태에서 전화가 오면 메모리 경고가 발생할 수 있습니다.

따라서 메모리 경고가 있다고 해서 반드시 앱이 원인인 것은 아닙니다.

 

didReceiveMemoryWarning에서는 앱에서 사용 중인 메모리를 해제하는 과정을 넣으면 좋습니다.

대표적으로 캐시가 있는데요. 캐싱 중인 데이터를 삭제하면 앱의 메모리 사용량을 줄일 수 있습니다. 

위 사진에서는 3 페이지를 차지했던 딕셔너리가 모든 캐시 데이터를 삭제하여 1 페이지가 되었네요.

 

많은 데이터 캐싱은 CPU에는 좋을 수 있어도 메모리에는 좋지 않습니다.

캐시가 꼭 필요하다면 딕셔너리보다 NSCache를 사용하는 것이 메모리 측면에선 효율적입니다.

Thread-Safe하게 데이터를 Read, Write 할 수 있고, 메모리가 부족해지면 데이터 Cost에 따라 캐시 데이터가 삭제되기 때문입니다.

반대로 딕셔너리는 메모리가 부족해도 자동으로 데이터를 삭제하지 않습니다.

그러니까 메모리가 제한적인 상황이라면 NSCache를 사용하세요!

 

이번 섹션에서 말하는 앱의 공간은 Dirty와 Compressed 입니다. Clean 영역은 중요하지 않아요.

모든 앱에는 Footprint 제한이 있는데 이건 기기의 메모리 스펙에 따라 달라집니다.

4기가의 디바이스가 1기가의 디바이스보다 제한이 널널하겠죠?

Extension도 공간 제한이 있는데 이건 앱의 공간 제한보다 더 적습니다.

 

제한된 Footprint 공간을 초과하면 EXC_RESOURCE_EXCEPTION이 발생합니다.

따라서 Footprint를 프로파일링해서 익셉션에 대비해야 합니다.

 

위에서도 말했듯이 Footprint 프로파일링 부분은 따로 포스팅하겠습니다.

다만, Tool을 선택하는 기준은 중요해 보여서 여기서도 다루고 다음에 또 다루겠습니다.

프로파일링하는 툴은 세 가지 기준으로 정할 수 있습니다.

먼저, 객체 생성에 대한 메모리 문제, 인스턴스가 얼마나 큰지, 메모리가 어떤 주소를 참조하는지 등 "생성"에 대한 내용이라면 malloc_history를 참고하면 좋습니다.

두 번째로 객체를 참조하는 항목을 확인하려면 leaks를 참고하면 됩니다.

마지막으로 지역이나 인스턴스가 얼마나 큰지 궁금하다면 vmmap이나 heap을 실행해서 스레드를 따라가면 됩니다.

 

Images

이미지는 iOS에서 가장 많은 메모리를 차지합니다.

이미지가 차지하는 메모리 크기는 이미지 파일 크기와는 무관하며 이미지의 dimension에 따라 결정됩니다.

 

아래 예시를 볼까요?

이 이미지의 파일 크기는 590KB지만 메모리는 2048 * 1536 * 4byte = 10MB를 차지합니다. (넘 큰데 ㄷㄷ)

(여기서 4바이트는 한 픽셀이 차지하는 바이트 수에요.)

 

590KB가 10MB가 된 이유를 알려면 OS가 이미지를 처리하는 방식을 살펴봐야 합니다.

iOS에서 이미지는 load -> decode -> render 과정을 거쳐 이미지를 나타냅니다.

Load 단계에서는 OS가 압축된 이미지인 JPEG 파일을 메모리에 올리고, => 590KB

Decode 단계에서 GPU가 렌더링할 수 있도록 이미지 파일의 압축을 풉니다. => 10MB

Render 단계에서 디코드된 이미지를 시각적으로 표현합니다.

 

이미지 포맷

이미지 렌더링 포맷으로 자주 쓰이는 SRGB는 1픽셀 당 4바이트를 차지합니다.

RGB가 각각 1바이트를 가지고 Alpha가 1바이트를 차지해서 총 4바이트입니다.

 

iOS 하드웨어는 색을 더 정확히 표현하기 위해 더 확장된 이미지 포맷을 사용합니다.

Wide 포맷은 1픽셀 당 8바이트를 차지합니다.

(Wide 포맷은 이를 지원하는 디스플레이에서만 유용합니다.)

 

반대로 더 축소된 이미지 렌더링 포멧도 있습니다.

1픽셀 당 2바이트를 차지하는데요.

이 포맷은 Grayscale과 alpha 값만 가지기 때문에 Shader를 위해 사용됩니다.

 

여기서 더 작아지면 alpha 값만 가진 포맷이 됩니다.

1픽셀당 1바이트를 차지합니다.

단색 이미지(흑백인 텍스트, 마스크)를 표현할 때 사용되고 메모리 사용량이 SRGB보다 무려 75% 더 작습니다.

 

내용이 후루룩 지나갔는데도 4개의 이미지 렌더링 포맷이 나왔습니다.

우리는 이제 상황에 맞는 포맷을 선택해야합니다.

알파8 포맷으로 구현 가능한 것을 Wide 포맷으로 구현하면 메모리 낭비가 심할테니까요.

 

하지만 상황마다 매번 포맷을 설정하는 것도 골치겠죠?

그래서 생긴 것이 UIGraphicsImageRenderer입니다.

iOS가 생긴 뒤 계속해서 사용한 UIGraphicsBeginImageContextWithOptions는 무조건 4바이트의 포맷을 선택합니다.

iOS 12부터는 UIGraphicImageRenderer를 사용하면 "자동으로" 최적의 포맷을 선택해 줍니다.

 

검은색 원을 그리는 코드를 예시로 봅시다.

먼저 UIGraphicsBeginImageContextWithOptions를 사용한 코드입니다.

여기서는 픽셀당 4바이트를 사용합니다.

하지만 UIGraphicsImageRenderer을 이용한 이 코드에서는 검은색만 그리는 코드라는 것을 파악하고 자동으로 픽셀당 1바이트만 사용하는 포맷으로 렌더링 해줍니다.

추가로, 해당 마스킹을 다른 색으로 바꾸고 싶을 때 새로운 메모리 할당 없이 색을 바꿀 수 있습니다. (잇츠 릴리 쿨)

 

Downsampling

이미지를 축소하는 다운 샘플링은 UIImage에서도 가능합니다.

UIImage의 이미지 축소는 원본 이미지를 압축하는 방식이라 메모리 사용량이 높습니다.

메모리에 올라와 있는 원본 이미지를 수정하면서 내부 좌표를 변경하기 때문입니다.

 

메모리 사용량을 줄이기 위한 작업인데 메모리 사용량이 높다니... 말도 안 되죠??

그래서 다운샘플링에 UIImage를 사용하는 것은 비추천합니다.

 

대신 ImageIO라는 프레임워크가 있습니다.

ImageIO는 실제로 이미지를 축소할 수 있습니다. UIImage가 압축만 한 것과는 다르죠?

그래서 결과 이미지의 Dirty 메모리 비용만 지불해서 다운샘플링을 할 수 있어요.

 

UIImage를 이용해 이미지를 리사이징하는 코드입니다.

위 코드에는 memory spike가 존재합니다. (memory spike는 갑작스럽게 메모리 사용량이 높아지면서 튀는 것을 의미합니다.)

 

이제 ImageIO를 사용해볼게요.

ImageIO는 low level API이기 때문에 약간의 매개변수 세팅이 필요합니다.

여기서 만들어지는 이미지는 CGImage이며 UIImage를 통해 wrapping 해서 사용할 수 있습니다.

ImageIO는 UIImage 코드보다 50% 더 빠른 속도로 다운샘플링을 할 수 있고 memory spike도 없습니다.

 

Optimizing when in the background

큰 이미지를 보여주는 앱이 있다고 합시다. 앱을 실행하면 큰 이미지가 메모리에 할당되겠죠.

홈 화면으로 나가더라도 이 이미지는 메모리에 그대로 남아 있습니다.

그럼 메모리가 부족해질 수 있습니다.

따라서 화면에 보이지 않는 큰 리소스는 unload 해주는 것이 좋습니다.

 

크기가 큰 리소스를 Unload 하는 방법은 두 가지가 있습니다.

첫 번째는 앱의 생명주기이고, 두 번째는 UIViewController의 생명주기입니다.

(앱의 생명주기는 AppDelegate를 기준으로 합니다.)

백그라운드로 갈 때 Unload하고 포그라운드로 올라올 때 다시 로드를 하면 메모리를 효율적으로 사용할 수 있습니다.

ViewController에 귀속된 리소스라면 viewDidDisappear와 viewWillAppear에서 리소스를 관리하세요.

 


아직은 초보 개발자입니다.

더 효율적인 코드 훈수 환영합니다!

공감 댓글 부탁드립니다.

 

 

 

 

 

반응형