@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,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() ์‚ฌ์šฉ