diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..f5a0348 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,378 @@ +# Dori-iOS 개발 지침서 + +--- + +## 1. 프로젝트 개요 + +**앱 이름**: Dori(도리) +**목적**: 경조사 금전 거래(주도리/받도리)를 추적하고 관리하는 iOS 앱 + +### MVP 범위 + +| 구분 | 항목 | +|------|------| +| **IN** | KakaoLogin, Keychain 인증, 3탭(캘린더/내역/마이페이지), 전체 CRUD(등록/조회/수정/삭제), 서버 API 연동 | +| **OUT** | Apple Login, 알림(Push Notification), 내보내기(CSV/PDF), 소셜 기능 | + +--- + +## 2. 기술 스택 + +| 항목 | 버전/설정 | +|------|-----------| +| Swift | 6 | +| iOS Deployment Target | 17.6+ | +| Xcode | 26.2 | +| 아키텍처 | TCA (swift-composable-architecture) 최신 안정 버전 | +| 인증 SDK | KakaoSDK (`KakaoSDKAuth`, `KakaoSDKUser`) | +| 보안 저장소 | KeychainAccess | +| Concurrency | Swift 6 Strict Concurrency | + +### Swift 6 빌드 설정 + +| Build Setting | 값 | +|---|---| +| `SWIFT_APPROACHABLE_CONCURRENCY` | `YES` | +| `SWIFT_DEFAULT_ACTOR_ISOLATION` | `MainActor` | + +--- + +## 3. 아키텍처 + +### 3.1 TCA 단방향 데이터 플로우 + +기존 `temp/` MVVM 프로토타입 코드를 TCA로 전환한다. + +- 각 화면 = 1개 `@Reducer` + 1개 SwiftUI `View` +- 상태 변경은 반드시 Action → Reducer → State 경로를 따른다 +- Side Effect는 `Effect`으로 관리한다 + +### 3.2 Navigation 전략 + +| 범위 | 방식 | 설명 | +|------|------|------| +| App Root | Tree-based | `enum State`로 Intro / MainTab 분기 | +| Tab 내부 (History) | Stack-based | `StackState` / `StackAction`로 list → detail push | +| 모달/시트 | Tree-based | `@Presents` / `ifLet`으로 모달 표시 | + +### 3.3 의존성 관리 + +- TCA `@Dependency` 시스템 사용 +- Protocol 대신 **struct + closure** 패턴 (`DependencyKey` 준수) +- `liveValue` / `previewValue` / `testValue` 3단 분리 + +### 3.4 코드 패턴 참조 + +**자동화 스킬** (boilerplate 생성용): + +| Skill | 용도 | +|-------|------| +| `/tca-reducer` | @Reducer 구조 생성 | +| `/tca-view` | TCA Store 연결 SwiftUI View | +| `/tca-client` | @Dependency Client 구조 | + +**레퍼런스 문서** (분기/도메인 지식 필요): + +| 문서 | 용도 | +|------|------| +| `docs/tca-navigation.md` | Tree/Stack/Tab/AppRoot Navigation 패턴 | +| `docs/tca-test.md` | TestStore 기반 테스트 패턴 | +| `docs/tca-network.md` | API Client + 에러 핸들링 | + +--- + +## 4. Feature 명세 + +### 4.1 Intro (인트로/인증) + +**흐름**: KakaoLogin → 토큰 획득 → Keychain 저장 → MainTab 전환 + +**IntroFeature State**: +- `isLoading: Bool` +- `errorMessage: String?` + +**Dependencies**: +- `KakaoAuthClient` — 카카오 로그인/로그아웃 처리 +- `KeychainClient` — 토큰 저장/조회/삭제 + +**주요 Action**: +- `loginButtonTapped` → `.run`에서 KakaoAuthClient 호출 +- `loginResponse(Result)` → 성공 시 Keychain 저장, 부모에게 전환 알림 + +### 4.2 Calendar (캘린더 탭) + +**기능**: 월별 경조사 캘린더 뷰, 주도리/받도리 필터링 + +**CalendarFeature State**: +- `currentMonth: Date` +- `selectedType: TransactionType` — `.given` / `.received` +- `transactions: [Transaction]` + +**주요 컴포넌트**: +- `MonthSelectorView` — 이전/다음 월 네비게이션 +- `DoriSegmentControl` — 주도리/받도리 전환 +- `CalendarGridView` — 7열 그리드 캘린더 +- `AmountLabel` — 금액 표시 + +**FAB(Floating Action Button)**: 새 거래 등록 화면 진입 + +### 4.3 History (내역 탭) + +**기능**: 인물 목록 + 검색 → 상세 내역 (Stack navigation) + +**HistoryListFeature State**: +- `persons: [Person]` +- `searchText: String` +- `path: StackState` + +**HistoryDetailFeature State**: +- `person: Person` +- `transactions` (person 내 거래 내역) +- 수정/삭제 기능 + +**주요 컴포넌트**: +- `PersonCardView` — 인물 카드 (이름, 관계, 바 그래프, 합계) +- `DoriBarGraphView` — 주도리/받도리 비율 바 차트 +- `TransactionRowView` — 개별 거래 행 +- `DateHeaderView` — 날짜 헤더 + +### 4.4 MyPage (마이페이지 탭) + +**기능**: 사용자 프로필 (카카오 정보), 로그아웃, 회원 탈퇴 + +**MyPageFeature State**: +- `userName: String` +- `profileImageURL: URL?` + +**주요 Action**: +- `logoutButtonTapped` → 로그아웃 확인 Alert +- `deleteAccountButtonTapped` → 회원 탈퇴 확인 Alert +- `confirmLogout` → KakaoAuthClient.logout + Keychain 삭제 +- `confirmDeleteAccount` → KakaoAuthClient.unlink + Keychain 삭제 + +--- + +## 5. 네트워크 레이어 + +### 5.1 설계 원칙 + +- API Client를 TCA `@Dependency`로 정의 (인터페이스 우선 설계) +- 서버 API 스펙 전달 전까지 **Mock 구현**으로 개발 진행 +- `liveValue` / `previewValue` / `testValue` 3단 분리 + +### 5.2 API Client 구조 + +```swift +@DependencyClient +struct DataClient: Sendable { + var fetchPersons: @Sendable () async throws -> [Person] + var fetchPerson: @Sendable (UUID) async throws -> Person + var fetchTransactions: @Sendable (Date, TransactionType?) async throws -> [Transaction] + var deletePerson: @Sendable (UUID) async throws -> Void + // ... CRUD 메서드 추가 +} +``` + +### 5.3 에러 핸들링 + +```swift +enum APIError: Error, Equatable, Sendable { + case networkError(String) + case decodingError + case unauthorized + case serverError(Int) + case unknown +} +``` + +### 5.4 코드 패턴 + +`/tca-network` skill 참조 + +--- + +## 6. Swift 6 Concurrency 가이드 + +### 6.1 프로젝트 설정 + +- `MainActor` 기본 격리 (`SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`) +- Approachable Concurrency ON (`SWIFT_APPROACHABLE_CONCURRENCY = YES`) + +### 6.2 규칙 + +| 항목 | 규칙 | +|------|------| +| 도메인 모델 | `Sendable` + `Equatable` 준수 | +| Dependency Client struct | `Sendable` 준수 | +| Client closure 프로퍼티 | `@Sendable` 어노테이션 | +| `@unchecked Sendable` | **사용 금지** | +| TCA Effect 내 async 작업 | `.run { send in }` 패턴 사용 | + +### 6.3 도메인 모델 예시 + +```swift +struct Person: Identifiable, Codable, Equatable, Sendable { + let id: UUID + var name: String + var relationship: Relationship + var transactions: [Transaction] +} +``` + +### 6.4 Effect 패턴 + +```swift +case .loginButtonTapped: + state.isLoading = true + return .run { send in + let result = await Result { + try await kakaoAuthClient.login() + } + await send(.loginResponse(result)) + } +``` + +--- + +## 7. 디렉토리 구조 + +``` +Dori-iOS/ + DoriApp.swift + Core/ + Models/ → Person, Transaction, Relationship, TransactionType + Clients/ → DataClient, KeychainClient, KakaoAuthClient + DataClient.swift + DataClient+Live.swift + DataClient+Mock.swift + KeychainClient.swift + KeychainClient+Live.swift + KakaoAuthClient.swift + KakaoAuthClient+Live.swift + DesignSystem/ + Components/ → AmountLabel, PersonCardView, DoriBarGraphView, + TransactionRowView, DateHeaderView, + DoriSegmentControl, MonthSelectorView, + CalendarGridView, PrimaryButton, + DoriCommonAlert, AlertButton, + FloatingActionButton + Extensions/ → Color+Extensions, Date+Extensions, Int+Extensions + Feature/ + App/ → AppFeature (Root reducer), AppView + AppFeature.swift + AppView.swift + Intro/ → IntroFeature, IntroView + IntroFeature.swift + IntroView.swift + MainTab/ → MainTabFeature, MainTabView + MainTabFeature.swift + MainTabView.swift + Calendar/ → CalendarFeature, CalendarView + CalendarFeature.swift + CalendarView.swift + History/ + HistoryList/ → HistoryListFeature, HistoryListView + HistoryListFeature.swift + HistoryListView.swift + HistoryDetail/ → HistoryDetailFeature, HistoryDetailView + HistoryDetailFeature.swift + HistoryDetailView.swift + MyPage/ → MyPageFeature, MyPageView + MyPageFeature.swift + MyPageView.swift +``` + +### 의존 방향 + +``` +Feature/ → Core/Clients/ → Core/Models/ +Feature/ → Core/DesignSystem/ +``` + +- **모듈화 대비**: `Core/` → `DoriCore`, `DoriClients`, `DoriUI` 패키지로 분리 가능하도록 의존 방향을 유지한다 +- **Feature 간 직접 참조 금지**: Feature 간 통신은 부모 Reducer를 통한다 + +--- + +## 8. 코딩 컨벤션 + +### 8.1 네이밍 + +| 구분 | 패턴 | 예시 | +|------|------|------| +| Feature Reducer | `{Screen}Feature` | `CalendarFeature`, `IntroFeature` | +| View | `{Screen}View` | `CalendarView`, `IntroView` | +| TCA Client | `{Service}Client` | `DataClient`, `KeychainClient` | +| Extension 파일 | `{Type}+{Purpose}.swift` | `Color+Extensions.swift` | +| Live 구현 | `{Client}+Live.swift` | `DataClient+Live.swift` | +| Mock 구현 | `{Client}+Mock.swift` | `DataClient+Mock.swift` | + +### 8.2 파일 구조 + +각 파일은 아래 순서를 따른다: + +```swift +import ComposableArchitecture +import SwiftUI + +// 1. 타입 정의 (Reducer 또는 View) +@Reducer +struct CalendarFeature { + // ... +} + +// 2. Preview (View 파일인 경우) +#Preview { + CalendarView(store: Store(initialState: .init()) { + CalendarFeature() + }) +} +``` + +### 8.3 문자열 규칙 + +- UI 표시 문자열: **한국어** (`"로그아웃"`, `"주도리"`, `"받도리"`) +- 코드 식별자: **영어** (`loginButtonTapped`, `fetchPersons`) + +--- + +## 9. Git 전략 + +### 9.1 브랜치 구조 + +| 브랜치 | 용도 | +|--------|------| +| `master` | 릴리즈 | +| `develop` | 통합/CI | +| `feature/{issue}-{desc}` | 기능 개발 | + +### 9.2 PR 흐름 + +``` +feature/* → develop → master +``` + +### 9.3 민감 설정 관리 + +- Kakao App Key 등 민감 값: `.xcconfig` 파일로 관리 +- `.xcconfig` 파일은 `.gitignore`에 등록하여 저장소에 포함하지 않는다 + +--- + +## 10. 테스트 전략 + +### 10.1 Reducer 테스트 + +- TCA `TestStore`를 사용하여 Reducer 로직을 검증한다 +- `withDependencies`로 Mock 의존성을 주입한다 +- `store.send()` → State 변경 검증 +- `store.receive()` → Effect 결과 검증 +- 코드 패턴: `/tca-test` skill 참조 + +### 10.2 유틸리티 테스트 + +- `Date+Extensions`, `Int+Extensions` 등 Extension 유틸 함수 단위 테스트 + +### 10.3 MVP 제외 항목 + +- UI 테스트 / 스냅샷 테스트는 MVP에서 제외 diff --git a/.claude/skills/github-issue/SKILL.md b/.claude/skills/github-issue/SKILL.md new file mode 100644 index 0000000..ee9cf1b --- /dev/null +++ b/.claude/skills/github-issue/SKILL.md @@ -0,0 +1,99 @@ +--- +name: github-issue +description: GitHub 피처 이슈를 생성한다. 피처 개요/목표/구현 범위를 대화형으로 수집하여 이슈 자동 생성. +--- + +# GitHub 피처 이슈 생성 + +`.github/ISSUE_TEMPLATE/피처-생성.md` 템플릿 구조에 맞는 GitHub 이슈를 대화형으로 생성한다. + +## 지침 + +### 1단계: 필수 정보 수집 + +`AskUserQuestion`으로 아래 정보를 수집한다. + +**첫 번째 질문 (3개 동시):** +- 피처 이름 (필수) — 이슈 제목에 사용 +- 피처 개요 (필수) — 구현할 피처에 대한 간단한 설명 +- 목표 (필수) — 이 피처로 달성하고자 하는 것 + +**두 번째 질문:** +- 구현 범위 (multiSelect) — UI/UX, 아키텍처, 네트워킹, 데이터 계층 중 해당 항목 선택 + +### 2단계: 선택 정보 수집 + +`AskUserQuestion`으로 추가 정보를 수집한다. 사용자가 건너뛸 수 있도록 "없음" 옵션을 제공한다. + +- 의존성 이슈 번호 (선택) — 관련 이슈 번호 (예: #12, #15) +- 기술 고려사항 (선택) — 특별히 주의해야 할 기술적 사항 + +### 3단계: 이슈 본문 조립 + +수집한 정보를 아래 마크다운 구조로 조립한다: + +```markdown +## 📋 피처 개요 +{사용자 입력} + +## 🎯 목표 +{사용자 입력} + +## 📐 구현 범위 + +### UI/UX (선택된 경우만 포함) +- [ ] 화면 구성 +- [ ] 사용자 인터랙션 +- [ ] 애니메이션/트랜지션 +- [ ] 다크모드 지원 +- [ ] 접근성 고려사항 + +### 아키텍처 (선택된 경우만 포함) +- [ ] Feature 모듈 위치: `Features/[FeatureName]` +- [ ] State 정의 +- [ ] Action 정의 +- [ ] Reducer 로직 +- [ ] 의존성 주입 + +### 네트워킹 (선택된 경우만 포함) +- [ ] API 엔드포인트 +- [ ] Request/Response 모델 +- [ ] 에러 핸들링 + +### 데이터 계층 (선택된 경우만 포함) +- [ ] 로컬 저장소 (UserDefaults/CoreData/Realm) +- [ ] 캐싱 전략 + +## 🔗 의존성 +{사용자 입력 또는 "없음"} + +## ✅ 완료 조건 (Definition of Done) +- [ ] 코드 작성 완료 +- [ ] Unit Test 작성 +- [ ] UI Test 작성 (필요시) +- [ ] 코드 리뷰 완료 +- [ ] QA 테스트 통과 +- [ ] 문서화 완료 + +## 📝 기술 고려사항 +{사용자 입력 또는 "없음"} +``` + +### 4단계: 이슈 생성 + +아래 명령어로 이슈를 생성한다: + +```bash +gh issue create --title "[Feature] {피처 이름}" --assignee kangddong --body "{조립된 본문}" +``` + +- 본문은 HEREDOC(`cat <<'EOF' ... EOF`)을 사용하여 전달한다 +- 생성 완료 후 이슈 URL을 사용자에게 반환한다 + +## 규칙 + +- 피처 이름, 개요, 목표는 반드시 수집해야 한다 +- 구현 범위에서 선택되지 않은 섹션은 본문에서 제외한다 +- 의존성과 기술 고려사항은 선택 사항이며, 입력이 없으면 "없음"으로 표기한다 +- 완료 조건은 템플릿 기본값을 그대로 사용한다 +- 이슈 제목 형식: `[Feature] {피처 이름}` diff --git a/.claude/skills/tca-client/SKILL.md b/.claude/skills/tca-client/SKILL.md new file mode 100644 index 0000000..820421f --- /dev/null +++ b/.claude/skills/tca-client/SKILL.md @@ -0,0 +1,136 @@ +--- +name: tca-client +description: TCA @Dependency Client를 생성한다. 서비스명+엔드포인트만 변경하면 되는 boilerplate 자동 생성. +--- + +# TCA Dependency Client 생성 + +사용자가 요청한 서비스에 대한 TCA `@Dependency` Client를 생성한다. + +## 지침 + +1. `docs/GUIDE.md` 지침서를 읽고 프로젝트 컨텍스트를 파악한다 +2. 기존 Client 코드가 있는지 `Core/Clients/` 디렉토리를 확인한다 +3. 아래 템플릿을 기반으로 Client 정의, Live 구현, Mock 구현 파일을 생성한다 + +## 템플릿: Client 정의 + +```swift +// {Service}Client.swift +import ComposableArchitecture +import Foundation + +@DependencyClient +struct {Service}Client: Sendable { + var fetch: @Sendable () async throws -> [Item] + var get: @Sendable (_ id: UUID) async throws -> Item + var create: @Sendable (_ item: Item) async throws -> Item + var update: @Sendable (_ item: Item) async throws -> Item + var delete: @Sendable (_ id: UUID) async throws -> Void +} + +extension {Service}Client: TestDependencyKey { + static let previewValue = Self( + fetch: { [.mock] }, + get: { _ in .mock }, + create: { $0 }, + update: { $0 }, + delete: { _ in } + ) + + static let testValue = Self() +} + +extension DependencyValues { + var {service}Client: {Service}Client { + get { self[{Service}Client.self] } + set { self[{Service}Client.self] = newValue } + } +} +``` + +## 템플릿: Live 구현 + +```swift +// {Service}Client+Live.swift +import ComposableArchitecture +import Foundation + +extension {Service}Client: DependencyKey { + static let liveValue = Self( + fetch: { + // URLSession 기반 실제 API 호출 + let (data, _) = try await URLSession.shared.data( + from: URL(string: "https://api.example.com/items")! + ) + return try JSONDecoder().decode([Item].self, from: data) + }, + get: { id in + let (data, _) = try await URLSession.shared.data( + from: URL(string: "https://api.example.com/items/\(id)")! + ) + return try JSONDecoder().decode(Item.self, from: data) + }, + create: { item in + // POST 요청 구현 + item + }, + update: { item in + // PUT 요청 구현 + item + }, + delete: { id in + // DELETE 요청 구현 + } + ) +} +``` + +## 템플릿: Mock 구현 (서버 API 스펙 전달 전 사용) + +```swift +// {Service}Client+Mock.swift +import ComposableArchitecture +import Foundation + +extension {Service}Client { + static let mock = Self( + fetch: { + // MockDataService에서 가져온 샘플 데이터 반환 + [] + }, + get: { _ in + .mock + }, + create: { $0 }, + update: { $0 }, + delete: { _ in } + ) +} +``` + +## 규칙 + +- Client 이름: `{Service}Client` (예: `DataClient`, `KeychainClient`, `KakaoAuthClient`) +- struct은 `Sendable` 준수 필수 +- 모든 closure 프로퍼티는 `@Sendable` 어노테이션 +- `@unchecked Sendable` 사용 금지 +- `@DependencyClient` 매크로가 unimplemented 기본값 init을 생성하므로 `testValue = Self()`로 간단히 선언 가능 +- `liveValue`, `previewValue`, `testValue` 3단 분리 + +## 파일 위치 + +``` +Dori-iOS/Core/Clients/ + {Service}Client.swift → Client 정의 + TestDependencyKey + DependencyValues + {Service}Client+Live.swift → DependencyKey (liveValue) + {Service}Client+Mock.swift → Mock 구현 (개발용) +``` + +## 프로젝트 주요 Client 목록 + +| Client | 역할 | +|--------|------| +| `DataClient` | 경조사 데이터 CRUD (Person, Transaction) | +| `KeychainClient` | 토큰 저장/조회/삭제 | +| `KakaoAuthClient` | 카카오 로그인/로그아웃/회원탈퇴 | diff --git a/.claude/skills/tca-reducer/SKILL.md b/.claude/skills/tca-reducer/SKILL.md new file mode 100644 index 0000000..4c6cf9e --- /dev/null +++ b/.claude/skills/tca-reducer/SKILL.md @@ -0,0 +1,99 @@ +--- +name: tca-reducer +description: TCA @Reducer 구조를 생성한다. Feature별 State/Action/body 골격을 자동 생성. +--- + +# TCA Reducer 생성 + +사용자가 요청한 Feature에 대한 TCA `@Reducer`를 생성한다. + +## 지침 + +1. `docs/GUIDE.md` 지침서를 읽고 프로젝트 컨텍스트를 파악한다 +2. 기존 코드의 패턴과 네이밍 컨벤션을 확인한다 +3. 아래 템플릿을 기반으로 Feature에 맞는 Reducer를 생성한다 + +## 템플릿 + +```swift +import ComposableArchitecture +import Foundation + +@Reducer +struct {Feature}Feature { + @ObservableState + struct State: Equatable { + // 화면에 필요한 상태를 정의한다 + // 예: var items: [Item] = [] + // var isLoading = false + // var errorMessage: String? + } + + enum Action { + // 사용자 액션 (View에서 발생) + // 예: case onAppear + // case itemTapped(Item) + // case deleteButtonTapped + + // 내부 액션 (Effect 결과) + // 예: case itemsResponse(Result<[Item], Error>) + } + + // 의존성 선언 + @Dependency(\.dataClient) var dataClient + // @Dependency(\.dismiss) var dismiss + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .onAppear: + state.isLoading = true + return .run { send in + let result = await Result { + try await dataClient.fetchItems() + } + await send(.itemsResponse(result)) + } + + case let .itemsResponse(.success(items)): + state.isLoading = false + state.items = items + return .none + + case let .itemsResponse(.failure(error)): + state.isLoading = false + state.errorMessage = error.localizedDescription + return .none + } + } + } +} +``` + +## 규칙 + +- Reducer 이름: `{Screen}Feature` (예: `CalendarFeature`, `IntroFeature`) +- State: `@ObservableState struct State: Equatable` +- 모든 State 프로퍼티는 `Equatable` + `Sendable` 타입이어야 한다 +- Action enum은 사용자 액션과 내부 액션(Effect 결과)을 구분하여 정의한다 +- 비동기 작업은 `.run { send in }` 패턴을 사용한다 +- 자식 Feature 합성이 필요한 경우 `Scope`를 사용한다 +- Navigation이 필요한 경우 `docs/tca-navigation.md` 레퍼런스를 참조한다 + +## 자식 Feature 합성 예시 + +```swift +var body: some ReducerOf { + Scope(state: \.childFeature, action: \.childFeature) { + ChildFeature() + } + Reduce { state, action in + // ... + } +} +``` + +## 파일 위치 + +- `Dori-iOS/Feature/{FeatureName}/{Feature}Feature.swift` +- 예: `Dori-iOS/Feature/Calendar/CalendarFeature.swift` diff --git a/.claude/skills/tca-view/SKILL.md b/.claude/skills/tca-view/SKILL.md new file mode 100644 index 0000000..f455c67 --- /dev/null +++ b/.claude/skills/tca-view/SKILL.md @@ -0,0 +1,116 @@ +--- +name: tca-view +description: TCA Store를 연결하는 SwiftUI View를 생성한다. Store 주입 + Preview 패턴 자동 생성. +--- + +# TCA View 생성 + +사용자가 요청한 Feature에 대한 TCA Store 연결 SwiftUI View를 생성한다. + +## 지침 + +1. `docs/GUIDE.md` 지침서를 읽고 프로젝트 컨텍스트를 파악한다 +2. 대응하는 Feature Reducer가 이미 존재하는지 확인한다 +3. 기존 View 코드(`temp/` 또는 `Feature/`)의 UI 구조를 참고한다 +4. 아래 템플릿을 기반으로 View를 생성한다 + +## 템플릿 + +```swift +import ComposableArchitecture +import SwiftUI + +struct {Feature}View: View { + let store: StoreOf<{Feature}Feature> + + var body: some View { + // iOS 17+ @ObservableState를 사용하므로 + // WithPerceptionTracking은 불필요하다 + NavigationStack { + content + .navigationTitle("제목") + } + .onAppear { + store.send(.onAppear) + } + } + + private var content: some View { + List { + ForEach(store.items) { item in + Button { + store.send(.itemTapped(item)) + } label: { + Text(item.name) + } + } + } + } +} + +#Preview { + {Feature}View( + store: Store(initialState: {Feature}Feature.State()) { + {Feature}Feature() + } + ) +} +``` + +## Store 주입 방식: `let store` vs `@Bindable var store` + +- **`let store`**: 읽기 전용 접근 + `store.send()` 만 사용하는 경우 +- **`@Bindable var store`**: `$store.scope(...)`, `$store.property.sending(...)` 등 바인딩이 필요한 경우 + - Alert: `.alert($store.scope(state: \.alert, action: \.alert))` + - Sheet: `.sheet(item: $store.scope(state: \.destination?.edit, action: \.destination.edit))` + - NavigationStack: `NavigationStack(path: $store.scope(state: \.path, action: \.path))` + - TabView selection: `TabView(selection: $store.selectedTab.sending(\.tabSelected))` + +## 규칙 + +- View 이름: `{Screen}View` (예: `CalendarView`, `IntroView`) +- iOS 17+ `@ObservableState`를 사용하므로 `WithPerceptionTracking`은 사용하지 않는다 +- 액션 전송: `store.send(.actionName)` +- UI 문자열은 한국어로 작성한다 +- 파일 구조: `import` → View 정의 → `#Preview` 순서 + +## Alert 표시 패턴 + +```swift +.alert($store.scope(state: \.alert, action: \.alert)) +``` + +## Sheet/FullScreenCover 패턴 + +```swift +.sheet(item: $store.scope(state: \.destination?.edit, action: \.destination.edit)) { store in + EditView(store: store) +} +``` + +## Stack Navigation (리스트 → 디테일) + +```swift +struct HistoryListView: View { + @Bindable var store: StoreOf + + var body: some View { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + List { + ForEach(store.persons) { person in + NavigationLink(state: HistoryDetailFeature.State(person: person)) { + PersonCardView(person: person) + } + } + } + } destination: { store in + HistoryDetailView(store: store) + } + } +} +``` + +## 파일 위치 + +- `Dori-iOS/Feature/{FeatureName}/{Feature}View.swift` +- 예: `Dori-iOS/Feature/Calendar/CalendarView.swift` diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9fa8508 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,37 @@ + +## About this PR +### ⚓ Related Issue + +- #1 + +
+ +### 🥥 Contents + + +이런 작업을 했습니다~ + +
+ +### 📸 Screenshot + +|작업1|작업2| +|--|--| +||| + +
+ +## Other information 🔥 + + + +
+ + + diff --git a/docs/tca-navigation.md b/docs/tca-navigation.md new file mode 100644 index 0000000..787f6dc --- /dev/null +++ b/docs/tca-navigation.md @@ -0,0 +1,275 @@ +# TCA Navigation 패턴 레퍼런스 + +Navigation 유형에 따라 적절한 패턴(Tree/Stack/Tab/AppRoot)을 선택하여 적용한다. + +## 참조 + +- `docs/GUIDE.md` 지침서의 Navigation 전략 섹션을 함께 참고한다 + +--- + +## 패턴 1: Tree-based Navigation (모달, 시트, Alert) + +App Root에서 Intro/MainTab 분기, 또는 모달/시트 표시에 사용한다. + +### Reducer + +```swift +@Reducer +struct ParentFeature { + @Reducer + enum Destination { + case edit(EditFeature) + case detail(DetailFeature) + } + + @ObservableState + struct State: Equatable { + @Presents var destination: Destination.State? + } + + enum Action { + case destination(PresentationAction) + case editButtonTapped + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .editButtonTapped: + state.destination = .edit(EditFeature.State()) + return .none + + case .destination: + return .none + } + } + .ifLet(\.$destination, action: \.destination) + } +} +``` + +### View + +```swift +struct ParentView: View { + @Bindable var store: StoreOf + + var body: some View { + content + .sheet( + item: $store.scope( + state: \.destination?.edit, + action: \.destination.edit + ) + ) { store in + EditView(store: store) + } + } +} +``` + +--- + +## 패턴 2: Stack-based Navigation (Push) + +History 리스트 → 디테일 등 push navigation에 사용한다. + +### Reducer + +```swift +@Reducer +struct HistoryListFeature { + @ObservableState + struct State: Equatable { + var persons: [Person] = [] + var path = StackState() + } + + enum Action { + case path(StackActionOf) + case personTapped(Person) + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case let .personTapped(person): + state.path.append(HistoryDetailFeature.State(person: person)) + return .none + + case .path: + return .none + } + } + .forEach(\.path, action: \.path) { + HistoryDetailFeature() + } + } +} +``` + +### View + +```swift +struct HistoryListView: View { + @Bindable var store: StoreOf + + var body: some View { + NavigationStack(path: $store.scope(state: \.path, action: \.path)) { + List { + ForEach(store.persons) { person in + Button { + store.send(.personTapped(person)) + } label: { + PersonCardView(person: person) + } + } + } + } destination: { store in + HistoryDetailView(store: store) + } + } +} +``` + +--- + +## 패턴 3: Tab Navigation (iOS 17+) + +MainTab에서 자식 Feature들을 탭으로 합성한다. + +### Reducer + +```swift +@Reducer +struct MainTabFeature { + @ObservableState + struct State: Equatable { + var selectedTab: Tab = .calendar + var calendar = CalendarFeature.State() + var historyList = HistoryListFeature.State() + var myPage = MyPageFeature.State() + + enum Tab: Equatable { + case calendar, history, myPage + } + } + + enum Action { + case tabSelected(State.Tab) + case calendar(CalendarFeature.Action) + case historyList(HistoryListFeature.Action) + case myPage(MyPageFeature.Action) + } + + var body: some ReducerOf { + Scope(state: \.calendar, action: \.calendar) { + CalendarFeature() + } + Scope(state: \.historyList, action: \.historyList) { + HistoryListFeature() + } + Scope(state: \.myPage, action: \.myPage) { + MyPageFeature() + } + Reduce { state, action in + switch action { + case let .tabSelected(tab): + state.selectedTab = tab + return .none + case .calendar, .historyList, .myPage: + return .none + } + } + } +} +``` + +### View (iOS 17+ tabItem API) + +```swift +struct MainTabView: View { + @Bindable var store: StoreOf + + var body: some View { + TabView(selection: $store.selectedTab.sending(\.tabSelected)) { + CalendarView( + store: store.scope(state: \.calendar, action: \.calendar) + ) + .tag(MainTabFeature.State.Tab.calendar) + .tabItem { Label("캘린더", systemImage: "calendar") } + + HistoryListView( + store: store.scope(state: \.historyList, action: \.historyList) + ) + .tag(MainTabFeature.State.Tab.history) + .tabItem { Label("내역", systemImage: "list.bullet") } + + MyPageView( + store: store.scope(state: \.myPage, action: \.myPage) + ) + .tag(MainTabFeature.State.Tab.myPage) + .tabItem { Label("마이페이지", systemImage: "person.circle") } + } + } +} +``` + +--- + +## 패턴 4: App Root (Intro → MainTab 분기) + +`@Reducer enum`을 사용하여 앱 루트 상태를 분기한다. + +### Reducer + +```swift +@Reducer +enum AppFeature { + case intro(IntroFeature) + case mainTab(MainTabFeature) +} +``` + +TCA의 `@Reducer enum`은 자동으로 State/Action/body를 합성한다. +로그인 완료 시 상태 전환이 필요한 경우, 부모에서 처리하거나 +별도의 AppCoordinator Reducer를 두어 전환 로직을 관리한다. + +### View + +```swift +struct AppView: View { + let store: StoreOf + + var body: some View { + switch store.state { + case .intro: + if let introStore = store.scope(state: \.intro, action: \.intro) { + IntroView(store: introStore) + } + case .mainTab: + if let mainTabStore = store.scope(state: \.mainTab, action: \.mainTab) { + MainTabView(store: mainTabStore) + } + } + } +} +``` + +--- + +## 프로젝트 Navigation 맵 + +``` +AppFeature (@Reducer enum) +├── IntroFeature +└── MainTabFeature (Tab: Scope) + ├── CalendarFeature + │ └── (Tree: 거래 등록 모달) + ├── HistoryListFeature (Stack: StackState) + │ └── HistoryDetailFeature + │ └── (Tree: 수정 모달, 삭제 Alert) + └── MyPageFeature + └── (Tree: 로그아웃/탈퇴 Alert) +``` diff --git a/docs/tca-network.md b/docs/tca-network.md new file mode 100644 index 0000000..b968199 --- /dev/null +++ b/docs/tca-network.md @@ -0,0 +1,319 @@ +# TCA Network API Client 레퍼런스 + +API Client를 TCA `@Dependency` 패턴으로 생성할 때 참조하는 문서. Live/Mock 구현은 API 스펙에 의존하므로 도메인에 맞게 조정한다. + +## 참조 + +- `docs/GUIDE.md` 지침서의 네트워크 레이어 섹션을 함께 참고한다 +- `.claude/skills/tca-client/SKILL.md` — 기본 Client 골격 생성 스킬 + +--- + +## 템플릿: API 에러 타입 + +```swift +// Core/Clients/APIError.swift +import Foundation + +enum APIError: Error, Equatable, Sendable { + case networkError(String) + case decodingError + case unauthorized + case serverError(Int) + case unknown + + var localizedDescription: String { + switch self { + case let .networkError(message): + return message + case .decodingError: + return "데이터를 처리할 수 없습니다." + case .unauthorized: + return "인증이 만료되었습니다. 다시 로그인해주세요." + case let .serverError(code): + return "서버 오류가 발생했습니다. (코드: \(code))" + case .unknown: + return "알 수 없는 오류가 발생했습니다." + } + } +} +``` + +## 템플릿: API 기본 설정 + +```swift +// Core/Clients/APIConfig.swift +import Foundation + +enum APIConfig { + static let baseURL = URL(string: "https://api.example.com")! + + enum Path { + static let persons = "/persons" + static let transactions = "/transactions" + static let auth = "/auth" + } +} +``` + +## 템플릿: API Client 정의 + +```swift +// Core/Clients/DataClient.swift +import ComposableArchitecture +import Foundation + +@DependencyClient +struct DataClient: Sendable { + // Person CRUD + var fetchPersons: @Sendable () async throws -> [Person] + var fetchPerson: @Sendable (_ id: UUID) async throws -> Person + var createPerson: @Sendable (_ person: Person) async throws -> Person + var updatePerson: @Sendable (_ person: Person) async throws -> Person + var deletePerson: @Sendable (_ id: UUID) async throws -> Void + + // Transaction CRUD + var fetchTransactions: @Sendable (_ date: Date, _ type: TransactionType?) async throws -> [Transaction] + var createTransaction: @Sendable (_ personID: UUID, _ transaction: Transaction) async throws -> Transaction + var updateTransaction: @Sendable (_ transaction: Transaction) async throws -> Transaction + var deleteTransaction: @Sendable (_ id: UUID) async throws -> Void +} + +extension DataClient: TestDependencyKey { + static let previewValue = Self( + fetchPersons: { Person.mockList }, + fetchPerson: { _ in .mock }, + createPerson: { $0 }, + updatePerson: { $0 }, + deletePerson: { _ in }, + fetchTransactions: { _, _ in Transaction.mockList }, + createTransaction: { _, t in t }, + updateTransaction: { $0 }, + deleteTransaction: { _ in } + ) + + static let testValue = Self() +} + +extension DependencyValues { + var dataClient: DataClient { + get { self[DataClient.self] } + set { self[DataClient.self] = newValue } + } +} +``` + +## 템플릿: Live 구현 (URLSession) + +```swift +// Core/Clients/DataClient+Live.swift +import ComposableArchitecture +import Foundation + +extension DataClient: DependencyKey { + static let liveValue: Self = { + let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + }() + + @Sendable + func request( + path: String, + method: String = "GET", + body: (any Encodable)? = nil + ) async throws -> T { + var urlRequest = URLRequest( + url: APIConfig.baseURL.appendingPathComponent(path) + ) + urlRequest.httpMethod = method + urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + + // Keychain에서 토큰을 가져와 Authorization 헤더 설정 + // urlRequest.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + if let body { + urlRequest.httpBody = try encoder.encode(body) + } + + let (data, response) = try await URLSession.shared.data(for: urlRequest) + + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.unknown + } + + switch httpResponse.statusCode { + case 200..<300: + do { + return try decoder.decode(T.self, from: data) + } catch { + throw APIError.decodingError + } + case 401: + throw APIError.unauthorized + default: + throw APIError.serverError(httpResponse.statusCode) + } + } + + return Self( + fetchPersons: { + try await request(path: APIConfig.Path.persons) + }, + fetchPerson: { id in + try await request(path: "\(APIConfig.Path.persons)/\(id)") + }, + createPerson: { person in + try await request( + path: APIConfig.Path.persons, + method: "POST", + body: person + ) + }, + updatePerson: { person in + try await request( + path: "\(APIConfig.Path.persons)/\(person.id)", + method: "PUT", + body: person + ) + }, + deletePerson: { id in + let _: EmptyResponse = try await request( + path: "\(APIConfig.Path.persons)/\(id)", + method: "DELETE" + ) + }, + fetchTransactions: { date, type in + // Query parameter 구성은 서버 API 스펙에 따라 조정 + try await request(path: APIConfig.Path.transactions) + }, + createTransaction: { personID, transaction in + try await request( + path: "\(APIConfig.Path.persons)/\(personID)/transactions", + method: "POST", + body: transaction + ) + }, + updateTransaction: { transaction in + try await request( + path: "\(APIConfig.Path.transactions)/\(transaction.id)", + method: "PUT", + body: transaction + ) + }, + deleteTransaction: { id in + let _: EmptyResponse = try await request( + path: "\(APIConfig.Path.transactions)/\(id)", + method: "DELETE" + ) + } + ) + }() +} + +private struct EmptyResponse: Decodable {} +``` + +## 템플릿: Mock 구현 (서버 API 스펙 전달 전 사용) + +```swift +// Core/Clients/DataClient+Mock.swift +import ComposableArchitecture +import Foundation + +extension DataClient { + /// 서버 API 스펙 전달 전까지 사용하는 Mock 구현. + /// liveValue 대신 이 값을 DependencyKey에 등록하여 사용한다. + static let mockValue: Self = { + let storage = LockIsolated<[Person]>(Person.mockList) + + return Self( + fetchPersons: { + storage.value + }, + fetchPerson: { id in + guard let person = storage.value.first(where: { $0.id == id }) else { + throw APIError.unknown + } + return person + }, + createPerson: { person in + storage.withValue { $0.append(person) } + return person + }, + updatePerson: { person in + storage.withValue { persons in + if let index = persons.firstIndex(where: { $0.id == person.id }) { + persons[index] = person + } + } + return person + }, + deletePerson: { id in + storage.withValue { $0.removeAll { $0.id == id } } + }, + fetchTransactions: { date, type in + let allTransactions = storage.value.flatMap(\.transactions) + return allTransactions.filter { transaction in + let matchesDate = transaction.date.isSameMonth(as: date) + let matchesType = type.map { transaction.type == $0 } ?? true + return matchesDate && matchesType + } + }, + createTransaction: { personID, transaction in + storage.withValue { persons in + if let index = persons.firstIndex(where: { $0.id == personID }) { + persons[index].transactions.append(transaction) + } + } + return transaction + }, + updateTransaction: { transaction in + storage.withValue { persons in + for i in persons.indices { + if let j = persons[i].transactions.firstIndex(where: { $0.id == transaction.id }) { + persons[i].transactions[j] = transaction + } + } + } + return transaction + }, + deleteTransaction: { id in + storage.withValue { persons in + for i in persons.indices { + persons[i].transactions.removeAll { $0.id == id } + } + } + } + ) + }() +} +``` + +## 규칙 + +- API Client는 `@DependencyClient` 매크로로 정의한다 +- 모든 endpoint closure는 `@Sendable`로 선언한다 +- 에러 타입은 `APIError`로 통일한다 +- JSON 디코딩은 `iso8601` date strategy 사용 +- Authorization 헤더는 `KeychainClient`에서 토큰을 가져와 설정한다 +- 서버 API 스펙 전달 전까지 `mockValue`로 개발을 진행한다 +- `liveValue`는 서버 API 스펙이 확정된 후 구현한다 + +## 파일 위치 + +``` +Dori-iOS/Core/Clients/ + APIError.swift + APIConfig.swift + DataClient.swift + DataClient+Live.swift + DataClient+Mock.swift +``` diff --git a/docs/tca-test.md b/docs/tca-test.md new file mode 100644 index 0000000..5cfaca6 --- /dev/null +++ b/docs/tca-test.md @@ -0,0 +1,189 @@ +# TCA TestStore 기반 테스트 레퍼런스 + +Feature Reducer 테스트 작성 시 참조하는 문서. 각 Feature의 State 전이가 다르므로 도메인을 이해한 후 작성한다. + +## 참조 + +- `docs/GUIDE.md` 지침서의 테스트 전략 섹션을 함께 참고한다 + +--- + +## 템플릿: 기본 Reducer 테스트 + +```swift +import ComposableArchitecture +import Testing + +@testable import Dori_iOS + +@MainActor +struct {Feature}FeatureTests { + // MARK: - 초기 로딩 테스트 + + @Test + func onAppear_데이터를_로드한다() async { + let store = TestStore( + initialState: {Feature}Feature.State() + ) { + {Feature}Feature() + } withDependencies: { + $0.dataClient.fetch = { [.mock] } + } + + await store.send(.onAppear) { + $0.isLoading = true + } + + await store.receive(\.itemsResponse.success) { + $0.isLoading = false + $0.items = [.mock] + } + } + + // MARK: - 에러 처리 테스트 + + @Test + func onAppear_에러시_메시지를_표시한다() async { + let store = TestStore( + initialState: {Feature}Feature.State() + ) { + {Feature}Feature() + } withDependencies: { + $0.dataClient.fetch = { throw APIError.networkError("연결 실패") } + } + + await store.send(.onAppear) { + $0.isLoading = true + } + + await store.receive(\.itemsResponse.failure) { + $0.isLoading = false + $0.errorMessage = "연결 실패" + } + } + + // MARK: - 사용자 액션 테스트 + + @Test + func 삭제버튼_탭시_항목을_삭제한다() async { + let deletedID = LockIsolated(nil) + let mockItem = Item.mock + + let store = TestStore( + initialState: {Feature}Feature.State(items: [mockItem]) + ) { + {Feature}Feature() + } withDependencies: { + $0.dataClient.delete = { id in + deletedID.setValue(id) + } + } + + await store.send(.deleteButtonTapped(mockItem.id)) { + $0.items = [] + } + + #expect(deletedID.value == mockItem.id) + } +} +``` + +## 테스트 패턴 + +### 동기 액션 (State만 변경, Effect 없음) + +```swift +@Test +func 탭_선택시_상태가_변경된다() async { + let store = TestStore( + initialState: MainTabFeature.State() + ) { + MainTabFeature() + } + + await store.send(.tabSelected(.history)) { + $0.selectedTab = .history + } +} +``` + +### 비동기 액션 (Effect → receive) + +```swift +@Test +func 로그인시_토큰을_저장한다() async { + let savedToken = LockIsolated(nil) + + let store = TestStore( + initialState: IntroFeature.State() + ) { + IntroFeature() + } withDependencies: { + $0.kakaoAuthClient.login = { "mock_token" } + $0.keychainClient.save = { key, value in + savedToken.setValue(value) + } + } + + await store.send(.loginButtonTapped) { + $0.isLoading = true + } + + await store.receive(\.loginResponse.success) { + $0.isLoading = false + } + + #expect(savedToken.value == "mock_token") +} +``` + +### Navigation 테스트 + +```swift +@Test +func 인물_탭시_디테일로_이동한다() async { + let person = Person.mock + + let store = TestStore( + initialState: HistoryListFeature.State(persons: [person]) + ) { + HistoryListFeature() + } + + await store.send(.personTapped(person)) { + $0.path[id: 0] = HistoryDetailFeature.State(person: person) + } +} +``` + +### Exhaustive 테스트 비활성화 (복잡한 Effect 체인) + +```swift +@Test +func 복잡한_흐름_테스트() async { + let store = TestStore( + initialState: SomeFeature.State() + ) { + SomeFeature() + } + store.exhaustivity = .off + + await store.send(.someAction) + // receive를 일일이 검증하지 않아도 된다 +} +``` + +## 규칙 + +- `@MainActor struct` 으로 테스트 구조체 선언 (Swift Testing) +- `@Test` 매크로로 테스트 메서드 선언 +- 테스트 메서드 이름은 한국어로 동작을 설명한다 +- `withDependencies`로 모든 외부 의존성을 Mock으로 주입한다 +- `store.send()` → State 변경 클로저로 기대값 검증 +- `store.receive()` → Effect 결과에 대한 State 변경 검증 +- Side effect 호출 여부는 `LockIsolated`로 캡처하여 검증한다 + +## 파일 위치 + +- `Dori-iOSTests/Feature/{FeatureName}/{Feature}FeatureTests.swift` +- 예: `Dori-iOSTests/Feature/Calendar/CalendarFeatureTests.swift`