토스페이먼츠와 포트원 V2 공식 문서를 기준으로 빌링키 발급, 결제일 실행, 실패 재시도, 해지·환불, 증액·유료 전환 고지를 Next.js 라우트 핸들러 코드와 함께 정리합니다. 토스는 스케줄링과 완료 웹훅이 없고, 포트원 V2 리커버리는 종료 예정이라는 점을 반영했습니다.
정기결제는 빌링키 발급, 결제일 실행, 실패 재시도, 해지, 고지의 다섯 단계로 나뉩니다. 이 글은 토스페이먼츠와 포트원 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에서 증액·전환 동의 여부를 함께 확인합니다.
함께 읽을 글
- 바이브코딩 결제 연동 가이드: 국내 PG·해외 MoR 선택 기준
- 토스페이먼츠 Next.js 연동: App Router 결제 승인·금액 검증·웹훅까지
- 포트원 연동 가이드: V2 웹훅 서명 검증과 PG 계약 순서
- 결제 붙이기 전 법적 준비: 사업자등록·통신판매업 신고·환불 고지 체크리스트
- 서비스 구축 사업부: 서비스 구축·AI Agent 구축·MVP 개발 · 문의하기
자주 묻는 질문
재시도 간격은 몇 번, 며칠이 좋나요?
두 회사 모두 공식 권장 간격을 공개하지 않습니다(2026-10-17 확인). 잔액·한도 사유는 며칠 간격으로 재시도하고, 카드 상태 사유는 재시도 없이 재등록을 요청하는 식으로 나눕니다.
customerKey는 무엇으로 만드나요?
회원 ID나 이메일과 별개로 UUID를 만들어 회원 행에 저장합니다. 토스페이먼츠 API 레퍼런스는 자동 증가 숫자, 이메일, 전화번호, 사용자 아이디를 안전하지 않은 값으로 적습니다.
무료 체험 뒤 첫 결제는 어떻게 고지하나요?
무상 공급 뒤 유료 정기결제로 바뀌는 경우는 전자상거래법 제13조제6항의 대상입니다. 시행령 제20조의2에 따라 전환 전 30일 이내에 전환 일시, 가격, 결제방법에 대한 동의를 받고 해지 방법을 고지합니다.
출처
- 토스페이먼츠 자동결제 가이드·연동·용어, https://docs.tosspayments.com/guides/v2/billing , https://docs.tosspayments.com/guides/v2/billing/integration , https://docs.tosspayments.com/resources/glossary/billing (2026-10-17 접속)
- 토스페이먼츠 API 레퍼런스 자동결제·에러 코드, https://docs.tosspayments.com/reference , https://docs.tosspayments.com/reference/error-codes (2026-10-17 접속)
- 토스페이먼츠 웹훅 이벤트·LLM Quick Reference, https://docs.tosspayments.com/reference/using-api/webhook-events , https://docs.tosspayments.com/guides/v2/get-started/llms-quick-reference (2026-10-17 접속)
- 포트원 빌링키 결제 연동(발급·결제·예약), https://developers.portone.io/opi/ko/integration/start/v2/billing/readme (2026-10-17 접속)
- 포트원 빌링결제 내역 조회(리커버리), https://developers.portone.io/opi/ko/console/guide/billing-payments (2026-10-17 접속)
- @portone/server-sdk 0.19.0 타입 정의, https://www.npmjs.com/package/@portone/server-sdk (2026-10-17 확인)
- 전자상거래 등에서의 소비자보호에 관한 법률 제13조·제17조·제21조의2, https://www.law.go.kr/법령/전자상거래등에서의소비자보호에관한법률 (2026-07-21 시행본, 2026-10-17 접속)
- 같은 법 시행령 제20조의2, https://www.law.go.kr/법령/전자상거래등에서의소비자보호에관한법률시행령/제20조의2 (2026-10-17 접속)
코텍시스 창업자로 AX 컨설팅과 기업 AI 교육, AI 에이전트 개발을 직접 수행합니다.
코텍시스 AI 인사이트 최신 논문 리뷰
토큰당 KV 캐시 크기로 계산하는 온프레미스 동시 처리량
vLLM 논문이 보인 것처럼 LLM 서빙의 동시 처리량은 GPU 메모리에서 가중치를 빼고 남은 공간을 토큰당 KV 캐시 크기로 나눈 값이 정합니다. Qwen3-8B와 Qwen3-32B의 config.json으로 계산하면 80GiB GPU 1장에서 8,192토큰 세션 상한이 57개와 9개로, 가중치 크기 차이 4배보다 큰 6배 이상의 차이가 납니다.
초안 모델로 생성 속도를 높이는 추측 디코딩
Google Research의 추측 디코딩은 작은 초안 모델이 제안한 토큰을 큰 모델이 한 번에 검증하고 확률적 수락 규칙으로 출력 분포를 그대로 유지해, T5-XXL의 생성 속도를 재학습 없이 2배에서 3배 높였습니다. M3 Pro 맥북의 Gemma 4 12B 단일 사용자 실측에서는 초안을 켜면 같은 출력을 유지한 채 생성 속도가 15.3 tok/s에서 33.4 tok/s로 2.18배 올랐습니다.
AWQ 양자화: 활성값을 보고 가중치를 지키는 4비트 양자화
MIT 연구진의 AWQ는 활성값 분포를 기준으로 중요한 가중치 1%만 보호해도 저비트 양자화 오류가 크게 줄어든다는 관찰에서 출발해 재학습 없는 4비트 양자화를 구현했고 전용 커널 TinyChat으로 FP16 대비 3배 이상의 추론 속도를 달성했습니다. 온프레미스 sLLM을 소수의 GPU로 서빙해야 하는 기업에게 정확도 손실 없이 메모리와 비용을 동시에 줄이는 사실상의 표준 기법입니다.

