안정적인 웹훅 설계 방법

Ashley Innocent

Ashley Innocent

13 March 2026

안정적인 웹훅 설계 방법

요약

지수 백오프 재시도(5-10회), 멱등성 키, HMAC 서명 검증, 5초 타임아웃을 사용하여 안정적인 웹훅을 설계하세요. 2xx 응답은 즉시 반환하고, 처리는 비동기적으로 수행하세요. Modern PetstoreAPI는 주문 업데이트, 애완동물 입양, 결제 알림을 위한 웹훅을 완벽한 재시도 및 보안 기능과 함께 구현합니다.

서론

클라이언트에게 애완동물이 입양되었다고 알리기 위해 웹훅을 보냈습니다. 그런데 클라이언트 서버가 다운되었습니다. 웹훅 전송이 실패했습니다. 재시도해야 할까요? 몇 번이나 해야 할까요? 만약 클라이언트가 웹훅을 두 번 받아 고객에게 두 번 청구하면 어떻게 될까요?

웹훅은 이벤트 발생 시 클라이언트 URL로 푸시되는 HTTP 콜백입니다. 이론적으로는 간단하지만 실제로는 복잡합니다. 네트워크는 실패하고, 서버는 충돌하며, 클라이언트에는 버그가 있습니다. 프로덕션 웹훅에는 재시도 로직, 멱등성, 보안 및 모니터링이 필요합니다.

Modern PetstoreAPI는 주문 업데이트, 애완동물 입양, 결제 알림을 위한 프로덕션 준비 웹훅을 구현합니다. 모든 웹훅에는 재시도 로직, 서명 검증 및 멱등성이 포함됩니다.

💡
웹훅을 구축하거나 테스트 중이라면, Apidog는 웹훅 전달 테스트, 서명 검증, 실패 시나리오 시뮬레이션을 돕습니다. 재시도 로직을 테스트하고 멱등성 처리를 확인할 수 있습니다.
버튼

이 가이드에서는 Modern PetstoreAPI 패턴을 사용하여 안정적인 웹훅을 설계하는 방법을 배웁니다.

웹훅 기본 사항

웹훅은 이벤트 발생 시 클라이언트가 제공한 URL로 전송되는 HTTP POST 요청입니다.

웹훅 작동 방식

1. 클라이언트가 웹훅 URL을 등록합니다:

POST /webhooks
{
  "url": "https://client.com/webhooks/petstore",
  "events": ["pet.adopted", "order.completed"]
}

2. 이벤트 발생 (애완동물 입양)

3. 서버가 웹훅을 보냅니다:

POST https://client.com/webhooks/petstore
Content-Type: application/json
X-Webhook-Signature: sha256=abc123...

{
  "event": "pet.adopted",
  "data": {
    "petId": "019b4132",
    "userId": "user-456",
    "timestamp": "2026-03-13T10:30:00Z"
  }
}

4. 클라이언트가 응답합니다:

200 OK

안정성 문제

웹훅은 여러 가지 이유로 실패할 수 있습니다:

재시도 로직이 없으면 이벤트가 손실됩니다. 멱등성이 없으면 중복 웹훅이 중복 작업을 유발합니다.

지수 백오프를 이용한 재시도 로직

실패한 웹훅을 점진적으로 지연 시간을 늘려 재시도합니다.

지수 백오프 전략

시도 1: 즉시
시도 2: 1초 후
시도 3: 2초 후
시도 4: 4초 후
시도 5: 8초 후
시도 6: 16초 후

왜 지수적일까요? 클라이언트가 다운된 경우, 재시도를 퍼붓는다고 도움이 되지 않습니다. 지수 백오프는 복구 시간을 줍니다.

구현

async function sendWebhook(url, payload, attempt = 1, maxAttempts = 6) {
  try {
    const response = await fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Webhook-Signature': generateSignature(payload)
      },
      body: JSON.stringify(payload),
      timeout: 5000 // 5초 타임아웃
    });

    if (response.ok) {
      return { success: true, attempt };
    }

    // 5xx 오류 시 재시도
    if (response.status >= 500 && attempt < maxAttempts) {
      const delay = Math.pow(2, attempt - 1) * 1000;
      await sleep(delay);
      return sendWebhook(url, payload, attempt + 1, maxAttempts);
    }

    // 4xx 오류는 재시도 안함 (클라이언트 오류)
    return { success: false, status: response.status };

  } catch (error) {
    // 네트워크 오류 또는 타임아웃 - 재시도
    if (attempt < maxAttempts) {
      const delay = Math.pow(2, attempt - 1) * 1000;
      await sleep(delay);
      return sendWebhook(url, payload, attempt + 1, maxAttempts);
    }
    return { success: false, error: error.message };
  }
}

언제 재시도할 것인가

다음의 경우 재시도:

다음의 경우 재시도하지 않음:

데드 레터 큐

최대 재시도 횟수 이후, 실패한 웹훅을 수동 검토를 위해 데드 레터 큐로 이동합니다:

if (!result.success) {
  await deadLetterQueue.add({
    url,
    payload,
    attempts: maxAttempts,
    lastError: result.error,
    timestamp: new Date()
  });
}

중복 방지를 위한 멱등성

클라이언트는 동일한 웹훅을 여러 번 수신할 수 있습니다. 멱등성은 중복 처리를 방지합니다.

멱등성 키

각 웹훅에 고유 ID를 포함합니다:

{
  "id": "webhook_019b4132",
  "event": "pet.adopted",
  "data": {...}
}

클라이언트는 처리된 ID를 저장합니다:

app.post('/webhooks/petstore', async (req, res) => {
  const webhookId = req.body.id;

  // 이미 처리되었는지 확인
  const processed = await db.webhooks.findOne({ id: webhookId });
  if (processed) {
    return res.status(200).json({ message: 'Already processed' });
  }

  // 웹훅 처리
  await processPetAdoption(req.body.data);

  // 처리 완료 표시
  await db.webhooks.insert({ id: webhookId, processedAt: new Date() });

  res.status(200).json({ message: 'Processed' });
});

멱등성 작업

멱등성 작업을 설계하세요:

나쁜 예 (멱등성 없음):

// 두 번 청구하면 이중 청구 발생
await chargeCustomer(userId, amount);

좋은 예 (멱등성 있음):

// 멱등성 키로 청구하면 이중 청구 방지
await chargeCustomer(userId, amount, { idempotencyKey: webhookId });

보안을 위한 서명 검증

웹훅이 공격자가 아닌 API에서 온 것인지 확인합니다.

HMAC 서명

공유 비밀 키를 사용하여 서명을 생성합니다:

// 서버가 서명 생성
const crypto = require('crypto');

function generateSignature(payload, secret) {
  const hmac = crypto.createHmac('sha256', secret);
  hmac.update(JSON.stringify(payload));
  return hmac.digest('hex');
}

// 헤더에 포함
headers['X-Webhook-Signature'] = `sha256=${generateSignature(payload, webhookSecret)}`;

클라이언트가 서명을 검증합니다:

function verifySignature(payload, signature, secret) {
  const expected = generateSignature(payload, secret);
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(`sha256=${expected}`)
  );
}

app.post('/webhooks/petstore', (req, res) => {
  const signature = req.headers['x-webhook-signature'];

  if (!verifySignature(req.body, signature, WEBHOOK_SECRET)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // 웹훅 처리
  ...
});

타임스탬프 검증

재전송 공격을 방지하기 위해 타임스탬프를 포함합니다:

{
  "id": "webhook_019b4132",
  "timestamp": "2026-03-13T10:30:00Z",
  "event": "pet.adopted",
  "data": {...}
}

오래된 웹훅 거부:

const webhookAge = Date.now() - new Date(req.body.timestamp);
if (webhookAge > 5 * 60 * 1000) { // 5분
  return res.status(400).json({ error: 'Webhook too old' });
}

타임아웃 처리

느린 클라이언트가 시스템을 차단하는 것을 방지하기 위해 공격적인 타임아웃을 설정하세요.

5초 타임아웃

const response = await fetch(url, {
  method: 'POST',
  body: JSON.stringify(payload),
  timeout: 5000 // 5초
});

왜 5초인가요? 웹훅은 즉시 응답해야 합니다. 클라이언트가 더 오래 걸린다면, 동기적으로 처리하고 있다는 의미입니다 (잘못된 패턴).

비동기 처리 패턴

나쁜 예 (동기적):

app.post('/webhooks/petstore', async (req, res) => {
  // 이것은 30초가 걸리므로 웹훅이 타임아웃될 것입니다.
  await processOrder(req.body.data);
  await sendEmail(req.body.data);
  await updateInventory(req.body.data);

  res.status(200).json({ message: 'Processed' });
});

좋은 예 (비동기적):

app.post('/webhooks/petstore', async (req, res) => {
  // 즉시 반환
  res.status(200).json({ message: 'Received' });

  // 비동기적으로 처리
  queue.add('process-webhook', req.body);
});

Modern PetstoreAPI가 웹훅을 구현하는 방법

Modern PetstoreAPI는 프로덕션 준비 웹훅을 구현합니다.

웹훅 이벤트

pet.adopted - 애완동물이 입양됨
pet.status_changed - 애완동물 상태 변경됨
order.created - 주문 생성됨
order.completed - 주문 완료됨
payment.succeeded - 결제 성공
payment.failed - 결제 실패

웹훅 페이로드

{
  "id": "webhook_019b4132-70aa-764f-b315-e2803d882a24",
  "event": "pet.adopted",
  "timestamp": "2026-03-13T10:30:00Z",
  "data": {
    "petId": "019b4132-70aa-764f-b315-e2803d882a24",
    "userId": "user-456",
    "orderId": "order-789",
    "adoptionDate": "2026-03-13"
  },
  "apiVersion": "v1"
}

재시도 구성

보안

Apidog로 웹훅 테스트하기

Apidog는 웹훅 테스트를 지원합니다.

웹훅 전달 테스트

  1. Apidog에서 모의 웹훅 엔드포인트 생성
  2. PetstoreAPI에 엔드포인트 등록
  3. 이벤트 트리거 (애완동물 입양)
  4. 웹훅 수신 확인
  5. 페이로드 형식 확인

서명 검증 테스트

// Apidog 테스트 스크립트
const signature = pm.request.headers.get('X-Webhook-Signature');
const payload = pm.request.body.raw;
const secret = pm.environment.get('WEBHOOK_SECRET');

const expected = generateSignature(payload, secret);
pm.test('Signature valid', () => {
  pm.expect(signature).to.equal(`sha256=${expected}`);
});

재시도 로직 테스트

  1. 모의 엔드포인트에서 500 오류 반환
  2. 지수 백오프를 이용한 재시도 시도 횟수 확인
  3. 최대 재시도 후 데드 레터 큐 확인

멱등성 테스트

  1. 웹훅 수신
  2. 200 반환
  3. 동일한 웹훅 다시 수신 (재시도 시뮬레이션)
  4. 중복 처리 없음 확인

결론

안정적인 웹훅에는 다음이 필요합니다:

Modern PetstoreAPI는 이러한 모든 패턴을 구현합니다. 전체 예제는 웹훅 문서를 확인하세요.

프로덕션 환경에 배포하기 전에 Apidog로 웹훅을 테스트하여 재시도 로직, 서명 및 멱등성을 검증하세요.

버튼

자주 묻는 질문

웹훅은 몇 번 재시도해야 하나요?

지수 백오프와 함께 5-10회 재시도. 이는 클라이언트에 과부하를 주지 않으면서 일시적인 중단(5-17분)을 처리합니다.

웹훅은 4xx 오류 시 재시도해야 하나요?

아니요. 4xx 오류는 클라이언트 문제(잘못된 URL, 인증 실패)를 나타냅니다. 재시도는 이를 해결하지 못합니다. 5xx 오류 및 네트워크 실패 시에만 재시도하세요.

웹훅 타임아웃은 얼마나 길어야 하나요?

최대 5초. 클라이언트는 즉시 200을 반환하고 비동기적으로 처리해야 합니다. 타임아웃이 길다는 것은 클라이언트가 동기적으로 처리하고 있다는 것을 의미합니다.

클라이언트가 웹훅에 응답하지 않으면 어떻게 해야 하나요?

최대 재시도 후 데드 레터 큐로 이동합니다. 이메일을 통해 클라이언트에게 알립니다. 반복적인 실패 후에는 해당 클라이언트에 대한 웹훅을 비활성화하는 것을 고려하세요.

웹훅 URL은 HTTPS여야 하나요?

예, 항상 HTTPS를 요구해야 합니다. HTTP 웹훅은 가로채거나 수정될 수 있습니다. Modern PetstoreAPI는 HTTP 웹훅 URL을 거부합니다.

재전송 공격을 어떻게 방지하나요?

페이로드에 타임스탬프를 포함하고 5분 이상 된 웹훅은 거부합니다. 서명 검증과 결합하여 사용하세요.

클라이언트가 웹훅 재전송을 요청할 수 있나요?

예. Modern PetstoreAPI는 특정 웹훅을 재전송하는 엔드포인트를 제공합니다: POST /webhooks/{id}/redeliver

로컬에서 웹훅을 어떻게 테스트하나요?

ngrok와 같은 도구를 사용하여 localhost를 인터넷에 노출하거나, 개발 중에 웹훅 엔드포인트를 시뮬레이션하기 위해 Apidog의 모의 서버를 사용하세요.

Apidog에서 API 설계-첫 번째 연습

API를 더 쉽게 구축하고 사용하는 방법을 발견하세요