15. 구독 기능
🔗함께 보기: 카드 보관함 기능 · 21. 에러 테스트 — 구독 첫 결제 실패를 STG에서 재현하는 방법(QA)
이 문서는 구독 기능을 호출 진입(라우트/스케줄러)부터 반환까지 코드 흐름으로 따라갑니다. 생성(첫 결제/체험)·자동연장(스케줄러)·상태 전이·취소/재개/연장/수동결제·강제취소·요금제 변경을 다룹니다.
🍎쉽게 말하면 구독은 "요금제에 가입한 한 사용자의 상태(TRIAL→ACTIVE→…)를 관리하면서, 만료일이 되면 보관함 카드로 자동결제해 기간을 연장하는 것"입니다.
15.1 기능 개요·관련 파일·DB 테이블
15.1.1 핵심 규칙
- 서비스+사용자당 EXPIRED를 제외한 '열린' 구독은 최대 1개(부분 유니크 인덱스로 DB 강제).
- 빌링키는 구독이 직접 보유하지 않고
cards테이블(카드 보관함)에서 조회합니다. 구독은card_idFK만 갖습니다. - 취소는 즉시 종료가 아니라 CANCELED로 전환 후 만료일에 배치가 EXPIRED로 종료합니다.
- 자동결제 실패는 PAST_DUE(재시도) → SUSPENDED(정지) → EXPIRED로 이어집니다.
15.1.2 관련 파일
| 파일 | 역할 |
|---|---|
app/api/v1/subscriptions.py |
외부 API 라우터 — 생성·조회·취소·재개·수동결제·사용일추가·요금제변경 |
app/services/subscriptions.py |
생성·취소·재개·수동결제·강제취소·연장·사용일추가·요금제변경 |
app/services/renewals.py |
정기 갱신 배치(process_due) — 자동연장·만료·재시도 |
app/scheduler/runner.py |
배치 주기 실행(APScheduler) + 전역 Redis 락(run_renewals) |
app/services/transitions.py |
상태 전이 중앙화(transition + 허용 전이 테이블) |
app/services/billing_math.py |
결제 금액·주기 계산(plan_first_amount 등) |
app/services/cards.py |
get_card — 빌링키 조회 |
app/models/subscription.py |
Subscription 모델 |
app/models/enums.py |
SubscriptionStatus 등 열거형·상태 집합 |
15.1.3 DB 테이블 — subscriptions (app/models/subscription.py:19)
| 컬럼 | 설명 |
|---|---|
service_id / plan_id |
소속 서비스·가입 요금제(둘 다 FK RESTRICT) |
external_user_id(이메일) |
외부 서비스 사용자 식별자 |
card_id |
결제에 쓸 등록 카드(cards 참조, nullable) |
status |
상태 머신 현재 위치 |
current_period_start/current_period_end |
현재 주기 시작/종료(=접근 만료) |
next_billing_at |
다음 자동결제 예정 시각(스케줄러가 이 값으로 조회) |
retry_count |
PAST_DUE에서 재시도 누적 횟수 |
suspended_at |
SUSPENDED 진입 시각(유예 만료 판정 기준) |
pending_plan_id |
예약된 요금제 변경(다운그레이드) 대상 요금제(nullable, subscription.py:47) |
부분 유니크 인덱스 uq_subscriptions_one_per_user가 EXPIRED를 제외한 상태에 대해 서비스+사용자당 1건을 강제합니다(app/models/subscription.py:52). 스케줄러 due 조회용 복합 인덱스 ix_subscriptions_due (status, next_billing_at)도 함께 정의됩니다(subscription.py:59).
15.1.4 상태 열거형 (app/models/enums.py:71)
| 상태 | 의미 |
|---|---|
TRIAL |
체험 — 만료 시 첫 정기 결제 |
ACTIVE |
정상 이용 |
PAST_DUE |
결제 실패/유예(접근 유지) |
SUSPENDED |
강제 정지(접근 차단) — 수동 결제 대기 |
CANCELED |
해지 예약(만료일까지 유지) |
EXTENDED |
운영자 만료일 연장 — 이용 허용·새 만료일에 자동결제 |
EXPIRED |
완전 종료(종단) |
💡참고: 외부 서비스의 접근 권한 판정은
ACCESS_ALLOWED_STATUSES(enums.py:82) — TRIAL·ACTIVE·PAST_DUE·CANCELED·EXTENDED는 이용 허용, SUSPENDED·EXPIRED만 차단입니다. '열린 구독' 집합(OPEN_SUBSCRIPTION_STATUSES,enums.py:88)은 EXPIRED만 제외한 6개로, 슬롯 점유(1개 규칙)와 부분 유니크 인덱스 모두 이 집합을 씁니다.
15.2 주요 흐름별 단계 추적
15.2.1 구독 생성 — POST /api/v1/subscriptions
1) 라우터 (app/api/v1/subscriptions.py:83 create_subscription) — 첫 결제(토스 호출)를 수반하므로 payment_rate_limit. 전역 토스 클라이언트가 아니라 서비스별 키로 toss_provider.for_service(service)를 해석해 주입합니다(subscriptions.py:103).
toss = toss_provider.for_service(service)
sub = await subscription_service.create_subscription(
db, toss, cipher, service=service, plan_id=payload.plan_id,
external_user_id=payload.external_user_id,
trial=payload.trial, notifier=notifier)
return await _to_response(db, sub)
_to_response(subscriptions.py:53)는 구독 + 연결 Plan + cards 테이블의 마스킹 카드 정보를 묶어 응답합니다.
2) 서비스 함수 (app/services/subscriptions.py:163 create_subscription) 단계 추적:
| # | 단계 | 코드 위치 | DB/외부 |
|---|---|---|---|
| 1 | external_user_id(이메일) 검증 |
subscriptions.py:203 |
— |
| 2 | 요금제 유효성(ACTIVE·소속) | subscriptions.py:205 |
db.get(Plan) |
| 3 | 체험 가능 여부(trial_enabled·trial_days≥1) |
subscriptions.py:209 |
— |
| 4 | 중복 구독(열린 슬롯) 확인 | subscriptions.py:212 |
get_open_subscription |
| 5 | 등록 카드 조회 — 없으면 NotFoundError |
subscriptions.py:220 |
get_card |
| 6 | 비활성 카드면 ConflictError |
subscriptions.py:225 |
— |
| 7 | 첫구독 판정 → 결제 금액 결정 | subscriptions.py:228-233 |
_is_first_subscription |
| 8 | 읽기 트랜잭션 정리(commit) | subscriptions.py:240 |
COMMIT |
| 9 | Subscription 생성 + flush |
subscriptions.py:253-272 |
INSERT(유니크 경쟁→ConflictError) |
| 10 | (금액>0이면) PENDING 결제행 생성 + 감사 + 1차 commit | subscriptions.py:274-293 |
INSERT + COMMIT |
| 11 | (금액>0이면) 빌링키 복호화 → 결제 실행 | subscriptions.py:295-343 |
resolve_charge |
| 12 | 서비스 알림 + 반환 | subscriptions.py:346-354 |
notifier.send |
금액 결정 로직(subscriptions.py:232):
amount = 0 if trial else (
plan_first_amount(plan) if is_first else plan_recurring_amount(plan))
- 체험(
trial=True): amount=0 → 결제 없이 TRIAL 시작 — 카드 없이도 시작 가능. 만료 시 카드가 있으면 상시 할인가로 첫 자동결제, 없으면 결제 시도 없이 즉시 만료(EXPIRED,renewals._renew_one) - 비체험 첫구독:
plan_first_amount(정가 + 첫구독 할인/무료) - 재구독:
plan_recurring_amount(상시 할인가)
체험 여부에 따른 기간·상태 설정(subscriptions.py:242-250): 체험이면 current_period_end = now + trial_days, 상태 TRIAL; 비체험이면 compute_period_end(now, billing_cycle, cycle_days, cycle_minutes), 상태 ACTIVE. next_billing_at은 기본적으로 period_end로 두되, auto_renew=False이고 체험이 아니면 None으로 설정해 첫 결제 후 갱신을 예약하지 않습니다(subscriptions.py:263-264). 체험이면 auto_renew=False라도 체험 만료 시 첫 결제가 일어나야 하므로 next_billing_at을 유지합니다.
⚠️중요: commit이 2회입니다(
subscriptions.py:293,:343). 결제 전 1차 commit으로 슬롯과 PENDING 결제행을 내구성 있게 선점하고, 결제 결과 확정 후 2차 commit으로 최종 상태를 기록합니다. 1차 commit 없이 결제하면 결제 성공 직후 DB 장애 시 "과금만 되고 구독이 없는" 상태가 됩니다. (8단계의 commit은 검증용 읽기 트랜잭션을 닫기 위한 것으로, rollback이 아닌 commit인 이유는expire_on_commit=False로 로드된plan·card객체를 유지하기 위함입니다 —subscriptions.py:235-240.)
첫 결제 결과별 처리(subscriptions.py:299-343):
try:
result = await resolve_charge(toss, billing_key=billing_key, customer_key=customer_key,
amount=amount, order_id=payment.order_id, ...)
except TossTimeoutError as exc:
# 결과 불명 — 절대 실패 확정 안 함. PENDING 유지, 503 반환(배치 정산이 추후 확정)
await record_audit(..., action="subscription.first_payment_unresolved", ...)
await db.commit()
raise PaymentFailedError(PENDING_GRACE_MESSAGE, code="PAYMENT_UNRESOLVED", http_status=503)
except TossError as exc:
# 확정 실패(카드 거절 등) — 결제=FAILED, 구독=EXPIRED로 저장. 카드는 보존.
payment.status = PaymentStatus.FAILED; payment.failure_code = exc.code
sub.status = SubscriptionStatus.EXPIRED; sub.next_billing_at = None
await record_audit(..., action="subscription.first_payment_failed", ...) # persisted=True
await db.commit()
raise PaymentFailedError(f"첫 결제 실패: {exc.message}", code=exc.code)
payment.status = PaymentStatus.DONE; ...; await db.commit()
상태 전이 결과: 체험 → TRIAL, 비체험 성공 → ACTIVE, 첫 결제 실패(확정) → 구독 EXPIRED + 결제 FAILED(구독 결제 목록에 노출), 타임아웃 → ACTIVE(결제 PENDING — 배치 정산 대기).
💡참고: 첫 결제 실패는 구독을 EXPIRED로, 결제를 FAILED로 남겨 구독 결제 내역에 실패가 보입니다(요청). EXPIRED는 '열린 구독' 판정(
get_open_subscription= OPEN_STATUSES, EXPIRED 제외)에서 빠지므로 재구독을 막지 않습니다. 첫구독 혜택도 유지됩니다 —_is_first_subscription은 "DONE 결제가 있거나, 결제 시도 자체가 없는(FREE/100% 할인) 과거 구독"을 혜택 소진으로 보므로, FAILED 결제(DONE 아님)는 혜택을 소진시키지 않고, 무료 첫구독도 재구독 시 무료가 반복되지 않습니다.
15.2.2 자동연장(스케줄러) — run_renewals → process_due
배치 주기 실행 (app/scheduler/runner.py:107 start_scheduler). APScheduler가 scheduler_interval_minutes(기본 5분) 주기로 run_renewals를 호출합니다(max_instances=1, coalesce=True). 기동 직후에도 첫 배치를 즉시 1회 실행(next_run_time=now)하므로, 서버가 꺼져 있던 동안 밀린 구독 만료(해지예약·정지 유예·비자동갱신)와 갱신 재결제가 재기동 시점에 바로 처리됩니다 — process_due가 due 시각 기준으로 밀린 건을 전부 조회하며, 폭주 시 BATCH_LIMIT 상한만큼 처리하고 다음 주기가 이어받습니다. 다중 인스턴스 동시 기동 시에는 전역 Redis 락으로 1개만 실행됩니다.
분산 락(runner.py:75 run_renewals) — 다중 인스턴스(수평 확장) 환경의 중복 실행을 막기 위해 전역 Redis 락(SET NX, 무작위 토큰)을 사용합니다. 쉽게 말하면 "여러 서버 중 한 대만 배치를 돌리도록 Redis에 깃발을 꽂는" 방식입니다. 획득 실패 시 즉시 None 반환(다른 인스턴스 실행 중). 락 TTL은 배치 진행 중 heartbeat(runner.py:54, TTL의 1/3 주기)가 토큰 일치 시에만 연장하는 데드맨 스위치이며, 종료/예외 시 finally에서 heartbeat 취소 + 토큰 일치 시 락 해제를 보장합니다(runner.py:97-104). 전역 락이 소실돼도 구독별 Redis 락 + 토스 멱등키가 2차 방어선입니다.
진입점 (app/services/renewals.py:158 process_due). 락을 쥔 인스턴스가 배치 1회를 실행합니다.
1) due 대상 조회(읽기 전용, 락 없음) — GlobalSettings(DB)를 같은 세션에서 로드한 뒤(renewals.py:190) 4개 카테고리를 due 시각 오름차순 + BATCH_LIMIT까지 수집(renewals.py:194-215):
| 카테고리 | 조건 | 처리 함수 |
|---|---|---|
canceled_due |
CANCELED + 기간 만료 | _expire_canceled → EXPIRED |
suspended_due |
SUSPENDED + suspended_at ≤ now - grace |
_expire_suspended → EXPIRED |
renew_due |
TRIAL/ACTIVE/PAST_DUE(=DUE_STATUSES) + next_billing_at 설정·도래 |
_renew_one |
non_renewing_due |
ACTIVE + next_billing_at NULL + 기간 만료 |
_expire_non_renewing → EXPIRED |
재시도 한계·간격·유예는 GlobalSettings(DB)에서 매 배치 로드합니다(renewals.py:190). 각 카테고리는 BATCH_LIMIT 상한에 도달하면 WARNING 로그를 남기고 잔여분은 다음 주기로 넘깁니다(renewals.py:218-221). 카테고리 간 상태 집합이 겹치지 않아(CANCELED/SUSPENDED/DUE/ACTIVE+non-renewing) 한 구독이 두 카테고리에 동시에 들 수 없으므로 순서 의존성이 없습니다.
2) 병렬 실행 — 세마포어(동시 실행 개수 제한 장치, BATCH_CONCURRENCY=10, renewals.py:73)로 전 카테고리를 하나의 풀로 실행하고, 한 항목 실패는 errors 집계 후 계속합니다(renewals.py:239-256). 토스 호출 직전 toss_provider.for_service(service)로 서비스별 클라이언트를 해석합니다.
3) _renew_one — 갱신 결제 1건(app/services/renewals.py:390). 토스 호출(최대 65초) 동안 DB 행 잠금·커넥션을 쥐지 않도록 3단계 트랜잭션으로 분리합니다:
# 1단계: Redis 락 + FOR UPDATE 검증 + PENDING 선기록 + commit
token = await acquire_lock(redis, f"lock:renew:{sub_id}") # 실패 시 skipped
sub = await db.get(Subscription, sub_id, with_for_update=True)
... order_id = _renewal_order_id(sub) # (sub.id, period_end, retry_count) 결정적
card = await get_card(db, service_id=..., external_user_id=...) # 빌링키는 cards에서
# (같은 order_id의 DONE 결제가 이미 있으면 재결제 없이 _advance_period로 기간만 전진 — 방어적 복구)
toss = toss_provider.for_service(service) # 키 미설정 → 합성 TossError → _handle_charge_failure
if card is None or sub.card_id is None or not card.is_active: # 미등록/비활성 → 실패 처리
... # 합성 TossError(NO_BILLING_KEY/CARD_INACTIVE) → _handle_charge_failure 위임
billing_key = cipher.decrypt(card.billing_key_encrypted)
await db.commit() # PENDING 내구성 + 행 잠금/커넥션 반납(외부 호출 전 필수)
# 2단계: 외부 호출(DB 비점유)
result = await resolve_charge(toss, billing_key=billing_key, ...)
# ALREADY_PROCESSED_PAYMENT → order_id로 재조회해 DONE이면 성공 취급(recovered_via)
# 3단계: FOR UPDATE 재취득 + 재검증 후 확정
sub = await db.get(Subscription, sub_id, with_for_update=True)
await db.refresh(payment, with_for_update=True)
if payment.status != PaymentStatus.PENDING: # 웹훅/정산이 먼저 확정 → 중복 적용 금지
await db.rollback(); stats["skipped"] += 1; return
# still_due = sub.status in DUE_STATUSES
# 성공 + still_due → payment DONE + _advance_period(sub, plan)
# 성공 + 풀 이탈(취소 등) → 결제만 DONE, requires_review 감사(환불 검토)
# 실패 + still_due → _handle_charge_failure
⚠️중요:
order_id는(sub.id, current_period_end, retry_count)로 결정적입니다(renewals.py:114_renewal_order_id) — 같은 입력이면 항상 같은 주문번호가 나온다는 뜻입니다. 크래시 후 재실행해도 같은 주문/멱등키로 수렴해 이중결제를 막습니다. 타임아웃(결과 불명)은 절대 실패로 확정하지 않고 PENDING 유지·sub불변(stats["unresolved"]) → 다음 배치가 같은 키로 재시도해 토스 멱등 재생으로 수렴합니다(renewals.py:563-575).
갱신 성공 시 기간 전진(renewals.py:119 _advance_period): transition(sub, ACTIVE)(retry_count=0·suspended_at=None 포함) → 새 주기 계산 → next_billing_at 재설정. 단 plan.auto_renew=False면 next_billing_at=None으로 두어 다음 주기 종료 시 _expire_non_renewing이 EXPIRED 처리합니다. 다운타임 캐치업(요청 043): 한 주기를 전진해도 새 만료일이 여전히 과거이면(서버가 오래 꺼져 있던 분·일 단위 주기 등) 만료일이 현재를 지날 때까지 주기를 계속 전진시키되 결제는 이번 1회로 유지합니다 — 지난 주기들은 소급 청구하지 않으며, 건너뛴 주기 수는 감사로그 detail(catchup_skipped_periods)에 남습니다. 기간 앵커(기존 만료일 정렬)는 유지됩니다.
상태 전이(성공): TRIAL→ACTIVE, ACTIVE→ACTIVE, PAST_DUE→ACTIVE.
4) 배치 종료 — reconcile_pending으로 타임아웃 결제 PENDING 정산 스윕을 실행하고 stats(renewed/failed/suspended/expired/skipped/unresolved/reconciled/errors)를 반환합니다(renewals.py:257-261).
15.2.3 자동결제 실패 처리 — _handle_charge_failure
app/services/renewals.py:667. retry_count에 따라 분기합니다.
payment.status = PaymentStatus.FAILED; payment.failure_code = exc.code; ...
if sub.retry_count >= cfg.retry_limit:
transition(sub, SubscriptionStatus.SUSPENDED, now=now) # suspended_at 기록 + next_billing=None
await record_audit(..., action="subscription.suspended", ...)
await email_sender.send(...) # 담당자 정지 안내 메일
stats["suspended"] += 1
else:
sub.retry_count += 1
transition(sub, SubscriptionStatus.PAST_DUE)
sub.next_billing_at = now + cfg.retry_interval # 재시도 예약
await record_audit(..., action="subscription.payment_failed", ...)
await email_sender.send(...) # 담당자 실패 안내 메일
stats["failed"] += 1
retry_count < retry_limit→ PAST_DUE(next_billing_at = now + retry_interval로 재시도 예약, 접근 유지)retry_count >= retry_limit→ SUSPENDED(정지, 접근 차단,next_billing_at=None로 자동결제 중지). 유예일(suspended_grace) 초과 시_expire_suspended가 EXPIRED 처리.
이 경로는 갱신 결제 거절뿐 아니라 카드 미등록(NO_BILLING_KEY)·비활성 카드(CARD_INACTIVE)·토스 키 미설정(TOSS_KEY_NOT_CONFIGURED)도 합성 TossError로 변환해 동일하게 처리합니다(renewals.py:514-545). 즉 청구 불가 상황도 새 상태를 만들지 않고 PAST_DUE→SUSPENDED 경로를 재사용합니다.
💡참고: SUSPENDED에서도 빌링키를 삭제하지 않습니다. 수동 결제로 복구할 수 있도록 카드를 보존합니다(빌링키는 카드 보관함이 소유).
_handle_charge_failure는billing_key파라미터를 받지만 현재 사용하지 않으며, 향후 정책 변경 대비용입니다(renewals.py:680).
15.2.4 취소 / 재개 / 수동결제 / 사용일추가
| 동작 | 라우터 | 서비스 함수 | 결과 |
|---|---|---|---|
| 취소 | subscriptions.py:231 |
cancel_subscription(:357) |
CANCELED(체험은 즉시 만료) |
| 재개 | subscriptions.py:255 |
resume_subscription(:594) |
CANCELED→ACTIVE 또는 PAST_DUE |
| 수동결제 | subscriptions.py:119 |
manual_charge_subscription(:549) |
SUSPENDED/PAST_DUE→ACTIVE |
| 사용일추가 | subscriptions.py:154 |
add_usage_days(:643) |
만료일·결제일 연장(상태 불변) |
취소(subscriptions.py:357) — 대상은 TRIAL/ACTIVE/PAST_DUE. 일반 구독은 기간 만료까지 혜택 유지, 체험 취소는 즉시 만료(이미 CANCELED면 ConflictError):
transition(sub, SubscriptionStatus.CANCELED) # next_billing=None 포함
if was_trial:
sub.current_period_end = utcnow() # 체험 취소 → 즉시 만료(다음 배치가 EXPIRED)
재개(subscriptions.py:594) — 만료 전 CANCELED만 가능(만료된 CANCELED는 ConflictError):
if sub.retry_count > 0:
transition(sub, SubscriptionStatus.PAST_DUE)
sub.next_billing_at = now # 미수금 — 즉시 재시도
else:
transition(sub, SubscriptionStatus.ACTIVE)
sub.next_billing_at = sub.current_period_end # 기존 기간 끝에 자동 갱신
# auto_renew=False면 next_billing_at=None (현 주기 종료 시 만료)
수동결제(subscriptions.py:390 _perform_manual_charge 공통 코어) — SUSPENDED/PAST_DUE 구독을 빌링키로 즉시 재청구(상시 할인가 plan_recurring_amount). 성공 시 ACTIVE 복귀 + 결제 기준일을 결제 시점으로 리셋:
card = await get_card(db, service_id=sub.service_id, external_user_id=sub.external_user_id)
if card is None or sub.card_id is None:
raise PaymentFailedError("등록된 카드가 없습니다. ...", code="NO_BILLING_KEY")
if not card.is_active:
raise PaymentFailedError("비활성화된 카드입니다. ...", code="CARD_INACTIVE")
...
result = await resolve_charge(toss, billing_key=cipher.decrypt(card.billing_key_encrypted), ...)
payment.status = PaymentStatus.DONE; ...
transition(sub, SubscriptionStatus.ACTIVE)
sub.current_period_start = now
sub.current_period_end = compute_period_end(now, plan.billing_cycle, plan.cycle_days, plan.cycle_minutes)
sub.next_billing_at = sub.current_period_end
외부 서비스 호출(manual_charge_subscription, :549)은 actor_type=SERVICE, 어드민 호출(admin_retry_payment, :573)은 actor_type=USER로 동일 코어를 재사용합니다. 수동결제도 타임아웃 시 PENDING 유지·503 반환, 거절 시 결제 FAILED 기록(상태는 불변)입니다(subscriptions.py:473-494).
사용일추가(subscriptions.py:643 add_usage_days) — 이용 중(ACTIVE·EXTENDED·PAST_DUE) 구독만(_USAGE_ADD_STATUSES, :85). 1~전체 설정의 사용일 추가 최대(GlobalSettings.bonus_days_max, 기본 3650 — 요청 040) 범위 검증 후 current_period_end와 next_billing_at(None이 아니면)을 함께 미루며 상태는 바꾸지 않습니다. 토스 호출은 없습니다.
15.2.5 강제취소 / 연장 (어드민)
강제취소(subscriptions.py:679 force_cancel_subscription) — ACTIVE·PAST_DUE·EXTENDED만 허용(그 외 ConflictError). transition(sub, CANCELED)로 즉시 next_billing_at=None이 되어 자동갱신 차단, 기간 만료 시 배치가 EXPIRED 처리. service_scope로 담당 서비스 권한을 검사합니다(None이면 슈퍼관리자 전체 허용, 목록 밖이면 NotFoundError).
연장(subscriptions.py:715 extend_subscription) — EXPIRED 외 열린 상태(OPEN_STATUSES)만 허용. 미래 날짜 new_end로 만료일·결제일을 모두 설정하고 상태를 EXTENDED로 전환:
transition(sub, SubscriptionStatus.EXTENDED)
sub.retry_count = 0; sub.suspended_at = None # 실패/정지 흔적 정리
sub.current_period_end = new_end
sub.next_billing_at = new_end # 그 시점에 갱신 배치가 자동결제로 갱신(DUE_STATUSES에 EXTENDED 포함됨)
💡참고: EXTENDED는
DUE_STATUSES에 포함되므로 새 만료일이 도래하면_renew_one이 자동결제로 갱신(성공 시 ACTIVE)합니다. 재연장(EXTENDED→EXTENDED)도 허용됩니다.
15.2.6 요금제 변경 — POST /.../change-plan (요청 r02)
진입점(app/services/subscriptions.py:766 change_plan) — 대상은 ACTIVE 구독만. 새 요금제(같은 서비스·ACTIVE)를 검증하고 구독 행을 FOR UPDATE로 잠근 뒤(동시 변경의 이중 결제 방지), 두 요금제의 상시할인 적용가(plan_recurring_amount)를 비교해 분기합니다.
업그레이드(새 금액 ≥ 현재 금액) — _upgrade_now. 결제 먼저, 환불 나중 순서로 즉시 전환합니다:
- 새 요금제 상시할인가를 즉시 결제(
PaymentType.CHANGE, 기존 결제 코어와 동일한 3단계 트랜잭션 — PENDING 선커밋 → 토스 호출(DB 비점유) → 확정). 첫구독 혜택은 적용되지 않습니다. - 기존 결제를 환불(토스 부분취소). 환불액은 서비스가 지정한
refund_amount(0~잔여 환불가능액) 또는 일할계산(미사용 기간 초 비례, 원 단위 내림). plan_id교체 + 기간을 지금부터 새 주기로 리셋.
결제 실패 시 구독은 무변경(402). 환불만 실패하는 드문 경우 전환은 확정하고 refund_status=FAILED로 응답하며, 감사로그에 수동 환불 대상으로 남깁니다.
다운그레이드(새 금액 < 현재 금액) — _schedule_downgrade. pending_plan_id에 저장만 하고 결제·환불·기간 변경이 없습니다. 재요청 시 예약을 덮어씁니다. 실제 전환은 갱신 배치가 수행합니다 — _renew_one이 pending_plan_id가 있으면 새 요금제 금액으로 청구하고(renewals.py:445-449) 성공 시 plan_id를 교체·예약을 지웁니다(renewals.py:630-640).
예약 취소(subscriptions.py:1159 cancel_plan_change) — pending_plan_id를 지웁니다. 예약이 없으면 NotFoundError. 구독을 취소(cancel)해도 예약은 유지됩니다 — 만료되면 갱신이 없어 자연 무효, 재개(resume)하면 다시 유효해집니다.
세 경로 모두 감사로그(subscription.plan_change_*)와 서비스 알림(subscription.plan_changed)을 남깁니다. 어드민 구독 상세의 "요금제 변경 이력" 섹션이 이 감사로그를 보여줍니다.
15.3 상태 전이·제약
15.3.1 상태 머신 (app/services/transitions.py)
모든 상태 변경은 transition(sub, new_status)(transitions.py:93)를 거칩니다. 허용되지 않은 전이는 InvalidStateTransition(코드 버그 → 500)으로 드러납니다.
TRIAL ──→ ACTIVE ──→ PAST_DUE ──→ SUSPENDED ──→ EXPIRED
│ │ ↑ │ ↑ │
│ │ └────────┘ │ └──(수동결제)──→ ACTIVE
└────┬────┴──────────────┘
↓
CANCELED ──→ EXPIRED (재개: CANCELED → ACTIVE | PAST_DUE)
💡참고: 카드 없이 시작한 체험은 만료 시 TRIAL → EXPIRED로 곧바로 종료됩니다(그림에는 생략 — 아래 표 참조).
허용 전이 표(transitions.py:43 ALLOWED_TRANSITIONS) 요약 — EXTENDED는 어떤 열린 상태에서도 진입 가능(연장):
| 현재 → 허용 대상 |
|---|
TRIAL → ACTIVE, PAST_DUE, SUSPENDED, CANCELED, EXTENDED, EXPIRED (EXPIRED = 카드 없이 시작한 체험이 만료돼 즉시 종료되는 경우) |
ACTIVE → ACTIVE, PAST_DUE, SUSPENDED, CANCELED, EXTENDED, EXPIRED |
PAST_DUE → ACTIVE, PAST_DUE, SUSPENDED, CANCELED, EXTENDED |
SUSPENDED → ACTIVE, CANCELED, EXTENDED, EXPIRED |
CANCELED → ACTIVE, PAST_DUE, EXTENDED, EXPIRED |
EXTENDED → ACTIVE, PAST_DUE, SUSPENDED, CANCELED, EXTENDED, EXPIRED |
EXPIRED → (없음 — 종단) |
transition은 전이 허용 검증 + 보편 불변식(어느 경로로 바뀌든 항상 지켜야 하는 값 정리)만 책임집니다(transitions.py:107-115):
- EXPIRED/CANCELED 진입 →
next_billing_at=None - SUSPENDED 진입 →
suspended_at=now기록 +next_billing_at=None - ACTIVE 진입 →
retry_count=0,suspended_at=None(실패 흔적 초기화)
전이별 고유 필드(기간 전진, 재시도 스케줄 등)는 호출측이 transition 호출 후 설정합니다. EXPIRED는 종단 상태로 어떤 전이도 불가합니다(transitions.py:89).
15.3.2 제약 요약
| 제약 | 위치 |
|---|---|
| 서비스+사용자당 열린 구독 1개 | uq_subscriptions_one_per_user(부분 유니크, subscription.py:52) |
| 구독 생성 전 카드 등록 필수(체험 제외) | subscriptions.py get_card → 비체험만 NotFoundError |
| 비활성 카드로 생성 불가 | subscriptions.py:225 ConflictError |
| 활성 구독 있는 카드 삭제 불가 | cards.py (카드 문서 참조) |
| 자동결제 실패 → PAST_DUE → SUSPENDED → EXPIRED | renewals.py:667 _handle_charge_failure |
| 사용일추가는 이용 중(ACTIVE/EXTENDED/PAST_DUE)만 | subscriptions.py:85 _USAGE_ADD_STATUSES |
15.3.3 에러 처리
| 조건 | 예외 | HTTP |
|---|---|---|
| 요금제 없음/비활성/타 서비스 | NotFoundError |
404 |
| 체험 미제공 요금제에 trial | InputValidationError |
422 |
| 이미 열린 구독 존재 | ConflictError |
409 |
| 카드 미등록 | NotFoundError |
404 |
| 비활성 카드 | ConflictError |
409 |
| 첫 결제 타임아웃(결과 불명) | PaymentFailedError(503, PAYMENT_UNRESOLVED) |
503 |
| 첫 결제 카드 거절 등 | PaymentFailedError |
4xx |
| 만료된 CANCELED 재개 | ConflictError |
409 |
| 강제취소·연장 시 권한 밖/없음 | NotFoundError |
404 |
| 요금제 변경: ACTIVE 아님/동일 요금제 | ConflictError |
409 |
요금제 변경: refund_amount 잔여 환불가능액 초과 |
InputValidationError |
422 |
| 요금제 변경: 예약 취소 시 예약 없음 | NotFoundError |
404 |
15.4 유지보수 팁
- 재시도 정책을 바꾸려면:
GlobalSettings(DB)의retry_limit/retry_interval_hours/suspended_grace_days를 수정하세요.process_due가 매 배치 로드하므로 즉시 반영됩니다(renewals.py:190). DB 연결 불가 시 폴백은renewals.py:61-63(DEFAULT_RETRY_LIMIT=4,12h,30d). - 상태 전이 규칙을 바꾸려면:
app/services/transitions.py:43ALLOWED_TRANSITIONS만 고치면 됩니다. 호출부 if문에 흩어져 있던 규칙이 한곳에 모였습니다. - 결제 금액 계산을 바꾸려면:
app/services/billing_math.py의plan_first_amount(첫구독)·plan_recurring_amount(상시)를 보세요. 금액 결정 분기는subscriptions.py:232. - 배치 처리량/동시성을 조정하려면:
BATCH_LIMIT(.envrenewal_batch_limit,renewals.py:69),BATCH_CONCURRENCY=10(renewals.py:73). 상한 도달 시 WARNING 로그가 남고 잔여분은 다음 주기에 처리됩니다. - 분산 락/배치 주기를 조정하려면: 전역 락 TTL은
.env scheduler_lock_ttl_seconds(runner.py:31), 배치 주기는scheduler_interval_minutes(runner.py:123), 활성화는scheduler_enabled입니다. - 이중결제가 의심되면:
_renewal_order_id(renewals.py:114)의 결정성과 3단계 트랜잭션의 PENDING 재검증(renewals.py:594)을 확인하세요. 타임아웃은 절대 실패 확정하지 않습니다. - 수동결제가 카드 없음/비활성으로 막히면:
_perform_manual_charge(subscriptions.py:429-437)의get_card·is_active검사를 보세요. 카드 보관함에서 카드를 재등록/활성화한 뒤 다시 시도해야 합니다.
🔗함께 보기: 자동연장에 쓰이는 빌링키가 어떻게 보관·복호화되는지는 카드 보관함 기능을 보세요.