‼️ 중요 - 픽셀의 크기
RGBA(Red, Green, Blue, Alpha)로 표현함.UIKit이 이미지를 그리는 방법을 알기 위해 이미지와 메모리의 관계를 먼저 알아야 합니다.

이미지 뷰는 다른 컴포넌트에 비해 메모리 사용량이 엄청나기 때문에 효율적으로 다룰 수 있어야 합니다.
위 사진처럼 2048x1536 해상도를 가지는 590KB 크기의 JPG 이미지는 메모리를 얼마만큼 차지할까요?
정답은 10MB입니다.
그 이유는 이미지가 사용하는 메모리 사용량은 파일 크기와 직접적인 관계가 없고, 이미지의 해상도(픽셀 수)에 따라 달라지기 때문입니다.
(JPG 또는 PNG의 경우 비트맵 이미지이므로 픽셀 단위로 저장됩니다.)
하나의 픽셀은 R, G, B, A(투명도) 총 4가지 채널로 표현되고, 각 채널마다 0~255까지의 정수값을 가집니다.
즉, 각 채널의 정보를 메모리에 저장하기 위해 8 비트 = 1 바이트만큼의 공간이 필요합니다.
그러므로 하나의 픽셀은 총 4 byte의 메모리 공간을 차지하게 됩니다.
따라서 위 이미지의 메모리 사용량은 2048 x 1536 x 4 byte = 약 10MB 로 계산할 수 있습니다.

iOS는 이미지를 화면에 표시(render)하기 위해 Load -> Decode -> Render 과정을 거칩니다.
590KB JPG 파일을 메모리로 불러옵니다.10MB 크기의 비압축 데이터가 됩니다.앞에서는 간단하게 이미지의 각 픽셀당 4바이트의 메모리 공간이 필요하다고 했습니다.
그러나 이미지 포맷에 따라 더 클수도 작을수도 있습니다.

SRGB 포맷은 이미지 렌더링 시 가장 일반적으로 사용되는 포맷입니다.
픽셀당 8바이트(채널 당 1바이트)이므로, 총 4바이트입니다.

SRGB보다 더 큰 포맷도 존재합니다.
iOS 하드웨어는 와이드 포맷(광색역)을 렌더링 할 수 있습니다.
이 포맷을 사용하면 더 풍부한 색을 표현하기 위해 채널당 2바이트가 필요하므로, 크기가 두 배가 됩니다.
이 포맷은 와이드 포맷 디스플레이에서만 사용가능하므로, 불필요하게 사용한다면 메모리를 많이 잡아먹게 됩니다.

RGB, Wide format보다 더 작은 포맷도 존재합니다.
LA8 포맷은 그레이스케일 값과 알파 값만 저장하므로, 픽셀 당 2바이트를 차지합니다.
이 포맷은 그레이스케일과 알파 값만 저장하므로 Shader에서 사용됩니다.
(Metal 앱 등에서 활용한다고 합니다.)

LA8에서 그레이스케일을 덜어내면 Alpha 8 포맷이 됩니다.
알파 1채널만 가지므로 픽셀 당 1바이트만을 사용합니다.
SRGB와 비교해 75% 작습니다.
즉, 메모리를 75%를 절약할 수 있으므로 단색(monochrome) 이미지 or 단색 텍스트, 마스크에 적합합니다.

이미지의 포맷에 따라 픽셀 당 메모리 사용량은 1바이트부터 8바이트까지 매우 다양합니다.
따라서 개발자는 올바른 포맷을 선택할 수 있어야 합니다.

다행히도 애플에서는 시스템이 자동으로 적절한 포맷을 골라줄 수 있도록 UIGraphicsImageRender라는 API를 제공해줍니다.
UIGraphicsBeginImageContextWithOptions는 초기 iOS부터 계속 사용했던 API입니다.
이 놈은 무조건 픽셀당 4바이트를 사용하기 때문에 UIGraphicsImageRender 를 사용한다면 상황에 따라 메모리 사용량을 절약할 수 있습니다.
(WWDC의 이후 내용들은 위 API를 활용한 최적화 관련 내용이라 생략했습니다.)
메모리와 CPU는 별개로 생각할때가 많습니다. 심지어 Xcode 디버그 내비게이터나 Instruments에서도 각각 독립된 트랙과 도구를 가지고 있습니다.
사실 이 두 놈은 긴밀하게 연결되어 있습니다.
CPU 사용량이 높을 때
위 사진처럼 CPU 사용량이 높을 때 배터리 수명과 응답성에 나쁜 영향을 줍니다.
메모리 사용량이 높을 때

만약 앱이 더 많은 메모리를 소비한다면 CPU 사용량도 증가하고, 배터리와 응답성, 성능에 추가적인 악영향을 줍니다.
이 두 자원을 효율적으로 사용하지 못하면 앱의 응답성이 낮아지고, 기기의 배터리 수명에도 영향을 미칩니다.
따라서 소중한 사용자들을 붙잡기 위해 최적화는 중요한 작업입니다.
특히 이미지 뷰는 메모리를 많이 잡아먹기 때문에 더욱 신경써야하는 컴포넌트입니다.

UIKit에서 이미지 데이터를 다룰 때 두 가지 고수준 API를 사용합니다.
UIImage: 이미지 데이터를 다루는 UIKit의 클래스.UIImageView: 해당 이미지를 화면에 표시하는 UIKit의 View 클래스.Note - 앱의 그래픽 컨텐츠: Rich content, Icon
앱의 그래픽 컨텐츠는 두 가지로 나눌 수 있습니다.
Rich content: 사진과 같은 컨텐츠를 의미합니다.Icongraphy: 버튼 같은 놈에서 볼 수 있는 아이콘을 의미합니다.

MVC 관점에서 UIImage는 모델, UIImageView는 뷰로 볼 수 있습니다.
따라서 UIImage는 이미지 컨텐츠 로딩의 책임을 가지고, UIImageView는 이미지 데이터 렌더링 책임을 가집니다.

UIImage와 UIImageView는 위 사진의 관계라고 생각할 수 있지만 사실은 더 복잡합니다.
렌더링은 한 번으로 끝나는 이벤트가 아니라 연속적인 과정입니다.
또한 성능 이해를 위해 디코딩 과정이 있다는 것을 꼭 알아야합니다.

앞에서는 디코딩 과정을 간단히 살펴봤습니다.
이젠 디코딩이 왜 필요한지, 어떤 과정이 있는지 등 더 깊게 살펴보겠습니다.
버퍼란 간단히 연속된 메모리 영역을 의미합니다.
일반적으로 내부 구조가 동일한 동일 크기의 원소들로 이루어진 메모리 시퀀스를 버퍼라고 합니다.

이미지 버퍼도 마찬가지로 이미지의 메모리 표현을 나타내는 버퍼입니다.
이미지 버퍼의 각 원소는 단일 픽셀의 정보를 나타냅니다.
앞에서 살펴봤듯이 SRGB 포맷이라면 R, G, B와 투명도 정보를 나타냅니다.
따라서 버퍼 크기는 이미지의 해상도에 비례하게 됩니다.
가장 중요한 버퍼는 프레임 버퍼입니다.
프레임 버퍼는 앱의 실제 렌더링 결과를 담습니다.
즉, Load → Decoding → Render의 최종 결과가 저장되는 버퍼입니다.
아래에서 더 자세히 살펴보겠습니다.
데이터 버퍼는 말 그대로 이미지 데이터가 저장된 버퍼로, 이미지의 메타데이터도 포함되어 있습니다.
특이한 점은 픽셀 자체의 정보를 담지 않고, JPEG/PNG 처럼 이미지 형식으로 인코딩된 이미지 데이터가 저장됩니다.
자세한 내용은 아래에서 다시 설명하겠습니다!
이미지를 렌더링하기 위해 총 세 가지의 버퍼가 사용됩니다.
결론부터 말씀드리면 이미지 렌더링은 아래의 과정을 거치게 됩니다.
이미지 렌더링 파이프라인
데이터 버퍼에 이미지 데이터를 저장하고
→ 이미지 버퍼의 각 영역에 픽셀 정보를 저장하고
→ 렌더링 된 결과를 프레임 버퍼에 저장하고
→ 프레임 버퍼에 저장된 값을 바탕으로 디스플레이 하드웨어가 화면에 표시한다.

이미지는 위 과정을 통해 그려집니다. 각 단계를 자세히 살펴봅시다!


앱의 뷰 계층이 변경되면 UIKit은 앱의 윈도우와 모든 서브뷰를 프레임 버퍼로 렌더링합니다.

이후 디스플레이 하드웨어는 프레임 버퍼의 각 요소를 읽어 화면의 픽셀을 빛나게 합니다.
이 과정은 고정된 간격(화면 주사율 주기)로 일어납니다.
주사율이 60Hz라면 1/60초, 120Hz라면 1/120초마다 발생합니다.
(뷰 계층의 변경이 없다면 디스플레이 하드웨어는 동일한 프레임 버퍼 데이터를 읽습니다.)

(UIImage가 하나 더 추가되었네요!)
만약 뷰 컨텐츠가 변경된다면 (예: 이미지 뷰에 새로운 UIImage를 할당),
UIKit은 다시 렌더링 작업을 수행하고, 프레임 버퍼에 결과를 저장합니다.
이후 디스플레이 하드웨어는 다음 주기에 새 컨텐츠를 얻은 후 화면에 표시됩니다.

데이터 버퍼는 단순히 바이트 시퀀스를 담고 있는 버퍼로 이미지 버퍼와 대비되는 개념입니다. 네트워크에서 fetch하거나 디스크에서 읽은 이미지 데이터가 저장됩니다.
데이터 버퍼의 구성
(이미지 파일을 담은) 데이터 버퍼는 보통 이미지 크기 등 메타데이터로 시작합니다.
이후 JPEG/PNG 등의 인코딩 된 이미지 데이터가 옵니다.
즉, 메타데이터 뒤의 바이트들은 픽셀 자체의 정보가 아닌 인코딩 된 데이터입니다.
(사진의 JPEG 표시가 이를 의미하는 것 같습니다!)
네트워크 혹은 디스크에서 이미지를 불러와 UIImageView에 이미지를 표시하는 과정을 살펴봅시다.
UIImageView에 UIImage를 할당했고(imageView.image = UIImage(…)), 이미지는 아직 화면에 표시되지 않은 상태입니다.
따라서 UIImageView 껍데기만 뷰 계층에 올라간 상태입니다.

UIImage는 네트워크/디스크에서 가져온 이미지 파일 데이터 버퍼를 갖고 있습니다.
(버퍼가 있으니 이미지 데이터가 메모리에 올려진 상태입니다!)
그러나 이 데이터 버퍼를 프레임 버퍼에 바로 할당할 수 없습니다.
프레임 버퍼는 각 픽셀 당 정보를 바탕으로 렌더링 한 결과를 담는 버퍼이고,
데이터 버퍼는 각 픽셀 당 정보가 아닌 인코딩 된 이미지 데이터를 담고있기 때문입니다!
즉, 데이터 버퍼에서 픽셀 단위 데이터를 얻어야합니다.
디코딩 과정이 필요한 이유는 인코딩 된 데이터 버퍼에서 각 픽셀의 정보를 얻기 위함이었네요.
디코딩 과정은 다음의 순서로 진행됩니다.
이미지 버퍼 할당

UIImage는 데이터 버퍼에 저장된 이미지의 해상도 크기만큼 이미지 버퍼를 할당합니다.
디코딩

JPEG/PNG 등의 인코딩 된 데이터를 픽셀 단위 정보로 디코딩하고, 이미지 버퍼에 저장합니다.
UIKit의 렌더링 요청

디코딩 단계는 아직 끝나지 않았습니다.
UIKit은 UIImageView의 contentMode에 따라 렌더링을 요청합니다.
이후 이미지 버퍼의 데이터를 필요에 맞게 스케일(크기/비욜 조정) 후 프레임 버퍼에 복사합니다.

디코딩 단계는 CPU를 집약적으로(CPU-intensive) 사용하고, 메모리도 많이 사용합니다.
이미지의 해상도가 클수록 CPU와 메모리에 더 많은 부담을 주게 됩니다.
🤔 CPU-intensive한 이유?
JPG/PNG는 압축된 데이터이므로 압축 해제와 디코딩 과정이 필요합니다.
이때 많은 수식 연산?이 필요하므로 CPU가 열심히 일해야 합니다.
🤔 메모리 사용량이 많은 이유?
첫 번째로 이미지 데이터는 다른 데이터에 비해 크기가 큽니다.
두 번째로 이미지 하나를 그리기 위해 데이터 버퍼, 이미지 버퍼, 프레임 버퍼에 데이터를 저장해야합니다. 즉, 하나의 데이터를 위해 세 공간을 할당해야 하기 때문입니다.
따라서 CPU 사용량을 줄이기 위해 디코딩 과정은 필요할 때 딱 한 번만 수행하도록 설계되어 있습니다. 즉, UIImage는 디코딩된 이미지 버퍼를 유지합니다.
그러나 메모리 관점에서는 디코딩 된 이미지마다 엄청난 메모리 공간을 잡아먹는 것이고, 이 공간이 계속 유지되고 있습니다.
위 과정에서 메모리 공간 효율성을 잃었지만, CPU 효율성은 얻은 것 같습니다.
정말 그럴까요?
과도한 메모리 사용의 파장을 살펴봅시다.

단편화 증가
큰 버퍼들이 힙 영역에 있으니 단편화 발생 가능성이 높아집니다.
(더 나아가 인스타 돋보기 화면처럼 이미지가 수십장이라면…?)
메모리 압축 → 압축/압축해제 과정에서 CPU 사용
https://www.apple.com/media/us/osx/2013/docs/OSX_Mavericks_Core_Technology_Overview.pdf

iOS와 macOS는 메모리가 부족할 때 메모리 압축(Compressed Memory)을 실행합니다.
메모리 사용량의 과다로 메모리 압축을 위해 CPU가 할 일이 늘어났다 라는 의미.참조 지역성의 저하
참조 지역성: CPU가 짧은 시간 동안 동일한 메모리 공간에 접근하는 경향이 있다라는 원리. 시간 지역성과 공간 지역성이 존재.
시간 지역성: 최근에 접근한 데이터는 가까운 미래에 또 접근할 가능성이 높다.
(예: 반복문 내부에서 스코프 밖의 변수 참조)
공간 지역성: 접근한 데이터의 주변 데이터도 곧 접근할 가능성이 높다.
(예: 배열을 순회하는 경우)
일반적인 시스템에서 메모리 사용량이 증가하면 중기 스케줄러에 의해 페이지들이 Swap out 됨.
→ 시/공간적으로 근방에서 참조했을 때 스왑 아웃 되어 참조의 지역성이 깨질 가능성 높아짐.
iOS는 swap out 대신 메모리 압축을 사용하기 때문에 비슷한 맥락에서 참조 지역성이 저하된다는 의미라고 추측.
프로세스 강제 종료
iOS는 시스템의 메모리가 부족하다면 경고없이 앱을 강제종료합니다.
메모리의 과도한 사용은 CPU 사용량 증가와 앱의 사용성에 큰 영향을 미치게 됩니다.
따라서 CPU와 메모리를 더 효율적으로 사용할 필요가 있습니다.

디코딩 과정을 살펴보며 다음의 사실을 알 수 있었습니다.
UIKit은 UIImageView의
contentMode에 따라 렌더링을 요청합니다.
이후 이미지 버퍼의 데이터를 필요에 맞게 스케일(크기/비욜 조정) 후 프레임 버퍼에 복사합니다.
즉, 프레임 버퍼는 이미지 버퍼에서 필요한 픽셀의 정보만 가지고 있습니다.

일반적으로 앱에 표시되는 이미지의 크기는 실제 크기보다 작은 경우가 많습니다.
따라서 이미지의 해상도를 필요한만큼 낮추는 다운샘플링 과정을 통해 메모리 사용량을 낮출 수 있습니다.
위 사진처럼 디코딩 단계 이전에 원본 이미지를 축소해 썸네일을 만듭니다.
이를 통해 디코딩 버퍼의 크기를 감소시킬 수 있습니다.

이후 디코딩 과정을 거쳐 이미지 버퍼를 생성한 후 UIImage로 감싸 이미지 뷰에 할당합니다.
이때 원본 데이터 버퍼를 삭제하여 앱의 메모리 사용량을 줄일 수 있습니다.
보통은 렌더링 단계에서 Core Animation이 축소를 담당했지만, 다운샘플링을 통해 디코딩 버퍼의 사용량도 줄일 수 있게 되는 것입니다!

위 코드를 통해 다운 샘플링을 진행합니다.

메모리 사용량을 약 42%정도 줄일 수 있습니다ㄷㄷ

위 사진처럼 스크롤이 가능한 뷰에 이미지뷰가 많이 존재한다면 어떨까요?
다운샘플링을 통해 메모리 사용량을 줄일 수 있지만, CPU 사용량은 높아지게 됩니다.
사진에서도 볼 수 있듯이 CPU 스파이크가 발생합니다. 그 결과 hitch(렌더 루프에서 끊기는 현상)가 발생할 수 있습니다. 또한 배터리 수명도 갉아먹게 됩니다.
원인
UICollectionView가 새로운 셀을 표시하기 전에 이미지 디코딩이 필요합니다.
이 과정에서 CPU가 너무 바빠지고, 프레임 버퍼 갱신을 화면 업데이트 사이클 내로 못한다면 사용자에겐 스크롤이 끊기는 것처럼 보입니다.