본문 바로가기
강의 정리

WWDC 2018: iOS Memory Deep Dive(이미지)에 관하여

by iOS 개린이 2023. 6. 21.

Images

이미지에 대해 가장 중요하게 기억해야 할 점은 메모리 사용량이 파일크기가 아닌 이미지의 'dimensions' 과 관련되어 있다는 것이다.

 

 

이 사진은 가로 2048 픽셀, 세로 1536 픽셀의 크기를 가지고 있고, 디스크 상의 파일 크기는 590KB이다.

하지만 실제로 얼마만큼의 메모리를 사용할까?

그 값은 10MB라는 굉장히 큰 수치가 나온다. 가로 픽셀 수와 세로 픽셀 수를 곱한 값인 2048 * 1536에 픽셀 당 4byte를 곱하면 대략 10MB가 나오기 때문이다.

 

왜 이런 차이가 발생할까?

이를 파악하기 위해서는 iOS에서 이미지가 어떻게 작동하는지에 대해 알아보아야 한다.

 

iOS에서 이미지가 어떻게 작동되는지

 

1. 로드 단계

이 단계에서는 압축된 590KB의 JPEG 파일을 메모리로 불러온다.

 

2. 디코드 단계

JPEG 파일을 GPU가 읽을 수 있는 형식으로 변환한다.

이 과정에서 파일이 압축 해제되며, 이로 인해 파일 크기가 10MB까지 증가하게 된다.

 

3. 렌더링 단계

한번 디코드가 완료되면, 원하는 시점에 이미지를 렌더링 할 수 있다.

 

이미지와 그래픽 최적화에 관한 더 많은 정보를 얻고 싶다면, 위 사진의 하단에 있는 세션을 확인하는 것을 추천한다.

 

 

Image-rendering formats

 

위에서 언급했듯이 픽셀 당 4byte 라는 값은 SRGB 포맷에 기반한다.

이것은 일반적으로 그래픽 이미지에서 가장 흔히 사용되는 형식이다.

이 포맷은 픽셀당 8bit 로, 빨간색, 녹색, 파란색 각각 1byte와 알파 컴포넌트로 구성되어있다. 

 

더 큰 형태의 포맷

 

iOS 하드웨어는 넓은 범위의 색상 포맷을 렌더링할 수 있다.

이 넓은 범위의 색상 포맷을 얻기 위해서는 픽셀 당 2byte가 필요하므로 이미지의 크기가 두 배로 증가한다.

아이폰 7, 8, X와 일부 아이패드 프로의 카메라는 이러한 고품질 컨텐츠를 캡처하는데 훌륭하다.

또한, 스포츠 로고와 같은 정확한 색상을 위해 이를 사용할 수도 있다.

 

하지만 이런 넓은 범위의 색상 포맷은 넓은 범위의 색상을 표현할 수 있는 디스플레이에서만 유용하기 때문에, 꼭 필요하지 않은 경우에는 사용하지 않는 것이 좋다.

 

*SRGB 포맷은 표준적인 이미지 포맷으로, 각 픽셀마다 빨강, 초록, 파랑의 색상 값과 투명도 값을 가지며 각 값은 1byte로 표현된다. 따라서 하나의 픽셀이 4byte를 차지하게 되는 것이다.

그리고 iOS 하드웨어에 따라 이 포맷이 표현할 수 있는 색상의 범위가 더 넓어질 수 있는데, 이를 위해 더 많은 데이터가 필요하며 각 픽셀 당 2byte 씩,  총 8byte 가 필요하게 되는 것임.

따라서 필요할 때만, 사용하셈!*

 

 

반대로 더 작은 형태의 포맷(Luminance and alpha 8 format)

 

이 포맷은 흑백 이미지(밝기 또는 'Luminance' 값)와 알파값만을 저장한다. 이 포맷은 주로 쉐이더에서 사용되는데, 쉐이더는 그래픽스 프로그래밍에서 픽셀 또는 버텍스의 색상 값을 계산하는데 사용된다.

이 포맷은 일반적인 앱에서는 잘 사용되지 않는다.

 

더 작아지는 포맷(Alpha 8 Format)

 

이 포맷은 알파값만을 가진다. 이는 각 픽셀이 1byte만 차지하기 때문에 매우 작은 메모리 크기를 가진다.

이 포맷은 마스크 또는 단색 텍스트 등에 유용하며, SRGB 포맷보다 75% 적은 메모리를 사용한다.

 

따라서 이렇게 이미지 포맷에 따라 메모리 사용량이 크게 달라질 수 있다.

Alpha 8 format은 1byte/pixel 을 차지하는 반면, Wide format은 8byte/pixel 을 차지한다.

우리는 이 포맷 중 어떤 것을 사용해야 할까?

 

How do we pick the right format?

 

가장 좋은 포맷을 선택하는 방법은, 직접 포맷을 선택하기 보다는, 시스템이 최적의 포맷을 선택하도록 하는 것이 좋다.

 

이미지를 생성할 때, 종종 사용하는 'UIGraphicsBeginImageContextWithOptions' API는 기본적으로 4byte pixel 포맷인 SRGB를 사용한다. 이는 그래픽스에 최적화된 wide 포맷이나, 1byte pixel A8 포맷을 사용할 수 없게 한다.

 

대신 'UIGraphicsImageRenderer' API는 iOS 10에서 소개되었으며, iOS 12 이후로는 자동으로 가장 좋은 그래픽스 포맷을 선택하게 된다. 이렇게 하면 메모리 사용량을 크게 줄일 수 있으며, 필요에 따라 최적의 이미지 포맷을 사용할 수 있게 된다.

 

기존 API 사용 예제

let bounds = CGRect(x: 0, y: 0, width: 300, height: 100)
UIGraphicsBeginImageContextWithOptions(bounds.size, false, 0)

UIColor.black.setFill()
let path = UIBezierPath(roundedRect: bounds,
                  byRoundingCorners: UIRectCorner.allcorners,
                        cornerRadii: CGSize(width: 20, height: 20))
                        
path.addClip()
UIRectFill(bounds)

let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()

 

기존 API(UIGraphicsBeginImageContextWithOptions)를 사용해서 검은색 원을 그리는 경우, 4byte pixel 포맷을 사용해야 하므로 많은 메모리가 필요하게 된다.

 

새로운 API 사용 예제

let bounds = CGRect(x: 0, y: 0, width: 300, height: 100)
let renderer = UIGraphicsImageRenderer(size: bounds.size)

let image = renderer.image { context in
   //Drawing Code
     
   UIColor.black.setFill()
   let path = UIBezierPath(roundedRect: bounds,
                  byRoundingCorners: UIRectCorner.allcorners,
                        cornerRadii: CGSize(width: 20, height: 20))
   
   
   path.addClip()
   UIRectFill(bounds)
}

let imageView = UIImageView(image: image)
imageView.tintColor = .blue

 

반면 이렇게 새로운 API(UIGraphicsImageRenderer)를 사용하여 동일한 원을 그리는 경우에는, 오직 1byte pixel 이미지만을 사용하게 된다. 이는 75%나 되는 메모리를 절약하고, 그림의 품질에도 영향을 미치지 않는다.

 

더불어 이 API를 사용하면, 한 번 그린 마스크의 색상을 변경하면서 재사용하는 것이 가능해진다.

ImageView의 'tintColor' 를 변경함으로써 추가적인 메모리 할당 없이도 다른 색상의 원으로 재사용할 수 있다.

검은색 뿐만 아니라 파란색, 빨간색, 초록색 등으로 재사용할 수 있고, 추가적인 메모리 비용 없이 다양한 색상의 원을 그릴 수 있게 된다.

 

이렇게 기존 API인 'UIGraphicsBeginImageContextWithOptions' 를 사용하면 항상 4byte pixel 포맷(SRGB)을 사용하기 때문에 항상 일정한 양의 메모리가 필요하다. 따라서 다양한 상황에 맞는 최적의 그래픽 포맷을 사용하는 것이 불가능하다.

 

반면에 'UIGraphicsImageRenderer' API는 그래픽 이미지에 가장 적합한 포맷을 자동으로 선택한다.

따라서 그래픽의 내용에 따라 최적화된 포맷을 사용하여 불필요한 메모리 사용을 피할 수 있다.

 

 

Downsampling

 

보통 원본 이미지를 작은 크기의 썸네일로 변환하려는 경우, 혹은 화면에 표시할 이미지가 원본 크기보다 훨씬 작아야 하는 경우에 다운샘플링을 사용한다.

 

그러나 여기서 중요한 점은 UIImage를 사용하여 이미지를 다운샘플링하지 않아야 한다는 것이다.

왜냐하면 UIImage를 사용하여 그릴 경우, 내부 좌표 공간 변환으로 인해 성능이 다소 저하되며, 이미지 전체를 메모리에 압축 해제하게 된다. 이로 인해 많은 메모리가 사용되게 된다.

 

대신에, ImageIO 프레임워크를 사용하는 것이 좋다.

ImageIO는 이미지를 다운샘플링할 수 있으며, 스트리밍 API를 사용하여 최종 이미지의 'Dirty memory cost' 만 지불하게 된다. 'Dirty memory cost' 란 실제로 앱에 의해 변경되거나 사용되는 메모리의 양을 의미한다.

이렇게 함으로써 메모리의 급격한 증가를 방지할 수 있다.

 

Image size with UIImage

//Image size with UIImage

//Getting image size
let filePath = "/path/to/image.jpg"
let image = UIImage(contentsOfFile: filePath)
let imageSize = image?.size

//Resizing image
let scale = 0.2
let size = CGSize(image?.size.width * scale, image?.size.height * scale)
let renderer = UIGraphicsImageRenderer(size: size)
let resizedImage = renderer.image { context in
    image?.draw(in: CGRect(x: 0, y: 0, width: size.width, height: size.height))
}

 

 

1. 디스크에서 이미지 파일을 불러온다.

2. 불러온 이미지의 크기를 얻는다.

3. 원하는 비율('scale')을 정의한다. 예제에서는 이미지 크기의 20%로 설정되었다.

4. 'UIGraphicsImageRenderer' 를 사용하여 새로운 크기로 이미지를 다시 그린다.

 

이 방법에는 문제가 두 가지 문제가 있다.

첫 째, 원본 이미지의 전체 크기가 메모리에 로드되므로 메모리 사용량이 크게 증가할 수 있다.

우리가 이미지를 메모리에 로드할 때, 원본 이미지의 전체 크기가 메모리에 올라간다는 것을 이해해야 한다.

이는 디스크에서 읽은 이미지 데이터가 디코드되어 각 픽셀마다 메모리를 차지하기 때문이다.

 

둘 째, 'UIImage' 를 사용하여 이미지를 그리는 것은 내부 좌표 변환 때문에 성능이 떨어질 수 있다.

코드를 보면 'UIImage' 의 'draw' 메서드를 사용하여 이미지를 다시 그리고 있죠?

이것은 'UIImage' 내부적으로 좌표 변환을 수행하고, 이 변환이 CPU에 부담을 주어 성능을 저하시키는 요인이 되는 것이다.

 

 

Image size with ImageIO

//Image size with ImageIO
import ImageIO

let filePath = "/path/to/image.jpg"
let url = NSURL(fileURLWithPath: filePath)

let imageSource = CGImageSourceCreateWithURL(url, nil)
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil)
let options: [NSString: Any] = [
    kCGImageSourceThumbnailMaxPixelSize: 100,
    kCGImageSourceCreateThumbnailFromImageAlways: true
]

let scaledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, options)

 

1. 디스크에서 이미지 파일을 불러온다.

2. 'CGImageSourceCreateWithURL' 을 사용하여 'CGImageSource' 를 생성한다.

3. 'CGImageSourceCreateThumbnailAtIndex' 를 사용하여 축소된 이미지를 생성한다.

 

이 방법을 사용하면,

첫 째, 원본 이미지의 전체 크기를 메모리에 로드하지 않아도 되므로 메모리 사용량이 크게 감소한다.

'CGImageSourceCreateThumbnailAtIndex' 메서드는 원본 이미지의 전체 크기를 메모리에 로드하지 않고도 이미지를 처리할 수 있다. 이것은 이 함수가 이미지를 작은 조각으로 분할하고, 각 조각을 개별적으로 처리하기 때문이다. 

 

둘 째, ImageIO는 내부적으로 스트리밍 API를 사용하여 이미지를 처리하기 때문에 처리속도가 더 빠르다.

스트리밍 API란 데이터를 작은 조각으로 나누어 처리하는 API를 말한다.

이 방식은 큰 데이터 세트를 처리하는데 사용되며, 각 조각은 독립적으로 처리되고, 메모리에서 해제될 수 있다.

따라서 전체 데이터 세트를 메모리에 한 번에 로드하는 것에 비해 메모리 사용량을 크게 줄일 수 있다.

 

 

Optimize when in the background

우리가 아름답고 멋진 이미지를 앱의 화면에서 보고 있다고 가정하자.

그리고 앱이 백그라운드로 이동하면, 화면에는 보이지 않지만 이미지의 리소스는 메모리에 남아있게 된다.

이는 비효율적인 메모리 사용을 의미한다. 이런 상황을 최적화하기 위해 사용자가 보지 않는 큰 리소스는 'Unloading' 하는 것이 좋다.

 

 

 

이에 대한 두 가지 방법이 있는데, 첫 번째 방법은 앱의 수명주기(lifecycle)를 활용하는 것이다. 

앱이 백그라운드로 이동하거나 포그라운드로 복귀하는 이벤트는 언제 리소스를 'Unloading' 할지 판단하는 좋은 지표가 될 수 있다.

 

두 번째는 UIViewController의 수명주기 메소드를 사용하는 것이다. 이는 화면에 한 번에 하나의 viewController만 표시되는 Tab Controller나 Navigation Controller 에서 유용하다.

'viewWillAppear'나 'viewDidDisappear' 와 같은 콜백을 사용하여 메모리 사용량을 줄일 수 있다.

 

 

App lifecycle 예제

NotificationCenter.default.addObserver(forName: .UIApplicationDidEnterBackground,
                                       object: nil,
                                       queue: .main) { [weak self] (note) in
    
    self.unloadImages()
}

NotificationCenter.default.addObserver(forName: .UIApplicationWillEnterForeground,
                                       object: nil,
                                       queue: .main) { [weak self] (note) in
    
    self.loadImages()
}

 

앱이 백그라운드로 이동하면 알림이 트리거 되고, 이 때 이미지를 언로드 한다.

그 후 앱이 포그라운드로 복귀하면 다른 알림이 트리거 되고, 이 때 이미지를 다시 로드한다.

 

이렇게 하면 사용자가 앱으로 돌아왔을 때, 이전과 동일한 화질을 유지하면서도 백그라운드에서는 메모리를 절약할 수 있다.

이 방법은 사용자에게 완전히 투명하게 작동하며, 시스템에 더 많은 메모리를 할당할 수 있게 한다. 즉, 사용자는 앱의 행동에 변화를 느끼지 못하지만, 내부적으로는 메모리 관리가 이루어지고 있는 것이다.

 

 

UIViewController lifecycle 예제

//unloading resources on foreground/background

//Unload large resource when off-screen
override func viewDidDisappear(_ animated: Bool) {
    unloadImages()
    super.viewDidDisappear(animated)
}

override func viewWillAppear(_ animated: Bool) {
    loadImages()
    super.viewWillAppear(animated)
}

 

화면에서 뷰컨트롤러가 'Disappear' 되면 해당 뷰컨트롤러는 자신이 가진 이미지를 언로드한다.

그리고 뷰컨트롤러가 WillAppear 되면 다시 이미지를 로드한다.

 

이 방법 또한 사용자에게 투명하게 작동하면서 앱은 더 적은 메모리를 사용하게 된다.

메모리 사용을 효율적으로 관리하면서 동시에 사용자 경험을 해치지 않는 좋은 방법이다.

 

 

정리

우리는 iOS에서 이미지가 어떻게 처리되는지, 이미지 렌더링에 사용되는 포맷은 무엇이 있는지 알아보았다.

그 다음 앱에서 이미지를 사용하면서 어떻게 메모리 사용량을 최적화할 수 있는지에 대한 방법을 학습했다.

 

메모리 사용량을 최적화 방법은 세가지 이다.

1. 'UIGraphicsImageRenderer' API를 사용하여 시스템이 최적의 포맷을 선택하도록 하는 방법.

2. 'ImageIO' 프레임워크를 사용하여 다운샘플링 하는 방법.

3. 백그라운드에서 사용되지 않는 이미지를 제거하여 메모리를 효율적으로 사용하는 방법.

 

 

 

Reference

https://developer.apple.com/videos/play/wwdc2018/416