우당탕탕 Kotlin 전환기

팀에서 Java로 진행한 프로젝트를 Kotlin 언어로 전환한 경험을 공유합니다!

신영재, 
김하균
2022.08.29

Kotlin 전환 목적

Kotlin이 좋다면서요? (Java 개발자의 막연한 생각)

Java 백엔드 개발 일을 하면서 주변으로부터 종종 Kotlin이 좋다는 이야기를 들었습니다. null 처리도 좋고, Coroutine을 이용한 동시성 프로그래밍도 직관적이고, 보일러플레이트 코드도 줄고, 기존 Java와 호환도 되고… ‘어쨌든 좋다(?)’는 이야기만 들었지, Kotlin을 실제 업무에 적용해보지는 않았기에 장점을 체감하지는 못했습니다.

그러던 중, 22년도에 딜리셔스 개발팀의 JVM 계열 언어는 Kotlin을 지향한다는 목표가 설정 되었고 이에 맞추어 차기 프로젝트는 Kotlin을 사용하기로 했습니다. 일단 차기 프로젝트를 진행하기 전에 Kotlin에 대한 본격적인 학습이 필요했습니다. Kotlin 학습을 하면서 언어의 특성을 조금씩 파악하게 되었는데, 실무에서 써본 것이 아니기에 깊이에 부족함이 있었습니다. 이를 해결하기 위해서, 팀 내에서는 기존 Java 프로젝트를 Kotlin으로 전환하기로 했고, 두 가지 목표를 갖게 되었습니다.

  1. 실제 프로젝트 코드에 사용하여 Kotlin에 충분히 익숙해 질 것
  2. 다른 팀원에게 전파 할 수 있도록 정리할 것

Kotlin 전환 준비과정

Kotlin, SpringBoot 공식문서 및 전환 사례 리서치

기존 프로젝트가 SpringBoot를 기반으로 되어 있었기에 이에 관련한 자료 조사가 필요했고, Kotlin으로 전환한 여러 회사의 사례를 조사하였습니다. 그 중 일부 내용은 아래와 같습니다.

  • 코틀린 표준 라이브러리 활용
    Java에서는 util 패키지로 제공하던 라이브러리들을 코틀린에서는 내장 라이브러리로 제공합니다. 내장 라이브러리들이 잘 구성되어 있어 자바의 라이브러리를 사용하지 않고 코틀린 내장 라이브러리를 사용하도록 변경해주는 것이 좋습니다.

  • 자바로 역컴파일하는 습관
    IntelliJ IDEA를 사용하는 사람들이라면, Tools > Kotlin > Show Kotlin Bytecode를 사용하면 현재 파일의 바이트코드를 확인할 수 있습니다. 여기서 Decompile 버튼을 클릭하면 자바 코드를 확인할 수 있어서 현재의 코틀린 코드가 자바 코드로는 어떻게 되어있는지 확인할 수 있습니다. 어떻게 전환되었는지 확인하면서 진행하면 에러가 발생했을 경우 쉽게 잡아낼 수 있습니다.

  • Compiler Plugin 활용

    • No-arg Compiler Plugin
      지정한 어노테이션이 있는 클래스에 한해 빌드 시점에 매개변수가 없는 생성자를 추가해주는 Compiler Plugin입니다.
      예를 들면, JPA Entity 객체 초기화를 위해 매개변수가 없는 생성자가 필요한데, 이를 해결해줄 수 있습니다.
      JPA를 사용하는 경우, No-arg Compiler Plugin을 래핑한 Kotlin-jpa Compiler Plugin이 있습니다.

    • All-open Compiler Plugin
      지정한 어노테이션이 있는 클래스와 모든 멤버에 빌드 시점에서 open 변경자를 추가해주는 Compiler Plugin입니다.
      Kotlin에서는 클래스와 프로퍼티가 기본적으로 final로 선언됩니다. 그래서 상속하거나 오버라이드 할 수 없어서 프록시도 생성이 불가능합니다. Spring AOP 구현체인 CGLIB를 활용 시, 프록시를 생성해야 하기 때문에 open 변경자를 추가해야 하는데, 플러그인으로 이를 해결할 수 있습니다.
      Spring을 사용하는 경우 All-open Compiler Plugin을 래핑한 Kotlin-spring Compiler Plugin이 있습니다.

전환 전략 세우기

이제 프로젝트의 Kotlin 전환을 어떻게 진행할 것인지 전략을 세우기로 했습니다.

우선 core 라고 볼 수 있는 Domain/Use case 영역을 1차로 전환하고, core 의 외부 영역에 속하는 UI/Infra 영역을 2차로 전환하기로 했습니다. UI/Infra 쪽의 전환 작업이 더 복잡하여 어려울 것으로 판단했기 때문이였습니다. 소스코드 병합 시 충돌을 최소화 하기 위해서 구성원에게 익숙한 패키지를 배타적으로 나누어 담당하고, 진행상태를 확인할 수 있는 장표를 만들었습니다.


또 Kotlin Migration이라는 공유문서를 생성하여 전환 시 발생하는 이슈 혹은 전환하며 얻게되는 지식들을 정리하기로 했습니다. 각자 경험한 이슈를 다른 팀원이 동일하게 경험할 수 있기 때문에 따로 리서치하는 시간을 줄이기 위함이었습니다.

Kotlin 전환

실제로 Kotlin 전환 작업을 해보니 기존에 세운 전략인 “Domain/Use case를 전환한 후 이후 UI/Infra를 전환한다”가 적용이 힘들었습니다. 순수 Domain 영역이 변경되면서 이에 의존하는 Infra 영역 변경이 수반되어야 했기 때문입니다.
그래서 전환의 과정을 일부 수정하여 특정 Domain 영역를 전환하고 해당 영역을 의존하고 있는 Service, Infra 영역을 동시에 수정하는 방식으로 진행했습니다.

전환 과정 모두를 설명드리기보다는 전환하며 발생했던 주요 이슈들에 관해서 말씀드리겠습니다.

전환 시 발생한 이슈

Kotlin nullable <-> Java Optional

Java와 Kotlin을 혼용하여 사용하는 구간에 발생한 문제가 있었습니다. 기존 Java에서 Optional로 선언되어 있던 타입을 Kotlin의 Nullable 타입으로 수정하게 되면, 아직 전환 되지 않은 Java에서 이를 사용 할 때, Optional.ofNullable()로 감싸서 사용해줘야 했습니다.

Java static 메소드와 Kotlin의 Companion object

Java에서의 static 메소드 및 변수가 Kotlin으로 전환되면서도 문제가 발생했습니다.
IntelliJ IDEA의 Convert Java File to Kotlin File 기능을 활용하여 전환작업의 초안으로 사용하였는데, 해당 기능을 사용하면 static 메소드와 변수가 companion object@JvmStatic 어노테이션을 추가한 채로 전환되었습니다. ( companion object는 Kotlin의 문법으로 Java의 static과는 조금 다르지만, static처럼 사용할 수 있습니다. Java에서 static 처럼 호출하기 위해서는 Kotlin Companion 메소드에 @JvmStatic 어노테이션이 필요합니다. )
초기에는 Kotlin 전환 코드쪽에 @JvmStatic을 붙이는 쪽으로 진행 하다가, Java에서 ClassName.Companion.methodName과 같은 형태로 호출할 수 있음을 알게 되었습니다. Kotlin으로 기전환된 케이스를 수정하기보다, 전환 예정인 Java 파일을 바꾸는 것이 유리하다고 생각해서 @JvmStatic 은 모두 제거하였습니다.

Kotlin은 일단 final

앞서 언급한 Compiler Plugin에서도 나왔던 이야기로, Kotlin 코드는 기본적으로 final임을 주의해야 합니다. 테스트를 진행할 때 Java 라이브러리로는 Kotlin 코드의 Mocking이 불가능합니다. 이를 가능하게 해주는 springmockk 혹은 mockito-kotlin 라이브러리를 사용하는 것을 추천드립니다.

Kotlin Primary Constructor와 Jackson

클래스를 작성 시, 주로 Primary Constructor 내에서 Property 를 선언하는 방법을 택했습니다. 이러한 방식은 Jackson JSON 을 오브젝트로 변환 할 때, no creators like default constructor exist 라는 오류를 발생시키게 하는데, 이를 해결하기 위해서 jackson-module-kotlin 모듈을 사용했습니다.

Kotlin 인터페이스 구현 메소드 vs Java 인터페이스 default 메소드

Kotlin에서 DataRepository 인터페이스를 상속한 인터페이스 내부에서, default메소드 역할을 기대하고 작성한 메소드에서 발생한 문제가 있습니다. Java의 인터페이스 default메소드와 Kotlin의 인터페이스에서 구현한 메소드의 Bytecode가 동일하지 않음에 유의해야 합니다. 기본적으로 Kotlin의 인터페이스에서 메소드를 구현한 경우, 컴파일러는 내부적으로 DefaultImpls 라는 클래스를 만들고 메소드를 구현하는 방식이 됩니다. Java 인터페이스의 default메소드와 동일하게 사용하려면 컴파일 옵션으로 -Xjvm-default를 사용해야 합니다. 이번 프로젝트에서는 호환성을 위하여 컴파일 옵션을 설정하는 것은 지양 하였기에, 조금 돌아가는 방식으로 CrudRepository 인터페이스를 상속한 인터페이스를 스프링 컴포넌트로 선언하고, class 에 주입하여 구현하는 방식으로 해결했습니다.

Kotlin 입구와 출구를 단속하자

전환하면서 신경을 가장 많이 쓴 부분은, 기존 Java 코드에서 명시되어 있지 않은 Nullability 를 전부 체크하는 일이었습니다. API request 영역과 서버에서 호출하는 외부 API response 의 Nullability 를 전부 확인해야만 프로퍼티를 Nullable 로 처리할지, Not Null 하게 할지, 혹은 기타 다른 처리를 할지 결정할 수 있습니다. 이러한 점을 고려할 때, 만약 Java 프로젝트를 Kotlin 프로젝트로 전환한다면 API 진입점부터 서버가 외부와 통신하는 곳 까지 수직적 변환하는 방식을 추천드립니다. (Request -> Controller -> Service -> Domain -> Infra -> External API Response) 또한 Kotlin에서 Java 라이브러리 호출 시, Java 라이브러리에 @NotNull 혹은 @Nullable 과 같은 어노테이션이 있는 경우가 많으므로, 전환 작업에 큰 도움이 되니 꼭 확인하면 좋습니다.

그래서 전환하면서 느낀 코틀린의 좋은 점들은?

Java에서 자주 사용하던 getter() 대신 property로 접근

Java에서 자주 사용하는 getter()는 무슨 일을 할까요? 객체의 공개되어있는 프로퍼티 값을 리턴한다는 것 외에는 특별한 의미가 없습니다. 대부분의 다른 메소드는 주어진 일을 하는 것이지만, getter()는 그냥 해당 프로퍼티를 가져오는 역할만을 수행하고 있습니다. Kotlin에서는 공개된 프로퍼티에 dot(.) 으로 접근하여 실제 “속성” 으로 느껴지게 됩니다.

// Java
var address = shop.getAddress()  
// Kotlin
val address = shop.address

생성자의 기본값 지원과 명시적 매개변수

Kotlin 생성자는 기본값을 가질 수 있고, 생성자 호출 시 파라미터가 생성자의 어떠한 값에 붙는지 명시할 수 있습니다. 이 두가지 기능을 통해서 기존 프로젝트 자바 코드에서 여러개의 생성자를 갖는 부분을 많이 줄일 수 있었습니다. (기존 프로젝트의 경우, 객체 생성을 거의 대부분 점층적 생성자 패턴 방식을 따랐기에, 전환하며 얻은 이점이 크다고 볼 수 있겠습니다.)
또한 생성자의 기본값을 사용하여 기존 Java 코드에서는 null이 될 수 있는 부분을 방어하기에 편했습니다.

고차함수 표기는 람다로!

java.util.function에서 정의된 함수형 인터페이스 Function, Consumer, Supplier, Predicate ...를 람다식으로 대체함으로써 좀 더 직관적으로 느껴졌습니다. 아래 코드는 공통으로 사용하는 JPA Entity 저장 함수입니다. 고차함수 표기가 람다 표현식으로 간결해졌고, 생성자 기본값을 이용해서 두개의 함수를 하나로 만들 수 있었습니다.

// Java

default T save(T domainEntity, Function<T, U> toJpaEntity) {
    return save(
        domainEntity,
        toJpaEntity,
        JpaEntity::toDomainEntity
    );
}

default T save(T domainEntity, Function<T, U> toJpaEntity, Function<U, T> toDomainEntity) {
    if (domainEntity.id() == null) {
        var jpaEntity = toJpaEntity.apply(domainEntity);
        entityManager().persist(jpaEntity);
        return toDomainEntity.apply(jpaEntity);
    }

    var jpaEntity = findEntityById(domainEntity.id())
            .orElseThrow(EntityNotFoundException::new);
    jpaEntity.update(domainEntity);
    return toDomainEntity.apply(jpaEntity);
}
// Kotlin

fun save(
    domainEntity: T,
    toJpaEntity: (T) -> U,
    toDomainEntity: (U) -> T = { it.toDomainEntity() },
): T {
    if (domainEntity.id == 0L) {
        val jpaEntity: U = toJpaEntity(domainEntity)
        entityManager().persist(jpaEntity)

        return toDomainEntity(jpaEntity)
    }
    val jpaEntity = findEntityById(domainEntity.id) ?: throw EntityNotFoundException()
    jpaEntity.update(domainEntity)

    return toDomainEntity(jpaEntity)
}

확장함수를 통한 가독성 향상, IDE 의 지원

코틀린으로의 전환이 마무리 되어갈 때, 반복적으로 나타나는 코드들이 있었습니다. ZonedDateTimelong 타입인 EpochMilli로 나타내거나, 날짜와 시간 정보 StringZoneDateTime으로 전환하는 것이었습니다. 이를 편하게 쓰기 위해 아래와 같은 확장함수를 정의하였습니다.

fun ZonedDateTime?.toMillis() = this?.toInstant()?.toEpochMilli() ?: 0
fun String.toZonedDateTime(): ZonedDateTime = ZonedDateTime.parse(this)
createdAt.toInstant().toEpochMilli() // 확장함수 미적용
createdAt.toMillis()                 // 확장함수 적용

ZonedDateTime.parse(createdAtFrom)   // 확장함수 미적용
createdAtFrom.toZonedDateTime()      // 확장함수 적용

확장함수를 정의함으로써 얻을 수 있는 이점은, 먼저 dot notation(.) 을 통해서 함수를 호출하는 형태로 가독성이 향상되고, IDE 에서 해당 타입에 .을 찍었을 때, 확장함수를 마치 해당 타입에서 지원하는 함수인 것 처럼 지원해주어 개발이 편해진다는 점입니다.

겉으로 쉽게 드러나는 불변성

  • val vs var
    val로 먼저 선언하여 변경이 불가능 하도록 하고, 추후 판단하여 필요할 때 var로 변경하여 변경이 가능하도록 수정할 수 있습니다. 이번 프로젝트에서는 객체 선언 시 property를 대부분 val로 하였고, data classcopy()와 같은 역할의 함수를 직접 구현하여 객체의 불변성을 유지할 수 있도록 했습니다.

  • List vs MutableList
    아무 접두어가 없는 List는 읽기 전용이고, 읽기/쓰기가 가능한 List 자료구조는 앞에 접두어로 Mutable이 붙습니다. 엄청 자랑하고 있습니다, 나는 가변이라고!

이렇게 불변성을 명시적으로 표기하는 Kotlin 설계는 개발자로 하여금 더 쉽고 직관적으로 불변성을 고려하며 개발할 수 있게 도와준다고 생각합니다.

겉으로 쉽게 드러나는 Nullability

// Java

// Nullable한 객체
String str = null;
if (str == null) {
  System.out.println("str is null");
}

// Optional 활용
Optional<String> str = null;
System.out.println(str.orElse("str is null"));

위처럼 기존 Java에서는 Nullable한 객체를 사용하거나, Optional을 사용하여 Null을 관리했습니다.

하지만 Kotlin에서는 Nullable한 객체를 사용하기 위해서는 ?를 활용해야 합니다.

// Kotlin

// NotNull
val str: String = "str"
println(str)
str.split("")

// Nullable
val str: String? = null
println(str ?: "str is null")
str?.split("")

Kotlin에서는 ?을 활용해 Nullable을 명시하기에 변수의 타입을 보고 Nullable을 바로 판별할 수 있으며, IDE와 컴파일러 또한 해당 내용을 바탕으로 개발자의 코드 작성에 도움을 줍니다. 이러한 명시성은 클래스 내의 프로퍼티를 선언하거나 함수의 리턴 타입을 작성하는 그 순간부터 ?를 붙일지 말지 고민하도록 강제하며 이 과정에서 Nullability를 한번 더 고려하도록 합니다. 결과적으로는 NPE로부터 멀어질 수 있습니다. 또한 Null이 아닐 경우 다음 메소드 체이닝을 실행하는 ?.와 Null일 경우 다음의 로직을 수행하도록 지정하는 elvis 연산자 ?: 등 Nullable 객체에 대한 핸들링이 사용하기 편하게 되어 있습니다.

스코프 함수를 통한 코드 가독성 향상

자주 사용하는 let 이외에도 apply, with, also, run과 같은 스코프 함수가 있습니다. 처음에는 'let 으로도 다 되는거 아닌가?' 라는 생각을 가졌었지만, 코틀린에 익숙해지면서 각 스코프 함수마다 정의에 익숙해지고 권장하는 가이드대로 사용하면 코드의 가독성도 향상시킬 수 있었습니다. 다만, 스코프 함수를 여러번 체이닝 하면 수신객체가 무엇인지 혼동이 생길 수 있으므로 조심해야 합니다. 과하면 독이 됩니다!
Kotlin에서 권장하는 가이드는 Function selection에서 확인할 수 있습니다.

쓰다보니 빠져드는 kotlin-stdlib

앞서 타사 전환 사례 리서치에서 코틀린 내장 라이브러리를 사용해야 한다고 했었는데, 실제로 사용하면서 많은 기능이 이미 구현되어 있다는 것을 느꼈습니다.
예를 들자면 기존 Java 코드에서 컬렉션의 특정 값을 기준으로 하는 distinct filter가 필요해서 개발자의 큰형님 stackoverflow에서 찾은 코드를 활용하고 있었는데, kotlin-stdlib에 해당 기능이 이미 있었습니다! 덕분에 조금 더 쉽게 코드를 풀어갈 수 있었습니다. 이 뿐만이 아니라 list를 map으로 바꿀 때 유용하게 쓰는 associateBy, associateWith도 있었고, collection 변환 시 collection의 element가 null인 경우는 제거하는 mapNotNull도 직관적이고 편리했습니다.

전환 작업 초기에는 kotlin-stdlib의 강력함을 잘 모르다가, 익숙해지면서 이전에 작업했던 내용을 바꾸곤 했습니다.
원하는 작업이 있다면 한번 검색해보세요. 이미 구현되어 쉽게 쓸 수 있는 함수가 있을 확률이 높습니다!

// stackoverflow 에서 가져온 Java 코드

<T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) {
    var seen = new ConcurrentHashMap<>();
    return t -> seen.putIfAbsent(keyExtractor.apply(t), Boolean.TRUE) == null;
}

categories.stream()
    .filter(distinctByKey(category ->
        Arrays.asList(
            category.categoryGender().id(),
            category.categoryItem().id()
        )
      )
    )
// Kotlin

categories.distinctBy {
            listOf(it.categoryGender.id, it.categoryItem.id)
        }

가주어 it?

영어에서 가주어인 it이 코틀린에도 있습니다. 람다 표현식에서 파라미터가 1개만 있을 때 적용할 수 있는 키워드로, 간단한 람다 표현식을 작성 할 때 사용하면 가독성을 높일 수 있습니다. it 을 쓰는 것이 오히려 가독성을 해치는 경우도 있으므로 (체이닝이 2번 이상 이어질 때?), 써보시면서 적응해보면 좋습니다.

코드라인 수 다이어트와 예뻐진 코드

Java에서 필요했던 Boilerplate 성격의 코드들이 사라지면서, 전체적으로 코드라인 수가 줄어들었고, 코드의 가독성도 높일 수 있었습니다.

결과

빠밤빠밤!

kotlin

프로젝트의 언어가 Kotlin으로 성공적으로 변경되었습니다.

기능과 테스트코드가 모두 동작하는 것도 확인했고, 배포 이후 서비스도 정상적으로 운영되는 것을 확인했습니다. 🎉

느낀 점

Java에서 Kotlin으로 전환이 성공적으로 마무리되어 기쁜 마음을 가지고 팀 내에서 회고를 진행했습니다.

Kotlin에서 JPA를 사용할 때 기본값을 부여해주기 위해서 Database에서도 null값이던 Column들을 기본값("")을 넣어주고 NOT NULL로 Column 설정을 수정했었습니다. 회고 중 "Converter를 사용하면 Database값은 변경이 필요없지 않았을까요?" 라는 말씀을 듣고 아차 싶었습니다. 미처 생각치못한 방법이라 다음엔 Converter를 활용해볼 것 같습니다.

또한 코드리뷰 시 어느 부분이 어떻게 변경되었는지 확인하는 방법이 불편했습니다. git 상에서 Java 파일이 삭제되고 Kotlin 파일이 추가된 것으로 인식하기에, 어떤 파일에서 변환된 파일인지를 직접 찾아봐야 했습니다. 동일한 리뷰 페이지의 창을 두 개 띄워서 왼쪽은 삭제된 기존 Java 파일을 Load Diff를 클릭해서 띄워놓고 오른쪽은 새로운 Kotlin 파일을 띄워놓은채로 비교하면서 진행했습니다. 또한 전체 파일이 바뀌는 것이다 보니 변경된 코드가 많아서 코드리뷰에 시간이 오래 걸렸습니다.

Java 프로젝트를 Kotlin 프로젝트로 전환했지만, 내가 적은 코드가 충분히 Kotlin스러운가 라는 질문에 대한 답은 "아직 진행중이다" 입니다. Kotlin에서 지원하는 특성을 잘 활용한 부분도 있고, 아직 더 고민해야 할 부분도 있습니다. 사소한 것이지만 예를 들면 하나의 .kt 파일에 여러 개의 클래스를 선언해서 쓸 수 있는데, 현재는 Java처럼 하나의 파일에 하나의 클래스만 선언되어 있습니다. 무엇이 더 보기 좋을지는 아직 고민 하고 있습니다.
그래도 이번 전환 프로젝트 덕분에 Kotlin을 기본적으로 어떻게 사용하는지 익숙해져 “이제 Kotlin이랑 좀 친해졌는데?” 라고 말 할 수 있겠습니다. 이제 막 첫 걸음마를 뗀 수준이지만, 앞으로도 Kotlin과 친하게 지내며 발전하는 팀이 되고자 합니다.

신영재

딜리셔스 신사업 서버 개발자

"재미나게 개발하자"

김하균

딜리셔스 신사업 서버 개발자

"PUT /api/knowledge/me?with=Dealicious"