정의
대부분의 큐는 메시지를 최소 한 번(at-least-once) 배달해요. 워커가 처리에 성공했다고 알리는 ack(확인 응답) 가 올 때까지 메시지를 지우지 않고, 실패하거나 응답이 없으면 다시 배달해요. 그래서 같은 메시지가 두 번 이상 올 수 있어요.
멱등(idempotent) 이란 같은 요청을 여러 번 실행해도 결과가 한 번 실행한 것과 같다는 성질이에요. 같은 요청에는 같은 번호(멱등 키)를 붙여서, 이미 처리한 번호가 다시 오면 건너뛰는 식으로 만들어요.
왜 필요한가
두 가지 사고가 서로 반대 방향에서 일어나요.
- 유실: 워커가 실패했는데 예외를 잡아서 정상 반환(= ack)해 버리면, 큐는 성공한 줄 알고 메시지를 지워요. 사용자에게는 이미 "접수 완료"라고 답한 뒤라서 아무도 모르는 채 데이터가 사라져요.
- 중복: 처리는 성공했는데 ack가 가는 도중 네트워크가 끊기면 큐는 실패로 보고 다시 배달해요. 처리가 두 번 일어나서 같은 행이 두 번 쌓이거나 같은 알림이 두 번 나가요.
해법은 짝이에요. 성공했을 때만 ack해서 유실을 막고, 중복 실행에 안전하게 만들어서 재배달을 견뎌요.
동작 원리
| 상황 | 큐의 동작 | 설계 대응 |
|---|---|---|
| 처리 성공 + ack | 메시지 삭제 | 정상 |
| 처리 실패 + 예외 | 간격을 두고 재배달 | 재시도 경로로 사용 |
| 처리 성공 + ack 유실 | 재배달 | 멱등 키로 중복 건너뜀 |
| 계속 실패 | 최대 배달 횟수까지 반복 | 소진 시 보고 후 격리/ack |
멱등 키의 흐름이에요.
- 사용자 행동 하나를 만드는 순간(폼 제출 등) 고유 번호(UUID)를 만들어요.
- 번호를 메시지에 싣고 큐에 넣어요(큐가 지원하면 큐의 중복 제거 옵션에도 같은 번호를 줘요).
- 최종 저장 단계에서 "이 번호가 이미 있나"를 확인하고, 있으면 쓰지 않고 성공으로 응답해요.
실무 적용
async function handle(message: { id: string; values: Values }) {
const result = await store.appendOnce(message.id, message.values); // 같은 id가 있으면 no-op
if (result.kind === "retryable") throw new Error(result.code); // 던지면 재배달
if (result.kind === "fatal") {
report({ id: message.id, code: result.code }); // 식별자와 함께 보고
return; // 여기서만 의도적으로 ack
}
}
- 예외 경로를 ack하지 않아요.
try/catch로 에러를 삼키고 끝내는 코드가 가장 흔한 유실 원인이에요. - 최대 배달 횟수를 정해요. 소진되면 식별자와 함께 오류 추적 도구에 보고한 뒤 ack하거나 dead-letter(실패 메시지 격리 큐)로 옮겨요.
- 중복 검사는 최종 저장소에서 해요. 큐 앞단에서만 걸러도, 재배달은 큐 뒤에서 일어나요.
- 세 경우를 테스트로 남겨요: 같은 메시지 두 번 처리, 실패 후 재배달, 소진 시 보고.
트레이드오프
| 선택 | 장점 | 비용 |
|---|---|---|
| at-least-once + 멱등 | 유실이 없고 구현이 현실적 | 모든 소비자가 멱등해야 해요 |
| at-most-once(ack 먼저) | 중복 없음 | 실패하면 유실이에요 |
| "정확히 한 번" 시도 | 이론적으로 이상적 | 보통 멱등 처리로 흉내 내요. 비용이 크거나 범위가 제한적이에요 |
사용하면 안 되는 경우
- 중복이 실제로 해롭지 않은 로그성 이벤트라면 복잡한 멱등 저장소는 과해요.
- "일단 ack하고 나중에 처리"는 유실이 허용되는 데이터에만 써야 해요.
- 멱등 키를 서버에서 매 요청마다 새로 만들면 중복 제거가 안 돼요. 사용자 행동 단위로 한 번만 만들어야 해요.
흔한 실수
- catch로 모든 예외를 삼키고 정상 반환. 조용한 유실의 가장 흔한 원인이에요.
- 재배달 간격이 설정한 대로 동작한다고 믿기. 실제 배달 시각을 로그로 확인해요. SDK 옵션이 환경에 따라 무시될 수 있어요.
- 영원히 실패하는 메시지(poison message) 방치. 큐를 막으니 격리 경로가 필요해요.
- 소진 보고 없이 ack. 사람이 알아챌 신호가 없어요.
관련 개념
- queue-based-load-leveling — 이 ack 규칙이 필요한 큐·워커 구조
- retry-backoff-with-jitter — 재배달 간격과 재시도 분류
- optimistic-outbox-rebase — 보낼 일을 쌓아 두고 순서를 맞춰 다시 적용하는 비슷한 문제의 클라이언트 측 해법