Foundry
메시징
심화
핵심

멱등 소비자와 중복 처리

중복은 막을 수 없다. 두 번 와도 결과가 같게

멱등 소비자와 중복 처리

중복이 생기는 지점

  • 컨슈머가 처리를 끝내고 커밋 직전에 죽음 → 리밸런스 후 같은 메시지 재전송
  • ack 응답이 네트워크에서 유실 → 브로커는 실패로 보고 재전송
  • DLQ에 쌓인 메시지를 운영자가 수동 재투입

멱등 처리 수단

수단구현주의
유니크 제약order_id UNIQUE, 충돌은 무시DB가 최종 방어선
업서트INSERT ... ON CONFLICT DO UPDATE덮어쓰기 성격의 데이터에 적합
처리 이력처리한 키를 Redis/DB에 기록TTL 만료와 캐시 유실 시 뚫림
상태 전이이미 PAID면 재요청 무시상태 머신 정의 필요

실무 포인트

  • 멱등 키는 브로커가 준 오프셋이 아니라 비즈니스 키(주문 ID, 결제 요청 ID)로 잡는다
  • 중복 확인과 실제 처리가 하나의 트랜잭션이 아니면 동시 요청 두 건이 함께 통과한다
  • 되돌릴 수 없는 부수효과(메일 발송, 외부 결제)는 호출 전에 멱등 키를 선점한다
  • Redis dedup 단독은 캐시가 비면 무력하다. DB 유니크 제약과 2중으로 둔다
면접에서 이렇게 나옵니다

Q.at-least-once 환경에서 결제가 중복되지 않게 어떻게 설계하나요?

같은 메시지를 여러 번 받는 것을 막을 수 없으므로, 여러 번 받아도 한 번만 처리되게 만듭니다.

순서동작
1메시지에서 업무 기준의 고유 키를 뽑는다. 주문 id 등
2처리 이력 테이블에 그 키로 삽입을 시도한다
3중복 키 오류가 나면 이미 처리한 것이므로 건너뛴다
4삽입이 성공하면 결제를 진행한다
5이력 삽입과 결제 기록을 같은 트랜잭션으로 커밋한다

핵심은 5번입니다. 이력과 결과를 같은 트랜잭션에 두면 검사와 처리 사이의 경쟁이 사라집니다. 따로 커밋하면 그 사이에 죽어 이력만 남고 결제는 안 된 상태가 생깁니다.

2번에서 DB 의 고유 제약을 쓰는 것도 중요합니다. 조회로 확인하고 없으면 삽입하는 방식은 동시에 두 개가 들어오면 둘 다 통과합니다.

외부 결제 API 를 부를 때는 한 겹 더 둡니다. 그 API 에 멱등 키를 함께 보내면 우리 쪽에서 중복 호출이 나가도 상대가 걸러냅니다.

흔한 실수: 메시지 id 를 키로 쓰는 것. 재발행되면 id 가 달라져 중복을 못 걸러냅니다. 업무적으로 같은 일을 가리키는 값이어야 합니다.

Q.멱등 키를 메시지 오프셋으로 잡으면 안 되는 이유는?

오프셋은 같은 일을 가리키는 값이 아닙니다. 브로커 안의 위치일 뿐입니다.

상황오프셋실제
발행자가 재시도해 같은 내용이 두 번 발행됐다다르다같은 일
다른 파티션에 같은 내용이 들어갔다다르다같은 일
토픽을 재생성하고 재적재했다다르다같은 일
파티션이 다르면 오프셋이 겹친다같을 수 있다다른 일

첫 번째와 마지막이 실제 사고로 이어집니다. 발행 재시도로 중복 발행된 메시지는 오프셋이 다르므로 그대로 두 번 처리됩니다. 반대로 파티션이 다른 두 메시지가 같은 오프셋을 가질 수 있어, 오프셋만 키로 쓰면 처리해야 할 것을 건너뜁니다.

키는 업무적으로 그 일을 식별하는 값이어야 합니다.

좋은 키
업무 식별자주문 id, 결제 요청 id
발행자가 만든 고유 키발행 시점에 생성해 메시지에 담는다
조합 키사용자 id + 대상 id + 행위

흔한 실수: 오프셋이 유일하다는 점만 보고 고르는 것. 유일성이 아니라 같은 일에 같은 값이 나오는지가 멱등 키의 요건입니다.

Q.Redis에 처리 이력을 남기는 방식의 한계는 무엇인가요?

빠르지만 처리 결과와 함께 커밋할 수 없다는 것이 근본 한계입니다.

한계내용
원자성Redis 기록과 DB 처리를 하나로 묶을 수 없다
유실Redis 가 죽으면 이력이 사라져 중복 처리된다
만료만료 시간이 지나면 그 뒤에 온 중복을 못 막는다
정합성 확인실제 처리 여부와 어긋났는지 알기 어렵다

첫 번째가 만드는 구멍입니다. Redis 에 기록하고 DB 처리 전에 죽으면, 재시도했을 때 이력이 있으니 건너뛰고 실제 처리는 되지 않습니다. 유실이 조용히 발생합니다.

세 번째도 실무에서 문제가 됩니다. 만료를 짧게 두면 늦게 온 중복을 놓치고, 길게 두면 메모리가 계속 늡니다.

그래도 쓰는 경우가 있습니다.

쓸 만한 곳이유
앞단 걸러내기DB 부하를 줄이는 1차 필터로. 최종 판단은 DB 제약으로
유실이 허용되는 처리알림 중복 발송 방지 정도

흔한 실수: Redis 이력만으로 결제 중복을 막는 것. 최종 보장은 처리 결과와 같은 트랜잭션에 들어가는 DB 고유 제약이어야 합니다.

Q.중복 검사와 실제 처리 사이의 경쟁 조건을 어떻게 제거하나요?

검사와 처리를 하나의 원자적 연산으로 합칩니다. 나눠 있으면 그 사이에 다른 실행이 끼어듭니다.

먼저 문제 상황입니다.

순서소비자 A소비자 B
1이력을 조회한다. 없다
2이력을 조회한다. 없다
3처리하고 이력을 남긴다
4처리하고 이력을 남긴다

둘 다 조회 시점에는 없었으므로 둘 다 처리합니다.

해결내용
고유 제약으로 삽입 시도조회 없이 바로 삽입한다. 중복 오류가 곧 판정이다
조건부 삽입INSERT ... ON CONFLICT DO NOTHING 의 영향 행수로 판단한다
같은 트랜잭션이력 삽입과 업무 처리를 함께 커밋한다
원자적 잠금Redis 라면 SET key NX 처럼 한 번에 검사와 설정을 한다

첫 번째가 가장 값싸고 확실합니다. 조회를 없애면 그 사이의 창도 없어집니다. 검사하지 말고 시도하고 실패를 읽는 방향입니다.

흔한 실수: 조회 후 삽입 구조에 잠금을 덧붙여 해결하려는 것. 동작하지만 처리량을 깎습니다. DB 고유 제약이 이미 그 일을 원자적으로 해 줍니다.

먼저 스스로 답해보고 아래 답변과 견줘보세요. 막히는 부분은 문제로 확인할 수 있어요.

읽었으면 문제로 확인해보세요

메시징 문제를 풀면 틀린 문제가 자동으로 노트에 쌓입니다. 가입 없이 5문제를 먼저 풀어볼 수도 있어요.