포스트

멱등성 설계 - 키의 단위와 저장 위치, 만료와 재시도 계약

멱등한 API 설계에서 HTTP 계약의 관점으로 멱등성을 다뤘다. 이 글은 그 아래 층이다. 키를 어떤 단위로 만들고, 어디에 저장하고, 언제 지우고, 동시 요청을 어떻게 막는가. 계약이 정해져도 이 넷을 틀리면 멱등성은 없다.

키의 단위가 “무엇이 한 번인가”를 정한다

키를 정하는 것은 곧 중복의 정의를 정하는 것이다.

요청 단위(request-level). 클라이언트가 UUID를 만든다. “이 버튼 누름 한 번”이 단위다. 같은 주문에 대한 서로 다른 결제를 허용한다. 대부분의 경우 이것이 맞고, 클라이언트가 키를 관리할 수 있을 때 쓴다.

엔티티 단위(entity-level). payment-1234-refund처럼 도메인 값으로 결정적으로 만든다. “결제 1234의 환불은 한 번”이 서버에 의해 강제된다. 클라이언트가 키를 잃어버려도 같은 키가 재구성되므로 더 강한 보장이다. 대신 부분 환불처럼 “같은 대상에 여러 번”이 정당한 경우를 막아 버린다.

선택 기준은 클라이언트를 믿을 수 있는가다. 외부 시스템이 호출자이고 그쪽의 재시도 정책을 통제할 수 없다면 엔티티 단위가 안전하다. 내부 서비스이고 계약을 강제할 수 있다면 요청 단위가 유연하다.

저장 위치: 조건부 삽입이어야 한다

멱등 레코드를 “조회 후 없으면 삽입”으로 만들면 동시 요청에서 깨진다. 두 요청이 같은 순간에 조회하면 둘 다 없다고 보고 둘 다 삽입한다. 유니크 제약이 있으면 한쪽이 제약 위반으로 실패하고, 없으면 둘 다 처리된다.

답은 조건부 삽입이다.

1
2
3
4
INSERT INTO idempotency (key, status, created_at)
VALUES (:key, 'IN_PROGRESS', now())
ON CONFLICT (key) DO NOTHING;
-- 영향 행 수가 0이면 이미 있는 키다

영향 행 수 하나로 “내가 처음인가”가 판정된다. 조회와 판정 사이의 창이 없다. ParityPay 4편의 결함 E가 정확히 이 교훈이었다. 원장 계정 생성을 “조회 후 없으면 삽입”으로 두었다가 부하 시작 직후 유니크 제약 위반이 났고, ON CONFLICT DO NOTHING 후 조회로 바꿨다.

저장 위치는 업무 데이터와 같은 트랜잭션에 들어갈 수 있는 곳이어야 한다. 멱등 레코드는 DB에 쓰고 업무 처리는 다른 곳에 한다면, 둘 사이에서 죽었을 때 어긋난다. Redis에 키를 두고 DB에 업무를 쓰는 구성이 흔한데, 그 구성은 두 저장소 사이의 원자성을 포기한 것이다.

세 가지 상태와 동시 진입

키 하나는 세 상태를 지난다.

상태새 요청이 왔을 때
없음처리 시작. IN_PROGRESS로 기록
IN_PROGRESS아직 결과가 없다. 409를 주거나 짧게 대기
COMPLETED저장된 최초 결과를 그대로 반환

IN_PROGRESS가 중요하다. 이 상태가 없으면 동시에 온 두 요청이 모두 “처리된 적 없음”을 보고 둘 다 처리한다. 조건부 삽입이 이 상태를 원자적으로 만든다.

그리고 IN_PROGRESS에는 만료가 필요하다. 처리 중에 프로세스가 죽으면 그 키는 영원히 진행 중으로 남고, 재시도가 계속 409를 받는다. 만료 시각을 두고 지난 것은 다시 시도할 수 있게 한다. Airbnb의 Orpheus가 lease 개념으로 다룬 것이 이것이고, 그 글은 “lease 만료가 RPC 타임아웃보다 길어야 한다”를 경험칙으로 적었다(리뷰).

여기에 분산락 실험에서 배운 것이 겹친다. lease의 시계는 그 lease를 준 쪽의 것이므로 “충분히 긴 lease”의 상한은 알 수 없다. 그래서 만료에만 의존하지 않고, 재개 시 외부 상태를 조회해 확정하는 경로가 함께 있어야 한다.

페이로드 일치 검증

같은 키에 다른 내용이 오면 어떻게 할 것인가. 조용히 첫 결과를 돌려주면 금액 불일치를 숨긴다. 클라이언트가 키 재사용을 잘못한 것이고, 그것을 알려야 한다.

요청 본문의 해시를 멱등 레코드에 함께 저장하고, 재시도 시 비교해 다르면 거부한다(보통 422). 저장 비용이 적고 잡아내는 사고가 크다.

만료와 보존

COMPLETED 레코드를 언제까지 두는가. 기준은 하나다. 클라이언트의 최대 재시도 창보다 길게.

너무 짧으면 늦게 도착한 재시도가 “처음 보는 키”가 되어 다시 처리된다. 이중 결제다. 너무 길면 테이블이 처리량에 비례해 자란다. ParityPay 5편의 한계에 적은 “PUBLISHED 행이 쌓인다”와 같은 종류의 문제이고, 파티셔닝이나 주기적 삭제가 필요하다.

응답 본문까지 저장하면 재시도가 빨라지지만 테이블이 더 커진다. 응답이 큰 API라면 결과의 식별자만 저장하고 본문은 다시 조회하는 절충도 있다.

이 설명이 깨지는 곳

  • 멱등성은 외부 부작용을 되돌리지 않는다. 저장은 한 번이어도 이미 나간 메일은 그대로다. 부작용마다 별도의 멱등 처리가 필요하다.
  • 외부 기관이 멱등하지 않으면 우리 쪽 키는 소용이 없다. ParityPay 9편에서 재시도 3회가 기관 요청을 정확히 3.00배로 만들었고, 이중 청구가 안 난 것은 Mock PG가 외부 키로 멱등했기 때문이다. 기관의 멱등성에 우리 돈의 정확성을 걸지 않으려면 재시도 대신 조회다.
  • 키를 URL이나 로그에 남기면 추측 가능해질 수 있다. 엔티티 단위 키는 특히 예측 가능하므로, 그것만으로 인가를 대신하면 안 된다.
  • 분산 환경에서 키 저장소가 단일 장애점이 된다. 그 저장소가 죽으면 전체가 멈춘다. 업무 DB와 같은 곳에 두는 편이 장애 면에서 단순하다.

무엇을 재면 확인되는가

  1. 같은 키로 동시에 N개를 보내고 부작용이 정확히 1회인지 센다. 순차 재시도보다 어려운 조건이고, IN_PROGRESS 처리의 실제 시험이다.
  2. 처리 중에 프로세스를 죽이고 재시도했을 때 복구되는지, 몇 초 뒤에 복구되는지.
  3. 보존 기간을 넘긴 재시도가 어떻게 처리되는지.

ParityPay 1편의 INV-004(“같은 업무 참조의 금융 효과는 한 번만”)를 “같은 멱등 키로 100번 보내면 잔액이 한 번만 움직이는가”라는 실험으로 바꾼 것이 1번의 순차 버전이다. 동시 버전은 따로 재야 한다.

정리

  • 키의 단위가 중복의 정의다. 요청 단위는 유연하고 엔티티 단위는 강제력이 있다.
  • 멱등 레코드는 조건부 삽입으로 만든다. 조회 후 삽입은 동시 요청에서 깨진다.
  • 키 저장소는 업무 데이터와 같은 트랜잭션에 들어갈 수 있는 곳이어야 한다.
  • IN_PROGRESS 상태와 그 만료가 동시 진입과 프로세스 사망을 함께 다룬다.
  • 페이로드 해시를 비교해 같은 키에 다른 내용이 오는 것을 거부한다.
  • 보존 기간은 클라이언트의 최대 재시도 창보다 길어야 한다.
  • 우리 쪽 멱등성은 외부 기관의 멱등성을 대신하지 못한다.

참고

결제·정산 정합성 장애 대응과 관측 아키텍처와 마이그레이션
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

댓글

아직 댓글이 없습니다