버거킹 앱을 클론 코딩한 iOS 사이드 프로젝트에서 이미지 캐시를 적용한 이유와 구체적인 적용 방법에 대해 공유하려고 한다
캐시가 왜 필요했을까?
아래는 프로젝트의 메인 화면이다
해당 화면은 Category, MenuItem, Cart 섹션으로 구성되어 있으며 사용자가 카테고리를 선택할 때마다 서버로부터 해당 카테고리의 메뉴 데이터를 받아오는 구조이다
예를들어,
- 처음에 ‘단품’ 카테고리가 선택되어 ‘단품 메뉴 아이템’을 서버로부터 받아오고
- 이후, ‘세트’ 카테고리를 선택하여 ‘세트 메뉴 아이템’을 받아오며
- 다시 ‘단품’ 을 선택하면 또다시 서버에 ‘단품 메뉴 아이템’을 요청하게 된다
하지만 메뉴 아이템 데이터는 실시간으로 자주 변경되는 정보가 아니기 때문에 매번 서버에 요청할 필요는 없다
즉, 처음 데이터를 받아왔을 때 로컬 저장소(캐시)에 저장해두고,
이후 동일한 데이터를 요청할 경우 네트워크가 아닌 캐시된 데이터를 활용하는 방식으로 처리할 수 있다
이를 통해
- 불필요한 네트워크 요청을 줄이고
- 데이터 로딩 시간을 단축시켜 사용자 경험(UX) 측면에서 더욱 원할한 화면 전환을 제공할 수 있다
이번 프로젝트에서 캐시가 필요하다고 생각한 부분은 다음과 같다
(카테고리 섹션에 대한 정보는 앱 실행 시 한 번만 호출되어 제외함)
- 메뉴 이미지
- 메뉴 아이템
이 중에서도 이번 포스팅은 메뉴 이미지 캐싱에 대해 다뤄보겠습니다
메뉴 이미지 캐싱이 필요한 이유
메뉴 이미지는 MenuItem 엔티티의 imageURL을 통해 URLSession으로 비동기 요청하여 받아온다
이러한 이미지들은 자주 변경되지 않기 때문에, 한 번 다운로드한 이미지를 캐시에 저장한 후, 이후에는 캐시된 데이터를 재사용하는 방식이 적합하다
특히 이미지 파일은 용량이 크고 로딩 시간도 상대적으로 길기 때문에 캐시를 통한 최적화가 UX 측면에서 매우 중요하다
캐시란?
자주 사용하는 데이터를 임시 저장소에 저장해두고, 다음 요청 때 원본 데이터가 아닌 캐시된 데이터를 사용하는 것
즉 캐시를 사용하면 다음과 같은 이점이 있다
- 속도 향상: 네트워크 / 디스크 접근 없이 빠르게 데이터 제공 가능
- 리소스 절약: 서버 API 요청, DB 쿼리 등을 줄여 비용 및 부하 감소
- 오프라인 사용: 캐시에 남은 데이터를 네트워크 없이도 사용할 수 있음
- 반복 데이터 방지: 동일한 요청/계산을 반복하지 않아 효율적
이번 포스팅에서 사용할 캐시 방식
캐시의 종류로는 메모리 캐시, 디스크 캐시, 네트워크 캐시, 서버 캐시.. 등등 많겠지만
해당 포스팅에서는 메모리 캐시만 사용하려고 한다
메모리 캐시란 앱이 실행되는 동안에만 메모리에 저장되는 캐시로 앱이 종료되면 캐시에 저장된 데이터가 모두 휘발된다
Swift에서는 대표적으로 NSCache를 사용해 메모리 캐시를 구현할 수 있다
Kingfisher 없이 직접 구현하는 이미지 캐시
이미지 캐싱은 보통 Kingfisher나 SDWebImage와 같은 라이브러를 사용하면 간다한게 처리할 수 있지만,
이번 포스팅에서는 라이브러리에 의존하지 않고 직접 구현할 것이다
직접 구현시에는 보통 다음 두 가지를 다뤄야 한다
- 크게 메모리 캐시
- 디스크 캐시 (이번 글에서는 디스크 캐시를 다루지 않지만, 실제 구현 시에는 함께 고려하는 것이 좋다)
이미지 캐싱의 목표
- 동일한 URL의 이미지를 다시 요청하지 않도록 캐싱
- 메모리 낭비를 방지하기 위해 메모리 캐시도 적절히 관리
- 캐시된 이미지에 대한 만료 정책 적용
- 캐시 용량 제한을 두어 디스크 사용량을 관리
이제 본격적으로 NSCache를 활용해 이미지 캐시를 직접 구현해보자
왜 이미지 캐시 관리에 싱글턴 패턴을 사용할까?
이미지 캐시를 구현할 때, NSCache는 내부적으로 스레드 세이프(thread-safe) 하긴 하지만
여러 인스턴스를 만들면 각기 다른 캐시 공간을 갖게 되어 캐싱의 일관성이 깨질 수 있다
예를 들어, A 화면에서 이미지를 캐싱했더라도
B 화면에서 다른 캐시 인스턴스를 사용한다면 같은 이미지를 또 불러오게 되는 일이 발생할 수 있다
그래서 앱 전체에서 하나의 캐시 인스턴스를 공유하도록 싱글턴 패턴을 적용하는 것이 좋다
싱글턴은 UserDefaults, DB, 캐시처럼
공통된 리소스를 여러 곳에서 일관되게 사용해야 할 때 자주 사용됨
이제 이를 바탕으로 이미지 캐시를 관리할 ImageCacheManager를 구현해보자
NSCache로 이미지 캐시 구현하기
let cache = NSCache<NSString, UIImage>()
- NSCache는 메모리에 데이터를 임시로 저장하는 캐시로, 앱 실행 중에만 유효하며 앱 종료 시 휘발된다
- NSString을 key로, UIImage를 value로 사용하는 구조
- NSCache는 String이 아니라 NSString만 지원해서 타입 변환이 필요함!
- NSCache는 countLimit, totalCostLimit 같은 자동 메모리 관리 기능도 제공해서 Dictionary 보다 안전하게 캐시 관리할 수 있음
- 시스템 메모리가 부족해질 경우, 자동으로 캐시를 비워줌!!
- LRU(Least Recently Used) 알고리즘을 사용하여 가장 오래된 데이터부터 삭제한다
ImageCacheManger 구현
final class ImageCacheManger {
static let shared = ImageCacheManger()
private let cache = NSCache<NSString, UIImage>()
private init() {}
}
- final 키워드로 상속 방지
- static let shared로 전역에서 동일한 인스턴스를 공유
- 외부에서 생성자를 호출할 수 없도록 private init() 선언
- 내부적으로 사용할 NSCache<NSString, UIImage> 인스턴스 정의
ImageCacheManger의 이미지 로드 메서드 구현
이제 캐시를 활용해 이미지를 불러오는 메서드를 만들어보자
아래는 urlString을 입력받아 UIImage?를 반환하는 비동기 메서드이다
func loadImage(for urlString: String) async -> UIImage? {
// 1. 캐시에 이미지가 있는지 먼저 확인
if let cachedImage = cache.object(forKey: urlString as NSString) {
print("캐시 이미지 사용")
return cachedImage
}
// 2. 없으면 URLSession을 통해 네트워크로부터 이미지 다운로드
guard let url = URL(string: urlString) else {
return nil
}
do {
let (data, _ ) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else { return nil }
// 3. 다운로드한 이미지를 캐시에 저장
cache.setObject(image, forKey: urlString as NSString)
print("이미지 다운로드 성공")
return image
} catch {
print("이미지 다운로드 실패")
return nil
}
}
- 캐시 우선 확인
- NSCache에서 urlString을 키로 사용하여 이미지가 이미 저장되어 있는지 확인
- 있다면 네트워크를 사용하지 않고 바로 캐시 데이터를 반환한다
- 네트워크 요청
- 캐시에 없다면 URLSession을 사용해 이미지를 다운로드하고, 성공 시 캐시에 저장
- 주의: UI 업데이트는 메인 스레드에서!
- 해당 메서드는 백그라운드에서 실행될 수 있으므로,
- 이미지를 UI에 적용할 땐 반드시 MainActor 또는 DispatchQueue.main.async를 사용해 메인 스레드에서 업데이트해야 한다
UIImageView 확장을 통한 이미지 설정
이미지뷰에서 간단하게 이미지를 설정할 수 있도록, UIImageView에 확장 메서드를 추가했다
extension UIImageView {
func setImage(with urlString: String) {
Task {
let image = await ImageCacheManger.shared.loadImage(for: urlString)
await MainActor.run {
self.image = image
}
}
}
}
- loadImage(for:) 메서드는 비동기적으로 이미지를 가져온다
- 가져온 이미지는 MainActor.run을 통해 메인 스레드에서 UI에 적용
테스트
10개의 UIImageView를 생성하고, 동일한 이미지를 각각의 뷰에 설정하여 캐싱된 이미지를 잘 사용하는지 테스트를 진행해봤다
func getImageView() -> [UIImageView]{
var arr = [UIImageView]()
(1...10).forEach { _ in
let imageView2: UIImageView = {
let view = UIImageView()
view.setImage(with: "<https://raw.githubusercontent.com/onevcat/Kingfisher/master/images/logo.png>")
return view
}()
arr.append(imageView2)
}
return arr
}
테스트 결과
→ 중복 다운로드 문제 발생
- loadImage(urlString:) 은 비동기로 동작하기 때문에 이전 요청으로 이미지를 다운로드 중인 상태로 다음 메서드를 호출한다
- 아직 이미지를 다운로드 중으로, 아직 캐시 데이터에 없으니, 이미지를 불필요하게 여러 번 다운로드하게 된다
중복 다운로드 방지를 위한 개선
앞선 테스트에서는 이미지 캐시가 저장되기 전에 setImage(with:)가 여러 번 호출되면서 같은 URL에 대해 중복 다운로드가 발생하는 문제가 있었다
이를 해결하기 위해, ImageCacheManager에서 현재 다운로드 중인 작업을 관리하도록 한다
- 캐시에 저장된 데이터 확인
- 데이터가 있다면 해당 데이터 반환
- 데이터가 없다면 현재 동일한 urlString으로 작업 중인 목록이 있는지 확인
- 작업이 있다면, 해당 작업이 끝날 때까지 기다렸다가 작업의 결과를 반환
- 작업이 없다면,
- 새로 다운로드 작업을 시작하고
- 작업 목록에 추가한다
- 다운로드가 끝나면 결과를 반환하고
- 작업이 완료되면 작업 목록에서 제거 (기존과 동일하게 캐시에 저장)
Actor를 사용한 작업 목록 관리
urlString을 Key 값으로 작업 목록을 관리한다
→ urlString으로 getTask(for:)에 접근하여 동일한 URL로 작업 중인 목록을 확인한다
actor OngoingTaskStore {
private var tasks: [String: Task<UIImage?, Never>] = [:]
func getTask(for key: String) -> Task<UIImage?, Never>? {
return tasks[key]
}
func setTask(_ task: Task<UIImage?, Never>, for key: String) {
tasks[key] = task
}
func removeTask(for key: String) {
tasks[key] = nil
}
func clear() {
tasks.removeAll()
}
}
왜 actor를 사용할까?
actor는 내부적으로 직렬큐를 통해 동시성 문제를 해결한다
즉, 여러 작업이 동시에 접근해도 순차적으로 안전하게 처리가 가능하게 된다
→ 프로퍼티나 메서드를 호출할 때 await 키워드를 사용하는 이유
프로퍼티나 메서드를 접근할 때도 직렬큐에 추가되고
이전에 요청되었던 작업이 모두 종료될 때까지 대기했다가 실행되므로 데이터를 일관되고 안정적이게 관리할 수 있다
만약 actor가 이닌 class를 사용한다면?
동시에 여러 스레드에서 접근할 수 있고, 이로 인해 tasks 딕셔너리가 예상치 못하게 덮어씌워지거나, 중복 다운로드 문제가 발생하는 등 테스트와 동일한 문제가 발생할 수 있다
중복 다운로드 문제를 개선한 ImageCacheManger 구조
final class ImageCacheManger {
static let shared = ImageCacheManger()
private let cache = NSCache<NSString, UIImage>()
private let taskStore = OngoingTaskStore() **// 작업 목록 store 객체 생성**
private init() {}
}
func loadImage(for urlString: String) async -> UIImage? {
if let cachedImage = cache.object(forKey: urlString as NSString) {
print("캐시 이미지 사용")
return cachedImage
}
// 현재 동일한 URL로 작업이 진행 중인지 확인
if let ongoingTask = await taskStore.getTask(for: urlString) {
let image = await ongoingTask.value // 작업 목록이 있다면, 작업 결과를 기다렸다가 반환
print("중복 요청 방지 후 이미지 반환")
return image
}
// 진행 중인 작업이 않다면 새로운 다운로드 작업 생성
let task = Task<UIImage?, Never> {
defer {
Task { await taskStore.removeTask(for: urlString) } // 작업 완료 후 목록에서 제거
}
guard let url = URL(string: urlString) else { return nil }
do {
let (data, _ ) = try await URLSession.shared.data(from: url)
guard let image = UIImage(data: data) else { return nil }
cache.setObject(image, forKey: urlString as NSString)
print("이미지 다운로드 성공")
return image
} catch {
print("이미지 다운로드 실패")
return nil
}
}
await taskStore.setTask(task, for: urlString) // 새로운 작업을 목록에 저장
return await task.value
}
defer란?
Swift에서 현재 스코프가 종료되기 직전에 꼭 실행되는 코드 블록으로
return 이나 throw가 있어도 무조건 실행됨
여러 개 쓸 수 있고 후입선출 순서로 실행됨
→ 중복 다운로드 문제가 해결
이미지 다운로드 중에도 중복 요청이 들어올 경우,
기존 작업을 기다렸다가 캐시 저장 후 이미지를 반환하므로
불필요한 네트워크 요청이 사라지고, 중복 다운로드 문제가 해결되었다
내용이 너무 길어져서 다음 내용은 다음 포스팅에서 소개하겠습니다
- 캐시된 이미지에 대한 만료 정책 적용
- 캐시 용량 제한을 두어 디스크 사용량 관리
'Today I Learn' 카테고리의 다른 글
Error vs LocalizedError: Swift 에러 처리, 어떤 걸 선택할까? (1) | 2025.04.15 |
---|---|
Swift - NSCache + Actor 기반 이미지 캐시 매니저 구현기(2) (0) | 2025.04.11 |
RxSwift에서 observe(on:)으로도 UI 업데이트가 즉시 반영되지 않는 이유와 해결법 (1) | 2025.04.01 |
Swift - 날짜 설정 DateFormmater VS Date.FormatStyle (0) | 2025.03.27 |
Today I Learn - 배열의 짝수 번째 요소를 제거하는 다양한 방법 (0) | 2025.03.20 |