LabHub

큐와 비동기 API · 소비자 멱등성 · 이론

정확히 한 번이라는 신화

LabHub 에서 이어서 보기

한 줄 요약

정확히 한 번 = 적어도 한 번 전달 + 수신 측의 중복 제거. 인프라가 마법처럼 해 주기를 기다리지 말고 소비자를 멱등하게 만든다.

왜 이게 필요했나

결제 버튼을 눌렀는데 화면이 멈췄습니다. 사용자는 다시 누릅니다. 서버는 두 번의 요청을 받았고, 둘 다 처리하면 이중 청구입니다.

이 장면의 근원은 앞에서 본 구별 불가능성입니다. 보낸 쪽이 확인 응답을 못 받았을 때, 메시지가 도착하지 못한 것인지 도착했는데 응답만 유실된 것인지 알 방법이 없습니다. 그래서 유실을 막으려면 재시도해야 하고, 재시도는 중복을 낳습니다.

전략은 둘밖에 없습니다. 최대 한 번 — 보내고 잊는다, 유실 가능, 중복 없음. 적어도 한 번 — 확인 응답 받을 때까지 재시도, 유실 없음, 중복 가능. 대부분의 시스템은 후자를 고릅니다. 유실이 중복보다 나쁘기 때문입니다.

어떻게 동작하나

먼저 멱등한 연산과 아닌 연산을 구별해야 합니다. "이메일을 a@b.com 으로 설정하라"는 몇 번을 보내도 결과가 하나입니다. "잔액을 100원 증가시켜라"는 두 번 보내면 200원입니다. 설정은 멱등하고 증가는 아닙니다.

HTTP 는 이것을 메서드 수준에서 이미 규정합니다. GET/HEAD/OPTIONS 는 안전하고 멱등, PUT/DELETE 는 멱등, POST 는 아닙니다. PUT 이 멱등한 이유는 "이 리소스를 이 값으로 만들어라"라는 설정 연산이기 때문입니다. DELETE 도 마찬가지입니다 — 이미 지운 것을 또 지우라 해도 최종 상태는 같습니다. 응답 코드가 404 로 달라질 수는 있지만, 멱등성은 상태에 관한 성질이지 응답 코드에 관한 성질이 아닙니다.

POST 를 멱등하게 만드는 표준적 방법이 멱등성 키입니다. 클라이언트가 요청마다 UUID 를 하나 만들어 헤더에 싣고, 서버는 그 키를 기억했다가 같은 키가 또 오면 새로 처리하지 않고 저장해 둔 첫 응답을 그대로 돌려줍니다. Stripe 와 PayPal 이 정확히 이 방식을 씁니다.

구현에서 신경 쓸 곳이 넷입니다.

첫째, 키의 저장과 만료. 영원히 보관할 수 없으니 보통 24시간을 둡니다.

둘째, 동시성. 같은 키의 요청 두 개가 거의 동시에 도착하면 둘 다 "처음 보는 키"로 착각해 이중 처리할 수 있습니다. 키를 받는 순간 원자적으로 선점해야 합니다. Redis 라면 SET key <state> NX EX 86400 한 번이면 됩니다.

셋째, 키와 본문의 일치 검증. 같은 키인데 본문이 다르면 클라이언트 실수이거나 공격입니다. 거부하는 편이 안전합니다.

넷째, 실패의 성격 구분. 일시적 실패(DB 타임아웃)는 재시도가 진짜로 다시 시도되어야 하지만, 확정적 실패(잔액 부족)는 캐시해 같은 답을 주는 편이 낫습니다.

현장에서 만나는 모습

메시지 큐 소비자에서는 키가 이미 있는 경우가 많습니다. 이벤트 ID 나 집합체 ID + 시퀀스입니다. 처리한 ID 를 Redis 집합이나 DB 유니크 제약으로 기록하면 끝입니다. 유니크 제약을 쓰면 데이터베이스가 원자성까지 대신 보장해 주므로 경쟁 조건이 사라집니다.

주의할 함정 하나. "처리했다고 기록"과 "실제 처리"가 서로 다른 저장소에 있으면 다시 이중 쓰기 문제입니다. 가능하면 같은 트랜잭션 안에 넣습니다.

다음 실습에서 할 것

일부러 이중 청구가 나는 결제 서비스를 만들고, 멱등성 키를 붙이고, 동시 요청에서도 한 번만 처리되게 원자적 선점을 넣고, 본문 불일치를 거부하고, 키 만료까지 설정합니다.