이 페이지가 담는 것
이력서에는 “Apple 네이티브 프레임워크만으로 촬영과 합성 파이프라인 구현” 한 줄이 들어간다. 실제로 시간이 들어간 곳은 그 구현이 아니라, 화질이 나쁘다는 신고를 받고 원인을 값으로 특정하는 과정이었다. 그 과정에서 세운 가설 몇 개가 측정으로 반증됐다.
프리뷰 두 개를 어떻게 그리는가
이 앱의 제품 가치는 촬영 전에 가로와 세로 두 프레이밍을 동시에 확인하는 것이다. 그런데 단일 AVCaptureSession은 AVCaptureVideoPreviewLayer를 하나만 안정적으로 렌더한다.
먼저 해 본 것: AVCaptureVideoPreviewLayer 두 개로 듀얼 박스를 그리는 일회용 진단 빌드를 만들어 iPhone 12 Pro(iOS 18)에서 돌렸다. 가로 박스만 그려지고 세로 박스는 검은 화면으로 남았다(2026-06 측정, 이슈 #162). 이전 결정 문서는 같은 현상을 iOS 17 한정으로 귀속했으나, 버전과 무관한 구조 제약이었다.
고른 것: AVCaptureVideoDataOutput이 주는 NV12 프레임 하나를 브로드캐스트 delegate로 받아 여러 AVSampleBufferDisplayLayer에 나눠 보낸다. 프로덕션 코드는 AVCaptureVideoPreviewLayer를 인스턴스화하지 않는다.
그래서 직접 만들어야 했던 것
- 탭 좌표 역변환.
captureDevicePointConverted를 못 쓰므로 scaleEffect 역변환, aspect-fill, 90도 회전을 손으로 계산한다. 정확도는 Apple 표준 변환기와 실기기에서 매 탭 대조해 Δ가 약 0.000임을 확인했다(2026-07, 이슈 #212). videoDataOutput과photoOutput두 connection의 회전을 같이 맞추는 일. 한쪽만 맞추면 프리뷰와 저장본이 90도 어긋난다.- active format이 바뀐 뒤 레이어가 오래된 프레임에 남는 경로. flush로 다시 채운다.
AVSampleBufferDisplayLayer의 renderer가.failed로 떨어지면enqueue가 조용히 무시된다. 매 프레임 enqueue 전에 상태를 확인하고flush()한다.
치른 대가: 직접 계산하는 프리뷰 배율에 검증되지 않은 보정 상수가 1년 넘게 남아, 프리뷰가 저장본보다 좁게 보인 적이 있다.
같이 기각한 것: 프리뷰 하나에 교집합 오버레이와 어두운 마스크를 씌우는 방식. 실사용에서 “세로가 가로의 중앙 크롭”으로 체감되어 제품의 차별점이 약해졌다.
대기 상태 CPU를 기본 카메라 수준으로 내린 방법
프레임 하나를 두 레이어에 나눠 보내는 구조라, 프리뷰 하나짜리 카메라 앱보다 대기 상태 CPU가 본질적으로 높다. 목표를 “더 낮게”가 아니라 “iPhone 기본 카메라 앱과 동등”으로 잡았다.
조정한 것은 세 가지다.
activeVideoMinFrameDuration = 1/30으로 최대 프레임률을 30으로 고정videoDataOutput픽셀 포맷을 NV12 네이티브 그대로 사용. 화소당 4바이트 강제 변환을 없앤다(1080p30에서 초당 약 250MB의 변환이 사라진다)CMMotionManager.deviceMotionUpdateInterval = 1/15로 수평계 오버레이의 SwiftUI 재구성 빈도를 낮춤
iPhone 12 Pro에서 Instruments Activity Monitor로 35초씩 재니 적용 전 3033%, 적용 후 1517%였다. 같은 기기의 기본 카메라 앱이 평균 약 17%다(2026-05-21).
세 조정 모두 화질이나 프리뷰 반응성을 낮추지 않는다. 대신 deviceMotionUpdateInterval을 낮춘 만큼 수평계 갱신이 성겨진다.
화질 신고의 원인을 값으로 특정하기까지
“기본 카메라보다 화질이 나쁘다”는 신고를 받았다. 눈으로 비교하는 방식으로는 한 달 동안 결론이 나지 않았다.
처음 했던 방식과 그 결과
진단 토글 4종(자동 deferred 사진 처리, 후면 광각 단독, 셔터 순간 렌즈 교체, 배율마다 렌즈 고정)을 프로덕션 캡처 경로에 넣고 폰에서 켜고 꺼 가며 눈으로 비교했다. 2026-07-17에 들어가 2026-08-19에 삭제됐다. 그 한 달 동안 이슈 #213과 #214는 열린 채였다.
판정 수단에 같은 값을 다시 얻을 절차가 없었다. 그리고 그동안 진단 분기가 setZoom 진입점과 캡처 결과 타입과 사진 라이브러리 저장 경로에 남아 있었다.
바꾼 방식
무엇을 확정으로 인정할지부터 정했다. 기준은 재실행 가능성이다. 지금 명령 하나로 다시 재서 같은 값이 나오면 확정이고, 그 외는 전부 미확정이다.
측정 계층을 넷으로 갈랐다.
| 명령 | 재는 것 | 재지 않는 것 |
|---|---|---|
make measure-camera |
이 iPhone이 줄 수 있는 값 | 앱이 그중 무엇을 뽑아내는지 |
make ios-diag |
앱 캡처 경로가 무엇을 요청하고 무엇을 받았는지 | 하드웨어의 상한 |
make test-device |
앱 UI 경로가 동작하는지 | 화질과 하드웨어 값 |
make test-unit |
순수 함수 입출력 | 실기기에서만 정해지는 것 |
측정 하네스(ios/CookieCutTests/DeviceMeasurement/)는 @testable import CookieCut을 하지 않는다. 앱의 세션 구성도 줌 계산도 호출할 수 없다. 두 값을 따로 재야 그 차이가 앱의 손실을 가리킨다.
컷 하나에서 남기는 것도 정했다. AVCaptureResolvedPhotoSettings가 알려준 값과 인코딩된 파일 바이트를 다시 열어 읽은 값을 둘 다 남긴다. 두 값이 갈릴 수 있고, 갈렸다는 사실 자체가 판정에 쓰인다. AVCapturePhoto.metadata는 쓰지 않는다. 그것은 촬영 시점 메타데이터이지 파일에 기록된 내용이라는 보증이 아니다.
가설 하나가 반증됐다
이슈 #213의 가설은 “기본 카메라가 deferred 멀티프레임 노이즈 제거를 쓰기 때문에 우리가 진다”였다. 재보니 deferred 최종본과 .quality 컷의 값이 같은 수준이고, 기본 카메라와의 P25 SNR 9.9dB 격차가 그대로 남았다(2026-08-20). 같은 실행에서 deferred를 켜면 전달 치수만 12MP에서 24MP로 올라갔다.
같은 시점에 확인된 사실 하나가 더 있다. isAutoDeferredPhotoDeliveryEnabled를 켜지 않으면 24MP를 요청해도 12MP로 강등된다(Apple DTS 확인). deferred proxy의 최종본은 앱 프로세스가 접근할 수 없어 PhotoKit 완료 처리 전용이다. 그래서 deferred를 켜면 자동 모자이크가 원본에 걸리지 않고, 모자이크 없는 사진이 라이브러리에 먼저 들어간다. 이 앱의 addOnly 권한 구조도 무너진다. 켜지 않기로 했다.
남은 격차의 출처는 AVFoundation이 아니었다. 같은 ISO와 같은 노출 시간을 주면 하네스 컷과 기본 카메라 컷의 표준편차가 0.5dB 안에서 겹친다(2026-08-23). 저조도 2배율에서 기본 카메라는 ISO 320에 0.5초로 찍고 이 앱은 ISO 3200에 1/15초로 찍는다. 자동 노출이 포맷 한계보다 훨씬 앞인 1/15초에서 스스로 멈춘다. 이건 아직 못 고쳤다.
실제 원인 하나를 찾았다
앱이 사진 앱에 저장하는 크롭본에 ICC 프로파일이 아예 없었다. sandbox 원본은 Display P3인데 저장본은 Color Primaries: Unspecified였다.
원인은 createCGImage에 색공간을 넘기지 않으면 CIContext의 기본 출력 색공간인 sRGB로 나가는 것이었다. 색역 왕복 비용을 화소군별로 재니 고채도 화소(전체의 3.9%)가 rmse 3.502 / max 30.0, 무채색 화소(86.6%)가 rmse 0.300 / max 4.0이다. 채도가 높은 화소만 크게 밀린다. 육안 신고와 맞는 모양이다.
이 손실은 픽셀 차이 지표에 나타나지 않는다. 같은 쌍의 재인코딩 손실은 휘도 50.07dB로 정상 범위였다. 그래서 회귀를 막는 수단은 PSNR이 아니라 프로파일을 직접 읽는 테스트다(exportKeepsSourceDisplayP3Profile).
고친 뒤 실기기에서 크롭본 두 장 모두 Display P3를 달고 나왔고, 픽셀 차이 지표는 그대로였다.
재인코딩 품질은 올리지 않았다
같은 원본을 품질만 바꿔 재인코딩해 스윕했다(2026-08-23).
| 품질 | 가로 크롭 바이트 | 원본 대비 | 전체 휘도 | 전체 색차 |
|---|---|---|---|---|
| 0.8 | 914,934 | 55% | 46.23dB | 50.68dB |
| 0.85 | 914,934 | 55% | 0.8과 동일 | 0.8과 동일 |
| 0.9 | 1,373,091 | 83% | 50.31dB | 53.41dB |
| 0.95 | 2,113,277 | 127% | 54.61dB | 55.51dB |
| 1.0 | 3,954,983 | 238% | 60.61dB | 52.74dB |
읽은 것
- 0.8과 0.85는 바이트까지 같다. 이 값은 연속이 아니라 인코더의 양자화 단계다.
- 1.0은 무손실이 아니라 다른 인코더 프로파일이다.
ffprobe로 읽으니 0.9와 0.95는Main Still Picture에 4:2:0인데 1.0만Rext에 4:4:4다. 휘도를 6dB 올리는 대신 색차를 0.95보다 떨어뜨린다. - 0.95는 색차가 가장 좋지만 파일이 1.54배가 되어 원본보다 커진다. 얻는 것은 휘도 4.3dB와 색차 2.1dB다.
육안 신고의 원인은 색공간이었고 그건 고쳤으므로, 파일 크기를 키우는 교환을 지금 치를 근거가 없다고 보고 0.9를 유지했다.
크롭 재인코딩 자체는 없앨 수 없다. HEVC 코딩 데이터는 CTB(보통 64x64) 경계에 정렬돼야 잘라낼 수 있는데 이 컷의 세로 크롭 시작점이 (882, 0)이고 882는 64의 배수가 아니다. 자르지 않고 표시 영역만 지정하는 HEIF clap 필드는 ImageIO에 쓰기 수단이 없다.
첫 인코드도 고쳤다
AVCapturePhotoSettings()를 인자 없이 만들면 기본 코덱이 JPEG다. 이 앱은 그 바이트를 original.heic라는 이름으로 저장하고 있었고, 사진 앱에 올릴 크롭본은 그것을 디코드해 HEIC로 다시 인코딩했다. 손실 압축을 두 번 지나는데 첫 인코드가 버린 디테일은 두 번째가 되살리지 못한다. 첫 인코드를 HEIC로 맞췄다.
파일명도 고쳤다. hevc가 없는 기기에서는 JPEG로 떨어지므로, 확장자를 고정하지 않고 촬영이 고른 값을 세트 메타(ShotSet.originalPhotoFilename)에 담아 따라가게 했다.
아직 못 한 것
솔직하게 남아 있는 것들이다.
- 첫 세션의 첫 컷이
AVErrorSessionNotRunning으로 실패하는 원인이 미결이다. iPhone 16 Pro에서 6조건씩 5회를 조건마다 별도 프로세스로 재서(2026-08-25, 30회) 렌즈, 요청 치수, 세션 출력 구성, 화질 우선순위 어느 축으로도 갈리지 않는다는 것까지 확인했다. 세션을 새로 열면 30회 모두 성공하므로 앱은 세션을 껐다 켜고 한 번 더 찍어 컷을 잃지 않는다. 원인을 모른 채 증상만 덮은 상태다. - 저조도 2배율 노이즈를 기본 카메라 수준으로 내리는 방법. 남은 것은 자동 노출이 어두운 장면에서 왜 다른 값을 고르는가인데, 노출을 직접 지정하려면 물리 렌즈를 열어야 하고 그러면 0.5배율과 5배율 도달을 잃는다.
- 손실 두 번을 한 번으로 줄인 이득 자체는 값으로 재지 않았다. 재려면 같은 장면을 JPEG 원본 경로로도 찍어야 하는데 그 경로는 이미 삭제했다.
- 기본 카메라 컷과 앱 저장본의 디테일을 한 표에 넣지 못했다. 2026-08-23 실행에서 프레이밍이 달라 관심 영역 크기가 1568x178과 1120x130으로 갈렸고, Laplacian 분산은 영역 크기에 따라 달라져 그대로 비교할 수 없다.
- 저조도 측정의 목표 조건을 정하지 않았다. 지금까지는 테스트 타깃의 밝기를 10%로 낮춰 어두운 장면을 만들었는데, 그 값이 이 앱을 실제로 쓰는 자리의 밝기라는 근거가 없다.