
“결제 웹훅을 받는데 같은 주문이 두 번 처리돼요”라는 질문, 검색해 보신 적 있으신가요? 답은 명확합니다. 웹훅 처리를 안전하게 하려면 서명 검증으로 발신자를 확인하고, 재시도로 인한 중복 전송을 전제로 설계하고, 멱등성 키로 같은 이벤트를 두 번 실행하지 않게 막아야 합니다. 이 세 가지 중 하나라도 빠지면 결제 중복 처리나 위조 요청 같은 사고로 이어질 수 있습니다.
이 글은 Python 3.10, Flask 2.x, Redis 6 이상 환경을 기준으로 설명합니다. 다른 언어·프레임워크에서도 개념은 동일하지만, 원자적 명령어 지원 여부에 따라 구현 방식이 달라질 수 있다는 점을 먼저 밝혀 둡니다.
웹훅 처리에서 서명 검증이 첫 번째 관문인 이유
웹훅 엔드포인트는 인터넷에 열려 있는 공개 URL입니다. 누군가 URL만 알아내면 결제 완료 이벤트나 회원가입 이벤트를 흉내 낸 가짜 요청을 얼마든지 보낼 수 있습니다. 실제로 이런 요청을 걸러내지 못하면 무료 쿠폰 지급, 포인트 적립 같은 로직이 그대로 뚫립니다.
그래서 대부분의 서비스(Stripe, GitHub, Slack 등)는 요청 본문을 발신자만 아는 비밀키로 서명해서 헤더에 실어 보냅니다. 수신 서버는 같은 방식으로 서명을 다시 계산해서 헤더 값과 비교하는데, 이 과정이 바로 서명 검증입니다. 값이 일치하지 않으면 그 요청은 처리하지 않고 즉시 거부해야 합니다.
HMAC 서명, 코드로는 어떻게 확인하나요?
가장 널리 쓰이는 방식은 HMAC-SHA256입니다. 타임스탬프와 원문 페이로드를 이어 붙인 뒤 비밀키로 해시를 만들고, 이 값을 요청 헤더에 담긴 서명과 비교합니다. 아래는 Flask와 Redis를 이용한 최소 구현 예시입니다.

import hmac
import hashlib
import time
from flask import Flask, request, abort
import redis
app = Flask(__name__)
r = redis.Redis(host="localhost", port=6379, db=0)
WEBHOOK_SECRET = b"my-signing-secret"
TOLERANCE_SECONDS = 300 # 5분
def verify_signature(payload: bytes, timestamp: str, signature: str) -> bool:
signed_payload = timestamp.encode() + b"." + payload
expected = hmac.new(WEBHOOK_SECRET, signed_payload, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
@app.route("/webhook", methods=["POST"])
def webhook():
timestamp = request.headers.get("X-Webhook-Timestamp", "")
signature = request.headers.get("X-Webhook-Signature", "")
payload = request.get_data()
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
abort(400, "타임스탬프 만료")
if not verify_signature(payload, timestamp, signature):
abort(401, "서명 불일치")
event_id = request.headers.get("X-Webhook-Id", "")
if not r.set(f"webhook:{event_id}", "1", nx=True, ex=86400):
return "", 200 # 이미 처리한 이벤트
# 실제 비즈니스 로직 처리
return "", 200
여기서 hmac.compare_digest를 쓰는 이유가 중요합니다. 일반적인 == 비교는 문자열이 다른 지점에서 바로 반환되기 때문에 걸리는 시간 차이로 원본 서명을 추측하는 타이밍 공격에 노출됩니다. 상수 시간 비교 함수를 쓰면 이 위험을 없앨 수 있습니다.
재시도 정책 때문에 같은 이벤트가 여러 번 도착합니다
웹훅을 보내는 쪽은 수신 서버가 2xx 응답을 주지 않으면 실패로 판단하고 다시 보냅니다. 서버 배포 중 잠깐 응답이 늦어지거나, 네트워크가 끊기거나, 처리 로직에서 타임아웃이 나는 순간에도 재전송이 발생합니다. 문제는 이 재시도가 수신 서버 입장에서는 완전히 똑같은 이벤트로 두 번, 세 번 도착한다는 점입니다.
그래서 웹훅 처리를 설계할 때는 “이벤트는 언젠가 중복으로 온다”를 기본 전제로 잡아야 합니다. 서명 검증을 통과했다고 해서 그 요청을 처음 받는 요청이라고 가정하면 안 됩니다. 서명은 위조 여부만 판단할 뿐, 중복 여부는 별도로 확인해야 하는 문제입니다.
멱등성 키는 이렇게 설계해 보세요
멱등성이란 같은 작업을 여러 번 실행해도 결과가 한 번 실행한 것과 동일하게 유지되는 성질입니다. 웹훅 처리에서는 이벤트마다 고유하게 부여되는 ID(위 코드의 X-Webhook-Id 같은 값)를 키로 삼아, 이미 처리한 ID면 로직을 건너뛰고 바로 200을 반환하는 방식으로 구현합니다.
저장소를 어디로 잡을지는 서비스 규모에 따라 달라집니다. 아래는 실무에서 흔히 쓰는 세 가지 방식을 비교한 표입니다.

| 방식 | 저장 위치 | 장점 | 주의할 점 |
|---|---|---|---|
| Redis SET NX EX | 인메모리 캐시 | 원자적 처리, 응답 속도 빠름 | 서버 재시작·장애 시 데이터 유실 가능 |
| DB UNIQUE 제약 | 관계형 DB | 영속적 저장, 트랜잭션과 함께 커밋 가능 | 매 요청마다 쓰기 부하 발생 |
| 큐 자체 중복 제거 (예: SQS FIFO의 MessageDeduplicationId) | 메시지 큐 | 애플리케이션 코드 없이 큐 단에서 처리 | FIFO 큐 등 특정 큐 타입에서만 지원 |
소규모 서비스라면 Redis만으로 충분하지만, 결제처럼 절대 유실되면 안 되는 이벤트는 DB의 UNIQUE 제약과 트랜잭션을 함께 걸어서 캐시가 날아가도 중복 처리가 막히도록 이중으로 잡는 편이 안전합니다.
서명 검증과 멱등성으로도 못 막는 경우가 있나요?
세 가지 장치를 다 갖춰도 뚫리는 지점이 있습니다. 먼저 비밀키(secret)가 소스코드 저장소나 로그에 노출되면 서명 검증 자체가 무의미해집니다. 비밀키는 환경변수나 시크릿 매니저로 관리하고, 로그에 요청 헤더 전체를 남기지 않도록 마스킹하는 편이 좋습니다.
또한 멱등성 키를 이벤트 ID가 아니라 페이로드 내용의 해시로 만드는 경우, 발신 서비스가 재시도 시 타임스탬프나 부가 필드를 살짝 바꿔서 보내면 해시가 달라져 중복 방지가 무력화될 수 있습니다. 가능하면 발신 서비스가 제공하는 고유 이벤트 ID를 그대로 키로 쓰는 편이 안전합니다. 마지막으로 Redis에 설정한 TTL이 지나면 같은 이벤트가 뒤늦게 재전송됐을 때 다시 처리될 수 있으므로, TTL은 발신 서비스의 최대 재시도 기간보다 넉넉하게 잡아야 합니다.
웹훅 처리 안정성, 세 가지 장치를 한 번에 붙이면 끝납니다
서명 검증은 요청이 진짜 발신자에게서 왔는지 확인하고, 재시도 대응은 중복 도착을 전제로 설계하게 만들고, 멱등성 키는 같은 이벤트를 두 번 실행하지 않도록 막아 줍니다. 이 세 가지는 따로 놓고 보면 각자 다른 문제를 풀지만, 실제 웹훅 엔드포인트에서는 항상 함께 작동해야 하는 한 세트입니다.
지금 운영 중인 웹훅 핸들러가 있다면, 먼저 서명 검증 로직에 hmac.compare_digest 같은 상수 시간 비교 함수를 쓰고 있는지 점검해 보세요. 그다음 이벤트 ID 기반 멱등성 체크가 빠져 있다면 위 코드의 Redis SET NX 패턴부터 추가해 보시는 것을 권해 드립니다.
