try-catch가 코드를 망치고 있었다
TypeScript로 복잡한 비즈니스 로직을 짜다 보면 어느 순간 이런 코드가 눈에 들어온다.
let user;
let parsed;
let result;
try {
parsed = JSON.parse(raw);
} catch (e) {
return { error: "파싱 실패" };
}
try {
user = await fetchUser(parsed.id);
} catch (e) {
return { error: "유저 조회 실패" };
}
try {
result = await processUser(user);
} catch (e) {
return { error: "처리 실패" };
}
처음 이 코드를 쓰던 날이 생각난다. 분명히 나는 “잘 짜고 있다”고 느꼈다. 에러 처리를 꼼꼼하게 하고 있었고, 각 단계에서 실패를 적절히 핸들링하고 있었다. 그런데 화면에서 눈을 조금 떼고 나서 다시 봤을 때, 뭔가 불편했다. 읽기가 힘들었다. 코드가 왜 이렇게 생겼지?
let이 강요되는 순간
TypeScript를 쓰는 이유 중 하나는 타입 안정성이다. 그리고 그 안정성을 최대한 활용하려면 const를 쓰는 게 좋다. 값이 재할당되지 않는다는 보장이 있어야 코드를 읽는 사람도 “이 변수는 중간에 바뀌지 않는구나”라고 믿고 읽어 내려갈 수 있다.
그런데 try-catch는 그 믿음을 깨뜨린다. try 블록 안에서 선언한 변수는 catch 블록에서 접근할 수 없다. 스코프가 분리되기 때문이다. 그래서 어쩔 수 없이 바깥에서 let으로 선언해두고, try 블록 안에서 할당하는 패턴을 쓰게 된다. 타입스크립트는 이 변수를 처음엔 undefined로 본다. 결국 이후 코드에서 user! 같은 non-null assertion이 튀어나오거나, 매번 if (user) 체크를 해야 한다.
이건 단순한 스타일 문제가 아니다. 타입 시스템이 우리를 지켜주지 못하는 구간이 생긴다는 뜻이다.
들여쓰기의 저주
let 문제만도 아니었다. 코드의 들여쓰기가 무서운 속도로 깊어지기 시작했다.
에러 처리를 제대로 하려면 try-catch를 써야 하고, try-catch를 쓰면 블록이 생기고, 블록이 중첩되면 들여쓰기가 깊어진다. 거기에 조건 분기까지 들어오면 어떤 코드는 화면 오른쪽 절반에서 살고 있다. 코드를 읽는다기보다 미로를 헤매는 기분이었다.
로직 자체는 단순했다. “유저를 가져와서, 검증하고, 처리하고, 저장한다.” 네 단계짜리 이야기인데 코드를 보면 그 이야기가 보이지 않았다. 에러 처리를 위한 구조물이 이야기를 완전히 가리고 있었다.
이 문제를 해결하려고 몇 가지를 시도했다. 함수를 더 잘게 쪼개거나, 에러를 한 곳에서 처리하거나, 커스텀 에러 클래스를 만들어 instanceof로 분기하거나. 그런데 결국 비슷한 문제로 돌아왔다. 에러가 예외(exception)로 던져지는 한, 흐름이 눈에 보이지 않는다.
함수형 프로그래밍 세계에서 온 개념
그러다 Railway Oriented Programming이라는 개념을 처음 접했다. Scott Wlaschin이 F# 커뮤니티에서 소개하면서 알려진 개념인데, 본질은 생각보다 간단하다.
기차가 두 개의 선로 위를 달린다고 상상해보자. 하나는 성공 선로, 하나는 실패 선로다. 각 연산은 하나의 구간이다. 입력이 성공 선로에 있으면 연산을 수행하고 다음 구간으로 넘긴다. 그런데 어딘가에서 실패가 발생하면, 기차는 실패 선로로 갈아타고 이후 연산들을 전부 건너뛴다. 기차는 계속 달리지만, 실패 선로 위에서는 아무것도 하지 않는다.
이게 전부다. 그런데 이 단순한 비유가 에러 처리에 대한 생각 방식을 완전히 바꿔놓는다.
기존 방식에서 에러는 흐름을 끊는 사건이었다. 어디서 터질지 알 수 없고, 터지면 위로 던져지고, 어딘가에서 잡히지 않으면 프로그램이 죽는다. 눈에 보이지 않는다는 게 핵심 문제다. 함수 시그니처만 봐서는 이 함수가 어떤 에러를 던질 수 있는지 알 수 없다.
Railway 방식에서는 에러가 값이다. 함수는 성공 또는 실패라는 두 가지 상태를 가진 컨테이너를 반환한다. 함수 시그니처 자체가 “나는 실패할 수 있다”는 사실을 명시적으로 드러낸다. 던지지 않으니까 잡을 필요도 없다. 그냥 다음 함수로 넘기면, 그 함수가 알아서 성공일 때만 작동한다.
Result 타입이라는 도구
이 개념을 TypeScript에서 구현하려면 먼저 Result 타입이 필요하다. 함수형 프로그래밍에서는 이걸 보통 Either 또는 Result라고 부른다. Rust의 Result<T, E>가 가장 유명한 구현이고, TypeScript 생태계에서는 neverthrow 같은 라이브러리가 이를 제공한다.
직접 만들면 이렇게 생겼다.
type Ok<T> = { success: true; value: T };
type Err<E> = { success: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;
const ok = <T>(value: T): Ok<T> => ({ success: true, value });
const err = <E>(error: E): Err<E> => ({ success: false, error });
이것만으로도 꽤 많은 게 달라진다. 이제 함수는 에러를 던지는 대신 Result를 반환한다. 호출하는 쪽에서는 타입스크립트가 강제로 두 경우를 모두 처리하게 만든다. 컴파일 타임에 에러 처리를 강요받는다.
그런데 여기서 끝이 아니다. Result를 반환하는 함수들을 연결할 때 또 문제가 생긴다.
const result1 = step1(input);
if (!result1.success) return result1;
const result2 = step2(result1.value);
if (!result2.success) return result2;
const result3 = step3(result2.value);
if (!result3.success) return result3;
try-catch보다는 낫지만, 여전히 반복적이고 지저분하다. 여기서 andThen(또는 flatMap, chain이라고도 불린다)이 등장한다. 이 함수는 성공 상태일 때만 다음 연산을 수행하고, 실패면 그냥 실패를 그대로 전달한다.
const andThen = <T, U, E>(
result: Result<T, E>,
fn: (value: T) => Result<U, E>
): Result<U, E> => {
if (!result.success) return result;
return fn(result.value);
};
이제 앞서 보았던 네 단계짜리 이야기를 코드로 다시 쓸 수 있다.
const pipeline = andThen(
andThen(
andThen(step1(input), step2),
step3
),
step4
);
혹은 pipe 유틸리티와 함께 쓰면 훨씬 더 읽기 좋아진다. “입력을 받아서, step1하고, step2하고, step3하고, step4한다.” 코드가 그 이야기를 그대로 말하기 시작한다.
neverthrow를 쓰기로 한 이유
직접 구현한 Result는 프로젝트가 작을 때는 충분하다. 그런데 비동기 처리, 에러 타입의 유니온, map과 mapErr 같은 유틸리티가 필요해지면 직접 구현체를 계속 발전시켜야 한다. 어느 시점부터는 결국 이미 잘 만들어진 라이브러리를 쓰는 게 낫다는 결론에 이르렀다.
neverthrow는 TypeScript를 위해 설계된 Result 라이브러리다. ResultAsync를 통해 Promise와도 자연스럽게 연결된다. API가 직관적이고, 타입 추론도 잘 된다. 무엇보다 라이브러리 자체가 “절대 throw하지 않는다”는 철학을 이름에 담고 있다는 게 마음에 들었다.
코드가 이야기를 하기 시작했다
레일웨이 방식으로 코드를 다시 작성하고 나서 가장 크게 달라진 건 코드를 읽는 경험이었다. 비즈니스 로직이 눈에 들어왔다. “이 파이프라인은 유저를 파싱하고, 검증하고, 권한을 확인하고, 처리한다”는 이야기가 코드에서 그대로 보였다. 에러 처리는 그 이야기의 흐름을 방해하지 않고 타입 시스템 안에 조용히 녹아들었다.
let도 사라졌다. 각 단계의 결과는 const로 받거나, 파이프라인 안에서 그냥 흘러간다. 타입스크립트가 각 단계에서 값이 무엇인지 정확하게 알고 있다. user! 같은 코드가 필요 없어졌다.
들여쓰기도 평평해졌다. 조건 분기는 andThen 안으로 들어가고, 파이프라인은 수평으로 흐른다. 코드가 오른쪽으로 밀려나지 않는다.
물론 처음에는 낯설었다. Result를 반환하는 함수를 처음 쓸 때 “그냥 throw 던지면 편한데”라는 생각이 드는 건 사실이다. 기존 라이브러리나 API가 예외를 던지는 경우, 그걸 Result로 감싸는 어댑터 레이어도 따로 만들어야 한다. 전환 비용이 없는 건 아니다.
하지만 그 비용을 치르고 나면, 코드가 훨씬 솔직해진다. 함수 시그니처를 보면 이 함수가 실패할 수 있는지 없는지 바로 알 수 있다. 에러를 숨기지 않는다. 호출하는 쪽에서는 타입스크립트가 “너 이 에러 처리 안 했어”라고 알려준다. 런타임에 터지는 게 아니라 컴파일 타임에 잡힌다.
모든 곳에 쓸 필요는 없다
마지막으로 하고 싶은 말은, 이게 은탄환은 아니라는 거다. 간단한 유틸리티 함수에 Result 타입을 도입하는 건 오버엔지니어링이다. 에러가 전혀 없다고 확신할 수 있는 코드에 굳이 두 선로를 깔 필요는 없다.
레일웨이 방식이 특히 빛을 발하는 건 복잡한 비즈니스 로직, 여러 단계를 거치는 파이프라인, 그리고 에러의 종류가 다양해서 타입으로 표현하고 싶을 때다. 외부 API 호출, 데이터베이스 접근, 복잡한 데이터 변환 파이프라인 — 이런 곳에서 Result 타입은 코드의 의도를 명확하게 만들어준다.
처음에는 try-catch가 문제라고 생각했다. 지금은 조금 다르게 본다. try-catch 자체가 나쁜 게 아니라, 에러를 예외로 취급하는 방식이 복잡한 파이프라인과 잘 맞지 않는 거였다. 레일웨이 프로그래밍은 에러를 값으로 격상시켰고, 그 순간 코드가 에러에 대해 훨씬 솔직하게 이야기하기 시작했다.
기차는 아직 달리고 있다. 그냥 이제 어떤 선로 위에 있는지 눈에 보일 뿐이다.