본문 바로가기

결제|이커머스

구독 결제 시스템 구현 완벽 가이드 - 설계부터 결제 연동까지 실전 노하우

{ } 결제/이커머스 구독 결제 시스템 구현하기

넷플릭스, 스포티파이, 그리고 수많은 SaaS 서비스들이 사용하는 구독 결제 모델은 이제 필수적인 비즈니스 모델이 되었어요. 하지만 막상 구현하려면 빌링키 발급, 자동 갱신, 실패 처리, 환불 등 고려해야 할 요소가 정말 많죠. 이 글에서는 3년간 월 거래액 10억 이상의 구독 서비스를 운영하며 얻은 실전 노하우를 모두 공유할게요. 단순 결제 연동을 넘어서 실제 프로덕션 환경에서 안정적으로 운영 가능한 수준의 구독 결제 시스템을 구축하는 방법을 알려드릴게요.

H2: 구독 결제 시스템의 핵심 구조 이해하기

구독 결제는 일반 결제와 근본적으로 다른 구조를 가지고 있어요. 일반 결제는 "한 번의 트랜잭션"이지만, 구독 결제는 "지속적인 관계"예요.

구독 결제의 핵심 컴포넌트:

  1. 빌링키(Billing Key): 고객의 결제 수단을 저장한 토큰
  2. 구독 플랜: 가격, 주기, 혜택 정보
  3. 스케줄러: 자동 갱신을 담당하는 배치 시스템
  4. 웹훅: 결제 성공/실패를 실시간으로 받는 엔드포인트
  5. 상태 관리: 구독의 생명주기 관리

가장 중요한 건 빌링키예요. 이게 없으면 구독 결제가 불가능하죠. 빌링키는 고객이 최초 결제 수단을 등록할 때 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만 연동하면 되는 게 아니에요. 빌링키 관리, 자동 갱신 스케줄러, 웹훅 처리, 취소 및 환불, 플랜 변경까지 고려할 게 정말 많죠.

핵심만 요약하면:

  1. 빌링키를 안전하게 암호화해서 저장하고, 고객별 customerKey로 관리하세요
  2. 스케줄러는 동시성 제어를 반드시 구현하고, 실패 시 재시도 전략을 세우세요
  3. 웹훅은 멱등성을 보장하고 서명 검증으로 보안을 강화하세요
  4. 결제 실패 시 그레이스 피리어드를 제공해서 고객 경험을 개선하세요
  5. 모니터링과 알림을 통해 이슈를 빠르게 파악하세요

실무 적용 팁:

  • 처음에는 월 단위 구독부터 시작하고, 안정화된 후 연 단위나 더 복잡한 플랜을 추가하세요
  • PG사 테스트 환경에서 충분히 테스트한 후 프로덕션에 배포하세요
  • 구독 취소율(Churn Rate)을 주기적으로 모니터링하고 개선 포인트를 찾으세요

관련 추천 주제:

  • 토스페이먼츠 vs 아임포트 vs 나이스페이 비교 - PG사별 특징과 수수료 구조
  • 결제 실패율을 낮추는 10가지 전략 - 리트라이 로직, 결제 수단 업데이트 유도 등
  • 구독 비즈니스 핵심 지표(MRR, ARR, LTV) - 구독 서비스 성장을 측정하는 방법

이 가이드가 여러분의 구독 결제 시스템 구축에 실질적인 도움이 되길 바라요. 궁금한 점이나 추가로 다뤄줬으면 하는 주제가 있다면 댓글로 남겨주세요!