Architecture: MVVM, Clean Architecture, Multi-Module
UI: Compose, SharedTransition, Design System
Testing & Code Quality: JUnit, MockK, Turbine, Spotless, MockAPI.io
@Serializable
data class Detail(val article: Article) : RouteScreen() {
companion object {
val typeMap = mapOf(typeOf<Detail>() to ArticlesType)
}
}
object ArticlesType : NavType<Article>(isNullableAllowed = false) {
override fun put(bundle: Bundle, key: String, value: Article) {
bundle.putParcelable(key, value)
}
override fun get(bundle: Bundle, key: String): Article? =
BundleCompat.getParcelable(bundle, key, Article::class.java)
override fun parseValue(value: String): Article =
Json.decodeFromString(Uri.decode(value))
override fun serializeAsValue(value: Article): String =
Uri.encode(Json.encodeToString(value))
}장점: 컴파일 타임에 타입 체크 가능 (타입 안전성), 객체 직렬화를 통해 route에 직접 encode 가능
단점: 객체마다 NavType 클래스를 따로 구현해야 함, 유지보수, 코드량 증가
장점: NavType을 만들 필요 없음, 경로가 가볍고 명확함, Deep link 지원이 쉬움
단점: 화면에서 데이터를 다시 조회해야 함, (cache구현 or API)
장점: NavArgs 없이 복잡한 객체 전달 가능, 빠름 (메모리에서 직접 가져옴)
단점: ViewModel 생명주기를 잘 관리해야 함, 화면 복원, deep link 처리 어려움
NavHost(navController = navHostController, startDestination = "home") {
composable("home") {
HomeScreen(
onNavigateToDetail = { id, title ->
navHostController.navigate("detail/$id/$title")
}
)
}
composable(
route = "detail/{id}/{title}",
arguments = listOf(
navArgument("id") { type = NavType.IntType },
navArgument("title") { type = NavType.StringType }
)
) {
val id = it.arguments?.getInt("id")
val title = it.arguments?.getString("title")
DetailScreen(id = id, title = title)
}
}장점: 설정이 간단하고 직관적, 기본 타입은 NavType 자동 지원
단점: 복잡한 객체는 전달할 수 없음
📌 상황에 따라 추천되는 방식
- 단순 타입(String, Int 등) 전달 -> 기본 NavArgs 사용
- 객체 전달 & deep link 필요 -> NavType + Serializable 사용
- ID 기반 조회 구조 -> ID만 넘기고 ViewModel 에서 조회 (캐시 or API)
장점: 간단하고 직관적
단점: 테스트 시 Dispatcher.IO 대체 어려움
대안: Dispatchers.setMain(...) 같은 별도 세팅 필요
장점: 코드 깔끔, Retrofit + suspend 조합에선 내부적으로 Dispatcher 사용하여 별도 정의 필요 없음
단점: Room, 비-Retrofit 작업 을 같이 쓸 경우 MainThread 에서 실행 -> ANR
참고 오픈소스 : DroidKnights → 대부분의 suspend 호출에서 Dispatcher를 생략
internal class ArticlesRepositoryImpl @Inject constructor(
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher
) {
override fun getArticles(): Flow<List<Article>> =
flow {
emit(api.getArticles())
}.flowOn(ioDispatcher)
}장점: Dispatcher 주입으로 테스트 시 유연하게 변경 가능
단점: 코드 초기 세팅 필요
참고 오픈소스 : Now in Android, Skydoves → Repository에서 Dispatcher 주입 후 flowOn(...) 처리
📌 상황에 따라 추천 방식
1. 빠르게 앱 구성 / Dispatcher 이해 낮음 -> 1번 방법 ViewModel에서 Dispatchers.IO
2. 대부분 Retrofit이고, UI에 집중 -> 2번 Dispatcher 생략
3. 테스트, 멀티모듈, 아키텍처 중요 -> ③ Dispatcher 주입 + flowOn 처리
4. (추천) 실무용 + Dispatcher 주입 추가 + 파싱 최적화
ViewModel.launch { // 정의 없음 MainThread
└── repository.getArticles() // Flow 시작됨
└── flowOn(IO) // API 호출 IO에서 수행
└── .map { sort }
└── .flowOn(Default) // 정렬, 파싱은 Default에서 수행
└── .collect { _uiState.value = it } // 최종 UI 반영은 Main (기본)
}
Retrofit 에 문제점
- try/catch + null 처리 지옥
- 공통 에러 처리
- 반복적인 if (isSuccessful) 체크
// Retrofit의 기본 Call<T> → ApiResponse<T>로 자동 래핑
addCallAdapterFactory(ApiResponseCallAdapterFactory.create())
// 기본 형태
suspend fun getArticles(): Response<List<Article>>
// Sandwich가 만든 형태
suspend fun getArticles(): ApiResponse<List<Article>>
응답은 받았지만 응답코드가 200이 아닌 경우
예) HTTP 4xx, 5xx
서버 연결 실패, 응답 파싱 실패
예) 인터넷 없음 (UnknownHostException)
타임아웃 (SocketTimeoutException)
JSON 파싱 실패 (JsonParseException)
apiService.getSomething().onException {
if (exception is UnknownHostException) {
// 네트워크 끊김일 경우
} else if (exception is SocketTimeoutException) {
// 응답이 너무 늦는 경우
}
}
ApiResponse는 Sandwich 라이브러리 타입으로, data계층에 속함
domain 계층은 외부 라이브러리에 의존하면 안됨.
APIResponse 로 받은걸 별도의 Result로 만들거나 순수 객체로 넘겨줘야 하기 때문에 클린아키텍처에서는 잘 사용하지 않는다. 클린아키텍처를 사용하지 않고 빠르게 앱을 개발하기에는 좋을 수 있다.
Jetpack Compose 기반 앱에서 공통 디자인 묶음(색상, 배경, 테마)을 전역적으로 관리하고,
다크/라이트 모드 대응 및 미리보기, 테스트, 재사용성을 향상시키기 위한 디자인 시스템 모듈
앱 전반에 사용되는 색상값을 정의한 데이터 클래스입니다.
defaultLightColors(),defaultDarkColors()를 통해 다크/라이트 테마를 자동 분기
배경 색상과 elevation 정보를 묶은 테마 전용 클래스입니다. 라이트/다크 모드에 따라 배경을 자동 설정
앱 전체 또는 특정 화면에 테마를 적용하는 래퍼 함수 CompositionLocalProvider 를 사용해 색상/배경을 하위 Composable 주입 예) ArticlesTheme.colors.backgroundLight 로 Composable 어디서든 전역으로 접근 가능
val LocalBackgroundTheme = staticCompositionLocalOf { ArticleBackground() }ArticleBackground는 거의 고정 값 (예: 테마용 배경) 변경될 일이 거의 없고, 변경되더라도 UI를 리렌더링하지 않아도 되는 경우 Recomposition 최적화됨 → 성능 향상
val LocalColors = compositionLocalOf<ArticleColors> {
error("No colors provided")
}ArticleColors는 다크/라이트 등으로 바뀔 수 있는 동적 값 이 값이 변경되면 해당 값을 사용한 컴포저블들이 자동으로 Recomposition 됨 UI가 동적으로 반응해야 하는 경우 사용
// Local 정의
val LocalWindowSizeClass = staticCompositionLocalOf<WindowSizeClass> {
error("WindowSizeClass not provided")
}// CompositionLocalProvider 설정
val windowSizeClass = calculateWindowSizeClass(LocalContext.current as Activity)
CompositionLocalProvider(LocalWindowSizeClass provides windowSizeClass) {
AppContent()
}// 반응형 계산
@Composable
fun rememberResponsiveGridCells(): GridCells {
return when (LocalWindowSizeClass.current.widthSizeClass) {
WindowWidthSizeClass.Compact -> GridCells.Fixed(2)
WindowWidthSizeClass.Medium -> GridCells.Adaptive(160.dp)
WindowWidthSizeClass.Expanded -> GridCells.Adaptive(200.dp)
else -> GridCells.Fixed(2)
}
}// 사용 예시
LazyVerticalGrid(columns = rememberResponsiveGridCells()) { ... }SharedTransitionLayout
└── NavHost
├── composable<Home>
│ └── HomeScreen(animatedVisibilityScope, sharedTransitionScope)
└── composable<Detail>
└── DetailScreen(animatedVisibilityScope, sharedTransitionScope)core:navigation 에 LocalSharedTransitionScope 를 정의하고 feature가 사용. feature 모듈 간 SharedTransitionScope, AnimatedVisibilityScope 인자를 받지 않아 불필요한 의존성을 줄인다. (관심사 분리)
SharedTransitionLayout
└── CompositionLocalProvider(LocalSharedTransitionScope)
└── NavHost
├── composable<Home>
│ └── CompositionLocalProvider(LocalAnimatedVisibilityScope)
│ └── HomeScreen() // 내부에서 Local*.current 로 스코프 접근
└── composable<Detail>
└── CompositionLocalProvider(LocalAnimatedVisibilityScope)
└── DetailScreen()SpotLess 코드 포멧터 도구 Kotlin 등 다양한 언어에서 자동으로 코딩 스타일을 통일한다.
class SpotlessConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
...
}
}Spotless 설정을 공통화해서 Convention Plugin 으로 정의 모든 모듈에 중복을 적는 대신 plugin 하나만 apply 하면 된다.
전체 검사 & 수정 ./gradlew spotlessCheck ./gradlew spotlessApply
모듈 단위 검사 & 수정 ./gradlew :feature:home:spotlessCheck ./gradlew :feature:home:spotlessApply
개별로 수시 포멧 자동 적용 or Action Save As 활용 CI 에서는 spotlessCheck 적용
책임 분리 : core:datastore 는 구현 및 DI 제공의 책임, core:data 직접 바인딩해서 사용하는 책임
의존성 역전 : core:data 는 core:datastore 의 추상타입에 의존하며 실제 구현은 datastore에 위치
DefaultLikedPreferencesDataStore 에서 @ApplicationContext Hilt가 주입 가능 하지만,
Kotlin 컴파일 타임에 Context.dataStore의 확장 프로퍼티를 코드로 생성하기 때문에 생성자 내부에서 사용불가.
초기화 타이밍, 컴파일타임 안전성을 고려하여 DataStore 는 hilt를 통해 provide 로 주입하는게 좋다.
@Module
@InstallIn(SingletonComponent::class)
object DataStoreModule {
private const val LIKE_DATA_STORE_NAME = "LIKE_PREFERENCES"
private val Context.likeDataStore by preferencesDataStore(LIKE_DATA_STORE_NAME) //컴파일 타임에 생성
@Provides
@Singleton
fun provideLikeDataStore(
@ApplicationContext context: Context
) : DataStore<Preferences> = context.likeDataStore
}