- 다운샘플링에 관해 알아 볼 예정으로 이 글을 읽기 전에
"https://developer.apple.com/videos/play/wwdc2018/416/" 를 보는 것을 추천합니다.
Downsampling(다운샘플링)
- 이미지의 해상도를 줄이는 과정
iOS에서 이미지와 메모리 사용량
- WWDC에 의하면, 이미지의 메모리 사용량은 파일 크기가 아닌 이미지의 dimensions(이미지 너비와 높이)에 있다.
즉, 이미지를 메모리에 로드할 때, 해당 이미지가 차지하는 픽셀의 총량이 메모리 사용량을 결정한다.
iOS에서 이미지 처리 단계
1. 로드 단계
- 압축된 JPEG 파일을 메모리로 불러온다.
2. 디코드 단계
- 이미지 파일을 실제 픽셀 데이터로 변환하는 과정이다. ex) JPEG 파일을 GPU가 읽을 수 있는 형식으로 변환
이 과정에서 파일이 압축 해제되며, 크기가 증가하게됨. (한번 디코드가 완료되면, 원하는 시점에 이미지를 렌더링 할 수 있다.)
3. 렌더링 단계
- 준비된 파일을 그리는 작업
중요 포인트
- 2번 디코드 단계에서 메모리 사용량이 증가할 수 있다.
만약 원본 이미지의 사이즈가 UIImageView의 사이즈보다 훨씬 크다면?
그 편차만큼 디코드 단계에서는 불필요한 메모리 사용량이 발생된다.
따라서 원본 이미지를 그대로 디코딩하기 전에 내가 원하는 사이즈로 다운샘플링하여 메모리 사용량을 줄여줘야 한다!
Downsampling 코드
func downsampledImage(data: Data, to pointSize: CGSize) -> UIImage? {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let imageSource = CGImageSourceCreateWithData(data as CFData, imageSourceOptions) else {
return nil
}
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * UIScreen.main.scale
let downsampleOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
]
guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(
imageSource, 0, downsampleOptions as CFDictionary
) else { return nil }
return UIImage(cgImage: downsampledImage, scale: UIScreen.main.scale, orientation: .up)
}
- KingFisher 라이브러리에서 사용하는 다운샘플링 코드를 참고했으며, 역시나 WWDC에 나와있는 코드.
- 하나씩 살펴보자.
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
- 이미지 소스를 생성할 때, 사용할 옵션을 설정
'kCGImageSourceShouldCache' 옵션은 이미지 소스를 생성할 때, 캐시를 사용할 지 여부를 결정
즉, 이미지를 처리할 때 원본 이미지 데이터를 메모리에 로드하지 않고, 처리된 이미지를 로드하여 메모리 사용량을 줄여준다.
guard let imageSource = CGImageSourceCreateWithData(data as CFData, imageSourceOptions) else {
return nil
}
- 이미지 데이터와 설정한 옵션을 통해 CGImageSource 생성
- CGImageSource란 이미지 데이터를 읽기 위해 사용되는 opaque 한 타입으로,
이미지 데이터를 로드하는 데 필요한 데이터 버퍼를 관리하고, 사용 가능한 이미지로 변환하기 위해 해당 데이터에 대한 작업을 수행할 수 있다.
- 추가로 CGImageSource는 썸네일 이미지를 가져오거나 생성하는 데 사용될 수 있으며, 이미지와 함께 저장된 메타데이터에 접근하는 데에도 사용된다.
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * UIScreen.main.scale
- 최대 픽셀 크기를 계산(이미지의 최종 크기를 결정)
let downsampleOptions: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
]
- 다운샘플링에서 사용할 옵션
1. kCGImageSourceCreateThumbnailFromImageAlways
- imageSource가 썸네일(다운샘플링) 이미지를 항상 생성할 지 여부를 결정
즉, 이 옵션을 사용하면 imageSource가 원본 이미지로부터 썸네일을 만들도록 지시
- 썸네일을 생성할 때, 'kCGImageSourceThumbnailMaxPixelSize' 에 지정된 최대 픽셀 크기에 따라 썸네일의 크기를 결정한다.
2. kCGImageSourceShouldCacheImmediately
- 이미지가 생성될 때, 이미지의 디코딩과 캐싱이 바로 이루어질 지 여부를 결정
- 'true' 로 설정했을 경우 imageSource는 이미지를 생성하는 즉시 디코딩과 캐싱을 수행하며,
이를 통해 더 빠르게 렌더링할 수 있도록 해준다. (WWDC에서 가장 중요한 옵션이라고 강조)
왜 더 빠른가?(추측)
- 이미지가 미리 디코딩되고, 메모리에 캐시된다는 것은 렌더링 전에 필요한 처리가 이미 완료되었기 때문.
- 이미지를 렌더링 할 때마다 디코딩을 수행하면 그만큼 리소스를 소모한다.
하지만 캐시로 인해 미리 처리된 이미지 데이터를 바로 사용할 수 있기 때문.
3. kCGImageSourceCreateThumbnailWithTransform
- 썸네일 이미지를 원본 이미지의 방향(예: 세로 또는 가로)과 가로 세로 비율에 맞게 조정할 지를 결정
4. kCGImageSourceThumbnailMaxPixelSize
- 썸네일 이미지의 최대 픽셀 크기를 지정
guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(
imageSource, 0, downsampleOptions as CFDictionary
) else { return nil }
- imageSource의 첫 번째 이미지(index: 0)에 대해 썸네일(다운샘플링 결과 이미지)을 생성
다운샘플링 적용
- 이렇게 구현한 다운샘플링 메서드를 언제 적용해야할까?
- 위에서 설명했듯이, 이미지 파일이 디코드 되기 이전에 적용해야 메모리 사용량을 줄일 수 있다.
이미지 캐시에서 다운샘플링 사용
final class ImageCache {
// MARK: - Property
static let shared = ImageCache()
private let memoryStorage = MemoryStorage()
private let diskStorage = DiskStorage()
// MARK: - Init
private init() { }
// MARK: - Load
func load(url: URL, downsampleSize: CGSize, completion: @escaping (UIImage?) -> Void) {
// 1. 캐시 메모리에서 이미지 데이터 찾기
if let cachedImageData = memoryStorage.value(forKey: url as NSURL) {
DispatchQueue.main.async {
completion(downsampledImage)
}
return
}
// 2. 디스크 메모리에서 이미지 데이터 찾기
if let diskImageData = diskStorage.value(with: url.lastPathComponent) {
DispatchQueue.main.async {
completion(downsampledImage)
}
return
}
// 3. 이미지 다운로드
URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
guard let self else { return }
if let error {
print(error.localizedDescription)
DispatchQueue.main.async {
completion(nil)
}
return
}
guard let data else {
DispatchQueue.main.async {
completion(nil)
}
return
}
// 다운샘플링
guard let downsampledImage = ImageProcessor.downsampledImage(data: data, to: downsampleSize) else {
DispatchQueue.main.async {
completion(nil)
}
return
}
DispatchQueue.main.async {
completion(downsampledImage)
}
self.memoryStorage.store(downsampledImage, forKey: url as NSURL)
self.diskStorage.store(downsampledImage, identifier: url.lastPathComponent)
}
.resume()
}
- 이미지 캐시 코드
- 이미지를 네트워킹하여 가져오는 시점에 다운샘플링을 진행하고, 그 결과를 메모리와 디스크에 각각 저장하고 있다.
- 이 방법은 고정된 사이즈의 다운샘플링 된 이미지를 저장한다.
만약 같은 이미지를 여러 다른 Size의 이미지 뷰에서 보여줘야 한다면, 이미지 해상도에 문제(이미지보다 이미지 뷰가 더 큼)가 생기거나 메모리 사용량이 최적화(이미지 뷰보다 이미지가 더 큼)되지 않는다.
- 따라서 다운샘플링 된 이미지를 저장하는 것 보다는 원본 이미지의 데이터 상태로 저장한 후, 꺼내올 때마다 다운샘플링을 진행하여 이미지 뷰에 적용하는 방법을 선택.
load()
// MARK: - Load
func load(url: URL, downsampleSize: CGSize, completion: @escaping (UIImage?) -> Void) {
// 1. 캐시 메모리에서 이미지 데이터 찾기
if let cachedImageData = memoryStorage.value(forKey: url as NSURL) {
DispatchQueue.global(qos: .userInitiated).async {
let downsampledImage = ImageProcessor.downsampledImage(
data: cachedImageData, to: downsampleSize
)
DispatchQueue.main.async {
completion(downsampledImage)
}
}
return
}
// 2. 디스크 메모리에서 이미지 데이터 찾기
if let diskImageData = diskStorage.value(with: url.lastPathComponent) {
DispatchQueue.global(qos: .userInitiated).async {
let downsampledImage = ImageProcessor.downsampledImage(
data: diskImageData, to: downsampleSize
)
DispatchQueue.main.async {
completion(downsampledImage)
}
}
memoryStorage.store(diskImageData, forKey: url as NSURL)
return
}
// 3. 이미지 다운로드
URLSession.shared.dataTask(with: url) { [weak self] data, _, error in
guard let self else { return }
if let error {
print(error.localizedDescription)
DispatchQueue.main.async {
completion(nil)
}
return
}
guard let data else {
DispatchQueue.main.async {
completion(nil)
}
return
}
// 다운샘플링
guard let downsampledImage = ImageProcessor.downsampledImage(data: data, to: downsampleSize) else {
DispatchQueue.main.async {
completion(nil)
}
return
}
DispatchQueue.main.async {
completion(downsampledImage)
}
self.memoryStorage.store(data, forKey: url as NSURL)
self.diskStorage.store(data, identifier: url.lastPathComponent)
}
.resume()
}
- 메모리나 디스크에서 이미지를 꺼내올 때마다 다운샘플링을 진행하여 반환
과연 KingFisher에서는?
- KingFisher에서는 이미지를 다운로드 받고, 이미지 처리 옵션에 따라 이미지를 처리한 결과를 저장, 따라서 각 처리에 따라 달라진 이미지를 모두 저장하고 있다. (.cacheOriginalImage 이라는 기능을 사용하면, 원본 이미지까지 저장.)
- 따라서 내게 필요한 옵션에 따라 처리된 이미지를 모두 가지고 있기 때문에 UX적인 요소(이미지 로드 속도가 빠름)들이 향상된다.
- 메모리 사용량에 부담이 되진 않을까? 하지만 KingFisher는 그만큼 범용성있는 이미지 처리 기법들을 지원하며, 여러 캐시정책들을 사용하고 있기 때문에 상황에 맞게 최적화되었다고 생각한다.
Reference
'개린이 이야기' 카테고리의 다른 글
면접 후기 (1) | 2024.07.19 |
---|---|
캐시정책에 관하여 (0) | 2024.01.24 |
이미지 캐싱에 관하여 (0) | 2024.01.18 |
MVVM(Model-View-ViewModel)에 관하여 (0) | 2023.07.12 |
Git Flow에 관하여 (0) | 2023.06.26 |