분산 트랜잭션이 없을 때: Payment Provider와 DB의 cancelFailed 롤백 패턴
DB + PG의 다단계 연산은 원자적이지 않습니다. Saga 보상 대신 마커를 저장하고 cron이 자동 복구하는 패턴으로 해결했습니다.
1. 문제 상황
SaaS 구독 결제 시스템에 플랜 변경(업그레이드/다운그레이드) 기능을 추가하는 중이었습니다. 개념은 단순한데 실제 구현은 다단계 연산이 됐습니다.
플랜 업그레이드가 수행하는 단계
1. 기존 PG 스케줄 취소 (PortOne API 호출)
2. 일할 계산 후 차액 결제 (PortOne API 호출, PAID Invoice 생성)
3. 새 플랜 정보로 Subscription 업데이트 (DB UPDATE)
4. 새 주기용 PENDING Invoice 생성 (DB INSERT)
5. 새 PG 스케줄 등록 (PortOne API 호출)
6. Subscription.pgScheduleId 업데이트 (DB UPDATE)
6단계 중 1, 2, 5번은 외부 API 호출이고 3, 4, 6번은 DB 연산입니다. 개발 환경에서는 별문제 없이 동작했습니다. 하지만 프로덕션에서는 다음 시나리오를 모두 마주쳤습니다.
실패 시나리오들
시나리오 A: 1번 성공 후 2번 실패
- 기존 스케줄은 취소됐는데 차액 결제가 실패
- 결과: 다음 달 자동 결제가 없는 상태 + 업그레이드도 안 됨 → 구독이 공중에 뜸
시나리오 B: 1~4번 성공 후 5번 실패
- DB에는 새 플랜·새 PENDING Invoice가 적용됐지만 PG에 스케줄이 없음
- 결과: 다음 달 자동 결제가 안 됨. 사용자는 새 플랜으로 서비스를 쓰지만 결제 체인이 끊어짐
시나리오 C: 1~5번 성공 후 6번 실패 (DB 네트워크 장애)
- PG에는 새 스케줄이 등록됐는데 Subscription.pgScheduleId는 업데이트 안 됨
- 결과: "떠다니는 스케줄" — 결제는 실행되지만 서버가 어느 스케줄인지 추적 못함
이 상황을 하나의 트랜잭션으로 감쌀 수 있을까요? 불가능합니다. Prisma 트랜잭션은 DB 내부 작업만 원자적으로 묶어주고 PortOne API 호출은 포함되지 않습니다. "분산 트랜잭션"을 흉내내려면 사가(Saga) 패턴이나 XA 2-phase commit이 필요한데, PG사는 XA를 지원하지 않습니다.
원하는 목표
- 모든 단계가 성공하면 새 플랜 적용 + 결제 체인 연결
- 중간 단계 실패 시 명시적 상태를 남기고 사용자에겐 에러 안내
- 자동 복구: 장애 원인(네트워크 일시 장애 등)이 해소되면 수동 개입 없이 정합성 회복
2. 원인 분석
2.1 완벽한 롤백은 불가능
사가 패턴의 정석은 "각 단계에 대응하는 보상 연산(compensating action)"을 정의하는 것입니다. 그러나 결제 컨텍스트에서 "보상 연산"은 위험합니다.
- 결제의 보상 = 환불. 차액 결제 후 DB 실패 시 환불하면 사용자는 잠시 결제됐다 환불된 이상한 카드 내역을 보게 되고, 카드사에 따라 수수료가 부과됩니다.
- 스케줄 취소의 보상 = 재등록. 재등록도 실패할 수 있고, 그럼 보상의 보상이 필요해지고, 끝없는 코드가 늘어납니다.
"실패하면 전부 되돌린다"는 이상입니다. 현실은 "실패 시점을 기록해두고 이후 자동 복구한다" 쪽이 훨씬 실용적입니다.
2.2 관찰: 단계마다 중요도가 다르다
6단계의 중요도를 다시 보면 차이가 있습니다.
| 단계 | 실패 시 영향 | 복구 방식 |
|---|---|---|
| 1. 기존 스케줄 취소 | 약함 — 구 스케줄이 살아있으면 다음 달에 구 금액이 한 번 더 결제될 수 있음 | 웹훅 도착 시 orphaned payment로 감지해 환불 처리 |
| 2. 차액 결제 | 강함 — 돈이 걸림 | 사용자에게 에러 반환, 전체 작업 중단 |
| 3. DB 업데이트 | 강함 — 새 플랜이 적용 안 됨 | 2번이 성공했으면 반드시 성공시켜야 함 |
| 4. PENDING Invoice 생성 | 중간 — 결제 체인 끊김 | cron이 재생성 |
| 5. 새 스케줄 등록 | 중간 — 다음 달 자동 결제 안 됨 | cron이 재시도 |
| 6. pgScheduleId 업데이트 | 약함 — 추적 실패 | cron이 재조회·갱신 |
특히 1번과 5번은 "실패해도 결제 자체는 이미 성공했다"는 공통점이 있습니다. 이 두 단계의 실패는 **"지금 당장 중단할 이유가 없다"**입니다. 차액 결제가 이미 성공했으므로 사용자는 업그레이드 혜택을 받을 권리가 있고, 다음 달 결제 준비에 실패했다는 이유로 이번 달을 취소할 수는 없습니다.
2.3 해결 방향: 마커 기반 eventually consistent 복구
"5단계가 실패했다"를 DB에 기록해두고, cron이 이 마커를 찾아 나중에 재시도합니다. 마커 값은 PG_SCHEDULE_FAILED라는 특수 문자열입니다.
// lib/domain/billing/constants.ts
export const PG_SCHEDULE_FAILED = '__SCHEDULE_FAILED__';
Subscription.pgScheduleId는 평소에는 sch_xxx 같은 PG 측 ID를 저장하지만, 실패 시에는 이 마커를 저장합니다. 마커는 NULL과 구분되는데, NULL은 "의도적으로 스케줄 없음(해지됨)"을 의미하고 마커는 "스케줄이 있어야 하는데 등록 실패함"을 의미합니다.
3. 해결 방법
3.1 마커 판별 헬퍼
여러 API·cron에서 "이 subscription에 활성 스케줄이 있는가?"를 체크하므로 헬퍼로 추출했습니다.
// lib/domain/billing/utils.ts
import { PG_SCHEDULE_FAILED } from './constants';
/**
* PG 스케줄 ID가 유효한 활성 스케줄인지 확인
*
* null, undefined, PG_SCHEDULE_FAILED 마커를 모두 비활성으로 판단.
*/
export function hasActivePgSchedule(
pgScheduleId: string | null | undefined
): pgScheduleId is string {
return !!pgScheduleId && pgScheduleId !== PG_SCHEDULE_FAILED;
}
type predicate(pgScheduleId is string)로 만들어서 호출부에서 narrowing이 됩니다. 아래처럼 쓰면 TypeScript가 블록 내에서 sub.pgScheduleId를 string으로 인식합니다.
if (hasActivePgSchedule(sub.pgScheduleId)) {
// 이 블록에서 sub.pgScheduleId는 string (null 아님, marker 아님)
await portoneAdapter.cancelSchedule(sub.pgScheduleId);
}
3.2 에러 메시지 포맷 헬퍼
결제 에러는 여러 형태로 올 수 있습니다. Error, AxiosError, unknown. 메시지 추출을 공통화했습니다.
export function formatErrorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
이런 3줄짜리 헬퍼가 왜 필요한가 싶지만, 결제 관련 에러 핸들러가 20곳 넘게 있고 모두 error instanceof Error ? error.message : String(error)를 똑같이 쓰고 있었습니다. 한 곳에서 바꾸고 싶을 때 20곳을 수정하는 건 고통스러우니 빼두는 게 이득입니다.
3.3 업그레이드 API의 단계별 에러 처리
// app/api/billing/change-plan/route.ts (핵심 부분)
export const POST = withErrorHandler(async (request: NextRequest) => {
// ... 인증, 검증 생략
const subscription = await prisma.subscription.findUnique({
where: { companyId },
include: { paymentMethod: true, plan: true },
});
// 검증
if (isUpgrade && !subscription.paymentMethod) {
throw new ValidationError('결제 수단을 먼저 등록해주세요.');
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 단계 1: 기존 PG 스케줄 취소 (best-effort)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if (hasActivePgSchedule(subscription.pgScheduleId)) {
try {
await portoneAdapter.cancelSchedule(subscription.pgScheduleId);
} catch (error) {
// 취소 실패 = 약한 영향 → 로그만 남기고 계속 진행
logger.error('Failed to cancel old PG schedule on plan change', {
subscriptionId: subscription.id,
pgScheduleId: subscription.pgScheduleId,
error: {
code: 'PG_CANCEL_SCHEDULE_FAILED',
status: 500,
message: formatErrorMessage(error),
},
});
Sentry.captureException(error, {
tags: { severity: 'billing_important', subscriptionId: subscription.id },
});
// throw하지 않음 — 이전 스케줄이 살아있더라도 webhook 레이어에서 처리
}
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 단계 2: 차액 결제 (실패 시 중단)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if (isUpgrade) {
const { chargeAmount } = calculateUpgradeCharge(
oldPrice,
newPrice,
subscription.currentPeriodStart,
subscription.currentPeriodEnd
);
const changePaymentId = generatePaymentId(companyId, 'change');
const chargeResult = await portoneAdapter.charge({
billingKey: decryptedBillingKey,
paymentId: changePaymentId,
amount: chargeAmount,
orderName: `${newPlan.displayName} 업그레이드 차액`,
customerId: companyId,
});
if (chargeResult.status !== 'PAID') {
// ← 2번 실패 시 즉시 중단 (사용자 에러로 전환)
throw new ValidationError('업그레이드 결제에 실패했습니다.');
}
// PAID Invoice 기록 (즉시 결제 이력)
await prisma.invoice.create({
data: {
subscriptionId: subscription.id,
amount: chargeAmount,
status: 'PAID',
paidAt: new Date(),
lineItems: createLineItems(newPlan.displayName, chargeAmount, newBillingCycle),
periodStart: new Date(),
periodEnd: newPeriodEnd,
portonePaymentId: changePaymentId,
},
});
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 단계 3-4: PENDING Invoice + 새 스케줄 등록
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const renewPaymentId = generatePaymentId(companyId, 'renew');
const newAmount = getPlanPrice(newPlan, newBillingCycle);
let newPgScheduleId: string = PG_SCHEDULE_FAILED; // ← 실패 마커로 초기화
try {
// PENDING Invoice 먼저
await prisma.invoice.create({
data: {
subscriptionId: subscription.id,
amount: newAmount,
status: 'PENDING',
lineItems: createLineItems(newPlan.displayName, newAmount, newBillingCycle),
periodStart: newPeriodEnd,
periodEnd: calculatePeriodEnd(newPeriodEnd, newBillingCycle),
portonePaymentId: renewPaymentId,
},
});
// 새 PG 스케줄
const schedule = await portoneAdapter.schedulePayment({
billingKey: decryptedBillingKey,
paymentId: renewPaymentId,
amount: newAmount,
orderName: `${newPlan.displayName} 정기결제`,
customerId: companyId,
scheduledAt: newPeriodEnd.toISOString(),
});
newPgScheduleId = schedule.scheduleId; // ← 성공 시 실제 ID로 덮어씀
} catch (error) {
// ← 3~4번 실패 시 마커 + PENDING 정리
logger.error('Failed to register new PG schedule on upgrade', {
subscriptionId: subscription.id,
error: {
code: 'PG_SCHEDULE_FAILED',
status: 500,
message: formatErrorMessage(error),
},
});
Sentry.captureException(error, {
tags: { severity: 'billing_critical', subscriptionId: subscription.id },
});
// 실패한 PENDING Invoice는 CANCELED 처리
await prisma.invoice.updateMany({
where: {
subscriptionId: subscription.id,
status: 'PENDING',
portonePaymentId: renewPaymentId,
},
data: { status: 'CANCELED' },
});
// newPgScheduleId는 이미 PG_SCHEDULE_FAILED 마커 상태
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 단계 5: 구독 업데이트 (반드시 성공)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
await prisma.subscription.update({
where: { id: subscription.id },
data: {
planId: newPlan.id,
billingCycle: newBillingCycle,
currentPeriodStart: new Date(),
currentPeriodEnd: newPeriodEnd,
pgScheduleId: newPgScheduleId, // ← 성공이든 마커든 저장
...CLEAR_TRIAL_FIELDS,
},
});
}
return NextResponse.json({ success: true });
});
핵심 아이디어 네 가지입니다.
- 1번은 best-effort. 취소 실패해도
throw하지 않고 다음 단계로 진행. 구 스케줄의 부작용은 webhook 레이어가 감지합니다(다음 편 참고). - 2번은 fail-fast. 차액 결제가 실패하면 전체 작업을 중단합니다. 돈이 걸려있으니 사용자 에러로 돌아가고, 그 이후 단계는 실행하지 않습니다.
- 3~4번은 실패 시 마커. PENDING Invoice를 정리하고
newPgScheduleId = PG_SCHEDULE_FAILED로 유지합니다. cron이 재시도합니다. - 5번은 반드시 실행. 구독 업데이트는 DB 내부 연산이라 실패 확률이 낮고, 이 단계를 생략하면 차액 결제가 "뭘 위한 돈이었는지"를 기록할 곳이 없어집니다.
3.4 cron 재시도 로직
cron은 PG_SCHEDULE_FAILED 마커가 달린 모든 active/trialing 구독을 찾아 스케줄 재등록을 시도합니다.
// app/api/cron/billing-check/route.ts (발췌)
const failedSchedules = await prisma.subscription.findMany({
where: {
pgScheduleId: PG_SCHEDULE_FAILED,
status: { in: ['ACTIVE', 'TRIALING'] },
},
include: { paymentMethod: true, plan: true },
});
for (const sub of failedSchedules) {
try {
if (!sub.paymentMethod) continue;
const decrypted = decryptPaymentMethodFields({
billingKey: sub.paymentMethod.billingKey,
});
const amount = getPlanPrice(sub.plan, sub.billingCycle);
const newPaymentId = generatePaymentId(sub.companyId, 'renew');
// ↓ 기존 PENDING Invoice가 있으면 paymentId만 갱신 (중복 생성 방지)
const existingPendingInvoice = await prisma.invoice.findFirst({
where: { subscriptionId: sub.id, status: 'PENDING' },
});
if (existingPendingInvoice) {
await prisma.invoice.update({
where: { id: existingPendingInvoice.id },
data: { portonePaymentId: newPaymentId },
});
} 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: newPaymentId,
},
});
}
// 스케줄 재등록 시도
const schedule = await portoneAdapter.schedulePayment({
billingKey: decrypted.billingKey as string,
paymentId: newPaymentId,
amount,
orderName: `${sub.plan.displayName} 정기결제`,
customerId: sub.companyId,
scheduledAt: sub.currentPeriodEnd.toISOString(),
});
await prisma.subscription.update({
where: { id: sub.id },
data: { pgScheduleId: schedule.scheduleId }, // ← 실제 ID로 교체
});
results.scheduleRetried++;
audit(auditCtx, {
action: 'SCHEDULE_RETRY_SUCCESS',
resourceType: 'Subscription',
resourceId: sub.id,
details: { scheduleId: schedule.scheduleId, companyId: sub.companyId },
}).catch(handleAuditError);
} catch (error) {
results.errors++;
logger.error('Failed to retry schedule payment in cron', {
subscriptionId: sub.id,
error: {
code: 'CRON_SCHEDULE_RETRY_FAILED',
status: 500,
message: formatErrorMessage(error),
},
});
// 다음 cron에서 다시 시도
}
}
주의할 점:
- 기존 PENDING Invoice 재사용. 새로 만들면 동일 Subscription에 중복 PENDING이 생기고 조회가 꼬입니다. 있으면
portonePaymentId만 교체합니다.paymentId는 PG 기준 식별자일 뿐이고 Invoice의 금액·기간은 그대로 유효합니다. - 각 구독을 개별 try/catch로 격리. 한 구독의 실패가 전체 cron을 중단시키지 않도록(poison pill 방지).
- 성공 시 감사 로그. 자동 복구는 결과를 추적할 수 있어야 합니다.
SCHEDULE_RETRY_SUCCESS감사 로그로 "언제 어느 구독이 자동 복구됐는지"를 남깁니다.
3.5 해지 플로우의 cancelFailed 변수
해지는 또 다른 분기입니다. 해지 시에는 새 스케줄을 등록할 필요가 없고 기존 스케줄 취소만 하면 됩니다. 그런데 이 취소조차 실패할 수 있습니다. 해지 cron의 처리를 보면 cancelFailed라는 지역 변수가 등장합니다.
// cron: 해지 예약이 만료된 구독 처리
for (const sub of cancelable) {
try {
let cancelFailed = false;
if (hasActivePgSchedule(sub.pgScheduleId)) {
try {
await portoneAdapter.cancelSchedule(sub.pgScheduleId);
} catch (pgError) {
cancelFailed = true; // ← 실패 기록
logger.error('Failed to cancel PG schedule in cron', {
subscriptionId: sub.id,
pgScheduleId: sub.pgScheduleId,
error: {
code: 'PG_CANCEL_FAILED',
status: 500,
message: formatErrorMessage(pgError),
},
});
}
}
await prisma.subscription.update({
where: { id: sub.id },
data: {
status: 'CANCELED',
// 성공: null (깨끗한 해지 상태)
// 실패: PG_SCHEDULE_FAILED (운영자에게 "확인 필요" 신호)
pgScheduleId: cancelFailed ? PG_SCHEDULE_FAILED : null,
},
});
results.canceled++;
} catch (error) {
results.errors++;
logger.error('Failed to cancel subscription in cron', {
subscriptionId: sub.id,
error: {
code: 'CRON_ERROR',
status: 500,
message: formatErrorMessage(error),
},
});
}
}
해지된 구독의 PG_SCHEDULE_FAILED 마커는 cron이 자동으로 재시도하지 않습니다(cron의 retry 쿼리가 status: { in: ['ACTIVE', 'TRIALING'] }로 필터링하기 때문). 이유는 이미 해지된 구독을 자동으로 재처리하는 것이 위험해서입니다. 대신 Sentry로 알람을 보내 운영자가 PG 대시보드에서 수동 확인하게 합니다. 고정된 해지 상태는 자동 재시도보다 사람의 개입이 안전합니다.
4. 핵심 개념 정리
| 개념 | 설명 |
|---|---|
| Best-effort 단계 | 실패해도 무시하고 다음으로 진행 (예: 구 스케줄 취소) |
| Fail-fast 단계 | 실패 시 즉시 중단, 사용자 에러로 반환 (예: 차액 결제) |
| PG_SCHEDULE_FAILED 마커 | "DB는 성공, PG는 실패" 상태를 표현하는 특수 문자열 |
| hasActivePgSchedule 헬퍼 | NULL/마커/실제 ID 3-state 판별 type predicate |
| cron 자동 복구 | 마커 → 재시도 → 실제 ID로 교체 |
| 수동 개입 대기 상태 | 자동 복구 대상이 아닌 마커 (해지된 구독 등) |
3-state Schedule ID 모델
Subscription.pgScheduleId:
'sch_xxx_abc' → 정상 — PG에 활성 스케줄 존재
'PG_SCHEDULE_FAILED' → 실패 마커 — DB는 업데이트 완료, PG는 미확인
null → 의도적으로 스케줄 없음 (해지 완료)
이 3-state는 hasActivePgSchedule가 단일 지점에서 판단합니다. 전체 코드베이스가 동일한 기준으로 동작하게 만드는 핵심입니다.
단계별 에러 처리 전략
| 단계 | 실패 영향 | 처리 | 재시도 |
|---|---|---|---|
| 기존 스케줄 취소 | 약함 | 로그만, 계속 진행 | 웹훅 레이어가 orphan 감지 |
| 결제 (신규/차액) | 강함 | 사용자 에러 throw | 없음 (사용자가 재시도) |
| DB 업데이트 | 강함 | 500 에러 | Sentry critical 알람 |
| PENDING Invoice 생성 | 중간 | CANCELED 처리 | cron이 재시도 시 재생성 |
| 새 스케줄 등록 | 중간 | 마커 저장 | cron 자동 재시도 |
5. 베스트 프랙티스
- [ ] 단계별로 실패 영향을 분류한다. 모든 단계를 같은 기준으로 처리하면 과잉 또는 누락이 생깁니다.
- [ ] 보상 연산(compensating action) 대신 마커 + cron 재시도. 환불 같은 실제 보상은 돈의 흐름을 역전시키므로 최대한 피합니다.
- [ ] 마커는 상수로 정의하고 모든 체크를 헬퍼로 통일.
'__SCHEDULE_FAILED__'문자열이 여러 곳에 흩어지면 오타 한 번에 복구가 무너집니다. - [ ] Type predicate로 narrowing.
hasActivePgSchedule가is string형태이면 호출부에서 TypeScript가 null/마커를 제거해줘서 null 체크 실수가 줄어듭니다. - [ ] cron 재시도 대상에 구독 상태 필터. CANCELED·EXPIRED는 자동 복구 대상에서 제외. 상태 변경의 의도를 자동화가 훼손하면 안 됩니다.
- [ ] 개별 레코드를 try/catch로 격리. poison pill 하나가 전체 배치를 멈추지 않도록.
- [ ] 재시도 성공 시 감사 로그.
SCHEDULE_RETRY_SUCCESS액션으로 자동 복구의 실제 효과를 추적합니다. - [ ] 수동 개입 대상과 자동 복구 대상을 구분. 모든 실패를 자동 재시도하지 말고, 위험한 케이스는 사람이 보게 남기세요.
6. FAQ
Q1. 왜 Saga 패턴의 정석인 "보상 연산"을 쓰지 않았나요?
A. 결제 컨텍스트에서 보상 연산은 환불입니다. 환불은 카드사 수수료·사용자 혼란·회계 복잡도를 동반합니다. 운영 경험상 "일시적 네트워크 장애"가 대부분인데, 이런 장애에 환불까지 동반시키면 비용이 너무 큽니다. 반면 마커 + cron 재시도는 1~10분 안에 대부분 자동 복구되고, 사용자는 장애를 인지하지도 못합니다.
Q2. PG_SCHEDULE_FAILED 마커를 pgScheduleId 필드에 저장하는 것보다 별도 scheduleStatus 필드를 두는 게 정상 설계 아닌가요?
A. 맞습니다. 정규화 관점에서는 별도 상태 컬럼이 올바릅니다. 다만 저희는 세 가지 이유로 이 선택을 했습니다. (1) 마이그레이션이 한 줄로 끝남 (값 체크로 기존 null을 의도된 null과 구분). (2) 모든 체크가 hasActivePgSchedule 한 함수에 집중되어 실수가 없음. (3) "유효한 스케줄 ID가 있는가?"라는 단일 질문에 단일 필드로 답할 수 있음. 정규화를 포기한 대가로 단순성과 일관성을 얻었습니다.
Q3. cron이 재시도 중에도 실패하면 어떻게 되나요?
A. 다음 cron 주기에 다시 시도됩니다. cron은 하루 한 번 돌고 재시도에 타임아웃·백오프가 없습니다. 영구 실패(예: PG 계정이 정지됨)가 의심되면 Sentry 알람이 연속으로 올라가서 운영자가 개입하게 됩니다. 자동 복구가 수일간 실패하면 운영 문제이지 코드 문제가 아닙니다.
Q4. "PENDING Invoice 정리 → 마커 저장" 순서에서 Invoice 정리까지는 성공했는데 Subscription 업데이트가 실패하면 어떻게 되나요?
A. Subscription.pgScheduleId가 이전 값(sch_xxx) 상태로 남습니다. 이 경우 다음 요청에서 "활성 스케줄이 있다"고 잘못 판단할 수 있습니다. 실제로는 드문 케이스지만, 방어하려면 Subscription 업데이트를 await prisma.$transaction([invoiceUpdate, subscriptionUpdate])로 묶으면 됩니다. 저희는 이 케이스를 Sentry critical로 잡아 수동 확인하기로 했고, 자동화는 일반 실패 시나리오에만 집중했습니다.
Q5. cancelFailed 지역 변수와 PG_SCHEDULE_FAILED 상수의 차이는 뭔가요?
A. cancelFailed는 현재 함수 내에서 취소 실패 여부를 추적하는 플래그이고, PG_SCHEDULE_FAILED는 DB에 저장되는 영속적 상태 마커입니다. cancelFailed = true일 때 Subscription에 PG_SCHEDULE_FAILED를 저장하는 패턴으로 둘이 맞물려 동작합니다. 상수명을 PG_CANCEL_FAILED로 바꿀 수도 있었지만, "스케줄 등록"과 "스케줄 취소" 두 실패 모드를 같은 마커로 다루기 때문에 중립적인 이름을 선택했습니다.
Q6. 운영 중에 이 패턴이 실제로 작동하는 것을 어떻게 검증했나요?
A. 두 가지입니다. (1) PortOne 테스트 환경에서 의도적으로 네트워크 차단 후 스케줄 등록 시도 → 마커 저장 확인 → 네트워크 복구 후 cron 실행 → 마커 소거 확인. (2) 프로덕션에서 실제 장애 발생 시 Sentry 알람 타임라인과 SCHEDULE_RETRY_SUCCESS 감사 로그 타임스탬프를 비교해 "실패 발생 → N분 뒤 자동 복구" 사이클이 동작하는지 확인. 결과적으로 운영자가 개입해야 했던 케이스는 카드사 정지 같은 영구 실패뿐이었습니다.
7. 참고 자료
- Saga Pattern — microservices.io
- Stripe: How we handle partial failures
- Prisma Transaction 가이드
- 검색 키워드: "distributed transaction saga pattern", "payment provider rollback compensation"
8. 다음 단계
DB와 PG의 정합성을 맞추는 패턴이 자리잡으면 그 다음 질문은 "Trial을 어떻게 모델링할 것인가?" 입니다. 많은 SaaS가 Trial을 줄 때 planId를 Pro로 덮어씁니다. 그런데 Trial 중도 취소 시 원래 플랜 정보가 사라지고, Trial + 구독이 동시 존재하는 상태를 표현할 수도 없습니다. 다음 글에서 trialPlanId 분리 패턴을 다루겠습니다.
시리즈 목차 (결제):
- 스케줄 결제의 숨겨진 덫: PENDING Invoice를 먼저 만들어야 하는 이유
- 분산 트랜잭션이 없을 때: cancelFailed 롤백 패턴 ← 현재 글
- Subscription 모델링: 왜 planId는 불변이어야 하는가 (trialPlanId 분리)