본문 바로가기
Android

[Android/Kotlin] Gson -> kotlinx.serialization으로 Migration하기

by 옹구스투스 2022. 12. 15.
반응형

https://www.facebook.com/developerhumor/

 

코다 앱에선 객체 - Json 간 변환을 Gson, 그리고 이를 Retrofit2의 Converter.Factory를 통해 진행하고 있었다.

이제 Gson을 놓아주고, kotlinx.serialization으로 Migration 하려고 한다.

모든 작업엔 이유가 필요하다.

왜 kotlinx.serialization을 사용하려 하는가?

코다 앱의 안드로이드 개발은 혼자 맡아서 진행하고 있기 때문에 그동안 이런 Migration작업에 그냥 스스로 결정해서 진행했다.

실무 프로젝트에선 어떨까?

실무 프로젝트에서 Migration하는 시나리오를 상상하여 나름 과정을 정리해봤다.

1. 문제점 발견, 기술부채 확인

2. 해결 방안 생각

  a. Migration 비용

  b. Migration으로 얻는 이득

  c. 도입할 기술에 대한 본인, 팀원의 이해

기존 프로젝트의 문제점 (Gson)

Gson을 사용한 기존 Response Model이다.

현재 commentList를 제외하곤 모두 non null 타입이다.

그런데 만약 서버에서 content 값을 null로 내려준다면?

non null 변수인 content에 null값이 들어가게 되어 예기치 못한 런타임 오류가 발생할 수 있다.

이번엔 서버에서 내려주는 Json에는 포함되지 않지만, 로컬에서 사용할 tempYn이란 변수를 추가하여 Default Value로 'Y'를 주는 경우를 생각해보자.

이 경우는 tempYn의 Default Value가 무시되고 null값이 들어가게 된다.

이게 정상적으로 동작하게 하려면 DiaryResponse의 모든 변수에 Default Value를 정의해주어야 한다...

 

해결 방안 (kotlinx.serialization)

코다 앱은 100% Kotlin 코드로 개발했다.

점점 Kotlin 기반의 라이브러리들이 늘어나고 있는 시점에서 Kotlin으로 개발한 프로젝트에 Java 기반의 Gson을 사용하는 것은 장차 기술 부채가 될 수 있다.

위의 문제점들을 해결할 수 있는 Kotlin 기반의 라이브러리가 있을까?

kotlinx.serialization을 사용한다면 어떨까

첫 번째 사례와 같이 서버에서 content에 null값을 내려준다고 하자.

serialization을 사용하는 경우 Json -> Object로 역직렬화 하는 과정에서 에러가 발생한다.

그럼 이 경우는 어떻게 처리할 수 있을까?

content에 Default Value를 설정하면 된다.

Serialization을 이용한 직렬화/역직렬화 과정엔 kotlinx.serialization.json 객체를 사용하는데, 해당 객체의 coerceInputValues 설정을 true로 바꾸어주면 null을 허용하지 않는 프로퍼티이지만 Json에선 Null인 경우 프로퍼티에 설정한 Default Value로 할당 가능하다.

두 번째 사례와 같이 Json에는 존재하지 않는 tempYn, 그리고 이 변수에 Default Value가 설정되어있다고 할 때 

Serialization은 Default Value가 정상적으로 할당된다.

 

문제를 해결할 방안도 마련했고 kotlinx.serialization이 Kotlin기반이라는 점도 맘에 든다.

이제 Migration 작업을 제안할 차례이다.

우선 Migration 비용을 생각해 보자.

- kotlinx.serialization Dependency 추가

- Gson -> kotlinx.serialization 직렬화/역직렬화 로직 변경

- Gson Dependency 제거

kotlinx.serialization Dependency

코다 앱은 buildSrc, kotlin dsl을 이용해 Dependency를 관리한다.

코다 앱에선 Data 레이어에서만 사용하기 때문에 위와 같이 buildSrc에 선언해둔 Dependency를 Data 모듈, DI 모듈에 추가하면 된다.

기존에 사용하던 GsonConverterFactory등을 Serialization Converter Factory로 바꾸어준다.

코다 앱에선 Response Model을 BaseResponse로 Wrapping했다.

따라서 BaseResponse를 포함한 모든 Response를 kotlinx.serialization으로 바꾸어준다.

json의 key값과 동일한 변수명을 사용하기 때문에 클래스 상단에 @Serializable만 붙여주면 된다.

proguard rule을 이용해 release 빌드 타입에서의 프로퍼티 난독화를 제어했다.

@SerialName 어노테이션으로 변수명을 다르게 할 수 있다.

기존에 Error Message를 파싱하는 것은 따로 Gson 라이브러리를 사용하지 않고 org.json을 사용했다.

Gson은 retrofit2:converter-gson만 사용했다.

이 부분도 이제 kotlinx.serialization을 사용한다.

이제 Gson Dependency를 제거하는 것으로 마무리!

정리하자면 코다 앱에선 다음과 같은 작업 사항들이 있었다.

  • Dependency 추가 및 제거
  • Converter.Factory 변경 (DI 모듈)
  • 20개 정도의 Model 변경  (Data 모듈)
  • Converter.Factory 외에 직접 직렬화를 사용하는 부분(한 곳만 존재) 변경 (Data 모듈)

그럼 이 Migration으로 얻는 이득이 무엇이 있을까?

1. 기존 Gson, Scalars, NullOnEmpty(Custom) Converter -> Serialization으로 대체함으로써 코드 감소

2. 멀티플랫폼을 지원한다. (JVM, JavaScript, Kotlin/Native 등 다양한 플랫폼 지원, 현재 코다 앱에선 크게 장점이라고 느껴지지 않음)

3. 인코딩, 디코딩 자체 속도가 우수하다.

4. 기존 not null한 변수에 null이 들어갈 수 있고, Default value를 무시하는 문제 해결

성능 변화

코다 앱에서 Gson과 Serialization을 비교했을 때 Serialization이 느리면 느렸지 Gson보다 빠르지 않았다.

??? 

1.2버전에 비해 2배가량의 퍼포먼스를 보이는 1.5버전 이상의 Serialization이 Reflection 기반 직렬화인 Gson과 비교했을 때 큰 차이가 없다. 왜??

위 테스트에는 함정이 있다.

단순 인코딩/디코딩의 결과가 아니라 해당 직렬화 라이브러리를 이용한 네트워킹의 결과라는 것이 이유다.

 

kotlinx serialization에서 Serializer를 지정하는 방식에는 암시적, 명시적 지정 방식이 있다.

//Explicit
Json.decodeFromString(ListSerializer(ErrorResponse.serializer()),data)

//Implicit
Json.decodeFromString<List<ErrorResponse>>(data)

후자(암시적)방식이 간결하지만 치명적인 단점이 있다.

- Serializer를 검색하는 데에 Reflection을 사용해야 한다.

- Serializer 검색은 느리고 종종 직렬화 자체보다 느리다.

val data = Array<String>(10000){
    Json 형식 데이터
}
val t1 = measureTimeMillis {
    json.decodeFromString<List<ErrorResponse>>(data)
}

val t2 = measureTimeMillis {
    json.decodeFromString(ListSerializer(ErrorResponse.serializer()),data)
}
Timber.e("finish in ${t1}ms")
Timber.e("finish in ${t2}ms")

실제 테스트해보았을 때 암시적 Serializer 지정 방식이 명시적 지정 방식보다 2배 가량 느리다.

    @Provides
    @Singleton
    @OptIn(ExperimentalSerializationApi::class)
    fun provideConverterFactory(): Converter.Factory =
        json.asConverterFactory("application/json".toMediaType())

Retrofit2의 Converter.Factory를 사용함으로써 우리는 각 Response를 파싱할 Model의 직렬화 로직에 Serializer를 명시하는 것을 Retrofit2에 위임하여 편리함을 누리지만 Serializer를 런타임에 지정하는 것이 불가피하다.

따라서 코다 앱에서 아래와 같이 직접 ResponseBody를 파싱하는 곳을 제외하고는 성능의 개선이 있다고 볼 순 없을 것 같다.

private fun getErrorMessage(responseBody: ResponseBody?): String {
    return try {
        val json = Json {
            isLenient = true
            ignoreUnknownKeys = true
            coerceInputValues = true
        }
        json.decodeFromString(ErrorResponse.serializer(), responseBody!!.string()).message
    } catch (e: Exception) {
        Timber.e("$e")
        "Something wrong happened"
    }
}

나뿐만 아니라 다른 사람들도 비슷한 테스트를 해보았고 암시적 지정 방식에선 Serailization이 Gson보다 2배 정도 느렸다.

Kotlin팀은 위의 문제를 인지하고 있고, 이를 플러그인을 이용해 컴파일 타임에 serializer가 지정되도록 하는 솔루션을 제안했고, 아직 출시되지 않은 Kotlin 1.8.0에 구현해두었다고 한다.

Kotlin 1.8.0이 나오면 Jake Warton님도 retrofit-kotlinx-converter-serialization 라이브러리 업데이트해주시려나..

1.8.0 나오자마자 커스텀해서 만들어봐야지.

 

위의 내용은 아래 이슈에서 확인할 수 있다.

https://github.com/Kotlin/kotlinx.serialization/issues/1618

https://github.com/Kotlin/kotlinx.serialization/issues/1348

 

다음 글은 이번 글에서 다루지 못한 kotlinx.serialization에 대한 정리(도입할 기술에 대한 본인, 팀원의 이해)

그리고 이번 글의 컨셉은 실무에서 Migration하는 과정에 대한 연습이므로 Migration 제안서를 1page 분량으로 만들어볼 것이다. 

느낀 점

테스트 코드

Migration 이후 모든 API, ErrorBody에 대해 일일이 Serialization이 정상 동작하는지 확인해야 했다.

더 큰 규모의 프로젝트라면 상당히 피곤한 작업일 테고, 실제 몇몇 프로퍼티에 Default Value를 주지 않아서 에러가 발생했었다.

네트워킹에 테스트 코드를 짜 놨으면 어땠을까?

테스트 코드를 돌려서 쉽게 문제가 될 부분들을 찾을 수 있었을 것이다.

이참에 앱에 테스트 코드를 작성해볼까 해서 Networking에 대한 Unit Test를 작성해보려 했으나 무작정 해보긴 어려웠다.

좀 더 레벨업하여 테스트 코드에 대해 본격적으로 공부한 다음 도전해 보자.

 

기술 부채
"당신이 집중할 것은 일어날 수도 있는 문제들이 아니라 현재 눈앞에 놓인 문제입니다."

처음 코다 앱을 개발할 당시에는 2개월 만에 좋은 퀄리티로 앱을 개발하기엔 경험이 부족했다.

그렇다고 프로젝트에 필요한 기술들을 하나하나 깊이 파며 공부하는 데에만 시간을 쏟았다면 앱을 런칭할 수도 없었을 것이다.

그냥 실행하였기에 모르는 문제에 직접 부딪혀보고, 이를 해결하는 능력을 기를 수 있었고, 나아가 이 기술 부채 덩어리의 앱을 리팩토링하며 정말 깊게 학습하고 성장하고 있다.

실패하고 해결하면서 성장하자.

반응형

댓글