diff --git a/.claude/rules/lessons-learned.md b/.claude/rules/lessons-learned.md new file mode 100644 index 0000000..c906737 --- /dev/null +++ b/.claude/rules/lessons-learned.md @@ -0,0 +1,200 @@ +# Lessons Learned (실수 방지 가이드) + +이 파일은 개발 중 발생한 문제와 해결책을 기록하여 같은 실수를 반복하지 않기 위한 문서입니다. + +--- + +## 1. 토큰 검증 전략 (2026-02-25) + +### ❌ 잘못된 접근: 클라이언트에서 JWT 만료 시간 체크 + +```swift +// 하지 말 것! +let expirationDate = Date(timeIntervalSince1970: exp) +let now = Date() // 디바이스 로컬 시간 + +if expirationDate <= now { + // 만료됨으로 판단 → refresh 시도 +} +``` + +**문제점**: +- 디바이스 시간 조작 가능 +- 서버와 클라이언트 시간 불일치 +- 시간대 변경 시 오류 +- 과도한 복잡도 + +### ✅ 올바른 접근: 서버가 판단, 클라이언트는 반응 + +```swift +// Splash에서 +case .isAppeared: + let hasToken = (try? tokenStore.exists()) ?? false + + if hasToken { + // 바로 진입 (만료 여부 체크 안 함) + await send(.delegate(.authenticated)) + } else { + await send(.delegate(.unauthenticated)) + } + +// AuthInterceptor에서 +public func retry(...) { + guard response.statusCode == 401 else { return } + + // 서버가 401 보냄 → 이제 refresh + let success = await refreshTokens() + if success { + completion(.retry) // 원래 요청 재시도 + } +} +``` + +**핵심**: +- **서버만이 토큰 만료를 정확히 판단할 수 있음** +- 클라이언트는 401 응답에 반응만 +- RefreshCoordinator가 중복 refresh 방지 +- 가장 단순하고 안전 + +**참고 프로젝트**: Hambug 앱 (실제 운영 검증됨) + +--- + +## 2. AuthInterceptor의 별도 Session (2026-02-25) + +### 왜 필요한가? + +```swift +public final class AuthInterceptor: RequestInterceptor { + private let session: Session // ← 별도 Session 필수! + + public init(tokenStore: any AuthTokenStoring) { + self.session = Session() // interceptor 없음 + } + + private func refreshTokens() async -> Bool { + // 이 session은 interceptor가 없음 + let response = try await session.request(request) + // adapt 호출 안 됨 → 무한 루프 방지 ✅ + } +} +``` + +**필요한 이유**: +1. **무한 루프 방지**: refresh 요청에 interceptor 적용되면 안 됨 +2. **순환 참조 방지**: NetworkService ↔ AuthInterceptor + +**구조**: +``` +메인 Session (interceptor O) → 일반 API용 +AuthInterceptor 내부 Session (interceptor X) → refresh 전용 +``` + +--- + +## 3. Splash에서 TokenRefreshClient 사용 금지 (2026-02-25) + +### ❌ 문제가 있었던 코드 + +```swift +// Splash에서 +if hasToken { + // 메인 networkService로 refresh 시도 + let isValid = try await tokenRefreshClient.validateAndRefresh() + // → 메인 networkService 사용 + // → AuthInterceptor.adapt 호출 + // → 만료된 access token 추가 + // → 401 실패! +} +``` + +### ✅ 해결책 + +```swift +// Splash에서 +if hasToken { + // refresh 시도 안 함! + await send(.delegate(.authenticated)) +} + +// MainTab 진입 후 첫 API 호출 시 +// → 401 발생 +// → AuthInterceptor.retry → refresh +// → 성공 ✅ +``` + +**핵심**: +- Splash에서는 토큰 존재만 체크 +- refresh는 AuthInterceptor가 자동 처리 +- 단순하고 안전 + +--- + +## 4. RefreshCoordinator의 역할 (2026-02-25) + +### 중복 refresh 방지 + +```swift +private actor RefreshCoordinator { + private var refreshTask: Task? + + func refresh(with refreshTokens: @escaping @Sendable () async -> Bool) async -> Bool { + if let existingTask = refreshTask { + return await existingTask.value // 이미 진행 중이면 기다림 + } + + let task = Task { await refreshTokens() } + refreshTask = task + let result = await task.value + refreshTask = nil + + return result + } +} +``` + +**시나리오**: +``` +MainTab 진입 → 3개 API 동시 호출 → 모두 401 + ↓ +Calendar: AuthInterceptor.retry → RefreshCoordinator +History: AuthInterceptor.retry → RefreshCoordinator (기다림) +MyPage: AuthInterceptor.retry → RefreshCoordinator (기다림) + ↓ +1번만 refresh 실행 ✅ + ↓ +3개 모두 재시도 → 성공 +``` + +**핵심**: 여러 API가 동시에 실패해도 refresh는 1번만 + +--- + +## 5. 아키텍처 결정 시 참고 + +### 다른 프로젝트 패턴 검증 + +**Hambug 프로젝트**: +- Splash: 토큰 존재만 체크 +- refresh: AuthInterceptor에서만 처리 +- 실제 운영 검증됨 + +**교훈**: +- 실제 운영 중인 앱의 패턴을 참고하면 검증된 솔루션을 얻을 수 있음 +- 과도한 최적화보다 단순하고 검증된 방법이 더 안전 + +--- + +## 요약 + +| 항목 | ❌ 하지 말 것 | ✅ 해야 할 것 | +|------|-------------|-------------| +| **토큰 검증** | 클라이언트에서 만료 시간 체크 | 서버 401 응답에 반응 | +| **Splash** | refresh 시도 | 토큰 존재만 체크 | +| **AuthInterceptor** | 메인 Session 사용 | 별도 Session 사용 | +| **복잡도** | 과도한 최적화 | 단순하고 검증된 방법 | + +--- + +**업데이트 일자**: 2026-02-25 +**관련 이슈**: 자동 로그인 기능 구현 diff --git a/Projects/App/Sources/AppFeature.swift b/Projects/App/Sources/AppFeature.swift index 3a93901..60b0bf2 100644 --- a/Projects/App/Sources/AppFeature.swift +++ b/Projects/App/Sources/AppFeature.swift @@ -43,7 +43,11 @@ struct AppFeature { } Reduce { state, action in switch action { - case .splash(.delegate(.finished)): + case .splash(.delegate(.authenticated)): + state.route = .mainTab + return .none + + case .splash(.delegate(.unauthenticated)): state.route = .intro return .none diff --git a/Projects/App/Sources/DoriApp.swift b/Projects/App/Sources/DoriApp.swift index 622c535..0f2fa87 100644 --- a/Projects/App/Sources/DoriApp.swift +++ b/Projects/App/Sources/DoriApp.swift @@ -42,6 +42,7 @@ struct DoriApp: App { self.store = Store(initialState: AppFeature.State()) { AppFeature() } withDependencies: { + $0.authTokenStore = .live(tokenStore: tokenStore) $0.kakaoServerLoginClient = .live( networkService: networkService, tokenStore: tokenStore diff --git a/Projects/Feature/Onboarding/Sources/AuthTokenStoreClient.swift b/Projects/Feature/Onboarding/Sources/AuthTokenStoreClient.swift new file mode 100644 index 0000000..167a81d --- /dev/null +++ b/Projects/Feature/Onboarding/Sources/AuthTokenStoreClient.swift @@ -0,0 +1,74 @@ +// +// AuthTokenStoreClient.swift +// Dori-iOS +// +// Created by 강동영 on 2/25/26. +// + +import Foundation +import ComposableArchitecture +import DoriNetwork + +public struct AuthTokenStoreClient: Sendable { + public var save: @Sendable (_ accessToken: String, _ refreshToken: String?) throws -> Void + public var load: @Sendable () -> (accessToken: String?, refreshToken: String?) + public var clear: @Sendable () throws -> Void + public var exists: @Sendable () throws -> Bool + + public init( + save: @escaping @Sendable (_ accessToken: String, _ refreshToken: String?) throws -> Void, + load: @escaping @Sendable () -> (accessToken: String?, refreshToken: String?), + clear: @escaping @Sendable () throws -> Void, + exists: @escaping @Sendable () throws -> Bool + ) { + self.save = save + self.load = load + self.clear = clear + self.exists = exists + } +} + +extension AuthTokenStoreClient: DependencyKey { + public static let liveValue = Self( + save: { _, _ in }, + load: { (nil, nil) }, + clear: {}, + exists: { false } + ) + + public static let testValue = Self( + save: { _, _ in }, + load: { (nil, nil) }, + clear: {}, + exists: { false } + ) +} + +public extension DependencyValues { + var authTokenStore: AuthTokenStoreClient { + get { self[AuthTokenStoreClient.self] } + set { self[AuthTokenStoreClient.self] = newValue } + } +} + +public extension AuthTokenStoreClient { + static func live(tokenStore: any AuthTokenStoring) -> Self { + Self( + save: { accessToken, refreshToken in + try tokenStore.save( + accessToken: accessToken, + refreshToken: refreshToken + ) + }, + load: { + tokenStore.load() + }, + clear: { + try tokenStore.clear() + }, + exists: { + try tokenStore.exists() + } + ) + } +} diff --git a/Projects/Feature/Onboarding/Sources/SplashView.swift b/Projects/Feature/Onboarding/Sources/SplashView.swift index 418369e..e1b4d7a 100644 --- a/Projects/Feature/Onboarding/Sources/SplashView.swift +++ b/Projects/Feature/Onboarding/Sources/SplashView.swift @@ -8,6 +8,7 @@ import SwiftUI import DoriDesignSystem +import DoriNetwork import ComposableArchitecture public struct SplashView: View { @@ -67,25 +68,38 @@ public struct SplashFeature : Sendable { case delegate(Delegate) public enum Delegate: Equatable, Sendable { - case finished + case authenticated + case unauthenticated } } - + + @Dependency(AuthTokenStoreClient.self) var tokenStore + public func reduce(into state: inout State, action: Action) -> Effect { switch action { case .isAppeared: return .run { send in try await Task.sleep(for: .seconds(1.5)) - await send(.delegate(.finished)) + + // 토큰 존재 여부만 체크 (서버가 만료 여부 판단) + let hasToken = (try? tokenStore.exists()) ?? false + + if hasToken { + // 바로 진입 → API 호출 시 401 발생하면 AuthInterceptor가 자동 refresh + await send(.delegate(.authenticated)) + } else { + await send(.delegate(.unauthenticated)) + } } + case .delegate: return .none } } } -// -//#Preview { -// SplashView(store: Store(initialState: SplashFeature.State(), reducer: { -// SplashFeature() -// })) -//} + +#Preview { + SplashView(store: Store(initialState: SplashFeature.State(), reducer: { + SplashFeature() + })) +}