메인 콘텐츠로 건너뛰기

TossPayments 연동

TossPayments를 사용하여 신용/체크카드, 계좌이체, 가상계좌, 휴대폰 결제 등 한국의 주요 결제 수단을 지원할 수 있습니다.

사전 요구 사항

시작하기 전에 다음을 준비해주세요:
개발 중에는 테스트 모드 키를 사용하세요. 테스트 키는 test_ck_test_sk_ 접두사를 사용합니다. 실제 결제를 받을 준비가 되었을 때만 라이브 키로 전환하세요.

환경 변수

API 서버에 다음 환경 변수를 추가하세요:
# TossPayments 시크릿 키 (서버 전용 — 클라이언트에 절대 노출하지 마세요)
TOSS_SECRET_KEY=test_sk_xxxxxxxxxxxxxxxxxxxxxxxx

# TossPayments 웹훅 서명 시크릿
TOSS_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxxxxx
프론트엔드 애플리케이션에는 클라이언트 키를 추가하세요:
# TossPayments 클라이언트 키 (클라이언트에서 안전하게 사용 가능)
NEXT_PUBLIC_TOSS_CLIENT_KEY=test_ck_xxxxxxxxxxxxxxxxxxxxxxxx
TOSS_SECRET_KEYTOSS_WEBHOOK_SECRET은 절대 클라이언트에 노출하지 마세요. 서버 측에서만 사용해야 합니다.

결제 흐름

TossPayments 결제 흐름은 네 단계로 구성됩니다: 장바구니 생성, 체크아웃 시작, TossPayments Widget SDK로 결제 완료, 서버 측에서 결제 승인.
1
장바구니 생성 및 상품 추가
2
SDK를 사용하여 장바구니를 생성하고 상품을 추가합니다:
3
TypeScript
import { createStorefrontClient } from '@headless-commerce/sdk';

const client = createStorefrontClient({
  apiKey: process.env.NEXT_PUBLIC_HC_API_KEY!,
});

// 장바구니 생성
const cart = await client.carts.create({
  session_id: 'session_abc123',
});

// 상품 추가
await client.carts.addItem(cart.id, {
  variant_id: 'var_xxx',
  quantity: 2,
});
Python
import requests

base_url = "https://api.headlesscommerce.io/v1"
headers = {
    "Authorization": "Bearer pk_test_your_key",
    "Content-Type": "application/json",
}

# 장바구니 생성
cart = requests.post(f"{base_url}/storefront/carts", headers=headers, json={
    "session_id": "session_abc123",
}).json()

# 상품 추가
requests.post(
    f"{base_url}/storefront/carts/{cart['id']}/items",
    headers=headers,
    json={"variant_id": "var_xxx", "quantity": 2},
)
cURL
# 장바구니 생성
curl -X POST https://api.headlesscommerce.io/v1/storefront/carts \
  -H "Authorization: Bearer pk_test_your_key" \
  -H "Content-Type: application/json" \
  -d '{"session_id": "session_abc123"}'

# 상품 추가
curl -X POST https://api.headlesscommerce.io/v1/storefront/carts/{cart_id}/items \
  -H "Authorization: Bearer pk_test_your_key" \
  -H "Content-Type: application/json" \
  -d '{"variant_id": "var_xxx", "quantity": 2}'
4
TossPayments로 체크아웃 시작
5
payment_method: 'tosspayments'를 지정하여 체크아웃 엔드포인트를 호출합니다. API가 TossPayments 위젯 렌더링에 필요한 toss_payment 데이터를 반환합니다:
6
TypeScript
const order = await client.carts.checkout(cart.id, {
  email: 'customer@example.com',
  shipping_address: {
    line1: '서울특별시 강남구 테헤란로 123',
    city: '서울',
    postal_code: '06234',
    country: 'KR',
  },
  payment_method: 'tosspayments',
});

// 응답에 TossPayments 데이터가 포함됩니다
const { order_id, order_name, amount } = order.toss_payment;
console.log(order.id);      // order_xxxxxxxx
console.log(order_id);      // HC-order_xxxxxxxx
console.log(order_name);    // "클래식 티셔츠 외 1건"
console.log(amount);        // 58000
Python
order = requests.post(
    f"{base_url}/storefront/carts/{cart['id']}/checkout",
    headers=headers,
    json={
        "email": "customer@example.com",
        "shipping_address": {
            "line1": "서울특별시 강남구 테헤란로 123",
            "city": "서울",
            "postal_code": "06234",
            "country": "KR",
        },
        "payment_method": "tosspayments",
    },
).json()

# 응답에 TossPayments 데이터가 포함됩니다
toss = order["toss_payment"]
print(order["id"])        # order_xxxxxxxx
print(toss["order_id"])   # HC-order_xxxxxxxx
print(toss["order_name"]) # "클래식 티셔츠 외 1건"
print(toss["amount"])     # 58000
cURL
curl -X POST https://api.headlesscommerce.io/v1/storefront/carts/{cart_id}/checkout \
  -H "Authorization: Bearer pk_test_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "email": "customer@example.com",
    "shipping_address": {
      "line1": "서울특별시 강남구 테헤란로 123",
      "city": "서울",
      "postal_code": "06234",
      "country": "KR"
    },
    "payment_method": "tosspayments"
  }'
7
TossPayments Widget SDK로 결제 완료
8
프론트엔드에서 TossPayments Widget SDK를 사용하여 결제 UI를 표시하고 고객이 결제를 완료하도록 합니다:
9
import { loadTossPayments } from '@tosspayments/tosspayments-sdk';

const tossPayments = await loadTossPayments(
  process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY!
);

const widgets = tossPayments.widgets({ customerKey: 'guest' });

// 결제 금액 설정
await widgets.setAmount({
  currency: 'KRW',
  value: order.toss_payment.amount,
});

// 결제 수단 선택 위젯 렌더링
await widgets.renderPaymentMethods({
  selector: '#payment-method',
  variantKey: 'DEFAULT',
});

// 이용약관 동의 위젯 렌더링
await widgets.renderAgreement({
  selector: '#agreement',
  variantKey: 'AGREEMENT',
});

// 고객이 "결제하기" 버튼 클릭 시 결제 요청
async function handlePayment() {
  try {
    await widgets.requestPayment({
      orderId: order.toss_payment.order_id,
      orderName: order.toss_payment.order_name,
      successUrl: `${window.location.origin}/payment/success`,
      failUrl: `${window.location.origin}/payment/fail`,
    });
  } catch (error) {
    // 사용자가 취소했거나 오류 발생
    console.error(error);
  }
}
10
React 컴포넌트 예시:
11
import { useEffect, useRef } from 'react';
import { loadTossPayments } from '@tosspayments/tosspayments-sdk';

interface PaymentWidgetProps {
  orderId: string;
  orderName: string;
  amount: number;
}

function TossPaymentWidget({ orderId, orderName, amount }: PaymentWidgetProps) {
  const widgetsRef = useRef<ReturnType<
    Awaited<ReturnType<typeof loadTossPayments>>['widgets']
  > | null>(null);

  useEffect(() => {
    async function initWidgets() {
      const tossPayments = await loadTossPayments(
        process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY!
      );
      const widgets = tossPayments.widgets({ customerKey: 'guest' });

      await widgets.setAmount({ currency: 'KRW', value: amount });
      await widgets.renderPaymentMethods({
        selector: '#payment-method',
        variantKey: 'DEFAULT',
      });
      await widgets.renderAgreement({
        selector: '#agreement',
        variantKey: 'AGREEMENT',
      });

      widgetsRef.current = widgets;
    }

    initWidgets();
  }, [amount]);

  const handlePayment = async () => {
    if (!widgetsRef.current) return;

    await widgetsRef.current.requestPayment({
      orderId,
      orderName,
      successUrl: `${window.location.origin}/payment/success`,
      failUrl: `${window.location.origin}/payment/fail`,
    });
  };

  return (
    <div>
      <div id="payment-method" />
      <div id="agreement" />
      <button onClick={handlePayment}>결제하기</button>
    </div>
  );
}
12
서버 측에서 결제 승인
13
고객이 결제를 완료하면 TossPayments가 successUrl로 리다이렉트하며 paymentKey, orderId, amount를 쿼리 파라미터로 전달합니다. Headless Commerce의 승인 엔드포인트를 호출하여 결제를 최종 승인해야 합니다:
14
TypeScript
// 결제 성공 페이지에서 (예: /payment/success)
const searchParams = new URLSearchParams(window.location.search);
const paymentKey = searchParams.get('paymentKey')!;
const orderId = searchParams.get('orderId')!;
const amount = Number(searchParams.get('amount')!);

// Headless Commerce를 통해 결제 승인
const confirmation = await client.payments.confirm({
  payment_key: paymentKey,
  order_id: orderId,
  amount: amount,
});

console.log(confirmation.status); // "confirmed"
Python
# 결제 성공 라우트 핸들러에서
payment_key = request.args.get("paymentKey")
order_id = request.args.get("orderId")
amount = int(request.args.get("amount"))

# Headless Commerce를 통해 결제 승인
confirmation = requests.post(
    f"{base_url}/storefront/payments/confirm",
    headers=headers,
    json={
        "payment_key": payment_key,
        "order_id": order_id,
        "amount": amount,
    },
).json()

print(confirmation["status"])  # "confirmed"
cURL
curl -X POST https://api.headlesscommerce.io/v1/storefront/payments/confirm \
  -H "Authorization: Bearer pk_test_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "payment_key": "paymentKey_from_tosspayments",
    "order_id": "HC-order_xxxxxxxx",
    "amount": 58000
  }'
15
결제 승인은 반드시 서버 측에서 처리해야 합니다. 이 단계를 건너뛰면 결제가 일정 시간 후 자동으로 취소됩니다.

웹훅 설정

Headless Commerce는 TossPayments 웹훅 이벤트를 수신하여 비동기 결제 업데이트(예: 가상계좌 입금, 취소)를 처리합니다. TossPayments 개발자센터에서 웹훅을 설정하세요:
  1. 개발자센터웹훅 설정으로 이동
  2. 웹훅 URL 입력: https://api.headlesscommerce.io/v1/webhooks/tosspayments
  3. 수신할 이벤트 선택
  4. 웹훅 시크릿을 저장하고 TOSS_WEBHOOK_SECRET으로 설정

웹훅 서명 검증

TossPayments는 HMAC-SHA256을 사용하여 웹훅 페이로드에 서명합니다. 서명은 x-tosspayments-signature 헤더에 포함됩니다. Headless Commerce가 자동으로 검증하지만, 커스텀 검증이 필요한 경우:
import crypto from 'node:crypto';

function verifyTossWebhook(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expected = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('base64');

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// Express/Hono 핸들러에서의 사용 예시
app.post('/webhooks/tosspayments', async (req, res) => {
  const signature = req.headers['x-tosspayments-signature'] as string;
  const rawBody = await getRawBody(req);

  if (!verifyTossWebhook(rawBody.toString(), signature, process.env.TOSS_WEBHOOK_SECRET!)) {
    return res.status(401).json({ error: '유효하지 않은 서명' });
  }

  const event = JSON.parse(rawBody.toString());
  console.log(event.eventType); // 예: "PAYMENT_STATUS_CHANGED"
  res.status(200).json({ success: true });
});

이벤트 유형

TossPayments 이벤트Headless Commerce 동작
PAYMENT_STATUS_CHANGED (status: DONE)주문 상태 confirmed로 변경, 결제 completed 처리
PAYMENT_STATUS_CHANGED (status: CANCELED)주문 취소, payment.refunded 웹훅 전송
PAYMENT_STATUS_CHANGED (status: PARTIAL_CANCELED)부분 환불 기록
VIRTUAL_ACCOUNT_DEPOSIT가상계좌 입금 확인, 주문 confirmed 처리

테스트

테스트 모드 키

개발 중에는 테스트 모드 API 키를 사용하세요. 테스트 거래는 시뮬레이션되며 실제 결제가 처리되지 않습니다.
키 유형접두사예시
클라이언트 키test_ck_test_ck_D5GePWvyJ...
시크릿 키test_sk_test_sk_zXLkKEypN...

테스트 결제

테스트 모드에서 TossPayments 위젯은 시뮬레이션된 결제 흐름을 표시합니다:
  • 신용카드: 아무 카드사를 선택하고 시뮬레이션된 인증을 완료합니다
  • 계좌이체: 즉시 성공으로 시뮬레이션됩니다
  • 가상계좌: 테스트 가상계좌가 생성되며 입금을 시뮬레이션할 수 있습니다
  • 휴대폰 결제: 시뮬레이션된 모바일 결제 흐름
TossPayments 테스트 모드는 Stripe처럼 특정 테스트 카드 번호를 사용하지 않습니다. 대신, 결제 위젯 전체가 샌드박스 환경에서 동작하며 모든 결제 수단이 시뮬레이션됩니다.

로컬에서 웹훅 테스트

ngrok과 같은 터널링 도구를 사용하여 로컬 서버로 웹훅 이벤트를 전달할 수 있습니다:
# ngrok 설치
brew install ngrok

# 로컬 서버로 터널 시작
ngrok http 3000

# ngrok URL을 TossPayments 개발자센터에서 웹훅 엔드포인트로 설정
# 예: https://abc123.ngrok.io/v1/webhooks/tosspayments

오류 처리

체크아웃과 결제 승인 두 단계 모두에서 오류를 처리하세요: 체크아웃 오류:
try {
  const order = await client.carts.checkout(cart.id, {
    email: 'customer@example.com',
    shipping_address: { /* ... */ },
    payment_method: 'tosspayments',
  });
} catch (error: unknown) {
  if (error instanceof Error && 'code' in error) {
    const apiError = error as { code: string; message: string };
    switch (apiError.code) {
      case 'cart_empty':
        // 장바구니가 비어 있음
        break;
      case 'insufficient_stock':
        // 하나 이상의 상품 재고가 부족함
        break;
      case 'payment_provider_error':
        // TossPayments에서 오류 반환
        break;
      default:
        console.error(apiError.message);
    }
  }
}
결제 승인 오류:
try {
  const confirmation = await client.payments.confirm({
    payment_key: paymentKey,
    order_id: orderId,
    amount: amount,
  });
} catch (error: unknown) {
  if (error instanceof Error && 'code' in error) {
    const apiError = error as { code: string; message: string };
    switch (apiError.code) {
      case 'payment_amount_mismatch':
        // 금액이 원래 주문과 일치하지 않음
        break;
      case 'payment_already_confirmed':
        // 이미 승인된 결제
        break;
      case 'payment_expired':
        // 결제 세션이 만료됨
        break;
      case 'payment_confirm_failed':
        // TossPayments가 승인을 거부함
        break;
      default:
        console.error(apiError.message);
    }
  }
}

실패 리다이렉트 처리

결제가 실패하면 TossPayments가 failUrl로 리다이렉트하며 오류 정보를 전달합니다:
// 결제 실패 페이지에서 (예: /payment/fail)
const searchParams = new URLSearchParams(window.location.search);
const errorCode = searchParams.get('code');
const errorMessage = searchParams.get('message');
const orderId = searchParams.get('orderId');

// 사용자에게 적절한 메시지 표시
switch (errorCode) {
  case 'PAY_PROCESS_CANCELED':
    // 사용자가 결제를 취소함
    console.log('결제가 취소되었습니다.');
    break;
  case 'PAY_PROCESS_ABORTED':
    // 결제가 중단됨
    console.log('결제 프로세스가 중단되었습니다.');
    break;
  case 'REJECT_CARD_COMPANY':
    // 카드사에서 결제를 거절함
    console.log('카드사에서 결제를 거절했습니다.');
    break;
  default:
    console.log(`결제 실패: ${errorMessage}`);
}
주요 TossPayments 오류 코드:
오류 코드설명권장 조치
PAY_PROCESS_CANCELED사용자가 결제를 취소함재시도 허용
PAY_PROCESS_ABORTED결제 프로세스가 중단됨재시도 허용
REJECT_CARD_COMPANY카드사에서 결제 거절다른 카드 사용 요청
EXCEED_MAX_DAILY_PAYMENT_COUNT일일 결제 횟수 초과다음 날 재시도
EXCEED_MAX_PAYMENT_AMOUNT최대 결제 금액 초과주문 금액 조정
INVALID_CARD_EXPIRATION유효하지 않은 카드 만료일카드 정보 재입력 요청
NOT_SUPPORTED_INSTALLMENT_PLAN지원하지 않는 할부 옵션다른 할부 옵션 선택

다음 단계

웹훅

모든 웹훅 이벤트와 페이로드 형식을 확인하세요.

Stripe

글로벌 결제 수단을 위한 Stripe를 설정하세요.

API 레퍼런스

모든 체크아웃 및 결제 엔드포인트를 살펴보세요.

SDK

전체 SDK 레퍼런스와 모든 리소스 및 메서드를 확인하세요.