웹훅은 믿지 말고, 시그니처로 검증하라
웹훅 수신부는 외부의 불신을 전제로 설계해야 한다. 재전송과 처리 지연, 심지어 위조 시도까지 뒤따르는 불안정한 요청을 믿으면 안 되기에, 시그니처 검증과 멱등 처리, 재전송 ID 추적, 지연 시 큐잉 판단 등 수신부 설계에서 빼먹기 쉬운 요소를 모두 챙겨야 비로소 안정적 수신이 시작된다. 특히 재전송은 멱등키 없이 또다시 도착해도 중복 처리되지 않도록, 지연이 길어지면 즉시 큐에 넣어 처리를 미루어야 한다는 점을 잊지 말자
DevInsight에서 발행해요
웹훅 요청은 도착 즉시 처리해야 한다는 착각에서 시작된다. 수신한 서버가 재전송을 몇 번이나 받을지, 언제 도착할지, 심지어 요청을 누가 위조했는지조차 알 수 없는 외부 요청을 믿고 로직을 짜면, 그 순간 데이터 무결성과 시스템 안정성은 흔적도 남기지 않고 무너진다.
서명 검증, 그저 헤더를 비교하는 것이 아니다
많은 예제가 HMAC-SHA256으로 서명을 검증한다고 하면, 개발자들은 헤더 값을 받아서 같은 알고리즘으로 다시 계산해 비교하는 코드를 한 줄 작성한다. 하지만 이때 빼먹는 것이 있다. 서명 검증에서 가장 먼저 고려해야 하는 것이 **시간 창 문제(timing-attack)**다. 단순한 문자열 비교(== 연산)로 검증하면 공격자가 서서히 서명 값을 추측해낼 수 있는 기회를 준다.
import hmac import hashlib def verify_signature(payload, signature_header, secret, tolerance=300): # GitHub 스타일: sha256=<signature> try: sha_name, signature = signature_header.split('=', 1) except ValueError: return False if sha_name != 'sha256': return False secret_bytes = secret.encode('utf-8') payload_bytes = payload.encode('utf-8') expected = hmac.new(secret_bytes, payload_bytes, hashlib.sha256).hexdigest() if not hmac.compare_digest(expected, signature): return False # 타임스탬프 검증 (옵션) timestamp = extract_timestamp(payload_bytes) # 구현 필요 if abs(time.time() - timestamp) > tolerance: return False return True
서명 검증에 시간 창 제한을 더하는 이유는 단순한 만료 처리가 아니다. 재전송 공격자가 잡음 없는 환경에서 서명을 가로채어도, 시간이 흐르면 그 서명은 무용지물이 된다. 특히 Stripe이나 GitHub처럼 타임스탬프를 헤더에 포함하는 서비스는 반드시 이 시간 창을 확인해야 한다. 시간 창을 너무 짧게 잡으면(예: 60초) 정상적인 네트워크 지연으로 인해 유효한 요청이 거부될 수 있고, 너무 길게 잡으면(예: 24시간) 공격 표면이 커진다. 실무에서는 **300초(5분)**를 기본으로 삼고, 서비스 특성에 따라 조정하는 것이 안전하다.
멱등성 키 없인 재전송은 또다시 처리된다
웹훅 재전송은 예외가 아니라 정상이다. Stripe은 결제 실패 시 최대 3일 간격으로 3회까지 재전송을 시도하고, GitHub은 3일간 5회를 시도한다. 문제는 개발자가 "이미 처리했는데?"라는 반응을 할 때 발생한다.
{ "id": "evt_1J...", "data": { "object": { "id": "cs_test_...", "amount": 1500, "currency": "krw" } } }
같은 결제 건이라도 재전송된 웹훅은 새벽 3시나 밤 12시쯤, 아니면 급작스러운 네트워크 정상화 시점에 도착한다. 서버는 그때마다 같은 결제를 또다시 처리하려 할 것이다.
이 문제를 해결하려면 웹훅 ID 기반 멱등성 키를 반드시 도입해야 한다. 단순히 결제 금액이나 주문 ID만으로 처리 결과를 판단하면 안 된다. 주문 ID가 같다고 해서 항상 같은 웹훅이 도착하는 것이 아니기 때문이다.
INSERT INTO webhook_event (
event_id, -- 웹훅 고유 ID (예: evt_1J...)
processed_at,
status,
result_summary -- 처리 결과 요약 (예: payment_succeeded)
) VALUES (?, ?, ?, ?)
ON CONFLICT(event_id) DO NOTHING;
ON CONFLICT DO NOTHING 패턴은 멱등성을 보장하는 가장 단순한 방법이다. 하지만 여기에는 함정이 있다. 중복 요청이 들어왔을 때 어떤 처리 결과를 반환할지 고민해야 한다. 최초 처리가 실패한 경우(예: 외부 API 호출 타임아웃) 재전송된 요청이 같은 실패를 반복할 수 있다. 이때는 재시도 가능한 실패와 절대 재시도하면 안 되는 실패를 구분해야 한다.
결제 API 호출이 타임아웃으로 실패했다면 그것은 재전송을 통해 다시 시도될 가치가 있다. 하지만 "이미 취소된 결제" 같은 응답은 재전송해도 절대 성공하지 않으므로, 이런 경우에는 3회 시도 후 영구 실패로 표시하는 로직이 필요하다.
재전송 대기 시간, 즉시 처리 아니면 큐잠금으로
웹훅이 도착했을 때 2분이나 5분이나 느려지면 서버 입장에서는 동일한 처리 시간이다. 하지만 5분이라는 지연은 웹훅 발신자에게 "이벤트가 평범하게 처리되지 않았다"는 신호를 보낸다. GitHub이나 Stripe 같은 서비스는 지연이 발생하면 재전송을 가속화하거나, 사용자 콘솔에 경고를 표시한다.
그래서 재전송 대기 시간이 초과하는 경우, 즉시 처리를 포기하고 큐잠금으로 전환하는 판단이 필요하다. 여기서 "큐잠금"이라는 표현은 의도적으로 사용한 것이다. 단순히 큐에 넣었다고 해서 되는 것이 아니라, 재전송 제한 시간 내에 큐에서 처리해야 한다는 제약이 따른다.
MAX_WAIT_TIME = 3 * 24 * 3600 # 3일 (Stripe 재전송 한도) async def handle_webhook(request): event_id = request.headers.get('X-Webhook-ID') timestamp = extract_timestamp(request.body) if current_time() - timestamp > MAX_WAIT_TIME: # 더 이상 재전송되지 않을 것이므로 큐잠금 후 별도 처리 await queue.enqueue('manual_review', event_id=event_id) return HttpResponse(status=200)
이렇게 하면 큐가 아니라 수동 검토 대기열로 전환되는 것이다. 큐가 아니라 대시보드나 알림 시스템으로 라우팅되어 담당자가 직접 처리 결정을 내릴 수 있다. 큐잠금 시간이 지나도 처리되지 않은 웹훅은 시스템의 이상 징후가 아니라, 운영 프로세스의 일부로 인식되어야 한다.
동시성 제어 없인 중복 처리, 그대로 발생한다
서명 검증도, 멱등성 키도 완벽하게 구현했는데도 같은 이벤트가 두 번 처리되는 경우가 있다. 그것은 동시성 충돌에서 비롯된다. 웹훅이 재전송될 때, 새 서버 인스턴스가 기존 처리 중인 요청과 동시에 도착하면 레이스 컨디션이 발생한다.
# 잘못된 예: 검토 후 처리 패턴 async def process_webhook_event(event): existing = await db.fetch_one( "SELECT status FROM webhook_event WHERE event_id = ?", (event.id,) ) if existing and existing['status'] == 'completed': return # 중복 처리 방지 # ... 처리 로직 ... await db.execute("UPDATE webhook_event SET status = 'completed' WHERE event_id = ?", ...)
이 코드는 시간의 틈을 타서 두 요청이 모두 existing 검사를 통과하고 처리 로직을 실행하게 된다. 두 개의 요청이 각각 SELECT를 수행하고 둘 다 결과가 없다고 판단하면 둘 다 처리를 진행한다.
해결책은 **원자적 처리(atomic processing)**다. 데이터베이스의 트랜잭션이나 락 메커니즘을 활용해야 한다.
BEGIN; SELECT status FROM webhook_event WHERE event_id = ? FOR UPDATE; -- 또는 Redis SETNX 패턴 -- SETNX webhook:lock:evt_1J... "processing" EX 300 -- 처리 로직 수행 -- ... UPDATE webhook_event SET status = 'completed' WHERE event_id = ?; COMMIT;
FOR UPDATE 락이나 Redis의 SETNX 방식은 원자적으로 하나의 요청만 처리 흐름을 진행하도록 보장한다. 하지만 락이 잡힌 상태에서 처리가 오래 걸리면 다른 요청은 타임아웃을 겪고, 재전송 메커니즘은 또다른 문제를 낳는다.
처리 시간 초과 시 무엇이 벌어지는가
웹훅 수신자는 대부분 5초~10초의 응답 시간 제한이 있다. GitHub은 10초, Stripe은 30초 제한을 두지만, 대부분의 프록시나 로드 밸런서는 30~60초 후에 요청을 강제 종료한다. 이 제한을 초과하면 정상적인 응답이 아니라 504 Gateway Timeout이나 502 Bad Gateway로 기록되며, 발신 측은 이를 재전송 횟수로 간주한다.
이 문제를 해결하려면 즉시 응답 + 비동기 처리 패턴이 필수다.
웹훅 수신 (0~100ms)
↓
서명 검증 + 멱등성 기록
↓
200 OK 즉시 반환
↓
실제 처리 (비동기 큐/워커)
하지만 여기서 개발자들이 흔히 하는 실수가 있다. 응답을 200으로 빨리 반환했지만, 실제 처리에 실패한 경우 에러를 숨기고 있다는 착각에 빠진다. 웹훅 수신자는 응답 코드만 믿는 것이 아니라, 처리 결과를 모니터링해야 한다.
재전송 실패 기록, 로깅만으로는 부족하다
웹훅이 재전송되지 않거나, 재전송된 요청도 처리에 실패하면 그 이력을 반드시 기록해야 한다. 단순히 로그에 남기는 것은 부족하다. 로그는 서로 다른 서버 인스턴스에 흩어지고, 검색하기 어렵기 때문이다.
class WebhookFailureLog: event_id: str failure_reason: str retry_count: int last_attempt_at: datetime payload_snapshot: str # 디버깅용
실패한 웹훅의 페이로로드를 스냅샷으로 보관하는 이유는 단순한 재처리가 아니다. 때로는 수동으로 재조정이 필요한 경우가 있다. 예를 들어 결제 시스템에서 금액 오류가 발생했지만 재전송된 요청의 페이로드가 정정된 경우, 개발자가 직접 요청을 수정하여 재처리해야 한다.
실패 대응 없인 안정성은 허상이다
웹훅 시스템의 안정성은 성공률만으로 판단하지 않는다. 실패 시나리오별 대응 계획이 함께 존재해야 한다. Stripe의 경우 재전송을 포기하면 대시보드에서 직접 재전송할 수 있는 기능을 제공하지만, 이것은 발신 측의 책임이지 수신 측의 책임이 아니다. 수신 측은 스스로 재전송 포기 시 알림을 발송하거나, 수동 재처리 인터페이스를 갖추어야 한다.
# 실패 임계치 설정 MAX_RETRIES = 5 failure_counts = {} def should_alert_or_retry(event_id): count = failure_counts.get(event_id, 0) if count >= MAX_RETRIES: notify_ops_team(event_id) return False return True
결국 웹훅 수신의 핵심은 불신에서 시작하라는 원칙이다. 외부 요청은 언제나 불안정하고, 재전송은 정상이며, 위조 시도는 가능성이 존재한다. 이 모든 것을 믿고 처리하면 시스템은 언제나 불안정에 떨어진다.
서명 검증으로 요청 진위를 확인하고, 멱등성 키로 중복을 억제하며, 재전송 시간 제한으로 처리 지연을 판단하고, 원자적 처리로 동시성 충돌을 방지한다. 그리고 가장 중요하게, 실패를 기록하고 대응하는 운영 문화가 갖춰질 때 비로소 웹훅 수신은 믿을 수 있는 것이 된다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
메모리를 대신 관리해주는 쾌락과 고통의 세계
가비지 컬렉션은 현대 프로그래밍 언어가 거의 예외 없이 채택한 자동 메모리 관리 기술이지만, 그 내부는 대부분의 개발자에게 블랙박스다. 2판으로 돌아온 이 핸드북은 지난 60년간 축적된 GC 연구의 정수를 집대성한다. 단순한 mark-sweep에서 병렬·동시·실시간 컬렉터까지, GC가 어떻게 진화해왔고 오늘날 어떤 선택지를 제공하는지 한 권으로 조망한다.
작은 서비스도 버틸 수 있는가, 폴링 한계와 큐 도입 시점의 판단 기준
작은 서비스에서 DB 폴링으로 작업을 처리하다 보면 스루풋이 급격히 떨어지고 레이턴시가 늘어나는 신호가 나옵니다. 이 글에서는 폴링 기반 처리가 감당해야 하는 한도를 분석하고, 큐 시스템 도입이 필요한 시점의 구체적 판단 기준을 제시합니다. 단순히 '트래픽이 늘었다'는 증상이 아니라 시스템 자체의 포화 상태를 어떻게 인지하고 대응할지, 도입 후 새겨야 하는 운영 체크포인트까지 함께 다룹니다.
당신의 RLS 정책은 조용히 거짓말을 하고 있다
Supabase에서 RLS를 활성화한 순간 쿼리는 에러 없이 빈 배열을 반환하기 시작한다. service_role 키와 anon 키를 혼동할 때 벌어지는 일, SQL 에디터 테스트가 주는 환상, auth.uid()가 null을 뱉는 이유 등 실제 운영에서 마주치는 RLS 실수 패턴을 진단 쿼리와 함께 파헤친다.
이전 글
캐시 적중률 98%인데 배포판은 왜 자꾸 구버전일까
다음 글
작은 서비스도 버틸 수 있는가, 폴링 한계와 큐 도입 시점의 판단 기준
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.