프롤로그 — 에러 핸들링은 설계다
대부분의 코드 리뷰에서 에러 핸들링은 마지막 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초를 기다리는 건 "타임아웃이 없는 것"과 거의 같다 — 그 사이 자원은 다 잡혀 있다.
import requests
# 나쁨: 타임아웃 없음. 영원히 멈출 수 있다.
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장의 축(예상/일시/회복가능)으로 분류했는가?
- 예상된 도메인 실패는 에러 값/타입으로, 시그니처에 드러나게 했는가?
- 예상하지 못한 버그는 fail fast(예외/panic)로, 삼키지 않게 했는가?
- 입력 검증을 경계에서 한 번 하고 코어는 신뢰하는가?
- 코어의 불변식은 검증이 아니라 단언으로 지키는가?
- 모든 원격 호출에 타임아웃이 있는가? (예외 없이)
- 타임아웃이 기본값(보통 너무 김)이 아니라 의도적으로 정해졌는가?
- 타임아웃이 실제 취소와 묶여 있는가?
- 재시도 가능한 에러만 재시도하는가? (
4xx는 아님) - 재시도에 지수 백오프 + 지터가 있는가?
- 재시도가 중첩되지 않고, 횟수와 시간 예산이 둘 다 제한되는가?
- 쓰기 작업의 재시도가 멱등성 키로 안전한가?
- 핵심 의존성에 서킷 브레이커가, 부가 기능에 폴백이 있는가?
- 에러가 구조화되어 있고(
code/retryable/context), 메트릭으로 측정되는가?
안티패턴 10가지
- 빈 catch —
catch (e) {}. 실패를 조용히 삼킨다. 디버깅 불가능의 근원. - 모든 것을 재시도 —
4xx를 재시도해 영구 실패를 무한 반복, 부하만 증폭. - 지터 없는 재시도 — 동기화된 무리가 다운스트림을 회복할 틈마다 다시 때린다.
- 중첩 재시도 — 3×3×3 = 27회. 한 층에서만 재시도하라.
- 타임아웃 없는 호출 — 느린 의존성 하나가 풀을 고갈시켜 전체를 멈춘다.
- 취소 없는 타임아웃 — 기다리길 멈췄지만 작업은 백그라운드에서 자원을 계속 먹는다.
- 재시도되는 비멱등 쓰기 — 이중 청구, 중복 주문. 멱등성 키가 없는 결제 API.
- 흩뿌려진 검증 — 같은 검증을 코어 곳곳에서 반복, 일관성 없음, 비즈니스 로직이 묻힘.
- 에러 으깨기 —
catch후 원인을 버리고 새 일반 에러를 던짐. 근본 원인 추적 불가. - 불투명한 에러 메시지 —
"Error 500". 사용자도 운영자도 미래의 개발자도 행동할 수 없다.
다음 글 예고
코드 레벨에서 잘 실패하는 법을 봤다. 그런데 한 가지 가정이 남아 있다 — 우리는 이 코드가 정말 잘 실패하는지 어떻게 아는가? 해피 패스만 테스트하고 "타임아웃 처리 코드"는 한 번도 실행 안 해본 채 배포하는 일이 흔하다.
다음 글은 실패를 테스트하는 법이다. 결함 주입(fault injection)을 단위·통합 테스트에 넣기, 타임아웃·부분 실패·서킷 열림을 결정론적으로 재현하기, 그리고 거기서 자연스럽게 카오스 엔지니어링 — 프로덕션에 가까운 환경에서 실패를 일부러 일으켜 시스템이 정말 우아하게 실패하는지 검증하는 일 — 로 이어진다. 잘 실패하도록 설계했다면, 그 다음은 그 설계가 진짜인지 증명하는 것이다.
현재 단락 (1/305)
대부분의 코드 리뷰에서 에러 핸들링은 마지막 5분에 다뤄진다. "여기 `try/catch` 빠졌네요", "이 에러 로깅 좀 추가하죠" — 기능이 다 만들어진 뒤에 붙이는 장식처럼.