SwiftUI 이미지 로딩과 캐싱 완벽 가이드 — AsyncImage, URLCache, 이미지 다운로드와 사진 앱 저장

iPhone 앱을 개발하다 보면 서버에서 이미지를 다운로드해 화면에 표시해야 하는 경우가 많습니다.

생물도감 앱에서는 동물이나 식물의 사진을 표시하고, 관광지 안내 앱에서는 장소 이미지를 보여주며, 게시판 앱에서는 게시글의 썸네일을 로딩합니다.

Android에서는 이러한 기능을 구현할 때 Glide나 Coil을 사용하는 경우가 많습니다.

iOS에서는 SwiftUI의 AsyncImage를 사용하거나 URLSession, URLCache, NSCache 등을 조합해 직접 이미지 로딩 기능을 구현할 수 있습니다.

하지만 이미지를 단순히 다운로드하는 것과 실제 서비스에서 안정적으로 처리하는 것은 다릅니다.

예를 들어 다음과 같은 문제가 발생할 수 있습니다.

  • 이미지 로딩이 느림

  • 목록을 스크롤할 때 이미지가 반복 다운로드됨

  • 이미지가 많아지면서 메모리 사용량이 증가함

  • 네트워크 연결이 끊기면 이미지가 표시되지 않음

  • 원본 이미지를 불필요하게 큰 크기로 디코딩함

  • 이미지 저장 시 사진 접근 권한 문제가 발생함

이번 글에서는 이러한 문제를 해결하기 위한 이미지 로딩 구조를 단계별로 구현하겠습니다.

개발 환경

  • Xcode

  • Swift

  • SwiftUI

  • iOS 17 이상

  • Swift Concurrency

  • URLSession

  • URLCache

  • NSCache

  • Photos 프레임워크


1. Android와 iOS 이미지 처리 비교

먼저 Android에서 사용하는 이미지 처리 방식과 iOS를 비교해 보겠습니다.

AndroidiOS / SwiftUI역할
Glide / CoilAsyncImage 또는 이미지 라이브러리이미지 로딩
OkHttp CacheURLCacheHTTP 응답 캐싱
LruCacheNSCache메모리 캐싱
BitmapUIImage / CGImage이미지 데이터
RecyclerViewList / LazyVStack이미지 목록
MediaStorePhotos 프레임워크사진 앱 저장
ActivityResultContractsPhotosPicker 등사진 선택
BitmapFactoryImageIO이미지 디코딩

이 표는 기능적인 비교입니다.

Android의 Glide와 SwiftUI의 AsyncImage는 기능 범위가 동일하지 않습니다.

특히 Glide는 메모리 캐시, 디스크 캐시, 이미지 변환, 요청 생명주기 관리 등 다양한 기능을 제공합니다.

반면 AsyncImage는 비교적 간단한 원격 이미지 표시 기능에 초점을 맞추고 있습니다.


AsyncImage 사용하기

2. AsyncImage란?

AsyncImage는 URL을 이용해 원격 이미지를 비동기적으로 표시하는 SwiftUI View입니다.

별도의 이미지 다운로드 코드를 작성하지 않고도 간단하게 사용할 수 있습니다.

AsyncImage(
    url: URL(
        string: "https://example.com/image.jpg"
    )
)

다만 이미지의 크기, 로딩 상태, 실패 상태 등을 고려하면 조금 더 구체적으로 작성하는 것이 좋습니다.


3. 이미지 크기 지정

AsyncImage(
    url: URL(
        string: "https://example.com/image.jpg"
    )
) { image in

    image
        .resizable()
        .scaledToFill()

} placeholder: {

    ProgressView()
}
.frame(width: 120, height: 120)
.clipped()

resizable()은 이미지를 지정된 영역에 맞게 조정할 수 있도록 합니다.

scaledToFill()은 이미지의 비율을 유지하면서 지정된 영역을 채웁니다.

clipped()는 영역 밖으로 벗어난 부분을 잘라냅니다.


AsyncImage 로딩 상태 처리

4. AsyncImagePhase

AsyncImage는 로딩 상태를 구분할 수 있도록 AsyncImagePhase를 제공합니다.

대표적인 상태는 다음과 같습니다.

상태의미
empty이미지 로딩 전 또는 진행 중
success이미지 로딩 성공
failure이미지 로딩 실패

다음과 같이 사용할 수 있습니다.

AsyncImage(
    url: imageURL
) { phase in

    switch phase {

    case .empty:

        ProgressView()

    case .success(let image):

        image
            .resizable()
            .scaledToFill()

    case .failure:

        Image(systemName: "photo")
            .foregroundStyle(.secondary)

    @unknown default:

        EmptyView()
    }
}
.frame(width: 120, height: 120)
.clipped()

이렇게 하면 이미지가 없는 경우에도 기본 아이콘을 표시할 수 있습니다.


AsyncImage의 한계

5. 이미지 캐싱은 어떻게 동작할까?

AsyncImage는 내부적으로 이미지 다운로드를 처리하지만, Glide처럼 개발자가 메모리 캐시와 디스크 캐시를 세밀하게 제어할 수 있는 API를 제공하지 않습니다.

또한 AsyncImage가 특정 이미지를 항상 디스크에 저장하거나 앱을 다시 실행한 뒤에도 캐시를 재사용한다고 보장해서는 안 됩니다.

HTTP 응답 헤더, 시스템 캐시 정책, 요청 환경 등에 따라 동작이 달라질 수 있습니다.

따라서 이미지 캐싱을 명확하게 제어해야 한다면 별도의 이미지 로더를 구현하거나 검증된 이미지 라이브러리를 사용하는 것이 좋습니다.


URLSession으로 이미지 다운로드

6. 이미지 다운로드 함수

이번에는 URLSession을 이용해 이미지를 직접 다운로드하겠습니다.

import Foundation
import UIKit

func downloadImage(
    from url: URL
) async throws -> UIImage {

    let (data, response) = try await URLSession.shared.data(
        from: url
    )

    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {

        throw URLError(.badServerResponse)
    }

    guard let image = UIImage(data: data) else {
        throw URLError(.cannotDecodeContentData)
    }

    return image
}

이 함수는 이미지를 다운로드하고 UIImage로 변환합니다.

다만 큰 이미지의 디코딩은 메모리와 CPU를 많이 사용할 수 있으므로 실제 서비스에서는 이미지 크기 제한과 다운샘플링을 고려해야 합니다.


URLCache 이해하기

7. URLCache란?

URLCache는 URLSession에서 사용할 수 있는 HTTP 응답 캐시입니다.

메모리와 디스크에 응답 데이터를 저장할 수 있습니다.

하지만 모든 응답이 캐시되는 것은 아닙니다.

캐시 가능 여부는 다음 요소의 영향을 받습니다.

  • HTTP Cache-Control

  • Expires

  • ETag

  • Last-Modified

  • URLRequest의 캐시 정책

  • URLSessionConfiguration

  • 응답의 캐시 가능 여부

따라서 URLCache 용량을 설정했다고 해서 모든 이미지가 자동으로 저장되는 것은 아닙니다.


8. URLCache 설정

다음은 메모리 캐시 50MB, 디스크 캐시 200MB를 설정하는 예제입니다.

import Foundation

let cache = URLCache(
    memoryCapacity: 50 * 1024 * 1024,
    diskCapacity: 200 * 1024 * 1024,
    diskPath: "ImageCache"
)

let configuration = URLSessionConfiguration.default

configuration.urlCache = cache

configuration.requestCachePolicy =
    .useProtocolCachePolicy

let session = URLSession(
    configuration: configuration
)

이 설정은 캐시 저장 공간의 상한을 지정하는 것입니다.

실제 캐시 사용량이나 저장되는 응답은 시스템 및 서버의 캐시 정책에 따라 달라질 수 있습니다.


HTTP 캐시 헤더

9. Cache-Control 이해하기

서버에서 다음과 같은 헤더를 반환한다고 가정하겠습니다.

Cache-Control: public, max-age=86400

이 헤더는 공유 캐시에서도 사용할 수 있고, 응답을 일정 시간 동안 신선한 것으로 간주할 수 있음을 나타냅니다.

86400초는 24시간입니다.

반면 다음과 같은 헤더가 있다면 저장을 제한합니다.

Cache-Control: no-store

이미지 캐싱은 앱 코드뿐 아니라 서버의 HTTP 응답 정책도 중요합니다.

특히 직접 운영하는 서버에서 이미지를 제공한다면 Cache-Control과 ETag를 함께 검토하는 것이 좋습니다.


NSCache 메모리 캐싱

10. NSCache란?

NSCache는 메모리에 객체를 캐싱할 때 사용하는 Foundation 클래스입니다.

이미지를 반복해서 표시하는 앱에서는 디코딩된 UIImage를 메모리에 저장하는 용도로 활용할 수 있습니다.

NSCache는 메모리 압박 상황에서 항목을 제거할 수 있습니다.

따라서 NSCache에 저장된 이미지가 계속 유지된다고 가정해서는 안 됩니다.


11. 이미지 메모리 캐시 구현

import UIKit

final class ImageMemoryCache {

    static let shared = ImageMemoryCache()

    private let cache = NSCache<NSString, UIImage>()

    private init() {

        cache.countLimit = 100
        cache.totalCostLimit = 50 * 1024 * 1024
    }

    func image(for key: String) -> UIImage? {

        cache.object(
            forKey: key as NSString
        )
    }

    func insert(
        _ image: UIImage,
        for key: String
    ) {

        let pixelWidth = image.cgImage?.width ?? 0
        let pixelHeight = image.cgImage?.height ?? 0

        let estimatedCost =
            pixelWidth * pixelHeight * 4

        cache.setObject(
            image,
            forKey: key as NSString,
            cost: estimatedCost
        )
    }

    func removeAll() {
        cache.removeAllObjects()
    }
}

여기서 estimatedCost는 일반적인 4바이트 픽셀 형식을 가정한 대략적인 메모리 비용입니다.

실제 UIImage의 메모리 사용량은 이미지 표현 방식에 따라 달라질 수 있습니다.


이미지 로더 설계

12. 이미지 로딩 구조

실제 앱에서는 다음과 같은 구조를 고려할 수 있습니다.

SwiftUI View
     │
     ▼
ImageLoader
     │
     ├── NSCache
     │
     └── URLSession
             │
             ▼
          URLCache
             │
             ▼
         Image Server

먼저 메모리 캐시를 확인합니다.

캐시에 이미지가 없다면 URLSession을 이용해 다운로드합니다.

다운로드가 완료되면 이미지 디코딩 후 메모리 캐시에 저장합니다.


13. ImageLoader 구현

다음은 학습용으로 사용할 수 있는 간단한 이미지 로더입니다.

ImageLoader.swift

import Foundation
import UIKit

@MainActor
final class ImageLoader {

    static let shared = ImageLoader()

    private let cache = ImageMemoryCache.shared

    private let session: URLSession

    private init() {

        let configuration =
            URLSessionConfiguration.default

        configuration.urlCache = URLCache(
            memoryCapacity: 50 * 1024 * 1024,
            diskCapacity: 200 * 1024 * 1024
        )

        configuration.requestCachePolicy =
            .useProtocolCachePolicy

        session = URLSession(
            configuration: configuration
        )
    }

    func loadImage(
        from url: URL
    ) async throws -> UIImage {

        let key = url.absoluteString

        if let cachedImage = cache.image(
            for: key
        ) {
            return cachedImage
        }

        let (data, response) = try await session.data(
            from: url
        )

        try Task.checkCancellation()

        guard let httpResponse = response as? HTTPURLResponse,
              (200...299).contains(httpResponse.statusCode) else {

            throw URLError(.badServerResponse)
        }

        guard let image = UIImage(data: data) else {
            throw URLError(.cannotDecodeContentData)
        }

        try Task.checkCancellation()

        cache.insert(
            image,
            for: key
        )

        return image
    }
}

이 예제는 메모리 캐시와 HTTP 캐시를 결합한 기본 구조입니다.

다만 실제 서비스용 이미지 로더라면 다음 기능도 검토해야 합니다.

  • 동일 URL 중복 요청 병합

  • 이미지 크기 제한

  • 다운샘플링

  • 디코딩 작업 분리

  • 요청 우선순위

  • 요청 취소

  • 캐시 만료 정책

  • 오류 재시도

  • 이미지 변환별 캐시 키 관리


SwiftUI 이미지 로딩 View

14. CachedRemoteImage 구현

CachedRemoteImage.swift

import SwiftUI

struct CachedRemoteImage: View {

    let url: URL?

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

    var body: some View {

        Group {

            if let image {

                Image(uiImage: image)
                    .resizable()
                    .scaledToFill()

            } else if isLoading {

                ProgressView()

            } else {

                Image(systemName: "photo")
                    .foregroundStyle(.secondary)
            }
        }
        .task(id: url) {

            image = nil

            guard let url else {
                isLoading = false
                return
            }

            isLoading = true

            defer {
                isLoading = false
            }

            do {

                image = try await ImageLoader.shared.loadImage(
                    from: url
                )

            } catch is CancellationError {

                // 화면 변경으로 취소된 경우

            } catch {

                image = nil
            }
        }
    }
}

.task(id:)를 사용하면 URL이 변경될 때 기존 작업을 취소하고 새 이미지 로딩 작업을 시작할 수 있습니다.

다만 네트워크 요청 취소와 디코딩 작업의 중단 시점은 내부 구현에 따라 달라질 수 있습니다.


SwiftUI List에서 이미지 표시

15. 이미지 목록 만들기

생물도감이나 관광지 안내 앱에서는 여러 이미지를 목록으로 표시해야 합니다.

다음과 같은 모델을 사용하겠습니다.

struct PhotoItem: Identifiable {

    let id: Int
    let title: String
    let imageURL: URL?
}

목록 화면은 다음과 같이 구현할 수 있습니다.

import SwiftUI

struct PhotoListView: View {

    let items: [PhotoItem]

    var body: some View {

        NavigationStack {

            List(items) { item in

                HStack(spacing: 12) {

                    CachedRemoteImage(
                        url: item.imageURL
                    )
                    .frame(width: 80, height: 80)
                    .clipped()
                    .clipShape(
                        RoundedRectangle(
                            cornerRadius: 8
                        )
                    )

                    Text(item.title)
                        .font(.headline)
                }
            }
            .navigationTitle("사진 목록")
        }
    }
}

이 구조에서는 각 목록 항목이 이미지 URL을 기반으로 이미지를 로딩합니다.


이미지 재사용 문제

16. 스크롤 중 잘못된 이미지 표시 방지

목록을 빠르게 스크롤할 때 이전 항목의 이미지가 다른 항목에 표시되는 문제가 발생할 수 있습니다.

이러한 문제는 이미지 요청과 화면 항목의 식별이 제대로 연결되지 않았을 때 발생합니다.

이번 예제에서는 다음 코드를 사용했습니다.

.task(id: url) {
    // 이미지 로딩
}

URL이 변경되면 이전 작업의 취소가 요청되고 새로운 작업이 시작됩니다.

하지만 취소된 작업이 늦게 완료될 가능성까지 고려하려면 결과를 적용하기 전에 현재 요청과 일치하는지 확인하는 설계가 더 안전합니다.

실제 서비스에서는 요청별 식별자나 이미지 로딩 상태 객체를 이용하는 방법을 검토할 수 있습니다.


이미지 다운샘플링

17. 원본 이미지가 너무 큰 경우

예를 들어 6000×4000 크기의 사진을 다운로드했다고 가정하겠습니다.

픽셀 수는 다음과 같습니다.

6000 × 4000 = 24,000,000 pixels

RGBA 4바이트 형식으로 완전히 디코딩하면 픽셀 버퍼만 대략 다음과 같은 메모리가 필요할 수 있습니다.

24,000,000 × 4
= 96,000,000 bytes
≈ 91.6 MiB

목록에서 80×80 크기의 썸네일만 표시한다면 원본 전체를 디코딩할 필요가 없습니다.

이때 다운샘플링을 사용할 수 있습니다.


18. ImageIO로 다운샘플링

import Foundation
import UIKit
import ImageIO

func downsampleImage(
    data: Data,
    maxPixelSize: Int
) -> UIImage? {

    let sourceOptions: [CFString: Any] = [
        kCGImageSourceShouldCache: false
    ]

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

    let options: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxPixelSize,
        kCGImageSourceShouldCacheImmediately: true
    ]

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

    return UIImage(cgImage: cgImage)
}

이 함수는 이미지 데이터를 지정한 최대 픽셀 크기에 맞춰 디코딩하는 데 사용할 수 있습니다.

실제 목록에서는 화면의 표시 크기와 디스플레이 배율을 고려해 maxPixelSize를 결정하는 것이 좋습니다.


썸네일과 원본 분리

19. 서버에서 썸네일 제공하기

가능하다면 서버에서 원본 이미지와 썸네일을 별도로 제공하는 것이 좋습니다.

예를 들어 다음과 같은 구조입니다.

images/
 ├── original/
 │    └── bird_001.jpg
 │
 └── thumbnail/
      └── bird_001.webp

목록 화면에서는 썸네일을 사용합니다.

상세 화면에서는 원본 이미지를 사용합니다.

이렇게 하면 네트워크 전송량과 메모리 사용량을 줄일 수 있습니다.


이미지 파일 다운로드

20. URLSession.download 사용

이미지를 메모리에 올리지 않고 파일로 다운로드하려면 URLSession.download를 사용할 수 있습니다.

import Foundation

func downloadImageFile(
    from url: URL
) async throws -> URL {

    let (temporaryURL, response) =
        try await URLSession.shared.download(
            from: url
        )

    guard let httpResponse = response as? HTTPURLResponse,
          (200...299).contains(httpResponse.statusCode) else {

        throw URLError(.badServerResponse)
    }

    let destination = FileManager.default
        .urls(
            for: .cachesDirectory,
            in: .userDomainMask
        )[0]
        .appendingPathComponent(
            UUID().uuidString + ".jpg"
        )

    try FileManager.default.moveItem(
        at: temporaryURL,
        to: destination
    )

    return destination
}

URLSession이 제공하는 임시 다운로드 파일은 필요한 경우 앱 내부 저장소로 이동해야 합니다.

또한 이 예제는 JPEG 파일이라고 가정해 확장자를 지정했습니다.

실제 앱에서는 서버의 Content-Type이나 파일 형식을 확인해 적절한 확장자를 사용해야 합니다.


앱 내부 저장소 이해하기

21. Documents와 Caches 차이

iOS 앱에서는 목적에 따라 저장 위치를 구분해야 합니다.

디렉터리용도
Documents사용자가 보존해야 하는 문서와 데이터
Library/Application Support앱의 영속적인 지원 데이터
Library/Caches다시 생성하거나 다운로드할 수 있는 캐시
tmp임시 파일

중요한 사용자 사진이나 편집 결과물을 Caches에만 저장하면 안 됩니다.

시스템이 저장 공간 확보를 위해 캐시 파일을 삭제할 수 있기 때문입니다.


사진 앱에 이미지 저장

22. Photos 프레임워크

다운로드한 이미지를 iPhone의 사진 앱에 저장하려면 Photos 프레임워크를 사용할 수 있습니다.

import Photos

사진 저장 기능은 사용자가 명시적으로 요청했을 때 실행하는 것이 좋습니다.

또한 사진 라이브러리 접근 권한과 개인정보 안내 문구를 올바르게 구성해야 합니다.


23. Info.plist 권한 설정

사진 앱에 이미지를 추가하는 기능만 필요한 경우 다음 권한 설명 키를 사용할 수 있습니다.

<key>NSPhotoLibraryAddUsageDescription</key>
<string>다운로드한 이미지를 사진 앱에 저장하기 위해 사용합니다.</string>

사진 라이브러리에서 기존 사진을 읽거나 관리하는 기능이 필요하다면 별도의 권한 요구사항을 검토해야 합니다.

사진을 선택하기만 하는 경우에는 시스템 PhotosPicker를 이용해 전체 사진 라이브러리 접근 권한 없이 구현할 수도 있습니다.


사진 저장 권한 요청

24. Add Only 권한

사진을 추가하기만 하는 앱에서는 .addOnly 접근 수준을 사용할 수 있습니다.

import Photos

func requestPhotoAddPermission() async -> PHAuthorizationStatus {

    await PHPhotoLibrary.requestAuthorization(
        for: .addOnly
    )
}

권한 요청 결과에 따라 저장 기능을 진행할지 결정해야 합니다.


UIImage 사진 앱 저장

25. PHPhotoLibrary 사용

import UIKit
import Photos

func saveImageToPhotos(
    _ image: UIImage
) async throws {

    let status = await PHPhotoLibrary.requestAuthorization(
        for: .addOnly
    )

    guard status == .authorized else {
        throw NSError(
            domain: "PhotoSave",
            code: 1,
            userInfo: [
                NSLocalizedDescriptionKey:
                    "사진 저장 권한이 없습니다."
            ]
        )
    }

    try await PHPhotoLibrary.shared().performChanges {

        PHAssetChangeRequest.creationRequestForAsset(
            from: image
        )
    }
}

이 함수는 UIImage를 사진 라이브러리에 추가하는 기본 예제입니다.

실제 앱에서는 저장 성공과 실패 결과를 사용자에게 표시해야 합니다.

또한 원본 사진의 EXIF나 GPS 메타데이터를 보존해야 한다면 UIImage로 다시 저장하는 방식 대신 원본 파일 리소스를 이용하는 방식을 검토해야 합니다.


원본 사진 메타데이터

26. EXIF 정보 보존

사진에는 다음과 같은 메타데이터가 포함될 수 있습니다.

  • 촬영 날짜

  • 카메라 모델

  • 렌즈 정보

  • 노출 정보

  • GPS 좌표

  • 이미지 방향

  • 저작권 정보

UIImage로 디코딩한 뒤 다시 인코딩하면 원본 메타데이터가 그대로 유지되지 않을 수 있습니다.

따라서 사진의 GPS 정보나 촬영 정보를 보존해야 하는 앱이라면 원본 파일을 그대로 저장하는 것이 좋습니다.


27. 원본 파일을 사진 앱에 저장

다운로드한 파일이 유효한 이미지 파일이라면 다음과 같은 방식으로 저장할 수 있습니다.

import Photos

func saveOriginalFileToPhotos(
    fileURL: URL
) async throws {

    let status = await PHPhotoLibrary.requestAuthorization(
        for: .addOnly
    )

    guard status == .authorized else {
        throw NSError(
            domain: "PhotoSave",
            code: 2,
            userInfo: [
                NSLocalizedDescriptionKey:
                    "사진 저장 권한이 없습니다."
            ]
        )
    }

    try await PHPhotoLibrary.shared().performChanges {

        let request = PHAssetCreationRequest.forAsset()

        request.addResource(
            with: .photo,
            fileURL: fileURL,
            options: nil
        )
    }
}

이 방식은 원본 파일 리소스를 Photos에 전달합니다.

다만 파일 형식과 메타데이터가 모두 지원되는지, 실제 저장 후 정보가 유지되는지는 테스트해야 합니다.


이미지 저장 버튼 만들기

28. SwiftUI 저장 버튼

import SwiftUI

struct ImageSaveButton: View {

    let image: UIImage

    @State private var isSaving = false
    @State private var message: String?

    var body: some View {

        Button {

            Task {

                isSaving = true

                defer {
                    isSaving = false
                }

                do {

                    try await saveImageToPhotos(image)

                    message = "사진을 저장했습니다."

                } catch {

                    message = error.localizedDescription
                }
            }

        } label: {

            Label(
                "사진 저장",
                systemImage: "square.and.arrow.down"
            )
        }
        .disabled(isSaving)
        .alert(
            "사진 저장",
            isPresented: Binding(
                get: { message != nil },
                set: { if !$0 { message = nil } }
            )
        ) {

            Button("확인", role: .cancel) {}

        } message: {

            Text(message ?? "")
        }
    }
}

사용자가 저장 버튼을 누르면 사진 앱 저장 권한을 확인하고 저장 작업을 실행합니다.


이미지 캐시 정리

29. 메모리 캐시 삭제

이미지 캐시가 너무 많이 쌓이지 않도록 관리해야 합니다.

메모리 캐시는 다음과 같이 비울 수 있습니다.

ImageMemoryCache.shared.removeAll()

다만 캐시를 지나치게 자주 삭제하면 같은 이미지를 반복해서 다운로드하거나 디코딩하게 되어 오히려 성능이 떨어질 수 있습니다.


30. URLCache 삭제

URLCache.shared.removeAllCachedResponses()

이 코드는 URLCache.shared의 캐시를 삭제합니다.

별도로 생성한 URLCache를 사용하고 있다면 해당 캐시 인스턴스에서 삭제해야 합니다.


이미지 성능 최적화

31. 실제 서비스에서 확인할 사항

이미지 처리 성능을 개선하려면 다음 항목을 검토하는 것이 좋습니다.

  1. 원본 대신 썸네일 사용

  2. 필요한 이미지 크기로 다운샘플링

  3. NSCache 메모리 캐싱

  4. URLCache HTTP 캐싱

  5. 동일 이미지 중복 다운로드 방지

  6. 화면 이탈 시 요청 취소

  7. 이미지 URL 변경 시 상태 초기화

  8. 대용량 이미지 디코딩 최적화

  9. 메모리 압박 상황 대응

  10. 서버 캐시 헤더 설정

특히 이미지가 많은 생물도감이나 관광지 앱에서는 이미지 크기와 캐시 정책이 체감 성능에 큰 영향을 줍니다.


이미지 파일 형식 비교

32. JPEG, PNG, WebP, HEIC

형식특징
JPEG사진에 널리 사용
PNG무손실 압축 및 투명도 지원
WebP손실·무손실 압축 지원
HEIC고효율 이미지 저장에 활용
AVIF고효율 이미지 압축 형식

지원되는 이미지 형식과 디코딩 기능은 iOS 버전과 사용하는 프레임워크에 따라 달라질 수 있습니다.

특히 AVIF나 특수한 이미지 형식을 사용할 때는 실제 대상 iOS 버전에서 디코딩 가능 여부를 확인해야 합니다.


실전 적용 사례

33. 생물도감 앱 이미지 구조

생물도감 앱에서는 다음과 같은 구조를 사용할 수 있습니다.

SwiftUI List
     │
     ▼
CachedRemoteImage
     │
     ▼
ImageLoader
     │
     ├── NSCache
     │
     └── URLSession
             │
             ▼
          Image Server

목록 화면에서는 작은 썸네일을 표시합니다.

상세 화면에서는 원본 이미지를 다운로드합니다.

사용자가 사진 저장 버튼을 누르면 Photos 프레임워크를 통해 사진 앱에 저장합니다.


테스트 체크리스트

34. 출시 전 확인 사항

  • AsyncImage 정상 표시

  • 로딩 상태 표시

  • 이미지 실패 상태 표시

  • URLSession 이미지 다운로드

  • HTTP 상태 코드 검사

  • NSCache 동작 확인

  • URLCache 동작 확인

  • 서버 Cache-Control 확인

  • 목록 스크롤 테스트

  • 이미지 URL 변경 테스트

  • 요청 취소 테스트

  • 대용량 이미지 테스트

  • 다운샘플링 확인

  • 메모리 사용량 확인

  • 앱 내부 파일 저장

  • 사진 앱 저장 권한

  • 사진 저장 성공·실패 처리

  • EXIF 메타데이터 보존 확인

  • 오프라인 환경 테스트

  • 실제 iPhone 테스트


35. 마무리

SwiftUI에서는 AsyncImage를 이용해 원격 이미지를 간단하게 표시할 수 있습니다.

하지만 이미지가 많은 실제 앱에서는 이미지 캐싱, 다운로드 취소, 메모리 사용량, 파일 저장 등을 함께 고려해야 합니다.

이번 글의 핵심 내용을 정리하면 다음과 같습니다.

  1. AsyncImage는 간단한 원격 이미지 표시 기능을 제공합니다.

  2. URLSession으로 이미지 다운로드를 직접 구현할 수 있습니다.

  3. URLCache는 HTTP 응답 캐싱에 사용합니다.

  4. NSCache는 디코딩된 이미지의 메모리 캐싱에 활용할 수 있습니다.

  5. 서버의 Cache-Control 헤더도 캐싱 동작에 영향을 줍니다.

  6. 이미지 목록에서는 요청 취소와 항목 식별이 중요합니다.

  7. 큰 이미지는 다운샘플링을 고려해야 합니다.

  8. 앱 내부 저장소와 사진 앱 저장은 목적이 다릅니다.

  9. Photos 프레임워크로 사진 앱에 이미지를 추가할 수 있습니다.

  10. EXIF와 GPS 정보를 유지하려면 원본 파일 보존 방식을 검토해야 합니다.

Android의 Glide에 익숙한 개발자라면 iOS에서도 유사한 이미지 로딩 구조를 구현할 수 있습니다.

다만 SwiftUI의 AsyncImage만으로 Glide의 모든 기능을 대체할 수 있다고 생각해서는 안 됩니다.

이미지 처리 요구사항이 복잡하다면 Nuke나 Kingfisher 같은 검증된 이미지 라이브러리도 고려할 수 있습니다.

iOS 이미지 처리의 핵심은 이미지를 다운로드하는 것보다 필요한 크기의 이미지를 적절한 시점에 로딩하고, 메모리와 네트워크 사용량을 안정적으로 관리하는 것입니다.


참고 자료

태그: iOS, Swift, SwiftUI, AsyncImage, URLSession, URLCache, NSCache, UIImage, ImageIO, 이미지 캐싱, 이미지 다운로드, Photos, 사진 저장, EXIF, iPhone 앱 개발