결제
Toss, Stripe와 PayPal 단건 결제를 공통 payment 함수로 처리합니다.
전체 결제 흐름
- 결제 전에 서버 DB에 주문번호, 금액, 통화, 결제사와 PENDING 상태를 먼저 저장합니다.
- payment.request에는 클라이언트가 보낸 금액이 아니라 DB에 저장한 주문 금액을 전달합니다.
- 사용자 인증이 끝나면 서버에서 payment.confirm을 호출하고 paid 결과를 DB에 기록한 뒤 상품을 제공합니다.
- 브라우저 성공 화면은 결제 완료 근거가 아닙니다. 서버 confirm 결과나 검증된 webhook만 신뢰합니다.
- webhook은 누락되거나 재전송될 수 있으므로 이벤트 ID를 저장하고 같은 이벤트를 여러 번 처리해도 결과가 같게 만듭니다.
- 환불은 원래 결제 레코드의 provider ID를 사용하고 환불 성공 결과도 별도 이력으로 남깁니다.
주문 생성(PENDING)
→ payment.request
→ 결제사 화면에서 사용자 인증
→ payment.confirm
→ 결제 결과를 DB에 기록(PAID)
→ 상품 제공
결제사 webhook
→ payment.verifyWebhook
→ 이벤트 중복 검사
→ DB 결제 상태 보정
환불 요청
→ payment.refund
→ 환불 결과를 DB에 기록결제 기록 모델 예제
- 이 모델은 복사해서 수정할 수 있는 권장 예제이며 Cake20이 자동 생성하거나 관리하지 않습니다.
- Payment 한 행은 주문이 아니라 한 번의 결제 시도를 나타냅니다. 같은 주문을 다시 결제하면 새 행을 만듭니다.
- provider에는 toss, stripe 또는 paypal을 저장합니다. 다른 결제 어댑터를 추가해도 DB enum 변경 없이 사용할 수 있습니다.
- providerId에는 Toss paymentKey, Stripe PaymentIntent ID 또는 PayPal Order ID를 저장합니다.
- transactionId에는 providerId와 실제 거래 ID가 다른 PayPal Capture ID 등을 저장합니다.
- requestKey는 payment.request 전에 생성해 DB에 저장하고 같은 요청을 재시도할 때 같은 값을 전달합니다.
- status에는 ready, requires_action, pending, paid, canceled, refunded, partially_refunded 또는 failed를 저장합니다.
- amount는 최소 통화 단위의 정수입니다. DB의 BigInt를 payment 함수에 전달할 때 Number로 바꾸고 Number.isSafeInteger로 확인합니다.
- request 전 ready 행을 만들고, request 결과의 providerId와 status, confirm 결과의 transactionId와 최종 status를 차례로 저장합니다.
- 기존 주문 모델과의 relation이나 provider와 providerId의 복합 unique는 웹사이트 구조에 맞게 추가합니다. 여러 웹훅 이벤트나 환불 이력은 Payment 한 행에 합치지 말고 필요할 때 별도 모델로 둡니다.
// server/db/payment.db.ts
export const Payment = {
id: z.id(),
orderId: z.string().max(200).index(),
provider: z.string().max(20),
providerId: z.string().max(200).nullable().index(),
transactionId: z.string().max(200).nullable(),
requestKey: z.string().max(38).nullable().unique(),
status: z.string().max(30).default("ready"),
amount: z.bigint().min(1),
currency: z.string().min(3).max(3),
createdAt: z.date().defaultNow().timestamp(),
updatedAt: z.raw("DateTime @updatedAt @db.Timestamp(0)")
};결제 기록 방식은 웹사이트가 선택합니다. 기존 주문 모델에 필요한 필드만 추가하거나 이 예제를 확장해도 payment 함수 사용에는 차이가 없습니다.
결제사 설정
- providers에는 toss, stripe와 paypal을 1개 이상 등록합니다.
- default를 생략하면 첫 번째 결제사, currency를 생략하면 KRW를 사용합니다.
- mode는 test 또는 live이며 생략하면 안전하게 test를 사용합니다.
- 금액은 최소 통화 단위의 양의 정수입니다. KRW 10,000원은 10000, USD 12달러는 1200입니다.
// package.json
{
"payment": {
"providers": ["toss", "stripe", "paypal"],
"default": "toss",
"currency": "KRW",
"mode": "test"
}
}결제 인증정보
- 결제 키는 package.json이나 서버 소스가 아니라 웹사이트 설정의 암호화 Secret에 저장합니다.
- payment.request 결과의 clientKey와 clientSecret은 결제 화면에만 전달하고 로그에 남기지 않습니다.
- 에디터 API 테스트도 실제 결제사 API를 호출하므로 test 키와 mode: test만 사용합니다.
TOSS_CLIENT_KEY
TOSS_SECRET_KEY
STRIPE_PUBLISHABLE_KEY
STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET
PAYPAL_CLIENT_ID
PAYPAL_CLIENT_SECRET
PAYPAL_WEBHOOK_ID결제 준비
- Toss는 clientKey와 결제창 요청값을 반환합니다.
- Stripe는 PaymentIntent를 만들고 clientKey와 clientSecret을 반환합니다.
- PayPal은 Order를 만들고 구매자 승인용 checkoutUrl을 반환합니다.
- 주문번호와 금액은 클라이언트 입력을 신뢰하지 말고 서버 DB 값으로 결정합니다.
const ready = await payment.request({
provider: "stripe",
orderId: "order-20260724-1",
orderName: "딸기 케이크",
amount: 1200,
currency: "USD",
returnUrl: "https://example.com/pay/success",
cancelUrl: "https://example.com/pay/fail",
idempotencyKey: "order-20260724-1"
});승인과 검증
- Toss는 결제 승인 API를 호출합니다.
- Stripe는 Stripe.js 인증 후 PaymentIntent를 다시 조회해 주문번호, 금액과 통화를 검증합니다.
- PayPal은 구매자가 승인한 Order를 capture하고 Capture ID를 transactionId로 반환합니다.
- 성공 결과를 DB에 먼저 기록한 뒤 상품 제공이나 후속 작업을 실행합니다.
const result = await payment.confirm({
provider: "toss",
id: data.paymentKey,
orderId: order.id,
amount: order.amount,
idempotencyKey: `${order.id}-confirm`
});
if (result.status !== "paid") {
throw new Error("결제가 완료되지 않았습니다.");
}전체·부분 환불
- amount를 생략하면 전체 환불하고 값을 넣으면 부분 환불합니다.
- Toss는 paymentKey, Stripe는 PaymentIntent ID를 id로 사용합니다.
- PayPal은 confirm 결과의 transactionId인 Capture ID를 id로 사용합니다.
- 같은 작업을 재시도할 때 같은 idempotencyKey를 사용하도록 주문과 함께 저장합니다.
await payment.refund({
provider: order.provider,
id: order.paymentId,
amount: 5000,
currency: order.currency,
reason: "고객 요청",
idempotencyKey: `${order.id}-refund-1`
});웹훅 검증
- JSON으로 다시 직렬화하지 않은 원본 body가 필요합니다.
- Stripe는 HMAC signature, PayPal은 공식 검증 API로 확인합니다.
- Toss 일반 결제 이벤트는 paymentKey 또는 orderId로 결제를 다시 조회해 비교합니다.
- 웹훅은 재전송될 수 있으므로 이벤트 ID와 상태 변경을 중복 처리해도 안전하게 만듭니다.
// server/api/payment/webhook.post.ts
export default async (request: Request) => {
const event = await payment.verifyWebhook({
provider: "stripe",
body: await request.text(),
headers: request.headers
});
// event ID로 중복 처리를 막고 DB 상태를 갱신합니다.
return { received: event.verified };
};