시스템 간 연동 (EAI) · 오류 처리와 재처리 설계 · 이론
재시도는 공짜가 아니다
한 줄 요약
재시도는 여러 계층에서 겹치는 순간 곱셈이 되어 회복 중인 상대를 다시 쓰러뜨리므로, 한 계층에서만 하고 지수 백오프에 지터를 섞어야 한다.
재시도는 왜 장애를 키우는가
상대 시스템이 잠깐 흔들린다. 우리 쪽에서 재시도를 건다. 좋은 설계 같다.
그런데 이런 상황을 생각해 보자.
게이트웨이: 재시도 4회 → 서비스A: 재시도 4회 → 서비스B: 재시도 4회사용자가 버튼을 한 번 눌렀는데 최종 시스템에는 4 × 4 × 4 = 64번의 요청이 간다.
회복 중이던 상대는 이 폭격에 다시 쓰러진다. 그리고 이 파도는
재시도 간격마다 반복된다.
재시도는 한 계층에서만 한다는 원칙이 여기서 나온다.
여러 계층에서 재시도하면 지수적으로 증폭된다.
지수 백오프와 지터
고정 간격 재시도는 두 가지가 나쁘다. 너무 빨리 다시 때리고, 다 같이 때린다.
1차 실패 → 1초 대기 → 2차 → 2초 → 3차 → 4초 → 4차 → 8초 ... (상한 30초)지수 백오프는 첫 문제를 푼다. 그런데 두 번째가 남는다.
> 1만 개의 클라이언트가 같은 규칙을 따르면 정확히 같은 순간에 재시도한다.
> 회복 중인 서버는 그 순간 다시 쓰러진다. 그리고 그 파도가 2초, 4초, 8초마다 반복된다.
이것이 thundering herd 다. 해법은 지터(jitter) —
계산된 대기 시간에 무작위를 섞는다.
sleep = random(계산값 × 0.5, 계산값) # full jitter 계열지터가 없는 백오프는 절반만 한 것이다. 그리고 지터가 없어도
평소에는 잘 돌아가기 때문에, 장애가 크게 났을 때 처음 발견된다.
재시도하면 안 되는 오류
이 구분이 없으면 재시도는 해악이 된다.
| 오류 유형 | 재시도? | 이유 |
| --- | --- | --- |
| 연결 거부, 타임아웃 | O | 일시적일 가능성이 크다 |
| HTTP 500, 502, 503, 504 | O | 서버 측 일시 오류 |
| HTTP 400 (형식 오류) | X | 100번 보내도 100번 실패 |
| HTTP 401/403 (인증/권한) | X | 자격이 바뀌기 전엔 안 된다 |
| HTTP 404 | X | 대상이 없다 |
| HTTP 409 (중복) | X | 이미 처리됐다는 뜻일 수 있다 |
| HTTP 429 (한도 초과) | 조건부 | Retry-After 를 존중해서 |
429 를 무시하고 즉시 재시도하는 것은 특히 나쁘다.
상대가 "천천히 오세요"라고 말했는데 더 빨리 가는 셈이다.
많은 API 가 이 경우 차단으로 대응한다.
DLQ — 포기할 줄 알아야 한다
최대 시도를 넘긴 메시지는 DLQ(Dead Letter Queue) 로 보낸다.
무한 재시도는 큐를 막고, 막힌 큐는 전체 정지다.
DLQ 에 넣을 때 원본만 넣으면 안 된다. 나중에 사람이 봐야 하기 때문이다.
{ "msg_id": "M-20260819-000123", "original": { ...원본 전문... }, "reason": "upstream 503 after 5 attempts", "attempts": 5, "first_failed_at": "2026-08-19T02:11:03+09:00", "last_failed_at": "2026-08-19T02:12:47+09:00", "trace_id": "a1b2c3d4"}- 원본이 있어야 재처리할 수 있다
- 사유와 시도 횟수가 있어야 원인을 분류한다
- 최초 실패 시각이 있어야 "언제부터 문제였나"를 안다
그리고 DLQ 는 감시 대상이다. 건수가 0 이 아니면 누군가 봐야 한다.
DLQ 를 만들어 놓고 아무도 안 보는 시스템이 정말 많다.
그건 메시지를 조용히 버리는 것과 같다.
재처리 설계
DLQ 에 쌓인 것을 어떻게 되돌릴 것인가.
1. 분류 — 사유별로 나눈다. 시스템 오류인가, 데이터 오류인가
2. 판단 — 고쳐서 다시 넣을 것인가, 원천에 재전송을 요청할 것인가
3. 안전 확인 — 재처리가 멱등한가. 아니면 중복 처리가 된다
4. 실행 — 소량부터. 전부 한꺼번에 넣으면 같은 이유로 또 막힌다
5. 기록 — 언제 누가 몇 건을 재처리했는가
3번이 핵심이다. 멱등하지 않은 인터페이스의 재처리는
"안 하는 것보다 나쁜" 결과를 만들 수 있다. 중복 입금, 중복 발주.
멱등성 — 어떻게 만드는가
멱등하다는 것은 같은 요청을 여러 번 처리해도 부수 효과가 한 번만 나는 것이다.
"응답이 항상 같다"가 아니다.
방법은 결국 하나다. 요청마다 고유 키를 붙이고, 수신측이 이미 처리한 키를 기억한다.
CREATE TABLE inbox_log ( msg_id TEXT PRIMARY KEY, -- ★ 멱등키. 제약이 마지막 방어선 biz_key TEXT NOT NULL, status TEXT NOT NULL, response TEXT, -- 최초 응답을 그대로 보관 created_at TEXT NOT NULL);멱등키를 무엇으로 잡을 것인가가 설계의 핵심이다.
order_no만? — 위험하다. 같은 주문에 대한 수정 전문도 있을 수 있다송신시스템 + 전문번호— 좋다. 전문번호가 송신측에서 유일하면송신시스템 + 전문번호 + 일자— 전문번호가 일 단위로 순환하는 경우
그리고 반드시 DB 제약(PRIMARY KEY / UNIQUE)으로 막아라.
애플리케이션의 if 조회 then 없으면 insert 는
동시에 두 건이 들어오면 뚫린다. 조회와 삽입 사이에 틈이 있기 때문이다.
제약은 그 틈을 없앤다. 애플리케이션 버그가 있어도 제약은 뚫리지 않는다.
저장-후-응답 (store and return)
한 걸음 더 나가면, 중복 요청에 최초 응답을 그대로 돌려준다.
1. msg_id 로 조회2. 있으면 → 저장된 response 를 그대로 반환 (처리 안 함)3. 없으면 → 처리하고, 결과를 response 에 저장이렇게 하면 송신측 입장에서 재전송이 완전히 안전해진다.
타임아웃으로 응답을 못 받았을 때 그냥 다시 보내면 된다.
이것이 Idempotency-Key 헤더 규약이 하는 일이고,
결제 API 들이 이 방식을 쓰는 이유다.
멱등 이력의 보관 기간
inbox_log 는 무한히 커진다. 정리해야 한다.
그런데 정리하는 순간 그 구간의 중복 방어가 사라진다.
그래서 보관 기간은 "현실적으로 재전송이 올 수 있는 최대 기간" 보다 길게 잡는다.
상대의 재처리 정책이 "최대 7일 전 것까지 재전송"이면 30일 보관이 안전하다.
그리고 정리 배치는 기간 조건으로만 지운다.
"오래된 것부터 N 건" 같은 방식은 갑자기 유입이 늘었을 때 최근 것을 지운다.
중복이 생기는 네 가지 경로
마지막으로, 실무에서 중복이 어디서 오는지 정리해 두자.
1. 재시도 — 타임아웃 후 재전송 (응답만 유실된 경우)
2. 큐의 at-least-once — 브로커가 ack 를 못 받아 재전달
3. 운영자 수동 재전송 — 장애 후 "일단 다시 보내 주세요"
4. 송신측 배치 재실행 — 실패한 배치를 처음부터 다시 돌림
4번이 가장 크다. 배치가 절반 처리하고 죽었는데 전체를 다시 돌리면
절반이 중복이다. 그래서 배치도 재시작 지점을 기록하거나,
수신측 멱등성에 기대야 한다. 대부분 후자가 현실적이다.
현장에서 만나는 모습
가장 자주 보는 형태는 아무도 재시도를 설계하지 않았는데 재시도가 세 겹으로 걸려 있는 상황이다. 게이트웨이 기본값, HTTP 클라이언트 라이브러리 기본값, 그리고 우리가 짠 코드. 각각은 합리적인 3~4 회인데, 곱하면 수십 번이 된다. 그래서 장애 대응 때 "우리는 한 번만 보냈는데 상대 로그에는 40 건이 찍혀 있다" 는 대화가 오간다.
그 다음으로 흔한 것은 재시도하면 안 되는 오류를 재시도하는 것이다. 형식 오류나 인증 실패는 백 번을 보내도 같은 결과인데, 재시도 로직이 상태 코드를 구분하지 않으면 그 무의미한 재시도가 상대 로그를 채우고 우리 큐를 막는다.
그리고 마지막에는 포기할 줄 알아야 한다. DLQ 로 옮길 때 사유·시도 횟수·최초 실패 시각을 함께 남기지 않으면, 몇 주 뒤 그 파일을 열어 본 사람이 이걸 다시 넣어도 되는지 판단할 수 없다.