[iOS] PhotoBook Preview 메모리 안정화 기록
Error Case #3 · OOM, Cache Thrash, 그리고 슬라이더 무력화
by raykim
PhotoBook Preview 메모리 안정화 기록
Error Case #3 · OOM, Cache Thrash, 그리고 슬라이더 무력화
TL;DR
- 동기식 전체 페이지 렌더 + 압축이 돌아가는 동안 공용
imageCache가 수백 MB까지 팽창해 OOM을 촉발했다. - 프리뷰/제작 모드가 같은 캐시를 공유하면서 “렌더 중 다시 캐시에 적재” 패턴이 무한 반복됐다.
- 주문 플로우 진입 시 캐시를 명시적으로 비우고, 제작 단계는 디스크 직독 경로로 분리해 캐시 thrash를 차단했다.
- 슬라이더는 비활성화된 채 장식만 담당하고 있어 디버깅 중 UX 시나리오 자체가 실패했다. 상호작용을 복원했다.
시나리오 & 재현 조건
- 환경: iOS 18.x, SwiftUI + UIKit 혼합 뷰, 4GB RAM급 디바이스(아이패드 9세대/아이폰 15 등)에서 재현.
- 데이터 세트: 100페이지 프로젝트, 각 페이지가 12MP 내외 JPEG을 참조.
- 재현 절차
PBPreview에서 페이지를 전부 스크롤해 썸네일/페이지/커버 이미지를 캐시에 올린다.- 바로 “제작 주문” 버튼을 눌러 동기 렌더 + ZIP 업로드 루틴을 시작한다.
- Instruments → Memory Graph / Allocations에서
PBMakeViewModel.imageCache객체가 빠르게 수백 MB를 점유하고, 2~3회 GC 사이클 후 프로세스가 종료되는 것을 확인한다.
- 관찰 지표: resident size 1.7~1.8GB, dirty memory 급증, OOM 로그에는 메모리 워닝 없이 jetsam reason
0x8badf00d가 기록됐다.
배경: 왜 터졌나
- 사진 선택 → 페이지 편집 → 옵션 선택 →
PBPreview확인 → 주문이 기본 플로우다. - 프리뷰 상태에서 이미 썸네일/페이지/커버 이미지가 메모리에 올라와 있다.
- 주문 단계에서 다시 모든 페이지를 풀 해상도로 그리는 순간 여유 메모리가 몇 MB만 남고, QA가 즐겨 쓰는 100장 프로젝트에서는 거의 즉사 조건이 됐다.
문제 관찰
- 주문 버튼을 누르면
[PB] pb_XX_page success만 찍히다가 경고 없이 곧바로 OOM 크래시가 발생했다. - Instruments에서는
PBMakeViewModel.imageCache에 풀 해상도 이미지가 계속 쌓이고, 스냅샷 루프도 동일 이미지를 다시 캐시에 올려 메모리가 두 배로 뛰었다. - 프리뷰 UI가 계속 남아 있어 SwiftUI body가 반복 계산되고,
loadImageSynchronously호출 때마다 캐시가 중복으로 채워졌다. - 상단 슬라이더는 장식용이라 QA가 “드래그가 안 된다”고 버그를 열었다.
원인 분석
1. 캐시 재적재 루프
loadImageSynchronously는 조건 없이cache(image:for:)를 호출했다.- 주문 버튼 →
viewModel.isMake = true→ SwiftUI body 리빌드 → 페이지별 이미지가 다시 로딩되며 그때마다 캐시에 새 항목이 추가됐다. - 스냅샷 루프도 같은 이미지를 다시 캐시에 넣어 LRU가 끊임없이 축출/재적재를 반복하며 쓰레싱이 발생했다.
// PBMakeViewModel.swift
func loadImageSynchronously(url: String) -> UIImage {
guard !url.isEmpty else { return UIImage() }
let image = readImageFromDisk(url: url)
cache(image: image, for: url) // 항상 캐시에 적재
return image
}
// PageSpreadView.swift
Image(uiImage: viewModel.loadImageSynchronously(url: page.localID))
.resizable()
.aspectRatio(contentMode: .fit)
- SwiftUI가 body를 다시 만들 때마다 위
Image구문이 다시 실행되어 캐시 적재가 중복 발생했다.
2. 캐시 클리어 타이밍 부재
- 프리뷰 → 주문 사이에 캐시를 정리할 훅이 전혀 없었다.
- 로딩 뷰를 띄워도 실제 이미지 객체는 남아 있어 스냅샷 루프 직전에 메모리를 해제할 시간이 없었다.
// PBPreview.swift (이전)
Button("제작 주문") {
viewModel.isMake = true // 상태만 바꾸고
makePhotobook() // 바로 스냅샷 루프 진입
}
imageCache/cacheOrder를 초기화하는 호출이 없어, 주문 진입 직후에도 수백 MB가 그대로 남아 있었다.
3. 슬라이더 비활성화
SwiftUISlider.makeUIView에서slider.isUserInteractionEnabled = false로 고정되어 있었다.- 값 표시만 가능한 상태라 사용자가 직접 페이지를 움직일 수 없었다.
func makeUIView(context: Context) -> UISlider {
let slider = UISlider()
slider.minimumValue = 0
slider.maximumValue = Float(totalPageCount)
slider.isUserInteractionEnabled = false // 장식 전용
return slider
}
- QA가 페이지 점프를 요청해도 UI 자체가 반응하지 않아 버그로 분류되었다.
해결 과정
1. loadImageSynchronously에 캐시 옵션 추가
@discardableResult
func loadImageSynchronously(url: String, cacheResult: Bool = true) -> UIImage {
guard !url.isEmpty else { return UIImage() }
let image = readImageFromDisk(url: url)
if cacheResult {
cache(image: image, for: url)
}
return image
}
- 기본은 기존과 동일하게 캐시 적재.
- 스냅샷 루프 등 캐시가 필요 없는 곳에서는
cacheResult: false를 넘겨 디스크에서만 읽게 했다.
2. 프리뷰/제작 모드별 이미지 경로 분리
private func resolvedImage(localID: String?) -> UIImage {
guard let id = localID, !id.isEmpty else { return UIImage() }
if viewModel.isMake {
return viewModel.loadImageSynchronously(url: id, cacheResult: false)
} else {
return viewModel.loadEditImage(url: id)
}
}
- 프리뷰에서는 캐시를 그대로 활용해 스크롤 성능을 유지한다.
- 제작 모드(
isMake = true)에서는 디스크 직독만 수행해 캐시를 건드리지 않는다.
3. 주문 직전 캐시 비우기 + 지연 호출
Button("제작 주문") {
viewModel.isMake = true
viewModel.clearImageCache()
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
makePhotobook()
}
}
clearImageCache()가imageCache와 LRU 순서를 동시에 초기화한다.- 1초 지연으로 ARC가 기존 이미지를 정리할 시간을 확보한 뒤 스냅샷을 돌렸다.
4. 슬라이더 상호작용 복원
slider.isUserInteractionEnabled = true
slider.addTarget(context.coordinator,
action: #selector(Coordinator.valueChanged(_:)),
for: .valueChanged)
@objc func valueChanged(_ sender: UISlider) {
let rounded = Double(sender.value.rounded())
let clamped = min(max(rounded, Double(sender.minimumValue)), Double(sender.maximumValue))
if sender.value != Float(clamped) {
sender.value = Float(clamped)
}
self.value.wrappedValue = clamped
}
- 페이지 단위로만 움직이도록 반올림 후 min/max로 범위를 제어했다.
- 값이 바뀌면
TabView가 즉시 해당 페이지로 이동한다.
결과
- 주문 버튼 클릭 시 메모리 피크가 20~30% 감소했다.
- 스냅샷 루프가 캐시를 다시 채우지 않아 렌더링 종료 후에도 캐시 상태가 안정적으로 유지된다.
- 슬라이더 드래그만으로 페이지를 이동할 수 있어 QA 피드백이 종료됐다.
- 100장 프로젝트에서도 OOM 리포트가 재현되지 않는다.
보안 점검
- 로그는
[PB] pb_XX_page같은 추상화된 형식만 남기며 사용자 식별자를 기록하지 않는다. - 이미지 로딩·캐시는 앱 샌드박스 내 로컬 식별자만 사용하고 외부 네트워크/SDK에 공유하지 않는다.
- 이번 수정 어디에서도 토큰, 비밀키, 고객 데이터는 노출되지 않는다.
얻은 교훈
- 캐시는 용도별로 갈라야 한다. 프리뷰 JPEG와 제작용 원본을 같은 딕셔너리에 넣으면 관리가 꼬인다.
- 스냅샷 루프는 기본적으로 메모리 절약 모드로 돌려야 한다. 캐시 미사용 옵션,
clearImageCache(),autoreleasepool세트가 기본 장비다. - UI에서 보이는 컨트롤은 다 동작해야 한다. 장식 슬라이더를 둘 바엔
ProgressView가 낫고, 슬라이더를 남길 거라면 끝까지 상호작용을 살려야 한다. - SwiftUI 상태(
isMake)는 body 재빌드를 야기한다. 상태 전환 시 부수효과(캐시 삭제, 디스크 I/O)를 명확히 묶지 않으면 성능 문제가 연쇄적으로 터진다.
이번 업데이트로 PhotoBook 미리보기는 “100장 풀 프로젝트도 OOM 없이 주문까지 완료”라는 기준을 다시 충족한다. 다음 목표는 페이지 렌더링을 백그라운드 큐로 분리해 UI 스레드를 더 가볍게 만드는 것이다.