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
79 changes: 79 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## 프로젝트 개요

**앗차(Atcha)** — 위치 기반으로 막차(버스/지하철) 시간을 확인하고 출발 알림을 주는 iOS 앱. 이 레포에는 두 세계가 공존한다:

- **Tuist 워크스페이스** (`Atcha.xcworkspace`, 생성물) — "메인 2.0" **AtchaV2** 개발이 이뤄지는 곳. 모든 신규 작업은 여기서.
- **레거시** (`Atcha-iOS.xcodeproj` + `Atcha-iOS/` 소스) — 기존 1.x 앱. Tuist에서 `Projects/Legacy`의 단일 타겟으로도 래핑돼 있지만, **CI/fastlane은 아직 기존 xcodeproj를 직접 빌드**하므로 기존 xcodeproj를 삭제·수정하지 말 것. 참고용.

## 필수 명령어

```bash
# 최초 1회 (클론 직후·xcconfig 없을 때): 스탠드인 xcconfig 생성 + tuist install + generate
sh Scripts/bootstrap.sh

# 매니페스트(Project.swift 등) 수정 후 재생성
tuist generate --no-open

# AtchaV2 빌드 (구성: Debug/Stage/Release 3개 — Stage 빼먹으면 CI가 깨짐)
xcodebuild -workspace Atcha.xcworkspace -scheme AtchaV2 -configuration Debug \
-destination 'generic/platform=iOS Simulator' build

# 모듈 테스트 (Swift Testing 기반)
xcodebuild -workspace Atcha.xcworkspace -scheme HomeFeature \
-destination 'platform=iOS Simulator,name=iPhone 17' test
# 단일 테스트: -only-testing:HomeFeatureTests/HomeViewModelTests/viewDidLoad_success_transitionsLoadingToLoaded

# 의존 그래프 확인 (graph.dot 생성, gitignore됨)
tuist graph --format dot --no-open

# 레거시 빌드 — 디바이스 전용 (TMapSDK.framework가 arm64 디바이스 전용이라 시뮬레이터 빌드는 원래 불가)
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 전용으로 취급.

### 알아야 할 함정

- **xcconfig 4개(Base/Dev/Stage/Live)는 gitignore돼 있고 없으면 `tuist generate`가 에러로 실패한다.** `Scripts/bootstrap.sh`가 빈 스탠드인을 만들어 해결한다(CI는 시크릿에서 실제 파일을 복원). AtchaV2는 xcconfig에 의존하지 않도록 설계돼 있으므로 새 모듈에 xcconfig 참조를 추가하지 말 것.
- 빌드 구성은 프로젝트 전체가 **Debug/Stage/Release 3개**. 새 타겟·외부 의존성 설정에 Stage가 누락되면 `-configuration Stage` 빌드가 조용히 깨진다(외부 SPM은 `Tuist/Package.swift`의 `PackageSettings.baseSettings`가 3구성을 선언).
- SPM 의존성 추가는 `Tuist/Package.swift`에서. `Tuist/Package.resolved`는 커밋 대상(Amplitude가 branch 추적이라 리비전 고정 역할).
- 레거시 소스 글롭에서 `Atcha-iOS/App/DIContainer/DIContainer.swift`는 의도적으로 제외(기존 pbxproj도 컴파일하지 않던 죽은 파일, AppDIContainer 중복 선언).

## 아키텍처 (AtchaV2 — uFeatures + 클린아키텍처)

```
AtchaV2(앱, 조합 루트) ─► HomeFeature ─► {HomeFeatureInterface, Domain, DesignSystem, CoreCoordinator, SnapKit}
└─► AtchaData ─► {Domain, CoreNetwork}
```

의존 규칙(위반 금지, `tuist graph`로 검증 가능):
- **Presentation → Domain ← Data**: Feature 모듈은 `AtchaData`를 절대 import하지 않는다. Domain은 무의존.
- 구체 Data/Network 타입을 보는 곳은 앱의 `AppDIContainer`(조합 루트)뿐. 여기서 NetworkClient → RepositoryImpl → UseCase → 피처 DIContainer 순으로 주입한다.
- Firebase 등 외부 라이브러리는 **앱 타겟에서만** 링크(내부 모듈 전부 static framework, 중복 심볼 방지). 앱 타겟에 `-ObjC` 필요.

모듈 정의는 `Tuist/ProjectDescriptionHelpers/`의 DSL로만 한다:
- `Project.feature(name:)` — 피처당 `{N}Feature`/`{N}FeatureInterface`/`{N}FeatureTests`/`{N}FeatureExample` 4타겟. 새 피처는 `Projects/Feature/Home`을 그대로 본뜬다.
- `Project.layer(name:)` — 수평 모듈(framework+tests). isolation 파라미터: UI 모듈은 `.mainActor`, Domain/Data/Network은 `.nonisolated`.
- `Settings.atchaV2()` — Swift 6 + 3구성 + 구성별 컴파일 플래그(Debug=DEV, Stage=STAGE, Release=LIVE). 환경 분기는 앱의 `AppEnvironment` enum이 이 플래그로 수행(런타임 xcconfig 의존 없음).

### 피처 내부 컨벤션 (Home이 표준 템플릿)

- 클린아키텍처 수직 슬라이스 필수 구성: Domain에 Entity + **추상화 UseCase(프로토콜)** + Repository 인터페이스 / Data에 Request·Response DTO + `toEntity()` + RepositoryImpl / Presentation에 ViewData(Entity를 뷰에 직접 노출 금지) + ViewModel + VC.
- **모든 ViewModel은 `@MainActor`.** 비동기 작업은 `Task` 보관 + `deinit`에서 cancel + `[weak self]` + `Task.isCancelled` 가드.
- **조립은 피처 DIContainer, 화면 흐름은 Coordinator.** Coordinator는 `CoreCoordinator.Coordinator`를 채택하고 `finishDelegate`(weak)로 부모가 자식을 제거한다(누수 방지). navigationController는 앱 루트(AppCoordinator)만 강한 소유, 나머지는 weak.
- 다른 모듈에 노출하는 진입점은 Interface 타겟의 프로토콜(`HomeCoordinatorBuildable` 패턴)로만.
- 테스트는 **Swift Testing**(`@Test`/`#expect`). Example 앱은 스텁 UseCase로 피처 단독 실행(Data 무의존). 스텁이 Tests/Example에 중복되는 것은 의도된 트레이드오프.
- catch-all `Shared`/`Common` 모듈을 만들지 않는다. 로깅·캐싱 등이 필요해지면 목적별 단일 모듈(`Logger`, `Storage`)을 새로 판다.
- 모듈명 `Data`는 금지(Foundation.Data 섀도잉) — Data 레이어 모듈명은 `AtchaData`(디렉터리는 `Projects/Data`).
- UI는 UIKit 코드 기반(스토리보드 없음) + SnapKit + DesignSystem 토큰(`DSColor`/`DSFont`/`DSSpacing`).

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

- `AppEnvironment`의 API base URL은 플레이스홀더 — 실서버 주소 미정.
- `com.atcha.iOS.v2`용 GoogleService-Info.plist 미발급 — `AppDelegate`가 파일 존재를 가드한 뒤에만 `FirebaseApp.configure()` 호출. plist를 `Projects/App/Resources/`에 넣으면 자동 활성화.
- AtchaV2는 iOS 26 전용(AlarmKit 사용 예정). AlarmKit의 커스텀 알람 UI(Live Activity)는 추후 위젯 익스텐션 타겟이 별도로 필요.
4 changes: 4 additions & 0 deletions Projects/Core/Storage/Project.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import ProjectDescription
import ProjectDescriptionHelpers

let project = Project.layer(name: "CoreStorage", bundleSuffix: "core.storage", isolation: .nonisolated)
14 changes: 14 additions & 0 deletions Projects/Core/Storage/Sources/KeyValueStore+Codable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import Foundation

public extension KeyValueStore {
/// 부재 → nil. 저장된 데이터가 디코딩 불가면 throw (숨기지 않는다 — 무시 정책은 호출자 몫).
// JSONDecoder/JSONEncoder는 non-Sendable — 공유하지 않고 호출마다 새로 만든다.
func value<T: Decodable>(_ type: T.Type = T.self, forKey key: String) throws -> T? {
guard let data = try data(forKey: key) else { return nil }
return try JSONDecoder().decode(T.self, from: data)
}

func setValue<T: Encodable>(_ value: T, forKey key: String) throws {
try set(JSONEncoder().encode(value), forKey: key)
}
}
8 changes: 8 additions & 0 deletions Projects/Core/Storage/Sources/KeyValueStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import Foundation

/// Data 단위 key-value 저장 추상화. `nil` = 값 부재, `throw` = 실제 저장소 실패.
public protocol KeyValueStore: Sendable {
func data(forKey key: String) throws -> Data?
func set(_ data: Data, forKey key: String) throws
func removeValue(forKey key: String) throws
}
76 changes: 76 additions & 0 deletions Projects/Core/Storage/Sources/KeychainStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import Foundation
import Security
import Synchronization

public enum KeychainError: Error, Equatable, Sendable {
case unexpectedStatus(OSStatus)
}

/// 레거시 TokenStorage의 키체인+메모리 캐시 의미를 프로토콜 기반으로 재작성.
/// 캐시는 존재값만 보관하며 Mutex로 동기화한다 (레거시는 unsynchronized라 Swift 6 불가).
public final class KeychainStore: KeyValueStore {
private let service: String
private let cache = Mutex<[String: Data]>([:])

public init(service: String = "com.atcha.iOS.v2") {
self.service = service
}

public func data(forKey key: String) throws -> Data? {
if let cached = cache.withLock({ $0[key] }) {
return cached
}
var query = baseQuery(forKey: key)
query[kSecReturnData as String] = kCFBooleanTrue
query[kSecMatchLimit as String] = kSecMatchLimitOne

var result: AnyObject?
let status = SecItemCopyMatching(query as CFDictionary, &result)
switch status {
case errSecSuccess:
guard let data = result as? Data else { return nil }
cache.withLock { $0[key] = data }
return data
case errSecItemNotFound:
return nil
default:
throw KeychainError.unexpectedStatus(status)
}
}

public func set(_ data: Data, forKey key: String) throws {
var addQuery = baseQuery(forKey: key)
addQuery[kSecValueData as String] = data
addQuery[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock

// 레거시의 delete-then-add는 비원자적 — add 후 duplicate면 update로 대체한다.
var status = SecItemAdd(addQuery as CFDictionary, nil)
if status == errSecDuplicateItem {
let attributes: [String: Any] = [
kSecValueData as String: data,
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock,
]
status = SecItemUpdate(baseQuery(forKey: key) as CFDictionary, attributes as CFDictionary)
}
guard status == errSecSuccess else {
throw KeychainError.unexpectedStatus(status)
}
cache.withLock { $0[key] = data }
}

public func removeValue(forKey key: String) throws {
let status = SecItemDelete(baseQuery(forKey: key) as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw KeychainError.unexpectedStatus(status)
}
cache.withLock { $0[key] = nil }
}

private func baseQuery(forKey key: String) -> [String: Any] {
[
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
]
}
}
22 changes: 22 additions & 0 deletions Projects/Core/Storage/Sources/UserDefaultsKeyValueStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Foundation

// UserDefaults는 문서화된 thread-safe지만 SDK가 Sendable로 표기하지 않아 @unchecked가 필요하다.
public final class UserDefaultsKeyValueStore: KeyValueStore, @unchecked Sendable {
private let defaults: UserDefaults

public init(defaults: UserDefaults = .standard) {
self.defaults = defaults
}

public func data(forKey key: String) throws -> Data? {
defaults.data(forKey: key)
}

public func set(_ data: Data, forKey key: String) throws {
defaults.set(data, forKey: key)
}

public func removeValue(forKey key: String) throws {
defaults.removeObject(forKey: key)
}
}
63 changes: 63 additions & 0 deletions Projects/Core/Storage/Tests/KeyValueStoreCodableTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
@testable import CoreStorage
import Foundation
import Synchronization
import Testing

private final class InMemoryKeyValueStore: KeyValueStore {
private let storage = Mutex<[String: Data]>([:])

func data(forKey key: String) throws -> Data? {
storage.withLock { $0[key] }
}

func set(_ data: Data, forKey key: String) throws {
storage.withLock { $0[key] = data }
}

func removeValue(forKey key: String) throws {
storage.withLock { $0[key] = nil }
}
}

private struct Token: Codable, Equatable {
let value: String
let expiresAt: Int
}

struct KeyValueStoreCodableTests {
private let store = InMemoryKeyValueStore()

@Test
func setValue_thenValue_roundTripsCodable() throws {
let token = Token(value: "abc", expiresAt: 123)
try store.setValue(token, forKey: "token")
#expect(try store.value(Token.self, forKey: "token") == token)
}

@Test
func value_missingKey_returnsNil() throws {
#expect(try store.value(Token.self, forKey: "missing") == nil)
}

@Test
func value_corruptData_throwsDecodingError() throws {
try store.set(Data("not json".utf8), forKey: "token")
#expect(throws: DecodingError.self) {
try store.value(Token.self, forKey: "token")
}
}

@Test
func setValue_overwrite_replacesPreviousValue() throws {
try store.setValue(Token(value: "old", expiresAt: 1), forKey: "token")
try store.setValue(Token(value: "new", expiresAt: 2), forKey: "token")
#expect(try store.value(Token.self, forKey: "token") == Token(value: "new", expiresAt: 2))
}

@Test
func removeValue_thenValue_returnsNil() throws {
try store.setValue(Token(value: "abc", expiresAt: 1), forKey: "token")
try store.removeValue(forKey: "token")
#expect(try store.value(Token.self, forKey: "token") == nil)
}
}
1 change: 1 addition & 0 deletions Projects/Data/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ let project = Project.layer(
dependencies: [
.project(target: "Domain", path: "../Domain"),
.project(target: "CoreNetwork", path: "../Core/Network"),
.project(target: "CoreStorage", path: "../Core/Storage"),
]
)
20 changes: 20 additions & 0 deletions Projects/Data/Sources/DTO/AlarmRefreshResponseDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Domain
import Foundation

public struct AlarmRefreshResponseDTO: Decodable, Sendable {
public let departureTime: String?
public let updatedAt: String?
public let lastRouteId: String?
// 서버가 Bool이 아니라 "true"/"false" 문자열로 준다 (레거시 실측).
public let isReal: String?

public func toEntity() -> AlarmInfo? {
guard let lastRouteId else { return nil }
return AlarmInfo(
lastRouteId: lastRouteId,
departureTime: departureTime.flatMap { ServerDateParser.date(from: $0) },
updatedAt: updatedAt.flatMap { ServerDateParser.date(from: $0) },
isReal: isReal == "true"
)
}
}
7 changes: 7 additions & 0 deletions Projects/Data/Sources/DTO/AlarmRegisterRequestDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
public struct AlarmRegisterRequestDTO: Encodable, Sendable {
public let lastRouteId: String

public init(lastRouteId: String) {
self.lastRouteId = lastRouteId
}
}
87 changes: 87 additions & 0 deletions Projects/Data/Sources/DTO/LastRouteResponseDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import Domain
import Foundation

public struct LastRouteResponseDTO: Decodable, Sendable {
public let routeId: String?
public let departureDateTime: String?
public let totalTime: Int?
public let totalWalkTime: Int?
public let transferCount: Int?
public let totalDistance: Int?
public let totalWalkDistance: Int?
public let legs: [LegResponseDTO]?

/// routeId·막차 출발 시각이 없는 항목은 세울 수 없어 nil을 돌려준다 (호출부 compactMap).
public func toEntity() -> LastRoute? {
guard let routeId,
let departureDateTime,
let departureTime = ServerDateParser.date(from: departureDateTime)
else { return nil }
return LastRoute(
id: routeId,
departureTime: departureTime,
totalTime: totalTime ?? 0,
totalWalkTime: totalWalkTime ?? 0,
transferCount: transferCount ?? 0,
totalDistance: totalDistance ?? 0,
totalWalkDistance: totalWalkDistance ?? 0,
legs: legs?.map { $0.toEntity() } ?? []
)
}
}

// 지도 표시 전용 필드(passStopList/step/passShape 등)는 2.0 스코프에 없어 디코딩하지 않는다.
public struct LegResponseDTO: Decodable, Sendable {
public let distance: Int?
public let sectionTime: Int?
// 레거시는 enum으로 받아 미지의 mode 문자열에서 디코딩이 통째로 실패했다 — String으로 받고 매핑한다.
public let mode: String?
public let departureDateTime: String?
public let route: String?
public let type: String?
public let start: RoutePointResponseDTO?
public let end: RoutePointResponseDTO?
public let subwayFinalStation: String?
public let subwayDirection: String?
public let isExpressSubway: Bool?
public let isLastSubway: Bool?

public func toEntity() -> TransportLeg {
TransportLeg(
mode: TransportMode(serverValue: mode),
sectionTime: sectionTime ?? 0,
distance: distance ?? 0,
departureTime: departureDateTime.flatMap { ServerDateParser.date(from: $0) },
routeName: route,
lineType: type,
start: start?.toEntity(),
end: end?.toEntity(),
subwayFinalStation: subwayFinalStation,
subwayDirection: subwayDirection,
isExpressSubway: isExpressSubway ?? false,
isLastSubway: isLastSubway ?? false
)
}
}

public struct RoutePointResponseDTO: Decodable, Sendable {
public let name: String?
public let lon: Double?
public let lat: Double?

public func toEntity() -> RoutePoint? {
guard let name, let lat, let lon else { return nil }
return RoutePoint(name: name, coordinate: Coordinate(latitude: lat, longitude: lon))
}
}

private extension TransportMode {
init(serverValue: String?) {
switch serverValue {
case "WALK": self = .walk
case "BUS": self = .bus
case "SUBWAY": self = .subway
default: self = .unknown
}
}
}
Loading
Loading