iPhone 앱 개발
빈 저장소부터 실제 휴대폰에서 실행되는 서명된 빌드까지 SwiftUI 앱을 처음부터 끝까지 출시하면서 얻은 컨벤션입니다.
가정된 컨텍스트: 소규모 앱, 무료 개인 Apple 계정, App Store 배포 없음. 유료 개발자 팀은 아래 몇 가지 규칙을 변경합니다 — 서명 섹션에서 이를 표시합니다.
아키텍처 기본값
- SwiftUI + SwiftData, 로컬 퍼스트. 소규모 앱은 서버 연결이 필요하지 않아야 합니다. 데이터는 기기 내 SwiftData에 저장됩니다. 네트워크 호출은 공개 API에만 사용됩니다 (https, ATS 예외 없음). 앱이 기존 백엔드의 씬 클라이언트로 시작하는 경우, "서버를 없앨 수 있을까?"라는 전환을 예상하세요 — 서버의 로직을 SwiftData 배열 위에서 동작하는 순수 Swift 함수로 포팅하고, 하나의 휴대폰을 위해 서버를 유지하지 마세요.
- SwiftData 모델링: 정수 외래 키가 아닌 실제 객체 관계. 자연 키에는
@Attribute(.unique)를 사용하세요. SwiftData의 to-many 배열은 순서를 유지하지 않습니다 — 순서가 중요할 때는sortOrder필드와sortedX계산 속성을 추가하세요. 날짜는String이 아닌Date로 저장하세요. 열거형은String원시값을 가진Codable로 선언하세요. - 실제 설치에서의 스키마 변경: 모델이나 옵션 속성을 추가하면 다음 실행 시 자동으로 경량 마이그레이션됩니다. 하지만
SchemaMigrationPlan없이 이름 변경, 삭제 또는 타입 변경을 하면 저장소가 열리지 않습니다 — 배포된 앱은 사용자 데이터는 그대로이지만 시작 시 충돌합니다. (동일한 번들 ID로) 재배포해도 데이터 컨테이너는 지워지지 않습니다. 앱을 삭제해야만 지워집니다. 사용자가 실제 데이터를 쌓기 전에 내보내기/백업 경로를 계획하세요. - 뷰는
@Query+modelContext에 직접 접근합니다. 캐싱이 실제로 필요한 경우가 아니라면 Store나 repository 계층은 사용하지 않습니다. 순수 로직 (매칭, 파싱, 임포트)은 열거형 네임스페이스에 위치합니다 (enum Foo { static func … }) — UI 없이 테스트 가능합니다. - 배포 대상: 필요한 API가 달리 명시하지 않는 한 현재 메이저 버전에서 하나를 뺀 버전입니다. 하나의 타겟,
TARGETED_DEVICE_FAMILY: "1"이며, 앱이 회전을 필요로 하지 않는 한 세로 모드입니다.
프로젝트 생성 — XcodeGen, .xcodeproj는 절대 커밋하지 않음
ios/project.yml이 진실의 원천입니다. .xcodeproj 번들은 생성되며 gitignored 됩니다 (ios/build/와 생성된 Info.plist도 gitignore 하세요). 파일을 추가하거나 제거한 후에는 재생성하세요 — xcodegen은 소스 디렉토리를 glob 처리하므로 기존 파일 수정은 아무것도 필요하지 않습니다.
- GitHub 릴리스 zip에서 xcodegen을 설치하세요 (
PREFIX=~/.local ./install.sh) — Homebrew가 필요 없으며, 잠긴 Mac에서도 작동합니다. - 단위 테스트 타겟은
GENERATE_INFOPLIST_FILE: YES가 필요합니다. 그렇지 않으면 테스트 번들 서명에 실패합니다. project.yml에서gatherCoverageData: true로 스킴을 정의하고 테스트 타겟을 연결하세요. 그렇지 않으면xcodebuild test가 실행할 대상을 찾지 못합니다.
개발 루프 — CLI 전용, 기본 시뮬레이터
모든 iOS 프로젝트에는 다음 Makefile 타겟이 있습니다. 시뮬레이터 이름, 기기 ID, 팀 ID를 ?= 변수로 매개변수화하여 기계별 정보가 하드코딩되지 않도록 하세요.
| 타겟 | 설명 |
|---|---|
ios-project | xcodegen generate + Xcode에서 열기 |
ios-sim | 시뮬레이터용 서명되지 않은 빌드, 시뮬레이터 부팅, 설치, 실행 — 기본 루프; 서명 제로, 비밀번호 프롬프트 제로 |
ios-sim-seed | ios-sim 실행 후 --seed-demo로 재실행 |
ios-test | 시뮬레이터에서 xcodebuild test + xccov 타겟별 커버리지 보고서 |
ios-deploy | 연결된 휴대폰용 서명된 빌드 + devicectl 설치 + 실행 |
ios-test는 매 실행마다 커버리지를 출력합니다 — 기본 규칙은 make test가 항상 숫자를 보여주며, 엄격한 임계값은 없습니다.
시뮬레이터 빌드: CODE_SIGNING_ALLOWED=NO는 generic/platform=iOS 컴파일 검사에 적합합니다. 시뮬레이터 대상은 전혀 서명이 필요하지 않습니다. 시뮬레이터와 휴대폰은 별도의 데이터베이스를 가지고 있음을 기억하세요.
항상 효과가 있는 디버그 편의 기능
- 샘플 데이터 시더 (
DemoData.swift,#if DEBUG):.now를 기준으로 계산된 만료/날짜 필드를 가진 큐레이팅된 데이터셋, 멱등성 (재실행해도 추가되지 않음), 세 가지 방식으로 노출됨 — 설정 "Developer" 섹션 버튼,ProcessInfo.processInfo.arguments를 통해 확인되는--seed-demo실행 인자, 그리고ios-sim-seed타겟. 시드가 앱의 주요 상태를 실행하는지 확인하는 테스트를 추가하여 조용히 썩지 않도록 하세요. - 휠 피커 대신 검색 가능한 피커: 목록이 수십 개 항목을 초과할 수 있는 경우 (임포트된 데이터셋이 이를 보장함).
- 카메라/스캐너 기능:
DataScannerViewController.isSupported && .isAvailable을 확인하고 수동 입력 대체 기능을 제공하세요 — 시뮬레이터에서도 정상적으로 작동합니다.
테스팅
- 시뮬레이터에서 XCTest; 다른 스택에서 로직을 포팅할 때는 참조 테스트 스위트도 함께 포팅하여 언어 간 의미를 고정하세요.
- 공유 헬퍼:
isStoredInMemoryOnly: true로 ModelContainer를 만드는makeInMemoryContext();@MainActor테스트 클래스와setUp() async throws. - 결과 번들에서 통과/실패를 읽습니다:
xcrun xcresulttool get test-results summary --path build/test.xcresult.
서명 및 배포 — 문제점 (각각 실제 시간을 소비함)
- 팀 ID는
security find-identity의 괄호 안에 있는 값이 아닙니다 — 그것은 인증서 자체의 ID입니다. 프로비저닝 프로파일(security cms -D -i <profile>→application-identifier접두사) 또는 Xcode의 계정 창에서 실제 팀 ID를 얻으세요. 잘못된 ID는 오해의 소지가 있는 "No Account for Team" 오류를 발생시킵니다. -allowProvisioningUpdates는 일반 터미널에서 Apple ID 세션을 볼 수 없습니다. Xcode GUI에서 한 번 프로비저닝 프로파일을 만드세요 (Signing & Capabilities → Team 설정 → Try Again). 그 후에는DEVELOPMENT_TEAM=<id> CODE_SIGN_STYLE=Automatic으로 순수 CLI 빌드가 작동합니다.- 첫 번째 기기 절차: 휴대폰에서 개발자 모드 활성화 (설정 → 개인정보 보호 및 보안; 이 토글은 Xcode가 기기와 통신한 후에만 나타납니다. 재부팅 필요) → 컴퓨터 신뢰 → 첫 번째 설치 후, 개발자 인증서 신뢰 (설정 → 일반 → VPN 및 기기 관리). Xcode의 "your team has no devices" 오류는 일반적으로 등록 시도가 휴대폰이 재부팅 중이거나 잠겨 있을 때 발생한 것을 의미합니다 — 휴대폰을 실행 대상으로 선택하고 재시도하세요.
- 모든 빌드 시 키체인 프롬프트: 코드 서명 키체인 대화상자에서 항상 허용을 한 번 클릭하면 빌드별 비밀번호 프롬프트가 중단됩니다.
- 무료 개인 팀 제한사항: 설치는 7일 후 만료됩니다 (재배포하여 재서명). 최대 3개의 사이드로드 앱, 푸시 알림 없음, App Groups 없음. 유료 팀은 이 네 가지 제한을 모두 해제합니다 — 앱에 푸시 또는 App Groups가 필요하다면 유료 팀을 가져야 할 트리거입니다.
- 배포/실행 CLI:
xcrun devicectl list devices,xcrun devicectl device install app --device <id> <path>.app,… device process launch --device <id> <bundle-id>.
새 Mac 부트스트랩 (헤드리스 친화적)
- App Store에서 Xcode 설치 (
macappstore://apps.apple.com/app/id497799835). sudo xcode-select -s /Applications/Xcode.app && sudo xcodebuild -license accept && sudo xcodebuild -runFirstLaunch— 관리자 권한 필요; 표준 계정에서는 먼저 관리자 멤버십을 수정하세요 (su admin -c 'sudo dseditgroup -o edit -a <user> -t user admin').xcodebuild -downloadPlatform iOS— iOS SDK + 시뮬레이터 런타임은 Xcode 15 이후 별도의 수 GB 다운로드입니다. 이 없으면 빌드가 "iOS X.Y is not installed" 오류와 함께 실패합니다.DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer는 xcode-select가 고쳐지기 전에 명령별 재정의로 작동합니다.- 릴리스 zip에서
~/.local로 xcodegen 설치 (위 참조).
기존 서버 앱의 동반 클라이언트 — 동기화 계층
iPhone 앱이 이미 서버 + 웹앱이 있는 앱에 합류할 때 (서버가 단일 진실의 원천으로 유지됨), 휴대폰의 사용 사례가 허용하는 가장 저렴한 계층을 선택하세요. 먼저 "휴대폰이 서버 네트워크 밖에서 작동해야 하는가?"와 "캡처만? 전체 편집?"을 물어보세요.
- 씬 클라이언트 — 로컬 저장소 없음; 휴대폰은 기존 API를 호출합니다. 연결이 보장될 때 적합합니다.
- 스냅샷 미러 — 전체 JSON 풀에서 SwiftData 캐시를 전체적으로 다시 빌드합니다 (단일 사용자 DB는 작습니다). 병합 없음 → 툼스톤, 행별 타임스탬프, 충돌 엔진 없음. 서버 측 삭제/편집이 자동으로 반영됩니다. 사진은 URL 캐시를 통해 지연 로드됩니다.
- 스코프 아웃박스 — 오프라인 생성을 이미 존재하는 레코드의 자식으로 제한합니다 (알려진 부모에 대한 로그, 노트, 사진). 부모는 실제 서버 ID를 가지므로 재생은 일반 API 호출입니다 — 정수 PK 스키마는 UUID 마이그레이션이 필요 없습니다. 동기화 이벤트 = 아웃박스 푸시 후 스냅샷 풀 (자체 캡처는 확인되어 돌아옴). 404-고아는 표면화하세요 ("N을(를) 동기화할 수 없습니다"), 절대 조용히 드롭하지 마세요. 보류 중인 배지 + 마지막 동기화 타임스탬프를 표시하세요. 사용자가 고정한 서버 주소가 응답할 때만 자동 동기화 — 절대 외부 네트워크를 프로브하지 마세요.
- 완전 양방향 동기화 (UUID, 툼스톤, 델타 엔드포인트, LWW/CRDT) — 계층 1~3이 충분하지 않다는 증거가 있을 때만; 재작업은 Swift 코드를 작성하기 전에 서버 측에서 시작됩니다 (클라이언트에서 생성 가능한 UUID, 멱등성 upsert,
changes?since=+ 툼스톤).
계층 2와 3은 잘 조합됩니다: 읽기용 스냅샷 미러와 캡처용 스코프 아웃박스는 계층 4의 메커니즘 없이도 "집에서 작동, 어디서나 캡처"를 처리합니다.
프로젝트 위생
- 기계 및 기기별 정보 (팀 ID, 기기 UDID, 시뮬레이터 이름)는 Makefile
?=변수나 프로젝트 자체 노트에 저장하세요 — 스크립트에 절대 하드코딩하지 말고, 이 스킬에도 절대 넣지 마세요. - 진행 중인 각 작업 조각에 대해
plans/<topic>.md문서를 유지하고, 출시된 내용에 대한 최상위CHANGELOG.md를 유지하세요. 작업이 완료되면 계획을plans/done/<topic>.md로 이동하고 "Delivered in — " 헤더를 추가하세요. - 각 계획 조각은 휴대폰이나 시뮬레이터에서 실행 가능한 것으로 끝나도록 크기를 조정하세요. 실행할 수 없는 iOS 조각은 검토할 수 없습니다.