프롤로그 — 에러 핸들링은 설계다
대부분의 코드 리뷰에서 에러 핸들링은 마지막 5분에 다뤄진다. "여기 `try/catch` 빠졌네요", "이 에러 로깅 좀 추가하죠" — 기능이 다 만들어진 뒤에 붙이는 장식처럼.
그런데 프로덕션에서 깨지는 시스템을 한 시간만 들여다보면 깨닫게 된다. **장애의 원인은 거의 항상 "잘못 처리된 실패"지, "처리되지 않은 성공"이 아니다.** 해피 패스는 어차피 잘 동작한다. 시스템을 무너뜨리는 건 타임아웃 없는 호출, 무한히 재시도하는 루프, 절반만 적용된 트랜잭션, 사용자에게 `undefined`를 그대로 토해내는 에러 메시지다.
> 에러 핸들링은 기능을 다 만든 뒤 붙이는 게 아니다. **에러 핸들링이 곧 설계다.** 함수의 시그니처, 모듈의 경계, 호출 그래프의 모양 — 전부 "이게 실패하면 어떻게 되는가"라는 질문에서 나온다.
이 글은 **코드 레벨**에서 잘 실패하는 소프트웨어를 설계하는 법을 다룬다. 카오스 엔지니어링(인프라를 일부러 망가뜨려 검증하는 일)은 다른 글의 주제다. 여기서는 함수 하나, 클래스 하나, 호출 하나를 어떻게 쓰면 시스템 전체가 우아하게 실패하는지를 본다.
다루는 순서: 실패의 분류 → 예외 대 에러 값 → Result 타입 → 경계 원칙 → 타임아웃 → 재시도와 백오프 → 멱등성 → 서킷 브레이커와 격벽 → 우아한 성능 저하 → 에러 메시지와 관측성. 코드는 TypeScript, Go, Python을 섞어 쓴다 — 언어가 아니라 패턴이 핵심이기 때문이다.
1장 · 실패에도 종류가 있다
모든 실패를 똑같이 다루는 코드는 모든 실패를 잘못 다룬다. 처리 전략을 정하려면 먼저 **분류**해야 한다. 세 개의 축이 있다.
축 1 — 예상된 실패 대 예상하지 못한 실패
**예상된 실패**는 정상적인 동작의 일부다. 사용자가 없는 ID로 조회하면 "404", 잔액이 부족하면 "결제 거절". 이건 버그가 아니라 **도메인의 일부**다. 코드 흐름으로 다뤄야지 예외로 던지면 안 된다.
**예상하지 못한 실패**는 가정이 깨진 것이다. 널이 아니어야 할 값이 널이거나, 절대 닿지 않아야 할 분기에 닿았거나. 이건 **버그**이고, 빨리·크게 실패해서(fail fast) 개발자에게 알려야 한다.
축 2 — 일시적 실패 대 영구적 실패
**일시적(transient) 실패**는 다시 시도하면 성공할 수 있다. 네트워크 순간 단절, DB 커넥션 풀 고갈, `429 Too Many Requests`, `503 Service Unavailable`. 재시도가 의미 있다.
**영구적(permanent) 실패**는 백 번 시도해도 똑같이 실패한다. `400 Bad Request`, `401 Unauthorized`, `404 Not Found`, `422 Unprocessable Entity`. 재시도는 부하만 키운다.
축 3 — 회복 가능한 실패 대 치명적 실패
**회복 가능한** 실패는 이 요청 하나는 실패하지만 프로세스는 계속 살아 있어도 된다. 외부 API 호출 실패 → 이 요청만 에러 응답.
**치명적(fatal)** 실패는 프로세스의 불변식이 깨진 것이다. 메모리 손상, 설정 파일 파싱 실패(부팅 시), 닫힌 채널에 쓰기. 이때는 차라리 **크래시하고 재시작**하는 게 안전하다. 좀비 프로세스보다 깨끗한 재시작이 낫다.
분류표
| 분류 | 예시 | 전략 |
| --- | --- | --- |
| 예상 + 영구 | 잔액 부족, 검증 실패 | 도메인 에러 값으로 반환, 사용자에게 설명 |
| 예상 + 일시 | `429`, `503`, 락 경합 | 백오프 재시도, 한계 도달 시 포기 |
| 비예상 + 회복가능 | 외부 API의 알 수 없는 `500` | 로깅 + 이 요청만 실패, 알림 |
| 비예상 + 치명 | 설정 손상, 불변식 위반 | fail fast, 크래시, 재시작 |
이 분류가 머릿속에 없으면 두 가지 안티패턴이 나온다. (1) 모든 걸 재시도해서 영구 실패를 무한 반복하거나, (2) 모든 걸 똑같이 삼켜서 치명적 버그가 조용히 묻힌다.
2장 · 예외 대 에러 값
실패를 어떻게 **표현**할 것인가. 두 진영이 있다.
**예외(exception)**: 실패하면 던지고, 호출 스택을 거슬러 올라가다 누군가 잡는다. Java, Python, C#, JavaScript의 기본 모델.
**에러 값(error as value)**: 실패를 보통의 반환값으로 표현한다. 함수가 "결과 또는 에러"를 돌려주고, 호출자가 명시적으로 확인한다. Go, Rust의 모델.
예외의 문제
예외의 가장 큰 문제는 **시그니처에 보이지 않는다**는 것이다.
function getUser(id: string): User {
// 이 함수가 던질 수 있을까? 시그니처만 봐선 모른다.
// 던진다면 무엇을? 어디서 잡아야 하나?
}
타입이 `User`라고 말하지만 실제로는 "`User` 또는 어딘가에서 던져지는 무언가"다. 호출자는 무엇을 잡아야 하는지 모르고, 그래서 둘 중 하나를 한다. 아무것도 안 잡거나(크래시), 전부 잡거나(`catch (e) {}` — 모든 걸 삼킴).
게다가 예외는 **제어 흐름을 비지역적으로 만든다.** `throw`는 사실상 "여기서 알 수 없는 어딘가로 가는 goto"다. 코드를 읽을 때 정상 흐름과 에러 흐름이 분리되어 보이지 않는다.
에러 값의 문제
에러 값은 정직하지만 **장황하다.** Go 코드의 그 유명한 풍경:
user, err := getUser(id)
if err != nil {
return nil, err
}
account, err := getAccount(user.ID)
if err != nil {
return nil, err
}
balance, err := getBalance(account.ID)
if err != nil {
return nil, err
}
세 줄마다 `if err != nil`. 그리고 깜빡하면? Go는 반환값을 무시해도 컴파일된다(린터로 막아야 한다).
좋은 절충: 둘을 섞되 규칙을 둔다
실용적인 답은 "하나만 쓴다"가 아니라 **각각을 제 용도에 쓴다**는 것이다.
- **예상된 도메인 실패** → 에러 값 / 타입으로. 호출자가 반드시 다뤄야 하므로 시그니처에 드러나야 한다.
- **예상하지 못한 버그** → 예외로(혹은 panic). 어차피 회복 불가능하니 스택을 타고 올라가 최상단에서 잡혀 로깅·크래시되면 된다.
JavaScript/TypeScript라면: 도메인 실패는 판별 유니온(discriminated union)으로 반환하고, 진짜 예외 상황만 `throw`한다. Python이라면: 도메인 실패는 명시적 결과 객체나 좁은 커스텀 예외 계층으로, 시스템 실패는 넓게 잡는 핸들러를 최상단에 둔다.
3장 · Result 타입 — 실패를 타입으로 만들기
에러 값 진영의 가장 세련된 형태가 **Result 타입**이다. "성공 값 또는 에러 값" 둘 중 하나임을 **타입 시스템이 강제**한다.
Rust의 Result
enum Result<T, E> {
Ok(T),
Err(E),
}
fn parse_port(s: &str) -> Result<u16, ParseError> {
s.parse().map_err(|_| ParseError::InvalidPort)
}
Rust에서는 `Result`를 그냥 무시할 수 없다. 안의 값을 꺼내려면 반드시 `Ok`인지 `Err`인지 확인하는 코드를 거쳐야 한다. `?` 연산자가 "에러면 조기 반환" 보일러플레이트를 한 글자로 줄여준다.
Go의 다중 반환
Go는 타입 차원의 합(sum type)이 없어서 관례로 푼다 — 함수가 `(value, error)`를 반환하고, 호출자가 `err`를 확인한다. 합 타입만큼 강제력은 없지만(무시해도 컴파일됨) 문화와 린터가 메운다. Go 1.13+ 의 `errors.Is` / `errors.As` 가 에러 래핑·검사를 표준화했다.
if errors.Is(err, sql.ErrNoRows) {
// "없음"은 예상된 실패 — 도메인 흐름으로
return defaultUser(), nil
}
TypeScript의 판별 유니온
TypeScript에는 내장 `Result`가 없지만 직접 만들면 된다.
type Result<T, E> =
| { ok: true; value: T }
| { ok: false; error: E }
function parsePort(s: string): Result<number, "invalid_port"> {
const n = Number(s)
if (!Number.isInteger(n) || n < 1 || n > 65535) {
return { ok: false, error: "invalid_port" }
}
return { ok: true, value: n }
}
const r = parsePort(input)
if (!r.ok) {
// 컴파일러가 여기서 r.value 접근을 막는다
return reject(r.error)
}
use(r.value) // 여기서는 r.value가 안전하다
Result 타입의 가치 — 그리고 비용
가치는 분명하다. **실패가 시그니처에 드러나고**, 컴파일러가 "이 에러 처리했어?"라고 묻는다. 잊을 수가 없다.
비용도 솔직히 보자. (1) 보일러플레이트 — 언어에 `?` 같은 설탕이 없으면 장황하다. (2) 전염성 — `Result`를 반환하는 함수를 호출하는 함수도 보통 `Result`를 반환해야 한다. (3) 진짜 버그(널 역참조, 배열 범위 초과)에는 안 맞는다 — 그건 예외/panic의 영역이다.
**규칙**: Result 타입은 **예상된 도메인 실패**에 쓴다. "버그"를 Result로 표현하려 하지 마라. 1장의 분류로 돌아가라 — 예상된 실패는 타입으로, 예상하지 못한 버그는 fail fast로.
4장 · 경계 원칙 — 가장자리에서 검증하고 코어를 신뢰하라
가장 강력한 회복탄력성 패턴은 라이브러리가 아니라 **아키텍처 규칙** 하나다.
> 입력은 **시스템의 경계**에서 검증하라. 일단 안으로 들어온 데이터는 **코어 전체에서 신뢰하라.**
경계란 신뢰할 수 없는 데이터가 시스템에 들어오는 지점이다 — HTTP 핸들러, 메시지 큐 컨슈머, CLI 인자 파서, 외부 API 응답을 받는 곳, DB에서 읽은 행. 이 지점에서 **딱 한 번** 엄격하게 검증한다. 통과한 데이터는 잘 정의된 타입으로 바꿔서 안으로 넘긴다.
// 경계: HTTP 핸들러. 여기서 검증한다.
function handleCreateOrder(req: Request): Response {
const parsed = OrderSchema.safeParse(req.body) // zod 등
if (!parsed.success) {
return badRequest(parsed.error) // 경계에서 거절
}
// parsed.data 는 이제 검증된 Order 타입.
return createOrder(parsed.data) // 코어로 넘김
}
// 코어: 검증을 다시 하지 않는다. Order 가 유효함을 신뢰한다.
function createOrder(order: Order): Response {
// order.quantity > 0 인지 또 확인하지 않는다.
// 경계가 보장했다. 비즈니스 로직에 집중한다.
}
왜 이게 중요한가
검증이 코어 전체에 흩뿌려지면 세 가지가 망가진다. (1) 같은 검증을 여러 번 — 어디까지 했는지 아무도 모름. (2) 일관성 없는 검증 — A 경로는 확인하고 B 경로는 빠뜨림. (3) 비즈니스 로직이 방어 코드에 파묻혀 읽히지 않음.
경계에서 한 번 검증하면, 코어 함수의 시그니처가 **곧 계약**이 된다. `createOrder(order: Order)` 가 "유효한 `Order` 를 다오"라고 말하면, 그건 진짜 보장이다 — 함수 본문이 매번 의심할 필요가 없다.
코어의 불변식은 assert로 지킨다
코어에서도 "이건 절대 일어나면 안 된다"는 가정은 있다. 그건 검증이 아니라 **단언(assertion)**으로 표현한다. 검증은 "사용자가 틀릴 수 있다"는 전제고, 단언은 "내 코드가 틀렸다면"이라는 전제다. 단언이 깨지면 그건 버그 — fail fast 해야 한다.
| | 검증 (validation) | 단언 (assertion) |
| --- | --- | --- |
| 위치 | 경계 | 코어 어디든 |
| 전제 | 외부 입력은 못 믿는다 | 내 코드의 불변식 |
| 실패 시 | 정중한 에러 응답 | 크래시 / panic (버그다) |
| 대상 | 사용자/외부 시스템 | 개발자 |
5장 · 타임아웃 — 모든 원격 호출에는 시한이 필요하다
회복탄력성에서 가장 흔하게 빠뜨리는 것 하나만 꼽으라면: **타임아웃**.
> 프로세스 경계를 넘는 모든 호출 — HTTP, DB 쿼리, 캐시, gRPC, 메시지 발행 — 에는 타임아웃이 있어야 한다. 예외는 없다.
타임아웃이 없으면 어떻게 되는가. 다운스트림 서비스가 느려진다(죽은 게 아니라 그냥 느림). 응답을 기다리는 스레드/고루틴/커넥션이 쌓인다. 풀이 고갈된다. 이제 **건강한** 요청도 자원을 못 받는다. 하나의 느린 의존성이 **전체 서비스를 멈춘다.** 이게 장애가 연쇄(cascading failure)되는 가장 흔한 경로다.
기본값은 거의 항상 너무 길다
대부분의 HTTP 클라이언트는 기본 타임아웃이 없거나(무한 대기) 30초·60초처럼 사실상 무한에 가깝다. 사용자 대면 요청 안에서 30초를 기다리는 건 "타임아웃이 없는 것"과 거의 같다 — 그 사이 자원은 다 잡혀 있다.
나쁨: 타임아웃 없음. 영원히 멈출 수 있다.
r = requests.get(url)
좋음: (연결 타임아웃, 읽기 타임아웃)
r = requests.get(url, timeout=(1.0, 3.0))
// Go: context 로 타임아웃을 전파한다
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
resp, err := httpClient.Do(req.WithContext(ctx))
타임아웃 예산 — 더한 값이 부모를 넘으면 안 된다
타임아웃은 호출 그래프 전체에서 **예산**으로 다뤄야 한다. 핸들러 A의 예산이 3초인데, 내부에서 B(2초)와 C(2초)를 순차 호출하면 합이 4초 — A는 이미 끝났다. 자식 타임아웃의 합은 부모 예산 안에 들어와야 한다. `context`(Go)나 `AbortSignal`(JS), 명시적 데드라인 전파로 이 예산을 아래로 흘려보낸다.
타임아웃과 취소는 한 쌍이다
타임아웃이 발동했는데 작업이 백그라운드에서 계속 돌면 자원을 그대로 먹는다. 타임아웃은 반드시 **실제 취소**(`context` 취소, `AbortController.abort()`, 커넥션 종료)와 묶여야 한다. "기다리기를 멈춘다"와 "작업을 멈춘다"는 다르다 — 둘 다 해야 한다.
6장 · 재시도, 지수 백오프, 지터 — 그리고 재시도하면 안 될 때
일시적 실패(1장)에는 재시도가 답이다. 단, **올바르게** 했을 때만. 잘못된 재시도는 약한 장애를 큰 장애로 키운다.
규칙 1 — 재시도해도 되는 것만 재시도하라
재시도 OK: 408, 429, 503, 504, 연결 거부, 연결 타임아웃
재시도 금지: 400, 401, 403, 404, 409, 422 (다시 해도 똑같이 실패)
조심: 500 (원인에 따라 다름 — 기본은 보수적으로)
`400`을 재시도하는 건 같은 잘못된 요청을 다섯 번 보내는 것뿐이다. 부하만 5배.
규칙 2 — 지수 백오프
재시도 간격을 고정하지 마라(`매 1초`). 다운스트림이 과부하라면 고정 간격 재시도는 계속 같은 압력을 가한다. **지수적으로 간격을 늘려라**: 1초, 2초, 4초, 8초.
규칙 3 — 지터를 넣어라 (이게 핵심이다)
순수 지수 백오프에는 숨은 함정이 있다. 100개의 클라이언트가 **동시에** 같은 실패를 겪으면, 전부 1초 뒤에, 다시 2초 뒤에, 4초 뒤에 — **동기화된 무리(thundering herd)**로 재시도한다. 다운스트림은 회복할 틈에 또 100개를 맞는다.
해법은 **지터(jitter)** — 무작위성을 섞어 재시도 시점을 흩뿌린다.
function backoffWithJitter(attempt: number): number {
const base = 100 // ms
const cap = 10_000 // 상한 10초
const exp = Math.min(cap, base * 2 ** attempt)
// "full jitter": 0 ~ exp 사이 균등 난수
return Math.random() * exp
}
AWS의 유명한 분석은 "full jitter"(0부터 계산된 상한까지 균등 난수)가 가장 안정적이라고 결론지었다. 무리가 시간축에 고르게 퍼진다.
규칙 4 — 재시도 횟수와 전체 예산을 둘 다 제한하라
"최대 5회"만으로는 부족하다. 백오프 때문에 5회가 30초 넘게 걸릴 수 있다. **재시도 횟수 상한**과 **전체 시간 예산** 둘 다 둔다 — 둘 중 하나라도 닿으면 포기.
규칙 5 — 재시도를 중첩하지 마라
가장 위험한 안티패턴. A가 B를 3회 재시도, B가 C를 3회 재시도, C가 D를 3회 재시도 → D에 대한 실제 시도는 **27회**. 재시도는 스택의 **한 층**에서만 한다. 보통은 가장 바깥(혹은 클라이언트에 가장 가까운) 한 곳.
규칙 6 — 재시도와 멱등성은 분리할 수 없다
쓰기 작업(`POST`, 결제, 주문 생성)을 재시도하는 순간 곧장 다음 장의 질문에 부딪힌다 — **그 요청이 이미 성공했다면?** 그게 7장이다.
7장 · 멱등성 — 안전하게 재시도하기
**멱등(idempotent)** 연산은 한 번 적용하든 다섯 번 적용하든 결과가 같다. `GET`은 본래 멱등이고, `PUT`/`DELETE`는 보통 멱등이다. 문제는 **`POST`** — "주문 생성", "결제", "메시지 발행" 같은 것.
재시도 시나리오를 보자.
클라이언트 ──POST /payments──▶ 서버: 결제 처리 성공 ✅
클라이언트 ◀────(응답 유실)──── 네트워크가 응답을 삼킴 ❌
클라이언트: "타임아웃이네, 재시도하자"
클라이언트 ──POST /payments──▶ 서버: 또 결제 처리?? 💸💸
클라이언트는 첫 시도의 성공 여부를 **알 수 없다.** 재시도하면 이중 청구, 재시도 안 하면 결제 누락. 둘 다 나쁘다.
해법 — 멱등성 키 (idempotency key)
클라이언트가 요청마다 **고유한 키**(UUID 등)를 만들어 헤더에 싣는다. 같은 작업을 재시도하면 **같은 키**를 쓴다. 서버는 키별로 결과를 저장한다.
첫 요청: Idempotency-Key: abc-123 → 키 없음 → 실행 → 결과 저장 → 응답
재시도: Idempotency-Key: abc-123 → 키 있음 → 실행 안 함 → 저장된 결과 반환
async function createPayment(key: string, body: PaymentBody) {
const existing = await store.get(key)
if (existing) return existing.response // 재생: 다시 실행 안 함
// 경쟁 상태 주의: 키를 "진행 중"으로 먼저 선점한다
const claimed = await store.claim(key) // 원자적 insert
if (!claimed) return await store.waitFor(key) // 다른 워커가 처리 중
const response = await reallyCharge(body)
await store.complete(key, response) // 결과 영속화
return response
}
멱등성 키 설계 — 디테일이 전부다
- **키는 클라이언트가 만든다.** 서버가 만들면 재시도가 새 키를 받아 멱등성이 깨진다.
- **경쟁 상태를 막아라.** 두 재시도가 동시에 도착할 수 있다. 키 선점은 **원자적**이어야 한다(`INSERT ... ON CONFLICT`, 유니크 제약, 분산 락).
- **요청 본문을 키에 묶어라.** 같은 키 + 다른 본문 = 클라이언트 버그. `409`로 거절하라(조용히 옛 결과를 주지 마라).
- **TTL을 정하라.** 키를 영원히 들고 있을 순 없다. 보통 24시간 정도면 재시도 윈도우를 충분히 덮는다.
- **부분 실패를 다뤄라.** "진행 중"에서 워커가 죽으면? 그 키는 갇힌다. 타임아웃 후 재시도 가능하게 하거나, "진행 중" 상태에 만료를 둔다.
멱등성은 클라이언트 책임이기도 하다
서버만의 일이 아니다. 클라이언트가 재시도 전반에 걸쳐 **같은 키를 유지**해야 한다. 재시도 루프 안에서 키를 새로 만들면 멱등성 전체가 무너진다. 키는 재시도 루프 **바깥**에서 한 번 만든다.
8장 · 서킷 브레이커, 격벽, 폴백 — 장애를 가두기
타임아웃·재시도·멱등성은 **호출 하나**를 다룬다. 이번 장의 패턴들은 더 큰 그림 — **장애가 퍼지지 않게 가두는** 일을 한다.
서킷 브레이커 (circuit breaker)
다운스트림 의존성이 확실히 죽었다면, 매 요청마다 타임아웃을 기다리는 건 낭비다. **서킷 브레이커**는 실패를 추적하다가 임계치를 넘으면 **회로를 연다** — 그 뒤로는 다운스트림을 호출도 하지 않고 즉시 실패시킨다(fail fast). 죽은 서비스를 두드리길 멈추니 회복할 틈을 준다.
세 가지 상태가 있다.
실패율 임계치 초과
CLOSED ───────────────────▶ OPEN
▲ │
│ │ 쿨다운 타이머 만료
│ 시험 호출 성공 ▼
└──────────────────── HALF_OPEN
시험 호출 실패 ──▶ 다시 OPEN
CLOSED : 정상. 호출 통과. 실패를 센다.
OPEN : 망가짐. 호출 안 함. 즉시 실패. 쿨다운 대기.
HALF_OPEN : 시험 중. 호출 몇 개만 통과시켜 회복 여부 확인.
class CircuitBreaker {
private state: "CLOSED" | "OPEN" | "HALF_OPEN" = "CLOSED"
private failures = 0
private openedAt = 0
async call<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === "OPEN") {
if (Date.now() - this.openedAt < this.cooldownMs) {
throw new CircuitOpenError() // 시도조차 안 함
}
this.state = "HALF_OPEN" // 쿨다운 끝 — 한 번 시험해본다
}
try {
const result = await fn()
this.onSuccess() // CLOSED 로 복귀, 카운터 리셋
return result
} catch (err) {
this.onFailure() // 임계치 넘으면 OPEN 으로
throw err
}
}
}
핵심 가치: 서킷이 열리면 호출자는 **30초가 아니라 즉시** 실패 응답을 받는다. 그 빠른 실패가 폴백(아래)을 발동시킬 여유를 만든다.
격벽 (bulkhead)
배의 격벽에서 온 이름이다 — 한 구획에 물이 차도 배 전체가 가라앉지 않게 칸을 나눈다. 소프트웨어에서는 **자원을 분리된 풀로 나누는** 것이다.
서비스가 의존성 A, B, C를 호출하는데 커넥션 풀 하나(100개)를 공유한다고 하자. A가 느려지면 100개 커넥션이 전부 A를 기다리느라 잡힌다 — B와 C는 멀쩡한데도 호출할 커넥션이 없다. **A 하나의 장애가 B, C까지 끌고 내려간다.**
격벽: A·B·C에 각각 별도 풀을 준다(예: 40/30/30). 이제 A가 풀을 다 먹어도 그건 A의 40개뿐 — B와 C는 자기 풀로 정상 동작한다. **장애가 한 칸에 갇힌다.**
폴백 (fallback)
호출이 실패했을 때(또는 서킷이 열렸을 때) **차선의 답**을 줄 수 있는가? 그게 폴백이다.
| 1순위 (실패) | 폴백 |
| --- | --- |
| 실시간 추천 엔진 | 캐시된 인기 상품 목록 |
| 라이브 환율 API | 마지막으로 알려진 환율 + "지연됨" 표시 |
| 개인화 홈 피드 | 일반 큐레이션 피드 |
| 정밀 재고 수량 | "재고 있음 / 없음" 만 |
폴백의 철칙: 폴백은 **1순위보다 단순하고 의존성이 적어야** 한다. 1순위가 죽는 바로 그 이유로 같이 죽는 폴백은 폴백이 아니다. 좋은 폴백은 보통 캐시된 데이터, 정적 기본값, 또는 "지금은 못 보여줘요"라는 정직한 부분 응답이다.
9장 · 우아한 성능 저하 — 부드럽게 실패하기
8장의 패턴들을 하나의 사고방식으로 묶으면 **우아한 성능 저하(graceful degradation)**다.
> 시스템의 한 부분이 실패할 때, **0과 1 사이의 무언가**로 떨어질 수 있어야 한다. 완전 동작 아니면 완전 정지, 두 상태만 있는 시스템은 부서지기 쉽다.
전부 아니면 전무는 부서지기 쉽다
전자상거래 상품 페이지를 보자. 다음을 호출한다: 상품 정보, 가격, 재고, 리뷰, 추천, 개인화 배너. 리뷰 서비스가 죽으면 어떻게 되는가?
**부서지는(brittle) 설계**: 페이지 전체가 `500`. 사용자는 살 수 있는 상품을 사지 못한다 — 리뷰가 안 떴다는 이유로.
**탄력적(resilient) 설계**: 페이지가 뜬다. 상품·가격·재고·구매 버튼 — 다 정상. 리뷰 자리에는 "리뷰를 불러올 수 없습니다"가 뜬다. **핵심 기능(구매)은 살아 있다.** 부가 기능(리뷰)만 우아하게 빠진다.
기능을 핵심과 부가로 나눠라
이걸 하려면 의식적으로 분류해야 한다.
- **핵심(critical)**: 이게 죽으면 요청도 죽는다. 상품 페이지의 "상품 정보 + 가격". 정직하게 실패하라.
- **부가(enhancement)**: 이게 죽어도 요청은 살아야 한다. "추천 + 리뷰". 빈 자리로, 캐시로, 플레이스홀더로 대체하라.
코드 구조가 이 분류를 반영해야 한다. 부가 기능 호출은 자체 에러를 격리한다 — 그 실패가 핸들러 전체로 전파되면 안 된다.
async function getProductPage(id: string): Promise<ProductPage> {
// 핵심: 실패하면 전체 실패. 정직하게.
const [product, price] = await Promise.all([
getProduct(id),
getPrice(id),
])
// 부가: 각각 격리. 실패해도 페이지는 산다.
const reviews = await getReviews(id).catch(() => null)
const recs = await getRecommendations(id).catch(() => [])
return {
product,
price,
reviews, // null 일 수 있음 — UI가 처리
recommendations: recs, // 빈 배열일 수 있음
degraded: reviews === null, // 클라이언트에게 솔직히 알린다
}
}
부하 차단 — 의도적 성능 저하
성능 저하는 부분 장애에만 쓰는 게 아니다. 시스템이 과부하라면 **들어오는 일부를 의도적으로 거절**하는 게 전부를 느리게 죽이는 것보다 낫다. 부하 차단(load shedding): 큐가 가득 차면 새 요청을 빠르게 `503` + `Retry-After`로 거절한다. 90%를 빠르고 정직하게 거절하고 10%를 잘 처리하는 게, 100%를 받아 전부 타임아웃 내는 것보다 낫다. **포화 상태에서는 거절도 기능이다.**
10장 · 에러 메시지와 관측성 — 사람이 행동할 수 있는 에러
에러를 잘 다뤄도 그 에러가 **불투명**하면 절반만 한 것이다. 에러는 결국 **사람이 읽는다** — 디버깅하는 개발자, 화면을 보는 사용자.
좋은 에러 메시지의 세 청중
| 청중 | 필요한 것 | 나쁜 예 | 좋은 예 |
| --- | --- | --- | --- |
| 최종 사용자 | 무엇을 할지 | "Error 500" | "결제를 처리하지 못했어요. 카드는 청구되지 않았습니다. 다시 시도해 주세요." |
| 운영자 / 온콜 | 어디가 깨졌나 | "Something went wrong" | "payment-svc → stripe 호출 타임아웃 (2s), order_id=789" |
| 미래의 개발자 | 왜 깨졌나 | 스택 트레이스만 | 스택 + 입력 컨텍스트 + 어떤 불변식이 깨졌나 |
세 청중을 한 문자열로 만족시킬 순 없다. 그래서 에러를 **구조화**한다.
구조화된 에러 — 문자열이 아니라 데이터
`"user 123 not found in tenant 9"` 같은 문자열을 던지지 마라. 검색·집계·라우팅이 안 된다. 대신 **필드를 가진 구조**로:
type AppError = {
code: string // 안정적·기계 판독 가능: "PAYMENT_TIMEOUT"
message: string // 사람용 (개발자)
retryable: boolean // 호출자가 재시도해도 되나?
context: Record<string, unknown> // order_id, tenant, 시도 횟수...
cause?: unknown // 원래 에러 — 체인을 보존
}
이게 있으면: `code`로 메트릭을 집계할 수 있고, `retryable`로 재시도 로직이 분기할 수 있고, `context`가 재현에 필요한 걸 담고, `cause`가 근본 원인까지의 사슬을 보존한다.
에러를 래핑하되 컨텍스트를 더하라
에러가 스택을 타고 올라갈 때, 각 층은 **자기 컨텍스트를 더하되 원본을 보존**해야 한다. 으깨지 말고 감싸라.
// 나쁨: 원래 에러를 버린다. 어디서 났는지 영영 모른다.
if err != nil {
return errors.New("something failed")
}
// 좋음: 컨텍스트를 더하고, 원인을 %w 로 보존한다
if err != nil {
return fmt.Errorf("charging order %s: %w", orderID, err)
}
// 호출자는 errors.Is(err, context.DeadlineExceeded) 로 여전히 검사 가능
구조화 로깅 — 산문이 아니라 이벤트
`log.Error("payment failed for user " + id)` 같은 줄은 검색·집계가 안 된다. **구조화된 키-값**으로 찍어라.
logger.error("payment_failed", extra={
"error_code": "PAYMENT_TIMEOUT",
"order_id": order_id,
"downstream": "stripe",
"duration_ms": 2013,
"attempt": 2,
"trace_id": trace_id, # 분산 추적과 잇는다
})
이제 "지난 1시간 `error_code=PAYMENT_TIMEOUT` 를 `downstream` 별로"를 쿼리할 수 있다. `trace_id`는 이 로그를 분산 추적의 전체 요청 흐름에 연결한다. 그게 산문 로그와 관측 가능한 시스템의 차이다.
무엇을 셀 것인가
에러 처리는 **에러 메트릭**까지 포함해야 완성이다. 최소한 이건 카운터로:
- 에러율 — `code` 별, 엔드포인트 별
- 재시도 횟수 / 재시도 소진 횟수
- 서킷 브레이커 상태 전이 (몇 번 열렸나)
- 타임아웃 발생 — 호출 대상별
- 폴백 발동 횟수 (부가 기능이 얼마나 자주 빠지나)
이게 안 보이면 시스템은 **조용히** 성능 저하한다 — 누군가 불평할 때까지. 좋은 에러 처리는 실패를 **눈에 보이게** 만든다.
에필로그 — 잘 실패하는 것이 곧 잘 설계하는 것
처음의 명제로 돌아간다. **에러 핸들링은 기능을 다 만든 뒤 붙이는 장식이 아니라 설계 그 자체다.**
이 글을 관통하는 하나의 실은 이것이다 — **실패를 명시적으로 만들어라.** 시그니처에서 보이게, 타입에서 강제되게, 메트릭에서 측정되게, 로그에서 검색되게. 숨겨진 실패가 시스템을 죽인다. 드러난 실패는 다룰 수 있다.
이 모든 패턴 — 분류, Result 타입, 경계 검증, 타임아웃, 백오프, 멱등성, 서킷 브레이커, 우아한 성능 저하 — 은 같은 한 문장으로 압축된다.
> 모든 호출에 대해 물어라: **이게 실패하면 어떻게 되는가?** 그리고 그 답을 코드에 적어라 — 머릿속이 아니라.
14개 항목 체크리스트
1. 모든 실패를 1장의 축(예상/일시/회복가능)으로 **분류**했는가?
2. **예상된 도메인 실패**는 에러 값/타입으로, 시그니처에 드러나게 했는가?
3. **예상하지 못한 버그**는 fail fast(예외/panic)로, 삼키지 않게 했는가?
4. 입력 검증을 **경계에서 한 번** 하고 코어는 신뢰하는가?
5. 코어의 불변식은 검증이 아니라 **단언**으로 지키는가?
6. **모든 원격 호출**에 타임아웃이 있는가? (예외 없이)
7. 타임아웃이 기본값(보통 너무 김)이 아니라 **의도적으로** 정해졌는가?
8. 타임아웃이 **실제 취소**와 묶여 있는가?
9. **재시도 가능한 에러만** 재시도하는가? (`4xx`는 아님)
10. 재시도에 **지수 백오프 + 지터**가 있는가?
11. 재시도가 **중첩되지 않고**, 횟수와 시간 예산이 둘 다 제한되는가?
12. 쓰기 작업의 재시도가 **멱등성 키**로 안전한가?
13. 핵심 의존성에 **서킷 브레이커**가, 부가 기능에 **폴백**이 있는가?
14. 에러가 **구조화**되어 있고(`code`/`retryable`/`context`), 메트릭으로 **측정**되는가?
안티패턴 10가지
1. **빈 catch** — `catch (e) {}`. 실패를 조용히 삼킨다. 디버깅 불가능의 근원.
2. **모든 것을 재시도** — `4xx`를 재시도해 영구 실패를 무한 반복, 부하만 증폭.
3. **지터 없는 재시도** — 동기화된 무리가 다운스트림을 회복할 틈마다 다시 때린다.
4. **중첩 재시도** — 3×3×3 = 27회. 한 층에서만 재시도하라.
5. **타임아웃 없는 호출** — 느린 의존성 하나가 풀을 고갈시켜 전체를 멈춘다.
6. **취소 없는 타임아웃** — 기다리길 멈췄지만 작업은 백그라운드에서 자원을 계속 먹는다.
7. **재시도되는 비멱등 쓰기** — 이중 청구, 중복 주문. 멱등성 키가 없는 결제 API.
8. **흩뿌려진 검증** — 같은 검증을 코어 곳곳에서 반복, 일관성 없음, 비즈니스 로직이 묻힘.
9. **에러 으깨기** — `catch` 후 원인을 버리고 새 일반 에러를 던짐. 근본 원인 추적 불가.
10. **불투명한 에러 메시지** — `"Error 500"`. 사용자도 운영자도 미래의 개발자도 행동할 수 없다.
다음 글 예고
코드 레벨에서 잘 실패하는 법을 봤다. 그런데 한 가지 가정이 남아 있다 — **우리는 이 코드가 정말 잘 실패하는지 어떻게 아는가?** 해피 패스만 테스트하고 "타임아웃 처리 코드"는 한 번도 실행 안 해본 채 배포하는 일이 흔하다.
다음 글은 **실패를 테스트하는 법**이다. 결함 주입(fault injection)을 단위·통합 테스트에 넣기, 타임아웃·부분 실패·서킷 열림을 결정론적으로 재현하기, 그리고 거기서 자연스럽게 카오스 엔지니어링 — 프로덕션에 가까운 환경에서 실패를 일부러 일으켜 시스템이 정말 우아하게 실패하는지 검증하는 일 — 로 이어진다. 잘 실패하도록 설계했다면, 그 다음은 그 설계가 진짜인지 증명하는 것이다.
현재 단락 (1/305)
대부분의 코드 리뷰에서 에러 핸들링은 마지막 5분에 다뤄진다. "여기 `try/catch` 빠졌네요", "이 에러 로깅 좀 추가하죠" — 기능이 다 만들어진 뒤에 붙이는 장식처럼.