---
id: error-handling-result-vs-throw
title: 에러 처리 — Result 타입 vs throw
category: Coding
status: draft
canonical_id: error-handling-result-vs-throw
aliases: [Result type, Either, exception handling, error model, 에러 모델]
duplicate_of: null
source_trust_level: B
confidence_score: 0.85
verification_status: conceptual
created_at: 2026-05-09
updated_at: 2026-05-09
last_reinforced: 2026-05-09
review_reason: ""
merge_history: []
tags: [coding, error-handling, type-safety, control-flow, vibe-coding]
raw_sources: ["P-Reinforce session 2026-05-09 — bulk Coding seed batch 1"]
tech_stack:
  language: "TypeScript / Rust / Go / Kotlin"
  applicable_to: ["Backend", "Domain logic"]
applied_in: []
---

# 에러 처리 — Result 타입 vs throw

> 모든 에러를 throw 하지 마라. **예측 가능한 실패는 값으로**, **버그·시스템 오류는 throw**. 두 가지를 같은 채널로 합치면 호출자가 "어디까지 처리해야 하는가" 를 알 수 없다.

## 📖 핵심 개념

에러를 두 부류로 분리한다:

1. **예측 가능한 실패 (expected failure)** — 도메인의 정상 흐름의 일부. 예: 잔액 부족, 사용자 중복 가입, 검증 실패.
   → **Result 타입**으로 반환. 호출자가 명시적으로 처리.
2. **시스템 오류 / 버그 (unexpected fault)** — 코드가 가정한 invariant 깨짐. 예: null 참조, DB 연결 끊김, 메모리 부족.
   → **throw**. 호출 스택을 따라 올라가 최상위 핸들러가 잡음.

이 분리는 **타입 시그니처가 진실을 말하게** 만든다. `Result<User, EmailExists>` 를 받은 호출자는 `EmailExists` 를 처리하지 않으면 컴파일 에러.

## 💻 코드 패턴

### Result 타입 정의 (TypeScript)

```ts
type Ok<T> = { ok: true; value: T };
type Err<E> = { ok: false; error: E };
type Result<T, E> = Ok<T> | Err<E>;

const ok = <T>(value: T): Ok<T> => ({ ok: true, value });
const err = <E>(error: E): Err<E> => ({ ok: false, error });
```

### 예측 가능한 실패 — Result 반환

```ts
type RegisterError =
  | { kind: 'EMAIL_EXISTS'; email: string }
  | { kind: 'WEAK_PASSWORD'; reason: string };

async function registerUser(input: SignupInput): Promise<Result<User, RegisterError>> {
  if (await db.users.exists({ email: input.email })) {
    return err({ kind: 'EMAIL_EXISTS', email: input.email });
  }
  const passwordCheck = checkPasswordStrength(input.password);
  if (!passwordCheck.ok) {
    return err({ kind: 'WEAK_PASSWORD', reason: passwordCheck.reason });
  }
  const user = await db.users.create(input);
  return ok(user);
}

// 호출자
const result = await registerUser(input);
if (!result.ok) {
  switch (result.error.kind) {
    case 'EMAIL_EXISTS': return res.status(409).json({ error: 'duplicate' });
    case 'WEAK_PASSWORD': return res.status(422).json({ error: result.error.reason });
  }
}
res.status(201).json(result.value);
```

### 시스템 오류 — throw

```ts
async function findUserById(id: string): Promise<User> {
  const user = await db.users.find(id);
  if (!user) throw new NotFoundError(`User ${id} not found`); // 호출자 invariant 위반
  return user;
}
```

## 🤔 의사결정 기준

| 상황 | Result | throw |
|---|---|---|
| 도메인 검증 실패 (잔액 부족, 중복) | ✅ | ❌ |
| 사용자 입력 형식 오류 | ✅ | ❌ |
| 외부 API 비즈니스 거절 (4xx 성격) | ✅ | ❌ |
| DB 연결 끊김 / 타임아웃 | ❌ | ✅ |
| null 참조 / 배열 out of bounds | ❌ | ✅ (assertion) |
| 코드 가정 위반 (`if (!user) throw`) | ❌ | ✅ |
| 외부 라이브러리가 던지는 에러 | 핸들러 경계에서 catch → Result 변환 | — |

## ❌ 안티패턴

- **모든 에러를 throw**: 호출자는 어떤 에러가 가능한지 모름. catch 안 하면 process crash, catch 하면 광범위 catch(`catch (e)`) 가 되어 진짜 버그도 삼킴.
- **모든 에러를 Result**: NPE / OOM 까지 Result 로 만들면 모든 함수가 `Result<T, AnyError>` 가 되어 의미 없음.
- **빈 catch**: `try { ... } catch {}`. 에러가 사라지고 디버깅 지옥. 최소 로그.
- **throw 한 후 호출자가 catch 가능 여부를 주석으로 안내**: 컴파일러가 강제하지 못하면 실수 발생. checked exception 없는 언어에서는 Result 가 명확.
- **Error 클래스만 만들고 정보 없음**: `throw new Error('failed')` → 디버깅 불가. 최소한 error code / cause / context 포함.
- **Result 안에 throw 가능한 함수 호출 무방비 노출**: Result 함수 안에서 try/catch 로 감싸 변환. 안 그러면 "Result" 라는 약속이 거짓말.

## 🤖 LLM 활용 힌트

- LLM에게 도메인 로직 작성: "**예측 가능한 실패는 Result, 시스템 오류만 throw**" 라고 명시.
- 외부 라이브러리 wrapper 작성: "**try/catch 로 감싸 Result로 변환**" 명시.
- 에러 타입 정의: "**discriminated union 으로 kind 필드 포함**" → 호출자가 exhaustive switch.

## 🧪 검증 상태

- verification_status: `conceptual`
- Rust `Result<T, E>`, Haskell `Either`, Go `(value, err)` 의 공통 철학.
- 적용 사례 발견 시 `applied_in` 추가.

## 🔗 관련 문서

- [[Tagged_Union_Discriminated_Types]]
- [[Guard_Clauses]]
- [[Pure_Functions_in_Practice]]
