본문으로 건너뛰기

안동민 개발노트

본문 시작

`panic!` 사용 기준

호출자가 복구할 수 있는 실패, 값의 불변식, 계약 위반을 구분해 Result·커스텀 타입·panic 중 적절한 API 전략을 선택합니다.

실패할 수 있는 함수를 설계할 때 가장 먼저 물을 질문은 “실패가 발생하는가?”가 아니라 **누가 이 실패를 처리할 수 있는가?**입니다.

Result를 반환하면 호출자는 재시도, 대체값, 사용자 안내, 상위 오류로의 변환 중 하나를 선택할 수 있습니다. 반대로 panic!은 정상 반환 경로를 끝내므로 호출자에게 일반적인 오류 처리 선택권을 남기지 않습니다.

다만 패닉을 곧바로 “항상 전체 프로세스가 즉시 종료되는 상황”과 동일시해서는 안 됩니다. 패닉은 현재 스레드에서 시작하고 빌드 설정과 경계에 따라 스택을 되감거나 프로세스를 중단할 수 있습니다. 이 차이가 패닉을 예상 가능한 운영 실패의 반환 수단으로 만들어 주는 것은 아닙니다.

기본 원칙은 다음과 같습니다.

  • 사용자가 고칠 수 있거나 호출자가 정책을 선택할 수 있는 실패는 Result로 표현합니다.
  • 반복되는 값의 유효성은 생성 경계와 비공개 필드를 가진 커스텀 타입으로 모읍니다.
  • 호출자 코드의 계약이나 내부 불변식이 깨졌고 계속 실행하는 것이 유효하지 않다면 문서화된 패닉을 고려합니다.
반복되는 유효성은 fallible 생성자와 커스텀 타입으로 표현하고, 그 밖의 예상 가능한 실패는 Result로 호출자에게 남기며, 계약이나 불변식이 깨져 계속 실행하기 위험한 버그에는 문서화된 panic을 선택하는 의사결정 흐름

CALLER CHOICE · TYPE INVARIANT · CONTRACT BUG

API 경계가 실패 책임과 값 보장을 정한다

실패가 발생한다는 사실만으로 패닉을 선택하지 않습니다. 잘못된 값을 타입으로 막을 수 있는지, 호출자가 합리적으로 복구할 수 있는지, 계속 실행하면 계약과 안전이 깨지는지를 순서대로 확인합니다.

Result, 커스텀 타입, panic 선택 흐름 실패 가능 경계에서 타입 불변식, 호출자 복구, 계약 위반 여부를 차례로 물어 fallible 커스텀 타입과 Result를 조합하거나 문서화된 panic을 선택하는 흐름입니다. 아니오 아니오 아니오 실패 가능 경계 전용 타입의 불변식으로 표현할 수 있는가? 검증 타입 생성은 Result<타입, E> 호출자가 합리적으로 복구할 수 있는가? Result 호출자가 정책 선택 계약·불변식 위반이며 계속 실행하기 위험한가? panic! 조건을 문서화 명시적 오류 상위 경계에 반환 명확한 패닉 근거가 없으면 호출자의 선택권을 남기는 오류 계약을 우선한다
caller can recover

예상 가능한 실패는 Result

입력 수정, 재시도, 대체값, 사용자 메시지처럼 호출자가 선택할 정책이 있으면 성공과 오류를 반환 타입에 드러냅니다.

encode invariant

커스텀 타입과 Result를 조합

fallible 생성 경계와 비공개 필드로 잘못된 값을 만들기 어렵게 하고, 거절 이유는 호출자에게 반환합니다.

contract bug

계속 실행하기 위험한 계약 위반은 패닉 고려

호출자의 정상 입력 실패와 구분하고, 공개 API라면 어떤 전제가 깨질 때 패닉하는지 문서화합니다.

실패 의미와 책임 위치에 따른 API 전략
상황기본 전략보존할 계약
사용자 입력·파싱·파일·네트워크Result오류 종류와 호출자가 취할 수 있는 재시도·대체·보고 방법을 드러냅니다.
애플리케이션 최상단메시지와 종료 상태로 변환하위 계층은 Result의 정보를 보존하고, 사용자에게 보여 줄 문구와 종료 여부는 실행 경계에서 정합니다.
여러 함수가 공유하는 값 범위커스텀 타입외부 입력에는 fallible 생성자를 두고 필드와 변경 경로가 불변식을 유지하게 합니다.
호출자 코드의 계약 위반panic!계속 실행이 유효하지 않은 근거와 패닉 조건을 공개 문서에 적습니다.
예제·프로토타입·테스트unwrap 또는 expect실패 지점을 빠르게 드러내되 운영 경로의 정상 실패 처리로 남기지 않습니다.
사람이 증명한 불변식expect오류를 번역하는 대신 왜 성공해야 하는지 기대 조건을 메시지로 남깁니다.
Result

사용자 입력·파싱·파일·네트워크

오류 종류와 호출자가 선택할 재시도·대체·보고 방법을 반환 타입에 드러냅니다.

top-level

애플리케이션 최상단

하위 Result의 정보를 보존하고 실행 경계에서 메시지와 종료 상태로 변환합니다.

custom type

여러 함수가 공유하는 값 범위

fallible 생성자와 비공개 필드·변경 경로가 같은 불변식을 유지하게 합니다.

panic!

호출자 코드의 계약 위반

계속 실행이 유효하지 않은 근거와 패닉 조건을 공개 문서에 적습니다.

prototype

예제·프로토타입·테스트

unwrap·expect로 실패 지점을 드러내되 운영 경로의 복구 계약으로 남기지 않습니다.

proven invariant

사람이 증명한 불변식

expect 메시지에 오류 번역 대신 왜 성공해야 하는지를 남깁니다.

패닉은 “항상 즉시 프로세스 종료”와 같은 말이 아닙니다. 현재 스레드에서 시작해 설정과 경계에 따라 unwind하거나 abort할 수 있지만, 일반 라이브러리 API에서 예상 가능한 실패를 전달하는 일상적인 복구 채널로 사용하지는 않습니다.


예제, 프로토타입 코드, 테스트

예제는 핵심 개념을 짧게 보여 주어야 하므로 완전한 오류 정책을 생략하고 unwrap이나 expect를 사용할 때가 있습니다. 프로토타입에서도 아직 정책이 정해지지 않은 지점을 표시하는 임시 선택으로 쓸 수 있습니다.

하지만 이 편의 메서드들은 Err를 처리하지 않습니다.

  • unwrapOk의 값을 꺼내고 Err에서는 패닉합니다.
  • expect도 같은 동작을 하지만, 왜 성공해야 하는지 설명하는 메시지를 함께 남깁니다.
  • 운영 코드에서 사용자 입력, 파일, 네트워크 같은 정상적인 실패 경로를 이 메서드들로 닫아 버리면 호출자의 복구 선택지가 사라집니다.

테스트의 준비 단계에서 필요한 값이 만들어지지 않았다면 테스트 전체가 실패하는 편이 맞을 수 있습니다. 이때 expect는 “이 fixture가 왜 유효해야 하는가”를 드러내는 메시지와 함께 사용할 수 있습니다. 반대로 오류 반환 자체가 테스트 대상이라면 ResultErr 배리언트를 직접 단언해야 합니다.


컴파일러가 증명하지 못한 불변식

사람이 보기에 어떤 Result가 반드시 Ok라고 판단할 논리적 근거가 있지만 타입 시스템이 그 근거를 표현하지 못하는 경우가 있습니다.

하드코딩된 값의 불변식을 expect에 기록
use std::net::IpAddr;

let home: IpAddr = "127.0.0.1"
    .parse()
    .expect("hardcoded loopback address should be valid");

문자열이 소스 코드에 고정되어 있고 유효한 루프백 주소라는 사실을 개발자가 검토할 수 있으므로, 실패는 사용자 입력 오류가 아니라 코드에 적힌 불변식의 붕괴입니다. expect 메시지는 단순히 “파싱 실패”를 반복하기보다 왜 성공해야 하는지를 설명합니다.

같은 문자열이 설정 파일이나 사용자 입력에서 온다면 전제가 달라집니다. 유효하지 않은 주소는 충분히 예상 가능한 실패이므로 Result를 반환하거나 match?로 처리해야 합니다.


에러 처리를 위한 가이드라인

예상 가능한 실패에는 Result

잘못된 사용자 입력, 파싱 실패, 파일 없음, 권한 부족, 네트워크 지연, HTTP 속도 제한은 호출자가 만날 수 있는 정상적인 실패입니다. 함수는 가능한 오류를 반환 타입에 드러내고, 호출자가 상황에 맞는 정책을 선택하도록 해야 합니다.

라이브러리는 애플리케이션보다 호출 환경을 덜 알고 있으므로 보통 Result로 선택권을 남기는 편이 낫습니다. “라이브러리에서는 절대 패닉하지 않는다”가 규칙은 아닙니다. 인덱스 범위나 API 계약을 어긴 호출처럼 패닉 조건이 있는 공개 함수는 그 조건을 # Panics 문서에 분명히 적어야 합니다.

실행 파일의 최상단은 전달받은 오류를 사용자 메시지와 종료 상태로 바꿀 수 있습니다. 이 정책을 하위 라이브러리 함수의 패닉으로 미리 고정하기보다, 하위 계층은 Result의 정보를 보존하고 애플리케이션 경계가 최종 표시와 종료를 결정하게 합니다.

계약이나 불변식이 깨졌다면 패닉을 고려

패닉의 근거는 단순히 “오류가 심각해 보인다”가 아닙니다. 다음 조건을 함께 확인합니다.

  • 상태가 예상 가능한 외부 실패가 아니라 호출자 코드의 버그나 내부 불변식 붕괴입니다.
  • 이후 코드가 그 불변식을 전제로 하므로 계속 실행하는 결과가 유효하지 않거나 안전하지 않습니다.
  • 호출자가 같은 호출 지점에서 재시도나 대체값으로 합리적으로 복구할 수 없습니다.
  • 가능하면 타입으로 잘못된 상태를 표현할 수 없게 만드는 방법을 먼저 검토했습니다.

표준 라이브러리의 인덱싱이 범위를 벗어나면 패닉하는 이유도 메모리 안전 계약과 연결됩니다. 반면 slice.get(index)는 부재를 Option으로 표현해 호출자가 처리할 선택지를 줍니다. 같은 자료구조라도 API가 약속하는 실패 계약에 따라 반환 방식이 달라질 수 있습니다.

타입이 표현하는 보장만 믿기

타입은 런타임 검사를 줄이는 강력한 도구지만 실제로 표현한 조건만 보장합니다.

  • Option<T>가 아닌 T를 받는 함수는 “값이 없음”이라는 None 분기를 처리하지 않습니다.
  • u32는 음수를 표현하지 않지만 1..=100 범위를 보장하지는 않습니다.
  • 더 좁은 도메인 규칙은 별도의 커스텀 타입과 생성 경계로 표현해야 합니다.

유효성을 위한 커스텀 타입 생성하기

추리 게임의 입력이 반드시 1부터 100 사이라면 모든 함수에서 같은 if 검사를 반복하는 대신, 검증된 값만 담는 Guess 타입을 만들 수 있습니다.

중요한 것은 구조체 이름만 새로 만드는 일이 아닙니다.

  1. 원시 값이 들어오는 공개 생성 경계에서 범위를 검사합니다.
  2. 내부 필드는 비공개로 두어 모듈 밖의 우회 생성을 막습니다.
  3. 성공한 값만 Guess로 만들고 실패 이유는 호출자에게 반환합니다.
  4. 내부의 모든 변경 경로도 같은 불변식을 유지합니다.
원시 정수를 fallible 생성자에서 검증해 오류는 호출자에게 반환하고 유효한 값만 비공개 필드의 Guess 타입으로 만든 뒤 이후 함수가 그 불변식을 신뢰하는 흐름

RAW VALUE · VALIDATION BOUNDARY · VALID TYPE

검증 경계가 유효한 타입을 만든다

커스텀 타입의 핵심은 검사를 없애는 것이 아니라 한 생성 경계에 모으는 것입니다. 외부 입력처럼 거절이 정상 흐름이면 fallible 생성자가 Result를 반환하고, 성공한 값만 비공개 필드에 넣어 불변식을 유지합니다.

  1. 원시 입력은 아직 범위를 증명하지 않는다

    i32는 음수까지 표현하고 u32는 음수를 제외하지만, 어느 쪽도 값이 1..=100 안에 있다는 사실까지 보장하지 않습니다.

  2. 생성 함수가 형식과 범위를 한 번 검사한다

    Guess::try_new가 경계 안의 값만 Ok로 만들고, 범위 밖 값은 이유가 있는 Err로 돌려줍니다.

  3. 비공개 필드가 우회 생성을 막는다

    모듈 밖 코드는 구조체 리터럴로 내부 값을 바꿀 수 없고 공개 생성 경계를 통과해야 합니다. 내부 메서드도 불변식을 깨뜨리지 않아야 보장이 계속됩니다.

  4. 소비 함수는 타입이 보장한 조건을 믿는다

    매개변수로 Guess를 받는 함수는 같은 범위 검사를 반복하지 않고 비교나 도메인 로직에 집중할 수 있습니다.

Ok · validated

유효한 값만 Guess가 된다

생성에 성공한 뒤에는 값의 범위가 타입의 불변식입니다. getter는 읽기만 허용하고, 범위를 깨뜨리는 setter는 제공하지 않습니다.

Err · caller policy

외부 입력 실패는 호출자가 처리한다

사용자에게 범위를 다시 안내하거나 재입력을 받고, 로그를 남기거나 상위 오류로 변환할 수 있습니다. 정상적인 입력 거절을 패닉으로 바꾸지 않습니다.

fallible constructor

반환 타입이 생성 실패를 API에 드러낸다

#[derive(Debug, PartialEq, Eq)]
pub struct GuessError {
    value: i32,
}

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn try_new(
        value: i32,
    ) -> Result<Self, GuessError> {
        if (1..=100).contains(&value) {
            Ok(Self { value })
        } else {
            Err(GuessError { value })
        }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}
입력의 소유자와 실패 의미에 따른 생성 API 선택
생성 경계맞는 조건실패 계약
fallible 생성자사용자 입력, 파일, 네트워크처럼 유효하지 않은 값이 예상될 수 있습니다.Result로 거절 이유를 반환해 호출자가 재시도·표시·변환을 선택합니다.
패닉하는 생성자잘못된 인자가 오직 프로그래머의 API 계약 위반이고 합리적인 호출자 복구가 없습니다.패닉 조건을 공개 문서의 # Panics에 적고, 외부 입력은 먼저 검증합니다.
비공개 필드모든 생성과 변경 경로가 같은 불변식을 지켜야 합니다.모듈 밖 우회를 막되 내부 코드도 unchecked 변경으로 보장을 깨뜨리지 않습니다.
fallible

예상 가능한 거절은 Result

사용자 입력·파일·네트워크 값은 거절 이유를 반환해 호출자가 재시도·표시·변환을 선택하게 합니다.

contract violation

복구할 수 없는 API 계약 위반

패닉 조건을 공개 문서의 # Panics에 적고, 외부 입력은 이 경계 전에 검증합니다.

private field

모든 변경 경로가 불변식을 지킨다

모듈 밖 우회를 막고 내부 코드도 unchecked 변경으로 타입의 보장을 깨뜨리지 않습니다.

Option이 아닌 타입을 받는다는 사실은 값이 그 타입의 표현 안에 있다는 뜻이지만, 도메인 범위까지 자동으로 보장하지는 않습니다. Guess처럼 더 좁은 의미를 가진 타입이 그 추가 조건을 표현합니다.

외부 입력은 유효하지 않을 수 있으므로 fallible 생성자 이름을 try_new로 두고 Result를 반환하겠습니다.

1부터 100 사이의 값만 만드는 Guess 타입
#[derive(Debug, PartialEq, Eq)]
pub struct GuessError {
    value: i32,
}

pub struct Guess {
    value: i32,
}

impl Guess {
    pub fn try_new(value: i32) -> Result<Self, GuessError> {
        if (1..=100).contains(&value) {
            Ok(Self { value })
        } else {
            Err(GuessError { value })
        }
    }

    pub fn value(&self) -> i32 {
        self.value
    }
}

Guess::try_newOk를 반환했다면 비공개 value 필드는 범위 안에 있습니다. 모듈 밖 코드는 구조체 리터럴로 값을 직접 만들거나 바꿀 수 없고, Guess를 받는 함수는 같은 범위 검사를 반복하지 않아도 됩니다.

사용자 입력을 연결하면 생성 실패도 정상 흐름으로 처리할 수 있습니다.

입력 파싱과 도메인 검증을 각각 처리
let raw: i32 = match input.trim().parse() {
    Ok(value) => value,
    Err(_) => {
        println!("숫자를 입력해 주세요.");
        continue;
    }
};

let guess = match Guess::try_new(raw) {
    Ok(guess) => guess,
    Err(_) => {
        println!("1부터 100 사이의 숫자를 입력해 주세요.");
        continue;
    }
};

match guess.value().cmp(&secret_number) {
    // 비교 결과 처리
}

파싱 오류와 범위 오류는 의미가 다르므로 필요하다면 서로 다른 오류 타입과 메시지로 구분합니다.

패닉하는 new는 언제 가능한가

API가 오직 프로그래머가 만든 검증된 값만 받으며 범위 밖 인자가 명백한 계약 위반이라면 newGuess를 직접 반환하고 잘못된 값에서 패닉하도록 설계할 수도 있습니다.

그 경우에도 다음 조건이 필요합니다.

  • 외부 입력은 new를 호출하기 전에 별도 검증하거나 fallible 경계를 사용합니다.
  • 패닉 조건을 공개 문서의 # Panics에 적습니다.
  • 호출자가 정상적으로 복구해야 하는 상황을 “프로그래머 버그”로 가장하지 않습니다.

try_new와 패닉하는 new 중 어느 쪽이 절대적으로 옳은 것은 아닙니다. 입력의 출처와 호출자에게 남길 선택권이 API 계약을 결정합니다.


정리

러스트의 오류 처리는 실패를 없애는 기능이 아니라 실패의 책임 위치를 타입과 API에 기록하는 방법입니다.

  • Result는 예상 가능한 실패와 호출자의 복구 선택을 표현합니다.
  • 커스텀 타입은 반복되는 유효성 검사를 생성 경계로 모으고 잘못된 값을 만들기 어렵게 합니다.
  • panic!은 계약이나 불변식이 깨져 정상적으로 계속할 수 없는 버그를 드러냅니다.
  • unwrapexpect는 복구 전략이 아니며 예제, 테스트 준비, 사람이 증명한 불변식처럼 근거가 분명한 범위에 제한합니다.

이 기준을 적용하면 함수의 서명만 보아도 누가 실패를 처리하고 어떤 값이 유효한지 더 명확하게 알 수 있습니다.