Java야… 우리 그만 헤어져. Kotlin으로 환승연애

7 hours ago 1

배민페이플랫폼팀은 2025년 상반기 Java 기반의 레거시 시스템을 Kotlin으로 전환하는 프로젝트를 진행했습니다. 이번 전환은 단순한 언어 변경이 아니라, 테스트 기반 강화, 기술 부채 해소, 팀 내 개발 언어 통일 등 다양한 목표를 함께 담은 변화였습니다. 이 글은 그 과정에서 마주한 기술적 이슈와 해결 전략, 그리고 실제로 얻은 정량적·정성적 성과들을 담고 있습니다. 유사한 상황에서 Kotlin 전환을 고민하고 계신 분들께 참고가 되기를 바랍니다.


“이제는 Kotlin으로 옮길 때가 된 것 같다.”

2025년 1월, 잠실의 한 회의실에 모인 여섯 명의 개발자 머릿속에 공통으로 떠오른 문장이었습니다.

팀 내에는 2018년부터 Java 기반으로 운영해온 포인트 시스템이 있습니다. 오랜 시간 안정적으로 운영해 왔지만, 시간이 지날수록 코드베이스는 점차 무거워졌고, 개발자의 의도를 파악하기 어려운 코드들이 늘어나기 시작했습니다. 반복되는 유지보수 문제 역시 더 이상 외면할 수 없게 되었습니다.

게다가 팀 내 다른 시스템들은 이미 Kotlin을 주 언어로 사용하고 있었기에, 시스템 간 언어 불일치로 인한 혼동과 그로 인한 추가 비용이 점점 커지고 있었습니다.
코드 스타일의 차이에서 오는 커뮤니케이션 비용, 리뷰 시간의 증가, 테스트 코드 품질의 불균형 등은 점차 개발자의 생산성과 일관성을 떨어뜨리고 있었습니다.

이러한 상황에서, 포인트 시스템의 언어를 기존 Java 코드로 유지한 채 점진적으로 개선할 것인지, 아니면 과감하게 Kotlin으로 전환할 것인지에 대한 결정을 내려야 했습니다.

그리고 첫 문장에서 눈치채셨겠지만, 시스템 전체를 Kotlin 언어로 개편하는 길을 선택했습니다. 그 이유는 다음과 같습니다.

✅ 첫 번째, Java보다 Kotlin이 우리 팀에 더 잘 맞는 언어라고 판단했습니다.

  • Kotlin의 간결한 문법
  • null 처리에서의 안정성
  • 높은 가시성 등

이런 특성은 코드를 작성하는 데 실수를 줄이고 더 명확하게 작성할 수 있다고 판단했고, 포인트 시스템처럼 오래된 레거시 시스템에서도 더 안정적이고 예측 가능한 개발 환경을 만들 수 있다고 보았습니다.

✅ 두 번째. 팀의 주력 언어가 통일됨에 따라 얻는 장점입니다.

이미 다른 프로젝트들이 Kotlin으로 운영되고 있어, 포인트 시스템 역시 Kotlin으로 통일하면 문맥 전환 비용을 줄이고 협업 효율을 높일 수 있다고 판단했습니다.
더 나아가 언어 하나에만 집중함으로써 개발자 개인의 성장 방향성과 팀 전체의 기술 역량이 일관되게 향상될 수 있을 것으로 기대했습니다.

✅ 세 번째. 참고할 수 있는 레퍼런스가 많았다는 점이었습니다.

최근 몇 년 사이 Kotlin을 도입한 팀들의 경험이 다양한 기술 블로그, 콘퍼런스, 사내 문서 등으로 축적되어 있었고, 전환 과정에서 마주칠 수 있는 이슈나 해결 방법을 사전에 예측하고 준비할 수 있었습니다.
“우리가 처음 하는 건 아니구나”라는 인식은 심리적 저항을 줄이며, 자신감을 형성하는 데도 큰 도움이 되었습니다.

이처럼 단기적으로는 전환에 따른 리스크와 비용이 발생하지만, 장기적으로는 시스템의 유지보수성과 개발자의 생산성을 높일 수 있다는 의견에 팀원 모두가 공감했고,

약 3개월간의 Kotlin 전환 프로젝트가 시작되었습니다.


먼저, 프로젝트를 성공적으로 끝마치고 얻은 정량적인 결과를 공유하려고 합니다. 단순히 언어만 바꾸는 프로젝트인데, 과연 정말 달라지는 게 있을까? 이 의문에 대해 아래 수치들은 꽤 명확한 대답을 보여줍니다.

주제 변화
Lines of Code Modified 약 45,000 lines
SonarQube Code Smells 330개 → 4개 (98.8% 감소)
테스트 코드 커버리지 61.5% → 76.7% (15.2% 증가)

약 45,000줄에 달하는 코드가 수정되었습니다. 단순한 문법 전환을 넘어, 테스트 코드 강화, DTO 구조 정비, 유틸 함수 리팩터링 등 코드 전반에 걸쳐 일관성 있는 개선 작업이 함께 이루어진 변화였습니다.
정적 분석 도구 기준으로는 Code Smell이 330건에서 4건으로 감소했고, 테스트 코드 커버리지도 61.5% → 76.7%로 개선되었습니다.

물론 언어 전환이 이 모든 문제를 자동으로 해결해 준 것은 아닙니다.
하지만 전환 과정에서 택한 전략과 테스트 개선이 함께 이루어졌기에 가능한 결과였습니다.


첫 번째 발걸음: 테스트 코드부터 전환하자

전환을 결정한 이후 가장 먼저 고민한 건 운영 중인 서비스를 어떻게 안전하게 Kotlin으로 옮길 것인가였습니다.
한 번에 모든 코드를 바꾸는 방식은 너무 위험했고, 실제 서비스에 영향을 줄 수 있었기 때문에, 가장 먼저 테스트 코드부터 Kotlin으로 전환하기로 했습니다.
테스트 코드는 실제 운영 로직에 직접 영향을 주지 않으면서도, Kotlin 문법을 실험하고 도입해보기에 가장 안전한 영역이었기 때문입니다.

또한, 중요한 서비스 로직이 테스트 코드로 충분히 커버되고 있는지도 함께 점검했습니다.
커버리지가 부족한 부분이 있다면 테스트를 보완하여, 이후 서비스 코드 전환 시에도 안전함을 확보할 수 있도록 했습니다.
이 과정은 단순한 언어 전환이 아니라, 테스트 기반을 다지고 품질을 강화하는 계기가 되기도 했습니다.

다음은 테스트 코드를 Kotlin으로 전환할 때 결정했던 주요 내용입니다.

Kotest로 테스트 스타일 통일

기존에는 JUnit 기반 테스트가 혼용되어 있었고, 스타일도 팀원마다 조금씩 달랐습니다.
테스트 스타일을 통일할 필요가 있었고 그중 가장 널리 쓰이고 팀 컨벤션과도 잘 맞는 Kotest에 주목했습니다.

왜 Kotest였을까?

  • 선언형 스타일의 테스트 구조 (FreeSpec, ShouldSpec)가 더 직관적이라고 판단했습니다.
  • shouldBe, shouldThrow 같은 표현이 테스트의 의도를 더 명확히 드러냅니다.
  • 팀에서 사용하는 given-when-then 패턴과도 자연스럽게 어울렸습니다.

Kotest 도입 이후, 테스트 코드의 가독성과 작성 경험 모두 향상되었고, 코드리뷰 시 의도 파악도 훨씬 쉬워졌습니다.

MockK 도입

Java 시절에는 다양한 목킹 프레임워크가 혼용되고 있었습니다.
이번 Kotlin 전환을 계기로, 테스트 스타일도 함께 통일할 필요가 있었습니다.

이에 선언형 DSL 기반으로 가독성과 작성 속도 모두 뛰어나며, Kotlin 환경에서 널리 사용되는 라이브러리인 MockK를 표준 목킹 도구로 채택하고, 관련 컨벤션도 함께 정비했습니다.

MockK 도입 이후, 테스트 코드 품질뿐 아니라 리뷰 시 개발자 간 이해도 향상과 디버깅 효율 개선에도 도움이 되었습니다.

두 번째 발걸음: 배포 전략

언어 전환을 결정했을 때, 단순히 개발자의 코드 전환만으로 끝나지 않는다는 점을 알고 있었습니다.
실제 전환된 서비스 코드가 정상적으로 동작하는지 QA 확인과, 운영환경에서의 안전한 배포 전략이 필수적이었습니다.
이를 위해 영향 범위를 최대한으로 줄여 작은 기능 단위로 배포하는 전략을 선택했습니다.

서비스 코드는 작은 단위로 배포해야 장애 가능성과 영향 범위를 최소화할 수 있습니다.
그래서 전환 작업은 대규모 병합이 아닌, 기능 단위 혹은 패키지 단위로 잘게 나누어진 코드 단위로 점진적으로 배포하는 방식으로 진행했습니다.

이 전략은 실제 장애 대응에도 유리했고, 변경 이력이 적기 때문에 코드 리뷰와 QA의 부담도 크게 줄일 수 있었습니다.

세 번째 발걸음: 전환 과정에서 만난 기술 이슈들

본격적인 기술 이슈들을 이야기하기에 앞서, 먼저 실제로 Java 코드를 Kotlin으로 전환할 때 어떤 절차를 거쳤는지를 소개하려고 합니다.
전반적인 틀을 Kotlin으로 변경하기 위해 IntelliJ에서 제공하는 Java to Kotlin 자동 변환 기능을 이용했습니다.
이 기능은 매우 손쉽고 강력하지만, 이후에도 꽤 많은 수작업이 필요했습니다.
아래는 개발자들이 자주 마주한 수작업 항목들입니다

  • IntelliJ의 Convert Java File to Kotlin File 기능으로 기본 틀을 빠르게 전환할 수 있습니다.

✅ Null 처리 전략과 고민

Java를 Kotlin으로 전환하면서 가장 주의 깊게 살펴봐야 했던 부분 중 하나는 바로 null 처리였습니다.
Java에서는 모든 객체가 null일 수 있다는 가정을 기본으로 하고 개발하지만, Kotlin은 nullable 타입과 non-null 타입을 명확히 구분하도록 설계되어 있습니다.

전환 과정에서 단순히 !!(non-null 단정 연산자)나 ?.(세이프 콜)만으로 코드를 처리한다면,
오히려 더 위험하거나 불명확한 코드가 생성될 수 있기에, 다음과 같은 기준에 따라 클래스의 프로퍼티 타입을 점검하며 수작업을 진행했습니다.

  • 이 값이 정말 nullable일 수밖에 없는가?
    그렇지 않다면 명시적으로 non-null 타입으로 선언하여 코드의 안정성을 높였습니다.

  • nullable로 선언해야 한다면?
    해당 값이 사용되는 모든 지점에서 null 처리 로직이 빠지지 않았는지 꼼꼼히 확인했습니다.

  • 비즈니스적으로 null이 허용되지 않는 값이라면?
    타입은 nullable로 유지하되, requireNotNull 등의 검증 로직을 추가하여 명시적으로 방어했습니다.

null check 작업은 전체 전환 과정 중에서도 가장 고되고 집중이 필요한 작업이었습니다.
작업 자체는 어렵지 않지만, 잘못된 판단 하나가 곧바로 버그로 이어질 수 있었기 때문입니다.

그래서 테스트를 가장 많이 수행한 구간이기도 했고, 실제로 전환 후 안정성을 확보하는 데 큰 역할을 했습니다.

변환전 Java 코드

  • 해당 클래스는 param1~4까지 null일 수도 있는 프로퍼티를 보유하고 있습니다.
  • final 키워드를 달 수 없는 경우(대표적으로 역직렬화) 내부에서 유효성 검증을 진행하는 등의 방식으로 npe를 방지하곤 했습니다.

최초 자동변환

  • 최초 자동변환 시에는 final 키워드가 없는 프로퍼티의 경우 위처럼 nullable한 타입으로 변경됩니다.

최종 변환

  • 위에서 소개한 순서대로 클래스의 필드를 점검해 non-null 타입이 가능하다면, 의도를 명확히 드러내도록 수정했습니다.

✅ Lombok 제거 후 data class로 변경

Java에서 흔히 사용했던 @Getter, @Setter, @Builder, @ToString 등과 같은 Lombok 어노테이션이 더는 Kotlin에서는 필요하지 않다고 판단했습니다.
Kotlin은 이러한 기능을 언어 자체에서 자연스럽게 지원하기 때문에, 오히려 Lombok을 유지하는 것이 복잡도를 더 높이는 결과로 이어질 수 있습니다.

이러한 이유로, DTO 클래스나 단순 데이터 컨테이너 클래스들은 모두 Kotlin의 data class로 전환했습니다.

변환전 Java 코드

최초 자동변환

최종 변환

  • Kotlin data class로 lombok의 의존성을 제거하여 깔끔한 코드를 유지할 수 있었습니다.

✅ Stream API를 제거하며 얻은 간결함

Java에서는 복잡한 컬렉션 조작이나 집계를 위해 Stream API는 대부분 Kotlin 컬렉션 함수를 이용하여 더 간결하고 코드의 흐름을 더 읽기 쉽게 만들 수 있기에 해당 부분도 Kotlin스럽게 변경하고자 했습니다.

변환전 Java 코드

최종 변환

  • 각 로직의 목적이 함수명으로 자연스럽게 드러날 수 있게 Kotlin 컬렉션 함수로 변경하는 작업을 진행했습니다.

✅ Optional을 없애고 얻은 직관성

Java에서는 Optional을 사용해 null을 명시적으로 다루는 방법을 제공하지만, 실제로는 코드가 길어지고 복잡해지는 경우가 많고, 필드나 파라미터에 사용할 경우 오히려 불필요한 오버헤드를 유발하기도 합니다.

Kotlin은 언어 차원에서 null 가능성을 타입으로 표현할 수 있기 때문에, Optional 대신 nullable 타입(String?, Int? 등)과 ?:, ?. 등의 연산자를 조합해 더 간결하고 직관적으로 표현하고자 했습니다.

변환전 Java 코드

최종 변환

✅ 기존 유틸 클래스 제거 후 코틀린 확장 함수로 변경

Kotlin으로 전환하면서 자연스럽게 개선된 것 중 하나는 Java에서 사용하던 정적 유틸 클래스들을 Kotlin에서 제공하는 확장 함수로 대체할 수 있었다는 점입니다.

변환전 Java 코드

최종 변환


전환 과정에서 마주한 기타 기술적 과제

앞선 내용과는 다르게 이번에는 특정 이슈들을 해결하기 위해 조치했던 내용들을 소개하려고 합니다.

✅ git commit history 유실되지 않게 하기

처음 Kotlin 전환을 하고 해당 파일들을 바로 commit 하게 되면 new file로 여겨지며 기존의 히스토리를 잃어버리게 되는 이슈가 있었습니다.
이를 방지하기 위해 intelliJ git comiit시 option중 Extra commit for .java > .kt renames 옵션을 활성화하였습니다

위 옵션이 활성화되면 코드 변경분이 커밋되기 전 파일 확장자를 .java에서 .kt로 rename하는 커밋이 자동으로 진행되고 기존 히스토리를 유지할 수 있게 됩니다.

단, 작업 브랜치에서 메인 브랜치로 병합할 때 squash 머지를 사용하면 히스토리 유지를 할 수 없습니다.

✅ lombok 플러그인 설정

Kotlin으로 서비스 코드를 전환하던 중, 기존 Java DTO들의 @Getter, @Setter, @Builder와 같은 lombok 어노테이션을 인식하지 못하는 문제가 발생했습니다.
그 결과, DTO를 사용하는 다른 Java 서비스에서도 컴파일 및 테스트 오류가 발생하며, 전환 순서에 따라 관련된 코드 전체에 영향이 미치는 문제가 발생했습니다.

문제의 핵심은 Kotlin 빌드 순서에 있습니다.
빌드 순서: kapt → Kotlin → java annotation processor → java
이 순서 때문에 Kotlin 코드에서 Lombok으로 자동생성할 Java 메서드를 참조하면 빌드 시점에는 존재하지 않는 것으로 인식되어 오류가 발생합니다.

전환 순서를 lombok부터 제거하는 방향으로 해도 되지만, 이는 기능 단위의 전환을 목표로 하는 팀에게 맞지 않은 방식이었고, 순서에 구애받지 않기 위해 lombok 플러그인을 설정하여 해결했습니다.

  • Kotlin 버전 1.7.20 이상부터 지원됩니다.
  • 다만 같은 모듈 안에서 @builder는 인식이 안 되는 문제가 있습니다.

✅ QueryDSL DTO 매핑, Kotlin에서는 어떤 방식을 써야 할까?

Kotlin으로 전환하면서 QueryDSL 자체를 사용하는 데에는 큰 문제가 없었지만, 조회 쿼리 결과를 DTO에 매핑하는 방식에서 고민이 생겼습니다.

Java 환경에서는 보통 Projections.fields() 또는 Projections.constructor()를 상황에 따라 선택해 사용해 왔습니다.
하지만 Kotlin의 data class는 기본 생성자가 없는 구조이기 때문에, Projections.fields() 방식으로 매핑하려면 각 필드에 불필요한 nullable 처리나 기본값 설정을 추가해야 하는 불편함이 있습니다.

결과적으로 DTO 매핑 시 Projections.constructor()를 사용하는 것으로 결정했습니다.
그 이유는 다음과 같습니다.

  • 매핑 대상인 data class가 nullable 처리나 기본값 설정 없이 원래의 의도를 유지할 수 있습니다.
  • constructor 방식은 Kotlin 언어 특성과 잘 맞고, 추가적인 코드 수정이 필요하지 않습니다.
  • 생성자 파라미터의 순서가 일치해야 한다는 제약은 있지만, 이는 테스트 코드로 충분히 검증할 수 있습니다.

이 방식을 선택함으로써 DTO가 설계 목적에 맞게 간결하게 유지되었고, 코드의 의도도 더 명확하게 전달할 수 있었습니다.

변환전 Java 코드

  • Java reflection 기반으로 동작하는 Projections.fields는 매핑되는 DTO의 기본생성자를 통해 객체를 생성하고, 이름과 매칭되는 프로퍼티로 값을 주입하는 방식을 사용합니다.
  • Kotlin data class로 fields를 이용하려면 var과 기본값 사용이 강제되어 Kotlin의 불변성 철학과 어긋납니다.

변환후 Kotlin 코드

  • data class의 불변성을 지키고 가장 적은 수정을 위해 constructor(생성자) 주입 방식을 사용했습니다.
  • @QueryProjection도 사용할 수 있으나, DTO에 불필요한 정보가 담기는 것이 우려되어 사용하지 않았습니다.

✅ data class 역직렬화 문제

Kotlin으로 전환한 data class가 objectMapper를 이용하여 역직렬화되는 과정에서 기본생성자가 없다는 아래와 같은 오류가 나타날 때가 있었습니다.

nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException

이는 커스텀한 objectMapper를 사용하고 있을 때에 발생하는 문제로 아래 해결법으로 해결할 수 있었습니다.

// 의존성 추가 com.fasterxml.jackson.module:jackson-module-kotlin org.jetbrains.kotlin:kotlin-reflect

위 의존성을 추가할 경우 Spring에서 기본적으로 만드는 Jackson2ObjectMapperBuilder에 자동으로 Kotlin register(module)가 추가됩니다.
다만 커스텀한 ObjectMapper를 사용하고 있다면, new KotlinModule.Builder().build()를 명시적으로 등록해야 합니다.

✅ Kotlin에서의 예외 처리와 Spring 트랜잭션 롤백

Kotlin으로 전환하면서 예외 처리 방식에서의 미묘한 차이도 경험했습니다. 특히, Java에서의 checked exception과 Kotlin의 unchecked exception 차이가 Spring 트랜잭션 처리 방식에 영향을 줄 수 있다는 점을 인지해야 했습니다.

Kotlin은 모든 예외를 기본적으로 unchecked exception으로 처리합니다. 즉, IOException과 같은 Java의 checked exception도 Kotlin에서는 throws 없이 전파할 수 있으며, Spring 트랜잭션에서는 이 예외에 대해 자동으로 롤백이 발생합니다.

전환하려는 로직의 예외 처리 방식에 대해 고민해야 합니다. 의도치 않은 롤백을 방지하려면 @Throws 키워드를 반드시 붙여주세요.

  • Kotlin에서 함수 내 Exception은 모두 unchecked exception입니다. ( Spring 트랜잭션이 롤백됩니다 )
  • checked exception을 그대로 전파하려면 @Throws를 붙여주세요 ( Spring 트랜잭션이 롤백안됩니다 )

  • java에서의 checked exception은 예외가 전파되더라도 Spring transactional 정책에 의해 롤백 되지 않습니다.

  • Kotlin에서의 exception은 기본적으로 unchecked exception으로 간주하며, 이는 Spring transactional 정책에 의해 롤백이 발생합니다.
  • @Throws 어노테이션을 사용해 checked exception임을 명시할 수 있습니다.


이번 Kotlin 전환 프로젝트는 단순히 언어를 바꾸는 작업에 그치지 않았습니다.
레거시 코드를 점검하고, 테스트 기반을 보완하며, 안정적인 운영을 위한 QA와 배포 전략까지 함께 설계한 의미 있는 변화였습니다.

총 82개의 작업 티켓을 소화했고, 10번의 운영 배포를 거쳤으며, 그 과정에서 단 한 건의 장애도 발생하지 않았습니다.

이 수치들은 단순한 코드 전환이 아니라, 신뢰할 수 있는 전환과 안정적인 운영을 동시에 이뤄냈다는 것을 보여줍니다.
이번엔 앞서 소개한 기술부채 개선을 지표로 보여 드리겠습니다.

  • 가독성, 유지보수 등에 영향을 줄 수 있는 구조적 문제인 Code smells330개에서 4개로 크게 줄었습니다. 이는 무려 -98.8%의 감소율입니다.

  • 코드의 복잡성을 나타내는 Cognitive Complexity 또한 1940에서 1713으로 -11.7%의 감소율을 보여줍니다.

  • 안정적인 전환을 위해서 신경 썼던 테스트 커버리지는 61.5%에서 76.7%으로 +15.2%를 증가시켰습니다

정량적인 지표뿐만 아니라 이번 프로젝트를 통해 추가로 얻은 결과도 있었습니다.

  • 기술적 일관성 확보
    팀의 주력 언어를 Kotlin으로 통일함으로써, 협업과 코드 리뷰, 신규 개발의 진입 장벽이 낮아졌습니다.

  • 팀 내부 개발문화 성숙
    테스트와 배포 전략을 함께 설계하면서, 변화를 함께 설계하고 논의하는 협업 방식이 자연스럽게 자리 잡았습니다.

  • 안정적인 전환 경험 축적
    전체 코드를 한 번에 바꾸기보다는, 작은 단위로 점진적으로 전환하고 각 단계를 검증해가며 진행한 경험은 앞으로 유사 프로젝트에도 안전하게 개선할 수 있다는 자신감을 주었습니다.


지금까지 포인트 시스템의 주 언어를 Java에서 Kotlin으로 전환했던 프로젝트를 소개해 드렸습니다.
기술은 언제나 바뀔 수 있다고 생각합니다.
하지만 그 기술을 어떤 방식으로 받아들이고, 변화시켜 나갔는지에 대한 경험은 팀의 자산으로 남습니다.
이번 전환은 그 시작이자, 다음 변화 맞이하기 위한 밑거름이었습니다.
마지막으로 이 경험이 언젠가 Kotlin 전환을 고민하는 다른 팀에게도 참고 되었으면 합니다.

Read Entire Article