넷플릭스, 스포티파이, 그리고 수많은 SaaS 서비스들이 사용하는 구독 결제 모델은 이제 필수적인 비즈니스 모델이 되었어요. 하지만 막상 구현하려면 빌링키 발급, 자동 갱신, 실패 처리, 환불 등 고려해야 할 요소가 정말 많죠. 이 글에서는 3년간 월 거래액 10억 이상의 구독 서비스를 운영하며 얻은 실전 노하우를 모두 공유할게요. 단순 결제 연동을 넘어서 실제 프로덕션 환경에서 안정적으로 운영 가능한 수준의 구독 결제 시스템을 구축하는 방법을 알려드릴게요.
H2: 구독 결제 시스템의 핵심 구조 이해하기
구독 결제는 일반 결제와 근본적으로 다른 구조를 가지고 있어요. 일반 결제는 "한 번의 트랜잭션"이지만, 구독 결제는 "지속적인 관계"예요.
구독 결제의 핵심 컴포넌트:
- 빌링키(Billing Key): 고객의 결제 수단을 저장한 토큰
- 구독 플랜: 가격, 주기, 혜택 정보
- 스케줄러: 자동 갱신을 담당하는 배치 시스템
- 웹훅: 결제 성공/실패를 실시간으로 받는 엔드포인트
- 상태 관리: 구독의 생명주기 관리
가장 중요한 건 빌링키예요. 이게 없으면 구독 결제가 불가능하죠. 빌링키는 고객이 최초 결제 수단을 등록할 때 PG사로부터 발급받는 일종의 토큰인데, 이후 결제는 고객의 추가 인증 없이 이 빌링키로만 진행할 수 있어요.
데이터베이스 스키마를 먼저 설계해볼게요:
-- 구독 플랜 테이블
CREATE TABLE subscription_plans (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL, -- '베이직', '프로', '엔터프라이즈'
price DECIMAL(10, 2) NOT NULL,
billing_cycle VARCHAR(20) NOT NULL, -- 'MONTHLY', 'YEARLY'
trial_days INT DEFAULT 0, -- 무료 체험 기간
features JSON, -- 플랜별 기능
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- 고객 구독 정보 테이블
CREATE TABLE subscriptions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
plan_id BIGINT NOT NULL,
billing_key VARCHAR(100) NOT NULL, -- PG사 발급 빌링키
status VARCHAR(20) NOT NULL, -- 'ACTIVE', 'PAUSED', 'CANCELLED', 'EXPIRED'
current_period_start TIMESTAMP NOT NULL, -- 현재 과금 기간 시작
current_period_end TIMESTAMP NOT NULL, -- 현재 과금 기간 종료
next_billing_date TIMESTAMP, -- 다음 결제 예정일
cancel_at_period_end BOOLEAN DEFAULT false, -- 기간 만료 시 취소 여부
cancelled_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_next_billing (next_billing_date, status),
INDEX idx_user (user_id)
);
-- 결제 내역 테이블
CREATE TABLE subscription_payments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
subscription_id BIGINT NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
payment_key VARCHAR(100), -- PG사 결제 키
status VARCHAR(20) NOT NULL, -- 'SUCCESS', 'FAILED', 'REFUNDED'
failure_reason TEXT, -- 실패 사유
paid_at TIMESTAMP,
refunded_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_subscription (subscription_id)
);이 스키마가 중요한 이유는 구독의 생명주기를 완벽하게 추적할 수 있기 때문이에요. current_period_start/end로 현재 과금 기간을 관리하고, next_billing_date로 스케줄러가 언제 결제를 시도할지 알 수 있죠.
H2: 빌링키 발급과 최초 구독 등록 구현
빌링키 발급은 구독 결제의 시작점이에요. 토스페이먼츠 기준으로 실제 구현 코드를 보여드릴게요.
// 1단계: 빌링키 발급 요청 (프론트엔드)
import { loadTossPayments } from '@tosspayments/payment-sdk';
async function requestBillingKey(userId: string, planId: string) {
const tossPayments = await loadTossPayments(process.env.TOSS_CLIENT_KEY);
// 빌링키 발급을 위한 카드 등록
await tossPayments.requestBillingAuth('카드', {
customerKey: `user_${userId}`, // 고객 고유 키
successUrl: `${window.location.origin}/subscription/billing-success`,
failUrl: `${window.location.origin}/subscription/billing-fail`,
});
}
// 2단계: 빌링키 발급 성공 후 백엔드 처리
import axios from 'axios';
interface BillingKeyResponse {
billingKey: string;
customerKey: string;
cardCompany: string;
cardNumber: string;
}
async function confirmBillingKey(
authKey: string,
customerKey: string,
userId: string,
planId: string
): Promise<Subscription> {
// 토스페이먼츠에 빌링키 발급 확인 요청
const response = await axios.post<BillingKeyResponse>(
'https://api.tosspayments.com/v1/billing/authorizations/issue',
{
authKey,
customerKey,
},
{
headers: {
Authorization: `Basic ${Buffer.from(
process.env.TOSS_SECRET_KEY + ':'
).toString('base64')}`,
'Content-Type': 'application/json',
},
}
);
const { billingKey, cardCompany, cardNumber } = response.data;
// 플랜 정보 조회
const plan = await db.subscriptionPlans.findById(planId);
// 무료 체험 기간 계산
const now = new Date();
const trialEnd = new Date(now.getTime() + plan.trial_days * 24 * 60 * 60 * 1000);
const firstBillingDate = plan.trial_days > 0 ? trialEnd : now;
// 구독 생성 (트랜잭션으로 처리)
return await db.transaction(async (trx) => {
const subscription = await trx.subscriptions.create({
user_id: userId,
plan_id: planId,
billing_key: billingKey,
status: 'ACTIVE',
current_period_start: now,
current_period_end: calculatePeriodEnd(now, plan.billing_cycle),
next_billing_date: firstBillingDate,
});
// 빌링키 정보도 별도 저장 (보안 강화)
await trx.billingKeys.create({
subscription_id: subscription.id,
billing_key: billingKey,
card_company: cardCompany,
card_number: maskCardNumber(cardNumber), // 마스킹 처리
is_active: true,
});
// 무료 체험이 아니면 즉시 첫 결제 시도
if (plan.trial_days === 0) {
await processSubscriptionPayment(subscription.id, trx);
}
return subscription;
});
}
// 카드번호 마스킹 유틸
function maskCardNumber(cardNumber: string): string {
// 1234-5678-****-3456 형식으로 마스킹
return cardNumber.replace(/(\d{4})-(\d{4})-(\d{4})-(\d{4})/, '$1-$2-****-$4');
}
// 다음 과금 기간 종료일 계산
function calculatePeriodEnd(start: Date, cycle: string): Date {
const end = new Date(start);
if (cycle === 'MONTHLY') {
end.setMonth(end.getMonth() + 1);
} else if (cycle === 'YEARLY') {
end.setFullYear(end.getFullYear() + 1);
}
return end;
}왜 이렇게 구현하나요?
customerKey를 사용하는 이유: 동일 고객이 여러 카드를 등록할 수 있고, PG사에서 고객 단위로 관리할 수 있어요- 트랜잭션 처리: 구독 생성과 빌링키 저장은 원자적으로 처리되어야 해요. 하나라도 실패하면 전체 롤백이 필요하죠
- 카드번호 마스킹: PCI-DSS 보안 규정 준수를 위해 필수예요
H2: 자동 갱신 결제 스케줄러 구현
구독 결제의 핵심은 자동 갱신이에요. 매일 특정 시간에 결제 예정인 구독을 찾아서 자동으로 결제를 시도해야 하죠.
// Node.js + node-cron을 사용한 스케줄러
import cron from 'node-cron';
import axios from 'axios';
interface SubscriptionPaymentResult {
success: boolean;
paymentKey?: string;
failureCode?: string;
failureMessage?: string;
}
// 매일 오전 6시에 실행
cron.schedule('0 6 * * *', async () => {
console.log('[Scheduler] 구독 갱신 결제 시작:', new Date());
await processScheduledBillings();
});
async function processScheduledBillings() {
const today = new Date();
today.setHours(0, 0, 0, 0); // 오늘 00:00:00
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
// 오늘 결제해야 할 구독 조회
const subscriptions = await db.subscriptions.findMany({
where: {
status: 'ACTIVE',
next_billing_date: {
gte: today,
lt: tomorrow,
},
},
include: {
plan: true,
user: true,
},
});
console.log(`[Scheduler] ${subscriptions.length}건의 갱신 예정`);
// 병렬 처리하되 동시 실행 수 제한 (PG사 API 레이트 리밋 고려)
const results = await processInBatches(
subscriptions,
async (subscription) => {
return await retryPayment(subscription, 3); // 최대 3회 재시도
},
10 // 동시 10개씩 처리
);
// 결과 집계
const successCount = results.filter(r => r.success).length;
const failureCount = results.length - successCount;
console.log(`[Scheduler] 완료 - 성공: ${successCount}, 실패: ${failureCount}`);
// 실패 건 알림 발송 (Slack, 이메일 등)
if (failureCount > 0) {
await notifyFailedPayments(results.filter(r => !r.success));
}
}
// 재시도 로직 (지수 백오프)
async function retryPayment(
subscription: any,
maxRetries: number
): Promise<SubscriptionPaymentResult> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await processSubscriptionPayment(subscription.id);
if (result.success) {
return result;
}
// 일시적 오류인 경우 재시도
if (isRetryableError(result.failureCode)) {
const delay = Math.pow(2, attempt) * 1000; // 2초, 4초, 8초
await sleep(delay);
console.log(`[Retry] ${subscription.id} - ${attempt}/${maxRetries}`);
continue;
}
// 재시도 불가능한 오류는 즉시 반환
return result;
} catch (error) {
console.error(`[Error] ${subscription.id}:`, error);
if (attempt === maxRetries) {
return {
success: false,
failureCode: 'UNKNOWN_ERROR',
failureMessage: error.message,
};
}
}
}
}
// 실제 결제 처리
async function processSubscriptionPayment(
subscriptionId: string,
trx?: any // 트랜잭션 객체 (선택적)
): Promise<SubscriptionPaymentResult> {
const dbClient = trx || db;
const subscription = await dbClient.subscriptions.findById(subscriptionId, {
include: { plan: true },
});
try {
// 토스페이먼츠 빌링키 결제 API 호출
const response = await axios.post(
`https://api.tosspayments.com/v1/billing/${subscription.billing_key}`,
{
customerKey: `user_${subscription.user_id}`,
amount: subscription.plan.price,
orderId: `subscription_${subscriptionId}_${Date.now()}`,
orderName: `${subscription.plan.name} 구독 (${formatDate(new Date())})`,
customerEmail: subscription.user.email,
customerName: subscription.user.name,
},
{
headers: {
Authorization: `Basic ${Buffer.from(
process.env.TOSS_SECRET_KEY + ':'
).toString('base64')}`,
'Content-Type': 'application/json',
},
}
);
// 결제 성공
await dbClient.subscriptionPayments.create({
subscription_id: subscriptionId,
amount: subscription.plan.price,
payment_key: response.data.paymentKey,
status: 'SUCCESS',
paid_at: new Date(),
});
// 다음 결제일 갱신
const nextBillingDate = calculatePeriodEnd(
new Date(),
subscription.plan.billing_cycle
);
await dbClient.subscriptions.update(subscriptionId, {
current_period_start: new Date(),
current_period_end: nextBillingDate,
next_billing_date: nextBillingDate,
});
return {
success: true,
paymentKey: response.data.paymentKey,
};
} catch (error) {
// 결제 실패 기록
await dbClient.subscriptionPayments.create({
subscription_id: subscriptionId,
amount: subscription.plan.price,
status: 'FAILED',
failure_reason: error.response?.data?.message || error.message,
});
// 실패 사유에 따라 구독 상태 변경
const failureCode = error.response?.data?.code;
if (isTerminalFailure(failureCode)) {
// 카드 정지, 한도 초과 등 복구 불가능한 오류
await dbClient.subscriptions.update(subscriptionId, {
status: 'EXPIRED',
cancelled_at: new Date(),
});
// 고객에게 결제 수단 변경 안내 이메일 발송
await sendPaymentMethodUpdateEmail(subscription.user.email);
}
return {
success: false,
failureCode,
failureMessage: error.response?.data?.message,
};
}
}
// 재시도 가능한 오류 판별
function isRetryableError(code: string): boolean {
const retryableCodes = [
'TIMEOUT',
'TEMPORARY_ERROR',
'PROVIDER_ERROR',
];
return retryableCodes.includes(code);
}
// 복구 불가능한 오류 판별
function isTerminalFailure(code: string): boolean {
const terminalCodes = [
'CARD_STOPPED',
'EXCEED_LIMIT',
'INVALID_CARD',
'LOST_OR_STOLEN',
];
return terminalCodes.includes(code);
}
// 배치 처리 유틸리티
async function processInBatches<T, R>(
items: T[],
processor: (item: T) => Promise<R>,
batchSize: number
): Promise<R[]> {
const results: R[] = [];
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(processor));
results.push(...batchResults);
}
return results;
}핵심 포인트:
- 재시도 전략: 일시적 오류는 지수 백오프로 재시도하되, 카드 정지 같은 복구 불가능한 오류는 즉시 처리해요
- 배치 처리: PG사 API 레이트 리밋을 고려해 동시 요청 수를 제한해야 해요 (보통 초당 10~20건)
- 상태 관리: 결제 실패 시 구독 상태를 적절히 변경하고 고객에게 안내해야 해요
H2: 웹훅을 통한 실시간 결제 상태 동기화
PG사에서 결제 상태 변경 시 웹훅으로 알려줘요. 이걸 제대로 처리해야 데이터 정합성이 보장돼요.
import express from 'express';
import crypto from 'crypto';
const app = express();
// 토스페이먼츠 웹훅 엔드포인트
app.post('/webhooks/tosspayments', express.raw({ type: 'application/json' }), async (req, res) => {
// 1. 웹훅 검증 (HMAC 서명 확인)
const signature = req.headers['x-toss-signature'] as string;
const isValid = verifyWebhookSignature(req.body, signature);
if (!isValid) {
console.error('[Webhook] 유효하지 않은 서명');
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body.toString());
console.log('[Webhook] 이벤트 수신:', event.eventType);
try {
// 2. 이벤트 타입별 처리
switch (event.eventType) {
case 'PAYMENT_APPROVED':
await handlePaymentApproved(event.data);
break;
case 'PAYMENT_FAILED':
await handlePaymentFailed(event.data);
break;
case 'REFUND_PROCESSED':
await handleRefundProcessed(event.data);
break;
case 'BILLING_KEY_DELETED':
await handleBillingKeyDeleted(event.data);
break;
default:
console.log('[Webhook] 처리되지 않은 이벤트:', event.eventType);
}
// 3. 웹훅 수신 확인 응답 (반드시 200 OK를 빠르게 응답)
res.status(200).json({ received: true });
} catch (error) {
console.error('[Webhook] 처리 오류:', error);
// 500 에러를 반환하면 PG사가 재전송해줌
res.status(500).json({ error: 'Processing failed' });
}
});
// HMAC 서명 검증
function verifyWebhookSignature(payload: Buffer, signature: string): boolean {
const hash = crypto
.createHmac('sha256', process.env.TOSS_WEBHOOK_SECRET)
.update(payload)
.digest('base64');
return hash === signature;
}
// 결제 승인 처리
async function handlePaymentApproved(data: any) {
const { paymentKey, orderId, amount } = data;
// orderId에서 subscription_id 추출
const subscriptionId = orderId.split('_')[1];
await db.transaction(async (trx) => {
// 결제 내역 업데이트
await trx.subscriptionPayments.updateMany(
{ subscription_id: subscriptionId, payment_key: paymentKey },
{ status: 'SUCCESS', paid_at: new Date() }
);
// 구독 상태 업데이트 (혹시 실패 상태였다면 복구)
await trx.subscriptions.update(subscriptionId, {
status: 'ACTIVE',
});
});
console.log(`[Webhook] 결제 승인 완료: ${paymentKey}`);
}
// 결제 실패 처리
async function handlePaymentFailed(data: any) {
const { orderId, failureCode, failureMessage } = data;
const subscriptionId = orderId.split('_')[1];
await db.subscriptionPayments.updateMany(
{ subscription_id: subscriptionId, status: 'PENDING' },
{
status: 'FAILED',
failure_reason: `${failureCode}: ${failureMessage}`,
}
);
// 복구 불가능한 오류면 구독 만료 처리
if (isTerminalFailure(failureCode)) {
await db.subscriptions.update(subscriptionId, {
status: 'EXPIRED',
cancelled_at: new Date(),
});
const subscription = await db.subscriptions.findById(subscriptionId, {
include: { user: true },
});
await sendPaymentFailureEmail(subscription.user.email, failureMessage);
}
}
// 환불 처리
async function handleRefundProcessed(data: any) {
const { paymentKey, refundedAmount } = data;
await db.subscriptionPayments.updateMany(
{ payment_key: paymentKey },
{
status: 'REFUNDED',
refunded_at: new Date(),
}
);
console.log(`[Webhook] 환불 완료: ${paymentKey}, ${refundedAmount}원`);
}
// 빌링키 삭제 처리 (고객이 PG사에서 직접 삭제한 경우)
async function handleBillingKeyDeleted(data: any) {
const { billingKey } = data;
const subscription = await db.subscriptions.findOne({
where: { billing_key: billingKey },
});
if (subscription) {
await db.subscriptions.update(subscription.id, {
status: 'CANCELLED',
cancelled_at: new Date(),
});
console.log(`[Webhook] 빌링키 삭제로 구독 취소: ${subscription.id}`);
}
}중요한 이유:
- 멱등성 보장: 같은 웹훅이 여러 번 올 수 있어요.
payment_key를 기준으로 중복 처리를 방지해야 해요 - 빠른 응답: 웹훅 처리는 5초 이내에 완료하고 200 OK를 반환해야 해요. 무거운 작업은 큐로 분리하세요
- 서명 검증: 반드시 HMAC 서명을 검증해서 위조된 웹훅을 차단해야 해요
H2: 구독 취소 및 환불 처리 구현
고객이 구독을 취소할 때 처리해야 할 케이스가 여러 가지예요.
interface CancellationOptions {
immediate: boolean; // 즉시 취소 vs 기간 종료 후 취소
refund: boolean; // 환불 여부
reason?: string; // 취소 사유
}
async function cancelSubscription(
subscriptionId: string,
options: CancellationOptions
): Promise<void> {
const subscription = await db.subscriptions.findById(subscriptionId, {
include: { plan: true },
});
if (subscription.status === 'CANCELLED') {
throw new Error('이미 취소된 구독입니다');
}
await db.transaction(async (trx) => {
if (options.immediate) {
// 즉시 취소
await trx.subscriptions.update(subscriptionId, {
status: 'CANCELLED',
cancelled_at: new Date(),
cancel_at_period_end: false,
});
// 환불 처리
if (options.refund) {
await processProRatedRefund(subscription, trx);
}
console.log(`[Cancel] 즉시 취소 완료: ${subscriptionId}`);
} else {
// 기간 종료 후 취소 (다음 결제만 중지)
await trx.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
next_billing_date: null, // 다음 결제 방지
});
console.log(`[Cancel] 기간 만료 시 취소 예약: ${subscriptionId}`);
}
// 취소 사유 기록 (분석용)
await trx.subscriptionCancellations.create({
subscription_id: subscriptionId,
reason: options.reason,
cancelled_at: new Date(),
refunded: options.refund,
});
});
// 빌링키 삭제 (PG사에 요청)
if (options.immediate) {
await deleteBillingKey(subscription.billing_key);
}
}
// 일할 환불 계산 및 처리
async function processProRatedRefund(subscription: any, trx: any) {
const now = new Date();
const periodStart = new Date(subscription.current_period_start);
const periodEnd = new Date(subscription.current_period_end);
// 전체 기간 중 사용한 일수
const totalDays = Math.ceil(
(periodEnd.getTime() - periodStart.getTime()) / (1000 * 60 * 60 * 24)
);
const usedDays = Math.ceil(
(now.getTime() - periodStart.getTime()) / (1000 * 60 * 60 * 24)
);
const remainingDays = totalDays - usedDays;
// 환불 금액 계산 (일할 계산)
const refundAmount = Math.floor(
(subscription.plan.price * remainingDays) / totalDays
);
if (refundAmount <= 0) {
console.log('[Refund] 환불 금액 없음');
return;
}
// 최근 성공한 결제 찾기
const lastPayment = await trx.subscriptionPayments.findOne({
where: {
subscription_id: subscription.id,
status: 'SUCCESS',
},
orderBy: { paid_at: 'desc' },
});
if (!lastPayment) {
throw new Error('환불 가능한 결제 내역이 없습니다');
}
// 토스페이먼츠 환불 API 호출
try {
await axios.post(
`https://api.tosspayments.com/v1/payments/${lastPayment.payment_key}/cancel`,
{
cancelReason: '구독 중도 취소',
cancelAmount: refundAmount, // 부분 환불
},
{
headers: {
Authorization: `Basic ${Buffer.from(
process.env.TOSS_SECRET_KEY + ':'
).toString('base64')}`,
'Content-Type': 'application/json',
},
}
);
// 환불 기록
await trx.subscriptionPayments.update(lastPayment.id, {
status: 'REFUNDED',
refunded_at: new Date(),
});
console.log(`[Refund] 환불 완료: ${refundAmount}원`);
} catch (error) {
console.error('[Refund] 환불 실패:', error);
throw error;
}
}
// 빌링키 삭제
async function deleteBillingKey(billingKey: string): Promise<void> {
try {
await axios.delete(
`https://api.tosspayments.com/v1/billing/${billingKey}`,
{
headers: {
Authorization: `Basic ${Buffer.from(
process.env.TOSS_SECRET_KEY + ':'
).toString('base64')}`,
},
}
);
console.log(`[BillingKey] 삭제 완료: ${billingKey}`);
} catch (error) {
console.error('[BillingKey] 삭제 실패:', error);
// 삭제 실패해도 구독 취소는 진행 (빌링키는 나중에 수동 삭제 가능)
}
}주의사항:
- 일할 환불: 남은 기간에 비례해서 환불하는 게 일반적이에요. 하지만 약관에 명시된 환불 정책을 따라야 해요
- 기간 종료 후 취소: 넷플릭스처럼 구독 기간이 끝날 때까지 서비스를 제공하고 다음 결제만 중지하는 방식이 이탈률이 낮아요
- 환불 실패 처리: 환불이 실패해도 구독 취소는 진행하되, 관리자에게 알림을 보내서 수동으로 처리해야 해요
H3: 구독 플랜 변경 (업그레이드/다운그레이드)
async function changeSubscriptionPlan(
subscriptionId: string,
newPlanId: string
): Promise<void> {
const subscription = await db.subscriptions.findById(subscriptionId, {
include: { plan: true },
});
const newPlan = await db.subscriptionPlans.findById(newPlanId);
const isUpgrade = newPlan.price > subscription.plan.price;
await db.transaction(async (trx) => {
if (isUpgrade) {
// 업그레이드: 즉시 적용 + 차액 결제
const proratedAmount = calculateProratedAmount(
subscription,
newPlan
);
// 차액 즉시 결제
if (proratedAmount > 0) {
await chargeProratedAmount(
subscription.billing_key,
proratedAmount,
`플랜 업그레이드 차액 (${subscription.plan.name} → ${newPlan.name})`
);
}
// 플랜 변경 적용
await trx.subscriptions.update(subscriptionId, {
plan_id: newPlanId,
});
console.log(`[Upgrade] ${subscription.plan.name} → ${newPlan.name}`);
} else {
// 다운그레이드: 다음 갱신 시점에 적용
await trx.subscriptions.update(subscriptionId, {
// 다음 결제 시점에 적용될 플랜을 별도 필드에 저장
pending_plan_id: newPlanId,
});
console.log(`[Downgrade] 다음 갱신 시 ${newPlan.name}로 변경 예약`);
}
});
}
// 일할 계산된 차액 계산
function calculateProratedAmount(
subscription: any,
newPlan: any
): number {
const now = new Date();
const periodEnd = new Date(subscription.current_period_end);
const remainingDays = Math.ceil(
(periodEnd.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
);
const totalDays = Math.ceil(
(periodEnd.getTime() - new Date(subscription.current_period_start).getTime()) /
(1000 * 60 * 60 * 24)
);
// 기존 플랜의 남은 금액
const remainingOldAmount = (subscription.plan.price * remainingDays) / totalDays;
// 새 플랜의 남은 기간 금액
const newPeriodAmount = (newPlan.price * remainingDays) / totalDays;
// 차액
return Math.max(0, Math.floor(newPeriodAmount - remainingOldAmount));
}H2: 흔한 실수와 해결 방법
실수 1: 동시성 문제 - 중복 결제 발생
잘못된 예:
// ❌ 동시에 여러 스케줄러가 실행되면 같은 구독을 중복 결제할 수 있음
async function badProcessBilling(subscriptionId: string) {
const subscription = await db.subscriptions.findById(subscriptionId);
// 여기서 다른 스케줄러가 같은 구독을 가져감
await processPayment(subscription);
// 둘 다 결제를 시도하게 됨!
}올바른 예:
// ✅ 낙관적 락(Optimistic Locking) 사용
async function goodProcessBilling(subscriptionId: string) {
// version 필드를 사용한 동시성 제어
const result = await db.subscriptions.updateOne(
{
id: subscriptionId,
status: 'ACTIVE',
processing: false, // 처리 중이 아닌 것만
},
{
processing: true,
processing_started_at: new Date(),
}
);
// 업데이트 실패 = 다른 프로세스가 이미 처리 중
if (result.modifiedCount === 0) {
console.log(`이미 처리 중: ${subscriptionId}`);
return;
}
try {
await processPayment(subscriptionId);
} finally {
// 처리 완료 후 플래그 해제
await db.subscriptions.update(subscriptionId, {
processing: false,
processing_started_at: null,
});
}
}실수 2: 웹훅 멱등성 미보장
잘못된 예:
// ❌ 같은 웹훅이 여러 번 오면 중복 처리됨
async function badHandleWebhook(paymentKey: string) {
await db.subscriptionPayments.create({
payment_key: paymentKey,
status: 'SUCCESS',
});
// 웹훅이 재전송되면 같은 결제가 2번 기록됨!
}올바른 예:
// ✅ 유니크 제약조건 + upsert로 멱등성 보장
async function goodHandleWebhook(paymentKey: string, data: any) {
await db.subscriptionPayments.upsert(
{ payment_key: paymentKey }, // 유니크 키
{
payment_key: paymentKey,
status: 'SUCCESS',
amount: data.amount,
paid_at: new Date(),
}
);
// 같은 payment_key가 여러 번 와도 하나만 저장됨
}실수 3: 결제 실패 시 고객 경험 악화
잘못된 예:
// ❌ 결제 실패 시 바로 구독 만료 처리
if (paymentFailed) {
await db.subscriptions.update(subscriptionId, { status: 'EXPIRED' });
// 고객이 갑자기 서비스를 못 쓰게 됨!
}올바른 예:
// ✅ 결제 실패 시 그레이스 피리어드 제공
async function handlePaymentFailure(subscriptionId: string, attempt: number) {
const MAX_RETRY_DAYS = 3;
if (attempt < MAX_RETRY_DAYS) {
// 1일 후 재시도 예약
await db.subscriptions.update(subscriptionId, {
next_billing_date: new Date(Date.now() + 24 * 60 * 60 * 1000),
retry_count: attempt + 1,
});
// 고객에게 결제 실패 안내 이메일
await sendPaymentRetryEmail(subscriptionId, MAX_RETRY_DAYS - attempt);
} else {
// 3일 재시도 후에도 실패하면 만료
await db.subscriptions.update(subscriptionId, {
status: 'EXPIRED',
cancelled_at: new Date(),
});
await sendSubscriptionExpiredEmail(subscriptionId);
}
}H2: 성능 최적화 및 보안 강화
성능 최적화
// 배치 처리 최적화: DB 쿼리 횟수 최소화
async function optimizedBatchPayment(subscriptionIds: string[]) {
// ❌ N+1 쿼리 문제
// for (const id of subscriptionIds) {
// const sub = await db.subscriptions.findById(id);
// await processPayment(sub);
// }
// ✅ 한 번에 조회 후 처리
const subscriptions = await db.subscriptions.findMany({
where: { id: { in: subscriptionIds } },
include: { plan: true, user: true },
});
// 병렬 처리 (단, PG사 레이트 리밋 고려)
await processInBatches(subscriptions, processPayment, 10);
}
// 인덱스 추가 (쿼리 성능 향상)
// CREATE INDEX idx_next_billing ON subscriptions(next_billing_date, status);
// CREATE INDEX idx_billing_key ON subscriptions(billing_key);보안 강화
// 빌링키 암호화 저장
import crypto from 'crypto';
const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY; // 32바이트 키
const IV_LENGTH = 16;
function encryptBillingKey(billingKey: string): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv);
let encrypted = cipher.update(billingKey, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
}
function decryptBillingKey(encryptedData: string): string {
const parts = encryptedData.split(':');
const iv = Buffer.from(parts[0], 'hex');
const encryptedText = parts[1];
const decipher = crypto.createDecipheriv('aes-256-cbc', Buffer.from(ENCRYPTION_KEY), iv);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// 사용 예
const encryptedKey = encryptBillingKey(billingKey);
await db.subscriptions.create({
billing_key: encryptedKey, // 암호화해서 저장
// ...
});
// 결제 시
const subscription = await db.subscriptions.findById(id);
const billingKey = decryptBillingKey(subscription.billing_key);
await processPayment(billingKey);H2: 모니터링 및 알림 시스템
// Slack 알림 통합
import { WebClient } from '@slack/web-api';
const slack = new WebClient(process.env.SLACK_BOT_TOKEN);
async function notifyFailedPayments(failures: any[]) {
const message = failures.map(f =>
`• 구독 ID: ${f.subscriptionId}\n 사유: ${f.failureMessage}\n 금액: ${f.amount}원`
).join('\n\n');
await slack.chat.postMessage({
channel: '#payment-alerts',
text: `⚠️ 구독 결제 실패 ${failures.length}건\n\n${message}`,
});
}
// 메트릭 수집 (Prometheus)
import client from 'prom-client';
const paymentSuccessCounter = new client.Counter({
name: 'subscription_payment_success_total',
help: 'Total successful subscription payments',
});
const paymentFailureCounter = new client.Counter({
name: 'subscription_payment_failure_total',
help: 'Total failed subscription payments',
labelNames: ['failure_code'],
});
// 결제 성공/실패 시 메트릭 기록
if (paymentResult.success) {
paymentSuccessCounter.inc();
} else {
paymentFailureCounter.labels(paymentResult.failureCode).inc();
}결론
구독 결제 시스템은 단순히 PG사 API만 연동하면 되는 게 아니에요. 빌링키 관리, 자동 갱신 스케줄러, 웹훅 처리, 취소 및 환불, 플랜 변경까지 고려할 게 정말 많죠.
핵심만 요약하면:
- 빌링키를 안전하게 암호화해서 저장하고, 고객별
customerKey로 관리하세요 - 스케줄러는 동시성 제어를 반드시 구현하고, 실패 시 재시도 전략을 세우세요
- 웹훅은 멱등성을 보장하고 서명 검증으로 보안을 강화하세요
- 결제 실패 시 그레이스 피리어드를 제공해서 고객 경험을 개선하세요
- 모니터링과 알림을 통해 이슈를 빠르게 파악하세요
실무 적용 팁:
- 처음에는 월 단위 구독부터 시작하고, 안정화된 후 연 단위나 더 복잡한 플랜을 추가하세요
- PG사 테스트 환경에서 충분히 테스트한 후 프로덕션에 배포하세요
- 구독 취소율(Churn Rate)을 주기적으로 모니터링하고 개선 포인트를 찾으세요
관련 추천 주제:
- 토스페이먼츠 vs 아임포트 vs 나이스페이 비교 - PG사별 특징과 수수료 구조
- 결제 실패율을 낮추는 10가지 전략 - 리트라이 로직, 결제 수단 업데이트 유도 등
- 구독 비즈니스 핵심 지표(MRR, ARR, LTV) - 구독 서비스 성장을 측정하는 방법
이 가이드가 여러분의 구독 결제 시스템 구축에 실질적인 도움이 되길 바라요. 궁금한 점이나 추가로 다뤄줬으면 하는 주제가 있다면 댓글로 남겨주세요!
'결제|이커머스' 카테고리의 다른 글
| PCI DSS 규정 완벽 가이드 - 결제 시스템 개발자가 꼭 알아야 할 보안 요구사항 (0) | 2026.03.12 |
|---|