@su-record/vibe 0.4.6 โ 0.4.7
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.
- package/.vibe/rules/core/communication-guide.md +104 -0
- package/.vibe/rules/core/development-philosophy.md +53 -0
- package/.vibe/rules/core/quick-start.md +121 -0
- package/.vibe/rules/languages/dart-flutter.md +509 -0
- package/.vibe/rules/languages/go.md +396 -0
- package/.vibe/rules/languages/java-spring.md +586 -0
- package/.vibe/rules/languages/kotlin-android.md +491 -0
- package/.vibe/rules/languages/python-django.md +371 -0
- package/.vibe/rules/languages/python-fastapi.md +386 -0
- package/.vibe/rules/languages/rust.md +425 -0
- package/.vibe/rules/languages/swift-ios.md +516 -0
- package/.vibe/rules/languages/typescript-nextjs.md +441 -0
- package/.vibe/rules/languages/typescript-node.md +375 -0
- package/.vibe/rules/languages/typescript-react-native.md +446 -0
- package/.vibe/rules/languages/typescript-react.md +525 -0
- package/.vibe/rules/languages/typescript-vue.md +353 -0
- package/.vibe/rules/quality/bdd-contract-testing.md +388 -0
- package/.vibe/rules/quality/checklist.md +276 -0
- package/.vibe/rules/quality/testing-strategy.md +437 -0
- package/.vibe/rules/standards/anti-patterns.md +369 -0
- package/.vibe/rules/standards/code-structure.md +291 -0
- package/.vibe/rules/standards/complexity-metrics.md +312 -0
- package/.vibe/rules/standards/naming-conventions.md +198 -0
- package/.vibe/rules/tools/mcp-hi-ai-guide.md +665 -0
- package/.vibe/rules/tools/mcp-workflow.md +51 -0
- package/package.json +2 -2
|
@@ -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 ์ฃผ์
)
|