Ver4.0 타입스크립트 에러 핸들링 전략: try-catch를 넘어 Result와 Either 모나드 패턴 직접 구현하기

타입스크립트 에러 핸들링 전략: try-catch를 넘어 Result와 Either 모나드 패턴 직접 구현하기
throw는 타입이 없다. 그런데 우리는 타입스크립트를 쓴다. 이 모순을 오늘 끝내보자.
1. 시작: 타입스크립트에서 try-catch가 어정쩡한 이유
친구야, 솔직히 한 번쯤 생각해봤을 거야.
“타입스크립트는 모든 걸 타입으로 잡아주는데, 왜 에러만 타입이 없지?”
이건 기분 탓이 아니라 언어 스펙의 실제 한계야. 자바스크립트는 throw로 아무거나 던질 수 있어. 문자열도 던지고, 숫자도 던지고, 심지어 undefined도 던질 수 있지.
throw "그냥 문자열";
throw 42;
throw { code: 500 };
그래서 TypeScript 4.0부터 catch 변수의 기본 타입은 any였고, useUnknownInCatchVariables(strict에 포함) 옵션을 켜면 unknown이 돼.
try {
risky();
} catch (e) {
// e: unknown (strict 모드)
console.log(e.message); // ❌ 컴파일 에러
}
즉, catch에 들어온 값이 Error인지조차 보장되지 않는다. 매번 이런 좁히기를 해야 해.
catch (e) {
if (e instanceof Error) console.log(e.message);
else console.log(String(e));
}
try-catch의 진짜 문제 3가지
① 시그니처에 안 드러난다. function getUser(id: string): User 이 함수가 던질 수 있는지 없는지, 타입만 봐서는 절대 모른다. Java의 throws 같은 checked exception이 TS엔 없다.
② 호출자가 잊어버린다. 컴파일러가 “너 이거 에러 처리 안 했어”라고 말해주지 않는다.
③ 제어 흐름이 점프한다. 예외는 스택을 타고 위로 튀어오른다. 어디서 잡힐지 읽기 어렵다.
그래서 등장하는 게 “에러를 값으로 다루기(errors as values)” 전략이야. Rust의 Result<T, E>, Haskell·Scala의 Either, Go의 (value, err)가 전부 같은 철학이지.
2. 그림으로 보는 두 가지 흐름
왼쪽은 어디서 터질지 모르는 세계, 오른쪽은 모든 실패가 타입에 적혀 있는 세계야. 오른쪽으로 가보자.
3. Result 타입 직접 만들기 (30줄이면 끝난다)
거창한 라이브러리 없이도 된다. 핵심은 판별 유니온(Discriminated Union) 하나야.
// 1. 타입 정의
export type Ok<T> = { readonly ok: true; readonly value: T };
export type Err<E> = { readonly ok: false; readonly error: E };
export type Result<T, E = Error> = Ok<T> | Err<E>;
// 2. 생성자
export const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
export const err = <E>(error: E): Err<E> => ({ ok: false, error });
// 3. 타입 가드 (사실 ok 필드만으로도 좁혀짐)
export const isOk = <T, E>(r: Result<T, E>): r is Ok<T> => r.ok;
export const isErr = <T, E>(r: Result<T, E>): r is Err<E> => !r.ok;
여기서 마법은 ok: true | false라는 리터럴 타입 판별자야. TS 컴파일러가 이걸 보고 자동으로 타입을 좁혀준다.
const r: Result<number, string> = ok(10);
if (r.ok) {
r.value.toFixed(2); // ✅ number로 좁혀짐
// r.error // ❌ 존재하지 않음
} else {
r.error.toUpperCase(); // ✅ string
}
왜 readonly를 붙였을까?
Result는 값이다. 값은 변하지 않아야 추론이 안정적이다. 중간에 누가 r.ok = false로 바꾸면 value가 살아있는 유령 상태가 된다. 불변으로 잠그면 이런 사고가 원천 차단된다.
실전 예: 나눗셈과 JSON 파싱
function divide(a: number, b: number): Result<number, "DIV_BY_ZERO"> {
return b === 0 ? err("DIV_BY_ZERO") : ok(a / b);
}
function parseJson<T>(raw: string): Result<T, SyntaxError> {
try {
return ok(JSON.parse(raw) as T);
} catch (e) {
return err(e as SyntaxError);
}
}
보이지? try-catch를 없애는 게 목적이 아니라, 경계에 가두는 게 목적이야. 외부 API(JSON.parse, fetch, fs)는 어차피 throw하니까, 그 껍데기 한 겹만 감싸서 안쪽 세계를 안전하게 만드는 거지.
4. 모나드가 뭔데? — 겁먹지 말자
모나드라는 단어 때문에 도망가는 사람이 많은데, 실무 관점에서 필요한 건 딱 세 가지 연산이야.
| 연산 | 하는 일 | 친숙한 비유 |
|---|---|---|
of / ok | 평범한 값을 상자에 넣음 | Promise.resolve() |
map | 상자 안 값만 변환 (실패면 통과) | array.map() |
flatMap / andThen | 상자를 반환하는 함수 연결 (중첩 제거) | promise.then() |
즉 Promise를 써봤다면 이미 모나드를 써본 거다. then이 flatMap이고, reject가 Err이야. 차이는 Promise는 비동기 + 에러 타입이 any, Result는 동기 + 에러 타입이 명시적이라는 점.
map과 andThen 구현
export function map<T, U, E>(
r: Result<T, E>, fn: (v: T) => U
): Result<U, E> {
return r.ok ? ok(fn(r.value)) : r;
}
export function mapErr<T, E, F>(
r: Result<T, E>, fn: (e: E) => F
): Result<T, F> {
return r.ok ? r : err(fn(r.error));
}
export function andThen<T, U, E, F>(
r: Result<T, E>, fn: (v: T) => Result<U, F>
): Result<U, E | F> {
return r.ok ? fn(r.value) : r;
}
andThen의 반환 타입 Result<U, E | F>를 눈여겨봐. 체인을 이어갈수록 가능한 에러들이 유니온으로 누적된다. 이게 Result 패턴의 최고 장점이야. 함수 시그니처만 봐도 “이 파이프라인은 ParseError 또는 ValidationError 또는 DbError가 날 수 있다”가 보인다.
모나드 법칙(그냥 상식 수준)
좌항등: andThen(ok(x), f) === f(x)
우항등: andThen(r, ok) === r
결합: 체인 순서를 어떻게 묶든 결과가 같다.
이 법칙을 만족하니까 리팩터링해도 동작이 안 바뀐다는 보장이 생기는 거야. 수학이 우리 편이 되는 순간.
5. 체이닝이 불편하다면? 클래스로 감싸기
함수형 스타일 andThen(andThen(map(r, f), g), h)는 괄호 지옥이야. 메서드 체인으로 바꾸면 훨씬 읽기 좋다.
export class Res<T, E> {
private constructor(
private readonly _ok: boolean,
private readonly _v?: T,
private readonly _e?: E
) {}
static ok<T, E = never>(v: T) { return new Res<T, E>(true, v); }
static err<E, T = never>(e: E) { return new Res<T, E>(false, undefined, e); }
isOk(): boolean { return this._ok; }
map<U>(fn: (v: T) => U): Res<U, E> {
return this._ok ? Res.ok(fn(this._v as T)) : Res.err(this._e as E);
}
andThen<U, F>(fn: (v: T) => Res<U, F>): Res<U, E | F> {
return this._ok ? fn(this._v as T) : Res.err<E | F, U>(this._e as E);
}
mapErr<F>(fn: (e: E) => F): Res<T, F> {
return this._ok ? Res.ok(this._v as T) : Res.err(fn(this._e as E));
}
unwrapOr(fallback: T): T { return this._ok ? (this._v as T) : fallback; }
match<R>(h: { ok: (v: T) => R; err: (e: E) => R }): R {
return this._ok ? h.ok(this._v as T) : h.err(this._e as E);
}
}
이제 이렇게 쓸 수 있어.
const result = Res.ok<string, never>(" 42 ")
.map(s => s.trim())
.andThen(parseAge) // Res<number, "NOT_NUMBER">
.andThen(checkAdult) // Res<number, "TOO_YOUNG">
.mapErr(code => ({ code, at: Date.now() }));
const msg = result.match({
ok: age => `통과! 나이 ${age}`,
err: e => `실패: ${e.code}`,
});
match가 핵심이다. 이걸 쓰면 성공·실패 두 갈래를 모두 처리하지 않으면 컴파일이 안 된다. 개발자가 에러 처리를 “깜빡할” 물리적 방법이 사라지는 거지. 이게 checked exception이 없는 TS에서 만들 수 있는 가장 강력한 강제 장치야.
6. Either는 Result와 뭐가 다를까?
둘은 구조는 쌍둥이, 의미는 다르다.
| 구분 | Either<L, R> | Result<T, E> |
|---|---|---|
| 의미 | 둘 중 하나 (중립적) | 성공 또는 실패 (편향적) |
| 구성 | Left / Right | Ok / Err |
| 관례 | Right = 성공(right=옳다 말장난) | Ok = 성공, 명시적 |
| 대표 언어 | Haskell, Scala, fp-ts | Rust, Swift, Kotlin(arrow) |
Either는 “실패”가 아닌 경우에도 쓸 수 있어. 예를 들어 Either<Guest, Member>처럼 두 가지 정상 상태를 표현할 수도 있지. 반면 Result는 처음부터 “에러 처리용”이라고 못 박은 타입이야.
type Left<L> = { readonly _tag: "Left"; readonly left: L };
type Right<R> = { readonly _tag: "Right"; readonly right: R };
type Either<L, R> = Left<L> | Right<R>;
const left = <L>(left: L): Left<L> => ({ _tag: "Left", left });
const right = <R>(right: R): Right<R> => ({ _tag: "Right", right });
// Right-biased map: 오른쪽만 변환한다
const mapR = <L, R, U>(e: Either<L, R>, f: (r: R) => U): Either<L, U> =>
e._tag === "Right" ? right(f(e.right)) : e;
실무 팀에서는 Result 이름을 쓰는 쪽을 추천해. Left가 에러라는 건 학습 비용이 들지만, Err은 신입도 5초면 이해하거든. 협업에서 가독성은 우아함을 이긴다.
7. 비동기와 합치기 — AsyncResult
현실 코드의 90%는 비동기야. Promise<Result<T, E>>가 기본 형태가 된다.
export type AsyncResult<T, E> = Promise<Result<T, E>>;
// throw하는 Promise를 Result로 감싸는 어댑터
export async function tryCatchAsync<T, E>(
fn: () => Promise<T>,
onError: (e: unknown) => E
): AsyncResult<T, E> {
try {
return ok(await fn());
} catch (e) {
return err(onError(e));
}
}
실제 API 호출에 적용해보자.
type ApiError =
| { kind: "NETWORK"; cause: unknown }
| { kind: "HTTP"; status: number }
| { kind: "PARSE"; raw: string };
async function fetchUser(id: string): AsyncResult<User, ApiError> {
const res = await tryCatchAsync(
() => fetch(`/api/users/${id}`),
(cause) => ({ kind: "NETWORK", cause } as const)
);
if (!res.ok) return res;
const response = res.value;
if (!response.ok) {
return err({ kind: "HTTP", status: response.status });
}
const text = await response.text();
try {
return ok(JSON.parse(text) as User);
} catch {
return err({ kind: "PARSE", raw: text });
}
}
이제 호출부는 이렇게 된다.
const r = await fetchUser("u_1");
if (!r.ok) {
switch (r.error.kind) {
case "NETWORK": return toast("네트워크 확인해주세요");
case "HTTP": return toast(`서버 오류 ${r.error.status}`);
case "PARSE": return toast("응답 형식이 이상해요");
}
}
render(r.value);
exhaustiveness check 보너스: switch에 default: const _x: never = r.error;를 넣어두면, 나중에 ApiError에 TIMEOUT을 추가했을 때 컴파일러가 처리 안 한 곳을 전부 찾아준다. 에러 종류가 늘어나도 누락이 0이 되는 구조. 이게 진짜 무기야.
8. 에러 타입 설계법 — 여기서 승패가 갈린다
추천: 태그된 유니온 에러
type DomainError =
| { type: "VALIDATION"; field: string; message: string }
| { type: "NOT_FOUND"; entity: string; id: string }
| { type: "CONFLICT"; reason: string };
왜 Error 클래스를 상속하지 않고 이렇게 할까?
① 직렬화가 쉽다. Error 인스턴스는 JSON.stringify하면 {}가 된다. 서버-클라 통신에서 치명적.
② 구조적 타이핑과 궁합이 좋다. instanceof는 번들 경계나 realm이 다르면 깨지는데, 태그 비교는 절대 안 깨진다.
③ switch 완전성 검사가 동작한다.
안티패턴 경고: Result<T, string>처럼 에러를 문자열로 두지 마. 처음엔 편한데, 3개월 뒤 "not found"와 "Not Found"가 공존하면서 지옥이 열린다. 에러는 반드시 타입으로.
9. 여러 Result 합치기 — combine과 검증 누적
폼 검증에서는 첫 에러에서 멈추면 안 된다. 사용자가 5개 필드를 틀렸는데 하나씩 알려주면 화나잖아.
// 하나라도 실패하면 즉시 실패 (fail-fast)
export function all<T, E>(rs: Result<T, E>[]): Result<T[], E> {
const out: T[] = [];
for (const r of rs) {
if (!r.ok) return r;
out.push(r.value);
}
return ok(out);
}
// 모든 에러를 모은다 (Validation / Applicative 스타일)
export function allSettled<T, E>(rs: Result<T, E>[]): Result<T[], E[]> {
const values: T[] = [], errors: E[] = [];
for (const r of rs) r.ok ? values.push(r.value) : errors.push(r.error);
return errors.length ? err(errors) : ok(values);
}
const formResult = allSettled([
validateEmail(form.email),
validatePassword(form.pw),
validateAge(form.age),
]);
if (!formResult.ok) showFieldErrors(formResult.error); // 한 번에 전부
참고로 함수형 용어로 all은 모나드적(순차, 앞이 실패하면 뒤를 안 봄), allSettled는 애플리커티브(병렬, 전부 평가) 방식이야. fp-ts의 Validation이 바로 이 개념이지.
10. 실전 파이프라인: 회원가입 유스케이스
type SignupError =
| { type: "INVALID_EMAIL" }
| { type: "WEAK_PASSWORD"; need: number }
| { type: "EMAIL_TAKEN"; email: string }
| { type: "DB_DOWN"; cause: unknown };
const validateEmail = (e: string): Result<string, SignupError> =>
/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(e) ? ok(e) : err({ type: "INVALID_EMAIL" });
const validatePw = (p: string): Result<string, SignupError> =>
p.length >= 8 ? ok(p) : err({ type: "WEAK_PASSWORD", need: 8 });
async function signup(dto: { email: string; pw: string }) {
const emailR = validateEmail(dto.email);
if (!emailR.ok) return emailR;
const pwR = validatePw(dto.pw);
if (!pwR.ok) return pwR;
const dup = await tryCatchAsync(
() => db.users.findByEmail(emailR.value),
(cause) => ({ type: "DB_DOWN", cause } as const)
);
if (!dup.ok) return dup;
if (dup.value) return err({ type: "EMAIL_TAKEN", email: emailR.value });
return tryCatchAsync(
() => db.users.create({ email: emailR.value, pw: hash(pwR.value) }),
(cause) => ({ type: "DB_DOWN", cause } as const)
);
}
컨트롤러는 이제 에러를 HTTP로 번역만 하면 된다.
const r = await signup(req.body);
if (!r.ok) {
const map = {
INVALID_EMAIL: [400, "이메일 형식이 올바르지 않아요"],
WEAK_PASSWORD: [400, "비밀번호는 8자 이상"],
EMAIL_TAKEN: [409, "이미 가입된 이메일이에요"],
DB_DOWN: [503, "잠시 후 다시 시도해주세요"],
} as const;
const [status, msg] = map[r.error.type];
return res.status(status).json({ message: msg });
}
return res.status(201).json(r.value);
도메인 로직은 HTTP를 모르고, 컨트롤러는 DB를 모른다. 관심사 분리가 타입 레벨에서 자동으로 이루어진 것이지. 이런 구조 설계 노하우는 재능넷의 개발 멘토링 카테고리에서도 자주 다뤄지는 단골 주제야.
11. 직접 만들까, 라이브러리를 쓸까?
| 선택지 | 특징 | 추천 상황 |
|---|---|---|
| 직접 구현 (30~100줄) | 의존성 0, 학습 비용 최소, 팀 맞춤 | 대부분의 서비스 프로젝트 |
| neverthrow | Result/ResultAsync 중심, API 직관적 | 실용적으로 바로 도입할 때 |
| fp-ts / Effect | Either·TaskEither·의존성 주입까지 풀세트 | 팀 전체가 FP에 합의했을 때 |
| ts-results | Rust 스타일 미니멀 | 가볍게 Rust 감성을 원할 때 |
내 의견은 이래. 처음엔 직접 만들어라. 30줄짜리 Result는 팀원 누구나 열어보고 이해할 수 있고, 필요한 헬퍼만 골라 붙일 수 있어. 라이브러리는 pipe, chain, Kleisli 같은 용어 장벽이 함께 온다.
과설계 경고 — 이럴 땐 쓰지 마라
· 프로토타입, 해커톤: 그냥 try-catch가 빠르다.
· 라이브러리 공개 API: 사용자들은 throw를 기대한다.
· 팀원이 개념을 거부할 때: 이해 못 하는 추상화는 버그 공장이 된다.
· React 컴포넌트 렌더 에러: 이건 ErrorBoundary의 영역이다.
12. 마이그레이션 전략 — 점진적으로
기존 프로젝트 전부를 하루 만에 바꾸는 건 불가능해. 순서는 이렇게.
1단계. tsconfig에 "strict": true를 켜서 catch 변수를 unknown으로 만든다. 여기서부터 시작.
2단계. Result 유틸 파일 하나 추가. 아무도 안 쓰게 그냥 놔둔다.
3단계. 가장 에러가 많은 모듈 하나(보통 결제, 인증, 외부 API 연동)를 골라 Result로 전환.
4단계. 경계 어댑터 작성. 레거시 throw 함수를 감싸는 fromThrowable과, Result를 다시 throw로 바꾸는 unwrapOrThrow를 준비하면 두 세계가 공존할 수 있다.
export function fromThrowable<A extends unknown[], T, E>(
fn: (...args: A) => T,
onError: (e: unknown) => E
) {
return (...args: A): Result<T, E> => {
try { return ok(fn(...args)); }
catch (e) { return err(onError(e)); }
};
}
export function unwrapOrThrow<T, E>(r: Result<T, E>): T {
if (r.ok) return r.value;
throw r.error instanceof Error ? r.error : new Error(JSON.stringify(r.error));
}
5단계. ESLint 규칙으로 신규 도메인 코드에서 throw 금지. 이제 문화가 된다.
13. 자주 밟는 지뢰 5개
① Result를 반환했는데 호출부가 무시한다.
→ @typescript-eslint/no-unused-expressions 정도로는 부족하다. 함수를 _tag가 있는 브랜드 타입으로 만들거나, 코드 리뷰 체크리스트에 넣어라. neverthrow는 no-floating-results 룰을 제공한다.
② 중첩 Result 지옥. Result<Result<T, E1>, E2>가 생겼다면 map을 써야 할 곳에 andThen을 안 쓴 것. 상자를 반환하는 함수엔 무조건 andThen.
③ 에러 유니온이 20개까지 불어난다.
→ 레이어 경계에서 mapErr로 상위 개념으로 축약해라. 인프라 5종 에러를 도메인에서는 {type:"INFRA"} 하나로 접는 식.
④ 스택 트레이스가 사라진다. 태그 유니온 에러엔 스택이 없다. 디버깅용으로 cause 필드에 원본 에러를 담아두고, 로깅 시점에 출력하자. ES2022 Error.cause도 함께 쓰면 좋다.
⑤ 성능 걱정. 사실상 없다. 객체 하나 할당일 뿐이고, V8 기준 예외를 던지고 잡는 비용이 객체 생성보다 훨씬 비싸다. Error 객체는 생성 시 스택 캡처 때문에 특히 무겁다. 뜨거운 루프에서라면 Result가 오히려 빠르다.
14. 정리 — 오늘 가져갈 것
길게 왔는데, 핵심만 다시 짚자.
1. TS의 catch는 unknown이다. 예외는 타입 시스템 밖에 있다.
2. Result<T, E>는 판별 유니온 두 줄이면 만들어진다. 라이브러리 없이도 충분하다.
3. map은 값 변환, andThen은 상자 반환 함수 연결, match는 강제 분기.
4. Either와 Result는 구조가 같고 의미가 다르다. 팀엔 Result 이름을 권한다.
5. 예상 가능한 실패는 Result, 프로그래밍 버그는 throw. 이 경계가 전부다.
6. 에러는 문자열이 아니라 태그된 유니온으로. switch 완전성 검사가 미래의 너를 구한다.
처음엔 if (!r.ok) return r;이 지겹게 느껴질 거야. 근데 두 달쯤 지나면 알게 돼. 그 한 줄이 새벽 3시 장애 알림을 막아준 방패였다는 걸.
에러를 숨기지 말고, 타입으로 드러내자. 그게 타입스크립트를 제대로 쓰는 방법이니까. 더 깊은 실전 코드 리뷰나 아키텍처 상담이 필요하면 재능넷의 개발 전문가들에게 물어보는 것도 좋은 선택이야.
그럼, 오늘도 안전한 코드 쓰길! 🛡️
관련 키워드
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.

댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.