포스트

에러 핸들링 모델 — 예외 vs 에러 값 vs Result

함수가 실패했을 때 그것을 호출자에게 어떻게 전달하는가. 예외(C++/Java)·에러 값 반환(Go)·Result/Option 타입(Rust)이라는 세 모델을 명시성·강제성·성능 축으로 비교하고, '복구 가능한 에러'와 '프로그래밍 버그'를 왜 다르게 다뤄야 하는지 정리한다.

에러 핸들링 모델 — 예외 vs 에러 값 vs Result

난이도 입문 · 선행 없음

한 줄 요약

함수가 실패할 수 있을 때, 그 실패를 호출자에게 어떻게 전달하느냐로 언어가 갈린다. 세 가지다 — 예외를 던지거나(C++/Java), 에러를 값으로 반환하거나(Go), 성공/실패를 담는 타입을 반환한다(Rust).

어떤 문제를 푸는가

readFile("없는파일")은 실패한다. 이때 함수는 호출자에게 “실패했고 이유는 이거다”를 알려야 한다. 두 가지 흐름이 섞인다:

  • 정상 흐름 — 파일을 읽어 내용을 반환
  • 에러 흐름 — 실패를 전달, 호출자가 대응

이 둘을 코드에서 어떻게 분리·전달하느냐가 모델의 차이다. 그리고 좋은 모델의 조건은 대개 이렇다: 에러를 놓치지 않게 하고(강제성), 어디서 실패하는지 보이게 하며(명시성), 정상 로직을 어지럽히지 않는다(가독성). 세 모델은 이 셋을 서로 다르게 절충한다.

세 가지 모델

① 예외 — throw / catch (C++, Java, Python)

에러를 던지면(throw) 호출 스택을 거슬러 올라가 가장 가까운 catch가 잡는다.

1
2
3
4
5
6
7
8
9
10
std::string readFile(const std::string& path) {
    if (!exists(path)) throw std::runtime_error("no such file");
    // ... 정상 반환 ...
}

try {
    auto content = readFile("x");
} catch (const std::exception& e) {
    std::cerr << e.what();
}
  • 장점: 정상 로직이 에러 처리로 지저분해지지 않는다. 여러 단계 위로 한 번에 전파된다.
  • 단점: 어떤 함수가 무엇을 던지는지 시그니처에 안 보인다. 흐름이 코드에서 안 보이는 곳으로 점프한다. 스택 언와인딩 비용도 있다.

② 에러 값 반환 — (결과, error) (Go, C)

에러를 특별하게 취급하지 않고 그냥 반환값으로 돌려준다. Go는 다중 반환으로 (값, error)를 준다.

1
2
3
4
5
content, err := readFile("x")
if err != nil {
    return err          // 처리하거나 위로 올림
}
// content 사용
  • 장점: 완전히 명시적. 어디서 실패할 수 있는지 코드에 그대로 보인다. 언어가 단순하다.
  • 단점: if err != nil이 반복돼 장황하다. 그리고 반환된 에러를 무시해도 컴파일된다(실수의 여지).

③ Result / Option 타입 (Rust, Swift, Haskell)

성공값과 에러값을 하나의 타입에 담아 반환한다. 타입 시스템이 에러 처리를 강제한다.

1
2
3
fn read_file(path: &str) -> Result<String, io::Error> { ... }

let content = read_file("x")?;   // ★ ? : 성공이면 값 꺼내고, 실패면 이 함수가 즉시 그 에러를 반환
  • 장점: 에러를 무시할 수 없다Result를 안 다루면 컴파일 경고/에러. ? 연산자로 전파가 간결하다. 실패 가능성이 타입에 드러난다.
  • 단점: 대수적 타입·패턴 매칭에 익숙해져야 한다.
  • Option<T> — “값이 없을 수 있음”(null의 안전한 대체). Result<T, E> — “실패할 수 있음”.

한눈에 비교

 예외 (C++/Java)에러 값 (Go)Result (Rust)
실패 전달throw/catch반환값반환 타입
시그니처에 드러남❌ (숨음)△ (반환에 보임)✅ (타입에 명시)
무시 방지(강제성)약함약함(무시 가능)강함(컴파일 강제)
정상 로직 가독성깔끔장황(if err)중간(?로 완화)
전파자동(스택 위로)수동(return err)?로 반자동
성능던질 때 비쌈일정일정

흐름: 예외는 편의를, Go는 명시성을, Rust는 강제성을 최우선에 둔다. 어느 게 옳다기보다 무엇을 최우선으로 삼느냐의 선택이다.

별개의 축 — 복구 가능한 에러 vs 프로그래밍 버그

세 모델과 직교하는 중요한 구분이 있다. 모든 실패가 같은 실패가 아니다.

  • 복구 가능한 에러 — 파일 없음, 네트워크 끊김, 잘못된 입력. 예상되는 실패이고 프로그램이 대응해야 한다. → 위의 세 모델이 다루는 대상.
  • 프로그래밍 버그 — 배열 범위 초과, 불변식 위반. 일어나면 안 되는 상황. 대응이 아니라 즉시 중단이 맞다.

이 후자를 위한 별도 장치가 있다:

언어복구 가능 에러프로그래밍 버그(중단)
Goerrorpanic / recover
RustResultpanic!
C++예외assert, std::terminate

흔한 실수: 복구 가능한 에러를 panic/예외로 남발하는 것. “파일 없음”에 panic을 던지면 안 된다. Go 로드맵도 “panic을 일상 흐름 제어에 쓰지 말라”고 짚는 이유다.

언어별 정리

  • C++: 예외가 기본. 다만 성능·예측성 때문에 예외를 끄거나 피하는 코드베이스(게임·임베디드)도 많고, C++23의 std::expected로 Result 스타일도 가능해졌다.
  • Go: (val, err) + if err != nil. fmt.Errorf("...: %w", err)로 감싸고 errors.Is/errors.As로 판별. → Go 학습 로드맵 — ④ 에러 처리
  • Rust: Result<T, E> + ?. Option<T>으로 null을 대체. 에러 처리가 타입 시스템의 일부.

C++/Java에서 온 사람이 Go·Rust에서 겪는 가장 큰 충격이 “예외가 없다”는 것이다. 값·타입으로 에러를 다루는 건 불편이 아니라 명시성을 위한 의도된 선택이다.

스스로 점검

1. 예외 모델의 가장 큰 약점은?

어떤 함수가 무엇을 던지는지 시그니처에 드러나지 않는다. 제어 흐름이 코드에서 안 보이는 catch로 점프하고, 처리하지 않으면 프로그램이 죽는다. Go·Rust는 이걸 값/타입으로 끌어내 “보이게” 만든 것이다.

2. Go의 에러 값과 Rust의 Result의 결정적 차이는?

강제성. Go는 반환된 error를 무시해도 컴파일된다. Rust는 Result를 다루지 않으면 컴파일 경고/에러가 나 무시할 수 없다. 또 Rust는 ?로 전파가 간결한 반면 Go는 if err != nil { return err }를 매번 쓴다.

3. “파일 없음”에 panic을 쓰면 안 되는 이유는?

파일 없음은 복구 가능한(예상된) 에러이지 프로그래밍 버그가 아니다. panic은 “일어나면 안 되는” 상황에서 즉시 중단하는 장치다. 예상되는 실패는 error/Result로 호출자에게 전달해 대응하게 해야 한다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.