여행 준비물 앱 iOS 클라이언트. Swift, UIKit + ReactorKit/RxSwift 기반. 공통 원칙은 루트 CLAUDE.md 참조. 이 문서는 iOS 고유 사항만 다룬다.
- 최소 지원: iOS 17.6 / Swift 5.0 (
Packing.xcodeproj/project.pbxproj)⚠️ IPHONEOS_DEPLOYMENT_TARGET이 두 레벨에 따로 있다 — 타겟 레벨(:196Debug /:235Release) 17.6, 프로젝트 레벨(:307/:365) 18.2. 타겟 값이 우선하므로 실효 최소는 17.6이지만, 타겟 설정이 지워지면 조용히 18.2로 올라간다.
- 프로젝트 위치:
Packing/Packing.xcodeproj(워크스페이스 아님, 순수 xcodeproj) - 앱 진입:
Application/AppDelegate.swift+SceneDelegate.swift(UIWindowScene 기반, 최신 구조)
Application/ 앱 생명주기, AuthCoordinator(인증 분기), 화면 코디네이터
Data/ Network(APIClient·Endpoints), Storage(Keychain·UserManager)
Domain/ Models, Services(서버 호출 래퍼)
Presentation/ 기능별 화면. 화면 = ViewController + Reactor 짝
Extensions/ 공용 확장 (파일 3개뿐)
- 네트워크:
Domain/Services/*Service.swift→Data/Network/APIClient.swift. 인증 토큰은 Keychain(KeychainManager)에서 읽어Authorization: Bearer자동 첨부. Extensions/전체:DateFormatter.swift,NotificationConstants.swift,UIComponents.swift(공용 UI 팩토리). 이게 전부다.Presentation/Common/이 공용 컴포넌트 저장소다 — 현재FullScreenImageView(핀치줌 뷰어),ActivityView(공유 시트 래퍼),ButtonStyle,JourneySelectionViewController. 새 공용 UI를 만들기 전 여기부터 확인한다.
- 신규 화면은 ReactorKit로 작성한다.
Presentation/Auth/의ViewModel(MVVM) 방식은 레거시이며 따라 하지 않는다. - SwiftUI는 JourneyDetail 서브트리 +
Presentation/Common/한정. 그 외 전 화면은 UIKit. 새 SwiftUI 도입은 이 경계를 넘지 않도록 신중히. - 레이아웃은 Auto Layout(SnapKit 없음, 순수 앵커/코드). 절대 프레임(
CGRect(...)) 신규 사용 지양 — 회전·다크모드·동적폰트에 취약. 남아 있는 레거시 3곳:Presentation/Journey/AddJourney/View/LocationSearchViewController.swift:87Presentation/User/View/ProfileViewController.swift:269Presentation/Notification/NotificationsViewController.swift:214
- 이미지 로딩은 Kingfisher로 통일(
kf.setImage). SwiftUIAsyncImage는 캐싱 이점이 없어 지양(현재 JourneyDetail만 예외, 정리 대상).
ReactorKit / RxSwift / RxDataSources / Kingfisher / KeychainAccess.
버전은 Package.resolved가 단일 진실 소스다 — 이 문서에 버전 숫자를 복사해두지 않는다(복사본이 낡아 잘못된 TODO를 낳은 전례가 있다).
Podfile·CocoaPods 없음. 의존성 변경은 Xcode SPM UI 또는 pbxproj XCRemoteSwiftPackageReference.
실행 위치는 Packing-iOS/Packing/(Fastfile:7 기준 — 저장소 루트가 아니다).
LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 bundle exec fastlane beta version:1.5LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8 bundle exec fastlane release version:1.5LANG/LC_ALL을 US-UTF8로 준다. 비대화형 셸에서는 로케일이 없어 fastlane이 한글 출력에 인코딩 에러로 죽는다.version:을 명시한다. 빌드 번호는latest_testflight_build_number + 1로 자동 증가하지만(그래서 pbxproj의CURRENT_PROJECT_VERSION은 실제 TestFlight 번호와 다르다), 마케팅 버전은 자동으로 안 오른다. App Store에서 이미 마감된 버전 트레인이면 업로드가 409로 거부된다.
iOS 고유 시크릿은 fastlane/.env와 AuthKey_*.p8 둘이다(gitignore됨).
인증 분기를 건드릴 때 반드시 아는 상태로 시작할 것. 상세는 DEEPLINK-INVITE-HANDOFF.md.
SceneDelegate.handleIncomingURL이 양쪽 형식을 받는다 — 유니버설 링크https://packing-api.iyungui.dev/join/<code>, 커스텀 스킴packingapp://join?code=<code>. 파싱한 코드는AuthCoordinator.handleInviteCode로 넘긴다.handleInviteCode는 참여 전에GET /journeys/preview/:code를 먼저 부른다.- 이미 참가자면(
isParticipant == true) 시트 없이 즉시 참여 — 링크 재탭 마찰은 0으로 유지한다. - 아니면
Presentation/Invite/의 참여 확인 시트를 띄우고, "참여하기"를 누른 뒤에 로그인을 요구한다. - 404면 만료 안내 알럿, 통신 실패면 예전 경로(즉시 참여/로그인 유도)로 폴백한다. 새 기능이 기존 동작을 퇴행시키지 않게 한 것이므로 이 폴백을 지우지 말 것.
- 이미 참가자면(
- 미로그인이면 코드를 UserDefaults
pendingInviteCode(+pendingInviteSavedAt)에 저장하고 로그인을 유도한다. 로그인/가입 완료 후showMainScreen에서redeemPendingInviteIfNeeded가 참여를 처리한다. 저장 후 24시간이 지난 코드는 버린다 — 다른 계정이 남의 코드를 소진하는 걸 막는다. ⚠️ 같은 코드의 중복 처리는inFlightInviteCode가 막는다. 콜드스타트에서start()의 redeem과 SceneDelegate의 0.3초 지연 라우팅이 겹쳐 실제로 두 번 흐른다. 이 가드를 빼면 시트가 두 번 뜬다.- 미리보기는 비로그인도 호출 가능한 유일한 여행 API다.
APIClient.attachesTokenWhenAvailable가 이 엔드포인트만 "토큰 있으면 붙이고, 없어도 그대로 보낸다"로 다룬다. - 코드 형식 판정과 URL 파싱은
Extensions/PackingCode.swift하나로 모았다 (normalize/isValid/extractCode(from:)/extractCode(fromUserInput:)). SceneDelegate(딥링크)와 로그인 화면(클립보드)이 같은 파서를 쓴다 — 두 벌 만들지 말 것. 대소문자를 구분하므로 항상 대문자로 정규화한 뒤 다룬다.⚠️ 정규식 범위를 손으로 쓰지 말 것. 예전^[A-HJ-NP-Z2-9]{8}$는J-N구간 때문에 알파벳에 없는L을 허용하고 있었다.PackingCode는alphabet상수에서 직접 판정한다.
앱을 새로 깐 사람에게서 코드가 끊기지 않게 하는 장치다. 랜딩의 "App Store에서 받기"를 누르면
웹이 초대 링크 전체 URL을 클립보드에 넣고(서버 public/join.v1.js) 스토어로 보낸다.
- 🔴 클립보드 값을 직접 읽지 않는다.
UIPasteboard.general.string을 읽으면 붙여넣기 경고 배너가 뜬다.detectPatterns(for: [.probableWebURL])는 "URL이 있다"는 사실만 알려주고 경고를 띄우지 않으며, 실제 값은 사용자가UIPasteControl을 눌렀을 때paste(itemProviders:)로 들어온다. 이 구분을 깨지 말 것. UIPasteControl은 응답 체인에pasteConfiguration을 가진 responder가 있어야 활성화된다 (LoginViewController.viewDidLoad).- 배너는 URL이 감지될 때만 뜬다. 오탐(무관한 URL)은 붙여넣은 뒤 검증에서 걸러 안내한다.
친구 맺기·친구 검색·친구 코드는 전부 걷어냈다. 비동기 대기 2회(친구 요청 수락 → 초대 수락)를 거쳐야 동행을 추가할 수 있었고, 미가입자는 아예 초대할 수 없었다. 초대 경로는 링크 하나로 단일화됐다. 탭바도 2개(내 여행 / 내 프로필)로 줄었다.
- 서버에는 아직 친구 라우트·모델이 남아 있다(구버전 앱 보호). 제거는 별도 작업이다.
PackingCode는 이제 여행 초대 코드 전용이다(예전엔 친구 코드와 공유했다).
프로젝트가 PBXFileSystemSynchronizedRootGroup을 쓴다 → 새 .swift 파일은 해당 폴더에 넣기만 하면 타겟에 자동 포함된다. project.pbxproj를 수동으로 편집하지 않는다.
현재 테스트 타겟 없음. 추가는 후순위(Phase 0.5+).