Kotlin 사고방식 (5/7)

이전 편: [Kotlin] 4. 컬렉션의 모든 것

다음 편: [Kotlin] 6. DSL 설계

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

이전 편: 4 컬렉션의 모든 것

다음 편: 6 DSL 설계

문법 복습에서 ?., ?:, !!의 기본 사용법을 다뤘다. 이 문서에서는 그 이면의 원리와, 실무에서 마주치는 null safety가 무너지는 순간들을 깊이 파본다.

Kotlin의 null safety는 완벽하지 않다. 특히 Java 코드와 섞이는 순간 안전망에 구멍이 뚫린다. 플랫폼 타입이 그 대표적인 사례다. 이 구멍을 인식하고, 방어하는 전략을 아는 것이 Kotlin null safety를 "정복"하는 것이다.

스마트 캐스트의 동작 원리

Kotlin 컴파일러는 제어 흐름을 분석해서, 특정 지점에서 변수의 타입을 더 좁은 타입으로 추론한다. 이것이 스마트 캐스트다.

fun process(value: String?) {
    if (value == null) return

    // 여기부터 value는 String (non-null)로 추론
    println(value.length)  // ?. 없이 접근 가능
}

컴파일러의 사고 흐름은 이렇다. "value가 null이면 함수가 종료된다. 따라서 이 지점에 도달했다면 value는 null이 아니다."

스마트 캐스트는 if, when, &&, || 등 다양한 제어 흐름에서 작동한다.

// && 연산자에서도 동작
if (value != null && value.length > 5) {
    // value는 String (non-null)
}

// when에서 타입 체크
when (result) {
    is Success -> result.data    // result가 Success로 스마트 캐스트
    is Error -> result.message   // result가 Error로 스마트 캐스트
}

스마트 캐스트가 안 되는 경우

스마트 캐스트는 "이 값이 체크 이후로 바뀌지 않았다"는 보장이 있어야 동작한다. 바뀔 수 있는 상황에서는 컴파일러가 캐스팅을 거부한다.

class MyService {
    var name: String? = null

    fun printName() {
        if (name != null) {
            // println(name.length)  // 컴파일 에러!
            // 다른 스레드가 name을 null로 바꿀 수 있다
        }
    }
}

var 프로퍼티는 언제든 값이 바뀔 수 있기 때문에, null 체크 이후에도 non-null로 추론하지 않는다. 해결 방법은 지역 변수에 복사하는 것이다.

fun printName() {
    val localName = name       // 지역 변수에 복사
    if (localName != null) {
        println(localName.length)  // OK! 지역 변수는 다른 스레드가 못 바꾼다
    }
}

또는 let을 쓰면 더 간결하다.

fun printName() {
    name?.let { println(it.length) }
}

스마트 캐스트가 안 되는 대표적인 경우를 정리하면 이렇다.

  • var 프로퍼티 — 다른 코드에서 값을 바꿀 수 있다.
  • open 프로퍼티 — 하위 클래스에서 getter를 오버라이드할 수 있다.
  • 위임 프로퍼티(by) — getter가 호출마다 다른 값을 반환할 수 있다.

플랫폼 타입 — null safety의 구멍

Kotlin의 null safety는 Kotlin 코드 안에서만 완전하다. Java 코드에서 오는 값은 nullable인지 non-null인지 Kotlin 컴파일러가 알 수 없다. 이런 타입을 플랫폼 타입이라 하고, IDE에서 String!으로 표시된다.

// Java 코드
public class UserRepository {
    public String findNameById(Long id) {
        // null을 반환할 수도 있고 아닐 수도 있다
        return userMap.get(id);
    }
}

이 Java 메서드를 Kotlin에서 호출하면 반환 타입이 String!(플랫폼 타입)이 된다. 컴파일러가 null 가능성을 모르기 때문에 아무 경고 없이 통과하고, 실행 시에야 문제가 드러난다.

val name = userRepo.findNameById(1L)   // name의 타입은 String! (플랫폼 타입)
println(name.length)                    // null이면 NPE!

플랫폼 타입은 non-null로도 nullable로도 쓸 수 있다. 컴파일러가 아무 경고도 하지 않는다. Kotlin의 null safety가 무력화되는 유일한 지점이다.


방어 전략

플랫폼 타입을 안전하게 다루는 원칙은 하나다. 받는 즉시 타입을 명시하라.

// 방법 1: non-null로 선언 — null이 오면 대입 시점에서 즉시 예외
val name: String = userRepo.findNameById(1L)

// 방법 2: nullable로 선언 — 안전하게 처리
val name: String? = userRepo.findNameById(1L)
val displayName = name ?: "알 수 없음"

방법 1은 "이 값은 null이 오면 안 된다"는 계약이다. null이 오면 대입 시점에서 바로 IllegalStateException이 터진다. 나중에 엉뚱한 곳에서 NPE가 터지는 것보다 원인 추적이 훨씬 쉽다.

방법 2는 "null이 올 수 있다"고 인정하고 안전하게 처리하는 것이다.

어떤 방법이든, 플랫폼 타입을 그대로 전파하지 않는 것이 핵심이다.


Java 라이브러리의 null 어노테이션

Java 코드에 @Nullable, @NonNull 어노테이션이 붙어 있으면 Kotlin 컴파일러가 이걸 인식한다.

@NonNull
public String getName() { return name; }

@Nullable
public String getNickname() { return nickname; }

이 어노테이션이 있으면 Kotlin 컴파일러가 플랫폼 타입 대신 정확한 nullable/non-null 타입으로 추론한다.

val name: String = user.getName()      // non-null로 추론 — @NonNull 덕분
val nickname: String? = user.getNickname()  // nullable로 추론 — @Nullable 덕분

Spring Framework의 API는 대부분 이 어노테이션이 잘 붙어 있어서, 코프링에서는 플랫폼 타입 문제가 상대적으로 적다. 하지만 서드파티 라이브러리나 직접 작성한 Java 코드에는 없을 수 있으니 주의해야 한다.


null 처리 패턴

실무에서 null을 다루는 대표적인 패턴들을 정리한다.

조기 반환 (Early Return)

fun getPostTitle(id: Long): String {
    val post = postRepository.findByIdOrNull(id)
        ?: return "제목 없음"
    val category = post.category
        ?: return post.title
    return "[${category.name}] ${post.title}"
}

Elvis 연산자 + return으로 null인 경우를 먼저 처리하고 빠져나간다. 코드가 깊은 중첩 없이 평평하게 유지된다.


연쇄 safe call

val cityName = user?.address?.city?.name ?: "알 수 없음"

체인 중간에 하나라도 null이면 전체가 null이 되고, Elvis 연산자가 기본값을 제공한다.


require, check, error

Kotlin 표준 라이브러리는 null 체크를 위한 함수들을 제공한다.

fun updatePost(id: Long, request: PostUpdateRequest) {
    val post = postRepository.findByIdOrNull(id)

    requireNotNull(post) { "ID $id 에 해당하는 글이 없습니다" }
    // 여기부터 post는 non-null

    check(post.isPublished) { "발행된 글만 수정할 수 있습니다" }

    post.title = request.title
}
  • requireNotNull — null이면 IllegalArgumentException. 입력값 검증용.
  • checkNotNull — null이면 IllegalStateException. 상태 검증용.
  • errorIllegalStateException을 던진다. "여기에 도달하면 안 된다"는 표시.

이 함수들은 모두 Nothing을 반환하므로, 호출 이후의 코드에서 스마트 캐스트가 적용된다.


let + flatMap 패턴

여러 nullable 값을 조합해야 할 때 유용한 패턴이다.

fun createFullAddress(city: String?, street: String?, zipCode: String?): String? {
    return city?.let { c ->
        street?.let { s ->
            zipCode?.let { z ->
                "$c $s ($z)"
            }
        }
    }
}

세 값이 모두 non-null일 때만 결합한다. 하나라도 null이면 전체가 null이다. 하지만 중첩이 깊어지면 가독성이 떨어진다. 이런 경우 일반 if로 풀어쓰는 게 나을 수 있다.

fun createFullAddress(city: String?, street: String?, zipCode: String?): String? {
    if (city == null || street == null || zipCode == null) return null
    return "$city $street ($zipCode)"
}

어떤 스타일이 더 좋은지는 상황에 따라 다르다. 핵심은 가독성이 최우선이라는 것이다.


심화 분석

Kotlin에서 NPE가 발생하는 경우

Kotlin을 쓰면 NPE가 완전히 사라질까? 아니다. 다음 상황에서는 여전히 발생할 수 있다.

  1. !! 사용 — 가장 흔한 원인. 개발자가 직접 null safety를 포기한 것.
  2. 플랫폼 타입 — Java 코드에서 null이 넘어오는 경우.
  3. lateinit 초기화 전 접근UninitializedPropertyAccessException (엄밀히 NPE는 아니지만 비슷한 문제).
  4. Java 리플렉션 — Kotlin 타입 시스템을 우회해서 null을 주입하는 경우.
  5. 동시성 — 멀티스레드에서 non-null 변수가 null로 바뀌는 레이스 컨디션.

1번과 2번이 실무에서 가장 빈번하다. !!를 쓰지 않고, 플랫폼 타입을 명시적으로 선언하는 것만으로도 대부분의 NPE를 예방할 수 있다.

null을 반환하는 API 설계

함수를 설계할 때 null을 반환하는 것이 적절한 경우와 아닌 경우가 있다.

null 반환이 적절한 경우

  • "해당 데이터가 존재하지 않을 수 있다"는 의미가 명확할 때
  • 예: findByIdOrNull(id) → ID에 해당하는 엔티티가 없으면 null

null 대신 다른 방법이 나은 경우

  • 빈 컬렉션을 반환할 수 있을 때 → emptyList() 반환
  • 에러 상황일 때 → 예외를 던지거나 Result/sealed class 사용
  • 기본값이 있을 때 → 기본값을 직접 반환

원칙은 "null은 '없음'을 표현할 때만 쓴다. 에러나 예외 상황에는 null 대신 예외를 던져라."


자주 하는 실수

!! 사용 습관

// 나쁜 예 — "이건 null 안 되니까 !!"
val user = userRepository.findByIdOrNull(id)!!

// 좋은 예 — Elvis + 의미 있는 예외
val user = userRepository.findByIdOrNull(id)
    ?: throw NotFoundException("사용자를 찾을 수 없습니다: $id")

!!로 터진 NPE는 스택트레이스에 원인이 드러나지 않는다. Elvis + throw어떤 값이 왜 null이었는지 메시지로 알려준다.

nullable을 과도하게 사용

// 나쁜 예 — 모든 게 nullable
data class PostResponse(
    val id: Long?,
    val title: String?,
    val content: String?
)

// 좋은 예 — 정말 null일 수 있는 것만
data class PostResponse(
    val id: Long,
    val title: String,
    val content: String,
    val thumbnail: String?    // 이것만 진짜 null 가능
)

"혹시 null이 올 수도 있으니까"라며 모든 것을 nullable로 선언하면, 사용하는 쪽에서 매번 null 체크를 해야 한다. null이 될 수 없는 값은 non-null로 선언하는 것이 올바른 계약이다.

플랫폼 타입을 그대로 전파

// 나쁜 예 — 플랫폼 타입이 그대로 전파됨
fun getUserName(id: Long) = javaService.findUser(id).getName()
// 반환 타입이 String! → 호출하는 쪽도 불안

// 좋은 예 — 타입을 명시해서 경계를 끊음
fun getUserName(id: Long): String {
    return javaService.findUser(id).getName()
        ?: throw IllegalStateException("사용자 이름이 null입니다")
}

Java 코드와의 경계에서 타입을 명시하면, 플랫폼 타입이 Kotlin 코드 안으로 흘러들어오는 것을 막을 수 있다.