Kotlin 사고방식 시리즈 (2/7)

이전 편: 1 Kotlin 이디엄

다음 편: 3 함수는 일급 시민이다

Java의 타입 시스템은 "이 변수에 어떤 종류의 값이 들어가는가"를 관리한다. Kotlin의 타입 시스템은 여기에 한 가지를 더 관리한다 — "이 변수가 null일 수 있는가." 이 차이 하나가 코드 작성 방식을 근본적으로 바꾼다.

하지만 Kotlin의 타입 시스템은 nullable만 있는 게 아니다. 제네릭의 공변/반공변, sealed class 계층, value class, Nothing 타입 등 Java보다 풍부한 표현력을 가지고 있다. 이것들을 이해하면 컴파일러가 더 많은 실수를 잡아주는 코드를 작성할 수 있다.

이 문서에서는 Kotlin 타입 시스템의 핵심 개념들을 Java와 비교하며 깊이 파본다.

타입 계층 구조

Java에서 모든 클래스의 최상위 타입은 Object다. Kotlin에서는 Any가 그 역할을 한다. 하지만 Kotlin의 타입 계층은 Java보다 구조가 더 명확하다.

graph TD Any["Any (모든 non-null 타입의 최상위)"] AnyN["Any? (모든 타입의 최상위)"] String["String"] Int["Int"] StringN["String?"] Nothing["Nothing (모든 타입의 최하위)"] NothingN["Nothing? (null만 가능)"] AnyN --> Any AnyN --> StringN Any --> String Any --> Int String --> Nothing Int --> Nothing StringN --> NothingN Nothing --> NothingN style Any fill:#e1f5fe style AnyN fill:#fff3e0 style Nothing fill:#fce4ec style NothingN fill:#fce4ec

핵심을 짚으면 이렇다.

  • Any — 모든 non-null 타입의 최상위. Java의 Object와 비슷하지만, primitive 타입도 포함한다.
  • Any? — null을 포함한 모든 타입의 진짜 최상위.
  • Nothing — 모든 타입의 최하위. "이 함수는 절대 정상 종료하지 않는다"를 표현한다.
  • Unit — Java의 void에 해당. "반환할 값이 없다"를 나타낸다.

Java에서는 Object가 최상위이면서 모든 것을 대표하지만, Kotlin은 nullable과 non-null을 타입 계층에서 분리한다. StringAny의 하위 타입이고, String?Any?의 하위 타입이다. 이 구분이 컴파일 타임에 null 안전성을 보장하는 기반이다.


Nothing 타입

Nothing은 Java에 없는 개념이라 처음에 와닿지 않을 수 있다. "값이 존재할 수 없는 타입"이다.

어떤 상황에서 쓰일까? 함수가 예외를 던지거나 무한 루프에 빠져서 절대 정상 반환하지 않을 때다.

fun fail(message: String): Nothing {
    throw BusinessException(message)
}

반환 타입이 Nothing이면 "이 함수를 호출한 뒤의 코드는 실행되지 않는다"는 뜻이다. 컴파일러가 이걸 알기 때문에, Nothing을 반환하는 함수 뒤에 도달 불가능한 코드를 쓰면 경고를 준다.

실용적으로 유용한 점은 Elvis 연산자와의 조합이다.

val user = userRepository.findByIdOrNull(id)
    ?: fail("사용자를 찾을 수 없습니다")

// 여기서 user는 User (non-null)로 추론됨
println(user.name)

fail()Nothing을 반환하기 때문에, Elvis 연산자의 오른쪽이 실행되면 함수가 끝난다는 것을 컴파일러가 안다. 따라서 ?: 이후 user는 자동으로 non-null로 추론된다.

Nothing은 타입 계층의 최하위(bottom type)다. 모든 타입의 하위 타입이기 때문에, 어떤 타입이 기대되는 자리에든 Nothing을 넣을 수 있다. throw가 표현식으로 쓰일 수 있는 이유가 이것이다.

val name: String = user?.name ?: throw NotFoundException()
// throw는 Nothing 타입이므로, String 자리에 올 수 있다

제네릭 — 공변과 반공변

Java에서 제네릭을 깊이 다루다 보면 ? extends T? super T를 만나게 된다. 쓸 때마다 헷갈리는 이 문법을, Kotlin은 outin으로 더 직관적으로 표현한다.

먼저 문제를 이해하자. DogAnimal의 하위 타입이라면, List<Dog>List<Animal>의 하위 타입일까?

open class Animal
class Dog : Animal()
class Cat : Animal()

// Dog는 Animal의 하위 타입
val animal: Animal = Dog()  // OK

// 그러면 List<Dog>도 List<Animal>의 하위 타입?
// val animals: List<Animal> = listOf(Dog())  // ?

Java에서 배열은 이걸 허용하고(Animal[] = new Dog[]), 그래서 런타임에 ArrayStoreException이 터질 수 있다. 제네릭에서는 이걸 막기 위해 기본적으로 불변(invariant)이다. List<Dog>List<Animal>은 아무 관계도 아니다.

하지만 "이 리스트에서 읽기만 한다"면 List<Dog>List<Animal> 자리에 써도 안전하다. 읽어서 나오는 게 Dog이고, DogAnimal이니까.


out (공변, covariant)

"이 타입 파라미터를 출력 위치(반환)에만 쓴다"는 선언이다.

interface Producer<out T> {
    fun produce(): T     // OK — T를 반환
    // fun consume(item: T)  // 컴파일 에러 — T를 파라미터로 받을 수 없음
}

out을 붙이면 Producer<Dog>Producer<Animal>의 하위 타입이 된다. 값을 꺼내기만 하니까 안전한 것이다.

Kotlin의 List<out E>가 대표적인 예다. List는 읽기 전용이므로 out이 붙어 있고, 그래서 List<Dog>List<Animal> 자리에 넣을 수 있다.

val dogs: List<Dog> = listOf(Dog(), Dog())
val animals: List<Animal> = dogs  // OK! List가 out이니까

Java로 표현하면 List<? extends Animal>에 해당한다. out 한 글자가 ? extends를 대체한다.


in (반공변, contravariant)

out의 반대다. "이 타입 파라미터를 입력 위치(파라미터)에만 쓴다"는 선언이다.

interface Consumer<in T> {
    fun consume(item: T)   // OK — T를 파라미터로 받음
    // fun produce(): T    // 컴파일 에러 — T를 반환할 수 없음
}

in을 붙이면 관계가 뒤집힌다. Consumer<Animal>Consumer<Dog>의 하위 타입이 된다. Animal을 먹을 수 있는 소비자는 Dog도 먹을 수 있으니까.

Java로 표현하면 Consumer<? super Dog>에 해당한다.


기억법

out = 출력(Output) = 꺼내기만 = ? extends = 생산자(Producer)
in = 입력(Input) = 넣기만 = ? super = 소비자(Consumer)

Java의 PECS(Producer-Extends, Consumer-Super) 규칙과 같은 개념이다. Kotlin은 이걸 out/in 두 키워드로 선언 지점에서 한 번만 명시하면 된다. Java처럼 사용 지점마다 ? extends를 쓸 필요가 없다.

선언 지점 변성 vs 사용 지점 변성

Kotlin의 out/in선언 지점 변성(declaration-site variance)이다. 클래스를 정의할 때 한 번만 지정하면 모든 사용 지점에 적용된다. Java의 ? extends/? super사용 지점 변성(use-site variance)으로, 쓸 때마다 매번 지정해야 한다. Kotlin도 사용 지점 변성을 지원하지만, 대부분 선언 지점에서 해결된다.


Sealed Class와 Sealed Interface

enum은 값의 종류를 제한하지만, 각 값이 서로 다른 데이터를 가질 수는 없다. sealed class는 이 한계를 넘는다.

sealed class ApiResult<out T> {
    data class Success<T>(val data: T) : ApiResult<T>()
    data class Error(val message: String, val code: Int) : ApiResult<Nothing>()
    data object Loading : ApiResult<Nothing>()
}
  • Success는 데이터를 담고, Error는 메시지와 코드를 담고, Loading은 아무것도 없다.
  • 각 하위 타입이 서로 다른 프로퍼티를 가질 수 있다. enum으로는 불가능한 구조다.

sealed class의 핵심 가치는 when에서 모든 경우를 컴파일 타임에 검증할 수 있다는 것이다.

fun <T> handleResult(result: ApiResult<T>): T? = when (result) {
    is ApiResult.Success -> result.data
    is ApiResult.Error -> throw BusinessException(result.message)
    is ApiResult.Loading -> null
    // else가 필요 없다 — 모든 하위 타입을 다뤘으니까
}

나중에 ApiResult에 새 하위 클래스(예: Timeout)를 추가하면, 이 when을 처리하지 않은 모든 곳에서 컴파일 에러가 난다. 실수로 빠뜨리는 것이 불가능하다.

Kotlin 1.5부터는 sealed interface도 지원한다. sealed class와 같은 기능이지만, 인터페이스이므로 다중 구현이 가능하다.

sealed interface Renderable {
    data class Text(val content: String) : Renderable
    data class Image(val url: String, val alt: String) : Renderable
    data class Code(val language: String, val source: String) : Renderable
}

실무에서 sealed class/interface는 주로 에러 타입 정의, API 응답 래핑, 상태 머신 등에 쓰인다. enum보다 유연하고, 일반 상속보다 안전하다.


Value Class (Inline Class)

ID를 Long으로 쓰다 보면, 서로 다른 도메인의 ID를 혼동할 수 있다.

fun findPost(postId: Long): Post { /* ... */ }
fun findUser(userId: Long): User { /* ... */ }

val postId: Long = 42
val userId: Long = 42
findPost(userId)  // 컴파일은 되지만 논리적으로 틀림!

value class는 이런 문제를 런타임 오버헤드 없이 해결한다.

@JvmInline
value class PostId(val value: Long)

@JvmInline
value class UserId(val value: Long)

fun findPost(postId: PostId): Post { /* ... */ }
fun findUser(userId: UserId): User { /* ... */ }

val postId = PostId(42)
val userId = UserId(42)
// findPost(userId)  // 컴파일 에러! 타입이 다르다
  • value class는 컴파일 시 래퍼가 제거되고, 내부 값(Long)이 직접 사용된다.
  • 런타임에는 Long과 동일한 성능이지만, 컴파일 타임에는 타입 안전성을 보장한다.

비유하자면 "이름표를 붙이는 것"이다. Long이라는 같은 상자에 "이건 PostId야", "이건 UserId야"라는 이름표를 붙여서 실수로 바꿔 쓰는 것을 막는다. 이름표를 붙이는 비용(런타임 오버헤드)은 0이다.

value class의 제약

value class는 프로퍼티를 하나만 가질 수 있고, var는 안 된다. 상속도 불가하지만 인터페이스 구현은 가능하다. 모든 곳에서 인라인되는 건 아니고, nullable이나 제네릭 컨텍스트에서는 boxing이 발생할 수 있다.


심화 분석

플랫폼 타입

Java 코드에서 오는 타입은 Kotlin이 nullable인지 non-null인지 알 수 없다. 이런 타입을 플랫폼 타입이라 하고, IDE에서 String! 처럼 느낌표로 표시된다.

// Java 메서드
public String getName() { return name; }

// Kotlin에서 호출하면
val name = javaObject.getName()   // name의 타입은 String! (플랫폼 타입)

플랫폼 타입은 위험하다. Kotlin 컴파일러가 null 체크를 강제하지 않기 때문에, Java 메서드가 null을 반환하면 런타임에 NPE가 터진다.

방어하는 방법은 명시적 타입 선언이다.

val name: String = javaObject.getName()   // non-null로 선언 — null 오면 즉시 예외
val name: String? = javaObject.getName()  // nullable로 선언 — 안전하게 처리
  • non-null로 선언하면 Java 메서드가 null을 반환하는 순간 대입 지점에서 바로 예외가 터진다. 원인 추적이 쉽다.
  • nullable로 선언하면 이후 ?.?:로 안전하게 처리할 수 있다.

어떤 방식이든, 플랫폼 타입을 그대로 두지 말고 명시적으로 타입을 지정하는 것이 중요하다. 이 주제는 5 Null Safety 완전 정복에서 더 깊이 다룬다.

스마트 캐스트와 타입 체크

is 체크 후 자동으로 캐스팅되는 스마트 캐스트는 Kotlin의 타입 시스템이 흐름 분석(flow analysis)을 한다는 뜻이다.

fun process(result: ApiResult<Post>) {
    if (result is ApiResult.Success) {
        // 여기서 result는 자동으로 ApiResult.Success<Post>
        println(result.data.title)
    }
}

Java에서는 instanceof 체크 후 명시적 캐스팅이 필요했다. Kotlin은 컴파일러가 "이 시점에서 이 변수의 타입이 뭔지"를 추적하기 때문에 캐스팅이 자동이다. 이것은 when에서 sealed class를 다룰 때 특히 유용하다.


자주 하는 실수

제네릭에서 out/in 혼동

// "이 리스트에서 꺼내기만 한다" → out
fun printAll(items: List<out Animal>) { /* ... */ }

// "이 리스트에 넣기만 한다" → in
fun addDog(items: MutableList<in Dog>) { /* ... */ }

읽기 전용이면 out, 쓰기 전용이면 in이다. 헷갈리면 "이 파라미터에서 T를 꺼내는가(out), 넣는가(in)" 를 기준으로 판단하자.

sealed class의 하위 클래스를 다른 파일에 선언

sealed class의 하위 클래스는 같은 패키지 안에 있어야 한다 (Kotlin 1.5+, 이전에는 같은 파일). 다른 패키지에 선언하면 컴파일 에러가 발생한다. sealed의 의미 자체가 "확장을 제한한다"이기 때문이다.

value class를 JPA 엔티티 ID로 사용

value class를 JPA 엔티티의 ID 타입으로 쓰면 Hibernate가 제대로 처리하지 못할 수 있다. JPA 레이어에서는 Long을 쓰고, 도메인 레이어에서만 value class를 쓰는 것이 현실적인 타협안이다.