코틀린 클린코드(11) – 코틀린과 자바를 함께 쓸 때 클린코드가 깨지는 이유: Kotlin-Java interop 설계 원칙

코틀린 클린코드 11편 코틀린과 자바를 함께 쓸 때 interop 경계 설계 원칙
코틀린 클린코드 11편 코틀린과 자바를 함께 쓸 때 interop 경계 설계 원칙

1편에서는 코틀린 클린코드의 기준을 잡았습니다. 2편에서는 이름 짓기를, 3편에서는 함수 설계를 정리했습니다.

4편에서는 null safety를, 5편에서는 data class와 sealed class를, 6편에서는 extension function과 scope function을 다뤘습니다.

7편에서는 컬렉션과 람다를, 8편에서는 예외 처리를, 9편에서는 테스트하기 좋은 클래스 설계를, 10편에서는 코루틴을 살펴봤습니다.

이번 11편에서는 코틀린과 자바를 함께 쓸 때 클린코드가 왜 자주 깨지는지를 이야기합니다. 주제는 Kotlin-Java interop 경계에서 의도를 잃지 않는 설계 원칙입니다.

코틀린과 자바는 같은 JVM 위에서 잘 섞입니다. 실제로 많은 팀이 기존 자바 코드베이스 위에 코틀린을 점진적으로 도입합니다.

문제는 호환성과 가독성이 같지 않다는 점입니다. 함께 돌아간다함께 읽힌다는 전혀 다른 이야기입니다.

혼합 코드베이스에서 클린코드가 깨지는 가장 큰 이유는 두 언어의 문법 차이가 아닙니다. null, 예외, 컬렉션, API 노출 방식 같은 ‘기본값의 차이’를 경계에서 번역하지 않기 때문입니다.

이번 글에서는 platform type, Java getter와 Kotlin property 문법, @JvmOverloads, @JvmStatic, @JvmField, @Throws, 컬렉션, Optional, internal, 와일드카드까지 한 번에 정리하겠습니다.

시리즈 전체 흐름이 궁금하시다면 코틀린 클린코드 허브 페이지를 먼저 읽어보세요. 앞선 글이 필요하시다면 1편, 2편, 3편, 4편, 5편, 6편, 7편, 8편, 9편, 10편도 함께 보시면 좋습니다.

문제는 JVM이 아니라 언어의 기본값 차이입니다

코틀린과 자바는 상호 운용성이 좋습니다. 그래서 둘을 함께 쓰는 일 자체는 문제가 아닙니다.

문제는 두 언어가 같은 것을 다른 기본값으로 본다는 데 있습니다.

  • 코틀린은 null-safety를 타입 시스템으로 밀어 올립니다.
  • 코틀린은 checked exception을 기본 문법으로 강제하지 않습니다.
  • 코틀린은 읽기 전용 컬렉션과 변경 가능한 컬렉션을 구분합니다.
  • 코틀린은 top-level function, property, default parameter를 자연스럽게 씁니다.
  • 자바는 getter/setter, static member, checked exception, package-private에 익숙합니다.

이 차이를 무시하면 겉으로는 컴파일이 됩니다. 하지만 코드는 금방 흐려집니다.

예를 들어 코틀린에서 아주 자연스러운 API가 자바에서는 어색하게 보일 수 있습니다. 반대로 자바에서는 익숙한 관습이 코틀린 쪽에서는 의도를 숨길 수 있습니다.

혼합 코드베이스에서 중요한 질문은 이것입니다.

이 선언은 지금 어느 언어의 눈으로 읽어도 같은 의미로 보이는가?

이 질문에 자신 있게 답하기 어렵다면, 그 코드는 경계 설계가 덜 된 상태일 가능성이 큽니다.

platform type를 경계 밖으로 흘려보내지 마세요

코틀린-자바 경계에서 가장 먼저 조심할 것은 platform type입니다.

자바의 참조 타입은 언제든 null이 될 수 있습니다. 그래서 코틀린은 자바에서 넘어온 타입을 엄격한 non-null이나 nullable로 단정하지 못하고, 중간 성격의 platform type으로 다룹니다.

문제는 이 값이 Kotlin 코드 안으로 들어온 뒤입니다. 컴파일러가 조용하다고 해서 안전한 것은 아닙니다.

public class LegacyProfileClient {
    public String findNickname(long userId) {
        return null;
    }
}

위 자바 코드는 문법상 문제가 없습니다. 하지만 Kotlin에서 보면 위험합니다.

class ProfileService(
    private val client: LegacyProfileClient,
) {
    fun loadNicknameUppercase(userId: Long): String {
        val nickname = client.findNickname(userId)
        return nickname.uppercase()
    }
}

이 코드는 컴파일될 수 있습니다. 하지만 런타임에서 null이 들어오면 바로 깨집니다.

해결 방법은 단순합니다. 경계에서 nullability를 명시적으로 확정하면 됩니다.

자바 코드를 수정할 수 있다면, nullability annotation을 붙이는 편이 가장 좋습니다.

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import org.jspecify.annotations.NullMarked;

@NullMarked
public class LegacyProfileClient {
    public @Nullable String findNickname(long userId) {
        return null;
    }

    public @NonNull String findRequiredName(long userId) {
        return "guest";
    }
}

외부 라이브러리라서 자바 쪽을 바꿀 수 없다면, Kotlin 어댑터에서 바로 타입을 확정하세요.

class ProfileGateway(
    private val client: LegacyProfileClient,
) {
    fun findNickname(userId: Long): String? {
        val nickname: String? = client.findNickname(userId)
        return nickname?.takeIf { it.isNotBlank() }
    }
}

핵심은 간단합니다. platform type를 서비스 전체로 퍼뜨리지 말고, 어댑터에서 nullable 또는 non-null로 결정해야 합니다.

경계에서 번역하지 않으면 null 문제는 늦게 터집니다. 그리고 보통 가장 찾기 힘든 곳에서 터집니다.

특히 아래 같은 코드는 피하시는 편이 좋습니다.

fun loadNickname(userId: Long): String {
    val nickname: String = client.findNickname(userId)
    return nickname
}

이 코드는 타입이 깔끔해 보입니다. 하지만 사실은 경계를 속이고 있을 뿐입니다.

클린코드는 타입이 예뻐 보이는 코드가 아니라, 위험한 지점을 숨기지 않는 코드입니다.

Java getter가 Kotlin property처럼 보일 때 더 조심해야 합니다

코틀린은 자바의 getter/setter를 property 문법으로 자연스럽게 읽게 해줍니다. 그래서 user.getName()은 Kotlin에서 user.name처럼 쓸 수 있습니다.

대부분의 경우 이 기능은 정말 편합니다. 문제는 자바의 getter가 싼 값 조회가 아니라 비싼 작업일 때입니다.

public class ReportClient {
    public String getDailyReport() {
        return requestReportFromRemoteServer();
    }
}

이 자바 API를 Kotlin에서 쓰면 이렇게 보입니다.

fun printReport(client: ReportClient) {
    println(client.dailyReport)
}

문법상 자연스럽습니다. 하지만 읽는 사람은 이 코드를 단순 필드 접근처럼 받아들이기 쉽습니다.

실제로는 네트워크 호출일 수도 있고, 디스크 I/O일 수도 있습니다. 비용과 실패 가능성이 큰데도 너무 가볍게 보입니다.

이럴 때는 경계에서 이름을 다시 붙여주는 편이 낫습니다.

class ReportGateway(
    private val client: ReportClient,
) {
    fun loadDailyReport(): String {
        return client.dailyReport
    }
}

이제 Kotlin 쪽에서는 loadDailyReport()라는 이름을 읽는 순간 비용과 의도를 함께 떠올릴 수 있습니다.

property 문법은 좋습니다. 다만 값처럼 보여도 사실은 작업인 것을 그대로 노출하면 가독성이 깨집니다.

값은 property처럼 보이게 하세요. 작업은 함수처럼 보이게 하세요. Java getter라도 의미가 작업이면 Kotlin 경계에서 다시 이름을 붙이는 편이 좋습니다.

자바가 호출할 코틀린 API는 따로 다듬으세요

코틀린 안에서만 쓸 API와 자바에서도 호출할 API는 설계 기준이 조금 다릅니다.

코틀린에서는 default parameter와 named argument가 아주 강력합니다. 하지만 자바는 이 장점을 그대로 누리지 못합니다.

object MailSender {
    fun send(
        address: String,
        urgent: Boolean = false,
        retries: Int = 3,
    ) {
        // ...
    }
}

코틀린에서는 읽기 좋습니다. 하지만 자바에서는 호출감이 좋지 않습니다.

MailSender.INSTANCE.send("team@example.com", false, 3);

호출 자체는 됩니다. 하지만 INSTANCE, 기본값 미지원, Boolean 인자의 의미 숨김 같은 문제가 한 번에 나타납니다.

자바 소비자가 실제로 많은 API라면, 경계용 annotation을 명시적으로 사용하는 편이 좋습니다.

object MailSender {
    @JvmStatic
    @JvmOverloads
    fun send(
        address: String,
        urgent: Boolean = false,
        retries: Int = 3,
    ) {
        // ...
    }
}

이제 자바에서는 훨씬 자연스럽게 읽힙니다.

MailSender.send("team@example.com");
MailSender.send("team@example.com", true);
MailSender.send("team@example.com", true, 5);

top-level function도 마찬가지입니다. 코틀린에서는 매우 자연스럽지만, 자바에서는 파일명 기반의 FooKt 클래스로 보입니다.

@file:JvmName("DateFormats")

package com.example.format

fun isoDatePattern(): String = "yyyy-MM-dd"

자바에서는 아래처럼 읽히게 됩니다.

String pattern = DateFormats.isoDatePattern();

의도가 명확한 이름을 주면 자바 쪽 호출감이 크게 좋아집니다.

상수나 필드를 자바에 자연스럽게 노출해야 할 때는 @JvmField도 검토할 수 있습니다.

class ApiConfig {
    companion object {
        @JvmField
        val DEFAULT_TIMEOUT_MILLIS: Long = 3_000
    }
}

중요한 점은 annotation을 많이 붙이는 것이 아닙니다. 자바 소비자가 읽을 표면만 의도적으로 다듬는 것이 중요합니다.

모든 함수에 @JvmOverloads, 모든 companion object에 @JvmStatic를 붙이는 식으로 가면 오히려 API가 지저분해집니다.

예외 정책은 양쪽 언어에서 같은 의미로 보여야 합니다

자바와 코틀린이 크게 다르게 느껴지는 지점 중 하나가 예외입니다.

코틀린은 checked exception을 기본 문법으로 강제하지 않습니다. 그래서 Kotlin 코드가 Java checked exception을 던져도, Java 쪽 시그니처에 자동으로 throws가 붙지 않습니다.

fun writeAuditLog() {
    throw java.io.IOException("disk error")
}

이 함수를 자바에서 catch하려고 하면 의외의 문제가 생깁니다.

try {
    AuditLogKt.writeAuditLog();
} catch (java.io.IOException e) {
    // 컴파일 오류:
    // writeAuditLog() does not declare IOException
}

자바에서 이 예외 계약을 보이게 하려면 @Throws를 써야 합니다.

@Throws(java.io.IOException::class)
fun writeAuditLog() {
    throw java.io.IOException("disk error")
}

반대로 Kotlin이 Java 코드를 호출할 때도 주의가 필요합니다. Java의 checked exception은 Kotlin에서 호출 시 강제되지 않습니다.

public class FileStore {
    public void write(byte[] content) throws java.io.IOException {
        // ...
    }
}
class BackupService(
    private val fileStore: FileStore,
) {
    fun backup(content: ByteArray) {
        fileStore.write(content)
    }
}

이 코드는 짧습니다. 하지만 실패 정책이 전혀 드러나지 않습니다.

클린코드 관점에서는 두 가지 중 하나를 택하는 편이 좋습니다.

  • 경계에서 잡아서 도메인 예외로 변환한다.
  • 반환 타입에서 실패를 드러낸다.

예를 들면 이렇게 쓸 수 있습니다.

class BackupService(
    private val fileStore: FileStore,
) {
    fun backup(content: ByteArray): Result<Unit> {
        return runCatching {
            fileStore.write(content)
        }
    }
}

또는 이렇게 도메인 예외로 번역할 수도 있습니다.

class BackupFailedException(
    message: String,
    cause: Throwable,
) : RuntimeException(message, cause)

class BackupService(
    private val fileStore: FileStore,
) {
    fun backup(content: ByteArray) {
        try {
            fileStore.write(content)
        } catch (e: java.io.IOException) {
            throw BackupFailedException("백업 저장에 실패했습니다.", e)
        }
    }
}

핵심은 같습니다. 예외 정책을 경계에서 확정해야 합니다.

자바는 throws와 checked exception으로 말하고, 코틀린은 타입과 명시적 처리로 말합니다. 둘을 섞는 프로젝트일수록 어느 쪽 언어에서 읽어도 같은 실패 계약이 보여야 합니다.

컬렉션과 Optional은 경계에서 번역하세요

컬렉션은 혼합 코드베이스에서 특히 자주 오해를 부르는 주제입니다.

코틀린은 읽기 전용 List와 변경 가능한 MutableList를 구분합니다. 하지만 자바 컬렉션은 Kotlin 쪽에서 platform type 성격을 띠기 때문에, 의도보다 넓게 받아들여질 수 있습니다.

public class LegacyUserClient {
    public java.util.List<String> loadRoles(long userId) {
        return java.util.List.of("ADMIN", "WRITER");
    }

    public java.util.Optional<String> findEmail(long userId) {
        return java.util.Optional.empty();
    }
}

아래 Kotlin 코드는 얼핏 자연스럽지만, 실제로는 경계를 흐립니다.

class UserService(
    private val client: LegacyUserClient,
) {
    fun loadRoles(userId: Long): MutableList<String> {
        val roles: MutableList<String> = client.loadRoles(userId)
        roles.add("TEMP")
        return roles
    }

    fun loadEmail(userId: Long): java.util.Optional<String> {
        return client.findEmail(userId)
    }
}

문제는 두 가지입니다.

  • 반환받은 리스트를 수정해도 되는지 소유권이 보이지 않습니다.
  • Optional이 Kotlin 도메인 내부로 그대로 들어와 있습니다.

이럴 때는 경계에서 번역하는 편이 훨씬 좋습니다.

class UserGateway(
    private val client: LegacyUserClient,
) {
    fun loadRoles(userId: Long): List<String> {
        return client.loadRoles(userId).toList()
    }

    fun loadEditableRoles(userId: Long): MutableList<String> {
        return client.loadRoles(userId).toMutableList()
    }

    fun findEmail(userId: Long): String? {
        return client.findEmail(userId).getOrNull()
    }
}

이제 읽는 사람은 바로 알 수 있습니다.

  • toList()는 읽기 전용 복사본이 필요하다는 뜻입니다.
  • toMutableList()는 수정 가능한 사본이 필요하다는 뜻입니다.
  • getOrNull()은 자바의 Optional을 코틀린의 nullable로 번역했다는 뜻입니다.

이 패턴은 단순하지만 효과가 큽니다. 경계 바깥의 나머지 Kotlin 코드는 더 이상 자바식 null 표현이나 컬렉션 소유권 문제를 매번 고민하지 않아도 됩니다.

경계 바깥에서는 코틀린답게 쓰세요. Optional은 nullable로, 애매한 Java List는 명시적 복사본으로 바꾸는 편이 보통 더 읽기 좋습니다.

visibility와 제네릭도 자바 소비자 기준으로 다시 보세요

혼합 프로젝트에서는 visibility와 제네릭도 의외로 자주 발목을 잡습니다.

1. internal은 자바의 package-private가 아닙니다

코틀린의 internal은 모듈 내부 공개라는 뜻입니다. 그런데 JVM 바이트코드 관점에서는 Java에서 완전히 숨겨지는 방식이 아닙니다.

internal class InternalTokenGenerator {
    internal fun issue(): String = "token"
}

이 선언은 Kotlin 쪽에서는 제약이 분명해 보입니다. 하지만 자바 관점에서는 바이트코드가 public으로 보일 수 있고, 이름 mangling으로 실수 사용을 어렵게 만들 뿐 완전한 은닉이 되지는 않습니다.

그래서 자바와 Kotlin을 함께 쓰는 모듈에서 internal을 package-private처럼 믿고 설계하면 경계가 흐려질 수 있습니다.

진짜로 숨기고 싶다면 더 작은 모듈로 분리하거나, public surface 자체를 별도로 관리하는 편이 낫습니다.

2. declaration-site variance는 자바에서 wildcard로 보일 수 있습니다

코틀린의 제네릭은 자바보다 표현력이 좋습니다. 하지만 자바 소비자에게는 와일드카드가 섞인 시그니처로 보일 수 있습니다.

class Box<out T>(val value: T)

open class Base
class Derived : Base()

fun readBase(box: Box<Base>): Base = box.value

자바에서는 이런 시그니처가 Box<? extends Base> 같은 형태로 보일 수 있습니다. 기능상 문제는 없지만, Java API로는 다소 낯설 수 있습니다.

자바 소비자가 많은 공개 API라면, 이 부분도 경계에서 조정할 수 있습니다.

fun readBase(box: Box<@JvmSuppressWildcards Base>): Base = box.value

물론 이런 annotation은 정말 필요한 자리에서만 쓰는 편이 좋습니다. 중요한 것은 문법 자체보다 자바 소비자에게 어떤 시그니처가 보이느냐입니다.

경계용 어댑터를 두면 코드가 급격히 읽기 좋아집니다

지금까지 본 문제는 사실 하나의 원칙으로 정리됩니다.

자바와 코틀린이 만나는 지점에 얇은 어댑터를 둔다.

아래처럼 자바 타입과 규칙을 서비스 안쪽까지 끌고 오면, 코드가 짧아도 의미는 흐립니다.

public class LegacyUserClient {
    public java.util.Optional<String> findEmail(long userId) throws java.io.IOException {
        return java.util.Optional.empty();
    }

    public java.util.List<String> loadRoles(long userId) {
        return java.util.List.of("ADMIN", "WRITER");
    }
}
data class UserSummary(
    val email: String?,
    val roles: List<String>,
)

class UserService(
    private val client: LegacyUserClient,
) {
    fun loadSummary(userId: Long): UserSummary {
        val email = client.findEmail(userId).get()
        val roles: MutableList<String> = client.loadRoles(userId)
        roles.add("TEMP")

        return UserSummary(
            email = email.lowercase(),
            roles = roles,
        )
    }
}

이 코드에는 문제가 여러 개 섞여 있습니다.

  • Optional.get()이 비어 있으면 바로 실패합니다.
  • IOException 정책이 함수 시그니처에 보이지 않습니다.
  • 리스트를 수정해도 되는지 소유권이 보이지 않습니다.
  • 서비스가 자바 API 세부사항을 너무 많이 알고 있습니다.

어댑터를 하나 두면 상황이 크게 달라집니다.

class LegacyUserGateway(
    private val client: LegacyUserClient,
) {
    fun findEmail(userId: Long): String? {
        return try {
            client.findEmail(userId).getOrNull()
        } catch (e: java.io.IOException) {
            throw UserSourceException("이메일 조회에 실패했습니다.", e)
        }
    }

    fun loadRoles(userId: Long): List<String> {
        return client.loadRoles(userId).toList()
    }
}

class UserSourceException(
    message: String,
    cause: Throwable,
) : RuntimeException(message, cause)
class UserService(
    private val gateway: LegacyUserGateway,
) {
    fun loadSummary(userId: Long): UserSummary {
        val email = gateway.findEmail(userId)

        return UserSummary(
            email = email?.lowercase(),
            roles = gateway.loadRoles(userId),
        )
    }
}

이제 서비스는 읽기 쉬워졌습니다.

  • null 가능성은 String?로 드러납니다.
  • 외부 시스템 실패는 도메인 예외로 번역됐습니다.
  • 컬렉션 소유권은 toList()로 분명해졌습니다.
  • 서비스는 자바 interop 세부사항을 몰라도 됩니다.

실무에서는 이런 얇은 gateway, adapter, bridge 레이어가 생각보다 큰 차이를 만듭니다.

코드가 길어지는 것처럼 보여도 실제로는 반대입니다. 복잡함을 한 곳에 몰아넣고, 나머지 코드를 단순하게 만드는 방식이기 때문입니다.

체크리스트

  • platform type를 어댑터 밖으로 내보내지 않고 있나요?
  • 자바 getter가 Kotlin에서 값처럼 보일 때, 실제 비용과 실패 가능성이 숨겨지지 않나요?
  • 자바 소비자가 많은 Kotlin API에 @JvmOverloads, @JvmStatic, @JvmField, @file:JvmName를 필요한 만큼만 적용했나요?
  • Java checked exception을 Kotlin 경계에서 잡아 번역하거나, Java에 노출할 때는 @Throws로 계약을 보여주고 있나요?
  • Java 컬렉션을 바로 수정하지 않고, toList() 또는 toMutableList()로 소유권을 분명히 하고 있나요?
  • Optional을 Kotlin 도메인 내부까지 끌고 오지 않고, nullable로 바꾸고 있나요?
  • internal을 자바의 package-private처럼 착각하고 있지 않나요?
  • 자바 소비자에게 보이는 제네릭 시그니처가 너무 복잡하면 @JvmSuppressWildcards 같은 조정을 검토했나요?

자주 헷갈리는 질문

자바 라이브러리를 Kotlin 스타일로 감싸는 것이 과한 것 아닌가요?

보통은 과하지 않습니다. 오히려 혼합 코드베이스에서는 가장 비용 대비 효과가 큰 정리 방법입니다.

모든 곳을 감싸라는 뜻은 아닙니다. null, 예외, Optional, 컬렉션 소유권처럼 의미가 바뀌는 지점만 감싸도 효과가 큽니다.

모든 public API에 @JvmOverloads@JvmStatic를 붙여야 하나요?

그렇지 않습니다. 자바에서 실제로 호출할 API에만 붙이는 편이 좋습니다.

경계 annotation을 습관처럼 남발하면 오히려 표면이 지저분해지고, Kotlin 내부 코드까지 자바 기준으로 끌려가기 쉽습니다.

Optional을 Kotlin에서 그대로 써도 되지 않나요?

경계에서 잠깐 쓰는 것은 괜찮습니다. 하지만 도메인 내부까지 들고 들어오는 순간 Kotlin의 nullable 타입 장점을 잃기 쉽습니다.

가능하면 getOrNull() 같은 변환으로 빨리 Kotlin 타입 체계 안으로 옮기는 편이 좋습니다.

마무리

코틀린과 자바를 함께 쓰는 프로젝트에서 클린코드가 깨지는 이유는 기술이 낡아서가 아닙니다. 경계를 설계하지 않았기 때문입니다.

platform type, getter/property 문법, default parameter, checked exception, 컬렉션, Optional, visibility, 와일드카드는 각각 별개의 주제처럼 보입니다. 하지만 실무에서는 모두 같은 질문으로 이어집니다.

이 차이를 어디서 번역할 것인가?

답은 대부분 같습니다. 자바와 코틀린이 만나는 자리입니다.

그 자리에서 nullability를 확정하고, 예외를 번역하고, 컬렉션 소유권을 정하고, 자바 소비자에게 보일 API 표면을 다듬으면 나머지 Kotlin 코드는 훨씬 단순해집니다.

혼합 코드베이스의 클린코드는 두 언어를 똑같이 쓰는 데서 나오지 않습니다. 경계에서 한 번 번역하고, 안쪽에서는 각 언어답게 쓰는 데서 나옵니다.

다음 12편에서는 지금까지 다룬 원칙을 실제 before & after 코드로 묶어서, 코틀린 리팩터링 실전 사례 형태로 정리하겠습니다.


코틀린 클린코드 시리즈 이어서 보기

이 글을 기준으로 앞뒤 흐름을 연결하면 내용이 더 잘 잡힙니다. 아래 글을 이어서 읽어보세요.

함께보면 좋은 글