오늘은 오랜만에 돌아오게 됐는데 이미지 처리 방식이 새로 입사한 회사에서 내가 기존에 사용했던 kingfisher를 사용하는게 아닌 Nuke를 사용하고 있어 이부분에 대한 개념을 정리하고 나아가 이미지 캐싱 처리 방식에 대해 살짝 개념을 잡아가기 위해 작성하게 됐다.
앱 최적화를 시키면서 이 이미지 최적화는 항상 따라오는 문제인 것 같다. 왜냐하면 모바일 환경에서 이미지를 매번 네트워크에서 받아오는 방식이다보면 엄청나게 많은 비용이 발생하고 앱 성능이 떨어질 수 있기 때문이다.
계속해서 같은 이미지를 다운로드하면 배터리 및 데이터 부분에서 많은 소모가 일어나고 CPU 과부하로 인해 앱 성능이 많이 떨어질 수 있게 된다.
이런 문제를 어느정도 해결하기 위해 대부분의 이미지 처리는 메모리 및 디스크 캐싱으로 두 계층으로 나눠 캐싱을 하는 방식을 많이 사용하곤 하는데 여기서 각각 메모리 및 디스크 캐싱의 차이를 한번 살펴보면 다음과 같다.
| 메모리 캐시 | RAM | 매우 빠름 (즉시) | UIImage (Bitmap) | 앱 종료 시 휘발됨. 용량이 제한적이나 디코딩 비용이 없음. |
| 디스크 캐시 | Flash Storage | 빠름 (네트워크 대비) | Data (Encoded) | 앱 재실행 시에도 유지됨(영구). 사용 시 디코딩 과정 필요. |
그렇다면 Nuke의 핵심은 무엇일까? 내 생각엔 가장 큰 강점이라고도 할 수 있는 것은 이미지 로드 과정을 명확한 파이프라인으로 정의했다는 점!
이 이미지는 ImagePipeline.shared.loadImage() 가 호출되었을 때 내부적으로 일어나는 일을 나타낸 것이다.

- Memory Cache Check - 가장 먼저 메모리(RAM)을 확인해서 있다면 바로 즉시 UIImage를 반환한다. (가장 빠른 방식)
- Deduplication (Coalescing) - 동일한 URL 요청이 동시에 여러개 들어오면(ex. 리스트 스크롤과 같이), 이를 하나의 작업으로 합쳐 중복 다운로드를 방지한다.
- Disk Cache / Network -디스크 캐시를 확인하고 없다면 네트워크 요청을 보낸다!
- Decode & Process - 받아온 데이터를 이미지로 변환하고, 필요한 경우 리사이징 등의 후처리를 백그라운드에서 수행한다.
- Store - 최종 결과물을 메모리 캐시에 저장하고 뷰에 전달한다.
이러한 파이프라인을 통해 명확한 이미지 캐싱을 제공한다는 점이 Nuke의 핵심이 되지 않을까 생각한다.
그렇다면 이전에 사용했던 Kingfisher와 Nuke는 무엇이 다른걸까
내가 느꼈던건 Nuke는 Native 친화적이고, Kingfisher는 기능 친화적이라는 느낌!
| 디스크 캐싱 방식 | URLCache (Native) 사용 | 자체 File System 로직 구현 |
| 캐시 제어 | HTTP Header (Cache-Control) 준수 | 자체적인 만료 시간 설정 가능 |
| 메모리 관리 | Aggressive LRU (적극적 메모리 정리) | 표준적인 캐시 관리 |
| SwiftUI | NukeUI 모듈 제공 (매우 가벼움) | KFImage 제공 (기능 강력함) |
| 철학 | Performance & Simplicity | Feature Rich |
Nuke는 디스크 캐싱을 위해 별도로 파일 시스템을 구축하는것 보다 Apple이 만든 URLCache를 사용해서 Native 친화적으로 구현이 가능하다. 여기서 따라오는 장점은 HTTP 표준 캐싱 정책을 그대로 따르기 때문에 서버 사이드에서 캐시 주기를 제어하기 쉬워 진다는 점! 그리고 라이브러리 자체가 매우 가벼운 상태가 될 수 있다는게 장점인 것 같다.
그럼 앱 전반적으로 특성에 맞게 캐시 용량과 정책을 설정하고 파이프라인을 커스텀하면서 조금 더 이해를 해보도록 하자
import Nuke
func configureNuke() {
let pipeline = ImagePipeline {
// 1. DataLoader 설정 (디스크 캐시: URLCache 활용)
// 디스크 용량을 100MB로 설정
$0.dataLoader = DataLoader(configuration: {
let conf = URLSessionConfiguration.default
conf.urlCache = URLCache(memoryCapacity: 0, diskCapacity: 100 * 1024 * 1024)
return conf
}())
// 2. ImageCache 설정 (메모리 캐시: LRU 알고리즘)
// 메모리 용량을 50MB로 제한. 넘치면 오래된 것부터 삭제됨.
$0.imageCache = ImageCache(costLimit: 50 * 1024 * 1024)
// 3. 성능 옵션: 프로그레시브 디코딩 활성화 (이미지가 점진적으로 선명해짐)
$0.isProgressiveDecodingEnabled = true
// 4. 백그라운드 디코딩 우선순위 설정
$0.dataLoadingQueue.maxConcurrentOperationCount = 6
}
// 공유 파이프라인으로 교체
ImagePipeline.shared = pipeline
}
각각 캐싱하기 위핸 디스크 용량 및 메모리 옹량을 커스텀해 프로젝트의 특징에 맞게 넣어줄 수 있다!
여기서 추가적으로 이제 새로운 View를 만들때 더이상 UIKit을 우선순위로 개발하는것이 아닌 SwiftUI를 선택하게 되고 마이그레이션도 진행하면서 Nuke와 같은 이미지 캐싱, 로딩 라이브러리들도 변화가 필요했다. 기존에 UIKit에서 사용하던 UIImageView extension 방식은 View 구조체 기반인 SwiftUI에서 사용하기 좀 어려웠기 때문..
그래서 Apple에서는 iOS 15부터 AsyncImage를 도입하긴 했지만 실무적용해서 캐싱, 프로세싱 퍼포먼스 등 아쉬운 점이 다소 많아 쉽사리 선택해 사용하기엔 어려움이 있었는데.
이 내용을 Nuke의 이미지 파이프라인 성능을 유지하면서 SwiftUI의 선언형 문법에 맞게 NukeUI가 탄생하게 되었다!
이 NukeUI의 핵심은 LazyImage인데 이름에서 알 수 있듯이, 뷰가 화면에 나타나는 순간 이미지를 로드하는 형태로 작동하게 된다.
LazyImage는 내부적으로 로딩 과정을 State로 관리하게 되는데 이 로딩 과정을 상태로 관리하게 되면서 개발하며 이부분을 핸들링해 구현하는 방식이 가능해졌다!
import NukeUI
LazyImage(url: URL(string: "<https://example.com/image.jpg>"))
.frame(height: 300)
아주 간단하게 사용하면 URL만 던져주면 알아서 로드하고 보여주는 방식으로 사용이 가능하고
실질적으로 로딩 중에 프로그레스를 던지거나, 에러 시 대체 이미지를 보여줘야하는 순간에 LazyImage는 클로저를 통해 현재 상태를 제공하므로 완벽하게 호환이 가능해진다.
LazyImage(url: imageURL) { state in
if let image = state.image {
// [성공] 이미지 도착!
image
.resizable()
.aspectRatio(contentMode: .fill)
.transition(.opacity) // 부드럽게 등장
} else if state.error != nil {
// [실패] 에러 발생
Color.red.overlay(
Image(systemName: "exclamationmark.triangle")
)
} else {
// [로딩 중]
ProgressView()
.scaleEffect(1.5)
}
}
// 파이프라인 설정 (Modifier로 적용)
.priority(.high) // 중요도 높음
.processors([.resize(width: 300)]) // 300px로 리사이징 (메모리 최적화)
이렇게 각 상황에 맞게 원하는 상태를 전달할 수 있기 때문에 구현이 매우 편리하고 정확하게 할 수 있다.
그렇지만 단순히 SwiftUI 스럽게 구현이 가능하다해서 좋다고 하는건 아니고 SwiftUI의 렌더링 사이클과 Nuke의 파이프 라인을 알아서 똑똑하게 연결해주는게 제일 유용한 것 같다.
자동 요청 취소
예를 들어 List나 Grid에서 사용자가 스크롤을 매우 빠르게 내린다고 가정해보면 화면을 스쳐 지나간 셀의 이미지는 굳이 다운로드할 필요가 없는데 LazyImage는 뷰가 화면에 사라지는 onDisappear 순간 연결된 이미지 파이프라인의 작업을 즉시 취소한다. 이렇게 자동으로 처리해주기 때문에 CPU 자원을 많이 아낄 수 있게 되어 아주 유용한 것 같다.
뷰 갱신의 최적화
UIKit의 UIImageView는 이미지가 설정되면 다시 그리기만 하면 되지만, SwiftUI는 View 구조체 전체를 다시 계산해야한다. NukeUI는 내부적으로 ObservableObject ( FetchImage )를 사용하여, 이미지 로딩 상태가 변할 때만(로딩 시작 → 완료) 뷰를 갱신하도록 최적화 되어있다. 이로써 불필요한 렌더링을 방지하게 되는 것!
완벽한 파이프 라인 접근
SwiftUI 뷰 내부에서도 Nuke의 강력한 기능들을 Modifier 형태로 쉽게 주입할 수 있다.
LazyImage(url: url)
.pipeline(ImagePipeline.custom) // 커스텀 파이프라인 지정 가능
.processors([.gaussianBlur(radius: 10)]) // 블러 효과 즉시 적용
이렇게 오늘은 Nuke와 NukeUI에 대해서 알아봤다! 조금 더 공부해서 실제 프로젝트에 적용되어있는 코드를 잘 파악해야지!
'◽️ Programming > T I L' 카테고리의 다른 글
| FCM을 활용해 푸시 알림 구현하기 (2/2) (1) | 2025.06.26 |
|---|---|
| MVI 아키텍처로 단방향 데이터 흐름 만들기 (1) | 2025.06.17 |
| FCM을 활용해 푸시 알림 구현하기 (1/2) (0) | 2025.05.30 |
| 모듈화에 대한 고민, 최적의 구조를 찾아야한다!! (0) | 2025.05.09 |
| 단방향 데이터 흐름이 왜 좋은데!? (0) | 2025.04.03 |