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문제를 먼저 풀어볼 수도 있어요.