트러블 슈팅

[트리블 슈팅] API 호출 대신 이미지 캐싱으로 성능 최적화하기

kimsangjunzzang 2025. 5. 29. 23:33

안녕하세요, 루피입니다!! 

 

오늘은 제가 SwiftUI로 개발한 Hyodor 앱에서 공유 앨범의 이미지 로딩 성능을 개선하기 위해 이미지 캐싱을 활용한 경험을 공유하려 합니다. 특히, 반복적인 API 호출로 인한 로딩 지연 문제를 해결하기 위해 ImageCache를 구현하고 트러블슈팅한 과정을 중심으로 포스팅해 보겠습니다.


문제 상황: 반복적인 API 호출과 로딩 지연

Hyodor 앱의 핵심 기능 중 하나는 공유 앨범입니다. 사용자가 공유 앨범 뷰에서 사진을 볼 때, 각 사진의 이미지를 서버에서 가져오기 위해 API를 호출합니다. 하지만 동일한 이미지에 대해 매번 네트워크 요청을 보내다 보니 다음과 같은 문제가 발생했습니다:

  • 로딩 시간 증가: 네트워크 요청과 데이터 다운로드로 인해 이미지 표시가 느려짐.
  • 데이터 사용량 과다: 동일한 이미지를 반복적으로 다운로드해 데이터 낭비.
  • UX 저하: 사용자가 로딩 스피너를 보며 기다리는 시간이 길어짐.

이미지를 매번 서버에서 가져오는 대신, 로컬에 저장해 빠르게 표시할 수 있다면 성능과 UX를 모두 개선할 수 있을 거라 생각했습니다. 이를 해결하기 위해 이미지 캐싱을 도입했습니다.

해결 방안: 메모리와 디스크를 활용한 이미지 캐싱

이 문제를 해결하기 위해 ImageCache 클래스를 구현했습니다. 이 클래스는 메모리 캐싱(NSCache)과 디스크 캐싱(FileManager)을 조합해 API 호출을 최소화하고 이미지 로딩 속도를 높였습니다. 주요 접근법은 다음과 같습니다:

  • 메모리 캐싱: NSCache를 사용해 빠른 이미지 접근 제공.
  • 디스크 캐싱: 이미지를 로컬 디스크에 저장해 앱 재실행 시에도 재사용 가능.
  • 간단한 API: SharedPhotoCellVM과 SharedPhotoDetailVM에서 쉽게 사용할 수 있는 인터페이스 제공.

구현 과정

1. ImageCache 구현

ImageCache.swift는 메모리와 디스크를 활용한 캐싱 로직을 구현합니다.

import SwiftUI

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)
        }
    }
}
  • 싱글톤 패턴: static let shared로 앱 전역에서 단일 캐시 인스턴스 공유.
  • 메모리 캐싱: NSCache로 빠른 접근, 메모리 압박 시 자동 정리.
  • 디스크 캐싱: FileManager로 이미지를 JPEG 포맷(압축률 0.9)으로 저장, sha256()으로 파일명 충돌 방지.
  • 캐시 조회: 메모리 캐시 우선 확인, 없으면 디스크에서 로드 후 메모리에 저장.

2. 캐시 사용 in ViewModel

SharedPhotoCellVM과 SharedPhotoDetailVM에서 ImageCache를 활용해 이미지를 로드합니다.

@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
        }
    }
}
  • 캐시 우선 확인: ImageCache.shared.get(forKey:)로 캐시된 이미지를 먼저 확인.
  • 네트워크 요청 최소화: 캐시가 없으면 서버에서 다운로드 후 캐시에 저장.

SharedPhotoDetailVM도 유사한 로직을 사용하며, 동일한 캐싱 전략을 적용합니다.


오늘도 화이팅입니다!