Swift - 대용량 이미지 리스트 성능 최적화

Marble·2026년 4월 15일

개발을 하다보면 리스트에 대량의 이미지를 보여줘야 할 때가 있잖아요? 이번에는 그 때 최적화 하는 방법에 대해 알아볼게요.

리스트 성능 최적화의 본질은 재사용/가상화 + 비동기 로딩 + 캐시 + 다운샘플링 + 메인 스레드 경량화에요.

1. 셀 재사용

// UIKit
final class PhotoCell: UICollectionViewCell {
    static let reuseID = "PhotoCell"

    @IBOutlet private weak var imageView: UIImageView!
    private var loadTask: Task<Void, Never>?

    override func prepareForReuse() {
        super.prepareForReuse()
        loadTask?.cancel()
        loadTask = nil
        imageView.image = UIImage(named: "placeholder")
    }

    func configure(url: URL, loader: ImageLoader) {
        loadTask?.cancel()
        loadTask = Task {
            let image = try? await loader.image(for: url)
            guard !Task.isCancelled else { return }
            imageView.image = image
        }
    }
}

UIKit은 재사용을 직접 다뤄요. prepareForReuse()에서 이전 상태 초기화와 요청 취소를 명시해야 하죠. 반면에 SwiftUI는 prepareForReuse()가 없어서 List/LazyVStack의 가상화에 맡기고, task(id:) + onDisappear로 로딩 생명주기를 관리해요

2. 비동기 이미지 로딩

// UIKit
actor ImageLoader {
    func image(for url: URL) async throws -> UIImage {
        let (data, _) = try await URLSession.shared.data(from: url)
        guard let image = UIImage(data: data) else { throw URLError(.cannotDecodeContentData) }
        return image
    }
}

// SwiftUI
.task(id: url) { await vm.load(url) }
.onDisappear { vm.cancel() }

UIKit은 URLSession/Task를 셀과 묶어 관리하는데 재사용 시점 취소가 핵심이에요. SwiftUI는 보통 ObservableObject 로더를 두고, 뷰 생명주기에 맞춰 로딩/취소를 처리합니다

3. 메모리/디스크 캐시

final class ImageCache {
    static let shared = ImageCache()
    private let memory = NSCache<NSURL, UIImage>()
    private let fm = FileManager.default

    func image(for url: URL) -> UIImage? {
        if let mem = memory.object(forKey: url as NSURL) { return mem }
        let path = diskPath(for: url)
        if let data = try? Data(contentsOf: path), let img = UIImage(data: data) {
            memory.setObject(img, forKey: url as NSURL)
            return img
        }
        return nil
    }

    func store(_ image: UIImage, for url: URL) {
        memory.setObject(image, forKey: url as NSURL)
        let path = diskPath(for: url)
        if let data = image.jpegData(compressionQuality: 0.85) {
            try? data.write(to: path, options: .atomic)
        }
    }

    private func diskPath(for url: URL) -> URL {
        let caches = fm.urls(for: .cachesDirectory, in: .userDomainMask)[0]
        return caches.appendingPathComponent(url.lastPathComponent)
    }
}

UIKit/SwiftUI 모두 동일하게 NSCache + 디스크 캐시 조합이 정석이에요. 메모리 캐시는 즉시 응답, 디스크 캐시는 앱 재실행 이후 재사용에 유리해요. 캐시 정책은 LRU, TTL, 메모리 경고 대응이 중요합니다.

4. 프리페치

// UIKit
final class FeedVC: UIViewController, UICollectionViewDataSourcePrefetching {
    var urls: [URL] = []
    let loader = ImageLoader()
    var prefetchTasks: [IndexPath: Task<Void, Never>] = [:]

    func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) {
        for ip in indexPaths {
            let url = urls[ip.item]
            prefetchTasks[ip] = Task { _ = try? await loader.image(for: url) }
        }
    }

    func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) {
        for ip in indexPaths {
            prefetchTasks[ip]?.cancel()
            prefetchTasks[ip] = nil
        }
    }
}

// SwiftUI

// View
ForEach(vm.items) { item in
    Row(item: item)
        .onAppear {
            Task { await vm.prefetchIfNeeded(current: item) }
        }
}

// ViewModel
func prefetchIfNeeded(current item: Item) async {
    guard let idx = items.firstIndex(of: item) else { return }
    let threshold = 8
    if idx >= items.count - threshold {
        await loadMore() // 다음 페이지 미리 로드
    }
}

func loadMore() async {
    guard !isLoading else { return }
    isLoading = true
    defer { isLoading = false }
    items += await api.fetchNextPage()
}

UIKit은 UITableViewDataSourcePrefetching/UICollectionViewDataSourcePrefetching를 공식 지원해요. 하지만 SwiftUI는 동일 API가 직접적이지 않아, onAppear 기반의 선행 로딩 윈도우를 구현하는 방식이 일반적이에요

5. 다운샘플링

import ImageIO

func downsampledImage(from data: Data, to pointSize: CGSize, scale: CGFloat = UIScreen.main.scale) -> UIImage? {
    let maxDimension = max(pointSize.width, pointSize.height) * scale
    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,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: Int(maxDimension)
    ]

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

let image = downsampledImage(from: data, to: CGSize(width: 120, height: 120))

UIKit, SwiftUI 모두에서 사용 가능한 방식으로 원본 이미지를 그대로 디코딩하지 않고, 필요한 표시 크기(예: 120x120)에 맞는 썸네일로 바로 디코딩하는 방식이에요.

6. 오프스크린 렌더링 최소화

// UIKit
final class CardCell: UICollectionViewCell {
    private let shadowContainer = UIView()
    private let roundedContent = UIView()

    override init(frame: CGRect) {
        super.init(frame: frame)
        contentView.addSubview(shadowContainer)
        shadowContainer.addSubview(roundedContent)
        // layout code...
        roundedContent.layer.cornerRadius = 12
        roundedContent.layer.masksToBounds = true

        shadowContainer.layer.shadowColor = UIColor.black.cgColor
        shadowContainer.layer.shadowOpacity = 0.15
        shadowContainer.layer.shadowRadius = 8
        shadowContainer.layer.shadowOffset = .init(width: 0, height: 4)
    }
    required init?(coder: NSCoder) { fatalError() }

    override func layoutSubviews() {
        super.layoutSubviews()
        shadowContainer.frame = contentView.bounds
        roundedContent.frame = shadowContainer.bounds
        shadowContainer.layer.shadowPath = UIBezierPath(roundedRect: shadowContainer.bounds, cornerRadius: 12).cgPath
    }
}

// SwiftUI
struct FastCard: View {
    var body: some View {
        RoundedRectangle(cornerRadius: 12, style: .continuous)
            .fill(Color.white)
            .overlay(
                VStack(alignment: .leading, spacing: 6) {
                    Text("Title").font(.headline)
                    Text("Subtitle").font(.subheadline).foregroundStyle(.secondary)
                }
                .padding(12)
            )
            // 가벼운 shadow 1회만 사용 (과도한 blur/중첩 효과 지양)
            .shadow(color: .black.opacity(0.10), radius: 4, x: 0, y: 2)
            .padding(.horizontal, 16)
    }
}

UIKit은 레이어 속성(shadow, masksToBounds)을 직접 제어해 튜닝해요. SwiftUI는 .shadow, .mask, .clipShape, .drawingGroup() 사용 시 렌더 비용을 의식해야 합니다.
핵심은 “효과를 줄이는 것”이 아니라 “비싼 효과를 필요한 곳에만 쓰는 것”이죠

7. 레이아웃 계산 최적화

// UIKit
let layout = UICollectionViewFlowLayout()
layout.itemSize = CGSize(width: UIScreen.main.bounds.width, height: 96) // 고정 높이
layout.minimumLineSpacing = 8
layout.estimatedItemSize = .zero // 자동 계산 끄기
let collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)

// SwiftUI
struct OptimizedList: View {
    let items: [Item]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 8) {                 // 보이는 셀만 생성
                ForEach(items) { item in
                    Row(item: item)
                        .frame(height: 88)           // 고정 높이로 레이아웃 계산 단순화
                }
            }
            .padding(.horizontal, 16)
        }
    }
}

UIKit은 고정/예측 가능한 셀 크기와 단순 제약이 중요하고 SwiftUI는 뷰 계층 깊이, 복잡한 GeometryReader, 과도한 상태 변경을 줄여야 해요.
공통 포인트는 “스크롤 중 레이아웃 재계산을 최소화”입니다

8. 메인 스레드 작업 최소화

// UIKit
func configureCell(_ cell: PhotoCell, url: URL) {
    Task.detached(priority: .userInitiated) {
        let (data, _) = try await URLSession.shared.data(from: url)
        let image = downsampledImage(from: data, to: CGSize(width: 120, height: 120))
        await MainActor.run {
            cell.setImage(image) // UI 반영만 메인에서
        }
    }
}

// SwiftUI
Task.detached {
    let image = decode(data)
    await MainActor.run { self.image = image } // UI 반영만 메인에서
}

UIKit/SwiftUI 모두 동일해요. 메인 스레드는 터치/스크롤/렌더링에 집중시키고, 데이터 가공·디코딩은 백그라운드로 보내는거죠

비교



위에가 최적화 적용했을 때 Time Profiler가 수집한 CPU 실행 시간 가중치로 최적화 안 했을 때보다 2배는 더 빨라요

다음은 Persistent, 측정 시점까지 살아남아 있는(아직 해제되지 않은) 메모리 양을 비교할거에요

All Heap & Anonymous VM 기준으로 보면 Heap + 익명 VM 전체 중 현재도 점유 중인 메모리 크기 즉 쉽게 말해, Persistent가 크면 “지금도 계속 물고 있는 메모리”가 많다는 뜻입니다. 반대로 Persistent 낮으면 현재 점유 메모리 적다는 것을 의미하기 때문에 메모리 압박, OOM, 스크롤 끊김 위험이 감소하죠. 최적화를 적용했을 때 Persistent(11.10 MiB)가 안 했을 때 Persistent(896.14 MiB) 기준으로 98%가량 줄어들었어요

마지막으로 FPS를 볼거에요.


둘의 시간대가 살짝 다르긴 하지만 시간대별로 측정했을 때 최대값을 비교하면 최적화 했을 때 평균프레임이 더 높죠? 프레임이 높을수록 화면이 부드럽고, 낮으면 스크롤/애니메이션이 끊겨 보이죠.
이처럼 최적화를 하고 안하고의 차이가 크니 이미지 작업을 할 때는 최적화를 신경써야 할거 같아요.

다음은 비교를 위해 사용한 코드를 보여드리고 마칠게요

공용 코드

import SwiftUI

struct PhotoItem: Identifiable {
    let id = UUID()
    let title: String
    let url: URL
}

struct ImageTestView: View {
    let items: [PhotoItem] = (1...300).map { i in
        PhotoItem(
            title: "Image \(i)",
            url: URL(string: "https://picsum.photos/id/\(i % 1000)/4000/3000")! // 대용량
        )
    }
    @State var isFast: Bool = true
    var body: some View {
        //            FastListView(items: items) // 주석 해제해서 비교함
        SlowListView(items: items)
    }
}

최적화 적용한 코드

import SwiftUI
import ImageIO

struct FastListView: View {
    let items: [PhotoItem]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 8) { // 보이는 셀만 생성
                ForEach(items) { item in
                    FastRow(item: item)
                        .frame(height: 88) // 고정 높이로 레이아웃 계산 비용 감소
                }
            }
            .padding(.horizontal, 16)
        }
    }
}

struct FastRow: View {
    let item: PhotoItem
    @State private var image: UIImage?
    @State private var task: Task<Void, Never>?

    var body: some View {
        HStack(spacing: 12) {
            Group {
                if let image {
                    Image(uiImage: image).resizable().scaledToFill()
                } else {
                    Color.gray.opacity(0.15)
                }
            }
            .frame(width: 72, height: 72)
            .clipped()

            Text(item.title).lineLimit(1)
            Spacer()
        }
        .task(id: item.url) {
            task?.cancel()
            task = Task {
                do {
                    if let cached = ImageCache.shared[item.url] {
                        await MainActor.run { image = cached }
                        return
                    }

                    let (data, _) = try await URLSession.shared.data(from: item.url)
                    guard !Task.isCancelled else { return }

                    if let downsampled = downsample(data: data, to: CGSize(width: 72, height: 72)) {
                        ImageCache.shared[item.url] = downsampled
                        await MainActor.run { image = downsampled }
                    }
                } catch {
                    // 필요 시 로그 처리
                }
            }
        }
        .onDisappear {
            task?.cancel() // 화면 이탈 시 작업 취소
        }
    }
}

// 아주 단순한 메모리 캐시
final class ImageCache {
    static let shared = ImageCache()
    private let cache = NSCache<NSURL, UIImage>()
    subscript(url: URL) -> UIImage? {
        get { cache.object(forKey: url as NSURL) }
        set {
            if let newValue { cache.setObject(newValue, forKey: url as NSURL) }
            else { cache.removeObject(forKey: url as NSURL) }
        }
    }
}

func downsample(data: Data, to size: CGSize, scale: CGFloat = UIScreen.main.scale) -> UIImage? {
    let maxPixel = max(size.width, size.height) * scale
    guard let src = CGImageSourceCreateWithData(data as CFData, nil) else { return nil }

    let opts: [CFString: Any] = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceThumbnailMaxPixelSize: Int(maxPixel)
    ]

    guard let cg = CGImageSourceCreateThumbnailAtIndex(src, 0, opts as CFDictionary) else { return nil }
    return UIImage(cgImage: cg)
}

최적화 적용 안한 코드

struct SlowListView: View {
    let items: [PhotoItem]

    var body: some View {
        ScrollView {
            LazyVStack(spacing: 12) { // Lazy 아님: 모든 뷰 생성
                ForEach(items) { item in
                    SlowRow(item: item)
                }
            }
            .padding()
        }
    }
}

struct SlowRow: View {
    let item: PhotoItem
    @State private var image: UIImage?

    var body: some View {
        HStack(spacing: 12) {
            Group {
                if let image {
                    Image(uiImage: image).resizable().scaledToFill()
                } else {
                    Color.gray.opacity(0.2)
                }
            }
            .frame(width: 100, height: 100) // 큰 셀
            .clipped()

            Text(item.title)
            Spacer()
        }
        .onAppear {
            // 의도적 안티패턴: 메인스레드 동기 로딩 + 원본 그대로 디코딩
            if let data = try? Data(contentsOf: item.url),
               let uiImage = UIImage(data: data) {
                image = uiImage
            }
        }
    }
}
profile
개발자가 되고 싶은 공돌이

0개의 댓글