함수 시그니처가 User를 반환한다고 합니다. 그렇지 않습니다. User를 반환하거나 폭발합니다. 타입 시스템은 두 번째 분기에 대해 전혀 모릅니다.
이것이 예외 기반 에러 핸들링의 근본적인 불성실함입니다. 모든 throw는 컴파일러가 볼 수도, 검사할 수도, 강제할 수도 없는 제어 흐름 경로입니다. 결과적으로 코드는 타입 검사를 완벽히 통과하면서도, 세 단계 위에서 누군가 try/catch를 잊어버린 탓에 프로덕션에서 충돌합니다.
명시적인 에러 반환은 이 문제를 해결합니다. 실패를 타입 시스템의 일등 시민으로 만듭니다. 문제는 이런 정직함을 위한 인체공학적 비용이 감당할 만한가입니다.
에러를 위한 타입 수준 정확성이란 무엇인가
타입 수준 정확성이란, 타입이 함수가 생성할 수 있는 것에 대해 진실을 말하는 것입니다. 함수가 실패할 수 있다면 반환 타입이 그것을 알려야 합니다. 실패할 수 없다면 반환 타입은 성공을 보장해야 합니다.
Rust는 이를 Result<T, E>로 인코딩합니다:
fn parse_port(s: &str) -> Result<u16, ParseIntError> {
s.parse()
}
시그니처가 완전합니다. 호출자는 두 가지 결과가 있음을 압니다. 컴파일러는 둘 다 처리하도록 강제합니다.
TypeScript는 기본적으로 그렇지 않습니다. 던지는 함수와 던지지 않는 함수가 똑같이 보입니다:
function parsePort(s: string): number {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
throw new Error(`Invalid port: ${s}`);
}
return n;
}
반환 타입은 number라고 합니다. number | Error라고 해야 하고, 더 정확히는 Result<number, Error>라고 해야 합니다. 하지만 그렇지 않으므로, 호출자는 실패에 대비해야 한다는 신호를 받지 못합니다.
던져진 에러는 호출 그래프에서 어떻게 숨는가
호출 스택이 깊어질수록 피해가 누적됩니다. 리프 함수에서 던져진 에러는 중간의 모든 계층이 잡거나 암묵적으로 전파하도록 강제합니다. 이 모든 전파 경로는 어떤 타입 시그니처에도 나타나지 않습니다.
파서를 호출하고, 파서가 검증기를 호출하는 설정 로더를 생각해 보세요. 검증기가 던지면, 파서와 로더 둘 다 던질 수 있는 함수가 됩니다. 하지만 그들의 타입은 변하지 않습니다. loadConfig()를 보고 실패할 수 있다는 것을 알 수 없습니다. 모든 의존성의 모든 줄을 읽거나, 런타임 충돌을 기다려야 합니다.
이것이 타입이 작동해야 하는 방식과 정반대입니다. 타입은 구현을 읽지 않고도 계약을 이해할 수 있도록 존재합니다.
명시적인 에러 반환은 이를 뒤집습니다. 실패 경로가 모든 계층에서 보입니다:
type Result<T, E> = { ok: true; value: T } | { ok: false; error: E };
function parsePort(s: string): Result<number, string> {
const n = parseInt(s, 10);
if (isNaN(n) || n < 0 || n > 65535) {
return { ok: false, error: `Invalid port: ${s}` };
}
return { ok: true, value: n };
}
function loadConfig(raw: Record<string, string>): Result<Config, string> {
const portResult = parsePort(raw.port);
if (!portResult.ok) return portResult;
const host = raw.host || "localhost";
return { ok: true, value: { port: portResult.value, host } };
}
이제 loadConfig의 시그니처가 전체 이야기를 말해줍니다. 본문을 한 줄도 읽지 않고도 실패할 수 있다는 것을 압니다.
진짜 트레이드오프는 표현력이 아니라 인체공학입니다
정직한 접근법은 더 장황합니다. 누구도 throw new Error(...) 대신 { ok: false, error: ... }를 쓰는 것을 즐기지 않습니다. 시각적 노이즈가 쌓이고, 특히 여러 실패 가능한 연산을 연쇄할 때 더 그렇습니다.
이것이 대부분의 checked exception 언어가 이를 포기한 이유입니다. Java의 checked exception은 타입 수준에서 명시적 처리를 강제하려 했지만, 문법이 가혹했습니다. Rust의 ? 연산자와 map, and_then 같은 Result 콤비네이터는 그 인체공학의 대부분을 회복합니다. TypeScript에는 ? 연산자가 없지만, 헬퍼 함수로 거의 비슷하게 만들 수 있습니다:
function map<T, U, E>(result: Result<T, E>, fn: (value: T) => U): Result<U, E> {
return result.ok ? { ok: true, value: fn(result.value) } : result;
}
function flatMap<T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> {
return result.ok ? fn(result.value) : result;
}
Rust만큼은 아니지만 여전히 다소 어색합니다. TypeScript를 쓴다면 neverthrow 같은 라이브러리가 체인 가능한 메서드를 가진 Result 타입을 제공해서 거의 네이티브처럼 느껴집니다. 오버헤드는 실재하지만, 일단 헬퍼가 갖춰지면 생각보다 작습니다.
또 다른 비용이 있습니다. 던져진 에러의 스택 트레이스는 풍부하고 자동입니다. 반환된 에러는 그저 값일 뿐입니다. 스택 트레이스를 원하면 직접 구성해야 합니다. 디버깅을 위해 이것은 중요합니다. 비즈니스 로직에서 예측 가능한 에러 핸들링을 위해선 보통 중요하지 않습니다.
언제 던지기가 여전히 타당한가
우리는 예외를 완전히 없애자고 주장하는 것이 아닙니다. 어떤 실패는 진정으로 예외적이며 정상적인 에러 모델의 일부가 되어서는 안 됩니다. 설정 로딩 중 파일이 없는 것은 예상된 실패입니다. 메모리 부족은 예상된 실패가 아닙니다.
경험의 법칙: 실패가 도메인의 일부라면 반환하라. 프로그래밍 버그나 치명적인 시스템 실패라면 던져라. 잘못된 입력에 대해 JSON.parse가 던지는 것은 성가신 일입니다. 왜냐하면 잘못된 JSON은 흔하기 때문입니다. Array.prototype.map이 null 배열에 대해 던지는 것은 괜찮습니다. 왜냐하면 null에 .map을 호출하는 것은 버그이기 때문입니다.
실무에서는 I/O 경계, 파서, 검증기, 비즈니스 규칙이 에러를 반환해야 합니다. 불변식, 어서션, 진정한 재앙적인 조건은 여전히 던져도 됩니다.
API 경계부터 시작하라
전체 코드베이스를 리팩토링할 필요는 없습니다. 명시적 에러 반환을 도입할 가치가 가장 높은 곳은 공개 API와 I/O 계층입니다. 이것이 호출자가 무엇이 잘못될 수 있는지 알아야 하는 경계입니다.
하나의 모듈을 고르세요. 반환 타입을 Result<T, E>로 바꾸세요. 호출자를 업데이트하세요. 느낌이 어떤지 보세요. 이 패턴은 특정한 방식으로 중독성이 있습니다: 빠진 try/catch 블록을 스택 트레이스에서 찾아 헤매는 것을 그만두면, 던져진 예외가 얼마나 많은 정신적 오버헤드를 비용으로 삼았는지 깨닫게 됩니다.
에러를 위한 타입 수준 정확성은 완벽한 코드를 쓰는 것이 아닙니다. 더 적게 거짓말하는 코드를 쓰는 것입니다.