본문 바로가기
iOS/트러블슈팅

커스텀 이미지 캐시로 성능개선하기

by hong7 2026. 4. 16.

코드엘 iOS 프로젝트를 개발하면서, 홈 화면 첫 진입 경험이 서비스 인상에 꽤 큰 영향을 준다는 걸 많이 느꼈어요.

 

코드엘의 홈 화면은 앱에 들어오자마자 여러 사용자 프로필이 한 번에 보이는 구조예요. 상단에는 오늘의 코드매칭 카드가 있고, 그 아래에는 코드타임 영역에서 여러 사용자의 프로필 카드가 그리드 형태로 이어져요.

 

즉, 첫 화면부터 여러 장의 원격 이미지와 사용자 정보를 동시에 받아와 렌더링해야 했어요.

 

문제는 이 구간에서 체감 성능이 좋지 않았다는 점이었어요.

  • 이미지가 한꺼번에 붙으면서 첫 화면이 무겁게 느껴졌어요.
  • 스크롤도 완전히 매끄럽다기보다 약간 버벅이는 느낌이 있었어요.
  • placeholder가 길게 보이거나, 이미 본 이미지를 다시 불러오는 것처럼 느껴질 때도 있었어요.

그래서 저는 홈 화면의 체감 성능을 직접 개선해보고 싶었어요. 단순히 이미지를 보여주는 것을 넘어서, 많은 이미지를 어떤 방식으로 로딩하고 재사용할지 직접 설계해야겠다고 생각했고, 그 과정에서 메모리 캐시, 디스크 캐시, 중복 요청 처리, 이미지 디코딩 방식까지 제어할 수 있는 커스텀 이미지 캐시를 구현하게 되었어요.

문제의 발견

홈 화면은 생각보다 무거운 화면이었어요. 화면에 진입하면 상단 추천 카드와 하단 사용자 목록 데이터를 함께 불러오고, 여러 사용자 이미지가 거의 동시에 붙는 구조였거든요.

case .bodyTask:
    // 홈 화면에 진입하면
    // 상단 추천 카드와 하단 목록 데이터를 함께 불러와요.
    // 즉, 첫 진입 시점부터 여러 데이터와 이미지가 한꺼번에 화면에 올라오는 구조예요.
    return .merge(
        reduce(into: &state, action: .fetchMemberRecommend),
        reduce(into: &state, action: .fetchMemberAll)
    )

그리고 실제 화면에서는 여러 사용자 이미지를 연속해서 렌더링하고 있었어요.

ForEach(store.wavePaginationList.data) { member in
    // 각 사용자 셀마다 원격 이미지를 불러와요.
    // 홈 화면에서는 이런 셀들이 한 번에 많이 나타나기 때문에
    // 이미지 로딩 비용이 짧은 시간에 집중되기 쉬웠어요.
    waveCell(member)
}

즉, 홈 첫 화면은 단순한 정적 화면이 아니라 여러 API 응답, 여러 원격 이미지 로딩, 이미지 디코딩, 리스트 렌더링이 한 구간에서 동시에 일어나는 화면이었어요. 그래서 이 문제를 해결하려면 단순 다운로드보다 캐시 정책 자체를 직접 설계할 필요가 있다고 느꼈어요.

메모리 캐시와 디스크 캐시를 분리했어요

가장 먼저 한 일은 캐시를 메모리와 디스크로 분리하는 것이었어요.

 

메모리 캐시는 가장 빠른 재사용을 담당하고, 디스크 캐시는 앱 재실행 이후에도 이미지를 다시 활용할 수 있도록 했어요. 특히 두 계층 모두 단순 저장소가 아니라 운영 기준이 있는 캐시로 다루고 싶었어요.

final class ImageMemoryCache {
    static let shared = ImageMemoryCache()

    // NSCache는 메모리 캐시에 적합한 자료구조예요.
    // 일반 Dictionary와 달리 메모리 압박 상황에서 시스템이 비교적 유연하게 회수할 수 있어서
    // 이미지처럼 재생성 가능한 데이터를 담기에 더 안전하다고 판단했어요.
    private let cache = NSCache<NSString, UIImage>()

    private init() {
        // 메모리에 보관할 이미지 개수 상한이에요.
        // 너무 많은 이미지를 붙잡고 있지 않도록 개수를 제한했어요.
        cache.countLimit = 300

        // 전체 메모리 비용 상한이에요.
        // 큰 이미지가 누적되면서 메모리를 과도하게 점유하지 않도록
        // 개수뿐 아니라 전체 비용도 함께 제한했어요.
        cache.totalCostLimit = 120 * 1024 * 1024
    }
}

메모리 캐시는 NSCache를 기반으로 구성했고, 개수와 총 메모리 비용 상한을 코드에 명시했어요. 홈처럼 같은 이미지가 스크롤 중 다시 등장할 수 있는 구조에서는 가장 먼저 빠르게 꺼낼 수 있는 레이어가 필요했기 때문이에요.

 

또 메모리 경고가 들어오면 캐시를 비우도록 연결해서 안정성도 함께 챙겼어요.

memoryWarningObserver = NotificationCenter.default.addObserver(
    forName: UIApplication.didReceiveMemoryWarningNotification,
    object: nil,
    queue: .main
) { [weak self] _ in
    // 이미지 캐시는 있으면 빠르지만, 없어도 다시 만들 수 있는 데이터예요.
    // 그래서 메모리 경고가 들어오면 가장 먼저 비워도 되는 대상으로 보고
    // 캐시를 즉시 제거해서 앱 전체 안정성을 우선했어요.
    self?.removeAll()
}

디스크 캐시에는 원본 데이터를 저장하고, 전체 용량이 상한을 넘으면 오래된 파일부터 정리하도록 했어요.

actor ImageDiskCache {
    static let shared = ImageDiskCache()

    // actor를 사용해 디스크 캐시 접근을 직렬화했어요.
    // 파일 읽기/쓰기, 수정 시간 갱신, 캐시 정리 같은 작업이 동시에 겹치면
    // 상태가 꼬이거나 정리 기준이 흔들릴 수 있어서
    // 하나의 격리된 실행 문맥 안에서 일관되게 관리하고 싶었어요.
    private let maxCacheSizeBytes = 250 * 1024 * 1024
}

또 파일을 읽을 때 수정 시간을 갱신해서 최근에 사용한 이미지가 더 오래 남도록 했어요.

func data(forKey key: String) -> Data? {
    let fileURL = fileURL(forKey: key)
    guard let data = try? Data(contentsOf: fileURL) else { return nil }

    // 최근에 사용한 파일이 더 오래 유지되도록 수정 시간을 갱신해요.
    // 단순 저장 순서가 아니라 실제 사용 패턴을 기준으로 캐시를 유지하고 싶었어요.
    try? FileManager.default.setAttributes(
        [.modificationDate: Date()],
        ofItemAtPath: fileURL.path
    )

    return data
}
private func trimIfNeeded() throws {
    // 전체 캐시 용량이 상한을 넘을 때만 정리를 시작해요.
    // 디스크도 무한정 사용할 수 없기 때문에 상한을 두고 운영했어요.
    guard totalSize > maxCacheSizeBytes else { return }

    // 가장 오래된 파일부터 제거해서 디스크 사용량을 줄여요.
    // 최근에 사용한 이미지가 더 오래 남도록 LRU에 가까운 방식으로 정리했어요.
    let sortedByOldest = cacheFiles.sorted { $0.modifiedAt < $1.modifiedAt }

    for file in sortedByOldest where currentSize > maxCacheSizeBytes {
        try? fileManager.removeItem(at: file.url)
        currentSize -= file.size
    }
}

이렇게 하니 디스크 캐시도 “그냥 저장되는 공간”이 아니라, 무엇을 남기고 무엇을 지울지 기준이 있는 저장소가 됐어요.

조회 순서를 메모리 → 디스크 → 네트워크로 고정했어요

다음으로는 이미지 로딩 경로를 하나로 통일했어요. 이미지를 불러올 때는 항상 메모리, 디스크, 네트워크 순서로만 조회하도록 고정했어요.

func image(for url: URL) async throws -> UIImage {
    let key = cacheKey(for: url)

    // 1. 가장 먼저 메모리 캐시를 확인해요.
    // 메모리는 접근 비용이 가장 낮기 때문에 가장 빠른 재사용 경로예요.
    if let cachedImage = memoryCache.image(forKey: key) {
        return cachedImage
    }

    // 2. 메모리에 없으면 디스크 캐시를 확인해요.
    // 디스크 I/O 비용은 메모리보다 크지만 네트워크보다는 훨씬 저렴하기 때문에
    // 두 번째 계층으로 두는 게 가장 합리적이었어요.
    if let diskData = await diskCache.data(forKey: key),
       let diskImage = downsampledImage(from: diskData) ?? UIImage(data: diskData) {
        // 디스크에서 꺼낸 이미지는 다시 메모리에 올려서
        // 다음 접근을 더 빠르게 만들어요.
        memoryCache.store(diskImage, forKey: key)
        return diskImage
    }

    // 3. 둘 다 없으면 마지막으로 네트워크에서 내려받아요.
    ...
}

이렇게 하니 이미지가 어떤 경로로 로드되는지 예측 가능해졌고, 여러 화면에서도 같은 기준으로 동작하게 됐어요.

같은 요청은 하나의 작업으로 합쳤어요

홈 화면처럼 여러 사용자 카드가 동시에 붙는 구조에서는 같은 URL 요청이 짧은 시간 안에 겹칠 수 있어요. 이때 요청마다 다운로드와 디코딩이 따로 일어나면 비용이 커져요.

 

그래서 진행 중인 요청은 inFlightTasks로 관리해서, 이미 같은 URL에 대한 작업이 있다면 그 결과를 함께 기다리도록 했어요.

actor ImageCacheClient {
    static let shared = ImageCacheClient()

    // actor를 사용해 캐시 조회 흐름과 in-flight task 상태를 함께 보호했어요.
    // 같은 URL 요청이 동시에 들어오는 상황에서도
    // race condition 없이 하나의 작업만 생성되도록 만들고 싶었어요.
    private var inFlightTasks: [String: Task<UIImage, Error>] = [:]
}
if let task = inFlightTasks[key] {
    // 이미 같은 URL 다운로드가 진행 중이면
    // 그 작업 결과를 같이 기다려요.
    // 이렇게 하면 네트워크 요청과 디코딩 비용이 중복 수행되지 않아요.
    return try await task.value
}
let task = Task<UIImage, Error> {
    // 캐시에 없을 때만 실제 네트워크 요청을 보내요.
    let (data, response) = try await session.data(from: url)

    if let httpResponse = response as? HTTPURLResponse,
       !(200...299).contains(httpResponse.statusCode) {
        throw URLError(.badServerResponse)
    }

    // 다운로드한 데이터는 downsampling 후 이미지로 변환해요.
    guard let image = downsampledImage(from: data) ?? UIImage(data: data) else {
        throw URLError(.cannotDecodeRawData)
    }

    // 다음 요청을 위해 메모리와 디스크 캐시에 저장해요.
    memoryCache.store(image, forKey: key)
    await diskCache.store(data, forKey: key)

    return image
}

inFlightTasks[key] = task

이렇게 하면 같은 이미지를 동시에 요청하더라도 다운로드는 한 번만 일어나고, 나머지는 그 결과를 공유할 수 있어요. 첫 진입 순간 요청 밀도가 높은 홈 화면에서는 이런 중복 제거가 꽤 중요하다고 느꼈어요.

큰 이미지는 downsampling해서 디코딩 비용을 줄였어요

이미지 성능 문제는 네트워크만의 문제가 아니었어요. 원본 이미지가 크면 디코딩 과정 자체가 메모리와 렌더링 비용에 영향을 줄 수 있었거든요.

 

그래서 데이터를 받은 뒤에는 바로 UIImage(data:)만 쓰지 않고, 먼저 화면에 필요한 수준으로 줄인 이미지를 만들도록 했어요.

private func downsampledImage(from data: Data, maxPixel: CGFloat = 1536) -> UIImage? {
    // 원본 데이터를 이미지 소스로 만들되,
    // 처음부터 전체 이미지를 바로 메모리에 올리지는 않아요.
    // 불필요하게 큰 이미지를 그대로 디코딩하면 메모리 사용량이 커질 수 있기 때문이에요.
    let options: [CFString: Any] = [kCGImageSourceShouldCache: false]

    guard let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary) else {
        return nil
    }

    let downsampleOptions: [CFString: Any] = [
        // 원본 전체가 아니라 썸네일을 생성해요.
        // 즉, 필요한 크기까지만 디코딩해서 메모리 낭비를 줄여요.
        kCGImageSourceCreateThumbnailFromImageAlways: true,

        // 화면에 필요한 최대 크기까지만 디코딩해요.
        // 큰 이미지를 그대로 메모리에 올리면 스크롤 중 렌더링 비용도 함께 커질 수 있어요.
        kCGImageSourceThumbnailMaxPixelSize: maxPixel,

        // 방향 정보까지 반영해서 썸네일을 만들어요.
        kCGImageSourceCreateThumbnailWithTransform: true,

        // 생성 시점에 즉시 캐시해서
        // 실제 표시 직전에 다시 디코딩 비용이 튀지 않도록 했어요.
        kCGImageSourceShouldCacheImmediately: true
    ]

    guard let cgImage = CGImageSourceCreateThumbnailAtIndex(
        source,
        0,
        downsampleOptions as CFDictionary
    ) else {
        return nil
    }

    return UIImage(cgImage: cgImage)
}

이렇게 하면 큰 원본 이미지를 그대로 메모리에 올리는 부담을 줄일 수 있었고, 특히 여러 이미지를 빠르게 스크롤하는 홈 화면에서 더 의미가 있었어요.

공통 이미지 컴포넌트로 화면 전체에 같은 정책을 적용했어요

캐시 레이어만 만들고 끝내면, 화면마다 로딩 UI나 실패 UI가 달라질 수 있어요. 그래서 COCachedAsyncImage와 URL.lazyImage()를 통해 공통 이미지 진입점을 만들었어요.

func lazyImage<Content: View>(
    @ViewBuilder content: @escaping (Image) -> Content
) -> some View {
    COCachedAsyncImage(url: self) { image in
        // 성공 시에는 공통적으로 resizable 이미지를 넘겨줘요.
        // 화면마다 이미지 로딩 방식을 따로 구현하지 않도록 진입점을 통일했어요.
        content(image.resizable())
    } placeholder: {
        // 로딩 중에는 동일한 placeholder UI를 사용해요.
        // 이미지 상태 전환을 화면마다 제각각 만들지 않고 일관되게 가져가고 싶었어요.
        ZStack {
            Color.gray200
            Image(systemName: "photo")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 20, height: 20)
                .foregroundStyle(.gray500)
        }
    } failure: {
        // 실패 시에도 공통 fallback UI를 보여줘요.
        // 이미지 실패 경험도 공통화해서 유지보수 비용을 줄였어요.
        ZStack {
            Color.gray400
            Image(systemName: "photo")
                .resizable()
                .aspectRatio(contentMode: .fit)
                .frame(width: 24, height: 24)
                .foregroundStyle(.gray00)
        }
    }
}

이 구조 덕분에 홈, 채팅, 프로필 상세처럼 서로 다른 화면에서도 같은 캐시 정책과 같은 상태 전환을 공유할 수 있게 됐어요.

결과

이 개선 이후 코드엘의 이미지 로딩은 홈 화면의 체감 성능을 개선하기 위한 방향으로 다시 설계된 형태가 되었어요.

  • 홈 첫 진입 시 여러 이미지가 동시에 붙는 구간에서 캐시 흐름을 더 예측 가능하게 만들 수 있었어요.
  • 동일 URL 요청을 하나의 작업으로 묶어 중복 다운로드와 중복 디코딩을 줄일 수 있었어요.
  • 큰 이미지 처리 비용을 낮추기 위해 downsampling을 적용했어요.
  • 메모리 경고 대응, 디스크 상한, 오래된 파일 정리 기준을 코드에 명시할 수 있었어요.
  • 캐시 동작을 직접 이해하고 관리할 수 있는 구조를 갖출 수 있었어요.

개인적으로는 이번 작업이 단순한 캐시 구현보다, 성능 문제를 서비스 구조 관점에서 다시 해석하고 직접 개선해본 경험으로 더 크게 남았어요.

마치며

이번 작업은 단순히 이미지를 잘 띄우는 작업은 아니었어요.

 

코드엘 홈 화면처럼 이미지 밀도가 높은 화면에서는 “이미지를 어떻게 다운로드할까”보다 “이미지 로딩을 어떤 정책으로 운영할까”가 더 중요한 문제였어요. 저는 이번 경험을 통해, 성능 최적화는 단순히 더 빠른 도구를 찾는 일이 아니라 현재 서비스 구조를 이해하고, 그 구조에 맞는 운영 기준을 설계하는 일에 더 가깝다는 걸 배웠어요.

 

그리고 코드엘에서는 그 기준을 메모리 캐시, 디스크 캐시, 중복 요청 제거, downsampling으로 구체화해, 홈 화면의 첫인상과 사용성을 조금 더 안정적으로 다듬어볼 수 있었어요.