ADR-24: 구독 기간 일할 계산
| 날짜 | 작성자 | 레포지토리 |
|---|---|---|
| 2026-01-16 | @specvital | web |
컨텍스트
달력 월 기반 빌링 기간은 월말에 가입한 사용자에게 불공정한 쿼터 할당 발생. 예를 들어, 1월 29일에 가입한 사용자는 전체 월 쿼터를 받지만 2월 1일에 리셋되어 실제 사용 기간 3일 동안 2개월 분량의 쿼터 획득.
이 문제는 빌링 아키텍처의 결과로 식별되었으며 공정한 기간 계산 전략 필요.
문제 시나리오
| 가입 날짜 | 달력 리셋 | 실제 사용 일수 | 받은 쿼터 |
|---|---|---|---|
| 1월 1일 | 2월 1일 | 31일 | 1개월 |
| 1월 29일 | 2월 1일 | 3일 | 1개월 |
29일에 가입한 사용자가 1일에 가입한 사용자와 동일한 쿼터를 받음에도 불구하고 사용 기간은 10%에 불과.
ADR-13과의 관계
이 ADR은 ADR-13: 빌링 및 쿼터 아키텍처를 확장. ADR-13은 "가입일 기준 롤링 기간"을 선택된 전략으로 식별했으나 구현 세부사항은 미결정. ADR-13은 또한 "프로레이션 복잡성: 중간 기간 플랜 변경 시 쿼터 조정 필요"를 부정적 결과로 언급하며 향후 작업으로 남김.
결정
각 사용자의 가입 타임스탬프 기반 롤링 기간 계산 구현.
각 사용자의 빌링 기간은 실제 가입 날짜에서 시작하여 정확히 1개월 후 종료, 가입 시점에 관계없이 모든 사용자가 전체 월 쿼터 수령 보장.
구현
go
// src/backend/modules/subscription/usecase/assign_default_plan.go
now := time.Now().UTC()
periodStart := now // 가입 타임스탬프
periodEnd := now.AddDate(0, 1, 0).Add(-time.Nanosecond) // 정확히 1개월 후핵심 설계 결정
| 결정 | 근거 |
|---|---|
| UTC 정규화 | 타임존 관련 엣지 케이스 방지 |
AddDate(0, 1, 0) | Go의 달력 월 시맨틱 사용 (2월 28일, 윤년 올바르게 처리) |
| 나노초 감산 | SQL 범위 쿼리의 배타적 상한 보장 |
| 사용자별 기간 추적 | user_subscriptions 테이블에 기간 경계 저장 |
사용량 쿼리 패턴
sql
SELECT COALESCE(SUM(quota_amount), 0)::bigint AS total
FROM usage_events
WHERE user_id = $1
AND event_type = $2
AND created_at >= $3 -- current_period_start (포함)
AND created_at < $4 -- current_period_end (배타)고려한 옵션
옵션 A: 가입일 기준 롤링 기간 (선택됨)
사용자의 실제 가입 타임스탬프에서 시작하여 정확히 1개월 후 종료.
| 장점 | 단점 |
|---|---|
| 가입 시점 무관하게 모든 사용자에게 공정 | 사용자마다 다른 갱신 날짜 |
| 예측 가능한 갱신 날짜 (사용자 특정) | 배치 보고 복잡화 |
| 월말 가입 페널티 없음 | 중앙집중 처리 창 없음 |
| 월말 가입으로 시스템 악용 불가 |
옵션 B: 달력 월 기간
모든 사용자에 대해 항상 월 1일부터 말일까지 기간 적용.
| 장점 | 단점 |
|---|---|
| 단순 구현 | 월말 가입자에게 불공정 (최대 97% 가치 손실) |
| 쉬운 배치 처리 | 악용 가능성 (28일 가입, 1일 리셋) |
| 코호트 분석 정렬 | 불공정성에 대한 사용자 불만 |
기각: 사용자 공정성 트레이드오프 수용 불가.
옵션 C: 프로레이트된 달력 월
달력 월 정렬을 유지하되 첫 달 부분 쿼터를 비례 배분.
| 장점 | 단점 |
|---|---|
| 달력 정렬 유지 | 복잡한 부분 계산 |
| 수학적으로 공정 | 사용자에게 설명 어려움 |
| 부분 쿼터 UX 마찰 |
기각: 복잡성이 달력 정렬 이점 초과.
옵션 D: 고정 날짜 앵커 (1일 또는 15일)
예측 가능한 배치 창을 위해 가입일을 가장 가까운 앵커 날짜로 반올림.
| 장점 | 단점 |
|---|---|
| 예측 가능한 배치 처리 창 | 최대 14일 불공정 |
| 더 단순한 보고 | 임의적 컷오프가 불공정하게 느껴짐 |
| 사용자가 가치 극대화를 위해 가입 지연 가능 |
기각: 임의적 불공정성이 사용자 불만족 유발.
결과
긍정적
| 영역 | 이점 |
|---|---|
| 사용자 공정성 | 가입 시점 무관하게 모든 사용자가 정확히 1개월 쿼터 수령 |
| 예측 가능한 갱신 | 사용자가 정확한 갱신 날짜 인지 (UI에 특정 달력 날짜로 표시) |
| 악용 방지 | 즉시 리셋을 위한 월말 가입으로 시스템 악용 불가 |
| 명확한 기대 | UI에 "1월 16일에 리셋"으로 표시, 모호한 "월간" 대신 |
| ADR-13 정렬 | 상위 ADR에 문서화된 선택 전략 이행 |
부정적
| 영역 | 트레이드오프 | 완화책 |
|---|---|---|
| 배치 창 없음 | 사용자마다 다른 날짜에 리셋 | 현재 규모에서 수용 가능 |
| 인보이스 복잡성 | 결제 연동 시 사용자별 빌링 날짜 필요 | 결제 처리 필요 시 향후 고려 |
| 월 길이 변동 | 1월 31일이 2월 28일로 롤 (3일 손실) | Go의 AddDate가 올바르게 처리; 수용 가능한 변동 |
| 중간 기간 변경 | 플랜 업/다운그레이드 시 프로레이션 로직 필요 | 향후 작업으로 명시적 연기 |
| 보고 복잡성 | 롤링 기간이 코호트 분석 복잡화 | 사용자 공정성 우선으로 수용 |
처리된 엣지 케이스
| 엣지 케이스 | 처리 방법 |
|---|---|
| 월 경계 (1월 31일 → 2월 28일) | Go AddDate 달력 시맨틱 |
| 윤년 (2월 29일) | Go 표준 라이브러리가 올바르게 처리 |
| 타임존 변동 | 저장 레이어에서 UTC 정규화 |
| 경계 정밀도 | 나노초 정밀도 배타적 상한 |
연기된 결정
ADR-13에 언급된 결과에 따라 다음 중간 기간 시나리오는 미구현 상태:
| 시나리오 | 제안된 접근법 | 상태 |
|---|---|---|
| 중간 기간 업그레이드 | 비례 차이 추가: (newQuota - oldQuota) * remainingDays / totalDays | 향후 작업 |
| 중간 기간 다운그레이드 | 옵션: 미사용분 이월 또는 새 한도로 리셋 | 향후 작업 |
| 플랜 만료 | 무료 티어로 그레이스풀 디그레이데이션 | 향후 작업 |
참고 자료
- ADR-13: 빌링 및 쿼터 아키텍처
- Commit ec64e42 - fix(subscription): fix users getting 2 months quota when signing up late in month
- Commit b364f51 - fix(account): fix awkward grammar in usage reset date display
