에러 핸들링 모델 — 예외 vs 에러 값 vs Result
함수가 실패했을 때 그것을 호출자에게 어떻게 전달하는가. 예외(C++/Java)·에러 값 반환(Go)·Result/Option 타입(Rust)이라는 세 모델을 명시성·강제성·성능 축으로 비교하고, '복구 가능한 에러'와 '프로그래밍 버그'를 왜 다르게 다뤄야 하는지 정리한다.
난이도 입문 · 선행 없음
한 줄 요약
함수가 실패할 수 있을 때, 그 실패를 호출자에게 어떻게 전달하느냐로 언어가 갈린다. 세 가지다 — 예외를 던지거나(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 프로그래밍 버그
세 모델과 직교하는 중요한 구분이 있다. 모든 실패가 같은 실패가 아니다.
- 복구 가능한 에러 — 파일 없음, 네트워크 끊김, 잘못된 입력. 예상되는 실패이고 프로그램이 대응해야 한다. → 위의 세 모델이 다루는 대상.
- 프로그래밍 버그 — 배열 범위 초과, 불변식 위반. 일어나면 안 되는 상황. 대응이 아니라 즉시 중단이 맞다.
이 후자를 위한 별도 장치가 있다:
| 언어 | 복구 가능 에러 | 프로그래밍 버그(중단) |
|---|---|---|
| Go | error 값 | panic / recover |
| Rust | Result | panic! |
| 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로 호출자에게 전달해 대응하게 해야 한다.