안녕하세요, 루피입니다.
오늘은 캡스톤 작품인, 효도르 프로젝트를 진행하면서, 직접 구현했던 ImageCache를 KingFisher로 변경한 내용에 대해 글을 적어보려합니다. 바로 시작합니다.
1. 왜 ImageCahe가 필요했는가?
현재 Hyodor 앱은 가족 공유 앨범이라는 기능이 있습니다. 서버에서 이미지 URL 목록을 받아와 화면에 효율적으로 뿌려줘야 했기 때문에 캐시 구현이 필수로 요구 되었습니다. 캐시가 필요했던 가장 큰 이유는 한번 받아온 이미지 URL을 사용할때, 반복해서 서버에 API 호출하는 것이 아닌 메모리에 캐싱해 빠르게 접근해서 사용하기 위해서였습니다.
2. 왜 직접 구현을 먼저 했는가?
이거는 제가 생각하는 고집이자 공부의 방향성이긴 한데요. 저는 학습을 하는 과정에서는 제가 생각하는 모든 라이브러리나, 프레임워크를 직접 구현할 수 있는 개발자가 되기를 지향합니다.
물론 이미지 캐싱을 하는데 도움이 되는 KingFisher에 대해서는 이미 알고 있었으나, 직접 구현해면서, 과정을 고민해 보는것이 학습 성장과 왜 KingFisher를 사용해야하는가? 라는 대답을 해결할 수 있다고 생각했고, 그렇기에 직접 구현했습니다.
3. 기존 코드
ImageCache.swift
class ImageCache {
static let shared = ImageCache()
private let memoryCache = NSCache<NSString, UIImage>()
private let fileManager = FileManager.default
private var diskCacheURL: URL {
let caches = fileManager.urls(for: .cachesDirectory, in: .userDomainMask).first!
return caches.appendingPathComponent("SharedAlbumImageCache")
}
private init() {
try? fileManager.createDirectory(at: diskCacheURL, withIntermediateDirectories: true)
}
func get(forKey key: String) -> UIImage? {
if let img = memoryCache.object(forKey: key as NSString) {
return img
}
let diskURL = diskCacheURL.appendingPathComponent(key.sha256())
if let data = try? Data(contentsOf: diskURL), let img = UIImage(data: data) {
memoryCache.setObject(img, forKey: key as NSString)
return img
}
return nil
}
func set(_ image: UIImage, forKey key: String) {
memoryCache.setObject(image, forKey: key as NSString)
let diskURL = diskCacheURL.appendingPathComponent(key.sha256())
if let data = image.jpegData(compressionQuality: 0.9) {
try? data.write(to: diskURL)
}
}
}
extension String {
func sha256() -> String {
let data = Data(self.utf8)
let hash = SHA256.hash(data: data)
return hash.compactMap { String(format: "%02x", $0) }.joined()
}
}
기존에 제가 직접 커스텀한 ImageCache입니다. 캐싱 과정은 크게 Memory Cache와 Disk Cache로 나뉩니다.
저장된 데이터를 쓰기 위해서는 우선 MemoryCache에 이미지가 있는지 확인하고 있다면, 바로 리턴합니다.
만약 이미지가MemoryCache에 없다면 DiskCache에 접근하여, 해당 이미지의 키(URL)를 SHA256으로 해싱한 파일명을 찾습니다.
디스크에 해당 파일이 존재한다면, 파일 데이터를 읽어와 UIImage 객체로 변환합니다. 그리고 여기서 중요한 점은, 디스크에서 읽어온 이미지를 다시 메모리 캐시에 저장(set)한다는 것입니다. 이렇게 하면 다음번에 동일한 이미지를 요청할 때는 디스크까지 접근할 필요 없이, 더 빠른 메모리 캐시에서 바로 이미지를 가져올 수 있습니다.
만약 디스크 캐시에도 이미지가 없다면, 최종적으로 nil을 반환하여 이미지가 캐싱되어 있지 않음을 알립니다.
반대로 이미지를 캐시에 저장(set)할 때는, 빠른 접근을 위해 MemoryCache에 저장함과 동시에, 앱을 재시작해도 데이터가 유지되도록 DiskCache에도 파일 형태로 저장하는 구조입니다.
정리
저는 2-Layer Cache 전략을 구현했습니다.
- Memory Cache(1차 방어선) : 가장 빠른 RAM에서 먼저 찾아본다.
- Disk Cache(2차 방어선) : Memory에 없으면 디스크에서 찾아봅니다. 찾았다면, 다음 접근을 위해 Memory에도 올려놓습니다.
- Network(최후) : 둘다 없으면, 네트워크에서 다운로드하여 Memory와 Disk 양쪽에 모두 저장합니다.
4. 그렇다면 왜 FileManger를 선택했는가?
Swift에서 데이터를 저장하는 방식은 FileManaer말고도 UserDefaults 나 SwiftData, CoreData등의 방식이 있습니다.각각의 방식은 저마다 목적과 장단점이 명확하기에 해결하려는 문제에 가장 적합한 도구를 선택하는 것이 중요했습니다.
내가 해결해야 하는 문제는 무엇인가?
- 대용량 바이너리 데이터 처리 : 이미지는 용량이 큰 바이너리 데이터입니다.
- 임시성 : 캐시 데이터는 언제든지 원본으로부터 다시 받아올 수 있으므로, 영구적으로 보존 될 필요가 없다고 생각 했습니다.
- key를 사용한 접근 : 이미지 URL를 키로 사용하여 해당하는 이미지 데이터를 빠르게 찾아올 수 있어야 했습니다.
UserDefaults
유저 디폴트는 키를 이용한 접근이라는 점에 있어서는 좋은 선택지 였습니다. 하지만 유저 디폴트는 주로 String, Int, Bool 과 같은 작은 데이터를 다루는 데 최적화 되어 있기에 여기에 용량이 큰 이미지 데이터를 저장하면 앱의 성능이 저하 될것이라고 생각했고, 이는 결국 이미지 캐시 용도로는 전혀 적합하지 않아 가장 먼저 고려대상에서 제외 했습니다.
SwiftData / CoreData
SwiftData와 CoreData는 객체 간의 복잡한 관계를 관리하고, 구조화된 데이터를 영구적으로 저장하기 위한 강력한 데이터베이스 프레임워크입니다.
앱의 핵심 데이터를 영구적으로 관리하기에는 더없이 좋은 도구입니다. 하지만 이미지 캐시는 '언제든 사라져도 되는 임시 데이터의 모음'입니다. 복잡한 관계나 정교한 쿼리가 필요 없습니다. 이러한 단순한 목적을 위해 SwiftData의 데이터 모델을 설정하고 컨텍스트를 관리하는 것은 오버스팩이라고 판단했습니다.
FileManager
FileManager의 가장 큰 장점은 이미지와 같은 대용량 바이너리 파일을 다른 변환 과정 없이, 파일 그대로 다룰 수 있다는 점입니다. 이는 원본 데이터의 손실 없이 효율적으로 저장하고 읽어오는 데 가장 적합한 방식입니다.
무엇보다 FileManager를 통해 iOS가 제공하는 'Caches' 디렉토리에 직접 접근할 수 있다는 점이 결정적이었습니다.
애플의 가이드에 따르면 이 디렉토리는 시스템의 저장 공간이 부족할 때 OS가 임의로 내용을 삭제할 수 있는 공간입니다. 즉, '언제든 다시 만들 수 있는 임시 데이터'를 저장한다는 캐시의 목적과 완벽하게 일치하는, 유일한 공간인 셈입니다.
또한, 복잡한 데이터베이스 설정 없이도 URL을 해싱하여 파일명으로 사용하는 간단한 방식으로 Key-Value와 유사한 저장 방식을 구현할 수 있다는 단순성 역시 효율적인 개발에 큰 장점이었습니다.
5. 왜 KingFisher를 도입했는가?
1) 이미지 캐싱만 요구되는 요구 사항
물론 커스텀 캐싱을 구현해 기능을 구현할 수 있었으나, 결국 Hyodor에서 캐싱이 요구되는 요소는 이미지 캐싱만 이었기에 충분히 Kingfisher 사용시 더 효율적인 개발이 가능하다 판단했습니다.
2) 너무 많은 보일러플레이트 코드
직접 만든 캐시를 사용하니, 이미지 하나를 로딩하기 위해 불필요한 코드가 너무 많이 필요했습니다. 이미지 로딩 상태, 로딩된 이미지를 모두 @State나 @Observable로 관리해야 했고, 이를 위해 각 View에 대응하는 VM이 필수적이었습니다.
기존 SharedPhotoCellVM
@Observable
class SharedPhotoCellVM{
var image: UIImage?
var isLoading: Bool = true
private let photo: SharedPhoto
init(photo: SharedPhoto) {
self.photo = photo
Task { await loadImage() }
}
func loadImage() async {
guard let url = photo.imageURL else {
isLoading = false
return
}
if let cached = ImageCache.shared.get(forKey: url.absoluteString) {
self.image = cached
self.isLoading = false
return
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
if let uiImage = UIImage(data: data) {
ImageCache.shared.set(uiImage, forKey: url.absoluteString)
self.image = uiImage
}
self.isLoading = false
} catch {
self.isLoading = false
}
}
}
기존 SharedPhotoCell
struct SharedPhotoCell: View {
@State private var viewModel: SharedPhotoCellVM
private var cellSize: CGFloat { UIScreen.main.bounds.width / 3 }
init(photo: SharedPhoto) {
_viewModel = State(wrappedValue: SharedPhotoCellVM(photo: photo))
}
var body: some View {
ZStack {
if let image = viewModel.image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: cellSize, height: cellSize)
.clipped()
} else {
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(width: cellSize, height: cellSize)
if viewModel.isLoading {
ProgressView()
} else {
Image(systemName: "photo")
.font(.system(size: 30))
.foregroundColor(.gray)
}
}
}
.frame(width: cellSize, height: cellSize)
.background(Color.clear)
.contentShape(Rectangle())
}
}
위 코드에서 볼 수 있듯이, 단순히 이미지 셀 하나를 보여주기 위해 ViewModel과 View 양쪽에 상태(isLoading, image)를 관리하고 UI를 분기 처리하는 코드가 흩어져 있었습니다. 이는 전형적인 보일러플레이트 문제를 보여줬습니다.
Kingfisher의 KFImage는 이 문제를 쉽게 해결합니다. KFImage는 URL만으로 이미지 로딩, 캐싱, 플레이스홀더, 에러 처리까지 모두 선언적으로 처리하는 강력한 SwiftUI View입니다.
Kingfisher 도입 후 SharedPhotoCell
import SwiftUI
import Kingfisher // 1. Kingfisher 임포트
struct SharedPhotoCell: View {
// 2. ViewModel 의존성 제거, Model만 직접 받음
let photo: SharedPhoto
private var cellSize: CGFloat { UIScreen.main.bounds.width / 3 }
var body: some View {
// 3. KFImage로 교체. URL만 넘겨주면 끝.
KFImage(photo.imageURL)
.placeholder {
// 4. 로딩 중에 보여줄 플레이스홀더를 선언적으로 정의
ZStack {
Rectangle().fill(Color.gray.opacity(0.3))
ProgressView()
}
}
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: cellSize, height: cellSize)
.clipped()
.contentShape(Rectangle())
}
}
Kingfisher 도입 후 VM은 더이상 필요 없게 되어 삭제할 수 있었고, 코드가 훨씬 선언적으로 바뀌어 View가 좀더 무엇을 보여줄지에만 집중하게 되어 역할과 책임이 명학해진것을 확인할 수 있었습니다.
3) Kingfisher의 다운샘플링 기능
import SwiftUI
import Kingfisher
struct SharedPhotoCell: View {
let photo: SharedPhoto
private var cellSize: CGFloat { UIScreen.main.bounds.width / 3 }
var body: some View {
KFImage(photo.imageURL)
.placeholder {
ZStack {
Rectangle()
.fill(Color.gray.opacity(0.3))
ProgressView()
}
}
.retry(maxCount: 3, interval: .seconds(5))
.setProcessor(
DownsamplingImageProcessor(size: CGSize(width: cellSize * UIScreen.main.scale, height: cellSize * UIScreen.main.scale))
)
.scaleFactor(UIScreen.main.scale)
.cacheOriginalImage(false)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: cellSize, height: cellSize)
.clipped()
.contentShape(Rectangle())
}
}
제가 만든 ImageCache는 원본 이미지 데이터를 그대로 UIImage로 변환했습니다. 예를 들어 4000x3000 픽셀의 고화질 사진을 가로 120pt 크기의 작은 썸네일 View에 보여주더라도, 메모리에는 4000x3000 픽셀 전체가 올라가는 비효율적인 방식이었습니다. 이는 스크롤 시 메모리 사용량이 급증하여 앱이 버벅거리거나, 심한 경우 메모리 부족으로 앱이 강제 종료되는 원인이 될 수 있습니다.
Kingfisher는 다운 샘플링이라는 강력한 기능을 제공합니다. 이미지 데이터를 UIImage로 디코딩하기 전에, 이미지를 보여줄 View의 크기에 맞춰 해상도를 미리 낮추는 기술입니다. 즉, 120x120pt 크기의 뷰에는 그에 맞는 작은 사이즈의 이미지만 메모리에 올리게 됩니다.
4) 직접 구현한 ImageCache의 한계
제가 직접 구현한 ImageCache는 메모리 캐시는 NSCache의 자동 삭제 정책에 의존하지만, 디스크 캐시는 용량 제한(capacity)이나 유효 기간(expiration) 설정 같은 정교한 삭제 정책이 구현되어 있지 않습니다.
따라서 사용자가 앱을 오래 사용하면 디스크 용량을 불필요하게 계속 차지할 수 있는 한계가 있습니다. 반면 Kingfisher는 이런 디스크 캐시 관리 정책까지 모두 처리해주는 편리하고 안정적인 솔루션이라고 생각했습니다.
오늘도 화이팅입니다!
'트러블 슈팅' 카테고리의 다른 글
| [트리블 슈팅] API 호출 대신 이미지 캐싱으로 성능 최적화하기 (0) | 2025.05.29 |
|---|---|
| [트러블 슈팅] SplashView로 UX 개선하기 (0) | 2025.05.29 |
| [트러블 슈팅] SwiftUI에서 느린 사진 업로드 문제 해결 (0) | 2025.04.27 |
| [트러블 슈팅] 오렌지 마켓 : Navigation Splash 화면 구현 (0) | 2025.01.09 |