Skip to content

필사 모드: 에러 처리 완전 가이드: 실패를 계약으로 설계하기

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

들어가며

대부분의 코드베이스에서 성공 경로는 설계되고 실패 경로는 발생합니다. 성공 응답의 필드 이름은 리뷰에서 논쟁거리가 되지만, 오류 응답은 "일단 500으로 던지고 나중에 정리하자"로 넘어갑니다. 그 "나중에"는 대개 장애 회고 자리입니다.

이 블로그에는 CORS 에러, 서버를 고쳐야 하는 이유처럼 특정 오류를 해부한 글과, SLI/SLO/Error Budget 기반 신뢰성 엔지니어링처럼 오류를 예산으로 다루는 글이 있습니다. 하지만 애플리케이션 오류 설계 자체를 다룬 글은 없습니다. 이 글이 그 자리를 채웁니다. 실패를 예외 문법의 문제가 아니라 인터페이스 계약의 일부로 놓고, 오류가 계층 경계를 넘을 때마다 무엇을 번역하고 무엇을 버리는지를 따라갑니다.

근거는 RFC 9110, RFC 6585, RFC 9457, 구글 SRE 책의 캐스케이딩 실패 장, 그리고 OWASP 인증 치트시트입니다.


1. 오류를 분류하는 두 축

에러 처리 설계는 분류에서 시작합니다. 분류가 없으면 모든 오류가 catch (Exception e) 한 곳으로 모이고, 그 지점에서는 어떤 판단도 내릴 수 없습니다.

1-1. 축 1 — 예상된 실패인가 버그인가

  • 예상된 실패: 도메인 규칙상 정상적으로 일어날 수 있는 결과입니다. 잔액 부족, 중복 예약, 만료된 쿠폰. 이것은 이며, 함수 시그니처에 나타나야 합니다.
  • 버그: 코드의 불변식이 깨진 상태입니다. null 역참조, 배열 범위 초과, 도달 불가능한 분기. 이것은 값이 아니라 결함이며, 삼키면 안 됩니다.

이 구분을 흐리면 두 가지 사고가 동시에 납니다. 버그를 도메인 오류처럼 사용자에게 보여 주게 되고, 도메인 오류를 500으로 보고해 알림 피로를 만듭니다.

1-2. 축 2 — 재시도 가능한가

  • 재시도 가능: 같은 요청을 잠시 뒤 다시 보내면 성공할 수 있습니다. 일시적 네트워크 오류, 타임아웃, 429, 503.
  • 재시도 불가: 몇 번을 보내도 결과가 같습니다. 400, 401, 403, 404, 422, 대부분의 409.

1-3. 네 사분면과 처리 방침

재시도 가능재시도 불가
예상된 실패백오프 후 재시도, 실패 시 사용자에게 안내사용자에게 교정 방법을 알려 주고 종료
버그존재하지 않음 (있다면 분류가 틀린 것)로깅·알림, 사용자에게는 일반 메시지

"예상된 실패이면서 재시도 가능"이 가장 많은 코드를 필요로 하는 칸입니다. 여기가 5절과 6절의 주제입니다.

1-4. 세 번째 축 — 누구의 잘못인가

클라이언트, 이쪽 서버, 하위 의존성. 이 축은 4절의 HTTP 상태 코드 선택으로 그대로 이어집니다. 하위 의존성의 실패를 4xx로 보고하면 클라이언트가 고칠 수 없는 것을 고치라고 요구하는 셈이 됩니다.


2. 예외와 반환값 — 승자 없는 논쟁

여기는 언어 공동체마다 답이 다르고, 어느 쪽도 상대를 설득하지 못한 자리입니다. 승자를 정하는 대신 축을 봅니다.

예외 방식                       반환값 방식
호출자가 잊어도 컴파일됨        호출자가 처리를 잊기 어려움
시그니처에 안 보일 수 있음      시그니처에 실패가 드러남
중간 계층이 코드를 안 씀        중간 계층마다 전파 코드가 필요
원거리에서 한 번에 처리         호출 지점에서 지역적으로 처리

2-1. 축 다섯 가지

  • 강제성: 호출자가 실패를 무시할 수 있는가. 반환값 방식은 대개 무시하기 어렵고, 검사 없는 예외는 쉽게 잊힙니다.
  • 시그니처 가시성: 함수만 보고 어떤 실패가 가능한지 알 수 있는가.
  • 전파 비용: 오류를 위로 올리는 데 필요한 보일러플레이트의 양. 예외는 여기서 압도적으로 짧습니다.
  • 처리 위치: 실패를 발생 지점 근처에서 다루고 싶은가, 한참 위에서 모아 다루고 싶은가.
  • 정보 보존: 전파 과정에서 원인 체인과 문맥이 얼마나 남는가.

2-2. 각 접근이 지불하는 값

예외 중심 언어는 전파 비용이 거의 0인 대신, 어떤 실패가 어디서 나오는지 시그니처만 봐서는 알 수 없다는 값을 치릅니다. 검사 예외는 그것을 시그니처로 끌어올리는 대신, 인터페이스가 구현 세부에 오염되고 개발자가 빈 catch로 우회하는 경향을 만듭니다. 반환값 중심 언어는 실패를 타입으로 드러내는 대신, 계층마다 전파 코드를 쓰게 되고 그 과정에서 원인 문맥을 덧붙이는 규율이 필요합니다.

실무적 결론은 하나뿐입니다. 팀이 쓰는 언어의 관용을 따르는 것이 가장 싸다는 것입니다. 언어 관용을 거스르는 선택은 라이브러리 생태계, 정적 분석 도구, 신규 입사자의 기대와 전부 싸우게 됩니다.

2-3. 방식과 무관하게 공통인 규칙

  • 실패는 시그니처나 문서 어느 한쪽에는 반드시 드러나야 합니다.
  • 오류를 삼키지 않습니다. 빈 catch는 정보를 지우는 코드입니다.
  • 오류를 문자열 메시지로만 표현하지 않습니다. 분기 가능한 타입이나 코드가 필요합니다.
  • 흐름 제어에 예외를 쓰지 않습니다. 반복문 종료를 예외로 하는 코드는 읽는 사람과 프로파일러를 동시에 속입니다.

3. 경계에서 오류를 번역하기

오류 설계의 대부분은 경계에서 무엇을 번역하고 무엇을 버릴지 정하는 일입니다.

외부 API 클라이언트 ─┐
                     ├─▶ 도메인 오류 ─▶ HTTP 오류 계약 ─▶ 사용자 문구
데이터 저장소 ───────┘        │
                              └─▶ 이벤트·큐 재처리 정책

3-1. 경계별 번역 규칙

경계들어오는 것나가는 것보존할 것
저장소 → 도메인제약 위반, 커넥션 오류도메인 오류, 인프라 오류원인 체인, 재시도 가능 여부
외부 API → 도메인상태 코드, 타임아웃도메인 오류상대 서비스 이름, 상관 ID
도메인 → HTTP도메인 오류상태 코드 + problem details오류 유형 식별자
도메인 → 큐도메인 오류재시도, 지연 재시도, 데드레터시도 횟수, 마지막 원인

3-2. 두 가지 안티패턴

계층 누수가 첫 번째입니다. 저장소의 제약 위반 예외가 컨트롤러까지 그대로 올라오면, 컨트롤러가 저장소 기술을 알아야 하고 저장소를 바꾸는 순간 컨트롤러가 깨집니다.

과도한 래핑이 두 번째입니다. 계층마다 새 예외로 감싸면서 원인을 버리면, 로그에는 "처리 중 오류가 발생했습니다"만 다섯 겹으로 쌓이고 진짜 원인은 사라집니다. 규칙은 간단합니다. 감쌀 때는 반드시 원인을 붙여서 감쌉니다.

3-3. 재시도 가능 플래그를 경계 너머로 옮기기

하위 계층만 아는 정보가 하나 있습니다. 그 실패가 일시적인지 여부입니다. 이 정보를 도메인 오류에 실어 올리지 않으면, 상위 계층은 메시지 문자열을 보고 추측하게 됩니다. 도메인 오류 타입에 재시도 가능 여부를 명시적 속성으로 넣는 것이, 이 글에서 권하는 가장 값싼 개선 중 하나입니다.


4. HTTP 오류 계약: 상태 코드와 problem details

4-1. 상태 코드는 계층 전체가 읽는 값

RFC 9110의 정의를 기준으로 자주 헷갈리는 것을 정리합니다. 400은 클라이언트 오류로 서버가 처리할 수 없는 경우, 401은 "대상 리소스에 대한 유효한 인증 자격 증명이 요청에 없음", 403은 "서버가 요청을 이해했지만 이행을 거부함", 409는 "요청이 대상 리소스의 현재 상태와 충돌함", 422는 콘텐츠 타입과 문법은 이해했지만 지시를 처리할 수 없는 경우입니다. RFC 9110은 §15.5.2에서 401을, §15.5.4에서 403을 정의합니다. 401은 인증, 403은 인가입니다.

429 Too Many Requests는 RFC 9110이 아니라 RFC 6585에 있습니다. 원문은 "사용자가 주어진 시간 동안 너무 많은 요청을 보냈다(레이트 리미팅)"는 뜻이라고 하고, 응답에 Retry-After 헤더를 "포함할 수 있다(MAY)"고 씁니다.

4-2. 본문은 RFC 9457로

RFC 9457은 RFC 7807을 대체하며 application/problem+json 미디어 타입을 정의합니다. §3.1의 멤버는 type(URI 참조, 문제 유형의 주 식별자, 없으면 about:blank), status(조언용, 실제 상태 코드와 일치), title(지역화를 빼면 발생 건마다 바뀌면 안 됨), detail(이번 발생의 설명), instance(이번 발생을 식별하는 URI)입니다.

detail에 대해 스펙은 "디버깅 정보를 주기보다 클라이언트가 문제를 바로잡도록 돕는 데 초점을 맞춰야" 한다고 씁니다. 이 한 문장이 오류 메시지 작성의 기준입니다.

예시 — 재시도 가능 여부까지 담은 오류 응답.

HTTP/1.1 503 Service Unavailable
Content-Type: application/problem+json
Retry-After: 30

{
  "type": "https://api.example.com/problems/upstream-unavailable",
  "title": "Upstream service unavailable",
  "status": 503,
  "detail": "결제 승인 서비스에 일시적으로 연결할 수 없습니다. 30초 후 같은 요청을 다시 보내세요.",
  "instance": "/v1/payments/req_01J9XQ",
  "retryable": true,
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}

§3.2는 확장 멤버를 허용하고 클라이언트가 "인식하지 못하는 확장을 반드시 무시해야 한다(MUST)"고 규정하므로, retryabletraceId 같은 필드를 나중에 추가해도 파괴적 변경이 아닙니다.

4-3. 오류 유형 식별자를 클라이언트 분기의 유일한 근거로

클라이언트가 분기해야 하는 것은 type 값이지 detail 문자열이 아닙니다. 이 규칙을 문서에 명시하지 않으면 클라이언트가 메시지를 정규식으로 매칭하기 시작하고, 그 순간 문구 수정이 배포를 요구하게 됩니다.


5. 재시도 — 멱등성 없이는 재시도도 없다

5-1. 먼저 멱등성

RFC 9110 §9.2.2는 idempotent를 "여러 번의 동일한 요청이 서버에 의도한 효과가 한 번의 요청과 같다"고 정의하고 GET, HEAD, PUT, DELETE, OPTIONS, TRACE를 여기에 포함합니다. POST는 멱등하지 않습니다. 따라서 POST를 재시도하려면 멱등성 키가 필요하고, 키가 없다면 그 요청은 재시도하면 안 되는 요청입니다. 이 판단이 코드에 명시적으로 존재하지 않는 시스템에서는, 재시도가 조용히 중복 결제를 만듭니다.

5-2. 백오프는 반드시 무작위화한다

구글 SRE 책은 재시도를 스케줄할 때 "항상 무작위화된 지수 백오프를 사용하라"고 못 박습니다. 이유는 명확합니다. 고정 간격이나 순수 지수 백오프는 실패 시점이 같은 클라이언트들을 같은 시각에 다시 몰려오게 만들고, 이것이 회복 중인 서버를 다시 쓰러뜨립니다.

시도 1 실패 → 대기 = random(0, 1초)
시도 2 실패 → 대기 = random(0, 2초)
시도 3 실패 → 대기 = random(0, 4초)
상한 도달 → 포기하고 오류를 위로 전달

5-3. 재시도 예산과 상한

같은 문서는 프로세스 단위의 재시도 예산을 권합니다. 예로 든 값은 "프로세스에서 분당 60회의 재시도만 허용"입니다. 그리고 "특정 요청을 무한히 재시도하지 말라"고 명시합니다. 예산이 소진되면 재시도를 멈추고 오류를 그대로 위로 올립니다. 예산이 없는 재시도는 부하가 걸린 순간에 부하를 배로 만드는 장치입니다.

5-4. 다층 재시도가 곱해진다

가장 자주 놓치는 항목입니다. 구글 SRE 책은 세 계층이 각각 4회씩 재시도하면 한 번의 사용자 동작이 4 × 4 × 4 = 64회의 시도가 된다고 지적합니다. 클라이언트 SDK, API 게이트웨이, 서비스 간 클라이언트가 각각 "합리적인" 재시도를 켜 두면 이 상황이 만들어집니다.

대응은 재시도 계층을 하나로 정하는 것입니다. 대개 사용자에게 가장 가까운 한 계층에서만 재시도하고, 나머지 계층은 실패를 즉시 전달합니다. 그리고 재시도 여부를 로그와 트레이스에 표시해 실제 시도 횟수를 관측 가능하게 만듭니다. 재시도 횟수와 성공 확률의 관계는 재시도·누적 확률 계산기로 감을 잡을 수 있습니다.

5-5. 무엇을 재시도하고 무엇을 하지 않는가

  • 재시도한다: 연결 실패, 타임아웃(멱등일 때), 429(Retry-After 존중), 503, 일부 500
  • 재시도하지 않는다: 400, 401, 403, 404, 422, 대부분의 409

구글 SRE 책은 재시도 가능한 오류와 불가능한 오류를 명확한 코드로 구분하고 영구적 오류는 절대 재시도하지 말라고 말합니다. 400을 재시도하는 클라이언트는 자기 버그를 서버 부하로 바꿉니다.

5-6. 거절도 전략이다

같은 장은 부하 차단을 다룹니다. 무한정 큐잉하는 대신 일찍 거절하라는 것입니다. 503으로 빠르게 거절하면 클라이언트는 백오프하고 서버는 살아 있는 요청을 처리합니다. 큐에 쌓아 두면 큐 대기 시간이 클라이언트 타임아웃을 넘겨, 아무도 받지 않을 응답을 만드느라 자원을 태우게 됩니다. 회로를 여는 판단은 서킷 브레이커 패턴 완벽 가이드에서 더 다룹니다.


6. 부분 실패와 타임아웃 예산

6-1. 타임아웃은 예산이다

사용자 대면 요청에 3초의 마감이 있다면, 그 3초는 하위 호출들이 나눠 쓰는 예산입니다. 각 호출에 개별 타임아웃을 따로 정하면 합이 마감을 넘고, 그러면 사용자는 이미 떠났는데 서버는 계속 일합니다.

사용자 마감 3000ms
 ├─ 인증 확인      150ms
 ├─ 주문 조회      400ms
 ├─ 결제 승인     1500ms  (재시도 1회 포함 → 실제 상한 2 × 700ms)
 └─ 여유          950ms   (직렬화, GC, 네트워크 변동)

남은 시간을 하위 호출로 전달하면 이 예산이 실제로 지켜집니다. 상위에서 이미 2초를 쓴 뒤 호출되는 하위 서비스는 1초짜리 마감을 받아야 합니다. 이 전파가 없으면 각 서비스가 자기 기준으로 최대치를 기다립니다.

6-2. 타임아웃과 재시도는 곱해진다

타임아웃 1초에 재시도 3회면 최악의 경우 3초입니다. 여기에 상위 계층이 다시 재시도하면 5-4절의 곱셈이 일어납니다. 타임아웃 예산은 재시도를 포함해서 계산해야 합니다.

6-3. 부분 실패의 응답 설계

다건 요청에서 일부만 실패했을 때의 계약을 미리 정합니다. 선택지는 셋입니다.

  • 전부 실패로 처리: 단순하지만 성공한 작업을 되돌릴 수 있어야 합니다.
  • 항목별 상태 배열 반환: 각 항목에 성공 여부와 오류 유형을 실어 보냅니다. 클라이언트가 실패한 것만 재시도할 수 있습니다.
  • 작업 리소스로 비동기화: 요청을 접수하고 진행 상태를 조회하게 합니다.

두 번째가 가장 널리 쓰이지만, 전체 요청의 상태 코드를 무엇으로 할지를 반드시 문서화해야 합니다. 이것을 정하지 않으면 클라이언트마다 다르게 해석합니다.

6-4. 캐스케이딩 실패

구글 SRE 책은 캐스케이딩 실패를 "양의 피드백의 결과로 시간이 지나면서 커지는 실패"라고 정의합니다. 한 클러스터가 죽어 트래픽이 다른 클러스터로 몰리는 서버 과부하, 그리고 CPU·메모리·스레드·파일 디스크립터의 자원 고갈이 대표적인 원인이며, 자원 고갈은 서로를 악화시킵니다. 스레드가 모자라면 지연이 늘고, 지연이 늘면 대기 중인 요청이 메모리를 더 붙잡습니다.

에러 처리 관점에서의 교훈은 하나입니다. 실패한 요청이 자원을 오래 붙잡고 있게 두지 않는 것입니다. 타임아웃 없는 호출, 무한 큐, 무한 재시도는 전부 같은 실패를 만듭니다.


7. 관측 — 로그·메트릭·트레이스에서의 오류

7-1. 오류 하나에 로그 하나

가장 흔한 낭비는 계층마다 로깅하는 것입니다. 다섯 계층이 각각 로깅하면 오류 하나가 로그 다섯 줄이 되고, 오류율 계산과 알림이 전부 부풀려집니다. 규칙은 처리하는 곳에서 한 번만 로깅하는 것입니다. 중간 계층은 로깅 대신 문맥을 붙여 전파합니다.

예시 — 오류 로그에 반드시 들어갈 필드.

{
  "level": "error",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "errorType": "upstream_unavailable",
  "retryable": true,
  "attempt": 2,
  "upstream": "payment-gateway",
  "durationMs": 1043,
  "message": "payment authorization failed after retry"
}

7-2. 메트릭은 분모가 중요하다

오류 수만 세면 트래픽이 늘 때 자동으로 늘어납니다. 필요한 것은 오류율이고, 분모가 무엇인지가 계약입니다. 재시도 끝에 성공한 요청을 성공으로 셀지 실패로 셀지에 따라 숫자가 크게 달라지므로, 사용자 관점 성공률과 시도 단위 성공률을 둘 다 계측하는 편이 낫습니다. 오류율을 SLO로 묶는 방법은 SLI/SLO/Error Budget 기반 신뢰성 엔지니어링에서 다룹니다.

7-3. 카디널리티 폭발을 피한다

오류 메시지 문자열을 메트릭 라벨로 쓰면 시계열이 폭발합니다. 라벨에는 1절의 분류에서 나온 유한한 집합만 넣습니다. 오류 유형, 재시도 가능 여부, 상대 서비스 이름 정도입니다. 자유 문자열은 로그에 남기고 메트릭에는 넣지 않습니다.

7-4. 알림은 오류가 아니라 소진율에

오류 하나에 알림을 거는 시스템은 곧 무시됩니다. 알림은 오류 자체가 아니라 오류 예산의 소진 속도에 걸어야 합니다. 그리고 알림 본문에 상관 ID와 오류 유형을 넣어, 알림에서 트레이스로 한 번에 이동할 수 있게 합니다.


8. 사용자에게 무엇을 말할 것인가

8-1. 보안 경계

RFC 9457 §5는 문제 상세에 담기는 정보가 "신중하게 검토되어야" 하며 "스택 덤프 같은 구현 세부 정보"를 노출하지 말라고 명시합니다. 스택 트레이스, 내부 호스트명, SQL 문, 라이브러리 버전은 전부 공격자에게 유용한 정보입니다.

인증 실패는 특히 조심할 자리입니다. OWASP 인증 치트시트는 애플리케이션이 "HTTP와 HTML 양쪽에서 일반적인(generic) 방식으로 응답"해야 한다고 말하며, "로그인 실패: 잘못된 사용자 ID 또는 비밀번호" 같은 문구를 예로 듭니다. 사용자명 열거를 막기 위해서입니다. 같은 문서는 "HTTP 응답 코드가 달라지는 것만으로도 계정의 유효 여부가 새어 나갈 수 있다"고 덧붙입니다. 문구만 통일하고 상태 코드나 응답 시간이 갈리면 방어가 되지 않습니다.

8-2. 사용자 메시지의 세 요소

  • 무슨 일이 있었는가: 기술 용어 없이 한 문장
  • 무엇을 할 수 있는가: 재시도, 값 수정, 대기, 문의 중 하나
  • 문의용 식별자: 상관 ID. 이것이 없으면 지원 팀이 로그에서 사건을 찾지 못합니다

8-3. 대기 시간을 알려 주기

재시도 가능한 오류라면 얼마나 기다려야 하는지를 알려 줍니다. RFC 6585는 429 응답이 Retry-After 헤더를 포함할 수 있다고(MAY) 하고, 503에도 같은 헤더를 쓸 수 있습니다. UI는 이 값을 읽어 "30초 후 자동으로 다시 시도합니다"로 바꿔 보여 주면 됩니다. 이 하나로 사용자의 수동 새로고침이 크게 줄고, 그만큼 서버 부하도 줄어듭니다.

8-4. 기계용 값과 사람용 문장을 분리한다

type은 기계용, titledetail은 사람용입니다. 스펙이 title을 발생 건마다 바꾸지 말라고 하는 이유도 여기에 있습니다. 사람용 문장은 지역화와 문구 개선으로 자주 바뀌어야 하고, 기계용 값은 절대 바뀌면 안 됩니다.


9. 안티패턴 목록

  • 빈 catch: 오류를 지우는 코드입니다. 최소한 로깅하거나, 의도적 무시라면 이유를 주석이 아니라 코드로 표현합니다.
  • 모든 오류를 500으로: 클라이언트가 고칠 수 있는 오류와 고칠 수 없는 오류가 섞여, 재시도 로직과 알림이 동시에 망가집니다.
  • 200에 오류 담기: 프록시·모니터링·재시도 미들웨어 전부가 성공으로 읽습니다.
  • 오류를 null로 반환: 호출자는 "값이 없음"과 "실패"를 구분하지 못하고, 결국 null 검사와 예외 처리가 뒤섞입니다.
  • 문자열 매칭으로 분기: 오류 메시지가 계약이 되어 문구 수정이 장애가 됩니다.
  • 타임아웃 없는 호출: 하나의 느린 의존성이 전체 스레드 풀을 잠급니다.
  • 다층 재시도: 4 × 4 × 4 = 64회 문제. 재시도 계층을 하나로 정합니다.
  • 계층마다 로깅: 오류율이 부풀려지고 알림이 신뢰를 잃습니다.
  • 원인을 버리는 래핑: 감쌀 때는 반드시 원인 체인을 붙입니다.
  • 흐름 제어에 예외 사용: 성능과 가독성을 동시에 잃습니다.
  • 사용자에게 스택 트레이스 노출: RFC 9457 §5가 명시적으로 금지하는 패턴입니다.
  • 재시도 예산 없음: 부하가 걸린 순간 부하를 배로 만듭니다.

퀴즈: 실력을 확인해 보세요

퀴즈 1: 결제 요청이 타임아웃됐습니다. 클라이언트가 자동으로 재시도해도 될까요?

정답: 요청이 멱등성 키를 포함하고 서버가 그 키를 지원할 때만 안전합니다. 그렇지 않다면 재시도하면 안 됩니다.

설명: 타임아웃은 요청이 서버에 도달했는지조차 알려 주지 않습니다. RFC 9110 §9.2.2가 정의하는 멱등성은 GET, HEAD, PUT, DELETE, OPTIONS, TRACE에 적용되고 POST에는 적용되지 않으므로, POST 결제 요청의 재시도는 중복 승인을 만들 수 있습니다. 멱등성 키가 있다면 서버가 같은 키의 두 번째 요청을 첫 번째의 결과로 응답하므로 안전해집니다. 재시도 가능 여부를 오류 응답에 명시적으로 실어 보내면 클라이언트가 추측하지 않아도 됩니다.

퀴즈 2: 장애 중 오류율 대시보드가 평소의 다섯 배로 튀었습니다. 실제 실패 요청 수는 그 정도가 아니었습니다. 무엇을 먼저 의심할까요?

정답: 계층마다 로깅하고 있거나, 재시도 시도가 각각 오류로 집계되고 있을 가능성입니다.

설명: 오류 하나가 다섯 계층에서 각각 로깅되면 지표는 다섯 배가 됩니다. 재시도도 같습니다. 시도 단위로 세면 3회 재시도가 3건의 오류가 되지만, 사용자 관점에서는 실패 1건입니다. 대응은 처리하는 곳에서 한 번만 로깅하고, 사용자 관점 성공률과 시도 단위 성공률을 분리해 계측하는 것입니다. 두 숫자가 모두 필요하며, 하나만 보면 각각 다른 방향으로 오판하게 됩니다.

퀴즈 3: 클라이언트 SDK, 게이트웨이, 서비스 간 클라이언트가 각각 4회 재시도로 설정되어 있습니다. 무엇이 문제인가요?

정답: 재시도가 곱해져 사용자 동작 한 번이 최대 4 × 4 × 4 = 64회의 요청이 됩니다.

설명: 구글 SRE 책이 캐스케이딩 실패를 다루며 지적하는 대표적인 증폭 경로입니다. 하위 서비스가 이미 부하로 느려진 상태에서 이 증폭이 걸리면, 회복 중인 서비스를 다시 쓰러뜨립니다. 대응은 재시도 계층을 하나로 정하고 나머지는 실패를 즉시 전달하게 하는 것, 그리고 프로세스 단위 재시도 예산을 두는 것입니다. 같은 문서는 예로 프로세스에서 분당 60회 재시도 제한을 들고, 특정 요청을 무한히 재시도하지 말라고 명시합니다.

퀴즈 4: 로그인 실패 시 "존재하지 않는 아이디입니다"와 "비밀번호가 틀렸습니다"를 구분해 보여 주고 있습니다. 어떤 위험이 있나요?

정답: 사용자명 열거가 가능해집니다. 공격자가 어떤 계정이 실제로 존재하는지 확인할 수 있습니다.

설명: OWASP 인증 치트시트는 애플리케이션이 HTTP와 HTML 양쪽에서 일반적인 방식으로 응답해야 한다고 말하며 "로그인 실패: 잘못된 사용자 ID 또는 비밀번호" 같은 문구를 예로 듭니다. 주의할 점은 문구만 통일해서는 부족하다는 것입니다. 같은 문서는 HTTP 응답 코드가 달라지는 것만으로도 계정의 유효 여부가 새어 나갈 수 있다고 지적합니다. 응답 시간 차이도 같은 정보를 흘리므로 함께 맞춰야 합니다.

퀴즈 5: 사용자 대면 요청의 마감이 3초인데, 하위 호출 네 개에 각각 2초 타임아웃이 걸려 있습니다. 무엇이 잘못됐나요?

정답: 개별 타임아웃의 합이 마감을 크게 넘습니다. 타임아웃은 개별 값이 아니라 마감에서 나눠 쓰는 예산이어야 합니다.

설명: 최악의 경우 8초가 걸리고, 그동안 사용자는 이미 떠났는데 서버는 계속 자원을 붙잡고 일합니다. 이것이 캐스케이딩 실패의 자원 고갈 경로입니다. 대응은 마감에서 역산해 각 호출의 예산을 배분하고, 남은 시간을 하위 호출로 전파하는 것입니다. 그리고 재시도가 있다면 그 곱까지 예산 안에 포함해 계산해야 합니다.

퀴즈 6: 오류 응답의 detail 문구를 다듬었더니 모바일 앱에서 특정 화면이 동작하지 않게 됐습니다. 근본 원인은 무엇일까요?

정답: 클라이언트가 사람용 문장을 분기 조건으로 쓰고 있었습니다. 기계용 값과 사람용 문장이 분리되지 않은 것입니다.

설명: RFC 9457은 type을 문제 유형의 주 식별자로 두고, detail은 이번 발생에 대한 설명이며 "클라이언트가 문제를 바로잡도록 돕는 데 초점을 맞춰야" 한다고 규정합니다. 즉 detail은 사람이 읽는 문장이며 언제든 바뀔 수 있습니다. 클라이언트가 분기해야 하는 값은 type이고, 이 규칙을 API 문서에 명시해야 합니다. 명시하지 않으면 문구 개선과 지역화가 전부 파괴적 변경이 됩니다.


마치며

에러 처리는 문법의 문제가 아니라 계약의 문제입니다. 어떤 실패가 가능한지, 그 실패가 재시도 가능한지, 경계를 넘을 때 무엇이 보존되는지, 사용자에게 무엇을 말할지가 전부 계약이며, 계약이므로 문서에 적혀야 하고 바뀌면 파괴적 변경입니다.

가장 값싼 개선 세 가지를 꼽으면 이렇습니다. 첫째, 도메인 오류 타입에 재시도 가능 여부를 명시적 속성으로 넣습니다. 상위 계층이 문자열을 보고 추측하지 않게 됩니다. 둘째, 재시도 계층을 하나로 정하고 나머지 계층은 실패를 즉시 전달합니다. 4 × 4 × 4 문제가 사라집니다. 셋째, 오류 응답을 RFC 9457 형식으로 통일하고 클라이언트는 type으로만 분기한다를 문서에 못 박습니다. 문구를 고칠 자유가 생깁니다.

세 가지 모두 새 라이브러리가 필요 없고, 셋 다 장애 한 번의 비용보다 훨씬 쌉니다.


참고 자료

  • RFC 9110 — HTTP Semantics — §9.2.2의 idempotent 정의와 대상 메서드, POST가 멱등이 아니라는 점, 400·401·403·409·422·500·503의 정의, §15.5.2와 §15.5.4에 따른 401(인증)과 403(인가)의 구분을 인용했습니다. 2026-08-15 확인.
  • RFC 6585 — Additional HTTP Status Codes — 429의 정의("주어진 시간 동안 너무 많은 요청을 보냈다")와 Retry-After 헤더가 MAY라는 점을 인용했습니다. 2026-08-15 확인.
  • RFC 9457 — Problem Details for HTTP APIs — RFC 7807을 대체한다는 점, application/problem+json 미디어 타입, §3.1의 다섯 멤버와 detail이 문제 해결을 돕는 데 초점을 맞춰야 한다는 규정, §3.2의 확장 멤버와 클라이언트의 무시 의무, §5의 스택 덤프 노출 금지를 인용했습니다. 2026-08-15 확인.
  • Addressing Cascading Failures — Google SRE Book — 캐스케이딩 실패의 정의("양의 피드백의 결과로 시간이 지나면서 커지는 실패"), 서버 과부하와 자원 고갈이라는 원인, "항상 무작위화된 지수 백오프를 사용하라", 분당 60회 재시도 예산 예시, 무한 재시도 금지, 세 계층 4회 재시도가 64회가 된다는 지적, 재시도 가능·불가 오류의 구분, 일찍 거절하는 부하 차단을 인용했습니다. 2026-08-15 확인.
  • Authentication Cheat Sheet — OWASP — 인증 실패 시 HTTP와 HTML 양쪽에서 일반적인 방식으로 응답해야 한다는 권고와 예시 문구, HTTP 응답 코드 차이만으로도 계정 유효 여부가 새어 나갈 수 있다는 지적을 인용했습니다. 2026-08-15 확인.
  • 오류 분류의 두 축과 네 사분면, 경계별 번역 규칙 표, 재시도 가능 플래그를 도메인 오류에 싣는 방법, 타임아웃 예산 배분 방식, 오류 로그의 필수 필드, 안티패턴 목록은 위 자료에 그대로 나오는 것이 아니라 이 글에서 정리한 절차입니다.

이어서 읽기

완전 가이드 시리즈

현재 단락 (1/157)

대부분의 코드베이스에서 성공 경로는 설계되고 실패 경로는 발생합니다. 성공 응답의 필드 이름은 리뷰에서 논쟁거리가 되지만, 오류 응답은 "일단 500으로 던지고 나중에 정리하자"로...

작성 글자: 0원문 글자: 12,031작성 단락: 0/157