Java에서 Kotlin으로 전환할 때, 무엇을 먼저 봐야 할까
요약: Java에서 Kotlin으로 전환하는 일은 문법을 짧게 바꾸는 작업이 아닙니다. null 처리 방식, 예외 전파, 불변성, 컬렉션 선택, 아키텍처 경계까지 함께 다시 봐야 비로소 전환의 효과가 드러납니다. 이 글은 실제 마이그레이션에서 무엇부터 바꿔야 하는지, 어떤 지점에서 사고방식을 전환해야 하는지, 그리고 어디서 실수가 자주 나는지 정리한 기록입니다.
시작하며
Java 프로젝트를 오래 다루다 보면 한 번쯤 이런 고민이 생깁니다.
"이제 Kotlin으로 넘어가야 하지 않을까?"
처음에는 보통 이런 이유부터 떠오릅니다.
- 문법이 더 간결해 보이고
- Spring 생태계에서 Kotlin 활용 사례도 많아졌고
- 새로운 프로젝트는 Kotlin으로 시작하는 팀도 점점 늘고 있고
- 유지보수성과 생산성이 더 좋아 보이기 때문입니다
하지만 실제로 전환을 시작해 보면 금방 알게 됩니다.
문제는 public class가 data class로 바뀌는 정도가 아니었습니다. 진짜 차이는 null을 다루는 방식, 상태를 설계하는 방식, 예외를 전파하는 방식, 도메인과 인프라를 분리하는 방식에서 드러났습니다.
즉, Java에서 Kotlin으로의 전환은 언어 교체라기보다 코드를 바라보는 관점을 다시 세우는 일에 더 가까웠습니다.
이번 글에서는 Java 코드를 Kotlin으로 옮길 때 무엇을 먼저 봐야 하는지, 어떤 순서로 접근해야 안전한지, 그리고 Kotlin다운 코드로 정리하려면 어디까지 손봐야 하는지 차근차근 정리해보겠습니다.
TL;DR
| 항목 | 내용 |
|---|---|
| 핵심 관점 | Kotlin 전환은 문법 변환이 아니라 설계와 사고방식의 전환 |
| 먼저 볼 것 | 테스트 코드, null 처리, 예외 처리, 불변성, 빌드 설정 |
| 우선 전략 | 전체 일괄 전환보다 작게 바꾸고 자주 검증하는 점진 전환이 안전 |
| 자주 하는 실수 | 자동 변환 결과를 그대로 유지하고 Java 스타일을 Kotlin 문법으로만 옮김 |
| 중요 체크포인트 | @Transactional, @Throws, Jackson 설정, Lombok 공존, 컬렉션 성능 |
| 결론 | 좋은 마이그레이션은 코드가 더 짧아지는 것이 아니라 더 안전하고 더 읽기 쉬워지는 것 |
왜 단순 변환만으로는 부족했을까
IntelliJ의 자동 변환 기능은 정말 좋은 출발점입니다. Java 파일을 Kotlin으로 옮길 때 반복적인 문법 치환을 빠르게 해 주기 때문이죠.
하지만 문제는 그 다음부터였습니다.
자동 변환 결과물은 대개 Kotlin 문법을 입은 Java 코드에 가까웠습니다.
getter와setter중심의 사고가 그대로 남고Optional을 버리지 못한 채 nullable 타입과 혼재되고static유틸 패턴이 확장 함수로 자연스럽게 녹아들지 못하고if-else와try-catch도 표현식이 아니라 절차형 블록처럼 유지되곤 했습니다
겉으로는 Kotlin이지만, 읽다 보면 여전히 Java의 결을 강하게 느끼게 되는 코드였어요.
flowchart TD
ROOT["자동 변환 이후 자주 보이는 상태"]
ROOT --> A["nullable 남발"]
ROOT --> B["var 중심 설계 유지"]
ROOT --> C["Optional 사고방식 잔존"]
ROOT --> D["static util 패턴 반복"]
ROOT --> E["Java식 DTO/Builder 그대로 유지"]
결국 핵심은 명확했습니다.
자동 변환은 출발점일 뿐이고, 실제 품질은 그 이후 리팩터링에서 결정됩니다.
Java 스타일과 Kotlin 스타일은 어디서 갈릴까
Kotlin 전환에서 중요한 건 문법 자체보다 언어가 잘하는 방식으로 다시 표현하는 것이었습니다.
| Java 패턴 | Kotlin Idiomatic 패턴 | 핵심 차이 |
|---|---|---|
Lombok @Getter / @Setter / @Builder | data class + named arguments | 보일러플레이트를 언어 차원에서 제거 |
Optional.orElse() / Optional.get() | ?. / ?: / let / takeIf | Null Safety가 타입 시스템에 내장 |
| Stream API | 컬렉션 확장 함수 | 별도 Stream 생성 없이 바로 체이닝 |
| static 유틸 클래스 | 확장 함수 | 타입의 메서드처럼 자연스럽게 호출 |
| try-with-resources | use 확장 함수 | 람다 기반으로 더 간결하게 자원 관리 |
| if-else 문 | if-else 식 | 반환값을 직접 계산해 대입 가능 |
Kotlin 전환의 목적은 Java 코드를 있는 그대로 옮기는 것이 아니라, 더 안전하고 의도가 잘 드러나는 코드로 다시 표현하는 것입니다.
마이그레이션은 어떤 순서로 진행하는 게 안전했을까
실무에서는 전체 코드를 한 번에 바꾸는 방식보다, 영향 범위를 통제하면서 점진적으로 넓혀 가는 방식이 훨씬 현실적이었습니다.
flowchart LR
subgraph Phase1["Phase 1: 안전지대"]
A["테스트 코드<br>Kotlin 전환"] --> B["Kotest + MockK<br>테스트 스타일 정리"]
end
subgraph Phase2["Phase 2: 점진 전환"]
C["의존성 낮은 계층<br>DTO / Entity / Util"] --> D["Service 계층<br>비즈니스 로직"]
end
subgraph Phase3["Phase 3: 안정화"]
E["기능 단위<br>점진 배포"] --> F["정적 분석 + QA<br>품질 검증"]
end
B --> C
D --> E
처음부터 핵심 운영 로직을 전부 Kotlin으로 바꾸는 건 생각보다 위험했습니다. 반면 테스트 코드는 운영 영향도가 낮고, 팀이 Kotlin 문법과 테스트 스타일에 익숙해지기에도 좋은 출발점이었습니다.
실제로는 이런 순서가 특히 잘 맞았습니다.
- 테스트 코드부터 전환해 팀이 Kotlin 문법에 적응하기
- DTO, Entity, Utility 같은 영향 범위가 좁은 영역부터 전환하기
- 그 다음에 Service와 핵심 비즈니스 로직으로 범위를 넓히기
- 배포는 한 번에 하지 말고 기능 단위로 작게 나눠 검증하기
** 크게 바꾸는 것보다 작게 바꾸고, 자주 검증하고, 문제를 빨리 되돌릴 수 있게 만드는 것이 훨씬 안전했습니다.
Kotlin 전환에서 왜 null 처리가 가장 먼저 보였을까
Java에서 null은 늘 조심해야 하는 값이지만, Kotlin에서는 아예 타입 시스템 수준에서 null 가능성을 드러냅니다.
처음에는 이게 단순히 편리한 문법처럼 보일 수 있습니다. 하지만 실제로는 훨씬 더 큰 변화였습니다.
"이 값은 null일 수 있는가?"
이 질문을 더 이상 호출하는 쪽의 감각에 맡기지 않고, 선언 자체에 드러내기 때문입니다.
예를 들어 Java 스타일 DTO를 자동 변환하면 이런 코드가 쉽게 나옵니다.
class AuctionResponse { var itemId: String? = null var title: String? = null var price: Long? = null var status: String? = null}동작은 합니다. 하지만 모든 값이 nullable이고 mutable이면, 사용하는 쪽은 매번 방어 코드를 작성해야 합니다.
반면 Kotlin에서는 아래처럼 더 명확하게 설계할 수 있습니다.
data class AuctionResponse( val itemId: String, val title: String, val price: Long, val status: AuctionStatus) { companion object { fun from(item: AuctionItem) = AuctionResponse( itemId = item.itemId, title = item.title, price = item.startPrice, status = item.status ) }}이 차이는 단순한 문법 취향이 아니었습니다.
- non-null 타입은 객체가 어떤 상태로 존재해야 하는지 분명하게 해 주고
val중심 설계는 상태 변경 지점을 줄여 주고- 생성 시점의 명시성은 잘못된 상태가 시스템 깊숙이 들어가는 일을 막아줍니다
즉, Kotlin 전환에서 null 처리를 먼저 본다는 건 결국 도메인 모델의 신뢰도를 먼저 다시 세운다는 뜻에 가까웠습니다.
!!는 왜 대부분 마지막 수단이어야 할까
Kotlin을 쓰기 시작하면 !!는 정말 쉽게 눈에 들어옵니다.
짧고 빠르니까요. 그런데 실무에서는 이 문법이야말로 가장 조심해야 할 신호 중 하나였습니다.
fun getItemTitle(item: AuctionItem?): String { return item!!.title}이 코드는 의도보다 단정이 먼저 드러납니다.
사실상 "여기서 null이면 그냥 터져도 된다"는 선언에 가깝기 때문입니다.
같은 상황이라도 의도에 따라 더 좋은 선택이 있었습니다.
fun getItemTitle(item: AuctionItem?): String { val validItem = requireNotNull(item) { "AuctionItem은 null일 수 없다" } return validItem.title}
fun getItemTitleOrDefault(item: AuctionItem?): String { return item?.let { it.title } ?: "제목 없음"}
fun getActiveItemTitle(item: AuctionItem?): String? { return item?.takeIf { it.status == AuctionStatus.ACTIVE }?.title}세 코드는 모두 null을 다루지만, 의미는 전혀 다릅니다.
- null이면 예외로 실패시킬 것인지
- null이면 기본값으로 복구할 것인지
- 특정 조건일 때만 값을 흘려보낼 것인지
Kotlin다운 코드는 짧은 코드보다 의도가 선명한 코드에 더 가까웠습니다.
예외 처리는 왜 전환 과정에서 더 중요해졌을까
Kotlin은 Java와 달리 Checked Exception과 Unchecked Exception을 문법적으로 구분하지 않습니다.
이 특성은 코드를 간결하게 만들어 주지만, Spring의 트랜잭션 정책과 만나면 꽤 미묘한 차이를 만듭니다.
@Serviceclass PaymentService {
@Transactional @Throws(IOException::class) fun processPayment(orderId: String) { val result = externalPaymentClient.call(orderId) }}이 코드에서 @Throws는 단지 어노테이션 하나가 아닙니다. JVM 바이트코드 수준에서 예외 정보를 드러내 Java와 유사한 기대 동작을 맞추는 데 영향을 줍니다.
flowchart TD
A["예외 발생"] --> B{"Kotlin에서<br>@Throws 선언?"}
B -- "선언함" --> C["JVM 바이트코드:<br>Checked Exception 정보 포함"]
B -- "선언 안 함" --> D["JVM 바이트코드:<br>Unchecked Exception처럼 전파"]
C --> E["Spring @Transactional<br>기본 롤백 대상 아님"]
D --> F["Spring @Transactional<br>자동 롤백 가능"]
이 부분은 특히 놓치기 쉬웠습니다.
겉보기에는 Java와 Kotlin이 비슷하게 보이는데, 실제 운영에서는 트랜잭션 경계, 롤백 정책, 예외 전달 기대치가 달라질 수 있기 때문입니다.
⚠️
즉, Kotlin 전환에서 예외 처리는 문법 문제가 아니라 운영 안정성 문제였습니다. @Transactional 안에서 어떤 예외가 어떻게 전파되고 롤백되는지 반드시 다시 확인해야 했습니다.
헥사고날 아키텍처와 Kotlin이 잘 맞는 이유는 무엇일까
Kotlin은 헥사고날 아키텍처처럼 도메인 중심으로 구조를 세우는 방식과도 잘 맞았습니다.
이유는 단순합니다.
상태를 더 명확하게 표현할 수 있고, 생성 시점에 규칙을 강제하기 쉬우며, 포트와 유스케이스를 더 가볍게 표현할 수 있기 때문입니다.
flowchart TB
subgraph Bootstrap["Bootstrap Hexagon<br>(Primary Adapter)"]
API["REST Controller"]
Worker["Kafka / SQS Consumer"]
end
subgraph Application["Application Hexagon"]
UC["UseCase Interface"]
IP["InputPort 구현체"]
OP["OutputPort Interface"]
end
subgraph Domain["Domain Hexagon"]
Entity["Entity / VO / Aggregate"]
Rule["비즈니스 규칙"]
end
subgraph Framework["Framework Hexagon<br>(Secondary Adapter)"]
DB["DB Adapter<br>(MongoDB, MySQL)"]
ExtAPI["외부 API Adapter<br>(Retrofit, Feign)"]
end
API --> UC
Worker --> UC
UC --> IP
IP --> Entity
IP --> OP
OP -.->|"DIP: 인터페이스에 의존"| DB
OP -.->|"DIP: 인터페이스에 의존"| ExtAPI
예를 들어 도메인 객체는 data class, val, init, require를 통해 훨씬 단단하게 만들 수 있었습니다.
data class AuctionItem( val itemId: String, val title: String, val startPrice: Long, val status: AuctionStatus) { init { require(startPrice > 0) { "시작 가격은 0보다 커야 한다" } require(title.isNotBlank()) { "제목은 비어 있을 수 없다" } }}이 구조의 장점은 명확했습니다.
- 객체가 생성될 때 바로 규칙을 검증할 수 있고
- 잘못된 상태가 시스템 안쪽으로 깊게 들어오기 전에 막을 수 있고
- 포트와 어댑터의 경계를 더 읽기 쉽게 유지할 수 있습니다
interface AuctionRetrieveUseCase { fun getOrDefault(itemId: String): AuctionItem}
interface AuctionOutputPort { fun findById(itemId: String): AuctionItem?}
@Serviceclass AuctionRetrieveInputPort( private val auctionOutputPort: AuctionOutputPort) : AuctionRetrieveUseCase {
override fun getOrDefault(itemId: String): AuctionItem { return auctionOutputPort.findById(itemId) ?: AuctionItem(itemId, "기본 경매", 1000, AuctionStatus.PENDING) }}물론 이 구조가 항상 정답은 아닙니다. 작은 CRUD 서비스라면 오히려 구조가 과해질 수 있습니다.
하지만 도메인 규칙이 복잡하고 외부 연동이 많을수록 Kotlin과 헥사고날은 꽤 잘 맞는 조합이었습니다.
Java 스타일 코드를 Kotlin답게 바꾸는 대표 패턴들
마이그레이션을 하며 가장 자주 보게 되는 질문은 결국 이것이었습니다.
"그래서 어디를 어떻게 바꾸면 Kotlin다워지는가?"
1) Optional보다 nullable 타입이 더 자연스럽다
val item = repository.findById(id) ?: throw NotFoundException("경매 아이템을 찾을 수 없다: $id")Java의 Optional 체인을 억지로 유지하는 것보다, Kotlin에서는 nullable 타입과 Elvis 연산자가 훨씬 간결하고 의도가 분명했습니다.
2) 유틸 클래스보다 확장 함수가 더 읽기 좋다
public class StringUtils { public static String maskEmail(String email) { int atIndex = email.indexOf("@"); return email.substring(0, 2) + "***" + email.substring(atIndex); }}fun String.maskEmail(): String { val atIndex = indexOf("@") return substring(0, 2) + "***" + substring(atIndex)}호출부에서 userEmail.maskEmail()처럼 읽히기 때문에 기능의 주체가 더 분명해졌습니다.
3) 작은 함수는 Expression Body로 더 선명해진다
fun isDiscountTarget(user: User): Boolean = user.grade == Grade.VIP단순 계산이나 조건 판단은 블록보다 표현식이 의도를 더 빠르게 전달했습니다.
4) 컬렉션 선택은 문법보다 성능에 더 가깝다
val keepImageUrls: List<String> = getKeepUrls()val filtered = allImages.filter { it.url in keepImageUrls }간결해 보이지만 contains()가 선형 탐색이면 데이터가 커질수록 비용이 증가합니다.
val keepImageUrls: Set<String> = getKeepUrls().toSet()val filtered = allImages.filter { it.url in keepImageUrls }Kotlin스럽다는 건 문장이 짧다는 뜻이 아니라, 의도와 성능을 함께 고려한 선택까지 포함하고 있었습니다.
클린 코드 관점에서는 무엇이 달라졌을까
클린 코드는 보기 좋은 코드가 아니라 읽는 사람이 빨리 의도를 이해할 수 있는 코드라고 생각합니다. Kotlin은 이 부분에서 꽤 강력했습니다.
| 원칙 | Java에서 자주 보이는 문제 | Kotlin에서의 개선 방식 |
|---|---|---|
| 의미 있는 이름 | 불리언 의도가 이름에 충분히 드러나지 않음 | isExpired, hasDiscount 같은 프로퍼티로 자연스럽게 표현 |
| 작은 함수 | 단순 계산도 장황한 블록 구조가 필요 | Expression Body로 한 줄 함수 작성 |
| 불변성 | 가변 필드가 쉽게 늘어남 | val 중심 설계로 상태 변경 지점 축소 |
| Null 안전 | null 체크나 Optional 패턴이 반복됨 | nullable 타입과 안전 호출 연산자로 해결 |
| 보일러플레이트 제거 | Lombok, Builder, 매핑 코드 의존 증가 | data class, named arguments, default parameter 활용 |
결국 Kotlin으로의 전환은 코드 양을 줄이는 데서 끝나지 않았습니다.
어떤 값이 핵심 상태인지, 어디서 실패해야 하는지, 무엇을 변경 가능하게 둘 것인지를 더 분명하게 드러내는 방향으로 이어졌습니다.
전환 과정에서 실제로 무엇을 점검해야 할까
실무에서는 문법보다 주변 생태계에서 더 많은 문제가 발생했습니다. 그래서 마이그레이션 중에는 아래 항목을 반드시 함께 봐야 했습니다.
| 단계 | 검증 항목 | 위험도 |
|---|---|---|
| 빌드 설정 | Kotlin 버전, Lombok 플러그인, Gradle 설정 점검 | 높음 |
| Null 처리 | 자동 변환 후 nullable 프로퍼티의 비즈니스 의미 재검토 | 높음 |
| 예외 처리 | @Transactional 내부 Checked Exception 처리 방식 점검 | 높음 |
| Git 히스토리 | .java → .kt 변경 시 히스토리 보존 옵션 확인 | 중간 |
| 역직렬화 | jackson-module-kotlin, kotlin-reflect 적용 여부 확인 | 중간 |
| QueryDSL | DTO 매핑 시 Projections.constructor() 사용 여부 검토 | 중간 |
| Lombok 공존 | 같은 모듈 내 @Builder 사용 클래스 우선 전환 필요 | 낮음 |
| 배포 전략 | 기능 단위 점진 배포와 히스토리 관리 방식 점검 | 중간 |
** 코드를 Kotlin 문법으로 바꾸는 것이 아니라, 전환 이후에도 서비스가 안정적으로 동작하게 만드는 것입니다.
그래서 좋은 마이그레이션은 무엇으로 판단할 수 있을까
전환이 끝났다고 느껴질 때도, 사실 더 중요한 질문은 뒤에 남아 있습니다.
- 이 전환으로 코드가 더 안전해졌는가?
- 이 전환으로 의도가 더 잘 드러나는가?
- 이 전환으로 팀이 더 쉽게 읽고 수정할 수 있게 되었는가?
만약 답이 단순히 "코드가 더 짧아졌다"에 머문다면, 아직 Kotlin 전환은 절반밖에 끝나지 않았다고 생각합니다.
반대로 아래 변화가 보이기 시작하면 꽤 좋은 방향으로 가고 있는 겁니다.
flowchart TD
A["좋은 Kotlin 전환"] --> B["Non-null 중심 모델"]
A --> C["val 중심의 불변 설계"]
A --> D["예외와 트랜잭션 정책 재정리"]
A --> E["확장 함수와 표현식 중심 리팩터링"]
A --> F["점진 배포와 지속 검증"]
결국 좋은 마이그레이션은 언어를 바꾸는 일이 아니라, 더 안전하고 더 읽기 쉬운 설계로 이동하는 일이었습니다.
마치며
Java에서 Kotlin으로 넘어가는 과정은 생각보다 훨씬 넓은 작업이었습니다.
null 안전성을 다시 봐야 했고, 예외와 트랜잭션의 상호작용을 점검해야 했고, var보다 val, 유틸 클래스보다 확장 함수, 단순 변환보다 설계 개선이 더 중요하다는 걸 계속 확인하게 됐습니다.
그리고 가장 크게 느낀 건 이것이었습니다.
- Java 코드를 그대로 옮기는 데서 멈추지 말 것
- Kotlin이 잘하는 방식으로 다시 표현할 것
- null, 예외, 상태, 경계를 더 명확하게 드러낼 것
좋은 전환은 결국 팀이 다음 변경을 더 편하게 만들 수 있어야 합니다.
그 기준에서 본다면 Kotlin은 단순히 더 짧은 언어가 아니라, 더 안전한 설계를 밀어주는 언어에 가까웠습니다.
이 글이 Java에서 Kotlin으로 넘어가려는 분들에게 하나의 점검 기준이 되면 좋겠습니다.
참고 자료
- Java → Kotlin 전환 전략, 정량 성과, Null 처리, Lombok 제거, Stream API 대체, 예외 처리와 Spring 트랜잭션 롤백 이슈: 우아한형제들 — Java야…, 우리 그만 헤어져. Kotlin으로 환승연애
- Kotlin 점진적 전환 절차, Lombok 플러그인 지원, IntelliJ 자동 변환 활용법, Git history 보존 옵션: 카카오페이 — 자바 프로젝트 3개 코틀린 점진적 전환기
- Spring Boot Kotlin 멀티 모듈 기반 헥사고날 아키텍처 구성: 우아한형제들 — Spring Boot Kotlin Multi Module로 구성해보는 헥사고날 아키텍처
- Kotlin 도입 의사결정 기준, Null Safety와 표현력 향상의 실무 효과: 우아한형제들 — 똑똑, 프로젝트에 코틀린을 도입하려고 합니다
- Kotlin의 예외 처리 철학과 Checked Exception 제거 배경: Kotlin 공식 문서 — Exceptions
- 헥사고날 아키텍처 도입 시 고려사항: 카카오페이 — Hexagonal Architecture, 진짜 하실 건가요?
