시스템아 미안해

Chapter 1: Safety - Item 7: Prefer a nullable or Result resulttype when the lack of a result is possible 본문

책/Effective Kotlin

Chapter 1: Safety - Item 7: Prefer a nullable or Result resulttype when the lack of a result is possible

if else 2026. 1. 22. 17:59

때로는 함수가 의도한 결과를 만들어 낼 수 없는 경우가 있습니다. 대표적인 예는 다음과 같습니다.

  • 서버로부터 데이터를 가져오려 했지만, 인터넷 연결에 문제가 있는 경우
  • 특정 조건에 맞는 첫 번째 요소를 찾으려 했지만, 리스트에 그런 요소가 없는 경우
  • 텍스트로부터 객체를 파싱하려 했지만, 텍스트 형식이 잘못된 경우

이러한 상황을 처리하는 주요 방법은 두 가지가 있습니다.

  • null 또는 Result.failure를 반환하여 실패를 명시적으로 나타내는 방법
  • 예외(exception)를 던지는 방법

이 두 방식에는 중요한 차이점이 있습니다.
예외는 정보를 전달하는 일반적인 수단으로 사용해서는 안 됩니다.
모든 예외는 비정상적이고 특별한 상황을 의미하며, 그렇게 취급되어야 합니다
(조슈아 블로크의 Effective Java에서도 같은 원칙을 강조합니다).

 

예외를 정말 예외적인 상황에서만 사용해야 하는 이유는 다음과 같습니다.

  • 예외가 전파되는 방식은 많은 개발자에게 직관적이지 않으며, 코드 속에서 쉽게 놓칠 수 있습니다.
  • Kotlin에서는 모든 예외가 unchecked이기 때문에,
    사용자가 이를 반드시 처리하도록 강제되거나 권장되지도 않습니다.
    또한 예외는 문서화가 부족한 경우가 많고, API를 사용할 때 눈에 잘 띄지 않습니다.
  • 예외는 본래 예외적인 상황을 위한 메커니즘이므로,
    JVM 구현체 입장에서는 이를 명시적인 조건 검사만큼 빠르게 최적화할 유인이 적습니다.
  • try-catch 블록 안에 코드를 배치하면,
    컴파일러가 원래 수행할 수 있었던 일부 최적화가 제한될 수 있습니다.

다만, 예외가 관용적으로 사용되는 패턴들도 존재합니다.
예를 들어 백엔드에서는, 예외를 사용해 요청 처리를 즉시 중단하고,
특정 응답 코드와 메시지를 반환하는 경우가 흔합니다.
또한 Android에서는 예외를 사용해 프로세스를 종료하고,
특정 다이얼로그나 토스트 메시지를 사용자에게 표시하기도 합니다.

 

이러한 경우에는, 위에서 언급한 예외에 대한 단점들이 그대로 적용되지 않으며,
예외를 사용하는 것이 충분히 합리적일 수 있습니다.

 

반면에, 예상 가능한 오류(expected error) 를 나타내는 데에는
null이나 Result.failure가 아주 적합한 선택입니다.
이들은 명시적이고, 효율적이며,
Kotlin에서 관용적인(idiomatic) 방식으로 쉽게 처리할 수 있습니다.

 

따라서 정리하면 다음과 같은 규칙을 따르는 것이 바람직합니다.

  • 오류가 예상 가능한 경우 → null 또는 Result.failure를 반환한다.
  • 오류가 예상되지 않는 경우 → 예외를 던진다.

다음은 이러한 원칙을 적용한 예시들입니다.

 
 
kotlin
inline fun <reified T> String.readObjectOrNull(): T? {
    //...
    if (incorrectSign) {
        return null
    }
    //...
    return result
}

inline fun <reified T> String.readObject(): Result<T> {
    //...
    if (incorrectSign) {
        return Result.failure(JsonParsingException())
    }
    //...
    return Result.success(result)
}

class JsonParsingException : Exception()

Using Result result type

결과가 성공(success) 이거나 실패(failure) 일 수 있는 경우를 표현하기 위해 사용됩니다.
실패(Failure)에는 예외(exception) 가 포함되며, 이를 통해 오류에 대한 정보를 함께 전달할 수 있습니다.

 

Result는, 실패 시 추가적인 정보를 전달해야 하는 함수에서는
단순히 널(nullable) 타입을 반환하는 것보다 더 적합합니다.
예를 들어, 인터넷에서 데이터를 가져오는 함수를 구현한다고 가정해 보겠습니다.
이 경우에는 null을 반환하는 것보다,
에러 코드나 에러 메시지 같은 구체적인 오류 정보를 전달할 수 있는 Result를 사용하는 편이 바람직합니다.

 

Result를 반환하도록 설계하면,
이 함수를 사용하는 쪽에서는 Result 클래스가 제공하는 메서드들을 이용해
성공과 실패를 명확하게 처리할 수 있습니다.

 
 
kotlin
userText.readObject<Person>()
    .onSuccess { showPersonData(it) }
    .onFailure { showError(it) }

이와 같은 방식의 오류 처리는 try-catch 블록을 사용하는 것보다 더 단순합니다.
또한 더 안전하기도 한데, 예외는 놓칠 수 있고 애플리케이션 전체를 중단시킬 수 있는 반면,
null 값이나 Result 객체는 반드시 명시적으로 처리해야 하며,
애플리케이션의 흐름을 갑자기 끊어 버리지 않기 때문입니다.

 

nullable 결과와 Result 객체의 차이점은 다음과 같습니다.

  • 실패 시 추가적인 정보를 전달할 필요가 있다면 → Result를 사용하는 것이 좋습니다.
  • 추가 정보가 필요 없다면 → null을 반환하는 편이 더 간단합니다.

Result 클래스는 결과를 처리하기 위한 풍부한 API를 제공합니다. 주요 메서드들은 다음과 같습니다.

  • isSuccess, isFailure 프로퍼티
    결과가 성공인지 실패인지 확인할 수 있습니다
    (isSuccess == !isFailure는 항상 참입니다).
  • onSuccess, onFailure 메서드
    각각 성공 또는 실패일 때 전달한 람다를 실행합니다.
  • getOrNull 메서드
    성공이면 값을 반환하고, 실패이면 null을 반환합니다.
  • getOrThrow 메서드
    성공이면 값을 반환하고, 실패이면 해당 예외를 던집니다.
  • getOrDefault 메서드
    성공이면 값을 반환하고, 실패이면 전달한 기본값을 반환합니다.
  • getOrElse 메서드
    성공이면 값을 반환하고, 실패이면 람다를 실행한 결과를 반환합니다.
  • exceptionOrNull 메서드
    실패이면 예외를 반환하고, 성공이면 null을 반환합니다.
  • map 메서드
    성공 값(success value)을 다른 값으로 변환합니다.
  • recover 메서드
    실패(throwable)를 성공 값으로 복구합니다.
  • fold 메서드
    성공과 실패를 하나의 메서드에서 함께 처리합니다.

마지막으로,
예외를 던지는 함수를 Result를 반환하는 함수로 변환하고 싶다면
runCatching을 사용하시면 됩니다.

 
kotlin
fun getA(): Result<T> = runCatching { getAThrowing() }

Using null result type

Kotlin에서 null은 값이 없음을 나타내는 표시(marker) 입니다.
함수가 null을 반환한다는 것은, 의도한 값을 반환할 수 없다는 의미입니다. 예를 들면 다음과 같습니다.

  • List<T>.getOrNull(Int)
    → 주어진 인덱스에 값이 없을 경우 null을 반환합니다.
  • String.toIntOrNull()
    → 문자열을 Int로 올바르게 파싱할 수 없을 경우 null을 반환합니다.
  • Iterable<T>.firstOrNull(() -> Boolean)
    → 전달된 조건을 만족하는 요소가 하나도 없을 경우 null을 반환합니다.

보시다시피, null은 함수가 기대한 값을 반환할 수 없음을 나타내는 신호로 사용됩니다.
우리는 실패 시 추가적인 정보를 전달할 필요가 없고,
null의 의미가 명확한 경우에는 Result 대신 nullable 타입을 사용합니다.

 

예를 들어,

  • String.toIntOrNull()에서는
    null이 문자열을 정수로 파싱할 수 없다는 의미임이 분명합니다.
  • Iterable<T>.firstOrNull(() -> Boolean)에서는
    null이 조건에 맞는 요소가 존재하지 않는다는 의미임이 명확합니다.

이처럼 null을 반환하는 모든 함수에서는, 그 null의 의미가 분명해야 합니다.

 

한편, nullable 값은 사용하기 전에 반드시 언래핑(unwrapping) 해야 합니다.
이를 위해 Kotlin은 다음과 같은 유용한 기능들을 제공합니다.

  • 안전 호출 연산자 ?.
  • 엘비스 연산자 ?:
  • 스마트 캐스트(smart casting)
 
 
kotlin
val age = userText.readObjectOrNull<Person>()?.age ?: -1

val printer: Printer? = getFirstAvailablePrinter()
printer?.print() // Safe call
if (printer != null) printer.print() // Smart casting

Null is our friend, not an enemy

많은 Kotlin 개발자들은 Java 개발자 출신이며,
그 영향으로 null을 마치 적(enemy)처럼 다루는 경향이 있습니다.
예를 들어, Effective Java 2판에서 Joshua Bloch
Item 43: null 대신 빈 배열이나 컬렉션을 반환하라 라고 말합니다.
하지만 이런 권고는 Kotlin에서는 부적절합니다.

 

Kotlin에서 빈 컬렉션과 null은 완전히 다른 의미를 가집니다.
예를 들어 getUsers라는 함수를 호출했다고 가정해 보겠습니다.

  • 이 함수가 null을 반환한다면,
    이는 값을 만들어 낼 수 없었다는 의미이며,
    우리는 여전히 “결과가 무엇인지” 알 수 없습니다.
  • 반면 빈 컬렉션을 반환한다면,
    이는 사용자가 존재하지 않는다는 의미가 됩니다.

이 두 결과는 전혀 다른 의미를 가지며,
절대로 서로 혼동해서는 안 됩니다.

 

Kotlin의 타입 시스템은 어떤 값이 널이 될 수 있는지, 없는지를 명확하게 표현할 수 있게 해 주고,
개발자로 하여금 널을 의식적으로 처리하도록 강제합니다.
따라서 우리는 널을 두려워할 필요가 없습니다.
오히려 의도를 표현하는 수단으로 적극적으로 활용해야 합니다.

 

Java 시절에 통용되던
“널을 피하라”는 조언들은 Kotlin에는 그대로 적용되지 않습니다.
Kotlin에서 null은 적이 아니라,
의미를 정확하게 표현해 주는 친구입니다.

Defensive and offensive programming

아이템 5: 인자와 상태에 대한 기대 조건을 명시하라에서는
잘못된 인자나 상태를 알리기 위해 예외를 던져야 한다고 설명했고,
이번 아이템에서는 일반적으로 예외를 피하고 Result나 nullable 타입을 반환하는 편이 좋다고 설명했습니다.

이 두 주장은 서로 모순되어 보일 수 있지만, 실제로는 서로 다른 상황을 다루고 있기 때문에 충돌하지 않습니다.

 

예외는 정상적인 프로그램 실행 흐름의 일부가 되어서는 안 됩니다.
따라서 데이터베이스나 네트워크에서 데이터를 가져오는 것처럼
성공할 수도 있고 실패할 수도 있는 작업을 수행할 때에는,
예외 대신 Result나 nullable 타입을 사용하는 것이 바람직합니다.
이렇게 하면 개발자는 실패 케이스를 명시적으로 처리할 수밖에 없게 되며,
이는 정상적인 실행 흐름의 일부이기 때문에
모든 가능한 상황을 안전하게 처리하도록 유도합니다.
이는 방어적 프로그래밍(defensive programming) 의 한 구현입니다.

 

반면에, 개발자가 잘못된 인자로 메서드를 호출하거나,
객체가 올바르지 않은 상태에서 메서드를 호출하는 실수를 저질렀을 때,
이 상황을 조용히 넘겨버리는 것은 매우 위험합니다.
이는 프로그램이 명백히 예상하지 못한 상황에 도달했음을 의미하므로,
해당 문제를 강하게(명확하게) 알림으로써 프로그램이 수정될 수 있도록 해야 합니다.
이것이 바로 공격적 프로그래밍(offensive programming) 의 구현입니다.

 

방어적 프로그래밍과 공격적 프로그래밍은 서로 모순되는 개념이 아닙니다.
오히려 음과 양(yin and yang) 처럼,
프로그램의 안전성을 위해 모두 필요한 서로 다른 기법들입니다.
따라서 우리는 이 둘을 상황에 맞게 이해하고 적절히 사용해야 합니다.