@su-record/vibe 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.claude/settings.json +35 -35
  2. package/.claude/settings.local.json +24 -25
  3. package/.claude/vibe/constitution.md +184 -184
  4. package/.claude/vibe/rules/core/communication-guide.md +104 -104
  5. package/.claude/vibe/rules/core/development-philosophy.md +52 -52
  6. package/.claude/vibe/rules/core/quick-start.md +120 -120
  7. package/.claude/vibe/rules/languages/dart-flutter.md +509 -509
  8. package/.claude/vibe/rules/languages/go.md +396 -396
  9. package/.claude/vibe/rules/languages/java-spring.md +586 -586
  10. package/.claude/vibe/rules/languages/kotlin-android.md +491 -491
  11. package/.claude/vibe/rules/languages/python-django.md +371 -371
  12. package/.claude/vibe/rules/languages/python-fastapi.md +386 -386
  13. package/.claude/vibe/rules/languages/rust.md +425 -425
  14. package/.claude/vibe/rules/languages/swift-ios.md +516 -516
  15. package/.claude/vibe/rules/languages/typescript-nextjs.md +441 -441
  16. package/.claude/vibe/rules/languages/typescript-node.md +375 -375
  17. package/.claude/vibe/rules/languages/typescript-nuxt.md +521 -521
  18. package/.claude/vibe/rules/languages/typescript-react-native.md +446 -446
  19. package/.claude/vibe/rules/languages/typescript-react.md +525 -525
  20. package/.claude/vibe/rules/languages/typescript-vue.md +353 -353
  21. package/.claude/vibe/rules/quality/bdd-contract-testing.md +388 -388
  22. package/.claude/vibe/rules/quality/checklist.md +276 -276
  23. package/.claude/vibe/rules/quality/testing-strategy.md +437 -437
  24. package/.claude/vibe/rules/standards/anti-patterns.md +369 -369
  25. package/.claude/vibe/rules/standards/code-structure.md +291 -291
  26. package/.claude/vibe/rules/standards/complexity-metrics.md +312 -312
  27. package/.claude/vibe/rules/standards/naming-conventions.md +198 -198
  28. package/.claude/vibe/setup.sh +31 -31
  29. package/.claude/vibe/templates/constitution-template.md +184 -184
  30. package/.claude/vibe/templates/contract-backend-template.md +517 -517
  31. package/.claude/vibe/templates/contract-frontend-template.md +594 -594
  32. package/.claude/vibe/templates/feature-template.md +96 -96
  33. package/.claude/vibe/templates/spec-template.md +199 -199
  34. package/CLAUDE.md +345 -323
  35. package/LICENSE +21 -21
  36. package/README.md +744 -724
  37. package/agents/compounder.md +261 -261
  38. package/agents/diagrammer.md +178 -178
  39. package/agents/e2e-tester.md +266 -266
  40. package/agents/explorer.md +48 -48
  41. package/agents/implementer.md +53 -53
  42. package/agents/research/best-practices-agent.md +139 -139
  43. package/agents/research/codebase-patterns-agent.md +147 -147
  44. package/agents/research/framework-docs-agent.md +181 -181
  45. package/agents/research/security-advisory-agent.md +167 -167
  46. package/agents/review/architecture-reviewer.md +107 -107
  47. package/agents/review/complexity-reviewer.md +116 -116
  48. package/agents/review/data-integrity-reviewer.md +88 -88
  49. package/agents/review/git-history-reviewer.md +103 -103
  50. package/agents/review/performance-reviewer.md +86 -86
  51. package/agents/review/python-reviewer.md +152 -152
  52. package/agents/review/rails-reviewer.md +139 -139
  53. package/agents/review/react-reviewer.md +144 -144
  54. package/agents/review/security-reviewer.md +80 -80
  55. package/agents/review/simplicity-reviewer.md +140 -140
  56. package/agents/review/test-coverage-reviewer.md +116 -116
  57. package/agents/review/typescript-reviewer.md +127 -127
  58. package/agents/searcher.md +54 -54
  59. package/agents/simplifier.md +119 -119
  60. package/agents/tester.md +49 -49
  61. package/agents/ui-previewer.md +137 -137
  62. package/commands/vibe.analyze.md +245 -180
  63. package/commands/vibe.reason.md +223 -183
  64. package/commands/vibe.review.md +200 -136
  65. package/commands/vibe.run.md +838 -836
  66. package/commands/vibe.spec.md +419 -383
  67. package/commands/vibe.utils.md +101 -101
  68. package/commands/vibe.verify.md +282 -241
  69. package/dist/cli/index.js +385 -385
  70. package/dist/lib/MemoryManager.d.ts.map +1 -1
  71. package/dist/lib/MemoryManager.js +119 -114
  72. package/dist/lib/MemoryManager.js.map +1 -1
  73. package/dist/lib/PythonParser.js +108 -108
  74. package/dist/lib/gemini-mcp.js +15 -15
  75. package/dist/lib/gemini-oauth.js +35 -35
  76. package/dist/lib/gpt-mcp.js +17 -17
  77. package/dist/lib/gpt-oauth.js +44 -44
  78. package/dist/tools/analytics/getUsageAnalytics.js +12 -12
  79. package/dist/tools/index.d.ts +50 -0
  80. package/dist/tools/index.d.ts.map +1 -0
  81. package/dist/tools/index.js +61 -0
  82. package/dist/tools/index.js.map +1 -0
  83. package/dist/tools/memory/createMemoryTimeline.js +10 -10
  84. package/dist/tools/memory/getMemoryGraph.js +12 -12
  85. package/dist/tools/memory/getSessionContext.js +9 -9
  86. package/dist/tools/memory/linkMemories.js +14 -14
  87. package/dist/tools/memory/listMemories.js +4 -4
  88. package/dist/tools/memory/recallMemory.js +4 -4
  89. package/dist/tools/memory/saveMemory.js +4 -4
  90. package/dist/tools/memory/searchMemoriesAdvanced.js +22 -22
  91. package/dist/tools/planning/generatePrd.js +46 -46
  92. package/dist/tools/prompt/enhancePromptGemini.js +160 -160
  93. package/dist/tools/reasoning/applyReasoningFramework.js +56 -56
  94. package/dist/tools/semantic/analyzeDependencyGraph.js +12 -12
  95. package/hooks/hooks.json +121 -103
  96. package/package.json +73 -69
  97. package/skills/git-worktree.md +178 -178
  98. package/skills/priority-todos.md +236 -236
@@ -1,516 +1,516 @@
1
- # 🍎 Swift + iOS 품질 규칙
2
-
3
- ## 핵심 원칙 (core에서 상속)
4
-
5
- ```markdown
6
- ✅ 단일 책임 (SRP)
7
- ✅ 중복 제거 (DRY)
8
- ✅ 재사용성
9
- ✅ 낮은 복잡도
10
- ✅ 함수 ≤ 30줄
11
- ✅ 중첩 ≤ 3단계
12
- ✅ Cyclomatic complexity ≤ 10
13
- ```
14
-
15
- ## Swift/iOS 특화 규칙
16
-
17
- ### 1. SwiftUI 기본 구조
18
-
19
- ```swift
20
- // ✅ View 구조
21
- import SwiftUI
22
-
23
- struct UserProfileView: View {
24
- // 1. 상태 및 바인딩
25
- @StateObject private var viewModel: UserProfileViewModel
26
- @State private var isEditing = false
27
- @Binding var selectedUser: User?
28
-
29
- // 2. 환경 변수
30
- @Environment(\.dismiss) private var dismiss
31
- @EnvironmentObject private var authManager: AuthManager
32
-
33
- // 3. Body
34
- var body: some View {
35
- NavigationStack {
36
- content
37
- .navigationTitle("프로필")
38
- .toolbar { toolbarContent }
39
- .sheet(isPresented: $isEditing) { editSheet }
40
- }
41
- .task { await viewModel.loadUser() }
42
- }
43
-
44
- // 4. 뷰 컴포넌트 분리
45
- @ViewBuilder
46
- private var content: some View {
47
- if viewModel.isLoading {
48
- ProgressView()
49
- } else if let user = viewModel.user {
50
- userContent(user)
51
- } else {
52
- emptyState
53
- }
54
- }
55
-
56
- private func userContent(_ user: User) -> some View {
57
- List {
58
- Section("기본 정보") {
59
- LabeledContent("이름", value: user.name)
60
- LabeledContent("이메일", value: user.email)
61
- }
62
- }
63
- }
64
-
65
- @ToolbarContentBuilder
66
- private var toolbarContent: some ToolbarContent {
67
- ToolbarItem(placement: .topBarTrailing) {
68
- Button("편집") { isEditing = true }
69
- }
70
- }
71
- }
72
- ```
73
-
74
- ### 2. ViewModel (MVVM)
75
-
76
- ```swift
77
- // ✅ ViewModel with @Observable (iOS 17+)
78
- import Foundation
79
- import Observation
80
-
81
- @Observable
82
- final class UserProfileViewModel {
83
- // 상태
84
- private(set) var user: User?
85
- private(set) var isLoading = false
86
- private(set) var error: AppError?
87
-
88
- // 의존성
89
- private let userRepository: UserRepository
90
- private let userId: String
91
-
92
- init(userId: String, userRepository: UserRepository = DefaultUserRepository()) {
93
- self.userId = userId
94
- self.userRepository = userRepository
95
- }
96
-
97
- @MainActor
98
- func loadUser() async {
99
- isLoading = true
100
- error = nil
101
-
102
- do {
103
- user = try await userRepository.fetchUser(id: userId)
104
- } catch {
105
- self.error = AppError.from(error)
106
- }
107
-
108
- isLoading = false
109
- }
110
-
111
- @MainActor
112
- func updateUser(name: String) async throws {
113
- guard var currentUser = user else { return }
114
- currentUser.name = name
115
-
116
- user = try await userRepository.updateUser(currentUser)
117
- }
118
- }
119
-
120
- // ✅ ViewModel with ObservableObject (iOS 13+)
121
- import Combine
122
-
123
- final class UserListViewModel: ObservableObject {
124
- @Published private(set) var users: [User] = []
125
- @Published private(set) var isLoading = false
126
- @Published var searchText = ""
127
-
128
- private let userRepository: UserRepository
129
- private var cancellables = Set<AnyCancellable>()
130
-
131
- var filteredUsers: [User] {
132
- guard !searchText.isEmpty else { return users }
133
- return users.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
134
- }
135
-
136
- init(userRepository: UserRepository = DefaultUserRepository()) {
137
- self.userRepository = userRepository
138
- setupBindings()
139
- }
140
-
141
- private func setupBindings() {
142
- $searchText
143
- .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
144
- .sink { [weak self] _ in
145
- self?.objectWillChange.send()
146
- }
147
- .store(in: &cancellables)
148
- }
149
-
150
- @MainActor
151
- func loadUsers() async {
152
- isLoading = true
153
- defer { isLoading = false }
154
-
155
- do {
156
- users = try await userRepository.fetchUsers()
157
- } catch {
158
- print("Error: \(error)")
159
- }
160
- }
161
- }
162
- ```
163
-
164
- ### 3. Repository 패턴
165
-
166
- ```swift
167
- // ✅ Protocol 정의
168
- protocol UserRepository {
169
- func fetchUsers() async throws -> [User]
170
- func fetchUser(id: String) async throws -> User
171
- func createUser(_ user: User) async throws -> User
172
- func updateUser(_ user: User) async throws -> User
173
- func deleteUser(id: String) async throws
174
- }
175
-
176
- // ✅ 구현체
177
- final class DefaultUserRepository: UserRepository {
178
- private let apiClient: APIClient
179
- private let cache: CacheManager
180
-
181
- init(apiClient: APIClient = .shared, cache: CacheManager = .shared) {
182
- self.apiClient = apiClient
183
- self.cache = cache
184
- }
185
-
186
- func fetchUser(id: String) async throws -> User {
187
- // 캐시 확인
188
- if let cached: User = cache.get(key: "user_\(id)") {
189
- return cached
190
- }
191
-
192
- // API 호출
193
- let user: User = try await apiClient.request(
194
- endpoint: .user(id: id),
195
- method: .get
196
- )
197
-
198
- // 캐시 저장
199
- cache.set(key: "user_\(id)", value: user, ttl: 300)
200
-
201
- return user
202
- }
203
-
204
- func fetchUsers() async throws -> [User] {
205
- try await apiClient.request(
206
- endpoint: .users,
207
- method: .get
208
- )
209
- }
210
- }
211
- ```
212
-
213
- ### 4. 에러 처리
214
-
215
- ```swift
216
- // ✅ 커스텀 에러 정의
217
- enum AppError: LocalizedError {
218
- case networkError(underlying: Error)
219
- case decodingError(underlying: Error)
220
- case notFound(resource: String, id: String)
221
- case unauthorized
222
- case serverError(message: String)
223
- case unknown
224
-
225
- var errorDescription: String? {
226
- switch self {
227
- case .networkError:
228
- return "네트워크 연결을 확인해주세요"
229
- case .decodingError:
230
- return "데이터를 처리할 수 없습니다"
231
- case .notFound(let resource, let id):
232
- return "\(resource)을(를) 찾을 수 없습니다 (ID: \(id))"
233
- case .unauthorized:
234
- return "로그인이 필요합니다"
235
- case .serverError(let message):
236
- return "서버 오류: \(message)"
237
- case .unknown:
238
- return "알 수 없는 오류가 발생했습니다"
239
- }
240
- }
241
-
242
- static func from(_ error: Error) -> AppError {
243
- if let appError = error as? AppError {
244
- return appError
245
- }
246
-
247
- if let urlError = error as? URLError {
248
- return .networkError(underlying: urlError)
249
- }
250
-
251
- if error is DecodingError {
252
- return .decodingError(underlying: error)
253
- }
254
-
255
- return .unknown
256
- }
257
- }
258
-
259
- // ✅ Result 타입 활용
260
- func loadData() async -> Result<User, AppError> {
261
- do {
262
- let user = try await repository.fetchUser(id: userId)
263
- return .success(user)
264
- } catch {
265
- return .failure(AppError.from(error))
266
- }
267
- }
268
- ```
269
-
270
- ### 5. 네트워킹 (async/await)
271
-
272
- ```swift
273
- // ✅ API 클라이언트
274
- final class APIClient {
275
- static let shared = APIClient()
276
-
277
- private let session: URLSession
278
- private let decoder: JSONDecoder
279
- private let baseURL: URL
280
-
281
- init(session: URLSession = .shared, baseURL: URL = Config.apiBaseURL) {
282
- self.session = session
283
- self.baseURL = baseURL
284
- self.decoder = JSONDecoder()
285
- self.decoder.keyDecodingStrategy = .convertFromSnakeCase
286
- self.decoder.dateDecodingStrategy = .iso8601
287
- }
288
-
289
- func request<T: Decodable>(
290
- endpoint: Endpoint,
291
- method: HTTPMethod,
292
- body: Encodable? = nil
293
- ) async throws -> T {
294
- var request = URLRequest(url: baseURL.appendingPathComponent(endpoint.path))
295
- request.httpMethod = method.rawValue
296
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
297
-
298
- // 인증 토큰
299
- if let token = AuthManager.shared.accessToken {
300
- request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
301
- }
302
-
303
- // Body
304
- if let body {
305
- request.httpBody = try JSONEncoder().encode(body)
306
- }
307
-
308
- let (data, response) = try await session.data(for: request)
309
-
310
- guard let httpResponse = response as? HTTPURLResponse else {
311
- throw AppError.unknown
312
- }
313
-
314
- switch httpResponse.statusCode {
315
- case 200...299:
316
- return try decoder.decode(T.self, from: data)
317
- case 401:
318
- throw AppError.unauthorized
319
- case 404:
320
- throw AppError.notFound(resource: endpoint.resource, id: endpoint.id ?? "")
321
- default:
322
- throw AppError.serverError(message: "Status: \(httpResponse.statusCode)")
323
- }
324
- }
325
- }
326
-
327
- // ✅ Endpoint 정의
328
- enum Endpoint {
329
- case users
330
- case user(id: String)
331
- case createUser
332
- case updateUser(id: String)
333
-
334
- var path: String {
335
- switch self {
336
- case .users, .createUser:
337
- return "/users"
338
- case .user(let id), .updateUser(let id):
339
- return "/users/\(id)"
340
- }
341
- }
342
-
343
- var resource: String { "User" }
344
-
345
- var id: String? {
346
- switch self {
347
- case .user(let id), .updateUser(let id):
348
- return id
349
- default:
350
- return nil
351
- }
352
- }
353
- }
354
- ```
355
-
356
- ### 6. 의존성 주입
357
-
358
- ```swift
359
- // ✅ Environment를 통한 DI (SwiftUI)
360
- private struct UserRepositoryKey: EnvironmentKey {
361
- static let defaultValue: UserRepository = DefaultUserRepository()
362
- }
363
-
364
- extension EnvironmentValues {
365
- var userRepository: UserRepository {
366
- get { self[UserRepositoryKey.self] }
367
- set { self[UserRepositoryKey.self] = newValue }
368
- }
369
- }
370
-
371
- // 사용
372
- struct ContentView: View {
373
- @Environment(\.userRepository) private var userRepository
374
-
375
- var body: some View {
376
- UserListView(viewModel: UserListViewModel(userRepository: userRepository))
377
- }
378
- }
379
-
380
- // ✅ Container 패턴
381
- final class DIContainer {
382
- static let shared = DIContainer()
383
-
384
- lazy var userRepository: UserRepository = DefaultUserRepository(
385
- apiClient: apiClient,
386
- cache: cacheManager
387
- )
388
-
389
- lazy var apiClient: APIClient = APIClient()
390
- lazy var cacheManager: CacheManager = CacheManager()
391
-
392
- private init() {}
393
- }
394
- ```
395
-
396
- ### 7. 테스트
397
-
398
- ```swift
399
- import XCTest
400
- @testable import MyApp
401
-
402
- // ✅ Mock Repository
403
- final class MockUserRepository: UserRepository {
404
- var fetchUsersResult: Result<[User], Error> = .success([])
405
- var fetchUserResult: Result<User, Error> = .failure(AppError.notFound(resource: "User", id: ""))
406
-
407
- func fetchUsers() async throws -> [User] {
408
- try fetchUsersResult.get()
409
- }
410
-
411
- func fetchUser(id: String) async throws -> User {
412
- try fetchUserResult.get()
413
- }
414
-
415
- // ... 다른 메서드
416
- }
417
-
418
- // ✅ ViewModel 테스트
419
- final class UserListViewModelTests: XCTestCase {
420
- var sut: UserListViewModel!
421
- var mockRepository: MockUserRepository!
422
-
423
- override func setUp() {
424
- super.setUp()
425
- mockRepository = MockUserRepository()
426
- sut = UserListViewModel(userRepository: mockRepository)
427
- }
428
-
429
- override func tearDown() {
430
- sut = nil
431
- mockRepository = nil
432
- super.tearDown()
433
- }
434
-
435
- func test_loadUsers_성공시_users가_업데이트된다() async {
436
- // Given
437
- let expectedUsers = [
438
- User(id: "1", name: "테스트1", email: "test1@example.com"),
439
- User(id: "2", name: "테스트2", email: "test2@example.com")
440
- ]
441
- mockRepository.fetchUsersResult = .success(expectedUsers)
442
-
443
- // When
444
- await sut.loadUsers()
445
-
446
- // Then
447
- XCTAssertEqual(sut.users.count, 2)
448
- XCTAssertFalse(sut.isLoading)
449
- }
450
-
451
- func test_filteredUsers_검색어가_있으면_필터링된다() {
452
- // Given
453
- sut.users = [
454
- User(id: "1", name: "홍길동", email: "hong@example.com"),
455
- User(id: "2", name: "김철수", email: "kim@example.com")
456
- ]
457
-
458
- // When
459
- sut.searchText = "홍"
460
-
461
- // Then
462
- XCTAssertEqual(sut.filteredUsers.count, 1)
463
- XCTAssertEqual(sut.filteredUsers.first?.name, "홍길동")
464
- }
465
- }
466
- ```
467
-
468
- ## 파일 구조
469
-
470
- ```
471
- Project/
472
- ├── App/
473
- │ ├── ProjectApp.swift # 앱 진입점
474
- │ └── DIContainer.swift # 의존성 컨테이너
475
- ├── Features/
476
- │ ├── Auth/
477
- │ │ ├── Views/
478
- │ │ ├── ViewModels/
479
- │ │ └── Models/
480
- │ └── User/
481
- │ ├── Views/
482
- │ │ ├── UserListView.swift
483
- │ │ └── UserDetailView.swift
484
- │ ├── ViewModels/
485
- │ │ └── UserListViewModel.swift
486
- │ └── Models/
487
- │ └── User.swift
488
- ├── Core/
489
- │ ├── Network/
490
- │ │ ├── APIClient.swift
491
- │ │ └── Endpoint.swift
492
- │ ├── Storage/
493
- │ │ └── CacheManager.swift
494
- │ └── Utils/
495
- │ └── Extensions/
496
- ├── Repositories/
497
- │ ├── UserRepository.swift
498
- │ └── Implementations/
499
- ├── Resources/
500
- │ ├── Assets.xcassets
501
- │ └── Localizable.strings
502
- └── Tests/
503
- ├── UnitTests/
504
- └── UITests/
505
- ```
506
-
507
- ## 체크리스트
508
-
509
- - [ ] @Observable 또는 @ObservableObject 사용
510
- - [ ] MVVM 패턴 준수
511
- - [ ] async/await로 비동기 처리
512
- - [ ] Protocol로 의존성 추상화
513
- - [ ] @MainActor로 UI 업데이트 보장
514
- - [ ] LocalizedError로 에러 메시지 정의
515
- - [ ] @ViewBuilder로 조건부 뷰 분리
516
- - [ ] 테스트 가능한 구조 (Mock 주입)
1
+ # 🍎 Swift + iOS 품질 규칙
2
+
3
+ ## 핵심 원칙 (core에서 상속)
4
+
5
+ ```markdown
6
+ ✅ 단일 책임 (SRP)
7
+ ✅ 중복 제거 (DRY)
8
+ ✅ 재사용성
9
+ ✅ 낮은 복잡도
10
+ ✅ 함수 ≤ 30줄
11
+ ✅ 중첩 ≤ 3단계
12
+ ✅ Cyclomatic complexity ≤ 10
13
+ ```
14
+
15
+ ## Swift/iOS 특화 규칙
16
+
17
+ ### 1. SwiftUI 기본 구조
18
+
19
+ ```swift
20
+ // ✅ View 구조
21
+ import SwiftUI
22
+
23
+ struct UserProfileView: View {
24
+ // 1. 상태 및 바인딩
25
+ @StateObject private var viewModel: UserProfileViewModel
26
+ @State private var isEditing = false
27
+ @Binding var selectedUser: User?
28
+
29
+ // 2. 환경 변수
30
+ @Environment(\.dismiss) private var dismiss
31
+ @EnvironmentObject private var authManager: AuthManager
32
+
33
+ // 3. Body
34
+ var body: some View {
35
+ NavigationStack {
36
+ content
37
+ .navigationTitle("프로필")
38
+ .toolbar { toolbarContent }
39
+ .sheet(isPresented: $isEditing) { editSheet }
40
+ }
41
+ .task { await viewModel.loadUser() }
42
+ }
43
+
44
+ // 4. 뷰 컴포넌트 분리
45
+ @ViewBuilder
46
+ private var content: some View {
47
+ if viewModel.isLoading {
48
+ ProgressView()
49
+ } else if let user = viewModel.user {
50
+ userContent(user)
51
+ } else {
52
+ emptyState
53
+ }
54
+ }
55
+
56
+ private func userContent(_ user: User) -> some View {
57
+ List {
58
+ Section("기본 정보") {
59
+ LabeledContent("이름", value: user.name)
60
+ LabeledContent("이메일", value: user.email)
61
+ }
62
+ }
63
+ }
64
+
65
+ @ToolbarContentBuilder
66
+ private var toolbarContent: some ToolbarContent {
67
+ ToolbarItem(placement: .topBarTrailing) {
68
+ Button("편집") { isEditing = true }
69
+ }
70
+ }
71
+ }
72
+ ```
73
+
74
+ ### 2. ViewModel (MVVM)
75
+
76
+ ```swift
77
+ // ✅ ViewModel with @Observable (iOS 17+)
78
+ import Foundation
79
+ import Observation
80
+
81
+ @Observable
82
+ final class UserProfileViewModel {
83
+ // 상태
84
+ private(set) var user: User?
85
+ private(set) var isLoading = false
86
+ private(set) var error: AppError?
87
+
88
+ // 의존성
89
+ private let userRepository: UserRepository
90
+ private let userId: String
91
+
92
+ init(userId: String, userRepository: UserRepository = DefaultUserRepository()) {
93
+ self.userId = userId
94
+ self.userRepository = userRepository
95
+ }
96
+
97
+ @MainActor
98
+ func loadUser() async {
99
+ isLoading = true
100
+ error = nil
101
+
102
+ do {
103
+ user = try await userRepository.fetchUser(id: userId)
104
+ } catch {
105
+ self.error = AppError.from(error)
106
+ }
107
+
108
+ isLoading = false
109
+ }
110
+
111
+ @MainActor
112
+ func updateUser(name: String) async throws {
113
+ guard var currentUser = user else { return }
114
+ currentUser.name = name
115
+
116
+ user = try await userRepository.updateUser(currentUser)
117
+ }
118
+ }
119
+
120
+ // ✅ ViewModel with ObservableObject (iOS 13+)
121
+ import Combine
122
+
123
+ final class UserListViewModel: ObservableObject {
124
+ @Published private(set) var users: [User] = []
125
+ @Published private(set) var isLoading = false
126
+ @Published var searchText = ""
127
+
128
+ private let userRepository: UserRepository
129
+ private var cancellables = Set<AnyCancellable>()
130
+
131
+ var filteredUsers: [User] {
132
+ guard !searchText.isEmpty else { return users }
133
+ return users.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
134
+ }
135
+
136
+ init(userRepository: UserRepository = DefaultUserRepository()) {
137
+ self.userRepository = userRepository
138
+ setupBindings()
139
+ }
140
+
141
+ private func setupBindings() {
142
+ $searchText
143
+ .debounce(for: .milliseconds(300), scheduler: RunLoop.main)
144
+ .sink { [weak self] _ in
145
+ self?.objectWillChange.send()
146
+ }
147
+ .store(in: &cancellables)
148
+ }
149
+
150
+ @MainActor
151
+ func loadUsers() async {
152
+ isLoading = true
153
+ defer { isLoading = false }
154
+
155
+ do {
156
+ users = try await userRepository.fetchUsers()
157
+ } catch {
158
+ print("Error: \(error)")
159
+ }
160
+ }
161
+ }
162
+ ```
163
+
164
+ ### 3. Repository 패턴
165
+
166
+ ```swift
167
+ // ✅ Protocol 정의
168
+ protocol UserRepository {
169
+ func fetchUsers() async throws -> [User]
170
+ func fetchUser(id: String) async throws -> User
171
+ func createUser(_ user: User) async throws -> User
172
+ func updateUser(_ user: User) async throws -> User
173
+ func deleteUser(id: String) async throws
174
+ }
175
+
176
+ // ✅ 구현체
177
+ final class DefaultUserRepository: UserRepository {
178
+ private let apiClient: APIClient
179
+ private let cache: CacheManager
180
+
181
+ init(apiClient: APIClient = .shared, cache: CacheManager = .shared) {
182
+ self.apiClient = apiClient
183
+ self.cache = cache
184
+ }
185
+
186
+ func fetchUser(id: String) async throws -> User {
187
+ // 캐시 확인
188
+ if let cached: User = cache.get(key: "user_\(id)") {
189
+ return cached
190
+ }
191
+
192
+ // API 호출
193
+ let user: User = try await apiClient.request(
194
+ endpoint: .user(id: id),
195
+ method: .get
196
+ )
197
+
198
+ // 캐시 저장
199
+ cache.set(key: "user_\(id)", value: user, ttl: 300)
200
+
201
+ return user
202
+ }
203
+
204
+ func fetchUsers() async throws -> [User] {
205
+ try await apiClient.request(
206
+ endpoint: .users,
207
+ method: .get
208
+ )
209
+ }
210
+ }
211
+ ```
212
+
213
+ ### 4. 에러 처리
214
+
215
+ ```swift
216
+ // ✅ 커스텀 에러 정의
217
+ enum AppError: LocalizedError {
218
+ case networkError(underlying: Error)
219
+ case decodingError(underlying: Error)
220
+ case notFound(resource: String, id: String)
221
+ case unauthorized
222
+ case serverError(message: String)
223
+ case unknown
224
+
225
+ var errorDescription: String? {
226
+ switch self {
227
+ case .networkError:
228
+ return "네트워크 연결을 확인해주세요"
229
+ case .decodingError:
230
+ return "데이터를 처리할 수 없습니다"
231
+ case .notFound(let resource, let id):
232
+ return "\(resource)을(를) 찾을 수 없습니다 (ID: \(id))"
233
+ case .unauthorized:
234
+ return "로그인이 필요합니다"
235
+ case .serverError(let message):
236
+ return "서버 오류: \(message)"
237
+ case .unknown:
238
+ return "알 수 없는 오류가 발생했습니다"
239
+ }
240
+ }
241
+
242
+ static func from(_ error: Error) -> AppError {
243
+ if let appError = error as? AppError {
244
+ return appError
245
+ }
246
+
247
+ if let urlError = error as? URLError {
248
+ return .networkError(underlying: urlError)
249
+ }
250
+
251
+ if error is DecodingError {
252
+ return .decodingError(underlying: error)
253
+ }
254
+
255
+ return .unknown
256
+ }
257
+ }
258
+
259
+ // ✅ Result 타입 활용
260
+ func loadData() async -> Result<User, AppError> {
261
+ do {
262
+ let user = try await repository.fetchUser(id: userId)
263
+ return .success(user)
264
+ } catch {
265
+ return .failure(AppError.from(error))
266
+ }
267
+ }
268
+ ```
269
+
270
+ ### 5. 네트워킹 (async/await)
271
+
272
+ ```swift
273
+ // ✅ API 클라이언트
274
+ final class APIClient {
275
+ static let shared = APIClient()
276
+
277
+ private let session: URLSession
278
+ private let decoder: JSONDecoder
279
+ private let baseURL: URL
280
+
281
+ init(session: URLSession = .shared, baseURL: URL = Config.apiBaseURL) {
282
+ self.session = session
283
+ self.baseURL = baseURL
284
+ self.decoder = JSONDecoder()
285
+ self.decoder.keyDecodingStrategy = .convertFromSnakeCase
286
+ self.decoder.dateDecodingStrategy = .iso8601
287
+ }
288
+
289
+ func request<T: Decodable>(
290
+ endpoint: Endpoint,
291
+ method: HTTPMethod,
292
+ body: Encodable? = nil
293
+ ) async throws -> T {
294
+ var request = URLRequest(url: baseURL.appendingPathComponent(endpoint.path))
295
+ request.httpMethod = method.rawValue
296
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
297
+
298
+ // 인증 토큰
299
+ if let token = AuthManager.shared.accessToken {
300
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
301
+ }
302
+
303
+ // Body
304
+ if let body {
305
+ request.httpBody = try JSONEncoder().encode(body)
306
+ }
307
+
308
+ let (data, response) = try await session.data(for: request)
309
+
310
+ guard let httpResponse = response as? HTTPURLResponse else {
311
+ throw AppError.unknown
312
+ }
313
+
314
+ switch httpResponse.statusCode {
315
+ case 200...299:
316
+ return try decoder.decode(T.self, from: data)
317
+ case 401:
318
+ throw AppError.unauthorized
319
+ case 404:
320
+ throw AppError.notFound(resource: endpoint.resource, id: endpoint.id ?? "")
321
+ default:
322
+ throw AppError.serverError(message: "Status: \(httpResponse.statusCode)")
323
+ }
324
+ }
325
+ }
326
+
327
+ // ✅ Endpoint 정의
328
+ enum Endpoint {
329
+ case users
330
+ case user(id: String)
331
+ case createUser
332
+ case updateUser(id: String)
333
+
334
+ var path: String {
335
+ switch self {
336
+ case .users, .createUser:
337
+ return "/users"
338
+ case .user(let id), .updateUser(let id):
339
+ return "/users/\(id)"
340
+ }
341
+ }
342
+
343
+ var resource: String { "User" }
344
+
345
+ var id: String? {
346
+ switch self {
347
+ case .user(let id), .updateUser(let id):
348
+ return id
349
+ default:
350
+ return nil
351
+ }
352
+ }
353
+ }
354
+ ```
355
+
356
+ ### 6. 의존성 주입
357
+
358
+ ```swift
359
+ // ✅ Environment를 통한 DI (SwiftUI)
360
+ private struct UserRepositoryKey: EnvironmentKey {
361
+ static let defaultValue: UserRepository = DefaultUserRepository()
362
+ }
363
+
364
+ extension EnvironmentValues {
365
+ var userRepository: UserRepository {
366
+ get { self[UserRepositoryKey.self] }
367
+ set { self[UserRepositoryKey.self] = newValue }
368
+ }
369
+ }
370
+
371
+ // 사용
372
+ struct ContentView: View {
373
+ @Environment(\.userRepository) private var userRepository
374
+
375
+ var body: some View {
376
+ UserListView(viewModel: UserListViewModel(userRepository: userRepository))
377
+ }
378
+ }
379
+
380
+ // ✅ Container 패턴
381
+ final class DIContainer {
382
+ static let shared = DIContainer()
383
+
384
+ lazy var userRepository: UserRepository = DefaultUserRepository(
385
+ apiClient: apiClient,
386
+ cache: cacheManager
387
+ )
388
+
389
+ lazy var apiClient: APIClient = APIClient()
390
+ lazy var cacheManager: CacheManager = CacheManager()
391
+
392
+ private init() {}
393
+ }
394
+ ```
395
+
396
+ ### 7. 테스트
397
+
398
+ ```swift
399
+ import XCTest
400
+ @testable import MyApp
401
+
402
+ // ✅ Mock Repository
403
+ final class MockUserRepository: UserRepository {
404
+ var fetchUsersResult: Result<[User], Error> = .success([])
405
+ var fetchUserResult: Result<User, Error> = .failure(AppError.notFound(resource: "User", id: ""))
406
+
407
+ func fetchUsers() async throws -> [User] {
408
+ try fetchUsersResult.get()
409
+ }
410
+
411
+ func fetchUser(id: String) async throws -> User {
412
+ try fetchUserResult.get()
413
+ }
414
+
415
+ // ... 다른 메서드
416
+ }
417
+
418
+ // ✅ ViewModel 테스트
419
+ final class UserListViewModelTests: XCTestCase {
420
+ var sut: UserListViewModel!
421
+ var mockRepository: MockUserRepository!
422
+
423
+ override func setUp() {
424
+ super.setUp()
425
+ mockRepository = MockUserRepository()
426
+ sut = UserListViewModel(userRepository: mockRepository)
427
+ }
428
+
429
+ override func tearDown() {
430
+ sut = nil
431
+ mockRepository = nil
432
+ super.tearDown()
433
+ }
434
+
435
+ func test_loadUsers_성공시_users가_업데이트된다() async {
436
+ // Given
437
+ let expectedUsers = [
438
+ User(id: "1", name: "테스트1", email: "test1@example.com"),
439
+ User(id: "2", name: "테스트2", email: "test2@example.com")
440
+ ]
441
+ mockRepository.fetchUsersResult = .success(expectedUsers)
442
+
443
+ // When
444
+ await sut.loadUsers()
445
+
446
+ // Then
447
+ XCTAssertEqual(sut.users.count, 2)
448
+ XCTAssertFalse(sut.isLoading)
449
+ }
450
+
451
+ func test_filteredUsers_검색어가_있으면_필터링된다() {
452
+ // Given
453
+ sut.users = [
454
+ User(id: "1", name: "홍길동", email: "hong@example.com"),
455
+ User(id: "2", name: "김철수", email: "kim@example.com")
456
+ ]
457
+
458
+ // When
459
+ sut.searchText = "홍"
460
+
461
+ // Then
462
+ XCTAssertEqual(sut.filteredUsers.count, 1)
463
+ XCTAssertEqual(sut.filteredUsers.first?.name, "홍길동")
464
+ }
465
+ }
466
+ ```
467
+
468
+ ## 파일 구조
469
+
470
+ ```
471
+ Project/
472
+ ├── App/
473
+ │ ├── ProjectApp.swift # 앱 진입점
474
+ │ └── DIContainer.swift # 의존성 컨테이너
475
+ ├── Features/
476
+ │ ├── Auth/
477
+ │ │ ├── Views/
478
+ │ │ ├── ViewModels/
479
+ │ │ └── Models/
480
+ │ └── User/
481
+ │ ├── Views/
482
+ │ │ ├── UserListView.swift
483
+ │ │ └── UserDetailView.swift
484
+ │ ├── ViewModels/
485
+ │ │ └── UserListViewModel.swift
486
+ │ └── Models/
487
+ │ └── User.swift
488
+ ├── Core/
489
+ │ ├── Network/
490
+ │ │ ├── APIClient.swift
491
+ │ │ └── Endpoint.swift
492
+ │ ├── Storage/
493
+ │ │ └── CacheManager.swift
494
+ │ └── Utils/
495
+ │ └── Extensions/
496
+ ├── Repositories/
497
+ │ ├── UserRepository.swift
498
+ │ └── Implementations/
499
+ ├── Resources/
500
+ │ ├── Assets.xcassets
501
+ │ └── Localizable.strings
502
+ └── Tests/
503
+ ├── UnitTests/
504
+ └── UITests/
505
+ ```
506
+
507
+ ## 체크리스트
508
+
509
+ - [ ] @Observable 또는 @ObservableObject 사용
510
+ - [ ] MVVM 패턴 준수
511
+ - [ ] async/await로 비동기 처리
512
+ - [ ] Protocol로 의존성 추상화
513
+ - [ ] @MainActor로 UI 업데이트 보장
514
+ - [ ] LocalizedError로 에러 메시지 정의
515
+ - [ ] @ViewBuilder로 조건부 뷰 분리
516
+ - [ ] 테스트 가능한 구조 (Mock 주입)