스케줄 결제의 숨겨진 덫: PENDING Invoice를 먼저 만들어야 하는 이유

webhook을 Invoice 생성 트리거로 쓰면 세 가지 문제가 터집니다. 스케줄 등록 시점에 PENDING Invoice를 미리 만드는 구조로 해결한 방법을 정리했습니다.

스케줄 결제의 숨겨진 덫: PENDING Invoice를 먼저 만들어야 하는 이유

1. 문제 상황

B2B SaaS에 월간·연간 구독 결제를 붙였습니다. 한국 시장이라 PortOne(舊 아임포트) V2를 PG로 선택했습니다. 기술 흐름은 단순해 보였습니다.

1. 사용자가 카드를 등록 → 빌링키 발급
2. 첫 결제 즉시 실행 → Invoice PAID
3. 다음 결제일에 자동 결제 → PortOne 스케줄 등록
4. 스케줄 실행되면 webhook으로 결과 통보 → Invoice PAID/FAILED

첫 구현은 "webhook이 오면 Invoice를 만든다"였습니다. 결제가 성공하든 실패하든 webhook이 paymentId·amount·상태를 모두 보내주니 그 시점에 Invoice를 생성하면 된다고 판단했습니다. 개발 환경에서는 잘 돌아갔습니다.

그런데 프로덕션 운영을 시작하자 이상한 일이 생기기 시작했습니다.

증상 1: "Orphaned Payment" 경고

Sentry에 CRITICAL: No pending invoice for paid webhook — possible orphaned payment 에러가 올라오기 시작했습니다. PG에서 결제는 성공했다는데, 서버는 "이 결제에 해당하는 Invoice가 없다"고 불평합니다.

증상 2: 결제수단 변경 후 이중 결제 시도

사용자가 결제수단을 변경하면 기존 스케줄을 취소하고 새 빌링키로 재등록해야 합니다. 그런데 이전 스케줄이 이미 "실행 직전" 상태라 취소 API 호출이 race condition에 걸리면, 두 스케줄이 거의 동시에 실행되어 서로 다른 paymentId로 두 번 결제되는 상황이 발생했습니다.

증상 3: 환불 요청 오는데 어느 Invoice를 가리키는지 불명확

사용자가 "결제 되돌려주세요"라고 하면 어떤 Invoice의 portonePaymentId를 PG에 넘겨 환불해야 하는데, 해당 paymentId로 Invoice가 존재하지 않는 경우가 있었습니다. 결제가 성공했는데 Invoice 생성 과정에서 DB 에러가 났거나, webhook이 아예 도착 안 한 경우였습니다.

공통 원인

세 증상 모두 같은 뿌리였습니다. webhook을 "Invoice 생성의 트리거"로 쓴 것이 문제였습니다. Invoice는 결제가 일어나기 전에 이미 존재해야 합니다.


2. 원인 분석

2.1 PG 스케줄의 구조

PortOne V2의 빌링키 스케줄은 대략 다음과 같이 동작합니다.

// PG에 스케줄 등록
const schedule = await portone.schedulePayment({
  billingKey: 'billing-key-xxx',
  paymentId: 'renew_abc123',         // ← 여러분이 정한 ID
  amount: 9900,
  scheduledAt: '2026-05-01T00:00:00Z',
});
// → { scheduleId: 'schedule-yyy' } 반환

여기서 중요한 건 paymentId여러분이 결정한다는 점입니다. PG가 생성해주는 게 아니라, 여러분이 고유한 ID를 만들어서 넘기면 PG가 그 ID로 추적합니다. 스케줄이 실행되는 시점에 PG는 이 paymentId로 webhook을 호출합니다.

// webhook payload 예시
{
  type: 'payment.paid',
  paymentId: 'renew_abc123',
  amount: 9900,
  paidAt: '2026-05-01T00:00:15Z',
}

여러분의 서버는 이 webhook을 받고 "이 paymentId에 해당하는 Invoice"를 찾아 상태를 PAID로 바꿔야 합니다.

2.2 "webhook에서 Invoice 생성" 접근의 결함

순진한 구현은 이렇게 생겼습니다.

// ❌ 잘못된 방식: webhook에서 Invoice 생성
async function handlePaymentPaid(paymentId: string, amount: number) {
  // Subscription을 paymentId prefix로 추정하거나
  // 별도 매핑 테이블을 둬서 찾는다고 가정
  const subscription = await findSubscriptionByPaymentId(paymentId);

  if (!subscription) {
    logger.error('Unknown payment'); // ← 이게 orphaned payment
    return;
  }

  await prisma.invoice.create({
    data: {
      subscriptionId: subscription.id,
      amount,
      status: 'PAID',
      portonePaymentId: paymentId,
      // periodStart, periodEnd는 어디서 가져오지?
      // lineItems는?
    },
  });
}

문제가 줄줄이 터집니다.

문제 1: Invoice에 필요한 정보가 webhook에 없음. periodStart, periodEnd, lineItems는 사용자가 구독한 시점의 정보이지 결제 순간의 정보가 아닙니다. 현재 Subscription 상태에서 계산하려 해도, 갱신 결제가 언제 실행됐는지 정확히 알 수 없습니다. 특히 결제가 며칠 늦게 실행된 경우(PG 장애 등) 기간 계산이 틀어집니다.

문제 2: 중복 결제 감지 불가능. webhook은 네트워크 상황에 따라 2회 이상 도착할 수 있습니다. "이 paymentId로 이미 Invoice가 있는지" 체크하려면 Invoice가 이미 존재해야 하는데, 생성을 webhook에 의존하면 체크 기준이 없습니다.

문제 3: 환불·취소 요청의 역참조 불가능. 사용자가 "내가 지난달에 낸 9,900원 돌려주세요"라고 할 때, 관리자가 Invoice 목록에서 해당 결제를 찾아 환불 버튼을 눌러야 합니다. Invoice가 결제 후에 만들어지면 "결제했는데 Invoice가 아직 없는" 공백 시간이 존재합니다.

문제 4: webhook 유실 = 결제 손실. PG가 webhook을 보내지 못하면(네트워크, 방화벽, 앱 다운타임) Invoice가 영영 만들어지지 않습니다. 사용자는 카드에 청구됐지만 서비스 기록에는 결제가 없습니다.

2.3 해결 방향: "PENDING → PAID" 상태 전이

올바른 접근은 Invoice를 스케줄 등록 시점에 미리 만들어두는 것입니다. 이 시점의 Invoice는 "결제 예정"이라는 뜻에서 PENDING 상태입니다. webhook이 도착하면 이 PENDING을 찾아 PAID로 전이시킵니다.

[스케줄 등록 시점]
  ├─ portone.schedulePayment() 호출
  ├─ PENDING Invoice 생성 (paymentId, periodStart, periodEnd, amount, lineItems 전부 기록)
  └─ Subscription.pgScheduleId 저장

[스케줄 실행 → webhook 도착]
  ├─ findUnique({ portonePaymentId }) ← PENDING Invoice 조회
  ├─ 존재 확인 → PAID 전이
  └─ 존재 안 하면 → orphaned payment 알람 (수동 개입 필요)

이렇게 하면 webhook은 "기록을 만드는" 역할이 아니라 "이미 만들어둔 기록의 상태를 바꾸는" 역할로 격하됩니다. 더 중요한 건 Invoice 생성 타이밍이 서버의 통제 아래에 있다는 점입니다.


3. 해결 방법

3.1 첫 구독 시: 즉시 결제 + 다음 PENDING 선생성

// app/api/billing/subscribe/route.ts — 핵심 부분
export const POST = withErrorHandler(async (request: NextRequest) => {
  // 1. 인증, 플랜·금액 검증 (생략)
  const session = await auth();
  requireAuth(session);
  requireRole(session, ['SUPER_ADMIN']);
  const companyId = requireCompanyId(session);

  const plan = await prisma.plan.findUnique({ where: { id: data.planId } });
  const amount = getPlanPrice(plan, data.billingCycle);
  const now = new Date();
  const periodEnd = calculatePeriodEnd(now, data.billingCycle);
  const paymentId = generatePaymentId(companyId, 'sub');

  // 2. PG 먼저 결제 — 실패하면 아무것도 안 건드림 (롤백 불필요)
  const chargeResult = await portoneAdapter.charge({
    billingKey: savedPaymentMethod.billingKey,
    paymentId,
    amount,
    orderName: `${plan.displayName} 구독`,
    customerId: companyId,
  });

  if (chargeResult.status !== 'PAID') {
    throw new ValidationError('결제에 실패했습니다.');
  }

  // 3. 결제 성공 → DB 트랜잭션: Subscription + PAID Invoice 생성
  const result = await prisma.$transaction(async (tx) => {
    const subscription = await tx.subscription.create({
      data: {
        companyId,
        planId: plan.id,
        status: 'ACTIVE',
        billingCycle: data.billingCycle,
        currentPeriodStart: now,
        currentPeriodEnd: periodEnd,
        paymentMethod: { create: { /* 빌링키 등 */ } },
      },
    });

    const invoice = await tx.invoice.create({
      data: {
        subscriptionId: subscription.id,
        amount,
        status: 'PAID', // ← 즉시 결제 성공
        paidAt: now,
        lineItems: createLineItems(plan.displayName, amount, data.billingCycle),
        periodStart: now,
        periodEnd,
        portonePaymentId: paymentId,
      },
    });

    return { subscription, invoice };
  });

  // 4. ↓↓↓ 핵심: 다음 결제 스케줄 등록 + PENDING Invoice 선생성
  const renewPaymentId = generatePaymentId(companyId, 'renew');
  const nextPeriodEnd = calculatePeriodEnd(periodEnd, data.billingCycle);

  try {
    // 4-1. PENDING Invoice 먼저 — webhook이 도착하면 이걸 찾도록
    await prisma.invoice.create({
      data: {
        subscriptionId: result.subscription.id,
        amount,
        status: 'PENDING', // ← 아직 결제 안 됨
        lineItems: createLineItems(plan.displayName, amount, data.billingCycle),
        periodStart: periodEnd,     // ← 다음 주기
        periodEnd: nextPeriodEnd,
        portonePaymentId: renewPaymentId, // ← unique 제약
      },
    });

    // 4-2. PG 스케줄 등록
    const schedule = await portoneAdapter.schedulePayment({
      billingKey: savedPaymentMethod.billingKey,
      paymentId: renewPaymentId, // ← 동일한 ID로
      amount,
      orderName: `${plan.displayName} 정기결제`,
      customerId: companyId,
      scheduledAt: periodEnd.toISOString(),
    });

    await prisma.subscription.update({
      where: { id: result.subscription.id },
      data: { pgScheduleId: schedule.scheduleId },
    });
  } catch (error) {
    // 스케줄 실패 → PENDING Invoice 정리 + 마커 저장 (다음 편에서 다룸)
    logger.error('Failed to schedule next payment', { /* ... */ });
    await prisma.invoice.updateMany({
      where: {
        subscriptionId: result.subscription.id,
        status: 'PENDING',
        portonePaymentId: renewPaymentId,
      },
      data: { status: 'CANCELED' },
    });
    await prisma.subscription.update({
      where: { id: result.subscription.id },
      data: { pgScheduleId: PG_SCHEDULE_FAILED },
    });
  }

  return NextResponse.json({ success: true, /* ... */ });
});

이 코드의 시퀀스는 PG 먼저, DB 나중 원칙을 따릅니다. PG 결제가 성공한 후에만 DB에 Subscription/Invoice를 만들기 때문에, PG 결제 실패 시 롤백할 게 없습니다. 그리고 PENDING Invoice를 스케줄 등록 직전에 만듭니다.

3.2 Webhook 핸들러: 이미 존재하는 PENDING을 찾아 전이

// app/api/webhooks/portone/route.ts — 핵심 부분
async function handlePaymentPaid(
  paymentId: string,
  pgAmount: number,
  paidAt: string
) {
  // 1. findUnique — portonePaymentId에 unique 제약이 있음
  const invoice = await prisma.invoice.findUnique({
    where: { portonePaymentId: paymentId },
    include: {
      subscription: { include: { paymentMethod: true, plan: true } },
    },
  });

  // 2. 멱등성 — 이미 PAID면 스킵
  if (invoice?.status === 'PAID') {
    logger.info('Duplicate webhook, already processed', { paymentId });
    return;
  }

  // 3. Invoice 없음 또는 PENDING 아님 → orphaned payment
  if (!invoice || invoice.status !== 'PENDING') {
    logger.error('CRITICAL: No pending invoice for paid webhook', {
      paymentId,
      pgAmount,
      existingStatus: invoice?.status ?? 'NOT_FOUND',
    });
    Sentry.captureMessage(`Orphaned payment detected: ${paymentId}`, {
      level: 'fatal',
      tags: { paymentId },
    });
    // HTTP 200 반환 — PG 재시도 방지 (재시도해도 복구 불가)
    return;
  }

  // 4. 금액 검증 — PG가 보낸 금액과 우리가 기록한 금액이 일치해야 함
  if (pgAmount !== invoice.amount) {
    logger.error('CRITICAL: Payment amount mismatch', {
      paymentId,
      expected: invoice.amount,
      received: pgAmount,
    });
    await prisma.$transaction([
      prisma.invoice.update({
        where: { id: invoice.id },
        data: {
          status: 'FAILED',
          failedAt: new Date(),
          failureReason: `Amount mismatch: expected ${invoice.amount}, received ${pgAmount}`,
        },
      }),
      prisma.subscription.update({
        where: { id: invoice.subscriptionId },
        data: { status: 'PAST_DUE' },
      }),
    ]);
    return;
  }

  // 5. 정상 처리 — PENDING → PAID
  await prisma.$transaction([
    prisma.invoice.update({
      where: { id: invoice.id },
      data: {
        status: 'PAID',
        paidAt: new Date(paidAt), // ← PG 실제 결제 시각
      },
    }),
    prisma.subscription.update({
      where: { id: invoice.subscriptionId },
      data: {
        status: 'ACTIVE',
        failedPaymentCount: 0,
        gracePeriodEnd: null,
      },
    }),
  ]);

  // 6. ↓↓↓ 체인 연장: 다음 PENDING Invoice + PG 스케줄 등록
  const sub = invoice.subscription;
  const isRenewal =
    paymentId.startsWith('renew_') && !sub.pendingPlanId;

  if (isRenewal && sub.paymentMethod) {
    await extendPaymentChain(sub, invoice);
  }
}

핵심 체크 순서는 다음과 같습니다.

  1. findUnique with unique 제약. portonePaymentId@unique를 걸어두고, findUnique로 조회합니다. findFirst보다 멱등성·성능 모두 유리합니다.
  2. status === 'PAID' 스킵. webhook이 중복 도착해도 두 번째 이후는 no-op입니다.
  3. !invoice || status !== 'PENDING'으로 orphaned 판정. 여기 걸리면 매우 심각한 상태이므로 Sentry로 즉시 알람을 보냅니다.
  4. 금액 검증. PG가 보낸 금액과 Invoice에 기록된 금액이 다르면 중대한 부정 상황입니다. Invoice를 FAILED 처리하고 구독을 PAST_DUE로 전이합니다.
  5. 정상 전이. 여기까지 오면 PENDING을 PAID로 바꿉니다.
  6. 체인 연장. 이게 다음 중요 포인트입니다.

3.3 결제 체인 연장: 다음 PENDING 생성 + 다음 스케줄 등록

갱신 결제가 성공하면 그 다음 갱신 결제를 준비해야 합니다. webhook 핸들러 안에서 다음 사이클의 PENDING Invoice를 만들고 PG 스케줄도 새로 등록합니다.

async function extendPaymentChain(sub: Subscription, invoice: Invoice) {
  try {
    const decrypted = decryptPaymentMethodFields({
      billingKey: sub.paymentMethod.billingKey,
    });
    const newPeriodEnd = calculatePeriodEnd(invoice.periodEnd, sub.billingCycle);
    const amount = getPlanPrice(sub.plan, sub.billingCycle);
    const renewPaymentId = generatePaymentId(sub.companyId, 'renew');

    // 1. 다음 PENDING Invoice
    await prisma.invoice.create({
      data: {
        subscriptionId: sub.id,
        amount,
        status: 'PENDING',
        lineItems: createLineItems(sub.plan.displayName, amount, sub.billingCycle),
        periodStart: invoice.periodEnd, // ← 이번 사이클 끝 = 다음 사이클 시작
        periodEnd: newPeriodEnd,
        portonePaymentId: renewPaymentId,
      },
    });

    // 2. 다음 PG 스케줄
    const schedule = await portoneAdapter.schedulePayment({
      billingKey: decrypted.billingKey as string,
      paymentId: renewPaymentId,
      amount,
      orderName: `${sub.plan.displayName} 정기결제`,
      customerId: sub.companyId,
      scheduledAt: invoice.periodEnd.toISOString(),
    });

    // 3. Subscription 업데이트 — 기간·스케줄 ID
    await prisma.subscription.update({
      where: { id: sub.id },
      data: {
        currentPeriodStart: invoice.periodStart,
        currentPeriodEnd: invoice.periodEnd,
        pgScheduleId: schedule.scheduleId,
      },
    });
  } catch (error) {
    // 체인 연장 실패 → PG_SCHEDULE_FAILED 마커 + 수동 개입 요청
    logger.error('Failed to extend payment chain', { /* ... */ });
    Sentry.captureException(error, {
      tags: { severity: 'billing_critical', subscriptionId: sub.id },
    });
    await prisma.subscription.update({
      where: { id: sub.id },
      data: {
        currentPeriodStart: invoice.periodStart,
        currentPeriodEnd: invoice.periodEnd,
        pgScheduleId: PG_SCHEDULE_FAILED, // ← cron 재시도 마커
      },
    });
  }
}

이게 "체인"인 이유는 각 결제가 다음 결제의 발판이 되기 때문입니다. 1월 결제 webhook이 도착 → 2월 결제의 PENDING Invoice + 스케줄 등록. 2월 결제 webhook 도착 → 3월 PENDING + 스케줄. 이렇게 매 사이클 한 개의 PENDING이 앞서서 대기하는 구조가 유지됩니다.

3.4 Prisma 스키마: portonePaymentId에 unique 제약

모든 멱등성·검색은 이 unique 제약 위에서 동작합니다.

model Invoice {
  id                String        @id @default(cuid())
  subscriptionId    String
  amount            Int
  status            InvoiceStatus // PENDING | PAID | FAILED | REFUNDED | CANCELED
  paidAt            DateTime?
  failedAt          DateTime?
  failureReason     String?
  lineItems         Json
  periodStart       DateTime
  periodEnd         DateTime
  portonePaymentId  String?       @unique  // ← 핵심
  createdAt         DateTime      @default(now())

  subscription      Subscription  @relation(fields: [subscriptionId], references: [id])

  @@index([subscriptionId, status])
}

@unique가 있어서 findUnique가 가능해지고, 동시에 "같은 paymentId로 Invoice가 두 번 생성되는 것" 자체를 DB 레벨에서 막습니다.

3.5 Cron으로 재시도: PG_SCHEDULE_FAILED 마커

스케줄 등록이 실패했을 때(네트워크, PG 장애 등)는 Subscription.pgScheduleIdPG_SCHEDULE_FAILED 문자열을 저장합니다. 매일 도는 cron이 이 마커를 찾아 재등록을 시도합니다.

// app/api/cron/billing-check/route.ts (발췌)
const failedSchedules = await prisma.subscription.findMany({
  where: {
    status: 'ACTIVE',
    pgScheduleId: PG_SCHEDULE_FAILED, // ← 마커로 필터
  },
  include: { paymentMethod: true, plan: true },
});

for (const sub of failedSchedules) {
  try {
    const decrypted = decryptPaymentMethodFields({
      billingKey: sub.paymentMethod.billingKey,
    });
    const renewPaymentId = generatePaymentId(sub.companyId, 'renew');
    const amount = getPlanPrice(sub.plan, sub.billingCycle);

    // PENDING Invoice — upsert로 멱등성 (기존 PENDING이 있으면 paymentId 갱신)
    const existing = await prisma.invoice.findFirst({
      where: {
        subscriptionId: sub.id,
        status: 'PENDING',
      },
    });

    if (existing) {
      await prisma.invoice.update({
        where: { id: existing.id },
        data: { portonePaymentId: renewPaymentId },
      });
    } else {
      await prisma.invoice.create({
        data: {
          subscriptionId: sub.id,
          amount,
          status: 'PENDING',
          lineItems: createLineItems(sub.plan.displayName, amount, sub.billingCycle),
          periodStart: sub.currentPeriodEnd,
          periodEnd: calculatePeriodEnd(sub.currentPeriodEnd, sub.billingCycle),
          portonePaymentId: renewPaymentId,
        },
      });
    }

    // 스케줄 재등록
    const schedule = await portoneAdapter.schedulePayment({
      billingKey: decrypted.billingKey as string,
      paymentId: renewPaymentId,
      amount,
      orderName: `${sub.plan.displayName} 정기결제`,
      customerId: sub.companyId,
      scheduledAt: sub.currentPeriodEnd.toISOString(),
    });

    await prisma.subscription.update({
      where: { id: sub.id },
      data: { pgScheduleId: schedule.scheduleId },
    });
  } catch (error) {
    logger.error('Schedule retry failed', { subscriptionId: sub.id, error });
    // 다음 cron에서 다시 시도
  }
}

여기서 주목할 포인트는 기존 PENDING이 있으면 paymentId만 갱신한다는 점입니다. PENDING Invoice를 매번 새로 만들면 DB에 쓰레기가 쌓이고 한 구독에 여러 개의 PENDING이 생겨 조회가 꼬입니다. paymentId는 PG 기준 고유 식별자일 뿐이므로 교체해도 괜찮습니다.


4. 핵심 개념 정리

개념 설명 비고
PENDING Invoice 결제 예정 상태의 Invoice, 스케줄 등록 시점에 생성 portonePaymentId에 unique
Payment Chain 각 결제가 다음 결제의 PENDING을 만드는 연쇄 구조 webhook 성공 → 다음 사이클 준비
webhook 멱등성 동일 webhook 재수신 시 no-op findUnique + status === 'PAID' 체크
금액 검증 PG 금액 vs 내부 Invoice 금액 대조 조작·버그 탐지
Orphaned Payment PENDING Invoice 없이 온 결제 Sentry 심각 알람 + 수동 개입
PG_SCHEDULE_FAILED 마커 스케줄 등록 실패 표시 cron이 재시도

상태 전이 다이어그램

Invoice 상태:
  PENDING ─┬─ (webhook paid, amount OK) → PAID
           ├─ (webhook paid, amount mismatch) → FAILED
           ├─ (webhook failed) → FAILED
           └─ (cancel/change plan) → CANCELED

Subscription.pgScheduleId:
  <schedule-id>       (정상)
  PG_SCHEDULE_FAILED  (cron 재시도 대상)
  null                (해지 상태)

Before/After 비교

항목 Webhook에서 Invoice 생성 스케줄 등록 시 PENDING 선생성
Invoice 유실 가능성 높음 (webhook 미도착) 낮음 (DB에 이미 존재)
환불 요청 처리 불가능한 구간 존재 항상 가능
webhook 중복 처리 findFirst 복잡 findUnique 단순
금액 검증 어려움 (내부 금액 불명) 간단 (Invoice.amount 비교)
체인 연장 복잡 Invoice 모델에 기간·금액이 이미 있음

5. 베스트 프랙티스

  • [ ] Invoice는 결제 예정 시점에 생성. webhook은 상태 전이 트리거로만 사용합니다.
  • [ ] portonePaymentId(또는 동등 필드)에 DB unique 제약. 멱등성의 기반입니다.
  • [ ] webhook 핸들러는 무조건 findUnique → 상태 체크 → 전이 순서로 작성합니다. 순서를 바꾸면 race condition이 생깁니다.
  • [ ] 이미 PAID인 경우 스킵. 중복 webhook을 정상 플로우의 일부로 받아들이세요.
  • [ ] Invoice 없음 = 심각 알람. "모르는 결제"가 들어오면 Sentry fatal로 보냅니다. HTTP 응답은 200으로 주고 PG 재시도를 막습니다 (재시도해도 상황이 바뀌지 않음).
  • [ ] 금액 불일치 = FAILED 처리. 원화 1원 차이도 무시하면 안 됩니다. PG 오작동, 멀티파트 요청, 악의적 조작 모두 가능성이 있습니다.
  • [ ] 체인 연장 실패 시 마커 저장. 성공한 결제의 상태 전이는 유지하되, 다음 결제 준비 실패는 cron으로 재시도합니다.
  • [ ] cron에서 PENDING Invoice 재사용. 스케줄을 재등록할 때 기존 PENDING의 paymentId만 업데이트하세요.

6. FAQ

Q1. webhook이 PG 내부 네트워크 장애로 아예 안 오면 PENDING이 영원히 남지 않나요?

A. 네, 그래서 cron이 두 번째 안전망 역할을 합니다. currentPeriodEnd가 지났는데 Subscription이 여전히 ACTIVE 상태라면 cron이 PG에 결제 상태를 역조회하거나, Grace Period를 적용해 상태를 PAST_DUE로 전이시킵니다. 또한 PortOne V2는 webhook 재시도 메커니즘이 있어 일시적 장애는 자동 복구됩니다.

Q2. 첫 구독 시에 왜 즉시 결제(charge)와 다음 주기 스케줄(schedulePayment)을 동시에 호출하나요?

A. 즉시 결제는 "지금 이 주기의 비용"이고, 스케줄은 "다음 주기의 비용"입니다. 한 번의 구독 시작 시 두 사이클(현재 + 다음)이 동시에 다뤄져야 합니다. 만약 스케줄 등록을 생략하면 한 달 뒤 자동 결제가 안 됩니다. 생략할 수 없습니다.

Q3. portonePaymentId를 nullable로 둔 이유는?

A. 수동 환불·청구·조정 같은 관리자 액션으로 만들어진 Invoice는 PG를 거치지 않으므로 paymentId가 없습니다. nullable로 둬서 관리자 Invoice도 같은 모델에 저장할 수 있게 했습니다. unique 제약은 NULL을 중복 허용하므로(DB에 따라 다름) 충돌 없습니다.

Q4. 체인 연장 단계가 실패하면 결제는 이미 완료된 상태인데, 그 다음 달 갱신은 어떻게 되나요?

A. 이번 달 결제는 성공 상태로 남고(Subscription.status는 ACTIVE), 다만 pgScheduleId = 'PG_SCHEDULE_FAILED' 마커가 붙습니다. cron이 매일 이 마커를 찾아 다음 주기 스케줄을 재등록합니다. 사용자 입장에서는 이번 달 서비스는 문제없이 쓰고, 운영자는 알람을 받아 상황을 인지하지만 자동 복구됩니다.

Q5. paymentId prefix로 sub_renew_를 구분하는 이유는?

A. webhook 핸들러가 "갱신 결제인지 첫 결제인지"를 빠르게 판단하기 위해서입니다. 첫 결제(sub_)는 subscribe API가 이미 Subscription 기간을 설정했으므로 체인 연장만 필요합니다. 갱신 결제(renew_)는 현재 사이클 기간을 업데이트하고 다음 사이클 스케줄까지 등록합니다. prefix로 의도를 표현해 로직 분기를 단순화합니다.

Q6. 스케줄 취소·재등록은 어떻게 처리하나요?

A. 결제수단 변경이나 플랜 변경 시 기존 스케줄을 취소하고 새 빌링키·새 금액으로 재등록해야 합니다. 이 과정에서 실패가 나면 어떻게 롤백할까요? 다음 글에서 다룰 cancelFailed 패턴이 그 답입니다.


7. 참고 자료


8. 다음 단계

PENDING Invoice 구조가 자리잡으면 다음 질문은 "중간 단계가 실패했을 때 어떻게 복구하는가?"입니다. 플랜 변경은 ①DB 업데이트 ②기존 PG 스케줄 취소 ③새 스케줄 등록 ④PENDING 정리라는 다단계 연산인데, 각 단계가 실패할 수 있고 분산 트랜잭션은 존재하지 않습니다. 다음 글에서 cancelFailed 패턴으로 해결한 방법을 다루겠습니다.

시리즈 목차 (결제):

  1. 스케줄 결제의 숨겨진 덫: PENDING Invoice를 먼저 만들어야 하는 이유 ← 현재 글
  2. 분산 트랜잭션이 없을 때: cancelFailed 롤백 패턴
  3. Subscription 모델링: 왜 planId는 불변이어야 하는가 (trialPlanId 분리)