@su-record/vibe 0.4.4 โ†’ 0.4.5

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.
@@ -0,0 +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 ์ฃผ์ž…)