Either / Result
Either/Result는 계산 결과가 성공일 수도, 실패일 수도 있다는 사실을 값으로 표현하는 합 타입입니다. 성공이면 Ok나 Right, 실패면 Err나 Left처럼 두 경우 중 하나만 가집니다.
핵심은 실패를 예외로 밖에 던져 버리지 않고, 정상적인 반환값의 일부로 붙들어 둔다는 데 있습니다. 그래서 함수 시그니처만 봐도 '이 함수는 실패할 수 있구나, 실패하면 이런 정보가 오겠구나'가 드러납니다.
▶아키텍처 다이어그램
🔄 프로세스 다이어그램Diagram preview
The interactive diagram loads after the page becomes ready.
점선 애니메이션은 데이터 또는 요청의 흐름 방향을 나타냅니다
예외는 제어 흐름을 코드 바깥으로 튕겨 내기 때문에 눈에 잘 안 들어옵니다. 함수 안 어딘가에서 throw가 나면 호출자는 함수 이름만 보고는 실패 가능성을 읽기 어렵고, 어디서 잡아야 할지도 먼 곳에서 따로 정해야 합니다. 여러 단계를 잇는 파이프라인에서는 어느 지점에서 왜 끊겼는지 추적하기도 까다롭습니다. 반대로 Option만 쓰면 실패했다는 사실은 알 수 있어도 이유가 빠집니다. 성공과 실패를 모두 값으로 다루면서, 실패 원인까지 같이 보존할 타입이 필요해지는 이유가 여기 있습니다.
함수형 언어들은 오래전부터 예외보다 값 중심 오류 처리를 선호했습니다. Haskell의 Either, Elm의 Result, Rust의 Result가 그 대표 예입니다. 이 접근이 힘을 얻은 이유는 타입 시스템과 잘 맞기 때문입니다. 함수 시그니처에서 실패 가능성을 숨기지 않으니, 컴파일러와 IDE가 호출 지점마다 처리를 강제할 수 있습니다. 최근에는 TypeScript, Kotlin, Swift 같은 언어에서도 Result 스타일 API가 늘고 있습니다. 예외를 완전히 없애지는 못하더라도, 도메인 수준 실패는 값으로 다루는 편이 더 예측 가능하다는 경험이 널리 축적됐기 때문입니다.
Either/Result를 다룰 때 중요한 것은 성공과 실패가 같은 채널로 흐른다는 점입니다. 함수를 호출하면 항상 하나의 값이 돌아오고, 그 값의 모양이 Ok인지 Err인지에 따라 다음 단계가 결정됩니다. 성공 경로는 map이나 andThen으로 이어 붙일 수 있고, 실패 경로는 mapError나 패턴 매칭으로 따로 다룰 수 있습니다. 이 구조 덕분에 여러 단계를 연결할 때 '앞 단계가 성공한 경우에만 다음 계산을 실행하고, 실패면 즉시 그 이유를 반환한다'는 규칙을 일관되게 적용할 수 있습니다. 예외와 달리 제어 흐름이 타입 안에 머물기 때문에, 함수 조합이 훨씬 예측 가능해집니다.
예외 대신 Result 반환
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
function parseAge(input: string): Result<number, string> {
const age = Number(input);
if (Number.isNaN(age)) {
return { ok: false, error: "숫자가 아닙니다" };
}
if (age < 0) {
return { ok: false, error: "음수 나이는 허용되지 않습니다" };
}
return { ok: true, value: age };
}parseAge는 실패를 `throw`하지 않고 `ok: false` 값으로 돌려줍니다. 호출자는 함수 시그니처만 보고도 성공과 실패 두 경우를 모두 처리해야 한다는 사실을 알 수 있습니다.
성공일 때만 다음 단계로 연결
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E };
function andThen<T, E, U>(
result: Result<T, E>,
next: (value: T) => Result<U, E>
): Result<U, E> {
if (!result.ok) {
return { ok: false, error: result.error };
}
return next(result.value);
}
function toAdultLabel(age: number): Result<string, string> {
return age >= 18
? { ok: true, value: "adult" }
: { ok: false, error: "성인이 아닙니다" };
}
const ok = andThen(parseAge("20"), toAdultLabel);
// { ok: true, value: "adult" }
const fail = andThen(parseAge("abc"), toAdultLabel);
// { ok: false, error: "숫자가 아닙니다" }앞 단계가 실패하면 다음 계산은 실행되지 않고 기존 오류가 그대로 흘러갑니다. 이 패턴이 Result 기반 파이프라인의 핵심입니다.
Either/Result와 Maybe/Option의 차이는 실패 정보의 유무입니다. Option은 단순 부재만 표현하고, Result는 실패 이유를 함께 표현합니다. 값이 없을 수 있다는 사실만 중요하면 Option이 더 가볍고, 호출자가 사용자 메시지나 복구 전략을 결정해야 하면 Result가 필요합니다. 예외 처리와도 다릅니다. 예외는 실패를 호출자 바깥으로 던져 버리므로 함수 시그니처만 봐서는 실패 가능성을 읽기 어렵습니다. Result는 실패를 반환값 안에 붙들어 두기 때문에 흐름이 지역적이고, 어떤 단계가 실패를 일으킬 수 있는지 추론하기 쉽습니다. 다만 언어와 라이브러리 지원이 약한 환경에서는 예외보다 문법이 장황하게 느껴질 수 있습니다.
Either/Result는 검증, 파싱, 인증, 권한 확인, 외부 입력 해석처럼 실패가 정상적인 분기인 코드에 잘 맞습니다. 실무에서는 사용자 입력을 바로 예외로 터뜨리기보다 Result<UserInput, ValidationError> 같은 값으로 돌려서 UI가 실패 이유를 보여 주거나 복구 경로를 제시하게 만드는 패턴을 자주 씁니다. 또 여러 단계를 조합할 때 어디에서 멈췄는지와 그 이유가 함께 남아서 디버깅과 메시지 생성이 쉬워집니다. 다만 진짜로 복구 불가능한 시스템 수준 오류까지 전부 Result로 감싸면 코드가 과하게 퍼질 수 있으니, 도메인 실패와 시스템 실패는 구분해 두는 편이 좋습니다.