ondelva

← Archive

iOS 카메라 개발기 - 추측하지 말고 재라

실기기 빌드·로그·시뮬레이터·XcodeGen에서 걸린 것들과, 연재를 마치며 정리한 검증 원칙 다섯 가지. 같은 증상을 두 번째 들으면 추론을 멈춘다.

egg3분 읽기

iOS 카메라 개발기 마지막 편이다. 앞의 일곱 편은 플랫폼 이야기였고, 이번은 개발 환경과 습관 이야기다.

환경은 Xcode 26, macOS 26, 기기는 iPhone 17 Pro와 iOS 26.6.1이다. 2026년 9월 기준이고 도구 쪽은 버전이 바뀌면 달라질 수 있다.

실기기 빌드와 로그

팀 ID는 인증서 subject의 OU 값이다. CN 괄호 안에 있는 값을 팀 ID로 넣었다가 “No Account for Team”을 봤다. 둘 다 10자리 영숫자라 헷갈리기 쉽다.

앱 로그는 os_log보다 print가 편했다. xcrun devicectl device process launch --console로 실행하면 print 출력이 터미널에 바로 찍힌다.

log stream으로 기기 로그를 보려고 했는데 macOS 26의 log stream에는 --device-udid가 없다. 덤으로 zsh에서 log는 빌트인이라 /usr/bin/log로 불러야 한다.

기기가 잠겨 있으면 devicectl 실행이 Locked로 거부된다. 무선 연결에서는 개발자 디스크 이미지 마운트가 실패해서 결국 케이블을 꽂았다.

같은 이름의 기기가 둘이면 xcodebuild의 “Ineligible destinations”에 옛 기기가 떠서 헷갈린다. destination은 이름 말고 id로 준다.

시뮬레이터로 할 수 있는 것과 없는 것

시뮬레이터에는 카메라가 없어서 촬영 화면이 통째로 비어 있다. 방향, 프레임 페이싱, 저장 경로는 전부 실기기에서만 확인된다. 1편부터 3편까지가 거의 다 실기기 이야기였던 이유다.

그래도 SwiftUI 화면 스냅샷은 시뮬레이터 테스트에서 뽑았다. 처음엔 ImageRenderer를 썼는데 NavigationStack을 못 그린다.

그래서 창을 직접 띄워서 그렸다.

let scene = UIApplication.shared.connectedScenes.first as! UIWindowScene
let window = UIWindow(frame: scene.screen.bounds)
window.windowScene = scene            // 없으면 .task가 돌지 않는다
window.rootViewController = UIHostingController(rootView: view)
window.makeKeyAndVisible()

RunLoop.main.run(until: Date().addingTimeInterval(0.5))

let image = UIGraphicsImageRenderer(bounds: window.bounds).image { ctx in
    window.layer.render(in: ctx.cgContext)
}

windowScene을 안 붙이면 .task가 돌지 않아서 데이터가 빈 화면이 찍힌다. 오프스크린이라 애니메이션은 진행되지 않는다.

기기 여러 대로 테스트할 때는 build-for-testing을 한 번만 하고 test-without-building -xctestrun에 destination을 여러 개 준다. 동시 실행은 2대까지였다. 3대로 올리면 부팅 60초 타임아웃에 걸려 한 대가 떨어진다. 14대 도는 데 약 6분이었다.

배포 타깃 하한 OS의 시뮬레이터는 기본으로 안 깔려 있다. xcodebuild -downloadPlatform iOS -buildVersion 17.5로 받는다.

셰이더가 있으면 하나 더. Xcode 26부터 Metal 셰이더를 빌드하려면 xcodebuild -downloadComponent MetalToolchain으로 툴체인을 따로 받아야 한다.

프로젝트 파일

프로젝트는 XcodeGen으로 만든다. resources: 키는 조용히 무시되더라. sources에 type: folder, buildPhase: resources로 넣어야 한다. 이때 번들 루트에 Resources/라는 폴더가 생기면 codesign이 거부한다.

info:나 entitlements:에 배열이나 딕셔너리 값을 넣을 때 path를 안 주면 “Decoding failed at path: Nothing found”가 난다. path를 주면 INFOPLIST_KEY_* 스칼라 키와 자동으로 합쳐진다.

여러 브랜치가 동시에 건드린 project.pbxproj 충돌은 손으로 합치지 않는다. 한쪽으로 되돌리고 재생성하는 게 빠르다. .xcstrings는 JSON이 재배치되면서 줄 단위 병합이 안 돼서 스크립트로 키만 합쳤다.

긴 body에 modifier를 계속 붙이면 결국 “unable to type-check”를 만난다. 핸들러들을 ViewModifier로 묶어서 떼어냈다.

스킴에 연결한 .storekit 로컬 구성은 실기기에서도 살아 있다. 거래 기록은 재설치로 안 지워지고 앱을 삭제해야 한다. 지금 도는 게 로컬 구성인지는 Transaction.environment == .xcode로 구분한다.

결론. 검증 원칙 다섯 가지

연재를 쓰면서 다시 보니 같은 실수를 여러 번 했다. 그래서 원칙으로 적어둔다.

대입 성공은 적용 성공이 아니다. 2편의 해상도가 그랬고 1편의 회전이 그랬다. 넣은 값 말고 resolved 값, 버퍼 크기, 파일 크기를 찍는다.

디버그 프로브는 짧게 넣고 뺀다. 한 번 실행하는 동안 변형마다 print를 찍으면 빌드 한 번에 범인이 나온다. RAW 포맷 목록이 그렇게 풀렸다.

눈 대신 수치. 방향은 루마 상관으로, 화질은 스펙트럼으로, UI 치수는 밝기 단면으로 쟀다. 눈으로 “잘렸다”고 본 게 그라데이션이었다.

같은 증상을 두 번째 들으면 추론을 멈추고 검증 경로부터 만든다. 현지화 버그를 세 번 “고쳤다”. 세 번째 끝에 찾은 원인은 String(localized:locale:)의 locale이 보간 서식에만 쓰이고 로컬라이제이션 테이블을 고르지는 못한다는 것이었다. 20줄짜리 swift 스크립트 하나면 왕복 세 번이 필요 없었다.

Apple 문서를 도구로 읽을 때는 JSON으로. 문서 페이지 HTML은 스크립트로 받으면 빈 페이지다. developer.apple.com/tutorials/data/documentation/<path>.json으로 받으면 본문이 그대로 온다. AI 에이전트에게 문서를 읽힐 때도 이 주소를 준다.

여덟 편을 쓰고 보니 하고 싶은 말은 결국 제목 하나로 줄어드는 것 같다.

물론 이렇게 적어놓고도 다음 앱에서 또 감으로 방향부터 찍고 있을 것 같다… 그래서 적어두는 거다.

Filed under 개발

ShareXFacebookLinkedIn