개발을 하다보면 리스트에 대량의 이미지를 보여줘야 할 때가 있잖아요? 이번에는 그 때 최적화 하는 방법에 대해 알아볼게요.
리스트 성능 최적화의 본질은 재사용/가상화 + 비동기 로딩 + 캐시 + 다운샘플링 + 메인 스레드 경량화에요.
// 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로 로딩 생명주기를 관리해요
// 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 로더를 두고, 뷰 생명주기에 맞춰 로딩/취소를 처리합니다
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, 메모리 경고 대응이 중요합니다.
// 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 기반의 선행 로딩 윈도우를 구현하는 방식이 일반적이에요
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)에 맞는 썸네일로 바로 디코딩하는 방식이에요.
// 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() 사용 시 렌더 비용을 의식해야 합니다.
핵심은 “효과를 줄이는 것”이 아니라 “비싼 효과를 필요한 곳에만 쓰는 것”이죠
// 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, 과도한 상태 변경을 줄여야 해요.
공통 포인트는 “스크롤 중 레이아웃 재계산을 최소화”입니다
// 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
}
}
}
}