티스토리 뷰

웹 서비스를 만들다 보면 외부 서비스와 연동해야 하는 일이 자주 생깁니다.

예를 들어 결제 서비스에서 결제가 완료되었는지 알아야 할 수 있습니다.
GitHub에서 코드가 push되었는지 받아야 할 수도 있고, Slack이나 Discord로 알림을 보내야 할 수도 있습니다.
또 이메일 발송 서비스에서 메일이 성공적으로 전달되었는지, 반송되었는지 확인해야 할 수도 있습니다.

이때 자주 등장하는 개념이 웹훅(Webhook)입니다.

처음 보면 웹훅은 API와 비슷해 보입니다.
하지만 일반적인 API 호출과 웹훅은 방향이 다릅니다.

보통 API는 내가 필요할 때 외부 서비스에 요청을 보내 데이터를 가져오는 방식입니다.

내 서버
→ 외부 API 호출
→ 응답 받기

반면 웹훅은 외부 서비스에서 이벤트가 발생했을 때, 외부 서비스가 내 서버로 요청을 보내주는 방식입니다.

외부 서비스에서 이벤트 발생
→ 외부 서비스가 내 서버의 웹훅 URL로 요청 전송
→ 내 서버가 이벤트 처리

이번 글에서는 웹훅이 무엇인지, 일반 API와 어떻게 다른지, 어떤 상황에서 사용하는지, 백엔드에서 웹훅을 받을 때 무엇을 주의해야 하는지 정리해보겠습니다.



웹훅이란?

웹훅은 특정 이벤트가 발생했을 때 외부 서비스가 미리 등록된 URL로 HTTP 요청을 보내주는 방식입니다.

쉽게 말하면 다음과 같습니다.

어떤 일이 생기면, 이 URL로 알려주세요.

예를 들어 결제 서비스를 연동한다고 해보겠습니다.

사용자가 결제를 시도하고, 결제 서비스에서 결제가 완료됩니다.
이때 내 서버가 계속 결제 서비스에 “결제 완료됐나요?”라고 물어볼 수도 있습니다.

하지만 더 효율적인 방법은 결제 서비스가 결제 완료 시점에 내 서버로 알려주는 것입니다.

결제 서비스
→ 결제 완료 이벤트 발생
→ 내 서버의 /webhooks/payment/ URL로 요청 전송
→ 내 서버가 주문 상태를 결제 완료로 변경

이때 /webhooks/payment/ 같은 URL이 웹훅 엔드포인트가 됩니다.

웹훅은 다양한 서비스에서 사용됩니다.

  • 결제 완료 알림
  • 환불 완료 알림
  • GitHub push 이벤트
  • Slack 앱 이벤트
  • 이메일 발송 결과
  • 구독 상태 변경
  • 외부 CRM 이벤트
  • 채팅 메시지 이벤트
  • 파일 업로드 완료 이벤트
  • 배포 성공 또는 실패 알림

웹훅은 외부 서비스의 이벤트를 내 시스템에 전달하는 방식이라고 이해하면 좋습니다.



API 호출과 웹훅의 차이

웹훅을 이해할 때 가장 중요한 것은 일반 API 호출과의 차이입니다.

일반 API 호출

일반적인 API 호출에서는 내 서버나 클라이언트가 외부 서비스에 요청을 보냅니다.

내 서버
→ 외부 서비스 API 호출
→ 외부 서비스가 응답 반환

예를 들어 주문 상태를 확인하려면 내 서버가 결제 서비스 API를 호출합니다.

GET /payments/12345

이 방식은 내가 필요할 때 직접 물어보는 구조입니다.

웹훅

웹훅에서는 외부 서비스가 내 서버에 요청을 보냅니다.

외부 서비스
→ 내 서버의 웹훅 URL로 요청 전송
→ 내 서버가 이벤트 처리

예를 들어 결제가 완료되면 결제 서비스가 내 서버에 알려줍니다.

POST /webhooks/payment

이 방식은 외부 서비스가 일이 생겼을 때 알려주는 구조입니다.

정리하면 다음과 같습니다.

구분 일반 API 호출 웹훅
요청 방향 내가 외부 서비스에 요청 외부 서비스가 내 서버에 요청
동작 시점 내가 필요할 때 이벤트가 발생했을 때
대표 예시 결제 상태 조회 결제 완료 알림
방식 Pull Push

일반 API 호출은 내가 데이터를 가져오는 방식이고, 웹훅은 외부 서비스가 이벤트를 밀어주는 방식에 가깝습니다.



폴링과 웹훅

웹훅과 자주 비교되는 방식이 폴링(Polling)입니다.

폴링은 일정 시간마다 외부 API에 요청을 보내 상태를 확인하는 방식입니다.

예를 들어 10초마다 결제 상태를 확인한다고 해보겠습니다.

10초마다 결제 상태 확인
→ 아직 대기
→ 아직 대기
→ 결제 완료
→ 주문 상태 변경

이 방식은 단순하지만 비효율적일 수 있습니다.

  • 이벤트가 없어도 계속 요청을 보냅니다.
  • 상태 변경을 즉시 알기 어렵습니다.
  • 요청 횟수가 많아질 수 있습니다.
  • 외부 API rate limit에 걸릴 수 있습니다.
  • 서버 자원이 낭비될 수 있습니다.

웹훅은 반대로 이벤트가 발생했을 때만 요청이 옵니다.

결제 완료 이벤트 발생
→ 웹훅 요청 전송
→ 주문 상태 변경

그래서 실시간성이 필요하고 이벤트 기반으로 동작하는 기능에는 웹훅이 잘 맞습니다.

다만 웹훅도 완벽한 것은 아닙니다.

웹훅 요청이 실패할 수 있고, 네트워크 문제가 생길 수 있으며, 같은 이벤트가 여러 번 올 수도 있습니다.

따라서 실무에서는 웹훅을 받되, 필요할 때는 API로 최종 상태를 다시 조회해 검증하는 방식을 함께 사용하기도 합니다.



웹훅 처리 흐름

웹훅의 기본 흐름은 다음과 같습니다.

  1. 내 서버에 웹훅을 받을 URL을 만듭니다.
  2. 외부 서비스 관리자 화면에 해당 URL을 등록합니다.
  3. 외부 서비스에서 이벤트가 발생합니다.
  4. 외부 서비스가 내 서버의 웹훅 URL로 HTTP 요청을 보냅니다.
  5. 내 서버는 요청의 서명이나 토큰을 검증합니다.
  6. 이벤트 타입과 데이터를 확인합니다.
  7. 필요한 처리를 수행합니다.
  8. 성공 상태 코드를 반환합니다.

단순화하면 다음과 같습니다.

외부 서비스
→ 이벤트 발생
→ 내 서버의 Webhook Endpoint로 POST 요청
→ 요청 검증
→ 이벤트 처리
→ 2xx 응답 반환

웹훅 이벤트 처리 흐름도



웹훅은 보통 POST 요청으로 온다

웹훅은 보통 POST 요청으로 전달됩니다.

이벤트 정보가 요청 본문에 JSON 형태로 들어오는 경우가 많습니다.

예를 들어 결제 완료 웹훅은 다음처럼 올 수 있습니다.

POST /webhooks/payment
Content-Type: application/json

{
  "event_id": "evt_123",
  "event_type": "payment.completed",
  "created_at": "2026-05-13T10:30:00Z",
  "data": {
    "payment_id": "pay_456",
    "order_id": "order_789",
    "amount": 30000,
    "currency": "KRW",
    "status": "completed"
  }
}

여기서 중요한 값은 다음입니다.

  • event_id: 이벤트 고유 ID
  • event_type: 어떤 이벤트인지 나타내는 타입
  • created_at: 이벤트 발생 시각
  • data: 실제 이벤트 관련 데이터

내 서버는 event_type을 보고 어떤 처리를 할지 결정합니다.

payment.completed
→ 주문을 결제 완료 상태로 변경

payment.failed
→ 주문을 결제 실패 상태로 변경

payment.refunded
→ 주문 또는 결제를 환불 상태로 변경

웹훅 payload 구조는 서비스마다 다릅니다.
따라서 사용하는 외부 서비스의 웹훅 문서를 반드시 확인해야 합니다.



Django에서 웹훅 엔드포인트 만들기

Django에서 간단한 웹훅 엔드포인트를 만든다고 해보겠습니다.

먼저 URL을 등록합니다.

# urls.py
from django.urls import path

from .views import payment_webhook

urlpatterns = [
    path("webhooks/payment/", payment_webhook, name="payment_webhook"),
]

그 다음 view를 작성합니다.

# views.py
import json

from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt


@csrf_exempt
def payment_webhook(request):
    if request.method != "POST":
        return JsonResponse({"detail": "Method not allowed"}, status=405)

    try:
        payload = json.loads(request.body)
    except json.JSONDecodeError:
        return JsonResponse({"detail": "Invalid JSON"}, status=400)

    event_type = payload.get("event_type")
    data = payload.get("data", {})

    if event_type == "payment.completed":
        order_id = data.get("order_id")
        payment_id = data.get("payment_id")

        # 주문 상태 변경 로직
        # Order.objects.filter(id=order_id).update(
        #     payment_id=payment_id,
        #     status="paid",
        # )

    return JsonResponse({"received": True}, status=200)

이 코드는 웹훅을 이해하기 위한 단순 예시입니다.

실무에서는 다음 요소를 반드시 추가로 고려해야 합니다.

  • 요청 서명 검증
  • 중복 이벤트 처리
  • 트랜잭션 처리
  • 이벤트 로그 저장
  • 실패 처리
  • 비동기 작업 분리
  • 외부 서비스의 재시도 정책
  • CSRF 예외 적용 범위
  • 민감 정보 로그 제외

특히 웹훅은 외부에서 내 서버로 들어오는 요청이므로 보안 검증이 매우 중요합니다.



DRF에서 웹훅 엔드포인트 만들기

Django REST Framework를 사용한다면 APIView로 웹훅 엔드포인트를 만들 수 있습니다.

from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView


class PaymentWebhookView(APIView):
    authentication_classes = []
    permission_classes = []

    def post(self, request):
        payload = request.data

        event_type = payload.get("event_type")
        data = payload.get("data", {})

        if event_type == "payment.completed":
            order_id = data.get("order_id")
            payment_id = data.get("payment_id")

            # 주문 상태 변경 로직
            # ...

        return Response({"received": True}, status=status.HTTP_200_OK)

웹훅 요청은 일반 사용자 로그인 인증과 다르게 처리하는 경우가 많습니다.

예를 들어 외부 결제 서비스가 내 서버에 요청을 보내는 것이므로, 일반적인 세션 인증이나 JWT 인증이 아니라 웹훅 서명 검증을 사용합니다.

그래서 위 예시처럼 authentication_classespermission_classes를 비워두고, 대신 요청 헤더의 서명을 직접 검증하는 방식이 자주 사용됩니다.

다만 인증을 완전히 제거한다는 뜻이 아닙니다.

일반 API
→ 사용자 인증

웹훅 API
→ 외부 서비스 서명 검증

웹훅은 사용자 인증 대신 “정말 해당 외부 서비스에서 온 요청인가?”를 확인해야 합니다.



웹훅 보안: 서명 검증

웹훅 엔드포인트는 외부에서 접근 가능한 URL입니다.

따라서 아무나 이 URL로 요청을 보낼 수 있다고 가정해야 합니다.

만약 검증 없이 결제 완료 웹훅을 처리한다면 공격자가 가짜 요청을 보내 주문을 결제 완료로 바꿀 수도 있습니다.

공격자
→ 가짜 payment.completed 요청
→ 서버가 검증 없이 주문 상태 변경
→ 보안 문제 발생

그래서 많은 서비스는 웹훅 요청에 서명 값을 함께 보냅니다.

예를 들어 요청 헤더에 이런 값이 올 수 있습니다.

X-Webhook-Signature: abcdef123456...

서버는 공유 secret을 사용해 요청 본문으로 서명을 다시 계산하고, 헤더의 서명과 비교합니다.

단순 예시는 다음과 같습니다.

import hmac
import hashlib


def verify_signature(payload_body, signature, secret):
    expected = hmac.new(
        key=secret.encode(),
        msg=payload_body,
        digestmod=hashlib.sha256,
    ).hexdigest()

    return hmac.compare_digest(expected, signature)

사용 예시는 다음과 같습니다.

def payment_webhook(request):
    signature = request.headers.get("X-Webhook-Signature")

    if not verify_signature(request.body, signature, settings.WEBHOOK_SECRET):
        return JsonResponse({"detail": "Invalid signature"}, status=401)

    # 이후 이벤트 처리

실제 서명 방식은 서비스마다 다릅니다.

어떤 서비스는 timestamp를 함께 검증하고, 어떤 서비스는 여러 서명 버전을 지원하기도 합니다.
따라서 반드시 해당 서비스의 공식 문서를 기준으로 구현해야 합니다.



웹훅 보안에서 주의할 점

웹훅 보안은 단순히 URL을 어렵게 만드는 것으로 충분하지 않습니다.

물론 /webhooks/payment/보다 예측하기 어려운 URL을 사용하는 것이 약간의 도움은 될 수 있습니다.

하지만 URL은 언제든 노출될 수 있습니다.

중요한 것은 요청 자체를 검증하는 것입니다.

웹훅 보안에서 고려할 점은 다음과 같습니다.

1. 서명 검증

가장 중요한 것은 서명 검증입니다.

요청이 정말 해당 외부 서비스에서 온 것인지 확인해야 합니다.

2. Timestamp 검증

서명에 timestamp가 포함된다면, 너무 오래된 요청은 거부할 수 있습니다.

이렇게 하면 과거 요청을 재전송하는 replay attack을 줄일 수 있습니다.

현재 시각과 요청 timestamp 차이
→ 5분 이상이면 거부

3. HTTPS 사용

웹훅 URL은 반드시 HTTPS를 사용하는 것이 좋습니다.

HTTP로 받으면 요청 내용이 중간에서 노출되거나 변조될 수 있습니다.

4. 민감 정보 로그 제외

웹훅 payload에 개인정보나 결제 관련 정보가 들어올 수 있습니다.

요청 전체를 그대로 로그에 남기면 위험할 수 있습니다.

5. 허용 이벤트만 처리

알 수 없는 이벤트 타입은 무시하거나 로그만 남기는 것이 좋습니다.

if event_type not in ALLOWED_EVENT_TYPES:
    return JsonResponse({"received": True}, status=200)

6. IP 허용 목록은 보조 수단으로만 사용

일부 서비스는 웹훅 발송 IP 범위를 제공합니다.

IP allowlist를 사용할 수는 있지만, 클라우드 환경에서는 IP가 변경될 수 있고 관리가 번거로울 수 있습니다.

가능하다면 서명 검증을 기본으로 두고, IP 제한은 보조 수단으로 보는 것이 좋습니다.



중복 이벤트 처리

웹훅은 같은 이벤트가 여러 번 도착할 수 있습니다.

외부 서비스는 웹훅 요청을 보냈는데 내 서버가 응답을 늦게 하거나, 네트워크 오류가 발생하면 같은 이벤트를 재시도할 수 있습니다.

따라서 웹훅 처리 로직은 중복 실행되어도 안전해야 합니다.

예를 들어 결제 완료 이벤트가 두 번 들어온다고 해보겠습니다.

payment.completed event_id=evt_123
→ 주문 상태 paid로 변경

payment.completed event_id=evt_123
→ 다시 도착
→ 이미 처리한 이벤트이므로 무시

이를 위해 이벤트 ID를 저장해둘 수 있습니다.

class WebhookEvent(models.Model):
    event_id = models.CharField(max_length=255, unique=True)
    event_type = models.CharField(max_length=100)
    received_at = models.DateTimeField(auto_now_add=True)
    processed_at = models.DateTimeField(null=True, blank=True)

처리할 때는 이미 처리한 이벤트인지 확인합니다.

event_id = payload["event_id"]

event, created = WebhookEvent.objects.get_or_create(
    event_id=event_id,
    defaults={"event_type": event_type},
)

if not created and event.processed_at:
    return JsonResponse({"received": True}, status=200)

중복 이벤트 처리는 결제, 포인트, 쿠폰, 재고처럼 돈이나 상태가 바뀌는 기능에서 특히 중요합니다.



멱등성 있게 처리하기

웹훅 처리에서는 멱등성(idempotency)이 중요합니다.

멱등성이란 같은 요청을 여러 번 처리해도 최종 결과가 같게 만드는 성질입니다.

예를 들어 주문 상태를 결제 완료로 바꾸는 로직은 다음처럼 만들 수 있습니다.

order = Order.objects.get(id=order_id)

if order.status != "paid":
    order.status = "paid"
    order.payment_id = payment_id
    order.save(update_fields=["status", "payment_id"])

이미 paid 상태라면 다시 처리하지 않습니다.

나쁜 예시는 다음과 같습니다.

order.point += 1000
order.save()

이런 방식은 같은 이벤트가 두 번 들어오면 포인트가 두 번 지급될 수 있습니다.

더 안전한 방식은 지급 이력을 별도로 두고 중복 지급을 막는 것입니다.

event_id 기준으로 지급 이력 확인
→ 이미 지급했으면 무시
→ 처음이면 지급 후 이력 저장

웹훅은 “한 번만 정확히 온다”고 믿기보다, “여러 번 올 수 있다”고 가정하고 설계하는 것이 안전합니다.



웹훅 처리는 빠르게 응답하는 것이 좋다

웹훅 요청을 받은 서버는 가능한 한 빠르게 2xx 응답을 반환하는 것이 좋습니다.

외부 서비스는 내 서버가 2xx 상태 코드를 반환하면 웹훅 전달이 성공했다고 판단하는 경우가 많습니다.

반대로 응답이 늦거나 500 오류가 발생하면 재시도할 수 있습니다.

문제는 웹훅 안에서 오래 걸리는 작업을 모두 처리하면 응답이 늦어질 수 있다는 점입니다.

예를 들어 웹훅 요청 안에서 다음을 모두 처리한다고 해보겠습니다.

웹훅 수신
→ 서명 검증
→ DB 업데이트
→ 이메일 발송
→ 외부 API 호출
→ 보고서 생성
→ 응답 반환

이렇게 하면 웹훅 요청이 타임아웃될 수 있습니다.

더 나은 방식은 최소한의 검증과 저장만 수행하고, 오래 걸리는 작업은 Celery 같은 작업 큐로 넘기는 것입니다.

웹훅 수신
→ 서명 검증
→ 이벤트 저장
→ Celery task 등록
→ 빠르게 200 응답

이후 worker
→ 이메일 발송
→ 외부 API 호출
→ 추가 처리

이 구조는 앞에서 다룬 Celery와 자연스럽게 연결됩니다.



Celery와 함께 사용하는 웹훅 구조

웹훅 처리에서 Celery를 사용하면 안정성을 높일 수 있습니다.

웹훅 view는 요청을 검증하고 이벤트를 저장한 뒤, 백그라운드 작업을 등록합니다.

@csrf_exempt
def payment_webhook(request):
    signature = request.headers.get("X-Webhook-Signature")

    if not verify_signature(request.body, signature, settings.WEBHOOK_SECRET):
        return JsonResponse({"detail": "Invalid signature"}, status=401)

    payload = json.loads(request.body)
    event_id = payload["event_id"]

    event, created = WebhookEvent.objects.get_or_create(
        event_id=event_id,
        defaults={
            "event_type": payload["event_type"],
            "payload": payload,
        },
    )

    if created:
        process_payment_webhook.delay(event.id)

    return JsonResponse({"received": True}, status=200)

Celery task에서는 실제 처리를 수행합니다.

@shared_task(bind=True, max_retries=3)
def process_payment_webhook(self, event_id):
    event = WebhookEvent.objects.get(id=event_id)

    try:
        # 주문 상태 변경, 알림 발송 등 처리
        ...
    except Exception as exc:
        raise self.retry(exc=exc, countdown=60)

이 구조의 장점은 다음과 같습니다.

  • 웹훅 요청에 빠르게 응답할 수 있습니다.
  • 오래 걸리는 작업을 worker로 분리할 수 있습니다.
  • 실패한 작업을 재시도할 수 있습니다.
  • 이벤트 수신 이력을 남길 수 있습니다.
  • 중복 이벤트를 제어하기 쉽습니다.

웹훅 수신과 Celery 비동기 처리 구조



이벤트 타입별로 처리 분리하기

웹훅 하나의 엔드포인트로 여러 이벤트가 들어올 수 있습니다.

예를 들어 결제 서비스에서 다음 이벤트가 올 수 있습니다.

payment.completed
payment.failed
payment.refunded
subscription.created
subscription.cancelled

이벤트 타입별로 처리 함수를 분리하면 코드가 깔끔해집니다.

def handle_payment_completed(data):
    ...


def handle_payment_failed(data):
    ...


def handle_payment_refunded(data):
    ...


HANDLERS = {
    "payment.completed": handle_payment_completed,
    "payment.failed": handle_payment_failed,
    "payment.refunded": handle_payment_refunded,
}


def process_event(event_type, data):
    handler = HANDLERS.get(event_type)

    if handler is None:
        return

    handler(data)

이 방식은 이벤트가 늘어났을 때 관리하기 쉽습니다.

다만 이벤트 타입이 많아지면 별도 서비스 클래스나 handler 모듈로 분리하는 것이 좋습니다.



웹훅 로그 저장하기

웹훅은 장애 분석이 어렵기 쉬운 영역입니다.

외부 서비스는 보냈다고 하는데, 내 서버에서는 처리되지 않았을 수 있습니다.
또 내 서버는 받았지만 처리 중 실패했을 수도 있습니다.

그래서 웹훅 이벤트를 저장해두는 것이 좋습니다.

예를 들어 다음 정보를 저장할 수 있습니다.

  • event_id
  • event_type
  • 원본 payload
  • 서명 검증 결과
  • 수신 시각
  • 처리 상태
  • 처리 완료 시각
  • 실패 사유
  • 재시도 횟수

간단한 모델은 다음처럼 만들 수 있습니다.

class WebhookEvent(models.Model):
    event_id = models.CharField(max_length=255, unique=True)
    event_type = models.CharField(max_length=100)
    payload = models.JSONField()
    status = models.CharField(max_length=30, default="received")
    received_at = models.DateTimeField(auto_now_add=True)
    processed_at = models.DateTimeField(null=True, blank=True)
    error_message = models.TextField(blank=True)

이렇게 저장해두면 나중에 관리자 페이지에서 웹훅 처리 상태를 확인할 수 있습니다.

received
→ processing
→ processed
→ failed

장애가 발생했을 때 특정 event_id를 기준으로 추적할 수 있다는 점도 큰 장점입니다.



재시도 정책 이해하기

외부 서비스는 웹훅 요청이 실패하면 재시도하는 경우가 많습니다.

예를 들어 내 서버가 500 오류를 반환하거나, 일정 시간 안에 응답하지 않으면 같은 웹훅을 다시 보낼 수 있습니다.

따라서 내 서버는 다음을 고려해야 합니다.

  • 같은 이벤트가 여러 번 올 수 있습니다.
  • 이벤트 순서가 보장되지 않을 수 있습니다.
  • 일시적인 장애 후 과거 이벤트가 다시 올 수 있습니다.
  • 재시도 간격은 서비스마다 다릅니다.
  • 너무 늦게 응답하면 실패로 판단될 수 있습니다.

재시도 정책은 외부 서비스마다 다르므로 반드시 문서를 확인해야 합니다.

중요한 것은 재시도 자체를 문제로 보지 않는 것입니다.

재시도는 웹훅 전달 안정성을 높이기 위한 장치입니다.
내 서버는 재시도를 고려해 중복 처리와 멱등성을 준비해야 합니다.



이벤트 순서 문제

웹훅은 항상 순서대로 도착한다고 보장하기 어렵습니다.

예를 들어 구독 서비스에서 다음 이벤트가 발생했다고 해보겠습니다.

subscription.created
subscription.updated
subscription.cancelled

하지만 네트워크 상황이나 재시도 때문에 내 서버에는 다른 순서로 도착할 수 있습니다.

subscription.updated
subscription.created
subscription.cancelled

이런 경우 단순히 도착한 순서대로 처리하면 상태가 꼬일 수 있습니다.

순서 문제가 중요한 경우에는 다음을 고려해야 합니다.

  • 이벤트 발생 시각을 확인합니다.
  • 외부 API로 현재 최종 상태를 다시 조회합니다.
  • 상태 전이를 명확하게 검증합니다.
  • 오래된 이벤트는 무시할 수 있도록 합니다.
  • event version이나 sequence 값이 있다면 활용합니다.

특히 결제, 구독, 배송처럼 상태 변화가 중요한 도메인에서는 웹훅 payload만 믿기보다 외부 API로 최종 상태를 재확인하는 것이 안전할 수 있습니다.



웹훅과 최종 상태 조회

웹훅 payload에는 이벤트 정보가 담겨 있지만, 모든 데이터를 그대로 신뢰하기보다 필요한 경우 외부 API로 최종 상태를 조회하는 것이 좋습니다.

예를 들어 결제 완료 웹훅을 받았다고 해보겠습니다.

payment.completed 웹훅 수신
→ payment_id 확인
→ 결제 서비스 API로 payment_id 상태 재조회
→ 실제 상태가 completed인지 확인
→ 주문 상태 변경

이 방식은 다음 상황에서 유용합니다.

  • 웹훅 payload가 최소 정보만 포함하는 경우
  • 보안상 중요한 결제 상태를 다시 확인해야 하는 경우
  • 이벤트 순서가 꼬일 수 있는 경우
  • 최신 상태가 필요한 경우
  • 중간 이벤트보다 최종 상태가 중요한 경우

다만 모든 웹훅마다 외부 API를 다시 호출하면 비용과 지연이 늘어날 수 있습니다.

중요도에 따라 선택하는 것이 좋습니다.



로컬 개발에서 웹훅 테스트하기

웹훅은 외부 서비스가 내 서버로 요청을 보내야 하므로 로컬 개발 환경에서 테스트하기가 조금 까다롭습니다.

내 로컬 서버는 보통 외부 인터넷에서 접근할 수 없기 때문입니다.

이때 사용할 수 있는 도구가 터널링 도구입니다.

예를 들어 ngrok 같은 도구를 사용하면 로컬 서버를 외부에서 접근 가능한 URL로 노출할 수 있습니다.

로컬 Django 서버
→ localhost:8000

ngrok
→ https://random-id.ngrok.app
→ localhost:8000으로 전달

외부 서비스의 웹훅 URL에는 다음처럼 등록할 수 있습니다.

https://random-id.ngrok.app/webhooks/payment/

그러면 외부 서비스에서 보낸 웹훅 요청이 내 로컬 Django 서버로 전달됩니다.

로컬 테스트 시에는 다음을 확인하면 좋습니다.

  • 요청이 실제로 도착하는지
  • 헤더가 올바르게 들어오는지
  • 서명 검증이 통과하는지
  • payload 구조가 문서와 같은지
  • 2xx 응답을 반환하는지
  • 같은 이벤트 재전송 시 중복 처리되는지


웹훅 응답 상태 코드

웹훅 엔드포인트는 응답 상태 코드를 신중하게 반환해야 합니다.

일반적으로 처리에 성공했거나 이벤트를 정상적으로 수신했다면 2xx를 반환합니다.

HTTP/1.1 200 OK

또는 응답 본문이 필요 없다면 204 No Content를 사용할 수도 있습니다.

HTTP/1.1 204 No Content

요청이 잘못되었다면 4xx를 반환할 수 있습니다.

400 Bad Request
→ JSON 형식이 잘못됨

401 Unauthorized
→ 서명 검증 실패

405 Method Not Allowed
→ POST가 아닌 요청

서버 내부 오류가 발생하면 5xx가 반환될 수 있습니다.

다만 5xx를 반환하면 외부 서비스가 재시도할 수 있습니다.

중요한 이벤트라면 일단 이벤트를 저장한 뒤 200을 반환하고, 내부 처리는 Celery에서 재시도하도록 설계하는 것이 더 안정적일 수 있습니다.



웹훅을 사용할 때 자주 하는 실수

1. 서명 검증 없이 처리하는 경우

가장 위험한 실수입니다.

웹훅 URL은 외부에 노출될 수 있으므로, 반드시 요청이 신뢰할 수 있는 서비스에서 온 것인지 검증해야 합니다.



2. 중복 이벤트를 고려하지 않는 경우

웹훅은 재시도될 수 있습니다.

같은 이벤트가 여러 번 들어와도 문제가 없도록 event_id 저장과 멱등 처리를 고려해야 합니다.



3. 웹훅 안에서 오래 걸리는 작업을 모두 처리하는 경우

웹훅 요청은 빠르게 응답하는 것이 좋습니다.

이메일 발송, 외부 API 호출, 복잡한 계산은 작업 큐로 분리하는 것이 안전합니다.



4. 원본 payload를 저장하지 않는 경우

장애가 발생했을 때 어떤 이벤트가 들어왔는지 확인할 수 없으면 디버깅이 어렵습니다.

민감 정보에 주의하면서 필요한 원본 이벤트 정보는 저장해두는 것이 좋습니다.



5. 이벤트 순서가 항상 맞다고 가정하는 경우

웹훅은 순서가 바뀌거나 늦게 도착할 수 있습니다.

상태 전이가 중요한 경우에는 현재 상태를 다시 조회하거나 이벤트 발생 시각을 확인해야 합니다.



6. 테스트를 실제 운영 이벤트로만 하는 경우

웹훅은 로컬과 스테이징 환경에서 충분히 테스트해야 합니다.

외부 서비스가 제공하는 테스트 웹훅 기능이나 CLI, ngrok 같은 도구를 활용하면 좋습니다.



웹훅 구현 체크리스트

웹훅을 구현할 때는 아래 항목을 확인하면 좋습니다.

[ ] 웹훅 전용 URL을 만들었는가?
[ ] HTTPS로 요청을 받는가?
[ ] 요청 서명 또는 secret 검증을 하는가?
[ ] 허용된 이벤트 타입만 처리하는가?
[ ] event_id를 저장해 중복 처리를 막는가?
[ ] 같은 이벤트가 여러 번 와도 안전한가?
[ ] 웹훅 수신 로그를 남기는가?
[ ] 원본 payload를 필요한 범위에서 저장하는가?
[ ] 오래 걸리는 처리는 Celery 등 작업 큐로 분리했는가?
[ ] 실패한 작업을 재시도할 수 있는가?
[ ] 이벤트 순서가 바뀌어도 안전한가?
[ ] 로컬 또는 스테이징에서 테스트했는가?
[ ] 외부 서비스의 재시도 정책을 확인했는가?

이 체크리스트를 기준으로 보면 웹훅을 더 안정적으로 운영할 수 있습니다.



정리

웹훅은 외부 서비스에서 이벤트가 발생했을 때, 내 서버로 HTTP 요청을 보내 이벤트를 알려주는 방식입니다.

일반 API 호출이 내가 외부 서비스에 데이터를 요청하는 방식이라면, 웹훅은 외부 서비스가 나에게 이벤트를 알려주는 방식입니다.

핵심을 정리하면 다음과 같습니다.

  • 웹훅은 특정 이벤트가 발생했을 때 외부 서비스가 내 서버의 URL로 HTTP 요청을 보내는 방식입니다.
  • 일반 API 호출은 내가 외부 서비스에 요청하는 방식이고, 웹훅은 외부 서비스가 내 서버에 요청하는 방식입니다.
  • 웹훅은 결제 완료, 환불, GitHub push, 이메일 발송 결과, 구독 상태 변경 같은 이벤트 처리에 자주 사용됩니다.
  • 웹훅 요청은 보통 POST 방식으로 오며, JSON payload에 이벤트 정보가 담깁니다.
  • 웹훅 엔드포인트는 외부에서 접근 가능하므로 서명 검증이 매우 중요합니다.
  • 같은 웹훅 이벤트가 여러 번 올 수 있으므로 중복 처리와 멱등성을 고려해야 합니다.
  • 웹훅 요청에는 빠르게 응답하고, 오래 걸리는 작업은 Celery 같은 작업 큐로 분리하는 것이 좋습니다.
  • 이벤트 순서가 항상 보장된다고 가정하면 안 됩니다.
  • 중요한 상태 변경은 필요할 때 외부 API로 최종 상태를 다시 확인하는 것이 안전할 수 있습니다.
  • 웹훅 이벤트 로그를 저장하면 장애 분석과 재처리에 도움이 됩니다.
  • 로컬 개발에서는 ngrok 같은 터널링 도구로 웹훅을 테스트할 수 있습니다.

웹훅은 실무 백엔드에서 매우 자주 사용되는 연동 방식입니다.
하지만 단순히 URL 하나를 열어두는 것으로 끝나지 않습니다.

보안 검증, 중복 처리, 실패 재시도, 비동기 처리, 이벤트 로그 관리까지 함께 고려해야 안정적인 웹훅 연동을 만들 수 있습니다.



참고 링크