시스템아 미안해

Chapter 1: Safety - Item 5: Specify your expectations for arguments and state 본문

책/Effective Kotlin

Chapter 1: Safety - Item 5: Specify your expectations for arguments and state

if else 2026. 1. 22. 17:25

기대 조건(expectations)이 있다면, 가능한 한 빨리 선언해야 합니다.
Kotlin에서는 이를 주로 다음과 같은 방식으로 표현합니다.

  • require 블록:
    함수 인자(arguments) 에 대한 기대 조건을 명시하는 범용적인 방법입니다.
  • check 블록:
    객체나 애플리케이션의 상태(state) 에 대한 기대 조건을 명시하는 범용적인 방법입니다.
  • error 함수:
    애플리케이션이 절대로 도달해서는 안 되는 상태에 도달했음을 명확히 알릴 때 사용합니다.
  • 엘비스 연산자(?:)와 return 또는 throw의 조합:
    값이 없을 때 즉시 반환하거나 예외를 던져 흐름을 중단합니다.

다음은 이러한 메커니즘들을 활용한 예제입니다.

 
 
kotlin
// Part of Stack<T>
fun pop(num: Int = 1): List<T> {
    require(num <= size) {
        "Cannot remove more elements than current size"
    }
    check(isOpen) { "Cannot pop from closed stack" }
    val ret = collection.take(num)
    collection = collection.drop(num)
    return ret
}

이와 같은 방식으로 기대 조건을 코드에 명시한다고 해서,
문서에 이를 따로 명시할 필요가 없어지는 것은 아닙니다.
그럼에도 불구하고, 이러한 방식은 매우 큰 도움이 됩니다.

 

이처럼 선언적인 검사(declarative checks) 는 다음과 같은 여러 장점을 가집니다.

  • 기대 조건이 문서를 읽지 않은 개발자에게도 코드만으로 명확히 드러납니다.
  • 기대 조건이 충족되지 않을 경우,
    예상치 못한 동작으로 이어지는 대신 즉시 예외가 발생합니다.
    특히 중요한 점은, 이러한 예외가 상태가 변경되기 전에 발생한다는 것입니다.
    이는 일부 변경만 적용되고 나머지는 적용되지 않는 상황을 방지해 주며,
    이런 상황은 매우 위험하고 관리하기 어렵습니다.
    단언적인 검사(assertive checks) 덕분에 오류를 놓치는걸 방지하고,
    애플리케이션의 상태도 훨씬 안정적으로 유지됩니다.
  • 코드는 어느 정도 자기 검증(self-checking) 의 역할을 하게 됩니다.
    이러한 조건들이 코드에 직접 검사되어 있다면,
    단위 테스트에 대한 의존도도 줄어들 수 있습니다.
  • 위에 나열된 모든 검사는 스마트 캐스트(smart cast) 와 함께 동작하므로,
    불필요한 캐스팅이 줄어듭니다.

이제 각각의 검사 종류와, 왜 이러한 검사들이 필요한지에 대해 이야기해 보겠습니다.
가장 널리 사용되는 것부터 시작해 보죠. 인자(arguments) 검사입니다.

Arguments

함수를 정의할 때, 타입 시스템만으로는 표현할 수 없는 기대 조건을 인자(arguments)에 요구하는 경우는 매우 흔합니다.
몇 가지 예를 들어 보겠습니다.

  • 어떤 수의 팩토리얼을 계산할 때,
    해당 수가 양의 정수여야 한다는 조건이 필요할 수 있습니다.
  • 클러스터를 찾는 로직에서는,
    포인트 리스트가 비어 있지 않아야 한다는 조건이 필요할 수 있습니다.
  • 이메일을 전송할 때는,
    이메일 주소가 유효한 형식이어야 한다는 조건이 필요할 수 있습니다.

이러한 요구 사항을 Kotlin에서 가장 보편적이고 직접적으로 표현하는 방법
require 함수를 사용하는 것입니다.

 

require는 해당 조건을 검사하고,
조건이 만족되지 않을 경우 IllegalArgumentException을 발생시킵니다.

 
 
kotlin
fun factorial(n: Int): Long {
    require(n >= 0)
    return if (n <= 1) 1 else factorial(n - 1) * n
}

fun findClusters(points: List<Point>): List<Cluster> {
    require(points.isNotEmpty())
    //...
}

fun sendEmail(user: User, message: String) {
    requireNotNull(user.email)
    require(isValidEmail(user.email))
    //...
}

 

이러한 요구 조건들은 함수의 맨 앞부분에 선언되기 때문에 매우 눈에 잘 띄며,
그 결과 해당 함수를 읽는 사용자에게도 명확하게 전달됩니다
(물론 모든 사람이 함수 본문을 읽는 것은 아니므로, 문서에도 함께 명시해야 합니다).

 

이러한 기대 조건들은 무시될 수 없습니다.
require 함수는 조건이 만족되지 않을 경우 즉시 예외를 발생시키기 때문입니다.
이 블록이 함수의 시작 부분에 위치해 있으면,
인자가 올바르지 않을 경우 함수는 즉시 실행을 중단하며,
사용자는 자신이 이 함수를 잘못 사용하고 있다는 사실을 놓치지 않게 됩니다.

 

이처럼 예외는,
이상한 결과가 한참 뒤까지 전파되다가 나중에야 실패하는 경우와 달리,
문제가 발생했음을 즉시 명확하게 드러내 줍니다.

 

다시 말해, 함수의 시작 부분에서 인자에 대한 기대 조건을 올바르게 명시해 두면,
그 이후의 코드에서는 이 조건들이 반드시 만족된다고 가정하고 작성할 수 있습니다.

 

또한 require 호출 뒤에 람다 표현식을 전달하여,
예외에 사용할 메시지를 지연(lazy) 방식으로 지정할 수도 있습니다.

 
 
kotlin
fun factorial(n: Int): Long {
    require(n >= 0) {
        "Cannot calculate factorial of $n " +
        "because it is smaller than 0"
    }
    return if (n <= 1) 1 else factorial(n - 1) * n
}

 

Data class의 init block 내부에서 require를 자주 볼 수 있습니다. 이는 모든 primary constructor 인수가 올바른지 확인하여, 요구사항에 따라 잘못된 인스턴스를 생성하는 것을 불가능하게 만드는 데 사용됩니다:

 
 
kotlin
data class User(
    val name: String,
    val email: String
) {
    init {
        require(name.isNotEmpty())
        require(isValidEmail(email))
    }
}

require 함수는 함수 인자(arguments)에 대한 요구 조건을 명시할 때 사용합니다.
또 다른 흔한 경우는 현재 상태(state)에 대한 기대 조건이 있을 때인데,
이러한 경우에는 require 대신 check 함수를 사용합니다.

 

check 함수는 조건이 만족되지 않을 경우
IllegalStateException을 발생시킵니다.

 

 

State

특정 함수가 특정 조건에서만 호출되도록 허용되는 경우는 매우 흔합니다.
대표적인 예는 다음과 같습니다.

  • 어떤 함수는 객체가 먼저 초기화되어 있어야만 사용할 수 있습니다.
  • 어떤 동작은 사용자가 로그인한 경우에만 허용될 수 있습니다.
  • 어떤 함수는 객체가 열린(open) 상태일 때만 호출될 수 있습니다.

이처럼 상태(state)에 대한 기대 조건이 충족되었는지 확인하는 표준적인 방법
check 함수를 사용하는 것입니다.

 
 
kotlin
fun speak(text: String) {
    check(isInitialized)
    //...
}

fun getUserInfo(): UserInfo {
    checkNotNull(token)
    //...
}

fun next(): T {
    check(isOpen)
    //...
}

 

check 함수는 require와 유사하게 동작하지만,
명시한 기대 조건이 충족되지 않았을 경우 IllegalStateException을 발생시킵니다.
즉, 현재 상태(state)가 올바른지 검사하는 용도입니다.
require와 마찬가지로, 지연 메시지(lazy message) 를 사용해 예외 메시지를 커스터마이즈할 수도 있습니다.

 

기대 조건이 함수 전체에 적용되는 경우에는, 보통 함수의 맨 앞부분,
그리고 일반적으로 require 블록들 이후에 배치합니다.
다만 일부 상태 기대 조건은 국소적인 경우도 있기 때문에,
이럴 때는 함수 중간에서 check를 사용하기도 합니다.

 

이러한 검사는 특히 사용자가 계약(contract)을 어기고,
호출되면 안 되는 시점에 함수를 호출할 가능성이 있다고 의심될 때
유용합니다.
“설마 그러지 않겠지”라고 믿기보다는,
명시적으로 검사하고 적절한 예외를 던지는 편이 훨씬 안전합니다.

 

또한, 우리 자신의 구현이 상태를 올바르게 처리하고 있는지 확신할 수 없을 때
명시적인 check를 사용할 수 있습니다.
다만 이런 경우, 즉 주로 구현을 검증하기 위한 목적의 검사라면,
이를 위해 따로 제공되는 assert 함수를 사용하는 것이 더 적절합니다.

Nullability and smart casting

require와 check 함수는 모두 Kotlin 계약(contracts) 을 가지고 있습니다.
이 계약은 해당 함수가 정상적으로 반환되었다면,
그 시점 이후로는 검사에 사용된 조건(predicate)이 반드시 참이라는 것을 보장한다는 의미입니다.

 
public inline fun require(value: Boolean): Unit {
    2 contract {
    3 	returns() implies value
    4 }
    5 require(value) { "Failed requirement." }
6 }

 

이러한 블록들(require, check)에서 검사된 모든 조건은,
같은 함수 안에서는 이후에 항상 참인 것으로 취급됩니다.

 

이는 스마트 캐스트(smart cast) 와 매우 잘 어울립니다.
어떤 조건이 참임을 한 번 확인하면,
컴파일러는 그 사실을 확정된 정보로 간주합니다.

 

아래 예제에서는 사람의 옷(outfit)이 Dress 타입임을 require로 검사합니다.
그 이후에는, 해당 outfit 프로퍼티가 final 이라고 가정할 때,
컴파일러가 이를 자동으로 Dress 타입으로 스마트 캐스트해 줍니다.

 
fun changeDress(person: Person) {
    require(person.outfit is Dress)
    val dress: Dress = person.outfit
    //...
}

 

이 특성은 특히 무언가가 null인지 확인할 때 유용합니다:

 
kotlin
class Person(val email: String?)

fun sendEmail(person: Person, message: String) {
    require(person.email != null)
    val email: String = person.email
    //...
}

이러한 경우를 위해 전용 함수도 제공됩니다. 바로
requireNotNull과 checkNotNull입니다.

이 두 함수는 다음과 같은 특징을 가지고 있습니다.

  • 널이 아님을 보장해 주며, 이후 코드에서 해당 변수를 스마트 캐스트할 수 있게 해 줍니다.
  • 단순한 검사 용도뿐 아니라, 표현식(expression) 으로도 사용할 수 있어
    널 값을 “언패킹(unpack)”하는 데에도 활용할 수 있습니다.

즉, 이 함수들을 사용하면
널 체크와 동시에 안전하게 널이 아닌 값으로 다룰 수 있는 상태를 만들 수 있습니다.

 
kotlin
class Person(val email: String?)

fun validateEmail(email: String) { /*...*/ }

fun sendEmail(person: Person, text: String) {
    val email = requireNotNull(person.email)
    validateEmail(email)
    //...
}

fun sendEmail(person: Person, text: String) {
    requireNotNull(person.email)
    validateEmail(person.email)
    //...
}

The problems with the non-null assertion !!

requireNotNull이나 checkNotNull 대신에
널 아님 단언 연산자 !! 를 사용할 수도 있습니다.
이는 개념적으로 Java에서의 방식과 매우 유사합니다.
즉, “이 값은 널이 아니다”라고 우리가 믿고,
그 믿음이 틀렸을 경우 NPE가 발생하는 구조입니다.

 

하지만 !! 연산자는 매우 소극적(lazy)인 선택입니다.
이 연산자는 아무 설명도 없는 일반적인 NullPointerException 만을 던지며,
짧고 간단하다는 이유로 남용되거나 잘못 사용되기 쉽습니다.

 

!!는 보통 타입은 널 허용(nullable)인데, 실제로는 널이 오지 않을 것이라고 기대하는 상황에서 사용됩니다.
문제는, 지금은 널이 오지 않는다고 생각하더라도,
미래에는 거의 항상 널이 들어올 가능성이 생긴다는 점입니다.
이 연산자는 그런 위험을 해결하는 대신,
널 가능성을 조용히 숨겨 버릴 뿐입니다.

 

아주 단순한 예로, 4개의 인자 중 가장 큰 값을 찾는 함수를 생각해 보겠습니다.
이를 구현하기 위해 네 개의 인자를 리스트에 담고,
maxOrNull을 사용해 최댓값을 찾기로 했다고 가정해 보겠습니다.

 

문제는 maxOrNull이 컬렉션이 비어 있을 경우 null을 반환하기 때문에
반환 타입이 널 허용이라는 점입니다.
이 리스트가 비어 있을 수 없다는 사실을 알고 있는 개발자라면,
아마도 다음과 같이 !!를 사용하고 싶어질 것입니다.

 
kotlin
fun largestOf(a: Int, b: Int, c: Int, d: Int): Int =
    listOf(a, b, c, d).maxOrNull()!!

 

이 책의 리뷰어인 Márton Braun이 지적해 주었듯이,
널 아님 단언 연산자 !!는 이렇게 단순한 함수에서도 NPE를 유발할 수 있습니다.

 

예를 들어, 누군가 이 함수를 임의의 개수의 인자를 받을 수 있도록 리팩터링해야 할 수도 있습니다.
하지만 그 과정에서,
maxOrNull을 사용하기 위해 컬렉션이 비어 있지 않아야 한다는 사실을 깜빡한다면,
다음과 같은 문제가 발생할 수 있습니다.

 

 
kotlin
fun largestOf(vararg nums: Int): Int =
    nums.maxOrNull()!!

largestOf() // NPE

 

위의 예제에서는 널 가능성에 대한 정보가 숨겨져 버렸고,
정작 중요한 순간에 쉽게 놓칠 수 있는 상태가 되었습니다.
이런 상황은 변수에서도 마찬가지로 발생할 수 있습니다.

 

예를 들어, 나중에 반드시 값이 설정될 것이고,
첫 사용 이전에는 확실히 초기화된다고 알고 있는 변수가 있다고 가정해 보겠습니다.
이때 그 변수를 null로 초기화한 뒤, 사용할 때마다 널 아님 단언 연산자 !!를 사용하는 것은 좋은 선택이 아닙니다.

 

이 방식에는 몇 가지 문제가 있습니다.

  • 매번 해당 프로퍼티를 사용할 때마다 언패킹을 해야 해서 번거롭습니다.
  • 무엇보다도, 미래에 이 프로퍼티가 의미 있는 null 값을 가질 가능성 자체를 차단해 버립니다.

즉, 지금은 “반드시 값이 있다”고 가정하더라도,
!!에 의존하는 설계는 확장성과 안전성 모두에서 좋지 않은 선택이 됩니다.

kotlin
class UserControllerTest {
    private var dao: UserDao? = null
    private var controller: UserController? = null
    
    @BeforeEach
    fun init() {
        dao = mockk()
        controller = UserController(dao!!)
    }
    
    @Test
    fun test() {
        controller!!.doSomething()
    }
}

코드가 미래에 어떻게 변화할지는 누구도 예측할 수 없습니다.
따라서 널 아님 단언 연산자 !!를 사용하거나,
명시적으로 error를 던지는 코드를 작성한다면,
언젠가는 반드시 예외가 발생할 것이라고 가정해야 합니다.

 

예외는 본래 예상하지 못한 잘못된 상황을 알리기 위해 존재합니다
(아이템 7: 결과가 없을 수 있다면 null 또는 sealed result class를 선호하라).
다만, 의미 있는 명시적 오류 메시지
아무 설명도 없는 일반적인 NPE보다 훨씬 많은 정보를 전달하므로,
대부분의 경우 명시적인 오류를 사용하는 편이 훨씬 바람직합니다.

 

!! 연산자가 정당화될 수 있는 매우 드문 경우는,
주로 널 안정성이 제대로 표현되지 않은 라이브러리와의 상호 운용성 문제에서 발생합니다.
Kotlin을 고려해 올바르게 설계된 API와 상호작용하는 경우라면,
이 연산자를 사용하는 일은 일반적이어서는 안 됩니다.

 

전반적으로, 널 아님 단언 연산자 !!는 피하는 것이 좋습니다.
이 권장 사항은 커뮤니티에서도 널리 받아들여지고 있으며,
일부 팀에서는 이를 정책으로 금지하기도 합니다.
실제로 Detekt와 같은 정적 분석 도구를 설정해
!! 사용 시 오류를 발생시키는 팀도 있습니다.

 

개인적으로 이런 접근이 다소 과도하다고는 생각하지만,
!!가 코드 스멜(code smell) 인 경우가 많다는 점에는 동의합니다.
이 연산자의 모습 자체가 마치
조심하라”, “여기 뭔가 문제가 있다”라고 외치고 있는 것처럼 보이기 때문입니다.

 

!! 사용을 피하려면, 의미 없는 널 가능성(nullability)을 만들지 않는 것이 중요합니다.
앞서 예로 든 상황에서는,
!! 대신 lateinit 이나 Delegates.notNull 을 사용하는 것이 바람직합니다.

  • lateinit
    해당 프로퍼티가 첫 사용 전에 반드시 초기화된다는 것이 확실할 때 좋은 선택입니다.

이러한 패턴은 보통 클래스에 생명주기(lifecycle)가 존재하는 경우에 자주 등장하며,
초기에 호출되는 메서드 중 하나에서 프로퍼티를 설정합니다. 예를 들면 다음과 같습니다.

  • Android Activity의 onCreate
  • iOS UIViewController의 viewDidAppear
  • React의 React.Component에서 componentDidMount

이처럼 의도가 명확한 초기화 방식을 사용하면,
불필요한 !!를 제거하면서도 안전하고 확장 가능한 코드를 유지할 수 있습니다.

 
kotlin
class UserControllerTest {
    private lateinit var dao: UserDao
    private lateinit var controller: UserController
    
    @BeforeEach
    fun init() {
        dao = mockk()
        controller = UserController(dao!!)
    }
    
    @Test
    fun test() {
        controller.doSomething()
    }
}

lateinit 프로퍼티가 초기화되었는지 여부는 언제든지 확인할 수 있다는 점을 알아두시면 좋습니다.
프로퍼티 참조를 사용해 isInitialized 속성에 접근하면 됩니다.

 

예를 들어, 위의 예제에서는 다음과 같이 작성하여
dao가 초기화되었는지 확인할 수 있습니다.

 
::dao.isInitialized
 

이 방법을 사용하면, lateinit 프로퍼티를 사용하기 전에
안전하게 초기화 여부를 검사할 수 있습니다.

 

 

Using Elvis operator

널 가능성(nullability)을 처리할 때는,
엘비스 연산자(?:)의 오른쪽에 throw나 return을 사용하는 방식도 매우 널리 쓰입니다.
이 구조는 가독성이 높고,
상황에 따라 어떤 동작을 할지 유연하게 선택할 수 있다는 장점이 있습니다.

 

무엇보다도, 예외를 던지는 대신 return으로 함수 실행을 깔끔하게 종료할 수도 있습니다.
예를 들어 다음과 같은 형태입니다.

 
 
kotlin
fun sendEmail(person: Person, text: String) {
    val email: String = person.email ?: return
    //...
}

속성이 올바르지 않을 때 하나 이상의 작업을 수행해야 하는 경우, 항상 return이나 throw를 run 함수로 래핑하여 이러한 작업을 추가할 수 있습니다. 이러한 기능은 함수가 중단된 이유를 기록해야 하는 경우 유용할 수 있습니다:

 
 
kotlin
fun sendEmail(person: Person, text: String) {
    val email: String = person.email ?: run {
        log("Email not sent, no email address")
        return
    }
    //...
}

 

엘비스 연산자(?:)와 함께 return이나 throw를 사용하는 방식은,
변수가 null일 때 어떤 동작을 해야 하는지를 명확히 표현하는 매우 대중적이고 관용적인 방법입니다.
따라서 이러한 패턴은 주저하지 말고 사용하는 것이 좋습니다.

 

또한 가능하다면, 이러한 검사들은 함수의 시작 부분에 배치하여
의도를 눈에 잘 띄고 명확하게 드러내는 것이 바람직합니다.

 

 

error function

Kotlin 표준 라이브러리(stdlib)에는 error 함수가 있으며,
이 함수는 IllegalStateException을 발생시키는 데 사용됩니다.

 

error는 보통 절대로 발생해서는 안 된다고 가정하는 상황,
예를 들어 예상하지 못한 값의 타입이 들어온 경우와 같이
논리적으로 도달하면 안 되는 분기들을 처리할 때 자주 사용됩니다.

 
 
kotlin
// error implementation from Kotlin stdlib
public inline fun error(message: Any): Nothing =
    throw IllegalStateException(message.toString())

// example usage
fun handleMessage(message: Message) = when(message) {
    is TextMessage -> showText(message.text)
    is ImageMessage -> showImage(message.image)
    else -> error("Unknown message type")
}