← 문제로

27. 멱등 dedup 키의 TTL이 재시도 지평보다 짧아 발생하는 이중 처리 (C#)

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 멱등 dedup 키의 TTL이 재시도 지평보다 짧아 발생하는 이중 처리 (C#)

난이도: 상

요약

두 가지 결함이 겹친다. ① check-then-set 비원자: Exists로 확인(A) 후 지급하고 나서 SetEx로 표시(B)하는데, 이 사이가 원자적이지 않아 거의 동시에 도착한 두 콜백이 둘 다 Exists==false를 보고 둘 다 지급한다. ② TTL < 재시도 지평: dedup 키 TTL이 10분인데 PG 재시도·DLQ 재투입은 수 시간 뒤에도 온다. 키가 만료된 뒤 도착한 늦은 중복은 Exists==false가 되어 다시 지급된다. 또한 지급(DB 커밋) 후에 키를 세팅하므로, 커밋과 SetEx 사이에 크래시하면 표시가 없어 재시도 시 이중 지급된다.

문제점

  • 분류태그: 멱등성 설계 결함 / dedup 창(window) < 재시도 지평 / check-then-set 레이스 / 비원자 마킹
  • 증상: 대부분 막히지만 (a) 동시 중복, (b) 아주 늦은 중복(수 시간 후), (c) 지급~마킹 사이 크래시에서 이중 지급.
  • 재현조건: 동시 재전송, TTL 경과 후 재전송, 지급 직후 프로세스 크래시.
  • 근본원인: 멱등성 마커의 원자성(검사+선점)과 수명(재시도 최대 지평 이상)과 지속성(지급과 같은 트랜잭션/커밋 후 보장)이 모두 부족하다.

수정안

검사와 선점을 원자적 SET NX로 합치고, TTL을 재시도 최대 지평 이상(혹은 영구)으로 두며, 지급 완료를 dedup 키에 원자적으로 묶는다.

// 1) 원자 선점: 없을 때만 세팅. 성공한 요청만 진행.
bool won = _redis.StringSet(key, "processing",
                            expiry: TimeSpan.FromHours(48),   // 재시도 지평 이상
                            when: When.NotExists);
if (!won)
{
    // 이미 누가 선점 → 완료 여부 확인(완료면 스킵, 진행중이면 대기/재큐)
    if (_redis.StringGet(key) == "done") return;
    throw new RetryLaterException();
}

GrantCurrency(playerId, amount);     // DB 트랜잭션(가능하면 dedup 마커를 같은 DB 트랜잭션에)
_redis.StringSet(key, "done", TimeSpan.FromHours(48));

가장 견고한 형태는 dedup 마커를 지급과 같은 DB 트랜잭션에 UNIQUE 제약으로 넣는 것이다: INSERT INTO processed_payments(payment_id) ... 가 중복이면 커밋 자체가 실패해 지급도 롤백된다. Redis는 빠른 1차 필터로만 쓰고, DB UNIQUE가 최종 방어선이 된다.

더 나은 설계

  • 멱등 키를 지급과 동일 트랜잭션에서 커밋(트랜잭셔널 아웃박스/UNIQUE 제약)해 "지급했으면 마킹도 됐다"를 원자 보장. Redis 단독 마킹은 크래시 창이 남는다.
  • dedup 보존 기간은 상류의 최대 재시도/재투입 지평(+여유) 이상으로 산정. 저장 비용이 문제면 만료 대신 콜드 스토리지로 이관.
  • 상태를 processing/done으로 구분해 진행 중 중복은 재시도로, 완료 중복은 무시로 처리.

면접 포인트

  • 멱등성의 세 축: 원자적 선점(SET NX), 충분한 보존 기간(≥ 재시도 지평), 지급과의 원자적 결합(같은 트랜잭션/UNIQUE).
  • "Redis TTL로 dedup"의 함정 — TTL이 곧 멱등 보장 창이며, at-least-once 상류의 재시도 지평을 넘겨선 안 된다.