@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,491 +1,491 @@
1
- # 🤖 Kotlin + Android 품질 규칙
2
-
3
- ## 핵심 원칙 (core에서 상속)
4
-
5
- ```markdown
6
- ✅ 단일 책임 (SRP)
7
- ✅ 중복 제거 (DRY)
8
- ✅ 재사용성
9
- ✅ 낮은 복잡도
10
- ✅ 함수 ≤ 30줄
11
- ✅ 중첩 ≤ 3단계
12
- ✅ Cyclomatic complexity ≤ 10
13
- ```
14
-
15
- ## Kotlin/Android 특화 규칙
16
-
17
- ### 1. Jetpack Compose UI
18
-
19
- ```kotlin
20
- // ✅ Composable 함수
21
- @Composable
22
- fun UserProfileScreen(
23
- viewModel: UserProfileViewModel = hiltViewModel(),
24
- onNavigateBack: () -> Unit
25
- ) {
26
- val uiState by viewModel.uiState.collectAsStateWithLifecycle()
27
-
28
- UserProfileContent(
29
- uiState = uiState,
30
- onRefresh = viewModel::loadUser,
31
- onNavigateBack = onNavigateBack
32
- )
33
- }
34
-
35
- // ✅ Stateless Composable (재사용 가능)
36
- @Composable
37
- private fun UserProfileContent(
38
- uiState: UserProfileUiState,
39
- onRefresh: () -> Unit,
40
- onNavigateBack: () -> Unit,
41
- modifier: Modifier = Modifier
42
- ) {
43
- Scaffold(
44
- topBar = {
45
- TopAppBar(
46
- title = { Text("프로필") },
47
- navigationIcon = {
48
- IconButton(onClick = onNavigateBack) {
49
- Icon(Icons.Default.ArrowBack, contentDescription = "뒤로")
50
- }
51
- }
52
- )
53
- }
54
- ) { paddingValues ->
55
- when (uiState) {
56
- is UserProfileUiState.Loading -> LoadingContent(modifier.padding(paddingValues))
57
- is UserProfileUiState.Success -> UserContent(
58
- user = uiState.user,
59
- modifier = modifier.padding(paddingValues)
60
- )
61
- is UserProfileUiState.Error -> ErrorContent(
62
- message = uiState.message,
63
- onRetry = onRefresh,
64
- modifier = modifier.padding(paddingValues)
65
- )
66
- }
67
- }
68
- }
69
-
70
- // ✅ 재사용 가능한 컴포넌트
71
- @Composable
72
- fun UserCard(
73
- user: User,
74
- onClick: () -> Unit,
75
- modifier: Modifier = Modifier
76
- ) {
77
- Card(
78
- modifier = modifier
79
- .fillMaxWidth()
80
- .clickable(onClick = onClick),
81
- elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
82
- ) {
83
- Row(
84
- modifier = Modifier.padding(16.dp),
85
- verticalAlignment = Alignment.CenterVertically
86
- ) {
87
- AsyncImage(
88
- model = user.profileImage,
89
- contentDescription = "${user.name} 프로필",
90
- modifier = Modifier
91
- .size(48.dp)
92
- .clip(CircleShape)
93
- )
94
- Spacer(modifier = Modifier.width(16.dp))
95
- Column {
96
- Text(
97
- text = user.name,
98
- style = MaterialTheme.typography.titleMedium
99
- )
100
- Text(
101
- text = user.email,
102
- style = MaterialTheme.typography.bodySmall,
103
- color = MaterialTheme.colorScheme.onSurfaceVariant
104
- )
105
- }
106
- }
107
- }
108
- }
109
- ```
110
-
111
- ### 2. ViewModel (MVVM)
112
-
113
- ```kotlin
114
- // ✅ UiState 정의 (Sealed Interface)
115
- sealed interface UserListUiState {
116
- data object Loading : UserListUiState
117
- data class Success(
118
- val users: List<User>,
119
- val isRefreshing: Boolean = false
120
- ) : UserListUiState
121
- data class Error(val message: String) : UserListUiState
122
- }
123
-
124
- // ✅ ViewModel with Hilt
125
- @HiltViewModel
126
- class UserListViewModel @Inject constructor(
127
- private val getUsersUseCase: GetUsersUseCase,
128
- private val savedStateHandle: SavedStateHandle
129
- ) : ViewModel() {
130
-
131
- private val _uiState = MutableStateFlow<UserListUiState>(UserListUiState.Loading)
132
- val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()
133
-
134
- private val searchQuery = savedStateHandle.getStateFlow("search", "")
135
-
136
- val filteredUsers: StateFlow<List<User>> = combine(
137
- _uiState,
138
- searchQuery
139
- ) { state, query ->
140
- when (state) {
141
- is UserListUiState.Success -> {
142
- if (query.isBlank()) state.users
143
- else state.users.filter { it.name.contains(query, ignoreCase = true) }
144
- }
145
- else -> emptyList()
146
- }
147
- }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
148
-
149
- init {
150
- loadUsers()
151
- }
152
-
153
- fun loadUsers() {
154
- viewModelScope.launch {
155
- _uiState.value = UserListUiState.Loading
156
-
157
- getUsersUseCase()
158
- .onSuccess { users ->
159
- _uiState.value = UserListUiState.Success(users)
160
- }
161
- .onFailure { error ->
162
- _uiState.value = UserListUiState.Error(
163
- error.message ?: "사용자 목록을 불러올 수 없습니다"
164
- )
165
- }
166
- }
167
- }
168
-
169
- fun updateSearchQuery(query: String) {
170
- savedStateHandle["search"] = query
171
- }
172
-
173
- fun refresh() {
174
- viewModelScope.launch {
175
- val currentState = _uiState.value
176
- if (currentState is UserListUiState.Success) {
177
- _uiState.value = currentState.copy(isRefreshing = true)
178
- }
179
-
180
- getUsersUseCase()
181
- .onSuccess { users ->
182
- _uiState.value = UserListUiState.Success(users, isRefreshing = false)
183
- }
184
- .onFailure { /* 에러 처리 */ }
185
- }
186
- }
187
- }
188
- ```
189
-
190
- ### 3. UseCase (Clean Architecture)
191
-
192
- ```kotlin
193
- // ✅ UseCase 정의
194
- class GetUsersUseCase @Inject constructor(
195
- private val userRepository: UserRepository,
196
- private val dispatcher: CoroutineDispatcher = Dispatchers.IO
197
- ) {
198
- suspend operator fun invoke(): Result<List<User>> = withContext(dispatcher) {
199
- runCatching {
200
- userRepository.getUsers()
201
- }
202
- }
203
- }
204
-
205
- class GetUserUseCase @Inject constructor(
206
- private val userRepository: UserRepository
207
- ) {
208
- suspend operator fun invoke(id: String): Result<User> = runCatching {
209
- userRepository.getUser(id)
210
- }
211
- }
212
-
213
- class CreateUserUseCase @Inject constructor(
214
- private val userRepository: UserRepository,
215
- private val validator: UserValidator
216
- ) {
217
- suspend operator fun invoke(request: CreateUserRequest): Result<User> {
218
- // 유효성 검사
219
- validator.validate(request).onFailure { return Result.failure(it) }
220
-
221
- return runCatching {
222
- userRepository.createUser(request)
223
- }
224
- }
225
- }
226
- ```
227
-
228
- ### 4. Repository 패턴
229
-
230
- ```kotlin
231
- // ✅ Repository Interface
232
- interface UserRepository {
233
- suspend fun getUsers(): List<User>
234
- suspend fun getUser(id: String): User
235
- suspend fun createUser(request: CreateUserRequest): User
236
- suspend fun updateUser(id: String, request: UpdateUserRequest): User
237
- suspend fun deleteUser(id: String)
238
- }
239
-
240
- // ✅ Repository 구현
241
- class UserRepositoryImpl @Inject constructor(
242
- private val apiService: UserApiService,
243
- private val userDao: UserDao,
244
- private val dispatcher: CoroutineDispatcher = Dispatchers.IO
245
- ) : UserRepository {
246
-
247
- override suspend fun getUsers(): List<User> = withContext(dispatcher) {
248
- try {
249
- // API에서 데이터 가져오기
250
- val response = apiService.getUsers()
251
- val users = response.map { it.toDomain() }
252
-
253
- // 로컬 캐시 업데이트
254
- userDao.insertAll(users.map { it.toEntity() })
255
-
256
- users
257
- } catch (e: Exception) {
258
- // 오프라인: 로컬 데이터 반환
259
- userDao.getAll().map { it.toDomain() }
260
- }
261
- }
262
-
263
- override suspend fun getUser(id: String): User = withContext(dispatcher) {
264
- val response = apiService.getUser(id)
265
- response.toDomain()
266
- }
267
- }
268
- ```
269
-
270
- ### 5. 에러 처리
271
-
272
- ```kotlin
273
- // ✅ 커스텀 예외
274
- sealed class AppException(message: String) : Exception(message) {
275
- class NetworkException(message: String = "네트워크 연결을 확인해주세요") : AppException(message)
276
- class UnauthorizedException(message: String = "로그인이 필요합니다") : AppException(message)
277
- class NotFoundException(
278
- val resource: String,
279
- val id: String
280
- ) : AppException("${resource}을(를) 찾을 수 없습니다 (ID: $id)")
281
- class ServerException(message: String) : AppException(message)
282
- class ValidationException(message: String) : AppException(message)
283
- }
284
-
285
- // ✅ Result 확장 함수
286
- inline fun <T> Result<T>.onSuccess(action: (T) -> Unit): Result<T> {
287
- getOrNull()?.let(action)
288
- return this
289
- }
290
-
291
- inline fun <T> Result<T>.onFailure(action: (Throwable) -> Unit): Result<T> {
292
- exceptionOrNull()?.let(action)
293
- return this
294
- }
295
-
296
- // ✅ API 응답 처리
297
- suspend fun <T> safeApiCall(
298
- dispatcher: CoroutineDispatcher = Dispatchers.IO,
299
- apiCall: suspend () -> T
300
- ): Result<T> = withContext(dispatcher) {
301
- runCatching {
302
- apiCall()
303
- }.recoverCatching { throwable ->
304
- when (throwable) {
305
- is HttpException -> {
306
- when (throwable.code()) {
307
- 401 -> throw AppException.UnauthorizedException()
308
- 404 -> throw AppException.NotFoundException("리소스", "unknown")
309
- else -> throw AppException.ServerException("서버 오류: ${throwable.code()}")
310
- }
311
- }
312
- is IOException -> throw AppException.NetworkException()
313
- else -> throw throwable
314
- }
315
- }
316
- }
317
- ```
318
-
319
- ### 6. Hilt 의존성 주입
320
-
321
- ```kotlin
322
- // ✅ Module 정의
323
- @Module
324
- @InstallIn(SingletonComponent::class)
325
- object NetworkModule {
326
-
327
- @Provides
328
- @Singleton
329
- fun provideOkHttpClient(): OkHttpClient {
330
- return OkHttpClient.Builder()
331
- .addInterceptor(AuthInterceptor())
332
- .addInterceptor(HttpLoggingInterceptor().apply {
333
- level = HttpLoggingInterceptor.Level.BODY
334
- })
335
- .connectTimeout(30, TimeUnit.SECONDS)
336
- .readTimeout(30, TimeUnit.SECONDS)
337
- .build()
338
- }
339
-
340
- @Provides
341
- @Singleton
342
- fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
343
- return Retrofit.Builder()
344
- .baseUrl(BuildConfig.API_BASE_URL)
345
- .client(okHttpClient)
346
- .addConverterFactory(GsonConverterFactory.create())
347
- .build()
348
- }
349
-
350
- @Provides
351
- @Singleton
352
- fun provideUserApiService(retrofit: Retrofit): UserApiService {
353
- return retrofit.create(UserApiService::class.java)
354
- }
355
- }
356
-
357
- @Module
358
- @InstallIn(SingletonComponent::class)
359
- abstract class RepositoryModule {
360
-
361
- @Binds
362
- @Singleton
363
- abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
364
- }
365
- ```
366
-
367
- ### 7. 테스트
368
-
369
- ```kotlin
370
- // ✅ ViewModel 테스트
371
- @OptIn(ExperimentalCoroutinesApi::class)
372
- class UserListViewModelTest {
373
-
374
- @get:Rule
375
- val mainDispatcherRule = MainDispatcherRule()
376
-
377
- private lateinit var viewModel: UserListViewModel
378
- private lateinit var getUsersUseCase: GetUsersUseCase
379
- private lateinit var fakeUserRepository: FakeUserRepository
380
-
381
- @Before
382
- fun setup() {
383
- fakeUserRepository = FakeUserRepository()
384
- getUsersUseCase = GetUsersUseCase(fakeUserRepository)
385
- viewModel = UserListViewModel(getUsersUseCase, SavedStateHandle())
386
- }
387
-
388
- @Test
389
- fun `loadUsers 성공시 Success 상태가 된다`() = runTest {
390
- // Given
391
- val expectedUsers = listOf(
392
- User(id = "1", name = "테스트1", email = "test1@example.com"),
393
- User(id = "2", name = "테스트2", email = "test2@example.com")
394
- )
395
- fakeUserRepository.setUsers(expectedUsers)
396
-
397
- // When
398
- viewModel.loadUsers()
399
-
400
- // Then
401
- val state = viewModel.uiState.value
402
- assertThat(state).isInstanceOf(UserListUiState.Success::class.java)
403
- assertThat((state as UserListUiState.Success).users).hasSize(2)
404
- }
405
-
406
- @Test
407
- fun `loadUsers 실패시 Error 상태가 된다`() = runTest {
408
- // Given
409
- fakeUserRepository.setShouldReturnError(true)
410
-
411
- // When
412
- viewModel.loadUsers()
413
-
414
- // Then
415
- val state = viewModel.uiState.value
416
- assertThat(state).isInstanceOf(UserListUiState.Error::class.java)
417
- }
418
- }
419
-
420
- // ✅ Fake Repository
421
- class FakeUserRepository : UserRepository {
422
- private var users = mutableListOf<User>()
423
- private var shouldReturnError = false
424
-
425
- fun setUsers(users: List<User>) {
426
- this.users = users.toMutableList()
427
- }
428
-
429
- fun setShouldReturnError(value: Boolean) {
430
- shouldReturnError = value
431
- }
432
-
433
- override suspend fun getUsers(): List<User> {
434
- if (shouldReturnError) throw Exception("Test error")
435
- return users
436
- }
437
-
438
- // ... 다른 메서드
439
- }
440
- ```
441
-
442
- ## 파일 구조
443
-
444
- ```
445
- app/
446
- ├── src/main/java/com/example/app/
447
- │ ├── di/ # Hilt 모듈
448
- │ │ ├── NetworkModule.kt
449
- │ │ └── RepositoryModule.kt
450
- │ ├── data/
451
- │ │ ├── api/ # API 서비스
452
- │ │ │ └── UserApiService.kt
453
- │ │ ├── local/ # Room DAO
454
- │ │ │ └── UserDao.kt
455
- │ │ ├── model/ # DTO
456
- │ │ │ └── UserDto.kt
457
- │ │ └── repository/ # Repository 구현
458
- │ │ └── UserRepositoryImpl.kt
459
- │ ├── domain/
460
- │ │ ├── model/ # 도메인 모델
461
- │ │ │ └── User.kt
462
- │ │ ├── repository/ # Repository 인터페이스
463
- │ │ │ └── UserRepository.kt
464
- │ │ └── usecase/ # UseCase
465
- │ │ └── GetUsersUseCase.kt
466
- │ └── presentation/
467
- │ ├── ui/
468
- │ │ ├── components/ # 공통 Composable
469
- │ │ └── theme/ # Material Theme
470
- │ └── feature/
471
- │ └── user/
472
- │ ├── UserListScreen.kt
473
- │ ├── UserListViewModel.kt
474
- │ └── UserListUiState.kt
475
- └── src/test/
476
- └── java/com/example/app/
477
- └── presentation/
478
- └── feature/user/
479
- └── UserListViewModelTest.kt
480
- ```
481
-
482
- ## 체크리스트
483
-
484
- - [ ] Jetpack Compose 사용 (XML 레이아웃 지양)
485
- - [ ] StateFlow로 UI 상태 관리
486
- - [ ] Sealed Interface로 UiState 정의
487
- - [ ] Hilt로 의존성 주입
488
- - [ ] UseCase로 비즈니스 로직 분리
489
- - [ ] Repository 패턴으로 데이터 계층 추상화
490
- - [ ] Result/runCatching으로 에러 처리
491
- - [ ] collectAsStateWithLifecycle() 사용
1
+ # 🤖 Kotlin + Android 품질 규칙
2
+
3
+ ## 핵심 원칙 (core에서 상속)
4
+
5
+ ```markdown
6
+ ✅ 단일 책임 (SRP)
7
+ ✅ 중복 제거 (DRY)
8
+ ✅ 재사용성
9
+ ✅ 낮은 복잡도
10
+ ✅ 함수 ≤ 30줄
11
+ ✅ 중첩 ≤ 3단계
12
+ ✅ Cyclomatic complexity ≤ 10
13
+ ```
14
+
15
+ ## Kotlin/Android 특화 규칙
16
+
17
+ ### 1. Jetpack Compose UI
18
+
19
+ ```kotlin
20
+ // ✅ Composable 함수
21
+ @Composable
22
+ fun UserProfileScreen(
23
+ viewModel: UserProfileViewModel = hiltViewModel(),
24
+ onNavigateBack: () -> Unit
25
+ ) {
26
+ val uiState by viewModel.uiState.collectAsStateWithLifecycle()
27
+
28
+ UserProfileContent(
29
+ uiState = uiState,
30
+ onRefresh = viewModel::loadUser,
31
+ onNavigateBack = onNavigateBack
32
+ )
33
+ }
34
+
35
+ // ✅ Stateless Composable (재사용 가능)
36
+ @Composable
37
+ private fun UserProfileContent(
38
+ uiState: UserProfileUiState,
39
+ onRefresh: () -> Unit,
40
+ onNavigateBack: () -> Unit,
41
+ modifier: Modifier = Modifier
42
+ ) {
43
+ Scaffold(
44
+ topBar = {
45
+ TopAppBar(
46
+ title = { Text("프로필") },
47
+ navigationIcon = {
48
+ IconButton(onClick = onNavigateBack) {
49
+ Icon(Icons.Default.ArrowBack, contentDescription = "뒤로")
50
+ }
51
+ }
52
+ )
53
+ }
54
+ ) { paddingValues ->
55
+ when (uiState) {
56
+ is UserProfileUiState.Loading -> LoadingContent(modifier.padding(paddingValues))
57
+ is UserProfileUiState.Success -> UserContent(
58
+ user = uiState.user,
59
+ modifier = modifier.padding(paddingValues)
60
+ )
61
+ is UserProfileUiState.Error -> ErrorContent(
62
+ message = uiState.message,
63
+ onRetry = onRefresh,
64
+ modifier = modifier.padding(paddingValues)
65
+ )
66
+ }
67
+ }
68
+ }
69
+
70
+ // ✅ 재사용 가능한 컴포넌트
71
+ @Composable
72
+ fun UserCard(
73
+ user: User,
74
+ onClick: () -> Unit,
75
+ modifier: Modifier = Modifier
76
+ ) {
77
+ Card(
78
+ modifier = modifier
79
+ .fillMaxWidth()
80
+ .clickable(onClick = onClick),
81
+ elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
82
+ ) {
83
+ Row(
84
+ modifier = Modifier.padding(16.dp),
85
+ verticalAlignment = Alignment.CenterVertically
86
+ ) {
87
+ AsyncImage(
88
+ model = user.profileImage,
89
+ contentDescription = "${user.name} 프로필",
90
+ modifier = Modifier
91
+ .size(48.dp)
92
+ .clip(CircleShape)
93
+ )
94
+ Spacer(modifier = Modifier.width(16.dp))
95
+ Column {
96
+ Text(
97
+ text = user.name,
98
+ style = MaterialTheme.typography.titleMedium
99
+ )
100
+ Text(
101
+ text = user.email,
102
+ style = MaterialTheme.typography.bodySmall,
103
+ color = MaterialTheme.colorScheme.onSurfaceVariant
104
+ )
105
+ }
106
+ }
107
+ }
108
+ }
109
+ ```
110
+
111
+ ### 2. ViewModel (MVVM)
112
+
113
+ ```kotlin
114
+ // ✅ UiState 정의 (Sealed Interface)
115
+ sealed interface UserListUiState {
116
+ data object Loading : UserListUiState
117
+ data class Success(
118
+ val users: List<User>,
119
+ val isRefreshing: Boolean = false
120
+ ) : UserListUiState
121
+ data class Error(val message: String) : UserListUiState
122
+ }
123
+
124
+ // ✅ ViewModel with Hilt
125
+ @HiltViewModel
126
+ class UserListViewModel @Inject constructor(
127
+ private val getUsersUseCase: GetUsersUseCase,
128
+ private val savedStateHandle: SavedStateHandle
129
+ ) : ViewModel() {
130
+
131
+ private val _uiState = MutableStateFlow<UserListUiState>(UserListUiState.Loading)
132
+ val uiState: StateFlow<UserListUiState> = _uiState.asStateFlow()
133
+
134
+ private val searchQuery = savedStateHandle.getStateFlow("search", "")
135
+
136
+ val filteredUsers: StateFlow<List<User>> = combine(
137
+ _uiState,
138
+ searchQuery
139
+ ) { state, query ->
140
+ when (state) {
141
+ is UserListUiState.Success -> {
142
+ if (query.isBlank()) state.users
143
+ else state.users.filter { it.name.contains(query, ignoreCase = true) }
144
+ }
145
+ else -> emptyList()
146
+ }
147
+ }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
148
+
149
+ init {
150
+ loadUsers()
151
+ }
152
+
153
+ fun loadUsers() {
154
+ viewModelScope.launch {
155
+ _uiState.value = UserListUiState.Loading
156
+
157
+ getUsersUseCase()
158
+ .onSuccess { users ->
159
+ _uiState.value = UserListUiState.Success(users)
160
+ }
161
+ .onFailure { error ->
162
+ _uiState.value = UserListUiState.Error(
163
+ error.message ?: "사용자 목록을 불러올 수 없습니다"
164
+ )
165
+ }
166
+ }
167
+ }
168
+
169
+ fun updateSearchQuery(query: String) {
170
+ savedStateHandle["search"] = query
171
+ }
172
+
173
+ fun refresh() {
174
+ viewModelScope.launch {
175
+ val currentState = _uiState.value
176
+ if (currentState is UserListUiState.Success) {
177
+ _uiState.value = currentState.copy(isRefreshing = true)
178
+ }
179
+
180
+ getUsersUseCase()
181
+ .onSuccess { users ->
182
+ _uiState.value = UserListUiState.Success(users, isRefreshing = false)
183
+ }
184
+ .onFailure { /* 에러 처리 */ }
185
+ }
186
+ }
187
+ }
188
+ ```
189
+
190
+ ### 3. UseCase (Clean Architecture)
191
+
192
+ ```kotlin
193
+ // ✅ UseCase 정의
194
+ class GetUsersUseCase @Inject constructor(
195
+ private val userRepository: UserRepository,
196
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO
197
+ ) {
198
+ suspend operator fun invoke(): Result<List<User>> = withContext(dispatcher) {
199
+ runCatching {
200
+ userRepository.getUsers()
201
+ }
202
+ }
203
+ }
204
+
205
+ class GetUserUseCase @Inject constructor(
206
+ private val userRepository: UserRepository
207
+ ) {
208
+ suspend operator fun invoke(id: String): Result<User> = runCatching {
209
+ userRepository.getUser(id)
210
+ }
211
+ }
212
+
213
+ class CreateUserUseCase @Inject constructor(
214
+ private val userRepository: UserRepository,
215
+ private val validator: UserValidator
216
+ ) {
217
+ suspend operator fun invoke(request: CreateUserRequest): Result<User> {
218
+ // 유효성 검사
219
+ validator.validate(request).onFailure { return Result.failure(it) }
220
+
221
+ return runCatching {
222
+ userRepository.createUser(request)
223
+ }
224
+ }
225
+ }
226
+ ```
227
+
228
+ ### 4. Repository 패턴
229
+
230
+ ```kotlin
231
+ // ✅ Repository Interface
232
+ interface UserRepository {
233
+ suspend fun getUsers(): List<User>
234
+ suspend fun getUser(id: String): User
235
+ suspend fun createUser(request: CreateUserRequest): User
236
+ suspend fun updateUser(id: String, request: UpdateUserRequest): User
237
+ suspend fun deleteUser(id: String)
238
+ }
239
+
240
+ // ✅ Repository 구현
241
+ class UserRepositoryImpl @Inject constructor(
242
+ private val apiService: UserApiService,
243
+ private val userDao: UserDao,
244
+ private val dispatcher: CoroutineDispatcher = Dispatchers.IO
245
+ ) : UserRepository {
246
+
247
+ override suspend fun getUsers(): List<User> = withContext(dispatcher) {
248
+ try {
249
+ // API에서 데이터 가져오기
250
+ val response = apiService.getUsers()
251
+ val users = response.map { it.toDomain() }
252
+
253
+ // 로컬 캐시 업데이트
254
+ userDao.insertAll(users.map { it.toEntity() })
255
+
256
+ users
257
+ } catch (e: Exception) {
258
+ // 오프라인: 로컬 데이터 반환
259
+ userDao.getAll().map { it.toDomain() }
260
+ }
261
+ }
262
+
263
+ override suspend fun getUser(id: String): User = withContext(dispatcher) {
264
+ val response = apiService.getUser(id)
265
+ response.toDomain()
266
+ }
267
+ }
268
+ ```
269
+
270
+ ### 5. 에러 처리
271
+
272
+ ```kotlin
273
+ // ✅ 커스텀 예외
274
+ sealed class AppException(message: String) : Exception(message) {
275
+ class NetworkException(message: String = "네트워크 연결을 확인해주세요") : AppException(message)
276
+ class UnauthorizedException(message: String = "로그인이 필요합니다") : AppException(message)
277
+ class NotFoundException(
278
+ val resource: String,
279
+ val id: String
280
+ ) : AppException("${resource}을(를) 찾을 수 없습니다 (ID: $id)")
281
+ class ServerException(message: String) : AppException(message)
282
+ class ValidationException(message: String) : AppException(message)
283
+ }
284
+
285
+ // ✅ Result 확장 함수
286
+ inline fun <T> Result<T>.onSuccess(action: (T) -> Unit): Result<T> {
287
+ getOrNull()?.let(action)
288
+ return this
289
+ }
290
+
291
+ inline fun <T> Result<T>.onFailure(action: (Throwable) -> Unit): Result<T> {
292
+ exceptionOrNull()?.let(action)
293
+ return this
294
+ }
295
+
296
+ // ✅ API 응답 처리
297
+ suspend fun <T> safeApiCall(
298
+ dispatcher: CoroutineDispatcher = Dispatchers.IO,
299
+ apiCall: suspend () -> T
300
+ ): Result<T> = withContext(dispatcher) {
301
+ runCatching {
302
+ apiCall()
303
+ }.recoverCatching { throwable ->
304
+ when (throwable) {
305
+ is HttpException -> {
306
+ when (throwable.code()) {
307
+ 401 -> throw AppException.UnauthorizedException()
308
+ 404 -> throw AppException.NotFoundException("리소스", "unknown")
309
+ else -> throw AppException.ServerException("서버 오류: ${throwable.code()}")
310
+ }
311
+ }
312
+ is IOException -> throw AppException.NetworkException()
313
+ else -> throw throwable
314
+ }
315
+ }
316
+ }
317
+ ```
318
+
319
+ ### 6. Hilt 의존성 주입
320
+
321
+ ```kotlin
322
+ // ✅ Module 정의
323
+ @Module
324
+ @InstallIn(SingletonComponent::class)
325
+ object NetworkModule {
326
+
327
+ @Provides
328
+ @Singleton
329
+ fun provideOkHttpClient(): OkHttpClient {
330
+ return OkHttpClient.Builder()
331
+ .addInterceptor(AuthInterceptor())
332
+ .addInterceptor(HttpLoggingInterceptor().apply {
333
+ level = HttpLoggingInterceptor.Level.BODY
334
+ })
335
+ .connectTimeout(30, TimeUnit.SECONDS)
336
+ .readTimeout(30, TimeUnit.SECONDS)
337
+ .build()
338
+ }
339
+
340
+ @Provides
341
+ @Singleton
342
+ fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
343
+ return Retrofit.Builder()
344
+ .baseUrl(BuildConfig.API_BASE_URL)
345
+ .client(okHttpClient)
346
+ .addConverterFactory(GsonConverterFactory.create())
347
+ .build()
348
+ }
349
+
350
+ @Provides
351
+ @Singleton
352
+ fun provideUserApiService(retrofit: Retrofit): UserApiService {
353
+ return retrofit.create(UserApiService::class.java)
354
+ }
355
+ }
356
+
357
+ @Module
358
+ @InstallIn(SingletonComponent::class)
359
+ abstract class RepositoryModule {
360
+
361
+ @Binds
362
+ @Singleton
363
+ abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
364
+ }
365
+ ```
366
+
367
+ ### 7. 테스트
368
+
369
+ ```kotlin
370
+ // ✅ ViewModel 테스트
371
+ @OptIn(ExperimentalCoroutinesApi::class)
372
+ class UserListViewModelTest {
373
+
374
+ @get:Rule
375
+ val mainDispatcherRule = MainDispatcherRule()
376
+
377
+ private lateinit var viewModel: UserListViewModel
378
+ private lateinit var getUsersUseCase: GetUsersUseCase
379
+ private lateinit var fakeUserRepository: FakeUserRepository
380
+
381
+ @Before
382
+ fun setup() {
383
+ fakeUserRepository = FakeUserRepository()
384
+ getUsersUseCase = GetUsersUseCase(fakeUserRepository)
385
+ viewModel = UserListViewModel(getUsersUseCase, SavedStateHandle())
386
+ }
387
+
388
+ @Test
389
+ fun `loadUsers 성공시 Success 상태가 된다`() = runTest {
390
+ // Given
391
+ val expectedUsers = listOf(
392
+ User(id = "1", name = "테스트1", email = "test1@example.com"),
393
+ User(id = "2", name = "테스트2", email = "test2@example.com")
394
+ )
395
+ fakeUserRepository.setUsers(expectedUsers)
396
+
397
+ // When
398
+ viewModel.loadUsers()
399
+
400
+ // Then
401
+ val state = viewModel.uiState.value
402
+ assertThat(state).isInstanceOf(UserListUiState.Success::class.java)
403
+ assertThat((state as UserListUiState.Success).users).hasSize(2)
404
+ }
405
+
406
+ @Test
407
+ fun `loadUsers 실패시 Error 상태가 된다`() = runTest {
408
+ // Given
409
+ fakeUserRepository.setShouldReturnError(true)
410
+
411
+ // When
412
+ viewModel.loadUsers()
413
+
414
+ // Then
415
+ val state = viewModel.uiState.value
416
+ assertThat(state).isInstanceOf(UserListUiState.Error::class.java)
417
+ }
418
+ }
419
+
420
+ // ✅ Fake Repository
421
+ class FakeUserRepository : UserRepository {
422
+ private var users = mutableListOf<User>()
423
+ private var shouldReturnError = false
424
+
425
+ fun setUsers(users: List<User>) {
426
+ this.users = users.toMutableList()
427
+ }
428
+
429
+ fun setShouldReturnError(value: Boolean) {
430
+ shouldReturnError = value
431
+ }
432
+
433
+ override suspend fun getUsers(): List<User> {
434
+ if (shouldReturnError) throw Exception("Test error")
435
+ return users
436
+ }
437
+
438
+ // ... 다른 메서드
439
+ }
440
+ ```
441
+
442
+ ## 파일 구조
443
+
444
+ ```
445
+ app/
446
+ ├── src/main/java/com/example/app/
447
+ │ ├── di/ # Hilt 모듈
448
+ │ │ ├── NetworkModule.kt
449
+ │ │ └── RepositoryModule.kt
450
+ │ ├── data/
451
+ │ │ ├── api/ # API 서비스
452
+ │ │ │ └── UserApiService.kt
453
+ │ │ ├── local/ # Room DAO
454
+ │ │ │ └── UserDao.kt
455
+ │ │ ├── model/ # DTO
456
+ │ │ │ └── UserDto.kt
457
+ │ │ └── repository/ # Repository 구현
458
+ │ │ └── UserRepositoryImpl.kt
459
+ │ ├── domain/
460
+ │ │ ├── model/ # 도메인 모델
461
+ │ │ │ └── User.kt
462
+ │ │ ├── repository/ # Repository 인터페이스
463
+ │ │ │ └── UserRepository.kt
464
+ │ │ └── usecase/ # UseCase
465
+ │ │ └── GetUsersUseCase.kt
466
+ │ └── presentation/
467
+ │ ├── ui/
468
+ │ │ ├── components/ # 공통 Composable
469
+ │ │ └── theme/ # Material Theme
470
+ │ └── feature/
471
+ │ └── user/
472
+ │ ├── UserListScreen.kt
473
+ │ ├── UserListViewModel.kt
474
+ │ └── UserListUiState.kt
475
+ └── src/test/
476
+ └── java/com/example/app/
477
+ └── presentation/
478
+ └── feature/user/
479
+ └── UserListViewModelTest.kt
480
+ ```
481
+
482
+ ## 체크리스트
483
+
484
+ - [ ] Jetpack Compose 사용 (XML 레이아웃 지양)
485
+ - [ ] StateFlow로 UI 상태 관리
486
+ - [ ] Sealed Interface로 UiState 정의
487
+ - [ ] Hilt로 의존성 주입
488
+ - [ ] UseCase로 비즈니스 로직 분리
489
+ - [ ] Repository 패턴으로 데이터 계층 추상화
490
+ - [ ] Result/runCatching으로 에러 처리
491
+ - [ ] collectAsStateWithLifecycle() 사용