코드네임 :

🍏 이미지 관리 본문

👥Club/🍀UMC🍀

🍏 이미지 관리

비엔 Vien 2025. 4. 2. 08:20

[ 이미지 렌더링 ]

 

iOS 이미지 처리 방식

" Load → Decode → Render "

이 과정에서 적절한 최적화를 하지 않으면 메모리 사용량 증가 및 성능 저하가 발생가능

 

1. Load(로드)

이미지 파일을 네트워크에서 다운로드하거나 로컬에서 로드하는 과정입니다.

  • 일반적으로 압축된 상태로 저장되어 있으며, 파일 크기가 작습니다.
  • UImage(named:), UIImage(contentsOfFile:),URLSession을 사용하여 로드할 수 있습니다.
  • 사진의 예제는 590KB 파일이 로드된 것을 예시로 보여줍니다.

 

2. Decode(디코딩)

이미지는 로드될 때 압축된 상태이므로, 픽셀 단위로 디코딩해야 렌더링이 가능합니다.

  • 압축된 이미지는 메모리에 압축 해제된 상태로 저장됩니다.
  • 픽셀 단위로 변환되면서 메모리 사용량이 급격히 증가할 수 있습니다.
  • 사진의 예제는 10MB로 증가하였습니다.

>> 그럼 디코딩 과정은 어떻게 이루어져 있을까요?

  1. JPEG/PNG 등은 압축된 상태 → 디코딩하면 RGBA 픽셀 데이터로 변환
  2. UIImageView 또는 SwiftUI Image에서 자동으로 디코딩 수행
    • 크기가 큰 이미지일수록 디코딩 시간이 오래 걸리고 메모리 사용량이 증가

 

3. Render (렌더링)

디코딩된 픽셀 데이터를 화면에 표시하는 과정입니다.

• UIKit에서는 UIImageView, CALayer에서 렌더링

• SwiftUI에서는 Image 뷰에서 자동 렌더링

 

 

우리는 디코딩 과정에서 최적화 방법을 선택할 수 있습니다.

  1. 미리 디코딩된 이미지 사용(pre-rendered image)
    1. **UIGraphicsImageRenderer(신버전)**를 사용하여 미리 디코딩된 상태로 저장
    2. SwiftUI에서는 resizable()을 사용하면 내부적으로 최적화됨
  2. 적절한 이미지 크기 로드
    1. 화면에 표시할 크기보다 큰 이미지를 불필요하게 디코딩하면 메모리 낭비
    2. 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()
}