← Posts

Event-Driven · Idempotency · Messaging

같은 메시지가 두 번 와도 - 멱등 소비자

메시지 시스템은 확실히 전달하려다 중복을 만든다. 받는 쪽이 두 번 처리하면 결제가 두 번 된다. 처리한 걸 기억하거나 연산 자체를 멱등하게 만들어, 두 번 받아도 한 번처럼 삼키는 법.

목차
  1. 두 번 처리하면 두 번 일어난다
  2. 왜 두 번 오나
  3. 멱등 소비자 = 두 번 받아도 한 번처럼
  4. 길 하나: 처리한 메시지를 기억한다
  5. 길 둘: 연산을 멱등하게 만든다
  6. 기억을 어디에 두나
  7. 중복은 막아도 순서는 다른 문제다
  8. 실무에서: 보내는 쪽과 받는 쪽의 약속
  9. 정리

메시지는 한 번 보냈다고 한 번 도착하지 않는다. 같은 게 두 번, 세 번 올 수 있다. 받는 쪽이 그걸 그대로 처리하면 사고가 난다. 두 번 받아도 한 번처럼 만드는 게 이 글이다.

두 번 처리하면 두 번 일어난다

“결제 완료” 메시지를 받아 잔액을 깎는 소비자가 있다고 하자. 같은 메시지가 두 번 오면 잔액이 두 번 깎인다. 재고라면 두 번 빠지고, 알림이라면 문자가 두 통 간다.

문제는 “두 번 오는 일이 드물게 있다”가 아니다. 트래픽이 쌓이면 결국 언젠가 온다. 그래서 소비자는 처음부터 중복을 견디게 짜야 한다.

왜 두 번 오나

대부분의 메시지 시스템은 “적어도 한 번(at-least-once)“을 보장한다. 소비자가 메시지를 처리하고 나면 브로커에게 “다 했다”(ack)고 알린다. 그런데 처리는 됐는데 ack가 도착하기 전에 네트워크가 끊기거나 소비자가 죽으면, 브로커는 “아직 안 됐구나” 하고 다시 보낸다.

diagramdiagram

처리는 됐는데 브로커는 그걸 모른다. 그래서 다시 보낸다. “확실히 전달”과 “정확히 한 번”은 이렇게 어긋난다. message-queue 글의 중복이 바로 이 이야기다.

왜 “정확히 한 번”을 그냥 못 하나. 처리와 ack는 서로 다른 두 시스템(소비자·브로커)의 일이라, 그 사이에 끊김이 낄 수 있다. 이 틈을 없앨 수 없어서, 현실의 목표는 “정확히 한 번 전달”이 아니라 “여러 번 전달돼도 효과는 한 번”이 된다. 그 효과를 만드는 게 멱등 소비자다.

멱등 소비자 = 두 번 받아도 한 번처럼

멱등하다는 “여러 번 해도 한 번 한 것과 같다”는 뜻이다. idempotency 글이 이걸 HTTP 요청 관점에서 다뤘다면, 여기선 메시지 소비 관점이다 - 같은 메시지가 여러 번 배달돼도 상태가 한 번 처리한 것과 같으면 된다.

만드는 길은 크게 둘이다. 처리한 걸 기억하거나, 연산 자체를 여러 번 해도 같게 만들거나.

길 하나: 처리한 메시지를 기억한다

메시지마다 고유한 id를 붙인다(보내는 쪽이 붙인다). 소비자는 처리하기 전에 “이 id를 전에 봤나” 확인하고, 봤으면 그냥 버린다.

plaintext
메시지 도착 (id: msg-8f3a)
  이미 처리한 id인가?  →  그렇다  →  버림 (아무 일도 안 함)
                       →  아니다  →  처리 + id를 "처리함" 목록에 기록

이 “처리한 id 목록”을 흔히 인박스(inbox) 라 부른다. 핵심은 id 기록과 실제 처리가 같은 트랜잭션 안에 있어야 한다는 것이다. 따로 놀면, 처리는 됐는데 기록 직전에 죽어서 다음 재전송 때 또 처리하는 똑같은 구멍이 생긴다.

길 둘: 연산을 멱등하게 만든다

기억할 필요 없이, 연산 자체가 여러 번 해도 같으면 가장 깔끔하다.

plaintext
멱등하지 않음:  잔액 = 잔액 - 5000     (부르는 만큼 깎인다)
멱등함:         주문 8f3a를 "결제완료"로 표시  (몇 번을 해도 결과는 "결제완료" 하나)

“빼기·더하기”처럼 누적되는 연산은 위험하고, “이 값으로 정해라(set)“처럼 결과가 고정되는 연산은 안전하다. 상태를 덮어쓰는 모양으로 설계하면, 중복이 와도 같은 자리에 같은 값을 다시 쓸 뿐이라 문제가 없다.

기억을 어디에 두나

길 하나(기억하기)를 택하면, id 목록을 둘 곳이 중요하다. 처리 결과를 쓰는 그 DB에, 같은 트랜잭션으로 넣는 게 정석이다.

  • 주문 상태를 바꾸는 트랜잭션 안에서 인박스에 msg-8f3a 한 줄을 같이 넣는다.
  • 둘 다 커밋되거나 둘 다 안 된다. 그래서 “처리는 됐는데 기록은 안 된” 어중간한 상태가 없다.

별도 캐시(예: 메모리·외부 저장소)에 id만 따로 두는 방식도 있지만, 처리와 기록이 원자적으로 묶이지 않으면 그 틈으로 중복이 샌다. 이 “두 곳에 나눠 쓰면 새는” 구조는 다음 글 아웃박스의 문제와 뿌리가 같다.

중복은 막아도 순서는 다른 문제다

멱등 소비자는 “같은 메시지가 두 번”을 막는다. 하지만 “다른 메시지가 뒤바뀐 순서로” 오는 건 못 막는다. 이 둘은 별개다.

message-queue가 순서도 흔들린다고 했다. “가입” 다음 “탈퇴”가 와야 하는데 순서가 바뀌면, 멱등성으로는 안 풀린다. 순서가 중요하면 같은 대상의 메시지를 한 줄(파티션)로 몰아 순서를 지키는 다른 장치가 필요하다. 멱등 소비자에 “순서까지 해주겠지”를 기대하면 안 된다.

실무에서: 보내는 쪽과 받는 쪽의 약속

멱등 소비자는 받는 쪽 혼자 못 한다. 보내는 쪽이 안정적인 id를 붙여줘야 한다.

  • id는 재전송돼도 같아야 한다 - 재전송 때 새 id를 붙이면 소비자가 “새 메시지”로 오해해 중복 처리한다. “이 사건 하나”에 하나의 id가 평생 따라붙어야 한다.
  • 인박스는 무한히 쌓인다 - 오래된 id는 주기적으로 지운다(예: 재전송이 올 리 없는 기간이 지난 것). 지우는 기준은 브로커의 재전송 정책보다 넉넉히 잡는다.
  • 자연 멱등을 먼저 노린다 - 기억하기(인박스)는 관리 비용이 든다. 연산을 덮어쓰기 모양으로 바꿔 애초에 멱등하게 만들 수 있으면 그게 제일 싸다.

정리

  • 메시지 시스템은 “적어도 한 번”이라, 트래픽이 쌓이면 같은 메시지가 결국 언젠가 두 번 온다.
  • 그대로 두 번 처리하면 결제·재고가 두 번 나가니, 소비자를 멱등하게 짠다 - 두 번 받아도 한 번처럼.
  • 길은 둘 - 처리한 id를 기억(인박스, 반드시 처리와 같은 트랜잭션)하거나, 연산을 덮어쓰기 모양으로 만들어 애초에 멱등하게.
  • 중복은 막아도 순서는 못 지킨다 - 그건 파티셔닝 같은 다른 장치의 몫이다.

다음 글은 그 인박스와 짝을 이루는 반대편 - 저장과 발행을 한 묶음으로 만드는 아웃박스다.

관련 글