한 줄 정의

실패가 정상 흐름의 일부인 함수는 예외 대신 널 가능 타입이나 Result 를 반환해 호출자가 실패를 명시적으로 처리하게 하고, 예외는 개발자의 실수처럼 예상하지 못한 상황에만 남겨 둬야 합니다.

쉽게 말하면

카페에서 “딸기가 떨어져서 딸기 라떼는 안 됩니다”는 매일 일어날 수 있는 일이라 점원이 말로 알려 주고, 손님은 다른 메뉴를 고릅니다. 이유까지 필요 없으면 “안 됩니다” 한마디(null)로 충분하고, 다른 메뉴를 고르는 데 이유가 도움이 되면 “딸기가 떨어져서”(Result.failure)까지 붙입니다. 이걸 매번 화재경보 를 울려 매장을 통째로 비우는 식으로 알리면(예외) 커피를 마시러 온 아무도 커피를 못 마십니다.

화재경보가 정당한 순간은 따로 있습니다. 손님이 주문서에 “-3잔”이라고 쓰는 것처럼 정상적으로는 일어날 수 없는 실수가 벌어졌을 때는 조용히 넘기지 말고 크게 알려서 바로잡아야 합니다.

왜 중요한가?

함수가 원하는 결과를 만들지 못하는 일은 흔합니다. 인터넷 연결이 끊겨 서버에서 데이터를 못 가져오고, 조건에 맞는 요소가 리스트에 없고, 파싱하려는 텍스트의 형식이 잘못되어 있습니다. 이를 알리는 수단은 null 이나 Result.failure 를 반환하는 것과 예외를 던지는 것 두 가지인데, 예외를 정보 전달의 표준 수단으로 쓰면 다음 문제가 생깁니다.

문제의미
전파가 직관적이지 않음어디서 잡히는지 코드에서 놓치기 쉽습니다
모두 비검사 예외코틀린은 처리를 강요하지 않고, 문서화도 안 된 경우가 많아 API가 어떤 예외를 던지는지 알기 어렵습니다
느림비정상 상황용으로 설계되어 JVM에서 명시적 검사만큼 빠르게 처리되지 않습니다
최적화 방해try-catch 블록 안의 코드는 컴파일러가 최적화하기 어렵습니다

반면 null 과 Result.failure 는 예상되는 오류를 명시적이고 효율적이며 자연스럽게 표현합니다. 예외는 놓치기 쉬울 뿐 아니라 애플리케이션 전체를 멈출 위험이 있지만, null 이나 Result 는 타입 시스템이 처리를 강제하면서도 흐름을 중단시키지 않습니다.

핵심 내용

예상되는 오류는 반환값으로, 예상 못한 오류는 예외로

같은 JSON 파싱 함수도 실패를 어떻게 알릴지에 따라 두 가지로 쓸 수 있습니다. 실패 이유를 전달할 필요가 없으면 널 가능 타입, 필요하면 Result 를 반환합니다.

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()

예외가 정상 흐름의 일부인 패턴도 있습니다

백엔드에서 요청 처리를 끝내고 요청자에게 특정 응답 코드와 메시지로 응답하거나, 안드로이드에서 프로세스를 종료하고 사용자에게 대화 상자나 토스트를 보여 주는 데 예외를 쓰는 패턴이 널리 쓰입니다. 이런 경우라면 위 문제점의 상당 부분이 적용되지 않으므로 예외를 쓰는 것이 합리적일 수 있습니다.

반환 타입으로 Result 사용

Result 는 성공 또는 실패가 될 수 있는 결과를 담는 코틀린 표준 라이브러리 클래스이고, 실패 쪽에는 오류 정보를 가진 예외가 들어갑니다. 인터넷에서 정보를 가져오는 함수처럼 실패 시 오류 코드나 메시지를 전달해야 한다면 널 가능 타입 대신 Result 를 씁니다. 호출자는 try-catch 없이 Result 의 메서드로 처리합니다.

userText.readObject<Person>()
    .onSuccess { showPersonAge(it) }
    .onFailure { showError(it) }
메서드 · 프로퍼티역할
isSuccess / isFailure성공·실패 여부 확인 (isSuccess == !isFailure 는 항상 참)
onSuccess / onFailure각 경우에 해당 람다 호출
getOrNull성공이면 값, 실패면 null
getOrThrow성공이면 값, 실패면 해당 예외를 던짐
getOrDefault성공이면 값, 실패면 인수로 받은 기본값
getOrElse성공이면 값, 실패면 인수로 받은 함수를 호출한 결과
exceptionOrNull실패면 예외, 성공이면 null
map성공 값을 변환
recover예외를 성공 값으로 변환
fold성공·실패를 하나의 호출에서 모두 처리

이미 예외를 던지는 함수는 runCatching 으로 감싸면 Result 를 반환하는 함수가 됩니다.

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

반환 타입으로 null 사용

코틀린에서 null 은 값이 없음 (lack of value)을 나타내는 표시이며, 함수가 null 을 반환한다는 것은 기대한 값을 반환할 수 없다는 뜻입니다. 실패 이유를 전달할 필요가 없다면 Result 대신 널 가능 타입이면 충분하고, 대신 null 의 의미는 함수마다 명확해야 합니다.

함수null 의 의미
List<T>.getOrNull(Int)주어진 인덱스에 값이 없음
String.toIntOrNull()이 문자열은 Int 로 파싱될 수 없음
Iterable<T>.firstOrNull((T) -> Boolean)조건식에 맞는 요소가 없음

널 가능 값은 사용 전에 언래핑해야 하는데, 안전 호출 ?., 엘비스 연산자 ?:, 스마트 캐스팅이 이를 간결하게 만듭니다.

val age = userText.readObjectOrNull<Person>()?.age ?: -1
 
val printer: Printer? = getFirstAvailablePrinter()
printer?.print()                        // 안전한 호출
if (printer != null) printer.print()    // 스마트 캐스팅

null은 적이 아닌 친구입니다

자바 출신 개발자는 null 을 적으로 취급하는 경향이 있습니다. 《이펙티브 자바》의 “null이 아닌 빈 컬렉션이나 배열을 반환하라”가 대표적인데, 이 제안은 코틀린에서는 무의미합니다. getUsers 가 null 을 반환하면 “값을 만들 수 없어서 결과 자체가 없다”는 뜻이고, 빈 컬렉션을 반환하면 “사용자가 없다”는 뜻이라 전혀 다른 결과입니다.

코틀린의 타입 시스템은 널 가능 여부를 타입에 포함시켜 null 을 의도적으로 처리하도록 강제합니다. 그래서 null 을 피할 것이 아니라 함수의 의도를 나타내는 데 써야 하고, 자바의 “null을 쓰지 말라”는 규약은 코틀린에 적용되지 않습니다.

Quote

null은 실수가 아니라 당신의 친구입니다(Null is your friend, not a mistake). — 로만 엘리자로프(Roman Elizarov)

방어적 프로그래밍과 공격적 프로그래밍

잘못된 인수나 상태에는 예외를 던지라는 원칙과, 예외를 피하고 Result 나 널 가능 타입을 반환하라는 이 원칙은 상충되는 것처럼 보이지만 서로 다른 종류의 상황을 다룹니다.

flowchart TD
    A["함수가 원하는 결과를 만들지 못할 수 있다"] --> B{"정상 흐름에서 예상되는 실패인가?"}
    B -- "아니요: 잘못된 인수·상태 등 개발자 실수" --> C["예외를 던져 크게 알린다<br/>공격적 프로그래밍"]
    B -- "예: DB·네트워크·파싱" --> D{"실패 이유를 전달해야 하는가?"}
    D -- "예" --> E["Result 반환"]
    D -- "아니요" --> F["널 가능 타입 반환"]
    E --> G["호출자가 실패를 명시적으로 처리<br/>방어적 프로그래밍"]
    F --> G
구분방어적 프로그래밍(defensive programming)공격적 프로그래밍(offensive programming)
다루는 상황DB·네트워크 조회처럼 성공 또는 실패할 수 있는 정상 작업잘못된 인수·상태로 호출하는 개발자의 실수
원칙실패 처리도 정상 흐름이므로 안전하게 처리해 안정성을 높입니다묵인하면 예상 못한 문제로 번지므로 크게 알려 수정하게 합니다
도구Result, 널 가능 타입require · check · error 로 던지는 예외

예외는 프로그램의 정상 실행 흐름의 일부가 되어서는 안 됩니다. 두 접근법은 모순이 아니라 음양 관계라서, 프로그램의 안전을 위해 둘 다 이해하고 상황에 맞게 골라 써야 합니다.

비교 / 트레이드오프

기준예외널 가능 타입Result
적합한 상황예상하지 못한 상황, 개발자 실수예상되는 실패, 이유 불필요예상되는 실패, 이유 필요
실패 정보예외 객체 (문서화 없으면 종류를 알기 어려움)“값이 없다”뿐예외 객체로 오류 코드·메시지 전달
처리 강제없음 (비검사)타입 시스템이 강제타입 시스템이 강제
흐름전파되며 애플리케이션 전체를 멈출 수 있음정상 흐름 유지정상 흐름 유지

내 생각

  • “백엔드 예외 패턴”은 컨트롤러 경계의 일입니다. @RestControllerAdvice 로 예외를 HTTP 응답 코드로 바꾸는 것은 합리적이지만, 서비스·도메인 안에서 “조회 결과 없음”까지 예외로 흘려보내면 이 아이템이 지적하는 문제가 그대로 생깁니다. 없을 수 있는 조회는 findByIdOrNull 처럼 널 가능 타입으로 받고, 응답으로 바꿀 곳에서만 ?: throw 로 예외를 택하는 편이 흐름이 읽힙니다.
  • runCatching 은 Throwable 을 전부 잡습니다. 코루틴 안에서 쓰면 CancellationException 까지 삼켜 취소가 전파되지 않으므로, 취소 예외는 다시 던지거나 잡는 범위를 좁힌 래퍼를 쓰는 편이 안전합니다.
  • Result 의 실패는 Throwable 하나로만 표현됩니다. “잔액 부족”과 “계좌 없음”처럼 실패 유형을 타입으로 구분해 when 으로 분기하고 싶다면 sealed class 로 만든 자체 결과 타입이 Result 보다 낫습니다.
  • 빈 컬렉션과 null 의 구분은 HTTP 응답에서도 그대로입니다. 목록이 비면 200 에 빈 배열, 리소스 자체가 없으면 404 로 내려야 클라이언트가 “0건”과 “없음”을 혼동하지 않습니다.

관련 개념