한 번 눌렀는데 두 번 결제됐다면, 멱등성

사용자는 결제 버튼을 한 번 눌렀는데 청구는 두 번 나갔다. 응답을 못 받았다고 다시 보낸 게 원인이었다. 그런데 보낸 쪽은 처리가 됐는지 안 됐는지 알 길이 없다.

결제가 두 번 처리됐다. 사용자는 버튼을 한 번 눌렀는데 청구는 두 번 나갔다.

로그를 따라가 보니 네트워크 타임아웃이 끼어 있었다. 우리 서버가 결제사에 요청을 보냈고, 결제사는 정상 처리했다. 그런데 응답이 우리에게 닿기 전에 연결이 끊겼다. 우리 코드는 실패라고 판단하고 다시 보냈다.

여기서 붙잡아야 할 사실이 하나 있다. 응답을 못 받은 것과 처리가 안 된 것은 다른 일이다. 그런데 요청을 보낸 쪽에서는 둘을 구별할 길이 없다.

그래서 재시도는 언젠가 꼭 중복을 만든다. 확률이 아니라 시간 문제다. 요청이 충분히 쌓이면 반드시 만난다.

보낸 쪽은 왜 결과를 알 수 없나

분산 시스템에서 요청을 보낸 쪽이 아는 건 "응답을 받았다", "못 받았다" 둘뿐이다. 못 받았을 때 그게 처리 전에 끊긴 건지 처리 뒤에 끊긴 건지는 모른다.

그러니 고를 수 있는 길도 둘이다. 재시도를 안 하면 정말 처리 안 된 요청을 흘린다. 재시도를 하면 이미 처리된 요청을 또 보낼 수 있다.

대부분은 재시도 쪽을 고른다. 잃어버리는 것보다 겹치는 게 다루기 쉬우니까. 대신 조건이 하나 붙는다. 받는 쪽이 중복을 알아볼 수 있어야 한다.

그 장치가 멱등성이다. 같은 요청을 몇 번 받든 한 번 받은 것과 결과가 같게 만든다.

여기서 자주 헷갈린다. 멱등성은 "두 번째 요청을 무시한다"가 아니다. 두 번째 요청에도 첫 번째와 같은 응답을 돌려줘야 한다. 그래야 보낸 쪽이 결과를 안다. 무시하고 아무 응답도 안 주면 보낸 쪽은 여전히 깜깜하다.

여러 길이 결국 여기서 만난다. 아웃박스도, 컨슈머 리밸런싱도, 재시도도 전부 "적어도 한 번"을 만들어 내고, 그 전제 위에서는 멱등성이 꼭 필요해진다.

Idempotency-Key 를 붙이고 받는 쪽에서 막기

키로 구분하고, 같은 응답을 돌려준다:

코드
· 보내는 쪽 — 요청마다 고유 키를 만들어 붙인다
 Idempotency-Key: 550e8400-...
 재시도할 때는 같은 키를 써야 한다
 매번 새 키를 만들면 아무 의미가 없다

· 받는 쪽 — 키로 이미 처리했는지 확인
 BEGIN;
 INSERT INTO idem (key, status)
 VALUES (:key, 'processing')
 ON CONFLICT (key) DO NOTHING;
 -- 삽입이 0행이면 이미 있던 요청이다
 ... 처리 ...
 UPDATE idem SET status='done', response=:res
 WHERE key = :key;
 COMMIT;

· 두 번째 요청에는 저장해 둔 응답을 그대로 돌려준다
 무시하고 아무것도 안 주면 보낸 쪽은 여전히 모른다

✕ 문제 지점 · ✓ 해법 · 그대로 돌리기 전에 자기 환경에 맞게 고친다.

키는 누가 만들고, 언제까지 들고 있나

막상 구현하면 신경 쓸 데가 꽤 있다.

먼저 키를 누가 만드느냐. 보내는 쪽이 만들어야 한다. 그래야 재시도할 때 같은 키를 쓸 수 있다. 받는 쪽이 만들면 재시도인지 새 요청인지 가를 근거가 없다.

재시도 때 정말 같은 키를 쓰는지도 봐야 한다. 요청마다 새 키를 만드는 구현이 실제로 흔한데, 그러면 멱등성 장치를 달아 놔도 아무 역할을 못 한다.

키를 얼마나 들고 있을지도 정해야 한다. 영원히 두면 저장소가 끝없이 커지고, 너무 짧으면 늦게 온 재시도를 놓친다. 하루에서 며칠 정도가 흔한 범위다.

같은 키가 동시에 들어오면? 두 요청이 거의 같은 순간에 도착하면 둘 다 "처음 보는 키"라고 판단할 수 있다. 그래서 키 저장은 유니크 제약이나 조건부 삽입으로 원자적으로 해야 한다. 확인하고 나서 넣는 두 단계로 나누면 그 사이에 끼어들 틈이 생긴다.

처리 중이라는 상태도 있어야 한다. 첫 요청이 아직 돌고 있을 때 두 번째가 오면, 기다리게 하든지 "처리 중"이라는 응답을 주든지 해야 한다. 그냥 통과시키면 그게 중복이다.

부분 실패도 따져 봐야 한다. 처리는 끝났는데 응답을 저장하기 전에 죽으면, 다음 요청에 뭘 돌려줄지 애매해진다. 그래서 처리와 기록이 같은 트랜잭션 안에 있는 게 이상적이다. 외부 API 호출이 끼면 그게 안 되니, 그땐 상태를 단계별로 남겨서 이어서 할 수 있게 만들어야 한다.

그리고 어떤 연산은 처음부터 멱등하다. 값을 정해진 값으로 설정하는 건 몇 번을 해도 같다. 증감은 아니다. 설계할 때 이 성질을 이용하면 따로 장치를 안 달아도 안전해진다.

중복 결제가 다시 나기 전에

  1. 보내는 쪽이 고유 키를 만들어 붙인다. 재시도할 때 같은 키를 쓰는지는 꼭 따로 확인해야 한다.
  2. 받는 쪽에서는 키를 원자적으로 저장해야 한다. 유니크 제약이나 조건부 삽입으로. 확인 후 삽입은 틈이 생긴다.
  3. 두 번째 요청을 무시만 하면 보낸 쪽은 결과를 모른다. 저장해 둔 응답을 돌려주자.
  4. 동시에 온 두 요청 중 하나는 기다리거나 처리 중 응답을 받아야 한다. 처리 중 상태가 그래서 필요하다.
  5. 키의 유효 기간을 정하고 정리한다.
  6. 증감 대신 설정, 추가 대신 덮어쓰기. 가능하면 연산 자체를 멱등하게 설계하는 게 제일 편하다.
  7. 처리와 기록은 같은 트랜잭션에 두는 게 좋다. 그게 안 되면 단계별 상태를 남겨 이어서 할 수 있게 해야 한다.
  8. 외부 서비스가 멱등키를 지원한다면 반드시 쓴다. 결제사는 대부분 지원한다.
  9. 멱등키를 캐시에만 두면 캐시가 날아갈 때 보호도 같이 사라진다. 영속 저장소에 둬야 한다.

같은 키로 두 번 보내 보기

키가 제대로 동작하는가:

코드
· 재시도 시 같은 키를 쓰는지 — 가장 흔한 실수
 클라이언트 코드에서 키 생성 위치를 확인한다
 재시도 루프 안에서 만들면 매번 달라진다

· 같은 키로 두 번 보내 본다
 두 번째도 같은 응답이 오는가
 아무 응답이 없거나 다른 결과면 잘못됐다

· 동시에 같은 키를 보내 본다
 둘 다 통과하면 원자성이 없는 것이다

· 중복 처리가 실제로 얼마나 잡히고 있나
 멱등키 충돌 횟수를 카운터로 센다
 0 이 아닌 게 정상이다 — 장치가 일하고 있다는 뜻

· 키 저장소가 영속적인가
 캐시에만 두면 캐시가 날아갈 때 보호도 사라진다

핵심 한 줄재시도는 반드시 일어난다. 두 번 받아도 한 번인 것처럼 만들어라.

브루니가 적는다

색을 바꾸며 숨는 카멜레온이다. 운영에서 실제로 터지는 문제를 한 편씩 적는다. 같은 실수를 두 번 하지 않으려고 쓰는 기록이다.

장애·확장 글 22편 모두 보기 →