Android 개발/Kotlin

직렬화 라이브러리 3개를 kotlinx.serialization 한 곳으로 정리한 후기

stackD 2026. 7. 14. 18:00
반응형

 

kotlinx.serialization으로 갈아탄 진짜 이유는 성능이 아니었어요. 벤치마크에 따라 Moshi가 비슷하거나 약간 앞서는 경우도 있다고 합니다. 그럼에도 정리한 데에는 다른 이유가 있었습니다.

 

작년 가을 즈음이었어요. 안드로이드 앱은 Gson, 백엔드 모듈은 Moshi, 결제 응답 일부는 수기로 파싱하는 코드가 한 프로젝트 안에 같이 굴러가고 있었습니다. 같은 DTO를 세 번 정의하고 있다는 사실을 깨달은 순간, 더는 못 미루겠더라고요.

 

kotlinx.serialization을 택한 첫 번째 이유, KMP 공유 모듈

결정타는 KMP 공유 모듈을 만들기 시작하면서였습니다. commonMain에서 네트워크 응답 DTO를 같이 쓰려고 했는데, Gson과 Moshi 둘 다 JVM에 종속된 라이브러리거든요. 그 자리에서 컴파일이 막혔습니다.

 

kotlin("plugin.serialization") 플러그인 하나로 JVM, JS, Native가 같은 DTO를 쓸 수 있게 된다는 점이 진짜 컸어요. 컴파일 시점에 직렬화 코드를 만들어주는 구조라 kotlin-reflect 의존성도 같이 빠집니다. kotlin-reflect 의존성 약 3MiB가 그대로 빠진다고 보시면 돼요. 작은 이미지 라이브러리 하나를 통째로 들어내는 느낌입니다.

 

 

Gson non-null 필드 NPE 함정과 타입 안전성

KMP가 첫 번째 이유였다면, 두 번째 이유는 안정성이었습니다. Gson을 쓰면 코틀린의 non-null 프로퍼티에 서버가 보낸 null이 그대로 박혀들어가는 일이 생기는 편이에요. 컴파일러는 못 잡고, 한참 뒤에 그 필드를 건드린 화면에서 NPE가 터지면 추적이 굉장히 까다롭더라고요.

 

kotlinx.serialization은 이걸 컴파일 단계에서 막아줍니다. nullable이 아닌 필드에 null이 들어오면 디코딩 시점에 바로 예외를 던지기 때문에, 원인이 어디서 들어왔는지 한 번에 잡힙니다. 같은 NPE를 며칠 동안 쫓아본 분이라면 이 차이 하나만으로도 충분히 갈아탈 만한 가치가 있다고 봐요.

 

Retrofit kotlinx.serialization 컨버터 등록 순서

Retrofit과 붙일 때 한 가지만 기억하면 됩니다. Gson이나 Moshi 컨버터와 병행한다면 kotlinx 컨버터를 가장 마지막에 등록해 다른 컨버터가 먼저 시도하게 해야 합니다. kotlinx 컨버터는 모든 타입을 받아들이는 성격이라 앞에 두면 잔존 레거시 DTO까지 가로채버리거든요. JakeWharton의 공식 README도 fallback 위치에 두라고 안내하고 있어요.

 

val json = Json {
    ignoreUnknownKeys = true   // 서버가 새 필드를 추가해도 디코딩 실패 막아줌
    encodeDefaults = true      // 기본값 프로퍼티도 JSON으로 내보냄
    coerceInputValues = true   // default 값이 있는 경우, 모르는 enum/null을 기본값으로 떨어뜨림
}

val retrofit = Retrofit.Builder()
    .baseUrl("https://example.com/")
    // 잔존 레거시 타입은 Gson이 먼저 시도
    .addConverterFactory(GsonConverterFactory.create())
    // kotlinx는 마지막 (모든 타입을 받아들이므로 fallback 위치)
    .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
    .build()

 

옵션 중 coerceInputValues = truedefault 값이 있는 경우, 앱이 모르는 enum 값이나 null이 들어와도 기본값으로 떨어뜨려주는 안전장치예요. 서버가 enum을 늘렸을 때 앱이 죽지 않게 막아줍니다. DTO에는 @Serializable을 붙이고, @SerializedName@SerialName으로 바꿔주면 됩니다. 필드 수십 개를 일일이 다는 게 부담이라면 JsonNamingStrategy.SnakeCase를 전역으로 켜는 방법도 있어요.

 

 

sealed class 다형성과 SerializersModule 등록

다형성 처리가 정리 단계에서 가장 신경 쓰였던 영역입니다. sealed class는 자동으로 type 필드를 넣어주기 때문에 신경 쓸 게 거의 없어요. 문제는 open이나 abstract 클래스를 부모로 쓰는 경우입니다. 이건 SerializersModule에 손으로 직접 등록해줘야 런타임 예외를 피할 수 있습니다.

 

val module = SerializersModule {
    polymorphic(PaymentEvent::class) {
        subclass(CardPayment::class)
        subclass(BankPayment::class)
    }
}

val json = Json {
    serializersModule = module
    classDiscriminator = "kind"
}

 

LocalDateTime 같은 기본 미지원 타입도 똑같이 KSerializer를 직접 정의하고 @Serializable(with = ...)로 연결해주면 됩니다. 기존 Gson TypeAdapter를 그대로 옮기지 마시고, Encoder/Decoder API로 새로 짜는 쪽이 코드 라인도 줄고 디버깅도 훨씬 편했어요.

 

kotlinx.serialization 마이그레이션 4단계 전략

한 번에 다 바꾸려고 하면 백 퍼센트 사고가 납니다. 저희 팀이 안전하게 정리했던 순서는 이렇습니다.

 

  1. 신규 모듈은 처음부터 kotlinx.serialization으로만 시작합니다.
  2. 기존 모듈은 Retrofit에 컨버터를 병행으로 붙여두고 새 엔드포인트부터 kotlinx로 받습니다.
  3. DTO 단위로 점진 전환하면서 nullable·enum·sealed 다형성·커스텀 시리얼라이저는 단위 테스트로 회귀 검증합니다.
  4. 한 모듈 전환이 끝나면 그 모듈에서만 Gson/Moshi 의존성을 제거합니다.

 

Kotlin 컴파일러 버전과 kotlinx.serialization 플러그인 버전은 1:1로 맞물려 있는 사이라서, libs.versions.toml에 묶어 관리하지 않으면 업그레이드 시 빌드가 깨지는 일이 많거든요. 참고로 저는 컴파일러 업그레이드와 직렬화 플러그인 업그레이드를 같은 PR로 묶어 올리고 있어요. 둘이 어긋나면 원인 찾는 데 한나절은 그냥 갑니다.

 

성능 벤치마크만 봤다면 Moshi에 남았을 겁니다. 그래도 KMP 공유, non-null NPE 차단, 세 갈래로 흩어진 의존성 통일까지 한 번에 잡아주는 라이브러리는 지금 시점에서 kotlinx.serialization 하나라는 게 제가 내린 결론입니다.

 

 

반응형
LIST