정기결제 구현: 빌링키 발급부터 결제 실패 재시도·해지까지(토스·포트원)
개발

정기결제 구현: 빌링키 발급부터 결제 실패 재시도·해지까지(토스·포트원)

블로그로 돌아가기

토스페이먼츠와 포트원 V2 공식 문서를 기준으로 빌링키 발급, 결제일 실행, 실패 재시도, 해지·환불, 증액·유료 전환 고지를 Next.js 라우트 핸들러 코드와 함께 정리합니다. 토스는 스케줄링과 완료 웹훅이 없고, 포트원 V2 리커버리는 종료 예정이라는 점을 반영했습니다.

AX Specialist 김지우정기결제빌링키구독 결제토스페이먼츠포트원Next.js전자상거래법

정기결제는 빌링키 발급, 결제일 실행, 실패 재시도, 해지, 고지의 다섯 단계로 나뉩니다. 이 글은 토스페이먼츠와 포트원 V2 공식 문서(2026-10-17 확인)를 기준으로 단계별 서버 작업과 Next.js 라우트 핸들러 코드를 정리합니다. 두 회사 모두 재시도 간격을 정해 주지 않으므로 그 설계를 중심으로 다룹니다.

핵심 요약

  • 토스페이먼츠는 스케줄링 기능이 없어 서버가 결제일마다 POST /v1/billing/{billingKey}를 호출하고, 완료 웹훅이 없으므로 API 응답을 결과로 저장합니다.
  • 포트원 V2는 결제 예약 API로 결제를 예약하고 결과를 웹훅으로 보냅니다. 반복 결제는 결제가 끝난 뒤 다음 회차를 예약합니다.
  • 포트원 콘솔 리커버리(실패 건 일괄 재시도)는 V2에서 종료 예정입니다.
  • 재시도 간격의 공식 권장값은 두 회사 모두 없습니다. 실패 코드별로 재시도와 카드 재등록 요청을 나눕니다.
  • 정기결제 증액과 무료에서 유료로의 전환은 그 전 30일 이내에 소비자 동의를 받아야 합니다(전자상거래법 제13조제6항, 시행령 제20조의2).

단계별 구현 위치

단계토스페이먼츠포트원 V2
사용 조건리스크 검토와 추가 계약, 정기 구독형 서비스만빌링키는 정기 구독에만 사용
빌링키 발급브라우저 payment.requestBillingAuth → 서버 POST /v1/billing/authorizations/issue브라우저 PortOne.requestIssueBillingKey → 서버에 빌링키 전달
결제일 실행서버 스케줄러가 POST /v1/billing/{billingKey} 호출paymentSchedule.createPaymentSchedule 또는 payWithBillingKey
결과 확인API 응답(완료 웹훅 없음)Transaction.Paid·Transaction.Failed 웹훅 뒤 getPayment
해지다음 결제를 호출하지 않음, DELETE /v1/billing/{billingKey}revokePaymentSchedules, billingKey.deleteBillingKey

1. 빌링키 발급

토스페이먼츠 등록창 방식은 브라우저에서 requestBillingAuth({ method: "CARD", successUrl, failUrl })를 호출합니다. successUrl로 customerKey와 일회성 authKey가 돌아오면 서버가 빌링키 발급 API를 호출합니다.

  • 빌링키 조회 API는 없으므로 발급 응답에서 바로 저장합니다.
  • 빌링키 유효기간은 카드 유효기간과 같고, 카드를 재발급하면 새로 발급받습니다.
  • 등록창 자동결제는 국내 발급 카드만 지원합니다. 계좌 자동결제는 퀵계좌이체 가이드가 따로 있습니다.
  • customerKey는 UUID 같은 무작위 값입니다. 길이는 API 레퍼런스가 250자, LLM Quick Reference가 2300자로 서로 달라 50자 이하로 만듭니다.

successUrl을 라우트 핸들러로 받아 customerKey가 로그인 사용자의 값인지 먼저 확인합니다.

// app/api/billing/toss/success/route.ts
const auth = "Basic " + Buffer.from(`${process.env.TOSS_SECRET_KEY}:`).toString("base64");

export async function GET(req: Request) {
  const q = new URL(req.url).searchParams;
  const authKey = q.get("authKey");
  const user = await getSessionUser(); // 로그인 사용자
  if (!user || !authKey || q.get("customerKey") !== user.tossCustomerKey) {
    return Response.redirect(new URL("/billing/fail", req.url));
  }
  const res = await fetch("https://api.tosspayments.com/v1/billing/authorizations/issue", {
    method: "POST",
    headers: { Authorization: auth, "Content-Type": "application/json" },
    body: JSON.stringify({ authKey, customerKey: user.tossCustomerKey }),
  });
  if (!res.ok) return Response.redirect(new URL("/billing/fail", req.url));
  const { billingKey } = await res.json();
  await saveBillingKey(user.id, billingKey); // 다시 조회할 수 없으므로 여기서 저장
  return Response.redirect(new URL("/billing/done", req.url));
}

포트원은 브라우저에서 PortOne.requestIssueBillingKey({ storeId, channelKey, billingKeyMethod: "CARD" })를 호출하고 응답의 billingKey를 서버로 보냅니다. 응답에 code가 있으면 발급 실패입니다.

2. 결제일 실행

토스페이먼츠 문서는 자체 스케줄링이 없으니 자동결제 승인 API를 직접 호출하라고 명시합니다. 승인은 최대 60초가 걸려 타임아웃을 60초 이상으로 잡고, 자동결제는 결제 완료 웹훅이 오지 않습니다.

// app/api/cron/billing/route.ts  (외부 스케줄러가 하루 한 번 호출)
const auth = "Basic " + Buffer.from(`${process.env.TOSS_SECRET_KEY}:`).toString("base64");

export async function POST(req: Request) {
  if (req.headers.get("authorization") !== `Bearer ${process.env.CRON_SECRET}`) {
    return new Response(null, { status: 401 });
  }
  for (const a of await createDueAttempts()) { // 결제일이 된 구독마다 시도 행 생성
    const res = await fetch(`https://api.tosspayments.com/v1/billing/${encodeURIComponent(a.billingKey)}`, {
      method: "POST",
      headers: {
        Authorization: auth,
        "Content-Type": "application/json",
        "Idempotency-Key": a.idempotencyKey, // 시도 행을 만들 때 저장한 UUID
      },
      body: JSON.stringify({
        customerKey: a.customerKey,
        amount: a.amount, // 서버 DB의 요금
        orderId: a.orderId, // 시도마다 새로 만든 무작위 값
        orderName: a.planName,
      }),
      signal: AbortSignal.timeout(65_000),
    }).catch(() => null);
    await recordResult(a, res ? res.status : null, res ? await res.json() : null);
  }
  return new Response(null, { status: 204 });
}

응답이 없던 시도는 실패로 처리하지 않고 GET /v1/payments/orders/{orderId}로 조회합니다. 같은 멱등키로 다시 보내면 첫 응답이 돌아오므로, 에러 응답을 받은 시도는 조회로 미결제를 확인한 뒤 새 시도 행(새 orderId·멱등키)으로 재시도합니다.

포트원은 createPaymentSchedule로 timeToPay를 예약하면 그 시각에 결제하고 웹훅으로 결과를 알립니다. 아래 코드는 웹훅 서명 검증 뒤에 실행합니다.

if (webhook.type === "Transaction.Paid" || webhook.type === "Transaction.Failed") {
  const { paymentId } = webhook.data;
  const payment = await portone.payment.getPayment({ paymentId });
  const sub = await findSubscriptionByPaymentId(paymentId);
  if (!sub || sub.canceled) return new Response(null, { status: 200 });
  const paid = payment.status === "PAID" && payment.amount.total === sub.amount;
  const next = nextRun(sub, paid); // 다음 회차 또는 재시도 시각, 없으면 null
  if (next) {
    await portone.payment.paymentSchedule.createPaymentSchedule({
      paymentId: next.paymentId, // 회차·시도마다 고정된 아이디
      payment: {
        billingKey: sub.billingKey,
        orderName: sub.planName,
        customer: { id: sub.customerId },
        amount: { total: sub.amount },
        currency: "KRW",
      },
      timeToPay: next.at.toISOString(),
    });
  }
}

paymentId를 회차와 시도 번호로 고정하면 같은 웹훅이 두 번 와도 예약이 하나만 생깁니다. SDK 0.19.0 타입 정의의 PAYMENT_SCHEDULE_ALREADY_EXISTS·ALREADY_PAID_OR_WAITING 에러는 정상 흐름으로 처리합니다.

3. 결제 실패와 재시도

토스페이먼츠 용어 문서는 카드 재발급, 비밀번호 변경, 한도 초과, 분실·만료, 잔액 부족을 실패 원인으로 들고, 실패 시 고객 안내와 새 카드 등록 흐름을 구현하라고 합니다. 포트원 문서도 비슷한 원인을 적으며, 실패 건의 failure.pgCode·failure.pgMessage로 PG사 사유를 확인합니다.

포트원 콘솔 리커버리는 전날 실패한 빌링결제를 매일 정한 시각에 재시도합니다. V2 문서는 이 기능이 종료 예정이라 권장하지 않으며 결제 예약 API로 재시도를 직접 구현하라고 안내합니다(2026-10-17 확인).

아래 분류는 토스페이먼츠 에러 코드 목록을 바탕으로 코텍시스가 정리한 것입니다.

에러 코드(토스페이먼츠)문서상 의미처리
REJECT_ACCOUNT_PAYMENT, EXCEED_MAX_PAYMENT_AMOUNT, EXCEED_MAX_AMOUNT잔액 부족, 일 결제 금액·거래 한도 초과며칠 뒤 재시도, 고객 안내
INVALID_CARD_EXPIRATION, INVALID_STOPPED_CARD, INVALID_CARD_LOST_OR_STOLEN유효기간 오류, 정지·분실·도난 카드재시도하지 않고 카드 재등록 요청
REJECT_CARD_COMPANY, INVALID_REJECT_CARD카드사 승인 거절1회 재시도 후 재등록 요청
NOT_MATCHES_CUSTOMER_KEY빌링키와 customerKey 불일치코드 결함, 재시도 중단 후 수정
FAILED_INTERNAL_SYSTEM_PROCESSING, FAILED_PAYMENT_INTERNAL_SYSTEM_PROCESSING 등 500내부 또는 결제 기관 오류주문 조회 후 미결제면 새 시도로 재요청

마지막 시도가 실패하면 구독을 결제 실패 상태로 바꾸고 카드 변경 화면으로 안내합니다.

4. 해지와 환불

토스페이먼츠 문서의 해지 방법은 다음 결제일에 승인 API를 호출하지 않는 것입니다. 빌링키는 DELETE /v1/billing/{billingKey}로 지우고, 삭제되면 BILLING_DELETED 웹훅이 옵니다. 환불은 POST /v1/payments/{paymentKey}/cancel에 cancelReason과 부분 금액 cancelAmount를 보냅니다.

포트원은 revokePaymentSchedules({ billingKey })로 그 빌링키의 예약을 모두 취소하며, 하나라도 실패하면 요청 전체가 실패합니다. 빌링키는 billingKey.deleteBillingKey로 지우고 환불은 cancelPayment({ paymentId, amount, reason })으로 요청합니다.

전자상거래법 제21조의2제1항제4호는 해지 절차를 가입보다 복잡하게 설계하거나 다른 방법으로만 해지하게 하는 것을 금지합니다. 환불 범위는 제17조를 따르며, 제2항제5호는 디지털콘텐츠 제공이 시작된 경우를 청약철회 예외로 두면서 가분적 콘텐츠는 제공되지 않은 부분을 예외에서 뺍니다. 일할 환불 여부는 약관에 적고 법률 전문가에게 확인합니다.

5. 증액과 유료 전환 고지

상황근거구현
정기결제 금액 증액법 제13조제6항, 시행령 제20조의2증액 30일 전까지 일시·변동 전후 가격·결제방법 동의, 해지 조건·방법 고지
무료 체험 뒤 유료 전환같음전환 30일 전까지 같은 항목 동의와 고지
일반 갱신이번에 확인한 법·시행령 조문에 사전 고지 기간 규정 없음약관과 결제 내역 안내로 처리하고 적용 여부를 확인

createDueAttempts와 nextRun에서 증액·전환 동의 여부를 함께 확인합니다.

함께 읽을 글

자주 묻는 질문

재시도 간격은 몇 번, 며칠이 좋나요?

두 회사 모두 공식 권장 간격을 공개하지 않습니다(2026-10-17 확인). 잔액·한도 사유는 며칠 간격으로 재시도하고, 카드 상태 사유는 재시도 없이 재등록을 요청하는 식으로 나눕니다.

customerKey는 무엇으로 만드나요?

회원 ID나 이메일과 별개로 UUID를 만들어 회원 행에 저장합니다. 토스페이먼츠 API 레퍼런스는 자동 증가 숫자, 이메일, 전화번호, 사용자 아이디를 안전하지 않은 값으로 적습니다.

무료 체험 뒤 첫 결제는 어떻게 고지하나요?

무상 공급 뒤 유료 정기결제로 바뀌는 경우는 전자상거래법 제13조제6항의 대상입니다. 시행령 제20조의2에 따라 전환 전 30일 이내에 전환 일시, 가격, 결제방법에 대한 동의를 받고 해지 방법을 고지합니다.

출처

김지우 (Tristan Kim)

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

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

AI 서비스 구축

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