통합과 배포 · 우리가 받는 쪽일 때 · 이론
우리가 부르는 것이 아니라 우리가 받는 것
한 줄 요약
웹훅은 통제권이 반대편에 있는 통합이라, 받는 쪽은 누가 보냈는지(서명) · 언제 보낸 것인지(시각 창) · 이미 본 것인지(중복) · 더 새것인지(순서) 네 가지를 스스로 판단해야 하고, 그 판단을 다 하기 전에 200 을 돌려주어야 한다.
왜 이게 필요했나
우리가 API 를 부르는 통합에서는 시작 시각도 재시도 정책도 우리가 정한다. 웹훅은 반대다. 상대가 우리 주소로 POST 하고, 우리는 그 요청을 거절할 수도 미룰 수도 없다. 여기서 세 가지가 동시에 우리를 문다.
첫째, 아무나 우리 주소로 POST 할 수 있다. 웹훅 수신 종단은 인터넷에 열려 있어야 상대가 부를 수 있다. 그 말은 남도 부를 수 있다는 뜻이다. 주소를 아무도 모른다는 것은 방어가 아니다.
둘째, 상대는 다시 보낸다. 우리가 200 을 늦게 주거나 못 주면 상대는 실패로 보고 재전송한다. 즉 우리 처리 시간이 길수록 중복이 늘어난다. 그리고 재전송은 우리가 처리에 실패했을 때만 오는 것이 아니다. 우리는 이미 처리했는데 응답만 늦어 상대가 못 받은 경우가 훨씬 많다.
셋째, 순서는 보장되지 않는다. 주문 하나에 대해 accepted → paid → shipped 세 이벤트가 났다고 해서 그 순서로 도착하지 않는다. 상대가 병렬로 보내거나 한 건이 재시도를 도는 사이에 다음 건이 먼저 닿으면 순서가 뒤집힌다. 도착 순서대로 덮어쓰면 주문 상태가 뒤로 간다.
어떻게 동작하나
서명. 널리 쓰이는 방식은 공유 비밀로 계산한 HMAC 을 헤더에 실어 보내는 것이다. [RFC 2104](https://www.rfc-editor.org/rfc/rfc2104.html)가 HMAC 을 정의하고, 실무 구현은 대체로 [Stripe 의 웹훅 문서](https://docs.stripe.com/webhooks)와 [GitHub 의 배달 검증 문서](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries)가 보여 주는 모양을 따른다. 헤더에 타임스탬프 t 와 서명 v1 을 담고, 서명의 재료는 타임스탬프와 원본 본문을 이어 붙인 문자열이다.
여기서 가장 자주 나는 실수는 본문을 파싱한 뒤 다시 직렬화해서 서명을 계산하는 것이다. 키 순서나 공백이 한 글자만 달라도 서명은 전혀 다른 값이 된다. 서명 검증은 반드시 받은 바이트 그대로 해야 한다.
두 번째 실수는 비교 방법이다. got == want 로 비교하면 첫 번째 다른 바이트에서 곧바로 끝나고, 그 시간 차이가 공격자에게 "앞의 몇 글자는 맞았다" 는 정보를 준다. 파이썬에서는 [hmac.compare_digest](https://docs.python.org/3/library/hmac.html) 가 길이에 비례하는 시간으로 비교한다.
시각 창. 서명이 맞아도 그것이 지금 보낸 것이라는 뜻은 아니다. 예전에 오간 유효한 요청을 그대로 다시 밀어 넣는 것을 재생(replay)이라고 하고, 서명만으로는 막히지 않는다. 그래서 서명 재료에 타임스탬프를 넣고, 받는 쪽이 지금 시각과의 차이가 창(보통 몇 분) 안인지 본다. 창을 좁히면 시계가 조금만 어긋나도 정상 요청이 거절되고, 넓히면 재생 가능 구간이 길어진다.
중복. 열쇠가 둘이라는 점이 함정이다. 배달 번호(delivery id)는 한 번의 전송을 가리키고, 이벤트 번호(event id)는 벌어진 사건 하나를 가리킨다. 같은 배달 번호로 다시 오면 그것은 재전송이고, 배달 번호는 새것인데 이벤트 번호가 같으면 그것도 같은 사건이다. 막아야 하는 것은 사건의 중복 적용이므로 진짜 열쇠는 이벤트 번호다. 배달 번호만으로 거르면 후자를 놓친다.
순서. 도착 순서를 믿지 말고 이벤트가 들고 있는 판(version)이나 발생 시각으로 판단한다. 지금 저장된 것보다 새것일 때만 적용하고, 옛것은 조용히 버린다.
POST /webhook │ ├─ 서명 틀림 ──────────▶ 400 (원장에 손대지 않는다) ├─ 시각 창 밖 ─────────▶ 400 └─ 통과 ─▶ 큐에 적재 ─▶ 200 (여기서 끝. 처리는 뒤에서) │ └─ 배수(drain) ─▶ 중복인가? 더 새것인가? ─▶ 원장 적용빨리 200 을 주는 것이 마지막 조각이다. 받는 자리에서 원장까지 손대면 처리가 느려질수록 상대의 타임아웃에 걸리고, 그러면 재전송이 늘고, 재전송이 늘면 처리가 더 느려진다. 검증만 하고 큐에 넣은 뒤 곧바로 답하면 이 고리가 끊긴다.
현장에서 만나는 모습
첫째, "가끔 주문 상태가 뒤로 갑니다" 가 가장 흔한 신고다. 원인은 거의 언제나 도착 순서대로 덮어쓴 것이다. 로그를 보면 shipped 다음에 paid 가 적용돼 있다.
둘째, 서명 검증이 프레임워크에서 조용히 깨진다. 본문을 자동으로 파싱해 주는 프레임워크에서는 원본 바이트를 얻는 방법을 따로 찾아야 한다. 프록시가 본문을 다시 쓰는 경우(압축 해제, 문자셋 변환)도 있다.
둘째 반, 비밀 교체를 계획에 안 넣는다. 비밀을 바꾸는 순간 그 전에 보낸 배달이 전부 거절된다. 그래서 교체 기간에는 옛 비밀과 새 비밀을 둘 다 받아 둘 중 하나가 맞으면 통과시킨다.
셋째, 큐가 밀렸을 때 무엇을 버릴지 정해 두지 않는다. 웹훅은 계속 들어오므로 큐는 끝없이 자란다. 사건마다 판이 있으면 같은 대상의 옛 사건은 버려도 되지만, 그 판단은 미리 정해 두어야 한다.
넷째, 상대가 순서를 지켜 준다고 문서에 적혀 있어도 믿지 않는다. 그쪽 재시도 한 번이면 순서는 깨진다. 받는 쪽에 판 비교가 있으면 손해가 없고, 없으면 사고가 난다.
다음 실습에서 할 것
주문 이벤트를 밀어 넣는 파트너 발신기를 띄워 하루치 배달 41건을 손에 쥔다. 그중에는 재전송, 같은 사건의 새 배달, 오래된 재생 시도, 비밀을 모르는 쪽이 만든 가짜가 섞여 있다. 서명 검증기를 만들어 상수시간으로 비교하고, 시각 창을 붙이고, 배달 번호와 이벤트 번호 두 열쇠로 중복을 거르고, 판이 더 새것일 때만 적용하게 한다. 그다음 빨리 200 을 주고 큐에 넣는 수신 종단을 만들고, 마지막에 하루치를 하나도 빼지 않고 다시 흘려보내 각 갈래가 몇 건이었는지 집계한다.