Neoself의 기술 블로그

우선순위 캐시 전략을 통한 특정 이미지 로드 시간 단축하기 본문

개발지식 정리/알고리즘

우선순위 캐시 전략을 통한 특정 이미지 로드 시간 단축하기

Neoself 2025. 3. 23. 18:50

앱에서 이미지를 빠르게 로딩하는 것은 사용자 경험에 중요한 영향을 미칩니다. 특히 자주 방문하는 화면의 내 이미지 로딩 속도는 앱의 전반적인 체감 성능에 직접적으로 연결됩니다. 이번 글에서는 NeoImage 라이브러리에 우선순위 캐시 전략을 도입하여 나의 책장 탭의 이미지 로딩 시간을 단축한 과정을 소개합니다.

"나의 책장" 탭은 사용자가 보유중인 책의 이미지를 포함하고 있습니다. 이 탭으로 진입할 때마다 메모리 저장소에 캐싱되어있지 않으나, 디스크에 이미지 데이터가 캐싱되어있을 경우, io 작업에 의한 오버헤드가 발생했고, 이는 사용자 경험을 저하시키는 요인이 될 수 있다고 판단했습니다.

loaded with NeoImage in 0.01776 seconds 
loaded with NeoImage in 0.01759 seconds 
loaded with NeoImage in 0.01833 seconds 
loaded with NeoImage in 0.01839 seconds
...

실제 Swift Testing을 통해 이미지 로드 소요시간을 측정한 결과, MyPage 탭의 이미지 로드에 평균 0.018초가 소요되고 있었습니다. 

private let prefetchURLs = [
    URL(string: "https://example.com/profile.jpg")!,
    URL(string: "https://example.com/badge.jpg")!,
    URL(string: "https://example.com/achievement.jpg")!,
    // 아래는 실제로 사용되지 않을 수 있는 URL (휴먼 에러)
    URL(string: "https://example.com/old_profile.jpg")!,
    // 중복된 URL (휴먼 에러)
    URL(string: "https://example.com/profile.jpg")!
]

private var prefetcher: ImagePrefetcher?

private func setupPrefetcher() {
    // 프리페쳐 설정 및 시작
    prefetcher = ImagePrefetcher(urls: prefetchURLs, options: nil) { skippedResources, failedResources, completedResources in
        print("Prefetch 완료: \(completedResources.count)개 성공, \(failedResources.count)개 실패, \(skippedResources.count)개 스킵")
    }
    prefetcher?.start()
}

Kingfisher에서도 ImagePrefetcher를 제공해 이러한 문제를 해결할 수 있도록 하고 있습니다. 하지만, 실제 이미지 렌더를 요청하는 메서드가 아닌 다른 위치에서 Prefetch를 요청해야한다는 점, 직접 URL을 하드코딩하여 전달해줘야 한다는 점으로 인해 휴먼 에러 가능성이 있다고 판단했습니다.

 

1. 해결방안: 우선순위 이미지 캐싱

이 문제를 해결하기 위해 NeoImage 라이브러리에 우선순위 이미지 캐싱 전략을 도입했습니다. 핵심 아이디어는 다음과 같습니다:

 

  • 자주 접근하는 이미지에 priority_ 접두사를 붙여 우선순위 이미지로 표시
  • 앱 시작 시 이러한 우선순위 이미지를 디스크 캐시에서 메모리 캐시로 프리로드
  • 메모리 부족 상황에서도 우선순위 이미지는 메모리에 유지

이러한 접근법을 통해 MyPage 탭 진입 시 이미지 로딩 시간을 대폭 단축하고자 했습니다.

 

2. 고려한 엣지케이스

우선 우선순위 이미지 캐싱 시스템을 설계하면서 다음과 같은 엣지케이스들을 고려했습니다:

  • 중복 저장 문제: 동일한 이미지가 일반 키(hashedKey)와 우선순위 키(priority_hashedKey) 두 가지 버전으로 디스크에 중복 저장되어 저장 공간이 낭비될 수 있습니다.
  • 캐시 조회 불일치: 이미지가 우선순위 버전으로만 저장되어 있는데 일반 키로 조회하거나, 반대로 일반 버전으로만 저장되어 있는데 우선순위 키로 조회할 경우 캐시 미스가 발생할 수 있습니다.
  • 우선순위 상태 변경: 기존에 일반 이미지로 저장된 이미지를 나중에 우선순위 이미지로 저장하려 할 때(또는 그 반대의 경우) 발생할 수 있는 불일치 문제입니다.
  • 삭제 시 불일치: 특정 키에 대한 캐시를 삭제할 때 일반 버전만 삭제되고 우선순위 버전은 남아있거나, 그 반대의 경우가 발생할 수 있습니다.

이러한 엣지케이스들을 고려해 각 컴포넌트를 구현했습니다.

3. 코드 구현

1. ImageCache

1.1 store 메서드(Create & Update)

ImageCache 클래스에는 우선순위 이미지 관리를 위한 로직을 추가했습니다.

public func store(
    _ data: Data,
    for hashedKey: String
) async throws {
    await memoryStorage.store(value: data, for: hashedKey)
    
    let isPriority = hashedKey.hasPrefix("priority_")
    
    /// 우선순위 여부로 같은 데이터가 디스크 캐시에 동시에 존재할 가능성이 있습니다.
    /// 콜백이 불필요하며 글로벌 스레드에서 전적으로 실행되는 store에서 디스크 캐시에 대한 io작업을 최대한 수행토록하여 우선순위가 엇갈리는 동일한 데이터 유무를 검토하고 제거하는 추가 로직을 구현했습니다.
    /// 또한 일반 저장 시, 우선순위 적용 키가 캐싱되어있으면 중복 저장을 하지 않는 등의 엣지케이스도 고려했습니다.
    if isPriority {
        let originalKey = hashedKey.replacingOccurrences(of: "priority_", with: "")
        
        if await diskStorage.isCached(for: originalKey) {
            try await diskStorage.remove(for: originalKey)
            NeoLogger.shared.debug("원본 이미지 제거: \(originalKey)")
        }
    } else {
        if await diskStorage.isCached(for: "priority_"+hashedKey) {
            NeoLogger.shared.debug("우선순위 이미지가 존재하여 원본 저장 건너뜀: \(hashedKey)")
            return
        }
    }
    
    try await diskStorage.store(value: data, for: hashedKey)
}

이때 고려하였던 엣지케이스는 중복 저장 문제였습니다. 이론상 2곳에서 동일한 이미지에 대해 다른 우선순위를 부여하게 될 경우, 디스크 및 메모리 캐시에는 동일한 이미지를 중복저장할 수 있게 됩니다. 때문에, 저장 혹은 로드 시에 동일 이미지 유무를 파악하고 중복된 데이터를 제거하는 작업이 필요하게 됩니다.

 

이 때, 크게 2가지 선택지가 있었습니다.

1. 이미지를 저장하는 store 메서드 실행 시, 중복 이미지 유무 파악

2. 이미지를 로드하여 반환하는 retrieveImage 메서드 실행 시, 중복 이미지 유무 파악

 

retrieveImage 메서드는 최종적으로 로드된 이미지를 메인 스레드로 반환해줘야 하기 때문에, 이러한 무거운 작업을 도중에 실행하기에 부적절하다고 판단하였습니다. 때문에, 별도로 반환하는 데이터가 없으며 글로벌 스레드에서 실행하여도 문제가 없는 store 메서드에서 이러한 우선순위 상태 변경 및 기존 버전에 대한 관리 로직을 구현하여 중복 저장되는 문제를 해결하고자 했습니다.

 

 

 

 

1.2 retrieveImage 메서드(Read)

public func retrieveImage(hashedKey: String) async throws -> Data? {
    let isPriority = hashedKey.hasPrefix("priority_")
    let otherKey: String
    if isPriority {
        otherKey = hashedKey.replacingOccurrences(of: "priority_", with: "")
    } else {
        otherKey = "priority_" + hashedKey
    }

    if let memoryData = await memoryStorage.value(forKey: hashedKey) {
        return memoryData
    }

    if let memoryDataForOtherKey = await memoryStorage.value(forKey: otherKey) {
        return memoryDataForOtherKey
    }

    if let diskData = try await diskStorage.value(for: hashedKey){
        await memoryStorage.store(value: diskData, for: hashedKey, expiration: .days(7))
        return diskData
    }

    if let diskDataForOtherKey = try await diskStorage.value(for: otherKey){
        await memoryStorage.store(value: diskDataForOtherKey, for: hashedKey, expiration: .days(7))
        return diskDataForOtherKey
    }

    return nil
}

그 대신 retrieveImage 메서드에서는 정상적인 경로를 통해 이미지를 탐색하여도 없을 시, 우선순위 버전 혹은 일반 버전 키로도 있는지 탐색을 하여 캐시된 데이터를 찾지못하는 상황을 최소화하고자 했습니다.

물론 이때에도 메모리 저장소 탐색 이후에 디스크 저장소에 대한 탐색으로 넘어가게 순서를 설계하여 오버헤드를 최소화했습니다.

 

2. DiskStorage

앱 시작 시 우선순위 이미지를 메모리에 프리로드하는 preloadPriorityToMemory 메서드를 구현했습니다.

func preloadPriorityToMemory() async {
    do {
        let prefix = "priority_"
        let fileURLs = try allFileURLs(for: [.isRegularFileKey, .nameKey])

        let prefixedFiles = fileURLs.filter { url in
            let fileName = url.lastPathComponent
            return fileName.hasPrefix(prefix)
        }

        for fileURL in prefixedFiles {
            let fileName = fileURL.lastPathComponent
            let hashedKey = fileName.replacingOccurrences(of: "priority_", with: "")

            print("fileURL from preload:", fileURL)
            if let data = try? Data(contentsOf: fileURL) {
                await ImageCache.shared.memoryStorage.store(value: data, for: hashedKey)
            }
        }

        NeoLogger.shared.info("우선순위 이미지 메모리 프리로드 완료")
    } catch {
        print("메모리 프리로드 중 오류 발생: \(error)")
    }
}

프리로드의 핵심 로직인 디스크 캐시 내부 데이터를 메모리 저장소로 로드하는 로직입니다. 디스크 저장소에 접근하여 "priority_" 접두사가 삽입된 키를 가져와 해당 키에 대한 정보들을 메모리 저장소에 프리로드합니다.

 

이러한 프리로드 로직은 아래 ImageCache의 초기화기에서 호출됩니다.

public final class ImageCache: Sendable {
    // MARK: - Static Properties

    public static let shared = ImageCache(name: "default")
    public init(
        name: String
    ) {
    	...
        Task {
            await diskStorage.preloadPriorityToMemory()
        }
    }
    ...

여기서 눈치가 빠르신 분이면, 프리로드 로직 자체의 지연호출을 생각하실 수 있습니다. Swift에서 static let으로 선언된 프로퍼티는 기본적으로 지연 초기화 특성을 지녀 처음 접근될때 초기화되기 때문입니다. 하지만, 첫 앱 시작시 접근하는 홈화면에서 이미 이미지 캐시 및 로드 로직을 필요로 하여서, 초기화가 필연적으로 실행됩니다.

 

3. MemoryStorage

메모리 저장소에서는 메모리 부족 상황에서도 우선순위 이미지를 유지하기 위한 removeAllExceptPriority 메서드를 구현했습니다:

@objc
public func clearMemoryCache(keepPriorityImages: Bool = true) {
    Task {
        if keepPriorityImages {
            // 우선순위 이미지를 유지하는 전략
            await memoryStorage.removeAllExceptPriority()
        } else {
            // 모든 이미지 제거
            await memoryStorage.removeAll()
        }
    }
}

public func removeAllExceptPriority() async {
    let priorityKeys = keys.filter { $0.hasPrefix("priority_") }
    
    var priorityImagesData: [String: Data] = [:]
    
    for key in priorityKeys {
        if let data = value(forKey: key) {
            priorityImagesData[key] = data
        }
    }
    
    removeAll()
    
    for (key, data) in priorityImagesData {
        store(value: data, for: key, expiration: .days(7))
    }
    
    NeoLogger.shared.info("메모리 캐시 정리 완료: 우선순위 이미지 \(priorityImagesData.count)개 유지")
}

 

Kingfisher에서는 프리로드 시점이 앱 시작밖에 존재하지 않습니다. 허나, 각 이미지 데이터마다 우선순위를 명시할 수 있게 됨에 따라, 런타임 중에도 메모리 부족 시에 우선순위가 기입된 이미지를 유지하는 등의 세밀한 우선순위 로드가 가능해지게 되었습니다.

 

4. 결론

이러한 우선순위 이미지 캐싱 전략을 통해 MyPage 탭의 이미지 로딩 시간을 0.018초에서 0.002초로 단축하여, 평균 MyLibrary 탭 내부 이미지 로드 시간을 88.89% 단축시킬 수 있었습니다.

 

감사합니다.