목차
메시지는 한 번 보냈다고 한 번 도착하지 않는다. 같은 게 두 번, 세 번 올 수 있다. 받는 쪽이 그걸 그대로 처리하면 사고가 난다. 두 번 받아도 한 번처럼 만드는 게 이 글이다.
두 번 처리하면 두 번 일어난다
“결제 완료” 메시지를 받아 잔액을 깎는 소비자가 있다고 하자. 같은 메시지가 두 번 오면 잔액이 두 번 깎인다. 재고라면 두 번 빠지고, 알림이라면 문자가 두 통 간다.
문제는 “두 번 오는 일이 드물게 있다”가 아니다. 트래픽이 쌓이면 결국 언젠가 온다. 그래서 소비자는 처음부터 중복을 견디게 짜야 한다.
왜 두 번 오나
대부분의 메시지 시스템은 “적어도 한 번(at-least-once)“을 보장한다. 소비자가 메시지를 처리하고 나면 브로커에게 “다 했다”(ack)고 알린다. 그런데 처리는 됐는데 ack가 도착하기 전에 네트워크가 끊기거나 소비자가 죽으면, 브로커는 “아직 안 됐구나” 하고 다시 보낸다.
처리는 됐는데 브로커는 그걸 모른다. 그래서 다시 보낸다. “확실히 전달”과 “정확히 한 번”은 이렇게 어긋난다. message-queue 글의 중복이 바로 이 이야기다.
왜 “정확히 한 번”을 그냥 못 하나. 처리와 ack는 서로 다른 두 시스템(소비자·브로커)의 일이라, 그 사이에 끊김이 낄 수 있다. 이 틈을 없앨 수 없어서, 현실의 목표는 “정확히 한 번 전달”이 아니라 “여러 번 전달돼도 효과는 한 번”이 된다. 그 효과를 만드는 게 멱등 소비자다.
멱등 소비자 = 두 번 받아도 한 번처럼
멱등하다는 “여러 번 해도 한 번 한 것과 같다”는 뜻이다. idempotency 글이 이걸 HTTP 요청 관점에서 다뤘다면, 여기선 메시지 소비 관점이다 - 같은 메시지가 여러 번 배달돼도 상태가 한 번 처리한 것과 같으면 된다.
만드는 길은 크게 둘이다. 처리한 걸 기억하거나, 연산 자체를 여러 번 해도 같게 만들거나.
길 하나: 처리한 메시지를 기억한다
메시지마다 고유한 id를 붙인다(보내는 쪽이 붙인다). 소비자는 처리하기 전에 “이 id를 전에 봤나” 확인하고, 봤으면 그냥 버린다.
메시지 도착 (id: msg-8f3a)
이미 처리한 id인가? → 그렇다 → 버림 (아무 일도 안 함)
→ 아니다 → 처리 + id를 "처리함" 목록에 기록이 “처리한 id 목록”을 흔히 인박스(inbox) 라 부른다. 핵심은 id 기록과 실제 처리가 같은 트랜잭션 안에 있어야 한다는 것이다. 따로 놀면, 처리는 됐는데 기록 직전에 죽어서 다음 재전송 때 또 처리하는 똑같은 구멍이 생긴다.
길 둘: 연산을 멱등하게 만든다
기억할 필요 없이, 연산 자체가 여러 번 해도 같으면 가장 깔끔하다.
멱등하지 않음: 잔액 = 잔액 - 5000 (부르는 만큼 깎인다)
멱등함: 주문 8f3a를 "결제완료"로 표시 (몇 번을 해도 결과는 "결제완료" 하나)“빼기·더하기”처럼 누적되는 연산은 위험하고, “이 값으로 정해라(set)“처럼 결과가 고정되는 연산은 안전하다. 상태를 덮어쓰는 모양으로 설계하면, 중복이 와도 같은 자리에 같은 값을 다시 쓸 뿐이라 문제가 없다.
기억을 어디에 두나
길 하나(기억하기)를 택하면, id 목록을 둘 곳이 중요하다. 처리 결과를 쓰는 그 DB에, 같은 트랜잭션으로 넣는 게 정석이다.
- 주문 상태를 바꾸는 트랜잭션 안에서 인박스에
msg-8f3a한 줄을 같이 넣는다. - 둘 다 커밋되거나 둘 다 안 된다. 그래서 “처리는 됐는데 기록은 안 된” 어중간한 상태가 없다.
별도 캐시(예: 메모리·외부 저장소)에 id만 따로 두는 방식도 있지만, 처리와 기록이 원자적으로 묶이지 않으면 그 틈으로 중복이 샌다. 이 “두 곳에 나눠 쓰면 새는” 구조는 다음 글 아웃박스의 문제와 뿌리가 같다.
중복은 막아도 순서는 다른 문제다
멱등 소비자는 “같은 메시지가 두 번”을 막는다. 하지만 “다른 메시지가 뒤바뀐 순서로” 오는 건 못 막는다. 이 둘은 별개다.
message-queue가 순서도 흔들린다고 했다. “가입” 다음 “탈퇴”가 와야 하는데 순서가 바뀌면, 멱등성으로는 안 풀린다. 순서가 중요하면 같은 대상의 메시지를 한 줄(파티션)로 몰아 순서를 지키는 다른 장치가 필요하다. 멱등 소비자에 “순서까지 해주겠지”를 기대하면 안 된다.
실무에서: 보내는 쪽과 받는 쪽의 약속
멱등 소비자는 받는 쪽 혼자 못 한다. 보내는 쪽이 안정적인 id를 붙여줘야 한다.
- id는 재전송돼도 같아야 한다 - 재전송 때 새 id를 붙이면 소비자가 “새 메시지”로 오해해 중복 처리한다. “이 사건 하나”에 하나의 id가 평생 따라붙어야 한다.
- 인박스는 무한히 쌓인다 - 오래된 id는 주기적으로 지운다(예: 재전송이 올 리 없는 기간이 지난 것). 지우는 기준은 브로커의 재전송 정책보다 넉넉히 잡는다.
- 자연 멱등을 먼저 노린다 - 기억하기(인박스)는 관리 비용이 든다. 연산을 덮어쓰기 모양으로 바꿔 애초에 멱등하게 만들 수 있으면 그게 제일 싸다.
정리
- 메시지 시스템은 “적어도 한 번”이라, 트래픽이 쌓이면 같은 메시지가 결국 언젠가 두 번 온다.
- 그대로 두 번 처리하면 결제·재고가 두 번 나가니, 소비자를 멱등하게 짠다 - 두 번 받아도 한 번처럼.
- 길은 둘 - 처리한 id를 기억(인박스, 반드시 처리와 같은 트랜잭션)하거나, 연산을 덮어쓰기 모양으로 만들어 애초에 멱등하게.
- 중복은 막아도 순서는 못 지킨다 - 그건 파티셔닝 같은 다른 장치의 몫이다.
다음 글은 그 인박스와 짝을 이루는 반대편 - 저장과 발행을 한 묶음으로 만드는 아웃박스다.