기존 스터디 프로젝트에서는 JSON 파싱은 Moshi, Navigation은 문자열 라우트 + sealed class 조합으로 구성하고 있었습니다. 동작 자체에는 문제가 없었지만, 새 화면을 추가할 때마다 "detail/{id}" 같은 문자열 라우트와 navArgument 보일러플레이트를 반복적으로 작성해야 한다는 단점이 있었다.
현재 회사 프로젝트에서는 이미 Serialization 을 사용하고 있는데, 실무에서 써보니 Navigation Compose 2.8+ 의 Type-Safe Navigation까지 같은 라이브러리로 자연스럽게 통합되는 점이 인상적이었다. 그래서 이번 기회에 스터디 프로젝트에도 동일하게 반영해 JSON 파싱과 Navigation을 한 라이브러리로 통일하는 마이그레이션을 진행하였다.
이 글에서는 다음 두 가지를 정리
- 왜 요즘은 Kotlinx Serialization를 많이 쓰는지 (Gson, Moshi와 비교)
- 실제 프로젝트에서 Moshi → Kotlinx Serialization + 문자열 라우트 → Type-Safe Navigation 으로 마이그레이션한 과정
Android에서 JSON 파싱 라이브러리는 크게 세 가지가 쓰여왔는데 각자의 특징을 먼저 짚고 가면 왜 요즘 Serialization 을 쓰는지 자연스럽게 이해가 된다.
Google이 만든 라이브러리로, Android 초창기부터 표준처럼 쓰였다.
data class User(val id: Int, val name: String)
val gson = Gson()
val user = gson.fromJson(json, User::class.java)
한계점
val name: String(non-null)인데 JSON에 name이 없으면 그냥 null을 넣어버림val age: Int = 20으로 선언해도 JSON에 없으면 0이 들어감init 블록 검증 로직이 안 돌아감Square(Retrofit 만든 곳)에서 만든 라이브러리. Gson의 Kotlin 관련 단점을 보완하려고 등장
@JsonClass(generateAdapter = true)
data class User(val id: Int, val name: String)
개선점
남은 한계점
KotlinJsonAdapterFactory를 쓰면 Gson과 마찬가지로 kotlin-reflect의존. 리플렉션을 피하려면 @JsonClass(generateAdapter = true) + KSP 코드 생성 모드를 별도로 설정해야 함moshi-core, moshi-kotlin, moshi-kotlin-codegen)JetBrains가 직접 만든 Kotlin 공식 라이브러리
어노테이션 처리기가 아닌 Kotlin 컴파일러 플러그인으로 동작
@Serializable
data class User(val id: Int, val name: String)
핵심 차별점
| 항목 | Gson | Moshi | Kotlinx Serialization |
|---|---|---|---|
| 출시 | 2008 | 2016 | 2020 |
| 만든 곳 | Square | JetBrains (Kotlin 공식) | |
| 동작 방식 | 리플렉션 | 리플렉션 또는 KSP 코드 생성 | 컴파일러 플러그인 |
| Kotlin null safety | ❌ | ✅ | ✅ |
| Kotlin 기본값 | ❌ | ✅ | ✅ |
| sealed class 지원 | ❌ | △ (어댑터 직접 작성) | ✅ (네이티브) |
| Multiplatform | ❌ (JVM 전용) | ❌ (JVM 전용) | ✅ |
| R8 / ProGuard 친화 | ❌ (규칙 필요) | △ (codegen 모드만 친화적) | ✅ |
| Navigation Compose 연동 | ❌ | ❌ | ✅ |
정리하면 세 가지 이유로 압축
NetworkModule.kt)@Provides
@Singleton
fun provideMoshi(): Moshi =
Moshi.Builder()
.add(KotlinJsonAdapterFactory())
.build()
@Provides
@Singleton
fun provideRetrofit(moshi: Moshi, okHttpClient: OkHttpClient): Retrofit =
Retrofit.Builder()
.baseUrl("...")
.addConverterFactory(MoshiConverterFactory.create(moshi))
.client(okHttpClient)
.build()
Routes.kt)sealed class Screen(val route: String) {
data object Home : Screen("home")
data object Detail : Screen("detail/{id}") {
fun createRoute(id: Int) = "detail/$id"
const val ID_ARG = "id"
}
}
composable(
route = Screen.Detail.route,
arguments = listOf(
navArgument(Screen.Detail.ID_ARG) { type = NavType.IntType }
)
) { backStackEntry ->
val id = backStackEntry.arguments?.getInt(Screen.Detail.ID_ARG) ?: return@composable
DetailScreen(id = id, ...)
}
이 코드의 단점
"detail/{id}" 같은 문자열 라우트는 오타가 나도 컴파일러가 잡아주지 않음navArgument + NavType 보일러플레이트가 화면마다 반복arguments?.getInt(...) ?: return처럼 nullable 처리가 필요# libs.versions.toml
[versions]
kotlinx-serialization = "1.7.3"
[libraries]
retrofit-converter-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
[plugins]
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
// build.gradle.kts
plugins {
alias(libs.plugins.kotlin.serialization)
}
dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit.converter.kotlinx.serialization)
}
@Serializable 추가@Serializable
data class MessageDto(
val id: Int,
val title: String,
// ...
)
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
Retrofit.Builder()
.baseUrl("...")
.addConverterFactory(
Json { ignoreUnknownKeys = true }
.asConverterFactory("application/json; charset=UTF-8".toMediaType())
)
.client(okHttpClient)
.build()
Moshi 인스턴스 Provider가 사라져 NetworkModule이 훨씬 가벼워지고 ignoreUnknownKeys = true로 서버에서 새 필드가 추가되어도 앱이 깨지지 않게 방어적 코드 가능
@Serializable
data object Home
@Serializable
data class Detail(val id: Int)
NavHost(navController = navController, startDestination = Home) {
composable<Home> {
HomeScreen(onItemClick = { id -> navController.navigate(Detail(id)) })
}
composable<Detail> { backStackEntry ->
val detail: Detail = backStackEntry.toRoute()
DetailScreen(id = detail.id, ...)
}
}
@Serializable 클래스 자체가 라우트가 되며 navArgument, NavType, 문자열 헬퍼가 모두 사라짐
| 항목 | Before | After |
|---|---|---|
| JSON 파싱 라이브러리 | Moshi | Kotlinx Serialization |
| 컴파일러 지원 | KSP 어댑터 생성 | 컴파일러 플러그인 |
| Navigation 라우트 | 문자열 ("detail/{id}") | 타입 안전 객체 (Detail(id)) |
| 파라미터 추출 | arguments?.getInt(...) | backStackEntry.toRoute() |
Routes.kt 코드량 | 38줄 | 27줄 (-29%) |
특히 Navigation 쪽 변화가 체감상 가장 컸다. 라우트 정의 → 등록 → 파라미터 추출까지 모두 타입 안전한 객체 하나로 끝나기 때문에 새 화면을 추가할 때 신경 쓸 게 절반으로 줄어듬
JSON 파싱과 Navigation은 서로 무관해 보이지만 둘 다 "객체 ↔ 문자열" 직렬화라는 공통점이 있다. Kotlinx Serialization을 도입하면서 이 두 영역의 직렬화 방식을 한 라이브러리로 통일할 수 있었고 결과적으로 코드량이 줄고 타입 안전성이 올라갔다.
특히 Navigation Compose 2.8+의 Type-Safe Navigation은 Kotlinx Serialization을 전제로 설계되어 있어서 새 프로젝트라면 처음부터 Kotlinx Serialization으로 시작하는 게 가장 깔끔한 선택이라고 느꼈다.
실제 마이그레이션 커밋: GEUN-TAE-KIM/Mvi_Orbit_Study @ 41ca94f