Zum Hauptinhalt springen

Billing 모듈

모듈 개요

billing 모듈은 Lumie 플랫폼의 구독 결제와 알림톡 크레딧을 관리합니다. 결제 경로는 두 가지로 명확히 분리됩니다.

경로대상상태
구독 결제Lumie SaaS 요금제(구독)Toss Payments 연동 — 운영 중
수강료 수납학원 ↔ 학생/보호자 간 수강료TuitionBillingGatewayPort 시임 — PaySsam("결제선생") 어댑터 대기 중

수강료 직접 Toss 연동(Toss sub-merchant/빌링키 방식)은 commit ff9db43에서 제거되었습니다. 수납 로직은 modules/tuition이 담당하고, 결제 대행사와의 통신 지점은 TuitionBillingGatewayPort 단일 파일 교체로 활성화됩니다.

  • 배포: lumie-backend 모놀리스의 modules/billing
  • 데이터베이스: PostgreSQL public 스키마 (구독, 요금제, 인보이스, 알림톡 크레딧)
  • 결제 게이트웨이: Toss Payments (TossPaymentClient) — 구독 과금 전용
  • 주요 의존성: TenantService(internal-api)

주요 기능

1. 요금제 관리

  • 4단계 요금제 (FREE, BASIC, PRO, ENTERPRISE)
  • 요금제별 제한사항 및 기능 관리
  • 월간/연간 요금 설정

2. 구독 관리

  • 구독 생성, 변경, 취소, 취소 철회
  • 무료 구독 자동 생성
  • 구독 상태 관리 (ACTIVE, CANCELLED, SUSPENDED 등)
  • 예약 플랜 변경, 월간 자동 과금 스케줄러

3. 결제 처리 (구독 전용 — Toss Payments)

  • 빌링키 등록/관리 (/v1/billing-keys)
  • 결제 확인 및 Toss 웹훅 처리 (/internal/webhooks/toss)
  • 인보이스 생성 및 관리
  • 결제 내역 조회

4. 알림톡 크레딧

  • 크레딧 잔액 조회 및 충전 (/v1/alimtalk/credits)
  • 자동 충전 설정/해제
  • Toss Payments를 통한 크레딧 구매

5. 할당량 검증

  • 학생 수, 학원 수, 스태프 수 제한
  • OMR 월간 할당량 관리
  • BillingService(internal-api) 통해 다른 모듈이 조회

6. 수강료 수납 게이트웨이 시임 (modules/tuition)

  • TuitionBillingGatewayPort — PG-무관 아웃바운드 포트
  • 현재 구현체: NotConfiguredTuitionBillingGateway (loud-fail 스텁)
  • PaySsam("결제선생") 어댑터 1-파일 교체로 활성화 예정

아키텍처

헥사고날 구조

modules/billing/src/main/java/com/lumie/billing/
├── domain/{entity,vo,repository,exception}/
├── application/{service,port/out,dto/{request,response}}/
└── adapter/
├── in/web/ # REST 컨트롤러
├── in/internal/ # BillingServiceAdapter (BillingService impl)
├── in/scheduling/ # 월간 자동 과금 스케줄러
└── out/{persistence,external} # JPA, TossPaymentClient, PopbillTaxInvoiceClient

도메인 모델

주요 엔티티: Plan, Subscription, Invoice, BillingKey, PaymentTransaction, AlimtalkCredit, TaxInvoice

// Plan — 요금제 (FREE / BASIC / PRO / ENTERPRISE)
// 관련 VO: PlanLimits (maxStudents, maxAcademies, maxAdmins, omrMonthlyQuota)
// PlanFeatures (customDomains, whiteLabeling, ...)

// Subscription — 테넌트별 구독 레코드
// 상태: SubscriptionStatus (ACTIVE, TRIALING, PAST_DUE, SUSPENDED, CANCELLED 등)
// 예약 플랜 변경: pendingPlanChange 필드

// Invoice — 구독 청구서
// InvoiceStatus: PENDING, PAID, FAILED, REFUNDED
// 타임스탬프: Instant (UTC)

// BillingKey — Toss 빌링키 (구독 자동 과금용)
// BillingKeyStatus: ACTIVE, REVOKED

// AlimtalkCredit — 알림톡 발송 크레딧 잔액 + 자동 충전 설정

API 명세

REST 엔드포인트 (구독 결제)

메서드경로설명
GET/v1/plans요금제 목록 조회
POST/v1/subscriptions구독 생성 (Idempotency-Key 필수)
GET/v1/subscriptions/{tenantSlug}구독 조회
PATCH/v1/subscriptions/{tenantSlug}플랜 변경 예약
DELETE/v1/subscriptions/{tenantSlug}구독 취소
DELETE/v1/subscriptions/{tenantSlug}/cancellation취소 철회
POST/v1/billing-keys빌링키 등록 (Idempotency-Key 필수)
GET/v1/billing-keys빌링키 목록
GET/v1/billing-keys/active활성 빌링키 조회
DELETE/v1/billing-keys/{billingKeyId}빌링키 해지
POST/v1/payments/confirm결제 확인 (Idempotency-Key 필수)
GET/v1/payments/history결제 내역 (현재 테넌트)
GET/v1/payments/history/{tenantSlug}결제 내역 (슬러그 지정)
POST/v1/billing/subscribe구독 시작 통합 플로우
GET/v1/billing/config빌링 설정 조회
GET/v1/alimtalk/credits크레딧 잔액 조회
POST/v1/alimtalk/credits/recharge크레딧 충전 (Idempotency-Key 필수)
PUT/v1/alimtalk/credits/auto-recharge자동 충전 설정
DELETE/v1/alimtalk/credits/auto-recharge자동 충전 해제
POST/internal/webhooks/tossToss Payments 웹훅 (내부용)

internal-api (BillingService)

libs/internal-api에 정의된 BillingService 인터페이스를 BillingServiceAdapter가 구현합니다. exam, staff 등 다른 모듈이 할당량 검증과 구독 정보를 in-process로 조회합니다.

// libs/internal-api/src/main/java/com/lumie/billing/api/BillingService.java
public interface BillingService {
Optional<SubscriptionData> getSubscription(String tenantSlug);
QuotaResult checkQuota(String tenantSlug, MetricType metricType);
Optional<PlanFeaturesData> getPlanFeatures(String planId);
SubscriptionData createFreeSubscription(Long tenantId, String tenantSlug);
}
// 할당량 확인 예시 (exam 모듈에서)
BillingService.QuotaResult result =
billingService.checkQuota(tenantSlug, MetricType.OMR_MONTHLY_QUOTA);
if (!result.allowed()) {
throw new ExamException(ExamErrorCode.OMR_QUOTA_EXCEEDED);
}

수강료 수납 — TuitionBillingGatewayPort 시임

수강료 수납은 modules/tuition이 담당합니다. billing 모듈은 직접 관여하지 않으며, merchant_profiles 테이블은 보존 상태입니다.

포트 정의 (modules/tuition)

// tuition/application/port/out/TuitionBillingGatewayPort.java
public interface TuitionBillingGatewayPort {
// 보호자에게 청구서 전송 (PaySsam이 수납 대행)
SendInvoiceResult sendInvoice(String orderId, String guardianPhone, String guardianName,
String title, BigDecimal amount, Instant dueDate);
// 수납 상태 조회
CollectionStatusResult queryStatus(String externalInvoiceId);
}

현재 구현체 (스텁)

// tuition/adapter/out/external/NotConfiguredTuitionBillingGateway.java
@Component
public class NotConfiguredTuitionBillingGateway implements TuitionBillingGatewayPort {
// sendInvoice / queryStatus 호출 시 TuitionException(GATEWAY_NOT_CONFIGURED) throw
// PaySsam 어댑터로 이 파일을 교체하면 즉시 활성화
}

HARD_RULES_CROSS에 따라 sendInvoice/queryStatus는 반드시 @Transactional 경계 바깥에서 호출해야 합니다.

비즈니스 로직

요금제 체계

플랜월간 요금연간 요금최대 학생최대 학원최대 관리자OMR 할당량
FREE0원0원30명1개2명50회
BASIC49,000원490,000원100명3개5명200회
PRO149,000원1,490,000원500명10개20명1,000회
ENTERPRISE499,000원4,990,000원무제한무제한무제한무제한

할당량 검증 로직

public QuotaCheckResponse checkQuota(String tenantSlug, MetricType metricType, long currentUsage) {
Subscription subscription = subscriptionPersistencePort.findActiveByTenantSlug(tenantSlug)
.orElseThrow(() -> new ResourceNotFoundException("Subscription", tenantSlug));

PlanLimits limits = subscription.getLimits();
int limit = limits.getLimit(metricType);
boolean allowed = limits.isWithinLimit(metricType, currentUsage);

if (!allowed) {
return QuotaCheckResponse.exceeded(metricType, currentUsage, limit);
}

return QuotaCheckResponse.allowed(metricType, currentUsage, limit);
}

사용량 추적

public void recordUsage(Long tenantId, String tenantSlug, MetricType metricType, long value) {
LocalDate today = LocalDate.now();

UsageLog usageLog = usageLogPersistencePort
.findByTenantIdAndMetricTypeAndRecordedDate(tenantId, metricType, today)
.map(existing -> {
existing.updateValue(value);
return existing;
})
.orElseGet(() -> UsageLog.record(tenantId, tenantSlug, metricType, value));

usageLogPersistencePort.save(usageLog);
}

결제 처리 플로우

public PaymentResponse confirmPayment(ConfirmPaymentRequest request) {
// 1. 인보이스 조회
Invoice invoice = invoicePersistencePort.findByOrderId(request.orderId())
.orElseThrow(() -> new ResourceNotFoundException("Invoice", "orderId:" + request.orderId()));

// 2. 금액 검증
if (invoice.getAmount().toLong() != request.amount()) {
throw new PaymentFailedException("Amount mismatch");
}

// 3. 결제 게이트웨이 호출
PaymentResponse response = paymentGatewayPort.confirmPayment(
request.paymentKey(), request.orderId(), request.amount());

// 4. 결제 성공 시 처리
if (response.isSuccess()) {
invoice.markAsPaid(request.paymentKey());
invoicePersistencePort.save(invoice);
extendSubscription(invoice);
}

return response;
}

모듈 간 연동

Tenant Service 연동

TenantService(internal-api)를 in-process로 주입받아 테넌트 정보를 조회합니다.

Toss Payments 연동 (구독 결제)

TossPaymentClientPaymentGatewayPort를 구현합니다. 구독 과금(/v1/billing-keys 빌링키 기반) 및 알림톡 크레딧 충전에 사용됩니다. 운영 중입니다.

데이터베이스 설계

주요 테이블

-- 요금제 테이블
CREATE TABLE plans (
id VARCHAR(20) PRIMARY KEY,
name VARCHAR(50) NOT NULL,
description VARCHAR(500),
monthly_price DECIMAL(15,0) NOT NULL,
yearly_price DECIMAL(15,0) NOT NULL,
max_students INTEGER NOT NULL,
max_academies INTEGER NOT NULL,
max_admins INTEGER NOT NULL,
omr_monthly_quota INTEGER NOT NULL,
custom_domains BOOLEAN NOT NULL DEFAULT false,
advanced_analytics BOOLEAN NOT NULL DEFAULT false,
priority_support BOOLEAN NOT NULL DEFAULT false,
api_access BOOLEAN NOT NULL DEFAULT false,
white_labeling BOOLEAN NOT NULL DEFAULT false,
display_order INTEGER NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 구독 테이블
CREATE TABLE subscriptions (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
tenant_slug VARCHAR(30) NOT NULL,
plan_id VARCHAR(20) NOT NULL REFERENCES plans(id),
status VARCHAR(20) NOT NULL,
started_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP,
cancelled_at TIMESTAMP,
billing_cycle_day INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 인보이스 테이블
CREATE TABLE invoices (
id BIGSERIAL PRIMARY KEY,
invoice_number VARCHAR(50) NOT NULL UNIQUE,
subscription_id BIGINT NOT NULL REFERENCES subscriptions(id),
tenant_slug VARCHAR(30) NOT NULL,
amount DECIMAL(15,0) NOT NULL,
status VARCHAR(20) NOT NULL,
description VARCHAR(500),
billing_period_start TIMESTAMP,
billing_period_end TIMESTAMP,
due_date TIMESTAMP,
paid_at TIMESTAMP,
payment_key VARCHAR(200),
order_id VARCHAR(100),
failure_reason VARCHAR(500),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- 사용량 로그 테이블
CREATE TABLE usage_logs (
id BIGSERIAL PRIMARY KEY,
tenant_id BIGINT NOT NULL,
tenant_slug VARCHAR(30) NOT NULL,
metric_type VARCHAR(30) NOT NULL,
value BIGINT NOT NULL,
recorded_date DATE NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

인덱스 전략

-- 성능 최적화를 위한 인덱스
CREATE INDEX idx_subscriptions_tenant_id ON subscriptions(tenant_id);
CREATE INDEX idx_subscriptions_tenant_slug ON subscriptions(tenant_slug);
CREATE INDEX idx_invoices_subscription_id ON invoices(subscription_id);
CREATE INDEX idx_invoices_tenant_slug ON invoices(tenant_slug);
CREATE INDEX idx_invoices_status ON invoices(status);
CREATE INDEX idx_usage_logs_tenant_date ON usage_logs(tenant_id, recorded_date);
CREATE INDEX idx_usage_logs_tenant_metric ON usage_logs(tenant_id, metric_type);

초기 데이터

-- 요금제 초기 데이터
INSERT INTO plans (id, name, description, monthly_price, yearly_price, max_students, max_academies, max_admins, omr_monthly_quota, custom_domains, advanced_analytics, priority_support, api_access, white_labeling, display_order)
VALUES
('FREE', 'Free', '무료 체험판', 0, 0, 30, 1, 2, 50, false, false, false, false, false, 1),
('BASIC', 'Basic', '소규모 학원용', 49000, 490000, 100, 3, 5, 200, false, false, false, false, false, 2),
('PRO', 'Pro', '중규모 학원용', 149000, 1490000, 500, 10, 20, 1000, true, true, false, true, false, 3),
('ENTERPRISE', 'Enterprise', '대형 학원/프랜차이즈', 499000, 4990000, 10000, 100, 100, 10000, true, true, true, true, true, 4)
ON CONFLICT (id) DO NOTHING;

설정

billing 모듈은 독립적인 설정 파일이 없습니다. 모든 설정은 app/src/main/resources/application.yaml에서 통합 관리됩니다. 모듈 간 통신은 in-process 메서드 호출로 처리됩니다.

Toss Payments 시크릿은 Vault를 통해 주입됩니다(toss.payments.secret-key, toss.payments.client-key).

에러 처리

비즈니스 예외

public enum BillingErrorCode implements ErrorCode {
// Plan errors
PLAN_NOT_FOUND("PLAN_001", "Plan not found", 404),

// Subscription errors
SUBSCRIPTION_NOT_FOUND("SUBSCRIPTION_001", "Subscription not found", 404),
SUBSCRIPTION_ALREADY_EXISTS("SUBSCRIPTION_002", "Subscription already exists", 409),
INVALID_SUBSCRIPTION_STATE("SUBSCRIPTION_003", "Invalid subscription state", 400),

// Quota errors
QUOTA_EXCEEDED("QUOTA_001", "Quota exceeded", 403),

// Payment errors
PAYMENT_FAILED("PAYMENT_001", "Payment failed", 402),
INVOICE_NOT_FOUND("INVOICE_001", "Invoice not found", 404),

// General errors
INTERNAL_ERROR("BILLING_999", "Internal server error", 500);
}

글로벌 예외 처리

@RestControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(QuotaExceededException.class)
public ResponseEntity<ProblemDetail> handleQuotaExceeded(QuotaExceededException e) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.FORBIDDEN, e.getMessage());
problem.setType(URI.create("https://lumie.com/errors/quota-exceeded"));
problem.setTitle("Quota Exceeded");
problem.setProperty("metricType", e.getMetricType().name());
problem.setProperty("currentUsage", e.getCurrentUsage());
problem.setProperty("limit", e.getLimit());

return ResponseEntity.status(HttpStatus.FORBIDDEN).body(problem);
}
}

테스트

단위 테스트 예시

@ExtendWith(MockitoExtension.class)
class SubscriptionQueryServiceTest {

@Mock
private SubscriptionPersistencePort subscriptionPersistencePort;

@Mock
private UsageLogPersistencePort usageLogPersistencePort;

@InjectMocks
private SubscriptionQueryService subscriptionQueryService;

@Test
void shouldCheckQuotaSuccessfully() {
// Given
Subscription subscription = createTestSubscription();
when(subscriptionPersistencePort.findActiveByTenantSlug("test-tenant"))
.thenReturn(Optional.of(subscription));
when(usageLogPersistencePort.sumValueByTenantIdAndMetricTypeAndRecordedDateBetween(
any(), any(), any(), any())).thenReturn(Optional.of(50L));

// When
QuotaCheckResponse response = subscriptionQueryService.checkQuota(
"test-tenant", MetricType.MAX_STUDENTS, 50L);

// Then
assertThat(response.allowed()).isTrue();
assertThat(response.currentUsage()).isEqualTo(50L);
}
}

모니터링

주요 메트릭

  • 구독 생성/변경/취소 횟수
  • 결제 성공/실패율
  • 할당량 초과 발생 빈도
  • internal-api 호출 응답 시간
  • 데이터베이스 연결 풀 상태

로그 예시

{
"timestamp": "2024-01-15T10:30:00.000Z",
"level": "INFO",
"logger": "com.lumie.billing.application.service.SubscriptionCommandService",
"message": "Free subscription created: id=1, tenantSlug=awesome-academy",
"tenantSlug": "awesome-academy",
"subscriptionId": "1",
"planId": "FREE"
}

관련 문서