Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,13 @@ xcodebuild -workspace Atcha.xcworkspace -scheme Atcha-Dev -configuration Debug \
-destination 'generic/platform=iOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
```

도구는 mise로 고정(`mise.toml`, Tuist 4.202). `bundle exec`은 로컬에서 동작하지 않음(시스템 ruby 2.6 ↔ Gemfile.lock의 bundler 4.0 비호환) — fastlane은 CI 전용으로 취급.
도구는 mise로 고정(`mise.toml`, Tuist 4.209). **`/usr/local/bin/tuist`에 구버전(3.33.3)이 깔려 있고 PATH에서 mise shim보다 앞선다** — `tuist` 명령을 그냥 부르면 매니페스트 컴파일이 `cannot find type 'DeploymentTargets'`로 실패한다. `mise exec -- tuist ...`도 서브셸(`sh Scripts/bootstrap.sh` 등)에서는 구버전을 타므로, 스크립트를 돌릴 때는 PATH를 직접 앞세울 것:

```bash
export PATH="$(dirname "$(mise which tuist)"):$PATH"
```

`bundle exec`은 로컬에서 동작하지 않음(시스템 ruby 2.6 ↔ Gemfile.lock의 bundler 4.0 비호환) — fastlane은 CI 전용으로 취급.

### 알아야 할 함정

Expand Down Expand Up @@ -80,8 +86,10 @@ AtchaV2(앱, 조합 루트: 어댑터·스플래시·AlarmSyncService) ─► Ho

### 미완 상태 (작업 시 참고)

- **인증은 소셜 로그인(카카오·애플), 가입까지 완결** — 강제 로그인(게스트 없음)으로 `AuthFeature`가 최초 토큰을 발급받는다(`GET /auth/check`·`/auth/login`·`POST /auth/sign-up`, 소셜 토큰을 Bearer로 — plain client 전용). 미가입 계정은 `SignInUseCase`가 온보딩 화면 없이 **최소 가입**(현재 위치+역지오코딩 주소, 실패 시 레거시 실측 폴백 `""`·(0,0), 알림 빈도 [1,10])까지 자동 수행한다. 로그아웃(`POST /auth/logout` — refresh 토큰을 Bearer로 싣는 특례, CoreAuth `AuthSessionManager.signOut()`)·회원탈퇴(`DELETE /members/me`)·유저 정보(`GET /members/me`, 홈주소/알림빈도 PATCH — `UserRepository`)은 `SettingsFeature`(홈 상단 톱니바퀴 진입: 우리집 설정·약관·피드백·버전·로그아웃·계정 탈퇴)로 연결됐다. 알림 빈도는 레거시처럼 미노출. 로그아웃·탈퇴·강제 만료는 전부 `AppCoordinator.handleSessionExpiry`에 합류하고 거기서 `AlarmSessionTeardown`이 로컬 알람·LA·스냅샷·동기화 상태를 비운다(로그아웃은 토큰이 살아 있을 때 서버 알람 취소를 먼저 시도). 홈주소 변경은 `GET /locations/is-service-region` 통과 후 PATCH. 앱 버전은 `GET /app/version`(무토큰, 1.5초 예산·실패 시 통과)으로 권장 업데이트 팝업만. 카카오 키는 `TUIST_KAKAO_APP_KEY` env로 주입(미주입 시 카카오 버튼만 런타임 비활성). 잔여: 카카오/Apple 콘솔에 V2 번들 등록, 서버 audience 허용 확인. `DevDemoFallbacks`(실패 은폐 데모 폴백)와 "DEV 건너뛰기"는 제거됨 — DEV에 남은 것은 막차 변경 주입용 `DevChangeSimulator`(refresh 가로채기 전용, 에러 은폐 없음)뿐이라 서버 실패가 DEV에서도 그대로 표면화된다.
- `AppEnvironment`의 base URL은 dev/live 실주소 반영 완료. **Stage는 dev 호스트를 공유 중** — 전용 호스트만 미정.
- **인증은 게스트 계정 기반(2026-09-24 전환)** — 앱 시작 시 토큰이 없으면 `AppCoordinator.signInAsGuest()`가 `POST /auth/guest` `{deviceId, fcmToken?}`로 JWT를 받아 세션에 채택하고 곧장 홈으로 간다(`DefaultSignInAsGuestUseCase`, plain client 전용 — 토큰이 없는 것이 정상이라 `publicPathSuffixes`에 등록). **로그인 화면 단계가 없다.** `deviceId`는 게스트 계정의 유일한 신원이므로 `KeychainDeviceIdentifierAdapter`가 IDFV를 **키체인에 고정**한다 — IDFV는 앱 전체 삭제 후 재설치 시 값이 바뀌어 기존 계정에 영영 못 돌아가기 때문(키체인 항목은 앱 삭제 후 잔존). 토큰 만료도 로그인 화면이 아니라 조용한 재인증으로 끝난다: `GET /auth/reissue` 실패 → `AppCoordinator.handleSessionExpiry` → 같은 deviceId로 `/auth/guest` 재호출 → 서버가 같은 계정을 돌려준다.
- **소셜 로그인(카카오·애플) 코드는 보존돼 있으나 호출처가 없다** — `AuthFeature`·`SignInUseCase`·`SocialLoginAdapter`·`AppCoordinator.showLogin()`(`@available(deprecated)` 표시)은 그대로 남겨뒀다. 계정 승격이 도입되면 그 경로가 진입점이 된다. 관련 API(`GET /auth/check`·`/auth/login`·`POST /auth/sign-up`, 소셜 토큰을 Bearer로)와 미가입 시 **최소 가입**(현재 위치+역지오코딩 주소, 실패 시 폴백 `""`·(0,0), 알림 빈도 [1,10]) 로직도 유지된다. 로그아웃(`POST /auth/logout` — refresh 토큰을 Bearer로 싣는 특례, CoreAuth `AuthSessionManager.signOut()`)·회원탈퇴(`DELETE /members/me`)·유저 정보(`GET /members/me`, 홈주소/알림빈도 PATCH — `UserRepository`)은 `SettingsFeature`(홈 상단 톱니바퀴 진입: 우리집 설정·약관·피드백·버전·로그아웃·계정 탈퇴)로 연결됐다. 알림 빈도는 레거시처럼 미노출. 로그아웃·탈퇴·강제 만료는 전부 `AppCoordinator.handleSessionExpiry`에 합류하고 거기서 `AlarmSessionTeardown`이 로컬 알람·LA·스냅샷·동기화 상태를 비운다(로그아웃은 토큰이 살아 있을 때 서버 알람 취소를 먼저 시도). 홈주소 변경은 `GET /locations/is-service-region` 통과 후 PATCH. 앱 버전은 `GET /app/version`(무토큰, 1.5초 예산·실패 시 통과)으로 권장 업데이트 팝업만. 카카오 키는 `TUIST_KAKAO_APP_KEY` env로 주입(미주입 시 카카오 버튼만 런타임 비활성). 잔여: 카카오/Apple 콘솔에 V2 번들 등록, 서버 audience 허용 확인. `DevDemoFallbacks`(실패 은폐 데모 폴백)와 "DEV 건너뛰기"는 제거됨 — DEV에 남은 것은 막차 변경 주입용 `DevChangeSimulator`(refresh 가로채기 전용, 에러 은폐 없음)뿐이라 서버 실패가 DEV에서도 그대로 표면화된다.
- `AppEnvironment.apiBaseURL`은 **세 환경 모두 `https://atcha.kro.kr/api` 단일값**(2026-09-24 확정). 이전 호스트 둘은 모두 죽었다 — `atcha.online`은 NXDOMAIN, `atcha.p-e.kr`은 443 연결 불가. **`/api` 접두어는 base URL에서만 붙인다** — 각 `Endpoint.path`는 접두어를 모르므로(`/auth/guest`, `/routes/user-routes` …) 서버가 접두어 체계를 바꾸면 이 프로퍼티 한 줄만 고친다. 환경별 호스트가 다시 생기면 `switch`로 되돌리면 된다.
- **네트워크 진단**: `URLSessionNetworkClient`가 DEBUG에서 `com.atcha.network` 서브시스템에 `메서드 · URL · 상태코드/에러 · 소요시간`을 남긴다(헤더·본문은 토큰이 실리므로 절대 로깅 금지). `NetworkError.debugDescription`이 URLError 코드를 노출해 타임아웃(-1001)·연결 불가(-1004)·TLS 실패(-1200)를 가른다 — 이전에는 전부 `.transport`로 뭉개져 원인 구분이 불가능했다. 부트스트랩 실패는 `BootstrapFailureMessage`를 거쳐 스플래시의 `showRetry`로 표면화된다(게스트 전환으로 부트스트랩이 비동기가 되면서 비로소 쓰이는 경로).
- `Projects/App/Resources/GoogleService-Info.plist`는 **레거시 번들 ID(`com.atcha.iOS`)용 파일**이라 존재 가드만 통과할 뿐 V2(`com.atcha.iOS.v2`)로의 사일런트 푸시가 성립하지 않는다 — V2용 재발급·교체 필요. FCM 토큰은 로그인/가입 파라미터로만 서버에 가고, 갱신 전달은 `SyncPushTokenUseCase`까지 배선됐지만 서버 API 미확정이라 `UnconfirmedPushTokenRepository`(no-op)가 주입돼 있다. 그래서 갱신 채널은 현재 폴링(앱 시작·포그라운드 복귀)과 홈 pull-to-refresh(수동)뿐.
- AtchaV2는 iOS 26 전용. AlarmKit(CoreAlarm)·Live Activity(CoreLiveActivity + AtchaWidget 익스텐션)는 Phase 9~12에서 구축 완료.
- Phase 12 이후의 갭 분석·후속 로드맵: `docs/planning/atcha-v2-post12-roadmap.md` / Phase 13·14(알람 이후 + 재실행 정합성) 구현 프롬프트: `docs/prompts/atcha-v2-session-lifecycle-prompt.md`.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import CoreStorage
import Domain
import Foundation
import UIKit

/// 게스트 계정의 신원(deviceId) 제공자.
///
/// IDFV를 그대로 쓰지 않는 이유: IDFV는 같은 개발사 앱을 **전부 삭제한 뒤 재설치하면
/// 값이 바뀐다**. 서버는 deviceId로 게스트 계정을 되찾아주므로(토큰이 모두 죽어도
/// /auth/guest 재호출로 같은 계정 복귀), 값이 바뀌면 기존 계정의 알림 설정·검색 이력에
/// 영구히 접근할 수 없게 된다. 최초 1회 읽은 IDFV를 키체인에 고정해 재설치를 견딘다
/// (키체인 항목은 앱 삭제 후에도 잔존한다).
struct KeychainDeviceIdentifierAdapter: DeviceIdentifierProviding {
private enum Key {
static let deviceID = "deviceId"
}

private let store: any KeyValueStore

init(store: any KeyValueStore = KeychainStore()) {
self.store = store
}

func currentDeviceID() async -> String {
if let stored = storedDeviceID() { return stored }
let identifier = await freshIdentifier()
// 저장 실패는 치명적이지 않다 — 이번 호출은 유효한 값을 돌려주고,
// 다음 실행에서 IDFV를 다시 읽어 같은 값으로 복구될 가능성이 높다.
try? store.set(Data(identifier.utf8), forKey: Key.deviceID)
return identifier
}

private func storedDeviceID() -> String? {
guard let data = try? store.data(forKey: Key.deviceID) else { return nil }
let value = String(decoding: data, as: UTF8.self)
return value.isEmpty ? nil : value
}

/// IDFV는 기기가 잠긴 직후 등 드물게 nil이다 — 그때는 임의 UUID로 계정을 열고
/// 키체인 고정에 맡긴다(서버 입장에선 새 게스트, 이후로는 안정).
@MainActor
private func freshIdentifier() async -> String {
UIDevice.current.identifierForVendor?.uuidString ?? UUID().uuidString
}
}
63 changes: 54 additions & 9 deletions Projects/App/Sources/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate {

private weak var splashViewController: SplashViewController?
private var sessionExpiryTask: Task<Void, Never>?
/// 게스트 부트스트랩 in-flight — 만료 스트림의 중복 yield나 재시도 연타에
/// /auth/guest를 겹쳐 쏘지 않는다.
private var bootstrapTask: Task<Void, Never>?
/// 로그인 플로우 표시 중 가드 — 만료 스트림의 중복 yield에 로그인을 겹치지 않는다.
private var isShowingLogin = false
/// 세션 만료 경유 재로그인이면 성공 직후 수동 동기화 1회 — activate()의 시작
/// 동기화는 최초 1회 가드라 재로그인 경로에선 돌지 않기 때문.
/// 세션 만료 경유 재인증이면 성공 직후 수동 동기화 1회 — activate()의 시작
/// 동기화는 최초 1회 가드라 재인증 경로에선 돌지 않기 때문.
private var needsSyncAfterLogin = false

init(navigationController: UINavigationController, container: AppDIContainer) {
Expand All @@ -24,6 +27,7 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate {

deinit {
sessionExpiryTask?.cancel()
bootstrapTask?.cancel()
}

func start() {
Expand Down Expand Up @@ -66,16 +70,54 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate {
// 세션 판정은 동기(키체인 존재 여부) — 토큰 유효성은 첫 인증 요청이 증명한다.
switch container.authSessionManager.bootstrapState() {
case .active:
startHome()
// 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만.
container.alarmSyncService.activate()
enterHome()
case .loginRequired:
showLogin()
signInAsGuest()
}
}

/// 강제 로그인 — 스플래시를 root로 유지한 채 로그인 시트를 present한다
private func enterHome() {
startHome()
// 앱 시작 동기화 + 포그라운드 관찰 시작 — 세션이 준비된 뒤에만.
container.alarmSyncService.activate()
}

/// 게스트 부트스트랩(POST /auth/guest) — 토큰이 없으면 deviceId로 계정을 만들거나
/// 되찾는다. 서버 호출이라 여기서 부트스트랩이 비동기가 되고, 그래서 비로소
/// 스플래시의 실패 표면화(showRetry)가 실제로 쓰인다.
private func signInAsGuest() {
// in-flight면 재시도 연타·만료 중복 yield에도 /auth/guest를 겹쳐 쏘지 않는다.
guard bootstrapTask == nil else { return }
let useCase = container.makeSignInAsGuestUseCase()
bootstrapTask = Task { [weak self] in
defer { self?.bootstrapTask = nil }
do {
try await useCase.execute()
guard let self, !Task.isCancelled else { return }
self.enterHome()
// 게스트 가입 시 FCM 토큰이 아직 없었을 수 있다 — 세션이 생긴 직후 한 번 맞춘다.
self.syncPushTokenAfterLogin()
if self.needsSyncAfterLogin {
self.needsSyncAfterLogin = false
let syncService = self.container.alarmSyncService
Task { await syncService.syncNow() }
}
} catch {
guard let self, !Task.isCancelled else { return }
self.splashViewController?.showRetry(
message: BootstrapFailureMessage.text(for: error)
)
}
}
}

/// 소셜 로그인 시트 — 스플래시를 root로 유지한 채 present한다
/// (스플래시 배경 위 바텀시트 = 레거시와 같은 시각 결과).
///
/// 게스트 인증 전환(2026-09-24) 이후 **호출처가 없다.** 서버가 게스트 계정으로
/// 부트스트랩을 처리하므로 강제 로그인 단계 자체가 사라졌다. 소셜 계정 승격이
/// 도입되면 이 경로가 그대로 진입점이 되므로 AuthFeature와 함께 남겨둔다.
@available(*, deprecated, message: "게스트 부트스트랩으로 대체됨. 소셜 계정 승격 도입 시 재사용.")
private func showLogin() {
guard !isShowingLogin else { return }
isShowingLogin = true
Expand Down Expand Up @@ -112,7 +154,7 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate {
}

private func handleSessionExpiry() {
guard !isShowingLogin else { return }
guard bootstrapTask == nil, !isShowingLogin else { return }
needsSyncAfterLogin = true
// 로그아웃·탈퇴·강제 만료 공통 합류점 — 이전 계정의 알람이 로그인 화면에서 울리지 않게
// 로컬 정리를 여기서 한 번 더 보장한다(로그아웃 경로의 선행 정리와 겹쳐도 멱등).
Expand All @@ -127,7 +169,10 @@ final class AppCoordinator: Coordinator, CoordinatorFinishDelegate {
splashViewController = splash
navigationController.setViewControllers([splash], animated: false)
childCoordinators.removeAll()
showLogin()
splash.showLoading()
// 서버 계약: reissue가 죽어도 같은 deviceId로 /auth/guest를 부르면 같은 계정이
// 돌아온다 — 만료가 로그인 화면이 아니라 조용한 재인증으로 끝나는 근거다.
signInAsGuest()
}

/// 로그인 중에 토큰이 갱신됐을 수 있다 — 세션이 생긴 직후 현재 토큰을 한 번 맞춘다.
Expand Down
13 changes: 13 additions & 0 deletions Projects/App/Sources/AppDIContainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ final class AppDIContainer {
// 호출되지만, 실수로 이 데코레이터를 타도 그 Authorization이 서버 access 토큰으로
// 조용히 덮이지 않도록 이중 방어로 전부 public 처리한다(레거시 allowlist 대응).
publicPathSuffixes: [
// 게스트 진입 — 토큰이 없는 것이 정상이므로 빈 Authorization이 붙으면 안 된다.
"/auth/guest",
"/auth/reissue", "/auth/check", "/auth/login", "/auth/sign-up", "/auth/logout",
// 로그인 전 스플래시에서 호출된다 — 토큰이 없어 401 복구가 로그인 라우팅을 촉발하면 안 된다.
"/app/version",
Expand Down Expand Up @@ -144,6 +146,17 @@ final class AppDIContainer {
await liveActivityAdapter.reattachOrphans(snapshot: snapshot, now: Date())
}

/// 게스트 부트스트랩(서버 계약: POST /auth/guest) — 앱 시작 시 토큰이 없으면 이걸로 받는다.
/// plain client: 게스트는 토큰이 없고, 401이 세션 복구를 촉발해서도 안 된다.
func makeSignInAsGuestUseCase() -> any SignInAsGuestUseCase {
DefaultSignInAsGuestUseCase(
authRepository: AuthRepositoryImpl(networkClient: plainNetworkClient),
sessionStore: AuthSessionStoreAdapter(sessionManager: authSessionManager),
deviceIdentifierProvider: KeychainDeviceIdentifierAdapter(),
pushTokenProvider: FCMPushTokenAdapter()
)
}

func makeAuthDIContainer() -> any AuthCoordinatorBuildable {
AuthDIContainer(
signInUseCase: DefaultSignInUseCase(
Expand Down
16 changes: 8 additions & 8 deletions Projects/App/Sources/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,15 @@ enum AppEnvironment {
#endif
}

// Hosts recovered from the legacy trust-evaluator registrations
// (user-approved 2026-08-22). Stage shares the dev host until a dedicated
// one exists.
/// DEV·STAGE·LIVE가 같은 호스트를 쓴다(2026-09-24 확정). 이전 호스트 둘은 모두
/// 죽었다 — atcha.online은 DNS 등록이 사라졌고(NXDOMAIN), atcha.p-e.kr은 DNS는
/// 남았으나 443 연결이 되지 않는다. 환경별 호스트가 다시 생기면 switch로 되돌린다.
///
/// `/api` 접두어는 여기 한 곳에서 붙인다 — URLSessionNetworkClient가
/// baseURL.appendingPathComponent(endpoint.path)로 조립하므로 각 Endpoint의
/// path는 접두어를 몰라도 된다(서버가 접두어 체계를 또 바꾸면 이 줄만 고친다).
var apiBaseURL: URL {
switch self {
case .dev: URL(string: "https://atcha.p-e.kr")!
case .stage: URL(string: "https://atcha.p-e.kr")!
case .live: URL(string: "https://atcha.online")!
}
URL(string: "https://atcha.kro.kr/api")!
}

// TODO: [서버 확인] V2가 레거시 스토어 앱(id6747877903)을 잇는지 확정 — 별도 앱이면 교체.
Expand Down
Loading
Loading