SwiftUI Study – 비동기 이미지 로딩을 효율적으로 처리하고 캐싱·플레이스홀더·리사이즈 전략으로 성능 최적화하기
Dev Study/SwiftUI 2025. 12. 9. 10:45반응형
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(피드·갤러리·쇼핑·뉴스 앱)의 품질을 크게 향상시킨다.
반응형

