← 모든 글
Redis

Redis 분산 락이 결제 중복을 막아주지 못한 이유

락 만료와 긴 GC 정지 뒤에 발생한 중복 처리를 추적하고, fencing token과 데이터베이스 제약으로 정확성의 경계를 다시 세운 기록입니다.

같은 주문에 결제 요청이 동시에 들어오는 문제를 막기 위해 Redis 분산 락을 사용했다. 키가 존재하면 한 요청만 실행되므로 중복 결제가 사라질 것으로 기대했다. 평소에는 잘 동작했지만, 한 번의 긴 GC 정지 이후 같은 주문의 결제 레코드가 두 개 생겼다.

문제는 Redis가 락을 잘못 준 것이 아니었다. 락을 획득한 프로세스가 여전히 유효한 소유자인지 실제 데이터를 변경하는 시스템은 알지 못했다.

재현한 실패 경로

락의 TTL은 3초였고 일반적인 결제 준비 단계는 300ms 안에 끝났다. 장애 당시 흐름은 다음과 같았다.

  1. 인스턴스 A가 order:42 락을 얻는다.
  2. A가 4초 동안 GC로 멈춘다.
  3. 락이 만료되고 인스턴스 B가 같은 락을 얻는다.
  4. B가 결제 레코드를 생성한다.
  5. A가 깨어나 자신이 여전히 소유자라고 생각하고 레코드를 생성한다.

락을 얻을 때 임의 토큰을 값으로 저장하고 Lua 스크립트로 소유자만 해제하도록 구현해도 이 실패는 남는다.

SET order:42 9b71... NX PX 3000

안전한 해제는 다른 소유자의 락을 지우지 않게 할 뿐, 만료된 이전 소유자의 쓰기를 막지는 않는다.

TTL을 늘리는 것은 확률을 낮출 뿐이다

TTL을 30초로 늘리자는 제안이 있었다. 평균 처리 시간보다 충분히 길어 보이지만 상한을 증명할 수 없다면 같은 문제가 더 드물게 일어날 뿐이다. 네트워크 지연, stop-the-world GC, 외부 결제사 타임아웃이 겹치면 어떤 TTL도 만료될 수 있다.

락 연장 watchdog도 도움이 되지만 완전한 답은 아니다. 프로세스가 멈추면 watchdog도 멈추고, 네트워크가 분리되면 연장 성공 여부를 확정하기 어렵다. 가용성을 위해 만료가 필요하고, 만료를 허용하면 과거 소유자가 돌아올 가능성을 처리해야 한다.

쓰기 시스템이 오래된 소유자를 거절하게 한다

해결의 핵심은 단조 증가하는 fencing token이었다. 락을 얻을 때마다 더 큰 번호를 받고, 데이터를 변경할 때 토큰을 함께 전달한다. 저장소는 마지막으로 본 토큰보다 작은 쓰기를 거절한다.

UPDATE payment_attempt
SET status = 'READY',
    fencing_token = :token
WHERE order_id = :orderId
  AND fencing_token < :token;

A가 토큰 101을 받은 뒤 멈추고 B가 102를 받아 먼저 쓰면, 늦게 돌아온 A의 101은 updated rows = 0으로 거절된다. 시간이나 프로세스 상태를 추측하지 않고 순서 자체를 데이터에 남긴다.

다만 모든 외부 시스템이 fencing token을 이해하는 것은 아니다. 결제사 API에 임의 버전 조건을 전달할 수 없다면 토큰만으로 외부 부작용을 막을 수 없다. 이 경우 결제사에서 지원하는 멱등 키를 주문의 결제 시도 ID에 연결해야 한다.

PaymentResult result = paymentGateway.approve(
    request.withIdempotencyKey(paymentAttempt.getId().toString())
);

데이터베이스 제약을 마지막 방어선으로 둔다

우리의 실제 불변식은 “한 번에 한 프로세스만 실행”이 아니라 “주문 하나에 유효한 결제 시도는 하나”였다. 따라서 정확성을 락에만 맡기지 않고 데이터 모델에 표현했다.

CREATE UNIQUE INDEX ux_payment_attempt_order_active
ON payment_attempt (order_id, active_key);

active_key는 활성 시도일 때 고정값, 종료된 시도일 때 고유값을 사용했다. 데이터베이스 종류에 따라 partial unique index나 별도 상태 테이블을 선택할 수 있다. 중요한 점은 경쟁 요청이 락 경계를 통과하더라도 저장소가 불변식을 거절한다는 것이다.

각 장치의 역할은 달랐다.

장치 담당한 역할
Redis 락 동시에 수행되는 비싼 작업을 줄임
Fencing token 만료된 소유자의 늦은 쓰기를 거절
DB 유니크 제약 비즈니스 불변식의 최종 보장
결제사 멱등 키 외부 부작용의 중복 방지

락을 적용하기 전에 묻는 질문

이후 분산 락을 검토할 때 네 가지를 먼저 확인한다.

  1. 보호하려는 것은 실행 구간인가, 데이터 불변식인가?
  2. 락이 만료된 뒤 이전 소유자가 돌아오면 누가 쓰기를 거절하는가?
  3. 외부 부작용은 멱등 키나 상태 조회로 복구할 수 있는가?
  4. Redis를 사용할 수 없을 때 실패를 닫을 것인가, 제한적으로 열 것인가?

락이 없어도 데이터가 틀리지 않고, 락이 있을 때 불필요한 경쟁만 줄어드는 구조가 가장 운영하기 쉬웠다.

마치며

분산 락은 상호 배제를 위한 유용한 조정 도구지만 정확성의 증명은 아니다. TTL이 있는 락에는 항상 과거 소유자가 돌아올 수 있다. 그 가능성을 fencing token, 데이터베이스 제약, 외부 API의 멱등성으로 각 경계에서 차단해야 한다.

장애 이후 우리가 바꾼 가장 중요한 문장은 “Redis 락으로 중복 결제를 막는다”에서 “저장소와 결제사가 중복을 거절하고, Redis 락은 경쟁 비용을 줄인다”였다.