본문 바로가기
iOS

[최적화] downsampling

by Bokoo14 2024. 1. 13.

화면에 많은 이미지들을 보여주게 되면, 메모리가 기하급수적으로 증가한다.

 

메모리 사용량은 이미지의 파일 크기로 계산되는 것이 아니라, 이미지의 크기(해상도)로 메모리가 계산된다. 

 

메모리 사용량 = 해상도 (가로 pixel * 세로 pixel) * 4byte per pixel

Memory Usage (Bytes) = Width(pixel) × Height(pixel) × bytes per pixel

 

로드

압축된 JPEG 파일을 메모리에 불러온다

디코딩

이미지 파일을 실제 픽셀 데이터로 변환하는 과정

JPEG파일을 로드 후, GPU에서 읽을 수 있게 디코딩 작업 실행

데이터의 압축을 해제하여 이미지로 표현 -> 많은 메모리 공간 필요

 

data buffer -> 디코딩 -> image buffer 변환

렌더링

준비된 파일을 그리는 작업

매 초마다 화면에 업데이트됨

60Hz는 매 초 60번씩 화면을 그린다. 

 

이미지 처리 파이프라인

원래 방식

Disk I/O (로드)

  • 이미지는 디스크에서 로드 (JPEG/PNG 등)
  • Core Graphics 또는 Core Image 프레임워크가 이미지를 디코딩

Decompression (디코딩)

  • 압축된 이미지 데이터를 디코딩하여 원시 픽셀 데이터로 변환
  • CPU와 메모리를 크게 소모하는 작업

Rendering (렌더링)

  • 디코딩된 이미지가 GPU에 의해 화면에 렌더링
  • 적절한 크기로 스케일링되거나 클리핑 처리
Disk I/O -> 디코딩 (depression) -> image buffer -> 렌더링 -> frame buffer

보통 UIImage, NSImage는 전체 해상도를 디코딩하므로, 메모리 버퍼가 커져 앱 크래스 위험이 증가한다. 

 

UIImageView는 view를 display하는 역할을 하고, UIImage는 view를 load하는 역할을 한다.

UIImage가 view(=이미지)를 로딩하면 UIImageView가 그 결과물을 렌더링해서 Frame Buffer라는 곳에서 보여준다.

 

ImageView에서 랜더링을 요청하면 decoding으로 변환된 image buffer 데이터를 복사하여 보여지는 영역의 데이터 (frame buffer)만큼 ImageView의 크기에 맞춰 조정함

 

UIImage가 하는 decoding작업은 CPU 집약적이라 비용이 크다.

 

 

1픽셀 당 4byte (red, green, blue, alpha)


최적화 방법

1. UIGraphicsImageRenderer

자동으로 최적의 Image Render Format을 선택하여 계산해줌

이론상 75% 이상 메모리 절약 효과

 

let image = UIImage(named: "photo")!
let imageSize: CGSize = imageView.frame.size

let render = UIGraphicsImageRenderer(size: imageSize)
let renderImage = render.image { _ in
    image.draw(in: CGRect(origin: .zero, size: imageSize))
}

 

draw 동작은 많은 CPU 비용을 사용

-> 큰 해상도 이미지를 사용하거나, 다시 그려질 때, 앱 성능 문제가 발생할 수 있다.

 

이미지의 크기를 조절했지만, 보이는 화면과 이미지의 해상도가 같을 때는 메모리 사용량은 동일하므로 효과가 없음

 

2. Downsampling

큰 이미지 파일을 저해상도로 축소(load-time 또는 렌더링 시) 하여 메모리와 성능을 최적화하는 기법

 

⭐️이미지 파일이 디코딩되기 전 적용해야 메모리 사용량을 줄일 수 있음⭐️

 

  • 고해상도 이미지를 줄인 해상도로 디코딩해서 메모리에 올림
  • 시각적으로 거의 차이가 없지만 메모리 사용량은 훨씬 줄어듦
  • 스크롤 성능 개선, 메모리 누수 방지, 앱 크래시 방지 등에 매우 중요

 

보통 원본 이미지를 작은 크기의 썸네일로 변환하려는 경우, 혹은 화면에 표시할 이미지가 원본 크기보다 훨씬 작아야 하는 경우에 다운샘플링을 사용한다.

그러나 여기서 중요한 점은 UIImage를 사용하여 이미지를 다운샘플링하지 않아야 한다는 것이다.

왜냐하면 UIImage를 사용하여 그릴 경우, 내부 좌표 공간 변환으로 인해 성능이 다소 저하되며, 이미지 전체를 메모리에 압축 해제하게 된다. 이로 인해 많은 메모리가 사용되게 된다.

 

이미지가 로드되면, data buffer가 먼저 로드되고, decoding된 데이터(image buffer)가 복사되어, 보여지는 데이터(frame buffer)만큼 크기를 조절해준다.

 

GPU에서 이미지 데이터를 읽는 decoding과정에서 메모리를 많이 사용한다.

downsampling 적용 후

필요한 크기만큼 데이터를 미리 축소한 뒤, 썸네일로 캡쳐하여 불필요한 data buffer를 제거한 채로 decoding을 하면, 메모리를 절약할 수 있다.

-> 디코딩 전에 미리 필요없는 데이터를 사용하지 않고 작업을 줄여 메모리 비용을 낮춘다. 

(UIImage가 data buffer를 디코딩하기 전 원본 사이즈를 줄이고 난 후 디코딩. 디코딩된 이미지(image buffer)를 저장)

low level로 접근하여 처리 속도도 빠르다.

 

func downSample(at url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage {
    let imageSourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
    guard let imageSource = CGImageSourceCreateWithURL(url as NSURL, imageSourceOptions) else {
        return
    }
    
    let maxDimensionInPixels = max(pointSize.width, pointSize.height) * scale
    let downsampleOptions = [
        kCGImageSourceCreateThumbnailFromImageAlways: true,
        kCGImageSourceShouldCacheImmediately: true,
        kCGImageSourceCreateThumbnailWithTransform: true,
        kCGImageSourceThumbnailMaxPixelSize: maxDimensionInPixels
    ] as CFDictionary
    
    guard let downsampledImage = CGImageSourceCreateThumbnailAtIndex(imageSource, 0, downsampleOptions) else {
        return UIImage()
    }
    return UIImage(cgImage: downsampledImage)
}

 

작동 원리

  • 일반적으로 이미지 파일을 UIImage(contentsOf:) 또는 Data → UIImage로 로드하면, 전체 해상도로 디코딩
  • 하지만 화면에 작은 사이즈로만 보여줄 거라면, 굳이 큰 해상도로 디코딩할 필요 없음
  • 이미지 파일에서 원하는 크기만큼만 디코딩 가능

키 값 역할

kCGImageSourceCreateThumbnailFromImageAlways true “원본 이미지 파일에서 무조건 썸네일(축소판)을 생성”하도록 강제
기본적으로 섬네일 생성 조건이 있을 수 있는데, 이 옵션을 주면 항상 축소된 이미지를 리턴하도록 한다
kCGImageSourceShouldCacheImmediately true 디코딩된 픽셀 데이터를 즉시 디코드(cache)하여 CGImage로 만든다
그래야 뒤에서 UIImage(cgImage:)로 변환할 때 추가 작업 없이 바로 사용할 수 있다
kCGImageSourceCreateThumbnailWithTransform true 이미지의 EXIF 회전 정보(orientation)를 자동으로 적용해서, 원본 방향대로 회전·뒤집기(transform)한 축소 이미지를 만든다
kCGImageSourceThumbnailMaxPixelSize maxDimensionInPixels 썸네일 이미지의 최대 가로·세로 픽셀 크기를 지정한다.
높이와 너비 중 큰 쪽이 이 값 이하로 축소된다.

 

 

3. UICollectionViewDataSourcePrefetching

UICollectionViewDataSourcePrefetching은 컬렉션 뷰가 스크롤하면서 곧 표시할 셀에 필요한 데이터를 미리 요청(prefetch) 할 수 있도록 도와주는 프로토콜

iOS 10 이상에서 사용할 수 있고, 매끄러운 스크롤 경험과 빠른 콘텐츠 로딩을 위해 매우 유용

 

 

protocol UICollectionViewDataSourcePrefetching {
  // 1) 곧 표시될 인덱스 경로 배열
  func collectionView(_ collectionView: UICollectionView,
                      prefetchItemsAt indexPaths: [IndexPath])

  // 2) prefetch 요청이 더 이상 필요 없을 때 (취소)
  func collectionView(_ collectionView: UICollectionView,
                      cancelPrefetchingForItemsAt indexPaths: [IndexPath])
}

다운샘플링 적용 방법

디코딩 전에 픽셀 크기를 제한

A. PDFKit thumbnail(of:for:)

https://developer.apple.com/documentation/pdfkit/pdfpage/thumbnail(of:for:)

 

thumbnail(of:for:) | Apple Developer Documentation

There's never been a better time to develop for Apple platforms.

developer.apple.com

PDF는 벡터 형식이기 때문에, PDFKit이 제공하는 thumbnail API를 쓰면 “PDF -> 래스터” 변환 시점에 바로 축소 디코딩해준다. 

import PDFKit
import AppKit

/// PDFDocument의 각 페이지를 원하는 크기의 NSImage(썸네일)로 변환
func pdfPagesAsThumbnails(from url: URL,
                           thumbnailSize: CGSize) -> [NSImage] {
    guard let doc = PDFDocument(url: url) else { return [] }
    let scale = NSScreen.main?.backingScaleFactor ?? 1
    let pixelSize = CGSize(width: thumbnailSize.width * scale,
                           height: thumbnailSize.height * scale)

    return (0..<doc.pageCount).compactMap { idx in
        guard let page = doc.page(at: idx) else { return nil }
        // PDFKit이 내부에서 downsampling 해 주는 API
        return page.thumbnail(of: pixelSize, for: .mediaBox)
    }
}

pdfkit을 사용한다면 thumbnail을 활용할 수 있다.

page.thumbnail(of:…, for:) API를 쓰면 내부에서 downsampling 해준다. 

pixelSize만큼의 래스터 버퍼만 메모리에 올라간다.

 

thumbnail(of: for:)은 내부적으로 PDF백터를 스트리밍 방식으로만 pixelSize크기만큼만 디코딩 (레스터화)

EXIF, 벡터 방향까지 포함하여 최적화된 작은 NSImage로 생성한다.

결과적으로 메모리 버퍼가 pixelSize * pixelSize * 4B만큼 할당

 

장점

 

  • 벡터 특성 활용
    • PDF는 본래 벡터 데이터. “원본 해상도”가 없기 때문에 렌더링 시점에만 필요한 래스터 크기만 생성 → 무조건 최적화
  • 성능 최적화
    • PDFKit 내부가 최적화된 C/ObjC 코드로 구현
    • 대용량 PDF라도 빠르게 썸네일 생성 가능

 

  • 코드 단순성
    • 시중 API 의존, 직접 복잡한 downsampling 로직 관리 불필요

 

PDF는 ‘벡터(Vector)’ 문서

  • PDF 페이지는 선·도형·텍스트·이미지 등의 수학적(벡터) 명령어로 이루어져 있다.
  • “벡터”란 크기와 방향만 가진 도형 정보를 의미하고, 해상도 독립적(resolution-independent)이어서 같은 명령어라도 작게 또는 크게 렌더링할 수 있다.

레스터화(Rasterization)란?

  • 벡터로 정의된 도형을 “픽셀”로 변환(conversion)하는 과정
  • 픽셀(Pixel)은 화면의 최소 단위(예: 64×90 칸 중 하나)로, 각 픽셀은 RGBA 4바이트 색상 데이터를 가진다.
  • 레스터화가 끝나면 “픽셀 그리드” 형태의 비트맵(예: CGImage)이 생성되고, 이때 비로소 메모리에 픽셀 버퍼가 올라간다.

스트리밍 방식 디코딩(Streaming Decode)이란?

  • PDFKit/Quartz 2D는 CGPDFDocument 내부에 “스트림(stream)” 구조로 PDF 객체를 저장합니다.
  • 레스터화 시 ‘필요한 페이지’에 담긴 벡터 명령어만 파일에서 순차적으로 읽어 처리(디코딩)
  • 전체 문서를 한꺼번에 로드하지 않고, 그리기 직전에만 해당 페이지 데이터를 읽어와 픽셀을 채워 넣기 때문에 메모리 부담이 줄어든니다.
import PDFKit

class PDFImageViewModel: ObservableObject {
  @Published var pdfImages: [PDFImages] = []

  func loadImages(from url: URL) {
    guard let doc = PDFDocument(url: url) else { return }
    let scale = NSScreen.main?.backingScaleFactor ?? 1
    let thumbSize = CGSize(width: 64 * scale, height: 90 * scale)
    pdfImages = (0..<doc.pageCount).compactMap { i in
      guard let page = doc.page(at: i) else { return nil }
      // downsampling 썸네일 생성
      let img = page.thumbnail(of: thumbSize, for: .mediaBox)
      return PDFImages(PDFId: someID, image: img)
    }
  }
}

 

 

thumbnail(of:for:) 내부 동작 흐름

let thumb = page.thumbnail(of: pixelSize, for: .mediaBox)
  1. 오프스크린 비트맵 컨텍스트 생성
    • 크기 = pixelSize.width × pixelSize.height (포인트×화면 스케일)
    • 이 컨텍스트는 “픽셀 그리드”로, 1픽셀당 4바이트(RGBA) 버퍼를 가진다.
  2. 스트리밍 벡터 해석 & 레스터화
    • PDF 페이지의 벡터 명령어(선, 도형, 텍스트, 임베디드 이미지 등)를 순차적으로(스트림처럼) 읽어서 비트맵 컨텍스트에 scale/transform을 적용해 “pixelSize” 크기의 픽셀로 그립니다.
  3. 메모리 효율
    • 전체 PDF 페이지의 고해상도 버퍼(예: 8000×6000) 대신, “필요한” 픽셀 버퍼(예: 200×300)만 메모리에 올라간다.
    • 결과 NSImage는 이 비트맵 컨텍스트를 그대로 래핑해 돌려주므로, 메모리 사용량이 작아진다.

 

*픽셀 버퍼(Pixel Buffer): 메모리(또는 GPU 메모리) 상에 픽셀 하나하나의 색상 정보를 연속된 배열 형태로 저장한 공간

*래스터화: 이미지 파일(JPEG/PNG/PDF 레스터화) -> 픽셀 버퍼에 색상값을 채우는 작업(“디코드” 또는 “래스터화”)

어떤 크기의 이미지를 메모리에 올릴 때, 이 픽셀 버퍼가 바로 메모리 사용량을 결정한다. 

 

B. CGImageSource

이미 디스크에 저장된 NSImage(또는 Data에서 만든 이미지)에 바로 적용하려면, CGImageSource API를 써서 on-the-fly로 축소

뷰가 렌더링될 때마다 원본 대신 작게 디코딩된 이미지(실제 디코딩 시점에 작은 크기만 디코딩)를 사용하게 되어, 메모리 사용량 개선

import AppKit
import ImageIO

/// URL 또는 Data로부터 NSImage를 downsampling
func downsampleImage(at url: URL,
                     to displaySize: CGSize) -> NSImage {
  // 1) 디스크 I/O 참조: 디코딩 미루기
  let srcOpts = [kCGImageSourceShouldCache: false] as CFDictionary
  guard let src = CGImageSourceCreateWithURL(url as CFURL, srcOpts) else {
    return NSImage(contentsOf: url) ?? NSImage()
  }

  // 2) 화면에 보일 실제 픽셀 크기 계산
  let scale = NSScreen.main?.backingScaleFactor ?? 1
  let maxPix = max(displaySize.width, displaySize.height) * scale

  // 3) downsample 옵션 설정
  let downOpts = [
    kCGImageSourceCreateThumbnailFromImageAlways: true,
    kCGImageSourceShouldCacheImmediately: true,
    kCGImageSourceCreateThumbnailWithTransform: true,
    kCGImageSourceThumbnailMaxPixelSize: maxPix
  ] as CFDictionary

  // 4) 축소 디코딩(썸네일 생성)
  guard let cgThumb = CGImageSourceCreateThumbnailAtIndex(src, 0, downOpts) else {
    return NSImage(contentsOf: url) ?? NSImage()
  }

  // 5) NSImage로 래핑
  return NSImage(cgImage: cgThumb, size: displaySize)
}
import SwiftUI
import ImageIO

func downsample(nsImage: NSImage, to pointSize: CGSize) -> NSImage {
  // 1. NSImage → TIFF Data → CGImageSource
  guard
    let data = nsImage.tiffRepresentation,
    let src = CGImageSourceCreateWithData(data as CFData,
                                         [kCGImageSourceShouldCache: false] as CFDictionary)
  else { return nsImage }

  // 2. 최대 픽셀 수 계산 (포인트 × 스크린 스케일)
  let scale = NSScreen.main?.backingScaleFactor ?? 1
  let maxPix = max(pointSize.width, pointSize.height) * scale

  // 3. downsample 옵션
  let opts = [
    kCGImageSourceCreateThumbnailFromImageAlways: true,
    kCGImageSourceShouldCacheImmediately: true,
    kCGImageSourceCreateThumbnailWithTransform: true,
    kCGImageSourceThumbnailMaxPixelSize: maxPix
  ] as CFDictionary

  // 4. 축소 디코딩
  guard let cgThumb = CGImageSourceCreateThumbnailAtIndex(src, 0, opts) else {
    return nsImage
  }

  // 5. NSImage로 래핑
  return NSImage(cgImage: cgThumb, size: pointSize)
}

 

 

  • kCGImageSourceShouldCache: false: 최초 참조 시 디코딩을 미룬다.
  • kCGImageSourceThumbnailMaxPixelSize: 요청한 최대 픽셀 크기만큼만 디코딩
  • EXIF 회전도 CreateThumbnailWithTransform 옵션으로 한 번에 처리

 

C. Graphics + NSBitmapImageRep (세밀 제어)

PDFKit이 아니라, 직접 CGContext에 PDF나 CGImage를 그려서 downsampling할 수도 있다.

import AppKit

func downsampleViaBitmapRep(_ page: CGPDFPage,
                            displaySize: CGSize) -> NSImage {
  let scale = NSScreen.main?.backingScaleFactor ?? 1
  let pixelSize = CGSize(width: displaySize.width * scale,
                         height: displaySize.height * scale)

  // 1) 빈 비트맵 생성
  guard let bitmap = NSBitmapImageRep(
          bitmapDataPlanes: nil,
          pixelsWide: Int(pixelSize.width),
          pixelsHigh: Int(pixelSize.height),
          bitsPerSample: 8,
          samplesPerPixel: 4,
          hasAlpha: true,
          isPlanar: false,
          colorSpaceName: .deviceRGB,
          bytesPerRow: 0,
          bitsPerPixel: 0)
  else {
    return NSImage()
  }

  // 2) 그래픽 컨텍스트에 연결
  NSGraphicsContext.saveGraphicsState()
  let ctx = NSGraphicsContext(bitmapImageRep: bitmap)!.cgContext
  ctx.interpolationQuality = .medium
  ctx.translateBy(x: 0, y: pixelSize.height)
  ctx.scaleBy(x: 1, y: -1)
  
  // 3) PDF 페이지 또는 CGImage 그리기
  let rect = CGRect(origin: .zero, size: pixelSize)
  ctx.drawPDFPage(page, in: rect)

  NSGraphicsContext.restoreGraphicsState()

  // 4) NSImage 반환
  let img = NSImage(size: displaySize)
  img.addRepresentation(bitmap)
  return img
}

 

 


 

 

참고

더보기