반응형

SwiftUI Study – 비동기 이미지 로딩을 효율적으로 처리하고 캐싱·플레이스홀더·리사이즈 전략으로 성능 최적화하기

 

1. 왜 중요한가 (문제 배경)

SwiftUI 앱에서 이미지 로딩은 가장 흔하면서도 성능 이슈가 많이 생기는 영역입니다. 특히:

  • URL 이미지를 비동기로 불러와야 하고
  • 로딩 중 플레이스홀더가 필요하며
  • 스크롤 중에는 이미지가 계속 교체되고
  • 고해상도 이미지 리사이즈는 메인 스레드를 막고
  • 네트워크 이미지 재요청이 반복되면 데이터 낭비가 발생합니다

Apple이 제공하는 AsyncImage는 간단한 용도로는 좋지만,

실제 서비스 환경에서는 다음 문제가 나타납니다.

  • 캐싱 기능 부족
  • 해상도 제어 불가
  • 스크롤에서 깜빡임 발생
  • 플레이스홀더 커스터마이징 제한

그래서 실무에서는 커스텀 로더 + 캐시 + 리사이즈 전략을 함께 사용하는 것이 일반적입니다.

 

2. 잘못된 패턴 예시

❌ 예시 1: 매번 URLSession.data로 직접 이미지를 불러오는 방식

struct WrongImage: View {
    let url: URL
    @State private var img: UIImage?

    var body: some View {
        Group {
            if let img { Image(uiImage: img).resizable() }
            else { ProgressView() }
        }
        .task {
            // ❌ 캐싱 없음 → 스크롤할 때마다 계속 네트워크 요청
            let (data, _) = try await URLSession.shared.data(from: url)
            img = UIImage(data: data)
        }
    }
}

문제점

  • 스크롤할 때마다 계속 다운로드 → 데이터 낭비
  • 이미지가 표시될 때마다 깜빡임
  • 큰 이미지면 디코딩 비용으로 프레임 드랍 발생
  • UI 스레드에서 처리될 가능성 있음

❌ 예시 2: 원본 해상도를 그대로 사용하는 방식

Image(uiImage: originalImage)   // ❌ 고해상도 이미지 → 메모리 폭발
    .resizable()

문제점

  • iPhone 화면에서 실제로 필요 없는 고해상도를 그대로 메모리에 올림
  • 스크롤 뷰 내부에서 심각한 성능 저하
  • 메모리 경고 또는 크래시 위험

❌ 예시 3: AsyncImage만으로 모든 요구사항 해결하려는 방식

AsyncImage(url: url) { image in
    image.resizable()
} placeholder: {
    ProgressView()
}

문제점

  • 캐싱 정책은 OS 내부에 의존 (명시적 제어 불가능)
  • 이미지 리사이즈나 다운샘플링 제어 불가
  • 커스텀 로딩 상태 관리가 어려움
  • 스크롤에서 깜빡임 발생 빈도 높음

 

3. 올바른 패턴 예시

✅ 예시 1: 다운샘플링 + 캐싱 지원 이미지 로더 구현

actor ImageLoader {
    static let shared = ImageLoader()
    private var cache: [URL: UIImage] = [:]

    func load(url: URL, maxSize: CGSize) async throws -> UIImage {
        if let cached = cache[url] {
            return cached
        }

        let (data, _) = try await URLSession.shared.data(from: url)
        let image = downsample(data: data, to: maxSize)
        cache[url] = image
        return image
    }

    private func downsample(data: Data, to size: CGSize) -> UIImage {
        let options: [CFString: Any] = [
            kCGImageSourceShouldCache: false
        ]
        let source = CGImageSourceCreateWithData(data as CFData, options as CFDictionary)!
        let max = max(size.width, size.height)
        let downsampleOptions: [CFString: Any] = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceThumbnailMaxPixelSize: max,
            kCGImageSourceShouldCacheImmediately: true
        ]
        let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, downsampleOptions as CFDictionary)!
        return UIImage(cgImage: cgImage)
    }
}

장점

  • 큰 이미지를 화면에 필요한 해상도로 다운샘플링 → 메모리 절약
  • 캐싱 지원 → 스크롤 반복 로딩 제거
  • 메인 스레드를 막지 않음

✅ 예시 2: View에서 안전하게 로딩 & 상태 표현

struct CachedAsyncImage: View {
    let url: URL
    let size: CGSize

    @State private var image: UIImage?
    @State private var isLoading = false

    var body: some View {
        Group {
            if let image {
                Image(uiImage: image).resizable()
            } else if isLoading {
                ProgressView()
            } else {
                Color.gray.opacity(0.2)
            }
        }
        .task {
            guard image == nil else { return }
            isLoading = true
            image = try? await ImageLoader.shared.load(url: url, maxSize: size)
            isLoading = false
        }
    }
}

장점

  • 로딩 중/완료 상태를 명확히 분리
  • 캐시된 이미지면 즉시 표시 (깜빡임 없음)
  • 다운샘플링 포함 → 메모리 절약

✅ 예시 3: List/ScrollView에서 효율적으로 사용

struct ItemRow: View {
    let item: Item

    var body: some View {
        HStack {
            CachedAsyncImage(url: item.thumbnail, size: CGSize(width: 100, height: 100))
                .frame(width: 80, height: 80)
                .clipShape(RoundedRectangle(cornerRadius: 8))

            Text(item.title)
        }
    }
}

효과

  • 빠른 스크롤에도 깜빡임 최소
  • 캐싱으로 재요청 없음
  • 메모리 안정성 증가

 

4. 실전 적용 팁

✔ 팁 1 – AsyncImage는 간단한 경우에만 사용

프로필 이미지 정도의 단일 이미지에 적합.

✔ 팁 2 – 컬렉션·피드 UI는 반드시 캐시 + 다운샘플링 필요

스크롤 UI는 이미지 최적화가 성능의 핵심.

✔ 팁 3 – actor 기반 캐시로 스레드 안전성 확보

여러 Task가 동시에 이미지를 로드해도 안전.

✔ 팁 4 – 이미지 크기를 명확히 알고 있다면 다운샘플링이 필수

메모리 절약 & CPU 비용 절감 효과가 큼.

✔ 팁 5 – 플레이스홀더·로딩 상태는 UI 일관성에 중요

특히 야간 모드 & 다크 모드 대응도 고려.

 

5. 정리

  • SwiftUI에서 이미지 로딩은 성능·메모리·네트워크 사용량에 직결되는 중요한 요소이다.
  • 캐싱 + 다운샘플링 + 명확한 로딩 상태 표현을 조합해야 고품질 UI를 만들 수 있다.
  • 단순한 AsyncImage 의존은 실제 서비스 환경에서는 한계가 있으므로
    커스텀 로더를 통해 효율을 극대화하는 전략이 필요하다.
  • 이 구조는 스크롤 기반 UI(피드·갤러리·쇼핑·뉴스 앱)의 품질을 크게 향상시킨다.
반응형
Posted by 까칠코더
,