포트원 결제 연동 가이드 — 가입부터 결제 테스트까지 한 번에 따라하기 (2026)

포트원 결제 연동 가이드 — 가입부터 결제 테스트까지 한 번에 따라하기 (2026)

포트원 결제창 연동 테스트 화면

웹 서비스에 결제 기능을 넣어야 하는데, PG사 연동이 복잡해 보여서 막막하신가요? 포트원(PortOne)을 사용하면 한 번의 연동으로 토스페이먼츠, KG이니시스, NHN KCP 등 여러 PG사의 결제를 한꺼번에 붙일 수 있습니다.

이 글은 “위에서 아래로 따라하면 결제가 동작한다”를 목표로 작성했습니다. 코드는 복사해서 바로 사용할 수 있게 준비했고, 각 단계에서 어떤 값을 어디서 찾아야 하는지도 정확히 표시했습니다.

📌 이 가이드는 포트원 V2 + 토스페이먼츠 조합을 기준으로 작성했습니다. 다른 PG사도 기본 흐름은 동일합니다.

전체 흐름 미리보기

Step 1. 포트원 회원가입
Step 2. 채널 설정 (PG사 연결)
Step 3. 연동 정보 확인 (storeId, channelKey)
Step 4. 프론트엔드 — SDK 설치 + 결제 요청
Step 5. 서버 — 결제 검증
Step 6. 테스트 결제 실행
Step 7. 실 운영 전환

Step 1. 포트원 회원가입

별도 계약 없이 무료로 가입할 수 있습니다. 테스트 결제도 무료입니다.

  1. 포트원 관리자 콘솔에 접속
  2. 이메일로 회원가입 (또는 Google/GitHub 로그인)
  3. 상점 이름 입력 후 상점 생성

가입 후 자동으로 테스트 환경이 활성화됩니다. 실제 돈이 빠지지 않으니 안심하고 테스트하세요.

Step 2. 채널 설정 (PG사 연결)

포트원 관리자 콘솔에서 사용할 PG사를 선택합니다.

  1. 관리자 콘솔 → 결제 연동 → 테스트 연동 관리
  2. 채널 추가 클릭
  3. PG사 선택 (이 가이드에서는 토스페이먼츠)
  4. 결제 모듈: V2 선택
  5. 저장 클릭

테스트 채널은 포트원이 미리 준비한 테스트용 MID(가맹점 번호)가 자동으로 들어가므로, PG사에 별도 가입할 필요 없이 바로 테스트할 수 있습니다.

Step 3. 연동 정보 확인

코드에 넣어야 할 두 가지 값을 확인합니다.

  1. 관리자 콘솔 → 결제 연동 → 연동 정보
  2. 우측 상단에서 상점 아이디(storeId) 확인 → 복사
  3. 연동한 채널의 채널 키(channelKey) 확인 → 복사
  4. 하단 V2 API Secret → 발급 → 복사 (서버 검증에 필요)

⚠️ API Secret은 절대 프론트엔드(JavaScript)에 넣지 마세요. 서버 측 코드에서만 사용해야 합니다. 외부 노출 시 결제 조작이 가능합니다.

지금부터 아래 3개의 값을 코드에 사용합니다. 본인 값으로 교체해서 넣으세요:

storeId:     "store-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
channelKey:  "channel-key-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
API Secret:  "portone-api-secret-xxxxxxxx..." (서버에서만 사용)

Step 4. 프론트엔드 — 결제 요청

4-1. SDK 설치

방법 A: npm 설치 (React, Vue 등 프레임워크)

npm install @portone/browser-sdk

방법 B: CDN 스크립트 (HTML에서 바로 사용)

<script src="https://cdn.portone.io/v2/browser-sdk.js"></script>

4-2. 결제 요청 코드

아래 코드를 복사해서 본인의 storeId, channelKey만 교체하면 바로 결제창이 뜹니다.

방법 A: HTML + 순수 JavaScript (가장 간단)

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <title>결제 테스트</title>
  <script src="https://cdn.portone.io/v2/browser-sdk.js"></script>
</head>
<body>
  <h1>결제 테스트 페이지</h1>
  <button id="payment-button">1,000원 결제하기</button>

  <script>
    const button = document.getElementById("payment-button");

    button.addEventListener("click", async () => {
      const response = await PortOne.requestPayment({
        // ✅ 여기에 본인 값을 넣으세요
        storeId: "store-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
        channelKey: "channel-key-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",

        // 결제 정보
        paymentId: "payment-" + crypto.randomUUID(),  // 주문 고유 번호 (중복 불가)
        orderName: "테스트 상품",
        totalAmount: 1000,
        currency: "CURRENCY_KRW",
        payMethod: "CARD",

        // 구매자 정보 (선택)
        customer: {
          fullName: "홍길동",
          email: "[email protected]",
          phoneNumber: "01012345678",
        },

        // 모바일 결제 완료 후 돌아올 URL
        redirectUrl: window.location.href,
      });

      if (response.code) {
        // 결제 실패 또는 사용자 취소
        alert("결제 실패: " + response.message);
        return;
      }

      // 결제 성공 → 서버에 검증 요청
      alert("결제 완료! paymentId: " + response.paymentId);

      // TODO: 서버에 paymentId를 보내서 결제 검증
      // fetch("/api/payment/verify", {
      //   method: "POST",
      //   headers: { "Content-Type": "application/json" },
      //   body: JSON.stringify({ paymentId: response.paymentId }),
      // });
    });
  </script>
</body>
</html>

방법 B: React

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

function PaymentButton() {
  const handlePayment = async () => {
    const response = await PortOne.requestPayment({
      storeId: "store-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
      channelKey: "channel-key-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
      paymentId: `payment-${crypto.randomUUID()}`,
      orderName: "테스트 상품",
      totalAmount: 1000,
      currency: "CURRENCY_KRW",
      payMethod: "CARD",
    });

    if (response.code) {
      alert("결제 실패: " + response.message);
      return;
    }

    // 서버에 검증 요청
    const verifyResponse = await fetch("/api/payment/verify", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ paymentId: response.paymentId }),
    });

    const result = await verifyResponse.json();
    if (result.success) {
      alert("결제가 완료되었습니다!");
    }
  };

  return <button onClick={handlePayment}>1,000원 결제하기</button>;
}

4-3. paymentId 생성 규칙

paymentId주문마다 고유해야 합니다. 같은 paymentId로 두 번 결제할 수 없습니다.

// 방법 1: UUID (권장)
const paymentId = "payment-" + crypto.randomUUID();
// → "payment-a1b2c3d4-e5f6-7890-abcd-ef1234567890"

// 방법 2: 타임스탬프 + 랜덤
const paymentId = "order-" + Date.now() + "-" + Math.random().toString(36).substr(2, 9);
// → "order-1708234567890-k3m9x2p1q"

💡 토스페이먼츠를 사용하는 경우 paymentId에 영문 대소문자, 숫자, -, _만 허용되며, 6자 이상 64자 이하입니다.

4-4. 결제 수단 코드 (payMethod)

결제 수단 payMethod 값
신용/체크카드 CARD
계좌이체 TRANSFER
가상계좌 VIRTUAL_ACCOUNT
휴대폰 소액결제 MOBILE
간편결제 (카카오페이 등) EASY_PAY
상품권 GIFT_CERTIFICATE

Step 5. 서버 — 결제 검증 (필수!)

🚨 결제 검증은 반드시 서버에서 해야 합니다. 프론트엔드의 결제 응답은 조작될 수 있습니다. 서버에서 포트원 API로 실제 결제 상태를 조회해서 금액과 상태가 맞는지 확인해야 합니다.

검증 흐름

1. 프론트엔드 → 서버: "paymentId: xxx 결제됐어요"
2. 서버 → 포트원 API: "paymentId: xxx 진짜 결제됐나요?"
3. 포트원 API → 서버: "네, 1000원 카드결제 완료입니다"
4. 서버: 금액이 내가 기대한 1000원과 일치하는지 확인
5. 서버 → 프론트엔드: "검증 완료, 주문 처리합니다"

5-1. Node.js (Express) 서버 검증

// 포트원 서버 SDK 설치
// npm install @portone/server-sdk
// server.js
import express from "express";
import PortOne from "@portone/server-sdk";

const app = express();
app.use(express.json());

// ✅ 여기에 포트원 V2 API Secret을 넣으세요
const portone = PortOne.Portal({
  apiSecret: "portone-api-secret-xxxxxxxx...",
});

app.post("/api/payment/verify", async (req, res) => {
  const { paymentId } = req.body;

  try {
    // 1. 포트원에서 실제 결제 정보 조회
    const payment = await portone.payment.getPayment({ paymentId });

    // 2. 결제 상태 확인
    if (payment.status !== "PAID") {
      return res.json({ success: false, message: "결제가 완료되지 않았습니다." });
    }

    // 3. 금액 검증 — DB에서 주문 정보를 조회해서 비교
    const order = await getOrderFromDB(paymentId);  // 본인의 DB 조회 함수
    if (payment.amount.total !== order.expectedAmount) {
      return res.json({ success: false, message: "결제 금액이 일치하지 않습니다." });
    }

    // 4. 검증 통과 → 주문 상태 업데이트
    await updateOrderStatus(paymentId, "PAID");

    return res.json({ success: true, message: "결제가 완료되었습니다." });
  } catch (error) {
    console.error("결제 검증 에러:", error);
    return res.json({ success: false, message: "검증 중 오류가 발생했습니다." });
  }
});

app.listen(3000, () => console.log("서버 실행 중: http://localhost:3000"));

5-2. Python (FastAPI) 서버 검증

# 포트원 서버 SDK 설치
# pip install portone-server-sdk
# server.py
from fastapi import FastAPI, Request
import portone_server_sdk as portone

app = FastAPI()

# ✅ 여기에 포트원 V2 API Secret을 넣으세요
client = portone.Portal(api_secret="portone-api-secret-xxxxxxxx...")

@app.post("/api/payment/verify")
async def verify_payment(request: Request):
    body = await request.json()
    payment_id = body["paymentId"]

    try:
        # 1. 포트원에서 실제 결제 정보 조회
        payment = client.payment.get_payment(payment_id=payment_id)

        # 2. 결제 상태 확인
        if payment.status != "PAID":
            return {"success": False, "message": "결제가 완료되지 않았습니다."}

        # 3. 금액 검증
        order = get_order_from_db(payment_id)  # 본인의 DB 조회 함수
        if payment.amount.total != order.expected_amount:
            return {"success": False, "message": "결제 금액이 일치하지 않습니다."}

        # 4. 주문 상태 업데이트
        update_order_status(payment_id, "PAID")

        return {"success": True, "message": "결제가 완료되었습니다."}
    except Exception as e:
        return {"success": False, "message": str(e)}

5-3. REST API 직접 호출 (SDK 없이)

서버 SDK를 쓰지 않고 직접 REST API를 호출할 수도 있습니다:

# 결제 단건 조회
curl -X GET "https://api.portone.io/v2/payments/{paymentId}" \
  -H "Authorization: PortOne {API_SECRET}"

응답 예시:

{
  "id": "payment-a1b2c3d4...",
  "status": "PAID",
  "amount": {
    "total": 1000,
    "currency": "KRW"
  },
  "method": {
    "type": "CARD",
    "card": {
      "name": "신한카드",
      "number": "4567-12**-****-0000"
    }
  }
}

Step 6. 테스트 결제 실행

  1. Step 4의 HTML 파일을 브라우저에서 열기 (로컬 서버 또는 파일로)
  2. “1,000원 결제하기” 버튼 클릭
  3. 토스페이먼츠 테스트 결제창이 뜸
  4. 테스트 카드 정보 입력:

    카드 번호: 테스트 결제창에 표시되는 테스트 카드 사용

    유효기간: 아무 미래 날짜

    비밀번호: 아무 2자리

  5. 결제 완료 확인
  6. 포트원 관리자 콘솔 → 결제 탭에서 결제 내역 확인

✅ 테스트 환경에서는 실제 결제가 발생하지 않습니다. 마음껏 테스트하세요.

Step 7. 실 운영 전환

테스트가 완료되면 실제 결제를 받을 수 있도록 전환합니다.

  1. PG사 가맹점 계약 — 토스페이먼츠(또는 원하는 PG사)에 직접 가입하여 실 MID 발급받기. 포트원을 통해 계약 대행도 가능합니다.
  2. 관리자 콘솔 → 결제 연동 → 실 연동 관리에서 발급받은 실 MID/API 키 입력
  3. 코드 변경 사항: 없음! storeIdchannelKey는 테스트/실 운영에서 동일합니다. 채널 설정만 실 연동으로 바꾸면 됩니다.

웹훅 연동 (선택이지만 권장)

웹훅은 결제 상태가 변할 때 포트원이 서버에 자동으로 알려주는 기능입니다. 가상계좌 입금 확인, 결제 취소 통지 등에 필수적입니다.

웹훅 설정

  1. 관리자 콘솔 → 결제 연동 → 연동 정보 → 웹훅 관리
  2. 웹훅 수신 URL 입력: https://yourdomain.com/api/webhook/portone
  3. 저장

웹훅 수신 서버 (Node.js 예시)

app.post("/api/webhook/portone", async (req, res) => {
  const { paymentId } = req.body;

  // 1. 포트원 API로 실제 결제 상태 조회 (웹훅 내용도 검증 필요)
  const payment = await portone.payment.getPayment({ paymentId });

  // 2. 결제 상태에 따라 처리
  switch (payment.status) {
    case "PAID":
      // 결제 완료 처리
      await updateOrderStatus(paymentId, "PAID");
      break;
    case "CANCELLED":
      // 결제 취소 처리
      await updateOrderStatus(paymentId, "CANCELLED");
      break;
    case "VIRTUAL_ACCOUNT_ISSUED":
      // 가상계좌 발급됨 (입금 대기)
      await updateOrderStatus(paymentId, "AWAITING_DEPOSIT");
      break;
  }

  // 3. 200 OK 응답 (포트원이 수신 확인)
  res.status(200).json({ received: true });
});

결제 취소(환불) 구현

// 전액 환불
app.post("/api/payment/cancel", async (req, res) => {
  const { paymentId, reason } = req.body;

  try {
    const result = await portone.payment.cancelPayment({
      paymentId,
      reason: reason || "고객 요청에 의한 환불",
    });

    return res.json({ success: true, message: "환불이 완료되었습니다." });
  } catch (error) {
    return res.json({ success: false, message: error.message });
  }
});

// 부분 환불
const result = await portone.payment.cancelPayment({
  paymentId,
  reason: "부분 환불",
  amount: 500,  // 1000원 중 500원만 환불
});

자주 하는 실수 & 체크리스트

실수 해결
결제창이 안 뜸 storeId, channelKey 값이 정확한지 확인. 콘솔에서 복사했는지 체크
“paymentId 중복” 에러 이미 사용된 paymentId. 매 결제마다 새로운 고유값 생성 필수
모바일에서 결제 후 화면 안 돌아옴 redirectUrl 파라미터 필수 설정
서버 검증에서 404 API Secret이 V2용인지 확인. V1 Secret과 다름
금액이 0원으로 결제됨 totalAmount가 정수인지 확인 (문자열 “1000” ❌, 숫자 1000 ✅)
휴대폰 결제 실패 productType 필수. 실물: PRODUCT_TYPE_REAL, 디지털: PRODUCT_TYPE_DIGITAL

포트원이 지원하는 PG사

포트원 V2에서 사용 가능한 주요 PG사입니다:

PG사 카드 계좌이체 가상계좌 간편결제
토스페이먼츠
KG이니시스
NHN KCP
나이스정보통신
KSNET
스마트로
카카오페이
네이버페이
토스페이

코드 변경 없이 관리자 콘솔에서 채널만 추가하면 여러 PG사를 동시에 사용할 수 있습니다. 이게 포트원의 가장 큰 장점입니다.

마무리

정리하면 포트원 결제 연동은 크게 3단계입니다:

  1. 관리자 콘솔에서 채널 설정하고 키 발급받기
  2. 프론트엔드에서 SDK로 결제창 호출
  3. 서버에서 결제 검증

이 글의 코드를 복사해서 storeId, channelKey, API Secret만 본인 값으로 바꾸면 오늘 바로 결제 테스트를 해볼 수 있습니다. 공식 문서는 developers.portone.io에서 확인하세요.

📖 Django로 백엔드를 구축하고 있다면 Django 프레임워크 기초부터 확인해보세요. → Django 웹 프레임워크 완전 입문 가이드

댓글 남기기