AI가 짠 결제 코드 점검표: 금액 위변조·시크릿 키·웹훅 검증 8가지
개발

AI가 짠 결제 코드 점검표: 금액 위변조·시크릿 키·웹훅 검증 8가지

블로그로 돌아가기

토스페이먼츠·포트원 V2·Stripe 공식 문서를 기준으로 AI가 작성한 결제 코드에서 먼저 확인할 8가지 항목을 정리합니다. 세 회사의 웹훅 검증 방식 비교, Next.js 라우트 핸들러 예제, 프롬프트 점검 문장과 grep 명령을 함께 제공합니다.

AX Specialist 김지우결제 보안웹훅 검증토스페이먼츠포트원Stripe바이브코딩Next.js

Claude Code나 Cursor로 만든 결제 코드는 화면에서 결제가 끝나도 서버 검증이 비어 있는 경우가 많습니다. 이 글은 토스페이먼츠, 포트원 V2, Stripe 공식 문서(2026-10-17 확인)를 기준으로 먼저 확인할 8가지 항목과 프롬프트 점검 문장, grep 명령을 정리합니다.

핵심 요약

  • 결제 금액은 브라우저가 보낸 값으로 승인하지 않고 서버 저장 금액과 대조합니다.
  • 웹훅 검증 방식은 세 회사가 모두 다릅니다. 토스페이먼츠 일반 결제 웹훅에는 서명이 없고, 포트원 V2는 Standard Webhooks 서명을, Stripe는 Stripe-Signature 서명을 씁니다.
  • 서명 검증에는 파싱 전 원문이 필요하므로 라우트 핸들러에서 await req.text()로 받습니다.
  • 포트원 Webhook.verify에는 Headers 객체 대신 Object.fromEntries(req.headers)를 넘깁니다.
  • 정기결제 증액과 유료 전환은 그 전 30일 이내에 소비자 동의를 받습니다(전자상거래법 제13조제6항, 시행령 제20조의2).

8가지 점검 항목

#점검 항목토스페이먼츠포트원 V2Stripe
1금액 대조successUrl amount를 저장 금액과 비교 후 승인getPayment의 amount.total을 주문과 비교웹훅 객체 금액을 주문과 비교
2시크릿 키 위치test_sk_·live_sk_는 서버 전용API Secret은 서버 전용, storeId·channelKey는 브라우저용sk_·rk_는 서버 전용, pk_만 클라이언트
3웹훅 검증일반 결제는 서명 없음, 조회 API로 재확인webhook-signature 검증Stripe-Signature 검증
4멱등성Idempotency-Key(POST 전용, 15일)고객사가 정하는 paymentIdIdempotency-Key 헤더
5테스트와 라이브 분리키 접두어 test_·live_콘솔 설정 모드별 웹훅 URL·시크릿sk_test_·sk_live_
6비동기 결제가상계좌는 DEPOSIT_CALLBACK 뒤 완료VirtualAccountIssued와 Paid 구분이번 확인 범위 밖
7빌링키 결제서버에서 /v1/billing/{billingKey} 호출서버에서 payWithBillingKey 호출이번 확인 범위 밖
8법정 고지사업자 정보·청약철회·정기결제 증액 동의같음같음

1. 서버 저장 금액으로 승인

토스페이먼츠 LLM Quick Reference §2.2에 따르면 requestPayment는 브라우저에서 실행되므로 콘솔에서 금액을 바꿀 수 있고, 서버가 그 값을 그대로 승인 API에 넘기면 바뀐 금액으로 결제가 끝납니다. 주문 생성 시 서버 DB에 금액을 저장하고 successUrl 값과 비교한 뒤에만 승인합니다. 포트원 웹훅 예제도 조회 응답의 amount.total이 주문 금액과 다르면 위변조 시도로 봅니다.

2. 시크릿 키의 서버 전용 배치

NEXT_PUBLIC_* 같은 공개 접두어 변수는 빌드 시 브라우저 번들에 들어갑니다. 브라우저 코드에서 api.tosspayments.com을 부르면 Authorization 헤더가 개발자 도구에 보입니다. 브라우저는 자기 서버 라우트만 부르고, 결제사 API는 그 라우트만 호출합니다.

3. 회사별 웹훅 검증 방식

토스페이먼츠 문서 §8은 일반 결제 웹훅에 tosspayments-webhook-signature 검증을 만드는 것을 LLM 실수로 꼽습니다. 이 헤더는 지급대행 웹훅에만 붙습니다.

구분토스페이먼츠 일반 결제포트원 V2Stripe
서명 헤더없음webhook-id·webhook-timestamp·webhook-signatureStripe-Signature(t=, v1=)
서명 대상해당 없음id.timestamp.본문, HMAC SHA-256timestamp.본문, HMAC SHA-256
검증 방법paymentKey로 결제 조회 API를 다시 호출서버 SDK PortOne.Webhook.verifystripe.webhooks.constructEvent
시각 허용 범위해당 없음SDK 0.19.0 소스 기준 5분공식 라이브러리 기본값 5분
재전송10초 안에 200이 없으면 최대 7회최대 5회, 지수 백오프라이브 모드 최대 3일
중복 판별 기준조회한 결제 상태webhook-id(명세상 재전송에도 유지)처리한 event.id 기록

포트원 SDK는 헤더 객체의 속성을 순회해 헤더를 찾으므로 Headers 인스턴스를 그대로 넘기면 MISSING_REQUIRED_HEADERS가 납니다. 공식 문서의 Express 예제는 PortOneClient에 문자열을 넘기지만 npm 0.19.0 타입 정의는 { secret } 객체를 받습니다(2026-10-17 확인).

// app/api/webhooks/portone/route.ts
import * as PortOne from "@portone/server-sdk";

const portone = PortOne.PortOneClient({ secret: process.env.PORTONE_API_SECRET! });

export async function POST(req: Request) {
  const body = await req.text(); // 검증 전에 JSON으로 파싱하지 않는다
  let webhook;
  try {
    webhook = await PortOne.Webhook.verify(
      process.env.PORTONE_WEBHOOK_SECRET!,
      body,
      Object.fromEntries(req.headers),
    );
  } catch (e) {
    if (e instanceof PortOne.Webhook.WebhookVerificationError) {
      return new Response(null, { status: 400 });
    }
    throw e;
  }
  if ("data" in webhook && "paymentId" in webhook.data) {
    const payment = await portone.payment.getPayment({ paymentId: webhook.data.paymentId });
    if (payment.status === "PAID") {
      const order = await findOrder(payment.id); // 내 DB 조회
      if (order && order.amount === payment.amount.total) {
        await markPaid(order.id); // 이미 처리된 주문이면 아무것도 바꾸지 않는다
      }
    }
  }
  return new Response(null, { status: 200 });
}

Stripe 문서는 프레임워크가 본문을 조금이라도 바꾸면 검증이 실패한다고 경고합니다.

// app/api/webhooks/stripe/route.ts
import { Stripe } from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const sig = req.headers.get("stripe-signature") ?? "";
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response(null, { status: 400 });
  }
  if (!(await isProcessed(event.id))) await handle(event); // 처리 뒤 event.id 기록
  return new Response(null, { status: 200 });
}

토스페이먼츠 일반 결제 웹훅은 본문을 알림으로만 쓰고 결제 조회 API(GET /v1/payments/{paymentKey}) 응답으로 처리합니다.

4. 멱등성과 중복 웹훅 처리

토스페이먼츠 Idempotency-Key는 POST에만 적용되고 15일간 유효하며, 같은 키로 다시 보내면 첫 응답이 돌아옵니다. 문서는 에러를 확인하지 않고 키만 바꿔 재시도하는 것을 위험하다고 적고, 승인 API 실패 시 조회 API로 상태를 먼저 확인하라고 합니다. 웹훅 쪽은 위 표의 중복 판별 기준을 쓰고, 주문 상태 갱신은 이미 결제 완료인 주문을 다시 바꾸지 않는 조건부 갱신으로 작성합니다.

5. 테스트 키와 라이브 키 분리

토스페이먼츠 문서는 두 키를 한 환경 변수로 관리하는 것을 실수로 꼽습니다. 포트원은 실연동·테스트 모드마다 웹훅 시크릿을 따로 발급합니다. 서버 시작 시 키 접두어와 실행 환경을 대조합니다.

6. 가상계좌 입금 확인

토스페이먼츠 가상계좌는 승인 응답이 WAITING_FOR_DEPOSIT이고, 입금 뒤 DEPOSIT_CALLBACK과 조회로 DONE을 확인합니다. 포트원도 Transaction.VirtualAccountIssued와 Transaction.Paid를 따로 보냅니다.

7. 서버 전용 빌링키 결제

토스페이먼츠 빌링키 결제는 successUrl과 승인 API를 거치지 않는 서버 전용 호출입니다. API 레퍼런스는 customerKey에 이메일·회원 ID 같은 유추 가능한 값을 쓰지 말고 UUID 같은 무작위 값을 쓰라고 합니다.

8. 사업자 정보·청약철회·정기결제 고지

전자상거래법 제13조제1항은 상호·대표자 성명, 주소·전화번호·전자우편주소, 통신판매업 신고번호의 표시를 정합니다. 제17조제1항은 계약내용 서면을 받은 날부터 7일 안의 청약철회를 정하고, 제2항제5호는 디지털콘텐츠 제공이 시작된 경우를 예외로 두되 제6항의 표시·시험 사용 조치를 요구합니다. 제13조제6항과 시행령 제20조의2는 정기결제 증액이나 유료 전환 전 30일 안에 소비자 동의를 받고 해지 방법을 고지하도록 합니다. 제21조의2제1항제4호는 해지 절차를 가입보다 복잡하게 만드는 것을 금지합니다. 적용 여부는 공정거래위원회나 법률 전문가에게 확인합니다.

프롬프트에 넣을 점검 문장

아래 문장을 작업 지시에 그대로 붙입니다.

  • 결제 금액은 서버 DB의 주문 금액만 쓰고, 클라이언트 값과 다르면 승인하지 않는다.
  • 시크릿 키를 읽는 코드는 app/api 아래 route.ts에만 둔다. 공개 접두어 환경 변수에 키를 넣지 않는다.
  • 웹훅 라우트는 await req.text()로 원문을 받고 서명 검증 뒤에 파싱한다.
  • 토스페이먼츠 일반 결제 웹훅은 서명 검증 대신 결제 조회 API로 재확인한다.
  • 문서에 없는 필드나 헤더를 만들지 않고, 근거가 된 공식 문서 URL을 함께 적는다.
  • 주문 상태 갱신은 같은 웹훅이 두 번 와도 결과가 같게 작성한다.

토스페이먼츠(@tosspayments/integration-guide-mcp)와 포트원(npx @portone/cli@latest setup)은 공식 문서를 AI 도구에 연결하는 MCP 서버와 플러그인을 제공합니다(토스, 포트원).

grep 점검 명령

# 1. 공개 접두어 변수의 시크릿
grep -rnE '(NEXT_PUBLIC|VITE|REACT_APP|EXPO_PUBLIC)_[A-Z_]*(SECRET|SK)' --include='*.ts' --include='*.tsx' --include='.env*' .
# 2. 코드에 박힌 키
grep -rnE '(test|live)_sk_|sk_(live|test)_|whsec_' --exclude-dir=node_modules .
# 3. 클라이언트 컴포넌트의 결제사 API 호출
grep -rln 'api\.tosspayments\.com\|api\.portone\.io' app src --include='*.tsx'
# 4. 웹훅 라우트의 파싱된 본문
grep -rnE '(req|request)\.json\(' app/api/webhooks
# 5. 토스 일반 결제 웹훅의 지어낸 서명 검증
grep -rn 'tosspayments-webhook-signature' app/api
# 6. 지원 중단된 Edge 런타임
grep -rn "runtime = 'edge'\|runtime = \"edge\"" app

결과가 나온 줄을 위 항목과 대조합니다.

함께 읽을 글

자주 묻는 질문

MCP 서버를 붙이면 AI가 짠 결제 코드는 안전한가요?

MCP 서버는 AI가 최신 공식 문서를 찾게 해 줍니다. 금액 대조, 키 위치, 멱등 처리는 프로젝트 구조에 따라 달라지므로 8개 항목과 grep 점검은 리뷰에서 따로 수행합니다.

코드 리뷰는 무엇부터 보나요?

웹훅 라우트와 승인 라우트부터 봅니다. 원문 본문으로 서명을 검증하는지, 조회 응답 금액을 주문 금액과 비교하는지, 같은 이벤트가 다시 와도 상태가 그대로인지 확인합니다.

테스트로 어떻게 잡나요?

금액을 바꾼 successUrl 요청, 서명이 틀린 웹훅, 같은 웹훅 두 번 전송, 가상계좌 발급 이벤트를 테스트 케이스로 만듭니다.

출처

김지우 (Tristan Kim)

코텍시스 창업자로 AX 컨설팅과 기업 AI 교육, AI 에이전트 개발을 직접 수행합니다.

코텍시스 AI 인사이트 최신 논문 리뷰

AI 서비스 구축

MVP 설계부터 AI 내장 개발, 백오피스까지. 아이디어를 빠르고 견고하게 서비스로 만듭니다.