콘텐츠로 이동

멤버십 · 구독

사용자의 구독 상태를 표현하는 핵심 도메인. FREE / STANDARD / PREMIUM 세 티어를 지원하며, 각 멤버십은 30일 단위로 시작·만료되고 결제(Payment) 또는 쿠폰(Coupon)에 묶여 발급된다. 유료 구독은 매일 오전 9시 스케줄러가 만료 건을 자동 갱신하며, 갱신 실패 시 isSubscriptionPaused 플래그로 일시정지된다.

사용자 여정

신규 가입 직후 FREE 멤버십 자동 부여

sequenceDiagram
    autonumber
    participant U as 사용자
    participant FE as 프론트엔드
    participant API as Backend (AccountController)
    participant SVC as AccountService
    participant DB as MySQL

    U->>FE: 폰 인증 완료 → 회원가입
    FE->>API: 회원가입 흐름
(Auth 페이지의 verifyAndCreateAccount 단계) API->>SVC: account.initFreeMembership() SVC->>DB: INSERT membership (FREE, startDate=오늘, endDate=오늘+30) Note over DB: 계정엔 항상 최소 1개 membership 존재해야 함
(findCurrentMembership null 시 UI 깨짐) API-->>FE: 200 OK

결제 후 유료 멤버십 시작 (Toss 일반 결제)

sequenceDiagram
    autonumber
    participant U as 사용자
    participant FE as 프론트엔드
    participant API as Backend (AccountController)
    participant SVC as AccountService
    participant DB as MySQL
    participant TOSS as Toss Payments

    U->>FE: STANDARD/PREMIUM 결제 완료
    FE->>API: POST /accounts/payments/confirm
{ orderId, paymentKey, amount } API->>SVC: confirmPayment(...) SVC->>TOSS: POST /v1/payments/confirm TOSS-->>SVC: paymentKey, method, approvedAt SVC->>DB: payment.status=DONE SVC->>DB: account.initMembershipByPayment(payment, null) Note over DB: addMembership() 내부에서
현재 FREE면 endDate를 어제로 당기고
현재 유료면 새 멤버십 startDate를
maxAfterMembership.endDate+1로 연속 배치 API-->>FE: { membershipType, startDate, endDate }

자동 갱신 스케줄러 (매일 09:00)

sequenceDiagram
    autonumber
    participant CRON as @Scheduled cron(0 0 9 * * ?)
    participant SCHED as SubscriptionRenewalScheduler
    participant SVC as BillingService
    participant DB as MySQL
    participant TOSS as Toss Billing API

    CRON->>SCHED: processRenewals()
    SCHED->>DB: findExpiringTodayWithActiveBilling(today)
    Note over DB: endDate=오늘 AND
isSubscriptionWithdrawal IS NULL AND
PaymentMethod ACTIVATED + billingKey 존재 loop 만료 건마다 SCHED->>SVC: chargeBilling(accountId) SVC->>TOSS: POST /v1/billing/{billingKey} alt 결제 성공 TOSS-->>SVC: paymentKey SVC->>DB: 새 Payment(DONE) + 새 Membership 생성 else 결제 실패 TOSS-->>SVC: 4xx/5xx SVC->>DB: membership.isSubscriptionPaused = true SVC->>DB: paymentMethod.deactivate(reason) end end

백엔드 구현

계층 클래스 / 파일 역할
Controller AccountController (apps/backend/.../controller/AccountController.kt) /accounts/memberships/** HTTP endpoint
Service AccountService (apps/backend/.../application/service/account/AccountService.kt) 멤버십 조회·초기화·해지 로직
Service BillingService (apps/backend/.../application/service/account/BillingService.kt) 정기결제 charge — 결과로 새 Membership 생성
Scheduler SubscriptionRenewalScheduler (apps/backend/.../application/service/account/SubscriptionRenewalScheduler.kt) 매일 09:00 만료 건 자동 갱신
Domain (Entity) Membership (apps/backend/.../application/domain/product/Membership.kt) startDate/endDate, paidTime, withdraw/pause 플래그, currentCycleStart()
Domain (Entity) Account (apps/backend/.../application/domain/auth/Account.kt) findCurrentMembership(), addMembership(), initMembershipBy***()
Domain (Enum) MembershipType (apps/backend/.../application/domain/product/MembershipType.kt) FREE / STANDARD / PREMIUM (각 appliedDays=30)
Config MembershipPlanProperties (apps/backend/.../common/config/MembershipPlanProperties.kt) YML 기반 플랜별 가격 / 월 토큰 한도
Repository MembershipRepository (apps/backend/.../persistence/MembershipRepository.kt) findExpiringTodayWithActiveBilling, querydsl 페이지 조회

도메인 규칙

규칙 위치 값 / 설명
적용 기간 MembershipType.appliedDays 모든 티어 30일 (FREE/STANDARD/PREMIUM 동일)
가격 (월) application.yml membership.plans.*.price FREE 0원 / STANDARD 10,000원 / PREMIUM 15,000원
월 토큰 한도 application.yml membership.plans.*.monthly-token-limit FREE 50,000 / STANDARD 500,000 / PREMIUM 850,000
현재 멤버십 판정 Account.findCurrentMembership() endDate >= 오늘인 첫 멤버십
직전 멤버십 판정 Account.findPreviousMembership() endDate < 오늘인 멤버십 중 endDate 최대
멤버십 추가 시 정렬 Account.addMembership() 기존 FREE면 endDate를 어제로 당김. 기존 유료면 새 멤버십 startDate를 maxAfterMembership.endDate+1로 연속 배치
가입 시 FREE 자동 부여 Account.initFreeMembership() 가입 직후 호출. 계정엔 항상 최소 1개 membership 존재
멤버십 해지 (구독 취소) Membership.withdrawal() isSubscriptionWithdrawal=true. 현재 주기는 endDate까지 유지, 자동 갱신 대상에서 제외
멤버십 해지 자격 Account.withdrawal() 회원탈퇴는 현재 멤버십이 FREE일 때만 허용 — 유료면 WithdrawalFailException
자동 갱신 실패 처리 SubscriptionRenewalScheduler.processRenewals() isSubscriptionPaused=true + PaymentMethod deactivate
토큰 사용 주기 시작 Membership.currentCycleStart() startDate 기점 30일 반복. TokenUsageLog 집계 시 >= 경계
멤버십 초기화 (테스트) AccountService.initializeMembership() 모든 membership/payment/coupon 삭제 후 expired FREE 1개 생성 (최소 1건 보장)

API 엔드포인트

Method Path 설명
GET /accounts/memberships/current 현재 + 직전 멤버십 타입 + 일시정지 여부 조회
GET /accounts/memberships 전체 멤버십 + 결제 이력 페이지 (마이페이지용)
POST /accounts/memberships/initialize 모든 membership/payment 삭제 + expired FREE 재생성 (테스트·복구)
POST /accounts/memberships/withdrawal 현재 멤버십 해지 (구독 취소, 다음 갱신 차단)
GET /accounts/billing/status 자동결제 활성 여부 + 카드 정보 + 다음 결제일
POST /accounts/billing/cancel 자동결제 + 빌링키 해제 + 현재 멤버십 withdraw

스펙 상세(request/response schema)는 API 레퍼런스 / OpenAPI 페이지에서 endpoint별 검색.

결제 등록·확정·웹훅 흐름은 결제 (Toss V2) 페이지 참조. 쿠폰으로 멤버십을 발급하는 흐름은 쿠폰 페이지 참조.

데이터 모델

erDiagram
    ACCOUNT {
        string account_id PK
        string phone
        string role "USER / ADMIN"
    }
    MEMBERSHIP {
        int membership_id PK "AUTO_INCREMENT"
        string account_id FK
        string payment_id FK "nullable — 쿠폰 발급 시 null"
        string code FK "nullable — 결제 발급 시 null (Coupon.code)"
        string membership_type "FREE / STANDARD / PREMIUM"
        datetime start_date
        datetime end_date "start_date + 30일"
        datetime paid_time
        boolean is_subscription_paused "자동 갱신 실패 시 true"
        boolean is_subscription_withdrawal "사용자 해지 신청 시 true"
    }
    PAYMENT {
        string payment_id PK
        string account_id FK
        string product_type "STANDARD / PREMIUM / TOKEN_PACK"
        string status "PENDING / DONE / FAILED / WITHDRAWAL ..."
    }
    COUPON {
        string code PK
        string product_type
        string status "UNUSED / USED"
    }
    TOKEN_USAGE_LOG {
        int token_usage_log_id PK
        string account_id FK
        int total_tokens
        datetime created_at "currentCycleStart 기점 SUM"
    }
    ACCOUNT ||--o{ MEMBERSHIP : "1:N (시간순 누적)"
    MEMBERSHIP }o--o| PAYMENT : "0..1 (결제 발급 시)"
    MEMBERSHIP }o--o| COUPON : "0..1 (쿠폰 발급 시)"
    ACCOUNT ||--o{ TOKEN_USAGE_LOG : "주기별 한도 집계"

설정

항목 위치 비고
플랜별 가격·토큰 한도·기간 apps/backend/src/main/resources/application.yml:84-105 membership.plans.{FREE,STANDARD,PREMIUM} + membership.token-pack
자동 갱신 스케줄 SubscriptionRenewalScheduler.processRenewals() @Scheduled(cron = "0 0 9 * * ?") — 매일 09:00 KST
멤버십 만료 후 사용 차단 예외 Account.validateUsage() FREE 만료 시 FreeMembershipEndException, 유료 만료 시 PaidMembershipEndException, 일시정지 시 MembershipPausedException
토큰 한도 검증 위치 MembershipPlanProperties.getMonthlyTokenLimit() null 반환 시 무제한. ADMIN role은 quota 검증 우회

알려진 이슈 / 개선 예정

  • Flyway 미사용: 신규 컬럼(is_subscription_paused, is_subscription_withdrawal 등) 추가 시 마이그레이션은 외부에서 수동 적용 필요 (infra/sql/2026-05-11-prod-init.sql 패턴)
  • 만료 후 grace period 없음: 자동 갱신 실패 시 즉시 isSubscriptionPaused=true로 전환. 사용자에게 재시도 안내·재충전 유예 기간 미구현
  • 멤버십 시작일 보정 로직의 복잡성: Account.addMembership()이 현재 멤버십 종류에 따라 endDate를 어제로 당기거나 새 멤버십 startDate를 미래로 미는 분기 — 테스트(changeMembershipWithdrawalAvailableForTest)에서 11번 반복으로 미래 메모리십을 쌓는 코드가 있을 정도로 엣지 케이스가 많음
  • 테스트 전용 메서드가 production 서비스 클래스에 혼재 (switchTierForTest, injectCycleUsageForTest, grantTokenPackForTest): profile gate 없이 노출 — TestAccountController로 분리되어 있다고 가정하지만 AccountService가 직접 호출 가능
  • 멤버십 해지 시 환불 미연동: Membership.withdrawal()은 자동 갱신만 차단. 잔여 일수에 비례한 환불은 별도 cancelTossPayment 흐름으로 수동 처리

관련 문서