반응형

SwiftUI Study – 이미지 로딩·캐싱·리사이징을 효율적으로 처리하는 실전 패턴

 

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

SwiftUI에서 이미지 처리는 성능 문제의 핵심 요소입니다.

특히 다음과 같은 상황에서는 렌더링 지연 또는 메모리 폭증이 쉽게 발생합니다.

  • 리스트에 많은 이미지가 표시되는 경우
  • 고해상도(4K, 원본) 이미지를 그대로 로딩하는 경우
  • AsyncImage를 기본 설정으로 사용할 때
  • 이미지 리사이징을 body 안에서 수행할 때
  • 네트워크 이미지 캐싱을 구현하지 않은 경우

문제의 본질은 다음과 같습니다.

이미지 로딩은 CPU·메모리·디스크·네트워크 모두에 부담이 크므로

“언제·어디서·어떻게” 처리하느냐가 성능에 큰 영향을 준다.

 

2. 잘못된 패턴 예시

❌ 예시 1: body 안에서 직접 리사이징

Image(uiImage: image.resize(width: 300))   // ❌ 매번 리사이징 반복

문제점

  • body 호출 시마다 리사이징 연산 반복
  • 연산 비용 매우 큼 (특히 큰 이미지일수록 심각)
  • 스크롤 시 끊김 발생

❌ 예시 2: AsyncImage 기본 사용

AsyncImage(url: URL(string: urlString))   // ❌ 캐시 없음 / 플리커링 발생

문제점

  • 네트워크 이미지를 매번 새로 요청
  • 스크롤할 때 이미지 깜빡임(flickering) 발생
  • 메모리 캐시 관리 불가

❌ 예시 3: 고해상도 이미지를 그대로 표시

Image(uiImage: original)   // ❌ 4K·원본 이미지 그대로 로딩 → 메모리 폭증

문제점

  • iPhone 화면은 실제로 이미지의 5~10%만 사용
  • 원본 크기 그대로 로딩 → 메모리 낭비

❌ 예시 4: URLSession + Data → UIImage 변환을 메인스레드에서 실행

let data = try! Data(contentsOf: url)   // ❌ 동기 I/O + 메인스레드 블록
let image = UIImage(data: data)!        // ❌ 변환 작업도 메인스레드

문제점

- 앱 전체가 멈추는 현상 발생

- 스크롤·입력·애니메이션 지연

 

3. 올바른 패턴 예시

✅ 예시 1: 이미지 리사이징은 백그라운드에서 수행

ViewModel에서 수행:

@MainActor
final class PhotoViewModel: ObservableObject {
    @Published var resized: UIImage?

    func loadAndResize(from url: URL) {
        Task.detached {
            let (data, _) = try! await URLSession.shared.data(from: url)
            let original = UIImage(data: data)!

            let resized = original.resize(width: 300)

            await MainActor.run {
                self.resized = resized
            }
        }
    }
}

View:

if let image = viewModel.resized {
    Image(uiImage: image)
        .resizable()
}

장점

  • 리사이징 비용이 UI 스레드를 방해하지 않음
  • 스크롤 및 애니메이션 성능 안정적

✅ 예시 2: NSCache 기반 이미지 캐싱

final class ImageCache {
    static let shared = NSCache<NSString, UIImage>()
}

사용:

func cachedImage(for url: NSString) -> UIImage? {
    ImageCache.shared.object(forKey: url)
}

func saveImage(_ image: UIImage, for url: NSString) {
    ImageCache.shared.setObject(image, forKey: url)
}

장점

  • 메모리 캐싱 자동 관리
  • URLSession + 디스크 캐시와 함께 사용하면 이상적

✅ 예시 3: URLCache 기반 네트워크 캐싱

URLSession은 자동 캐싱 기능을 갖고 있지만, 캐시 정책이 중요함.

let config = URLSessionConfiguration.default
config.requestCachePolicy = .returnCacheDataElseLoad
let session = URLSession(configuration: config)

장점

  • 서버 응답이 캐싱 가능할 경우 네트워크 요청 없이 즉시 표시
  • 스크롤 시 깜빡임(flicker) 감소

✅ 예시 4: Swift Concurrency + AsyncImage 커스텀 구현

struct CachedAsyncImage: View {
    let url: URL

    @State private var image: UIImage?

    var body: some View {
        Group {
            if let image {
                Image(uiImage: image)
                    .resizable()
            } else {
                ProgressView()
            }
        }
        .task {
            await load()
        }
    }

    func load() async {
        if let cached = ImageCache.shared.object(forKey: url.absoluteString as NSString) {
            self.image = cached
            return
        }

        let (data, _) = try! await URLSession.shared.data(from: url)
        if let img = UIImage(data: data) {
            ImageCache.shared.setObject(img, forKey: url.absoluteString as NSString)
            self.image = img
        }
    }
}

장점

  • 캐시 + 비동기 다운로드 + flickering 최소화
  • 고성능 이미지 목록 구현 가능

 

4. 실전 적용 팁

✔ 팁 1 – 이미지 연산(리사이징)은 절대 body에서 하지 말 것

항상 ViewModel 또는 Task.detached에서 수행.

✔ 팁 2 – 리스트에 이미지를 많이 쓴다면 LazyVStack + 캐시는 필수

스크롤 성능이 체감될 정도로 개선됨.

✔ 팁 3 – URLCache와 NSCache를 함께 사용하면 최고 효율

  • URLCache → 디스크/네트워크 캐싱
  • NSCache → 메모리 캐싱

둘을 병행하면 대부분의 이미지 화면이 부드러워짐.

✔ 팁 4 – AsyncImage는 placeholder가 깜빡일 수 있음

커스텀 CachedAsyncImage 방식이 더 안정적.

✔ 팁 5 – 리사이징은 화면에서 필요한 크기만 로딩

원본 이미지 전체를 쓸 필요는 거의 없음.

 

5. 정리

  • 이미지 로딩은 성능에 매우 큰 영향을 주므로 설계가 중요하다.
  • 리사이징은 백그라운드에서 처리해야 하며 body 내부에서는 절대로 실행하면 안 된다.
  • 메모리 캐시(NSCache) + 네트워크 캐시(URLCache)를 함께 사용하면 성능이 크게 향상된다.
  • AsyncImage 대신 CachedAsyncImage 패턴을 사용하면 깜빡임 없는 안정적인 이미지 로딩을 구현할 수 있다.
반응형
Posted by 까칠코더
,