CodeGym /행동 /ChatGPT Apps /웹훅과 외부 통합: 서명, 타임아웃, 멱등성

웹훅과 외부 통합: 서명, 타임아웃, 멱등성

ChatGPT Apps
레벨 15 , 레슨 3
사용 가능

1. ChatGPT App에서의 웹훅: 누가 누구에게 요청하나

고전적인 HTTP 세계에서는 간단합니다: 당신은 클라이언트이고 POST /api/...를 호출하면 서버가 응답하고 모두 행복합니다. 웹훅은 반대입니다. 외부 서비스가 바깥에서 어떤 일이 일어났을 때 당신의 백엔드에 직접 HTTP 요청을 보냅니다.

ChatGPT Apps 생태계에서는 몇 가지 전형적인 시나리오에서 이 문제가 나타납니다. 예를 들어 GiftGenius가 ACP/Instant Checkout으로 체크아웃을 만든 뒤 결제 제공자로부터 웹훅으로 payment_succeeded 알림을 받습니다. 또는 선물용 프리뷰 이미지를 생성하는 백그라운드 서비스가 렌더링이 끝났을 때 image_ready를 보냅니다. 이런 경우 ChatGPT와 MCP 서버는 이미 할 일을 끝냈고, 공은 제3의 서비스 쪽에 있으며, 해당 서비스가 webhook으로 당신에게 결과를 통지합니다.

핵심 특징: 주도권이 당신의 시스템 바깥에 있습니다. 요청은 언제든 올 수 있고, 여러 번 올 수도 있습니다. 따라서 웹훅 핸들러를 잠재적으로 가장 취약한 지점이라고 생각하고 설계해야 합니다 — 그곳을 전 인터넷이 두드리니까요.

대조를 위한 작은 표:

호출 유형 누가 시작함 GiftGenius의 예
일반 API 요청 당신 MCP 서버가 Stripe API 호출
웹훅 외부 서비스 Stripe가 payment_succeeded를 보냄

2. 간단한 다이어그램: ChatGPT, MCP, 웹훅은 어디에 있는가

흐름은 대략 다음과 같습니다:

sequenceDiagram
    participant User as ChatGPT의 사용자
    participant GPT as ChatGPT + 모델
    participant App as GiftGenius (MCP/App)
    participant PSP as 결제 서비스 (Stripe/ACP)

    User->>GPT: "선물을 구매하고 싶어요"
    GPT->>App: callTool(create_checkout)
    App->>PSP: POST /checkout_sessions
    PSP-->>App: 200 OK + checkout_session_id
    App-->>GPT: ToolOutput (checkout info)

    PSP-->>App: POST /webhooks/payment_succeeded
    App-->>PSP: 200 OK (이벤트 수신 확인)
    App->>DB: 주문을 결제 완료로 표시

위쪽 부분은 이미 익숙한 일반적인 아웃바운드 요청입니다. 웹훅은 아래쪽 부분, 즉 결제 서비스가 스스로 당신에게 요청을 보내는 구간입니다. 오늘 우리의 관심사는 바로 이 지점입니다.

3. Next.js에서의 기본 웹훅 처리기(스켈레톤)

우리는 Next.js 16에서 학습용 GiftGenius를 계속 발전시키고 있습니다. 템플릿에는 UI가 있는 app/와 MCP 서버가 있는 app/mcp/route.ts가 있습니다.

웹훅 처리기는 별도의 HTTP 라우트로 분리하는 것이 합리적입니다. 예: app/api/webhooks/commerce/route.ts.

최소 골격은 다음과 같습니다:


// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();          // 1. 본문을 문자열로 읽기
  const headers = Object.fromEntries(req.headers); // 2. 헤더를 객체로 수집

  // 3. TODO: 서명 검증(아래에서 추가)
  // 4. TODO: JSON 파싱과 이벤트 처리

  return new Response("ok", { status: 200 }); // 5. 즉시 2xx로 응답
}

여기에는 이미 몇 가지 중요한 아이디어가 숨어 있습니다.

첫째, 본문은 텍스트로 읽고, 바로 await req.json()을 호출하지 않습니다. 많은 제공자가 요청 본문의 정확한 바이트 스트림을 기준으로 서명하기 때문에, 서명 검증 전에 파싱(더 나아가 포맷 변경)하면 서명이 일치하지 않습니다.

둘째, 빠른 2xx 응답을 염두에 둡니다. 무거운 작업은 별도의 워커로 넘기거나, 최소한 이벤트를 로깅한 뒤의 async 함수로 미루는 편이 좋습니다. 이는 곧 다룰 타임아웃과 재시도와 직결됩니다.

4. 웹훅 서명: 'Stripe'와 'curl 든 사람'을 어떻게 구분하나

스켈레톤 처리기의 TODO였던 "서명 검증"을 떠올려 봅시다. 진짜 Stripe와 'curl로 두드리는 사람'을 정확히 어떻게 구분하는지 살펴보죠.

가장 순진한 접근은 URL이 복잡하면(/api/webhooks/stripe/super-secret-abc123) 아무도 못 찾을 거라 생각하는 것입니다. 이런 URL 시크릿은 본질적으로 security through obscurity, 즉 복잡한 URL 뒤에 숨는 시도로, 보호 수준이 매우 약합니다. 올바른 방어선은 암호학적 서명입니다.

대부분의 진지한 제공자(Stripe, ACP, 여러 CRM)는 요청 본문과 시간 값을 바탕으로 HMAC 서명을 계산해 헤더에 담습니다. 수신자인 당신도 같은 방식으로 계산해 비교합니다. 조금이라도 다르면 변조로 판단하고 요청을 버립니다.

일반적인 절차:

  1. 제공자의 대시보드에서 받은 웹훅 시크릿이 있고, 이를 환경 시크릿에 저장합니다(예: Vercel env의 STRIPE_WEBHOOK_SECRET).
  2. 제공자는 요청을 보낼 때 timestamp + '.' + rawBody에 대해 HMAC을 계산합니다.
  3. 예를 들어 Stripe-Signature 같은 헤더에 timestamp와 하나 이상의 서명을 기록합니다.
  4. 처리기에서 timestamp를 꺼내 같은 규칙으로 HMAC을 계산해 비교합니다.

TypeScript로 crypto를 사용하는 아주 작은 예:

import crypto from "crypto";

function computeSignature(secret: string, payload: string) {
  return crypto
    .createHmac("sha256", secret)  // 알고리즘 선택
    .update(payload, "utf8")       // 원본 본문 텍스트
    .digest("hex");                // hex 문자열
}

서명과 이벤트 신선도를 검증하는 예:

const sigHeader = headers["stripe-signature"];
if (!sigHeader) return new Response("missing signature", { status: 400 });

const [tsPart, sigPart] = sigHeader.split(",").map(s => s.trim());
const timestamp = Number(tsPart.split("=")[1]);
const theirSig = sigPart.split("=")[1];

const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - timestamp) > 5 * 60) {
  return new Response("timestamp too old", { status: 400 });
}

const payload = `${timestamp}.${rawBody}`;
const expectedSig = computeSignature(
  process.env.STRIPE_WEBHOOK_SECRET!,
  payload
);

if (!crypto.timingSafeEqual(
  Buffer.from(expectedSig, "hex"),
  Buffer.from(theirSig, "hex")
)) {
  return new Response("invalid signature", { status: 400 });
}

timingSafeEqual에 주목하세요. 이는 비교에 걸리는 시간 차이를 이용해 서명을 추측하려는 타이밍 공격을 막는 장치입니다.

서명 검증에 성공했다면 이제 JSON.parse(rawBody)await req.json()을 안심하고 호출할 수 있습니다. 실제 제공자로부터 온 데이터임을 알았으니까요.

IP allowlist(제공자의 주소에서 오는 요청만 허용)나 웹훅 전용 도메인 같은 추가 방어도 좋지만, 진짜 신뢰는 암호학적 서명이 제공합니다.

5. 타임아웃, 빠른 응답, 그리고 비동기 처리

웹훅은 빨리 응답하는 서비스를 좋아합니다. 대부분의 결제/커머스 플랫폼은 엔드포인트가 몇 초 안(10초 이내가 흔하며, 더 짧을 때도 있음)에 2xx로 응답하길 기대합니다. 당신이 오래 "생각"하면 호출을 실패로 간주하고 재전송을 시작합니다.

정면 돌파는 보통 이렇게 됩니다. 서명을 확인하고, DB에 다녀오고, 외부 API를 또 셋이나 호출하고, 리포트를 계산하고, PDF를 만들고, 그제야 200 OK를 돌려줍니다. 이 중 하나라도 조금 지연되면 결제 서비스는 웹훅이 실패했다고 판단하고 다시 보냅니다. 그 결과 주문을 두 번 만들고, 메일을 두 번 보내고, 어떤 GPT 도구를 두 번 호출하는 등 혼란을 자초하게 됩니다.

올바른 패턴은 "받고, 기록하고, 미룬다"입니다:

  1. 서명과 기본 불변식(이벤트 타입, 필수 필드)을 검사합니다.
  2. 이벤트를 테이블/큐에 빠르게 기록합니다(DB 작업 최소화).
  3. 2xx를 반환합니다.
  4. 이벤트 처리는 백그라운드에서 별도 워커가 처리합니다.

별도 큐 없이도 빠르게 고정하는 "반쯤 올바른" 처리기 예:

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const headers = Object.fromEntries(req.headers);

  if (!verifySignature(headers, rawBody)) {
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  await saveWebhookEvent(event); // DB에 빠르게 기록

  // 여기서 setImmediate/queue로 백그라운드 작업을 보낼 수 있지만,
  // 학습 예제에서는 기록만 하고: await 없이 호출하여
  // 200 응답이 바로 나가게 합니다.
  processWebhookEventLater(event).catch(console.error);

  return new Response("ok", { status: 200 });
}

주의: 우리는 절대 await processWebhookEventLater(...)를 하지 않습니다. 처리기는 작업을 백그라운드에 등록하고 즉시 200을 반환해 웹훅 타임아웃에 걸리지 않도록 합니다.

실제 프로덕션에서는 보통 이 지점에 큐(예: webhook_jobs라는 별도 테이블이나 외부 서비스)가 등장하고, 워커가 이벤트를 차분하게 처리하면서 신규 수신을 막지 않습니다.

6. 멱등성과 중복 제거: 돈을 두 번 빼지 않으려면

학습 예제는 종종 완벽한 화살표를 그립니다. 한 이벤트 → 한 번의 처리 → 행복한 주문. 현실에서는 웹훅이 푹신한 고양이처럼 묶음으로, 그리고 연달아 도착합니다.

이유는 간단합니다. 네트워크는 신뢰할 수 없고, 타임아웃은 발생하며, 많은 제공자는 의도적으로 2xx를 확실히 받을 때까지 이벤트를 재전송합니다. 특히 결제에서는 payment_succeeded를 한 번 더 보내는 편이 영영 잃어버리는 것보다 낫습니다.

따라서 비즈니스 로직은 멱등적이어야 합니다. 같은 이벤트를 다시 처리해도 결과가 변하지 않거나, 적어도 시스템을 망가뜨리지 않아야 합니다.

전형적인 패턴:

  1. 이벤트에 안정적인 식별자가 있습니다. 예: event.id 또는 checkout_session_id.
  2. 이를 처리된 이벤트 테이블에 저장하고, 해당 필드에 고유 인덱스를 둡니다.
  3. 웹훅마다 우선 검사합니다. 동일 id에 "처리됨" 상태가 이미 있으면 200만 응답하고 아무것도 하지 않습니다.

의사 ORM으로 본 작은 예:

async function handlePaymentSucceeded(event: any) {
  const existing = await db.webhookEvents.findUnique({
    where: { providerId: event.id },
  });
  if (existing?.processedAt) {
    return; // 이미 처리됨
  }

  await db.$transaction(async (tx) => {
    await tx.webhookEvents.upsert({
      where: { providerId: event.id },
      update: { processedAt: new Date() },
      create: {
        provider: "stripe",
        providerId: event.id,
        type: event.type,
        payload: event,
        processedAt: new Date(),
      },
    });

    await tx.orders.update({
      where: { checkoutSessionId: event.data.object.id },
      data: { status: "PAID" },
    });
  });
}

여기서 트랜잭션이 중요한 이유는 이벤트를 처리됨으로 표시하는 것과 주문을 바꾸는 일을 동시에 수행하기 때문입니다. 중간에 실패하면 트랜잭션이 롤백되고, 다음 웹훅 재전송 시 중복 기록 없이 다시 시도할 수 있습니다.

또한 연산 자체를 멱등적으로 설계하는 것이 좋습니다. 예를 들어:

  • 「주문 상태를 PAID로 설정」처럼 값 설정을 선호(「잔액을 +100 증가」 대신);
  • 「없으면 생성」을 선호(「한 줄 더 추가」 대신).

7. 웹훅 데이터 검증과 PII: 서명만이 유일한 필터는 아니다

웹훅이 서명되어 실제 서비스에서 왔다고 해도, 그 데이터는 사용자 입력이나 도구 인자와 동일한 수준의 의심을 가지고 다루어야 합니다. 지난 강의에서 논의했듯, 스키마와 정규화는 일종의 방화벽입니다.

이벤트 스키마는 예컨대 TypeScript/Zod 수준에서 다음처럼 만들 수 있습니다:

import { z } from "zod";

const paymentSucceededSchema = z.object({
  id: z.string(),
  type: z.literal("payment_succeeded"),
  data: z.object({
    object: z.object({
      id: z.string(),            // checkout_session_id
      amount_total: z.number(),
      currency: z.string(),
      metadata: z.record(z.string(), z.string()).optional(),
    }),
  }),
});

처리기에서는 이렇게 검증합니다:

const event = JSON.parse(rawBody);
const parsed = paymentSucceededSchema.parse(event);
// 이후에는 parsed만 사용

이렇게 하면 「제공자가 포맷을 바꿨다」, 「테스트 환경에서 필드가 nullable이 되었다」 같은 깜짝 변화를 방어할 수 있습니다. 무언가 이상하면 로그에 오류를 기록하고 400을 반환하세요. 제공자가 나중에 재전송하거나 알림을 보낼 것입니다.

PII도 중요합니다. 웹훅 본문에는 종종 이메일, 배송지, 가끔은 결제 데이터 조각(토큰화된 형태)이 들어옵니다. 로그에서는 이를 마스킹하고, 원본 그대로를 외부 APM/로그 서비스로 보내지 않는 것이 필수 관행입니다. 이는 비밀/민감 정보 주제에서 이미 이야기했습니다.

그리고 전체 웹훅 JSON을 아무 필터 없이 ChatGPT로 ToolOutput으로 보내는 일은 절대 하지 마세요. 모델은 UX에 필요하지 않은 결제 제공자의 모든 데이터를 볼 필요가 없습니다.

8. 실전 GiftGenius: ACP/Instant Checkout의 결제 웹훅

우리의 GiftGenius로 돌아갑시다. 커머스와 ACP 모듈에서 에이전트가 체크아웃 세션을 만드는 법과 이후 Instant Checkout으로 결제가 이뤄지는 흐름을 이미 다뤘습니다. 백엔드 관점에서 그 다음은 웹훅 order.paid(Stripe 용어로는 checkout.session.completed)을 기다려 다음을 수행하는 것입니다:

  • 주문 상태를 확정;
  • 메일 전송/출고 준비 등의 체인을 시작;
  • 에이전트에게 "결제가 완료되었습니다"라는 확실한 응답을 제공.

Next.js의 간단한 처리기 예:

// app/api/webhooks/commerce/route.ts
import { NextRequest } from "next/server";
import { handlePaymentSucceeded } from "@/lib/webhooks/commerce";

export async function POST(req: NextRequest) {
  const rawBody = await req.text();
  const headers = Object.fromEntries(req.headers);

  if (!verifyCommerceSignature(headers, rawBody)) {
    return new Response("invalid signature", { status: 400 });
  }

  const event = JSON.parse(rawBody);
  if (event.type === "payment_succeeded") {
    // 이전 섹션의 멱등 처리기
    await handlePaymentSucceeded(event);
  }

  return new Response("ok", { status: 200 });
}

verifyCommerceSignature 함수는 앞에서 살펴본 것과 같은 HMAC 서명 검증 로직을 구현합니다. 실제 프로젝트에서는 제공자마다 모듈을 나누는 것이 좋습니다(verifyStripeSignature, verifyACPCheckoutSignature 등).

handlePaymentSucceeded 안에서는 다음을 수행합니다:

  • 객체를 스키마(Zod)로 검증;
  • 트랜잭션으로 이벤트를 처리됨으로 표시하고 주문을 갱신;
  • 선택적으로 "느린" 작업(메일, 분석, 추가 API 호출)을 위한 큐에 작업 등록.

이 접근은 "ACP → 웹훅 → GiftGenius" 체인을 재전송, 일시적 장애, 이상한 데이터에 견고하게 만듭니다.

9. 웹훅은 MCP, ChatGPT, 도구들과 어디에서 만나는가

겉보기에는 웹훅이 ChatGPT App과 별개로 백엔드의 어떤 HTTP 라우트에만 존재하는 듯 보입니다. 하지만 실제로는 전체 아키텍처의 중요한 일부입니다.

보통 연결은 다음과 같습니다:

  1. MCP 도구 create_checkout이 ChatGPT의 모델에 의해 호출됩니다.
  2. MCP 서버가 결제 서비스에 요청해 체크아웃 세션을 만들고, ToolOutput으로 주문 정보와 "결제 대기" 상태를 반환합니다.
  3. 사용자가 UI에서 결제를 마칩니다(Instant Checkout은 ChatGPT 내부에서 바로 처리).
  4. 결제 서비스가 당신의 백엔드로 웹훅을 보냅니다.
  5. 백엔드는 DB를 통해 주문 상태를 변경합니다. 이후 도구 호출이나 모델의 후속 응답에서 "주문이 결제되었습니다. 세부 정보는 다음과 같습니다"라고 자신 있게 말할 수 있습니다.

때로 백엔드는 간접적으로 후속 상호작용을 시작할 수 있습니다. 예를 들어 위젯이나 Realtime 통합이 서버 신호를 받아 스스로 sendFollowUpMessage를 호출하는 식입니다. 설령 그것이 없더라도, 결제 사실은 당신의 저장소에 있으며, 다음 도구 호출 시 백엔드는 DB에서 새로운 상태를 읽어 모델에 최신 데이터를 제공합니다.

중요한 점은, 웹훅은 MCP 서버와 같은 수준의 입력 지점이며 동일한 서비스들(DB, 큐, 시크릿)을 사용한다는 것입니다. 보안 논리는 사실상 동일합니다. 최소 권한, 검증된 입력, 신중한 로깅.

10. 웹훅과 외부 통합에서 흔한 실수

오류 №1: 웹훅 서명 검증 부재.
가끔 개발자들은 "비밀" URL이나 단순한 Bearer my-secret 헤더로 끝내곤 합니다. 이때 시크릿이 유출되면 누구나 웹훅을 보내 주문을 만들고 결제 상태를 바꾸는 등 무엇이든 할 수 있습니다. 올바른 접근은 본문(HMAC)의 암호학적 서명과 timestamp 검증입니다. 이는 "URL을 맞추기"보다 훨씬 위조가 어렵습니다.

오류 №2: 웹훅 요청 안에서 무거운 처리 수행.
웹훅 처리기에서 "주문 생성, 외부 API 2개 호출, PDF 생성, GPT 모델 호출, 메일 5통 발송" 같은 코드를 넣는 것은 타임아웃과 재시도를 부르는 지름길입니다. 그 결과 당신이 중복을 스스로 만들어내고, 나중에 이를 수습해야 합니다. 훨씬 더 견고한 방법은 이벤트 수신을 빠르게 확인(2xx)하고, DB나 큐에 기록한 뒤 백그라운드에서 처리하는 것입니다.

오류 №3: 멱등적이지 않은 비즈니스 로직.
종종 "매 payment_succeeded마다 잔액을 증가" 같은 코드를 봅니다. 웹훅이 두 번 오면 잔액이 두 배가 됩니다. 또 다른 예는 같은 주문을 두 번 만들거나, 같은 메일을 두 번 보내는 것입니다. 멱등성은 안정적인 이벤트 식별자, 처리된 이벤트 테이블, 트랜잭션, 그리고 "상태 설정" 같은 연산으로 달성합니다.

오류 №4: 웹훅 데이터 스키마와 검증 부재.
서명된 웹훅이라도 기대와 다를 수 있습니다. 제공자가 포맷을 바꾸거나, 문서의 JSON을 복사해 왔는데 테스트 환경에서는 필드 이름이 다르거나, 타입을 단순히 잘못 가정했을 수 있습니다. 이런 JSON을 스키마와 검증 없이 처리하면 오류가 조용히 주문을 망가뜨리거나 체인 중간에서 예외를 일으킵니다. 입력에 Zod/JSON Schema를 사용하면 진단이 쉬워지고, 잘못된 이벤트를 명확히 거를 수 있습니다.

오류 №5: PII가 담긴 웹훅 원문을 그대로 로깅.
디버깅의 열정으로 console.log(rawBody)를 넣고 잊기 쉽습니다. 프로덕션에서는 이메일, 주소 등 PII가 가득한 로그가 외부 로그 서비스로 흘러갑니다. 프라이버시와 규제(GDPR 등) 관점에서 자해에 가깝습니다. 애초에 PII 스크럽을 도입해 민감 필드를 마스킹하고, 진단에 꼭 필요한 정보만 로깅하세요.

오류 №6: 테스트와 실운영 웹훅을 혼합.
흔한 상황은 하나의 엔드포인트가 제공자의 테스트/실운영 환경 이벤트를 모두 받는 경우입니다. 그 결과 테스트 결제가 실제 주문 상태를 바꾸거나 그 반대가 발생합니다. URL을 분리하는 편이 더 안전합니다(예: /webhooks/commerce/test/webhooks/commerce/live) 혹은 최소한 구성에 "모드"를 저장하고 입력에서 이를 확인하세요.

오류 №7: ChatGPT 시나리오를 동기식 웹훅에 전적으로 의존.
도구 호출과 체크아웃 세션 생성 직후 모델이 결제 결과를 즉시 알길 바랄 수 있습니다. 하지만 웹훅은 본질적으로 비동기이며, 결제에는 시간이 걸릴 수 있습니다. 모든 일이 즉시 일어날 것처럼 시나리오를 짜는 것은 좋지 않습니다. 주문 상태를 저장하고, 사용자가 채팅으로 돌아와 나중에 최신 정보를 받을 수 있도록, 지연된 이벤트와 잘 공존하는 대화와 도구를 설계하세요.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION