포트원 연동 가이드: V2 웹훅 서명 검증과 PG 계약 순서
개발

포트원 연동 가이드: V2 웹훅 서명 검증과 PG 계약 순서

블로그로 돌아가기

포트원 가입부터 전자결제 신청, PG 계약, 실연동 채널 추가, V2 결제 조회 검증까지의 순서를 공식 문서 기준(2026-10-17 확인)으로 정리합니다. Next.js App Router 라우트 핸들러에서 @portone/server-sdk의 Webhook.verify로 Standard Webhooks 서명을 검증하는 코드를 함께 제공합니다.

AX Specialist 김지우포트원PortOne V2결제 연동웹훅Next.jsPG 계약

포트원(PortOne)은 여러 PG사의 결제창과 API를 하나의 SDK로 호출하게 해 주는 연동 계층이고, 결제대행 계약은 PG사와 따로 맺습니다. 이 글은 포트원 개발자센터 문서(2026-10-17 확인)를 기준으로 가입부터 PG 계약, 채널 추가, V2 결제, Standard Webhooks 서명 검증까지의 순서를 Next.js App Router 코드와 함께 정리합니다.

핵심 요약

  • 포트원 콘솔 가입은 별도 계약 없이 할 수 있고, 테스트 채널로 전자결제 신청 전에 개발을 시작할 수 있습니다.
  • 전자결제 신청에는 사업자등록증이 필요합니다. 문서는 사업자등록증이 없으면 신청할 수 없다고 명시합니다.
  • 실연동 채널은 PG사와 계약한 뒤 PG 가맹점 관리자에서 받은 상점아이디(MID)와 키를 포트원 콘솔에 입력해 만듭니다.
  • V2 결제 완료는 서버가 GET https://api.portone.io/payments/{paymentId}로 조회해 amount.total과 주문 금액을 대조한 뒤 처리합니다.
  • V2 웹훅은 Standard Webhooks 서명을 붙이며, @portone/server-sdk의 Webhook.verify에 원문 문자열 본문을 넘겨 검증합니다.

가입부터 실연동까지의 순서

포트원 문서의 연동 준비 가이드와 전자결제 신청 가이드를 합치면 순서는 아래와 같습니다.

순서할 일위치받는 값
1콘솔 회원가입, 이메일 인증admin.portone.io콘솔 계정
2테스트 채널 추가결제 연동 → 연동 관리 → 채널 관리(테스트 모드)테스트 채널 키
3비즈니스 인증전자결제 신청 화면사업자등록증 사본, 담당자, 서비스 URL 제출
4PG사·결제수단 선택추천 패키지 또는 직접 선택신청 내역
5PG 계약 후 실연동 채널 추가PG 가맹점 관리자 → 포트원 채널 관리(실연동 모드)PG의 MID와 PG별 키
6연동 정보 발급연동 관리 → 식별코드·API Keys, 결제알림(Webhook) 관리Store ID, V2 API Secret, 웹훅 시크릿

3단계의 서비스 URL은 운영 중인 웹이나 앱 주소여야 하며, 신청 가이드는 PG 계약을 위한 서비스 기본 구성 요건을 먼저 반영하라고 안내합니다. 5단계에서 콘솔에 입력할 값은 PG사마다 다릅니다. 예를 들어 나이스정보통신은 MID·Key·결제취소 비밀번호를, 스마트로는 MID·Merchant Key·결제취소 비밀번호를 입력합니다. 이미 계약한 PG가 있으면 신청 화면에서 고객센터로 연결됩니다.

식별값과 비밀값 관리

V2 연동에는 공개해도 되는 값과 서버에만 두는 값이 섞여 있습니다.

값발급 위치사용처환경변수(예시)
Store ID연동 관리 화면브라우저 requestPayment의 storeIdNEXT_PUBLIC_PORTONE_STORE_ID
채널 키채널 관리(테스트·실연동 각각)브라우저 channelKeyNEXT_PUBLIC_PORTONE_CHANNEL_KEY
V2 API Secret식별코드·API Keys → V2 API서버 REST 호출PORTONE_API_SECRET
웹훅 시크릿결제알림(Webhook) 관리(테스트·실연동 각각)서버 웹훅 검증PORTONE_WEBHOOK_SECRET

V2 API Secret은 Owner, Admin 계정만 발급할 수 있고 발급 직후에만 값을 확인할 수 있습니다. 문서는 이 값을 고객사 서버에서만 쓰고 브라우저에 노출하지 말라고 적고 있습니다. 웹훅 시크릿은 테스트와 실연동 환경별로 동시에 최대 2개까지 발급할 수 있어, 새 시크릿을 발급해 서버에 반영한 뒤 기존 시크릿을 만료시키는 방식으로 무중단 교체를 할 수 있습니다.

V2 결제 요청과 서버 검증

브라우저는 @portone/browser-sdk를 설치하고 @portone/browser-sdk/v2에서 가져온 PortOne.requestPayment()로 결제창을 엽니다. 결제 금액은 주문 생성 시 서버가 정해 저장해 둔 값을 씁니다.

"use client";
import * as PortOne from "@portone/browser-sdk/v2";

export async function pay(order: { paymentId: string; orderName: string; amount: number }) {
  await PortOne.requestPayment({
    storeId: process.env.NEXT_PUBLIC_PORTONE_STORE_ID!,
    channelKey: process.env.NEXT_PUBLIC_PORTONE_CHANNEL_KEY!,
    paymentId: order.paymentId, // 서버가 주문 생성 때 만든 값
    orderName: order.orderName,
    totalAmount: order.amount,
    currency: "CURRENCY_KRW",
    payMethod: "CARD",
    redirectUrl: `${window.location.origin}/payments/redirect`,
    forceRedirect: true,
  });
}

결제 연동 문서는 모바일을 지원하려면 redirectUrl을 반드시 지정하라고 안내합니다. 리다이렉트 쿼리에는 paymentId가 항상 붙고, 실패 시 code, message, pgCode, pgMessage가 더 붙습니다. 같은 paymentId로 여러 번 결제를 시도할 수 있지만 최종 성공은 한 번뿐이므로 paymentId가 중복 결제 방지의 기준이 됩니다.

리다이렉트 페이지는 paymentId만 서버로 보냅니다. 서버는 결제 조회 API로 상태와 금액을 확인합니다. 문서의 REST 예시는 Authorization: PortOne {API Secret} 헤더를 쓰고, 서버 SDK는 같은 호출을 PortOneClient({ secret })로 감쌉니다.

// lib/portone.ts (서버 전용)
import { PortOneClient } from "@portone/server-sdk";
import { getOrderByPaymentId, saveStatus } from "@/lib/orders"; // 프로젝트의 주문 저장소

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

export async function syncPayment(paymentId: string) {
  const order = await getOrderByPaymentId(paymentId);
  if (!order) return "UNKNOWN_ORDER";
  const payment: any = await portone.payment.getPayment({ paymentId }).catch(() => null);
  if (!payment) return "NOT_FOUND"; // 조회 실패(GetPaymentError)는 미결제로 둔다
  if (payment.amount?.total !== order.amount) {
    await saveStatus(paymentId, "AMOUNT_MISMATCH"); // 위변조 의심, 사람이 확인
    return "AMOUNT_MISMATCH";
  }
  await saveStatus(paymentId, payment.status); // PAID일 때만 권한 부여
  return payment.status;
}

// app/api/payments/complete/route.ts
import { syncPayment } from "@/lib/portone";
export async function POST(req: Request) {
  const { paymentId } = await req.json();
  return Response.json({ status: await syncPayment(paymentId) });
}

조회 결과의 status가 VIRTUAL_ACCOUNT_ISSUED이면 가상계좌만 발급된 상태이므로 완료 처리하지 않고, PAID일 때 권한을 부여합니다. saveStatus는 이미 PAID인 주문을 같은 값으로 다시 써도 결과가 같도록 만듭니다. 리다이렉트 경로와 웹훅 경로가 같은 syncPayment를 부르기 때문입니다. 서버 SDK는 Node.js v20 이상이 필요합니다.

V2 웹훅 서명 검증

웹훅 URL과 시크릿은 콘솔의 결제알림(Webhook) 관리 탭에서 웹훅 버전 "결제모듈 V2"를 고른 뒤 설정합니다. 최신 웹훅 버전 2024-04-25는 본문이 Standard Webhooks 형식이고 Content Type은 application/json만 지원합니다.

포트원 콘솔 결제알림(Webhook) 관리 화면
type의미처리
Transaction.Paid결제(예약 결제 포함) 승인syncPayment 후 PAID면 권한 부여
Transaction.VirtualAccountIssued가상계좌 발급입금 안내만, 완료 처리 안 함
Transaction.Cancelled, Transaction.PartialCancelled전체·부분 취소syncPayment로 상태와 권한 갱신
Transaction.Failed결제 실패주문 상태만 갱신
알지 못하는 type문서 기준 예고 없이 추가될 수 있음오류 없이 200으로 무시

서버 SDK의 verify 함수는 헤더를 일반 객체(Record<string, string>)로 받고 키를 Object.entries로 찾습니다. Next.js 라우트 핸들러의 req.headers는 Headers 인스턴스이므로 Object.fromEntries(req.headers)로 바꿔 넘깁니다. 그대로 넘기면 필수 헤더(webhook-id, webhook-timestamp, webhook-signature)를 찾지 못해 검증이 실패합니다. 본문은 JSON으로 파싱하지 않고 문자열 그대로 넘깁니다.

// app/api/webhooks/portone/route.ts
import { Webhook } from "@portone/server-sdk";
import { syncPayment } from "@/lib/portone";

export async function POST(req: Request) {
  const raw = await req.text();
  let webhook;
  try {
    webhook = await Webhook.verify(
      process.env.PORTONE_WEBHOOK_SECRET!,
      raw,
      Object.fromEntries(req.headers),
    );
  } catch (e) {
    if (e instanceof Webhook.WebhookVerificationError)
      return new Response("invalid signature", { status: 400 });
    throw e;
  }
  if (Webhook.isUnrecognizedWebhook(webhook)) return new Response("ignored", { status: 200 });
  if ("paymentId" in webhook.data) await syncPayment(webhook.data.paymentId);
  return new Response("ok", { status: 200 });
}

서명이 맞아도 웹훅 본문의 상태를 그대로 믿지 않고 syncPayment로 조회합니다. 웹훅 문서의 예제도 서명 검증 뒤 결제 단건 조회와 금액 대조를 거칩니다. SDK 소스(webhook.ts)는 webhook-timestamp가 현재 시각과 5분 넘게 차이 나면 TIMESTAMP_TOO_OLD 또는 TIMESTAMP_TOO_NEW로 거부하므로 서버 시계가 맞는지도 확인합니다.

운영 조건은 다음과 같습니다. 연결과 읽기 타임아웃은 각각 30초입니다. 실패하면 최대 5회 재전송하며 대기 시간은 0·1·4·16·64·256분에 지터가 더해집니다. 재전송되어도 본문의 timestamp는 같습니다. 포트원 서버는 웹훅 응답을 기다리지 않고 브라우저를 리다이렉트하므로 둘의 도착 순서는 보장되지 않습니다. 순서 보장이 필요하면 storeId를 적어 tech.support@portone.io에 요청할 수 있습니다. IP 필터를 쓰는 경우 V2 웹훅 IP는 52.78.5.241입니다.

AI 도구로 연동할 때

포트원은 Claude Code 플러그인 AI Codemate를 제공합니다. Node.js 22.6.0 이상에서 npx @portone/cli@latest setup을 실행하면 플러그인이 등록되고, Claude Code에서 /portone-integration:start로 대화형 연동 코드 생성을 시작합니다. MCP 서버는 npx -y @portone/mcp-server@latest로 등록하며, 문서 검색에 더해 콘솔 로그인 후 채널 목록 조회·테스트 채널 추가·결제 내역 조회를 수행할 수 있습니다. 문서는 MCP로 조회한 API 응답이 제3자 AI 서비스로 전달되어 그 서비스 정책에 따라 저장될 수 있다고 경고합니다. 운영 계정에서는 이 점을 검토한 뒤 씁니다. PG 선택이나 기존 서비스와의 권한 연동 설계는 서비스 구축 문의하기로 요청할 수 있습니다.

함께 읽을 글

자주 묻는 질문

포트원만 가입하면 결제를 받을 수 있나요?

받을 수 없습니다. 포트원 가입과 테스트 채널로는 개발과 테스트만 할 수 있습니다. 실제 결제는 전자결제 신청을 거쳐 PG사와 계약하고, PG에서 받은 MID와 키로 실연동 채널을 추가해야 시작됩니다. 전자결제 신청에는 사업자등록증이 필요합니다.

V1과 V2는 무엇이 다른가요?

포트원 문서는 신규 프로젝트에 V2를 권장하고 V1은 레거시 유지보수 용도로 지원한다고 적고 있습니다. V1은 고객사 식별코드와 REST API Key·Secret을 쓰고, V2는 Store ID, 채널 키, 별도로 발급하는 V2 API Secret을 씁니다. V1 웹훅 본문은 imp_uid, merchant_uid, status이고 단건 조회 API로 검증합니다. V2 웹훅은 Standard Webhooks 서명을 붙이며 서버 SDK로 검증할 수 있습니다. 문서를 볼 때는 URL에 ?v=v2를 붙여 버전을 고정합니다.

수수료는 어디서 정해지나요?

결제 수수료는 계약한 PG사와의 계약에서 정해집니다. 포트원 개발자센터와 포트원 웹사이트에서 공개 요금표는 찾지 못했습니다(공식 문서에 기재 없음, 2026-10-17 확인). 포트원 이용 조건은 포트원 고객센터에, 결제 수수료는 신청한 PG사에 확인합니다.

출처

포트원 공식 문서, 모두 2026-10-17 접속.

김지우 (Tristan Kim)

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

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

AI 서비스 구축

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