코드네임 :
🍏 이미지 관리 본문
[ 이미지 렌더링 ]
iOS 이미지 처리 방식
" Load → Decode → Render "
이 과정에서 적절한 최적화를 하지 않으면 메모리 사용량 증가 및 성능 저하가 발생가능

1. Load(로드)
이미지 파일을 네트워크에서 다운로드하거나 로컬에서 로드하는 과정입니다.
- 일반적으로 압축된 상태로 저장되어 있으며, 파일 크기가 작습니다.
- UImage(named:), UIImage(contentsOfFile:),URLSession을 사용하여 로드할 수 있습니다.
- 사진의 예제는 590KB 파일이 로드된 것을 예시로 보여줍니다.
2. Decode(디코딩)
이미지는 로드될 때 압축된 상태이므로, 픽셀 단위로 디코딩해야 렌더링이 가능합니다.
- 압축된 이미지는 메모리에 압축 해제된 상태로 저장됩니다.
- 픽셀 단위로 변환되면서 메모리 사용량이 급격히 증가할 수 있습니다.
- 사진의 예제는 10MB로 증가하였습니다.
>> 그럼 디코딩 과정은 어떻게 이루어져 있을까요?
- JPEG/PNG 등은 압축된 상태 → 디코딩하면 RGBA 픽셀 데이터로 변환
- UIImageView 또는 SwiftUI Image에서 자동으로 디코딩 수행
- 크기가 큰 이미지일수록 디코딩 시간이 오래 걸리고 메모리 사용량이 증가
3. Render (렌더링)
디코딩된 픽셀 데이터를 화면에 표시하는 과정입니다.
• UIKit에서는 UIImageView, CALayer에서 렌더링
• SwiftUI에서는 Image 뷰에서 자동 렌더링
우리는 디코딩 과정에서 최적화 방법을 선택할 수 있습니다.
- 미리 디코딩된 이미지 사용(pre-rendered image)
- **UIGraphicsImageRenderer(신버전)**를 사용하여 미리 디코딩된 상태로 저장
- SwiftUI에서는 resizable()을 사용하면 내부적으로 최적화됨
- 적절한 이미지 크기 로드
- 화면에 표시할 크기보다 큰 이미지를 불필요하게 디코딩하면 메모리 낭비
- CGImageSourceCreateThumbnailAtIndex를 사용하여 적절한 크기로 로드
DownSampling
필요한 크기만큼 데이터를 미리 축소한 뒤, 캡쳐하여 필요없는 부분의 데이터 버퍼 제거를 진행합니다. 그 후, 디코딩을 하여 메모리를 줄이면서 우리에게 이미지를 가져오게 됩니다. low 레벨에서 접근하여 리사이징되기 때문에 처리속도도 빠르며 메모리 절약되는 방법입니다.
import ImageIO
func downsampleImage(at imageURL: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
guard let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, imageSourceOptions) else {
return nil
}
let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
let downsampleOptions = [kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels] as CFDictionary
guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else {
return nil
}
return UIImage(cgImage: downsampledImage)
}
⬇️ 활용법
import SwiftUI
struct DownsampledImageView: View {
let imageURL: URL
let targetSize: CGSize
var body: some View {
if let downsampledImage = downsampleImage(at: imageURL, to: targetSize, scale: UIScreen.main.scale) {
Image(uiImage: downsampledImage)
.resizable()
.scaledToFit()
} else {
Text("이미지를 불러올 수 없습니다.")
}
}
}
struct ContentView: View {
var body: some View {
VStack {
if let imageURL = Bundle.main.url(forResource: "large_image", withExtension: "jpg") {
DownsampledImageView(imageURL: imageURL, targetSize: CGSize(width: 200, height: 200))
} else {
Text("이미지 파일을 찾을 수 없습니다.")
}
}
}
}
⚠️여기에는 단점이 존재
1. DownSampling은 매번 새로운 이미지를 생성(캐싱X)
- downsampleImage는 CGImageSourceCreateThumbnailAtIndex를 사용하여 매번 새로운 다운샘플링된 이미지를 생성합니다.
- 동일한 이미지를 여러 번 요청해도 새로운 인스턴스를 만들어 메모리를 낭비할 수 있습니다.
- 예를 들어, List나 LazyVStack에서 여러 개의 이미지를 다운샘플링할 경우 매번 새로 디코딩해야 하므로 성능 저하 발생합니다.
2. UIImage는 시스템 캐시를 활용하지 않습니다.
- UIImage(named:)를 사용하면 iOS가 자동으로 캐싱하지만, downsampleImage는 직접 CGImageSourceCreateWithURL을 사용하므로 캐싱이 되지 않음.
⬇️ 해결책
Kingfisher
: 비동기 로딩 + 캐싱 + 다운 샘플링 기능을 제공
(캐시 이후 설명넣어놓을게)
캐시
: 데이터를 빠르게 제공하기 위해 임시로 저장하는 메모리 공간
- CPU, 웹 브라우저, 운영체제, 데이터베이스 등 다양한 시스템에서 캐시를 사용하며, 주된 목적은 데이터 접근 속도를 향상시키고, 불필요한 중복 연산을 줄여 성능을 최적화
1. 메모리 캐시
: 앱 실행 중에 데이터를 RAM에 저장하여 빠르게 접근할 수 있도록 하는 방식\
- *NSCache를 사용하면 자동으로 캐시 메모리를 관리하고, 메모리가 부족하면 자동으로 데이터를 제거
/// NSCache를 활용한 이미지 캐싱 매니저
class ImageCache: ObservableObject {
static let shared = ImageCache()
private let cache = NSCache<NSString, UIImage>()
private init() {}
/// 이미지 캐싱 (저장)
func setImage(_ image: UIImage, forKey key: String) {
cache.setObject(image, forKey: key as NSString)
}
/// 캐시된 이미지 가져오기
func getImage(forKey key: String) -> UIImage? {
return cache.object(forKey: key as NSString)
}
}
class AsyncImageLoader: ObservableObject {
@Published var image: Image?
private var cache = ImageCache.shared
init(url: String) {
loadImage(from: url)
}
private func loadImage(from urlString: String) {
let cacheKey = urlString as NSString
// 1️⃣ 캐시에서 이미지 확인
if let cachedImage = cache.getImage(forKey: urlString) {
self.image = Image(uiImage: cachedImage)
print("✅ 캐시에서 이미지 로드: \(urlString)")
return
}
// 2️⃣ 네트워크에서 다운로드
guard let url = URL(string: urlString) else { return }
URLSession.shared.dataTask(with: url) { [weak self] data, response, error in
guard let self = self, let data = data, let uiImage = UIImage(data: data) else { return }
DispatchQueue.main.async {
self.image = Image(uiImage: uiImage)
self.cache.setImage(uiImage, forKey: urlString)
print("📥 네트워크에서 이미지 다운로드: \(urlString)")
}
}.resume()
}
}
⬇️ 사용방법
struct CachedImageView: View {
@StateObject private var loader: AsyncImageLoader
init(url: String) {
_loader = StateObject(wrappedValue: AsyncImageLoader(url: url))
}
var body: some View {
loader.image?
.resizable()
.scaledToFit()
.frame(width: 100, height: 100)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray, lineWidth: 1))
.onAppear {
print("📌 CachedImageView가 나타남")
}
}
}
2. 디스크 캐시
: 데이터를 파일 시스템에 저장하여 장기적으로 보관하는 방식
: URLCache를 사용하면 네트워크 요청 응답 데이터를 디스크에 저장할 수 있습니다.
import SwiftUI
/// 디스크 캐시를 관리하는 클래스
class ImageDiskCache: ObservableObject {
static let shared = ImageDiskCache()
@Published var image: Image? // SwiftUI에서 사용 가능하도록 변경
private init() {}
/// 이미지 디스크에 저장
func saveImageToDisk(image: UIImage, fileName: String) {
if let data = image.pngData() {
let filePath = getFilePath(fileName: fileName)
do {
try data.write(to: filePath)
print("✅ 이미지 저장 성공: \(filePath.path)")
} catch {
print("❌ 이미지 저장 실패: \(error.localizedDescription)")
}
}
}
/// 디스크에서 이미지 로드 - SwiftUI에서 사용 가능하도록 @MainActor 적용
@MainActor
func loadImageFromDisk(fileName: String) {
let filePath = getFilePath(fileName: fileName)
if FileManager.default.fileExists(atPath: filePath.path),
let uiImage = UIImage(contentsOfFile: filePath.path) {
self.image = Image(uiImage: uiImage) // SwiftUI의 Image로 변환
print("✅ 디스크에서 이미지 로드 성공: \(fileName)")
} else {
print("⚠️ 이미지 캐시 존재하지 않음: \(fileName)")
}
}
/// 디스크에서 이미지 삭제
func removeImageFromDisk(fileName: String) {
let filePath = getFilePath(fileName: fileName)
if FileManager.default.fileExists(atPath: filePath.path) {
do {
try FileManager.default.removeItem(at: filePath)
print("🗑️ 이미지 삭제 완료: \(fileName)")
} catch {
print("❌ 이미지 삭제 실패: \(error.localizedDescription)")
}
} else {
print("⚠️ 삭제할 이미지가 존재하지 않음: \(fileName)")
}
}
/// 저장된 이미지 파일이 존재하는지 확인
func isImageCached(fileName: String) -> Bool {
let filePath = getFilePath(fileName: fileName)
return FileManager.default.fileExists(atPath: filePath.path)
}
/// 캐시된 모든 이미지 삭제
func clearCache() {
let directory = getCacheDirectory()
do {
let fileURLs = try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil, options: [])
for fileURL in fileURLs {
try FileManager.default.removeItem(at: fileURL)
}
print("🧹 캐시 비우기 성공")
} catch {
print("❌ 캐시 비우기 실패: \(error.localizedDescription)")
}
}
/// 파일 경로 반환
private func getFilePath(fileName: String) -> URL {
return getCacheDirectory().appendingPathComponent(fileName)
}
/// 캐시 디렉토리 반환
private func getCacheDirectory() -> URL {
return FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first!
}
}
struct CachedImageView: View {
@StateObject private var imageCache = ImageDiskCache.shared
let fileName: String
let placeholder: Image
var body: some View {
VStack {
if let cachedImage = imageCache.image {
cachedImage
.resizable()
.scaledToFit()
.frame(width: 150, height: 150)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray, lineWidth: 1))
.shadow(radius: 2)
.padding()
} else {
placeholder
.resizable()
.scaledToFit()
.frame(width: 150, height: 150)
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(RoundedRectangle(cornerRadius: 10).stroke(Color.gray, lineWidth: 1))
.shadow(radius: 2)
.padding()
.onAppear {
imageCache.loadImageFromDisk(fileName: fileName)
}
}
}
}
}
⬇️ 사용 방법
struct ContentView: View {
var body: some View {
VStack {
Text("SwiftUI 디스크 캐싱 예제")
.font(.headline)
CachedImageView(fileName: "profile_image.png", placeholder: Image(systemName: "person.circle"))
}
}
}
이미지 캐싱
이미지 캐싱(Image Caching)이란 한 번 로드한 이미지를 저장하여 다시 요청할 때 빠르게 제공하는 기술입니다. 이미지 로딩은 네트워크 사용량이 많고 성능에 영향을 주므로, 적절한 캐싱 전략을 사용하면 앱의 속도를 향상시키고 데이터 사용량을 줄일 수 있습니다.

[ Kingfisher ]
: 비동기 로딩 + 캐싱 + 다운 샘플링 기능을 제공
import SwiftUI
import Kingfisher
struct ContentView: View {
var body: some View {
VStack {
if let url = URL(string: "https://i.namu.wiki/i/fqHQU-a11JHaYXNeLZzKMCg54_HLiaHpnfoZtOHVgfh5m6B9X683woHMNPVOaMBf5F1mAy0rpQ5-OpiLO4SypKIYHG1oRcN6N29nEmG0V11smqyjwAPujCTH3wsaGswplDlOV1cTpoUmtqHCjEjQrw.webp") {
KFImage(url)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
}
Text("경복궁입니다.")
}
.padding()
}
}
#Preview {
ContentView()
}

로딩 중 플레이스홀더 이미지 설정
: 네트워크에서 이미지를 다운로드하는 동안 표시할 placeholder를 설정할 수 있습니다.
>>ProgressView란?
SwiftUI에서 진행 상태를 표시하는 기본적인 뷰입니다. 진행 중인 작업의 상태를 시각적으로 사용자에게 제공하는 역할을 합니다. 네트워크에서 이미지를 다운로드하는 동안 로딩 상태를 표시할 때 사용합니다.
import SwiftUI
import Kingfisher
struct ContentView: View {
var body: some View {
VStack {
if let url = URL(string: "https://i.namu.wiki/i/fqHQU-a11JHaYXNeLZzKMCg54_HLiaHpnfoZtOHVgfh5m6B9X683woHMNPVOaMBf5F1mAy0rpQ5-OpiLO4SypKIYHG1oRcN6N29nEmG0V11smqyjwAPujCTH3wsaGswplDlOV1cTpoUmtqHCjEjQrw.webp") {
KFImage(url)
.placeholder({
ProgressView()
.controlSize(.mini)
})
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
}
Text("경복궁입니다.")
}
.padding()
}
}
#Preview {
ContentView()
}

다운로드 실패 및 성공 시 대체 이미지 설정
이미지 로드 실패하면 특정 이미지를 대체하여 표시할 수 있습니다.
import SwiftUI
import Kingfisher
struct ContentView: View {
var body: some View {
VStack {
if let url = URL(string: "https://i.namuLZzKMCg54_HLiaHpnfoZtOHVgfh5m6B9X683woHMNPVOaMBf5F1mAy0rpQ5-OpiLO4SypKIYHG1oRcN6N29nEmG0V11smqyjwAPujCTH3wsaGswplDlOV1cTpoUmtqHCjEjQrw.webp") {
KFImage(url)
.placeholder({
ProgressView()
.controlSize(.mini)
})
.onFailure { error in
print("이미지 로드 실패: \(error)")
}
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
}
Text("경복궁입니다.")
}
.padding()
}
}
#Preview {
ContentView()
}

Retry 조정하기
: 네트워크 요청이 실패했을 때 자동으로 다시 시도하는 기능을 제공합니다.
: 아래 코드는 최대 2번 시도를 합니다. 또한 각 재시도 사이에 2초 간격을 두고 실행하도록 합니다.
import SwiftUI
import Kingfisher
struct ContentView: View {
var body: some View {
VStack {
if let url = URL(string: "https://i.namu.wiki/i/fqHQU-a11JHaYXNeLZzKMCg54_HLiaHpnfoZtOHVgfh5m6B9X683woHMNPVOaMBf5F1mAy0rpQ5-OpiLO4SypKIYHG1oRcN6N29nEmG0V11smqyjwAPujCTH3wsaGswplDlOV1cTpoUmtqHCjEjQrw.webp") {
KFImage(url)
.placeholder({
ProgressView()
.controlSize(.mini)
}).retry(maxCount: 2, interval: .seconds(2))
.onFailure { error in
print("이미지 로드 실패: \(error)")
}
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
}
Text("경복궁입니다.")
}
.padding()
}
}
#Preview {
ContentView()
}

Kingfisher의 DownSampling
: DownSampling은 위에서 이미지 렌더링 방식 이해하기에서 설명했습니다. 위에서 언급한 DownSampling 방식은 캐싱 처리가 되지 않는다는 단점이 존재했죠. 하지만 Kingfisher의 DownSampling은 캐싱처리와 함께 이루어지기 때문에 아주 아주 완벽합니다.
: 네트워크에서 받은 고해상도 이미지를 메모리 효율적으로 처리할 수 있도록 크기를 줄입니다.
: CGSize(width: height:)는 최대 사이즈 크기를 정하는 것을 의미합니다. 즉, 아래 코드는 최대 400 * 400 크기로 리사이징 하는 경우입니다.
struct ContentView: View {
var body: some View {
VStack {
if let url = URL(string: "https://i.namu.wiki/i/fqHQU-a11JHaYXNeLZzKMCg54_HLiaHpnfoZtOHVgfh5m6B9X683woHMNPVOaMBf5F1mAy0rpQ5-OpiLO4SypKIYHG1oRcN6N29nEmG0V11smqyjwAPujCTH3wsaGswplDlOV1cTpoUmtqHCjEjQrw.webp") {
KFImage(url)
.downsampling(size: CGSize(width: 400, height: 400))
.cacheOriginalImage() /* 원본 이미지도 캐싱 */
.placeholder({
ProgressView()
.controlSize(.mini)
}).retry(maxCount: 2, interval: .seconds(2))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
}
Text("경복궁입니다.")
}
.padding()
}
}
#Preview {
ContentView()
}

Kingfisher의 Processor 다루기
: Kingfisher의 프로세서를 직접 사용하여 복잡한 이미지 변환을 처리할 수 있습니다.
: 다운 샘플링을 적용하며 동시에, 원형 이미지로 적용하도록 한다고 가정해봅시다. 즉, 추가적인 이미지 변환이 필요하다며
import SwiftUI
import Kingfisher
struct ContentView: View {
var body: some View {
VStack {
if let url = URL(string: "https://i.namu.wiki/i/fqHQU-a11JHaYXNeLZzKMCg54_HLiaHpnfoZtOHVgfh5m6B9X683woHMNPVOaMBf5F1mAy0rpQ5-OpiLO4SypKIYHG1oRcN6N29nEmG0V11smqyjwAPujCTH3wsaGswplDlOV1cTpoUmtqHCjEjQrw.webp") {
KFImage(url)
.setProcessors([DownsamplingImageProcessor(size: CGSize(width: 400, height: 400)), RoundCornerImageProcessor(radius: .heightFraction(0.5))])
.cacheOriginalImage()
.placeholder({
ProgressView()
.controlSize(.mini)
}).retry(maxCount: 2, interval: .seconds(2))
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 200, height: 200)
}
Text("경복궁입니다.")
}
.padding()
}
}
#Preview {
ContentView()
}

'👥Club > 🍀UMC🍀' 카테고리의 다른 글
🍎 4주차 워크북 - OCR (이후 비번 설정할 예정) / 바코드 스캐너는 그냥 읽엇긔 (0) | 2025.04.08 |
---|---|
🍎 4주차 문법 - 함수와 클로저, 클래스, 구조체 (0) | 2025.04.06 |
🍏 on시리즈 모디피어 (0) | 2025.04.01 |
🍎 3주차 실습 (0) | 2025.04.01 |
🍎 3주차 워크북 - List와 Navigation (0) | 2025.03.31 |