厳密に一度という神話
한국어 원문으로 표시합니다.
한 줄 요약
정확히 한 번 = 적어도 한 번 전달 + 수신 측의 중복 제거. 인프라가 마법처럼 해 주기를 기다리지 말고 소비자를 멱등하게 만든다.
왜 이게 필요했나
결제 버튼을 눌렀는데 화면이 멈췄습니다. 사용자는 다시 누릅니다. 서버는 두 번의 요청을 받았고, 둘 다 처리하면 이중 청구입니다.
이 장면의 근원은 앞에서 본 구별 불가능성입니다. 보낸 쪽이 확인 응답을 못 받았을 때, 메시지가 도착하지 못한 것인지 도착했는데 응답만 유실된 것인지 알 방법이 없습니다. 그래서 유실을 막으려면 재시도해야 하고, 재시도는 중복을 낳습니다.
전략은 둘밖에 없습니다. 최대 한 번 — 보내고 잊는다, 유실 가능, 중복 없음. 적어도 한 번 — 확인 응답 받을 때까지 재시도, 유실 없음, 중복 가능. 대부분의 시스템은 후자를 고릅니다. 유실이 중복보다 나쁘기 때문입니다.
어떻게 동작하나
먼저 멱등한 연산과 아닌 연산을 구별해야 합니다. "이메일을 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 유니크 제약으로 기록하면 끝입니다. 유니크 제약을 쓰면 데이터베이스가 원자성까지 대신 보장해 주므로 경쟁 조건이 사라집니다.
주의할 함정 하나. "처리했다고 기록"과 "실제 처리"가 서로 다른 저장소에 있으면 다시 이중 쓰기 문제입니다. 가능하면 같은 트랜잭션 안에 넣습니다.
실패한 메시지는 어디로 가나
멱등성을 갖춰도 남는 문제가 있다. 영원히 실패하는 메시지다. 형식이 깨졌거나, 참조하는 데이터가 지워졌거나, 코드에 결함이 있어서 몇 번을 다시 시도해도 같은 예외가 난다. 이 메시지가 큐 맨 앞에 있으면 그 뒤의 정상 메시지가 전부 막히고, 재시도가 계속 자원을 먹는다.
그래서 두 장치가 필요하다.
재시도에 상한과 간격을 둔다. 즉시 다시 시도하면 같은 이유로 또 실패하므로, 간격을 점점 늘린다. 그리고 여러 소비자가 동시에 실패했다면 재시도 시각도 함께 몰리므로, 간격에 약간의 무작위를 섞어 흩어 준다. 이것이 없으면 상대 서비스가 회복되자마자 재시도 폭풍을 맞고 다시 쓰러진다.
상한을 넘으면 죽은 편지 큐로 보낸다. 처리 흐름에서 빼내되 버리지는 않는 자리다. 여기로 옮길 때는 원본 메시지와 함께 왜 실패했는지, 몇 번 시도했는지, 마지막 오류가 무엇이었는지를 같이 남긴다. 이 정보가 없으면 나중에 그 큐를 열어 보고도 무엇을 해야 할지 알 수 없다.
죽은 편지 큐는 만들어 두고 잊기 쉽다. 그 큐의 길이에 알림을 걸어 두지 않으면 아무도 보지 않습니다. 그리고 알림이 울렸을 때 할 일도 정해 두어야 한다. 코드를 고친 뒤 다시 넣을 것인지, 데이터를 고칠 것인지, 그냥 버려도 되는 것인지. 이 판단은 대개 업무 담당자의 몫이므로, 무엇이 실패했는지를 사람이 읽을 수 있는 형태로 보여 줄 방법이 함께 있어야 한다.
마지막으로 처리 순서에 대한 것 하나. 재시도는 순서를 깨뜨린다. 앞의 메시지가 재시도로 미뤄지는 동안 뒤의 메시지가 먼저 처리되기 때문이다. 순서가 중요한 흐름이라면 같은 키의 메시지를 한 줄로 묶어 처리하거나, 아예 순서에 의존하지 않도록 각 메시지가 최종 상태를 담게 설계해야 한다. 후자가 훨씬 튼튼하고, 그것이 앞에서 본 "설정은 멱등하고 증가는 아니다" 와 같은 이야기다.
다음 실습에서 할 것
일부러 이중 청구가 나는 결제 서비스를 만들고, 멱등성 키를 붙이고, 동시 요청에서도 한 번만 처리되게 원자적 선점을 넣고, 본문 불일치를 거부하고, 키 만료까지 설정합니다.