Quota & 광고 정책 V3 — 개발 계획
확정일: 2026-04-30 근거: 5명 패널 8라운드 토론 (project_monetization.md 참고)
정책 요약
| 항목 | 값 |
|---|---|
| 무료 quota | 월 5회 (매월 1일 reset) |
| 신규 가입 보너스 | 첫 30일간 +5회 추가 |
짧은 공시 (doc_length < 1000) | quota 차감 X, 광고 X (자동 무료 풀 분석) |
| 광고 보충 | 광고 1회 → +1 분석, 횟수 제한 없음 (SSV가 실제 광고 시청 검증) |
| 광고 제거 상품 | ad_free_pass ₩2,900 일회성 (구독 X) |
| 한줄요약 | 전체 무료, 100자, gate 없음 |
| PENDING/PROCESSING UX | shimmer + 단계별 텍스트, 30s+ 시 push 약속 |
| 같은 공시 재시청 | 디바이스 로컬 unlock 캐시 (서버 저장 X) |
| 유료 슬롯 + 광고 결합 | 슬롯 결제 도입 시 재논의 (현재 보류) |
| 재조정 트리거 | 6개월 후 또는 MAU 1,000 도달 시 |
PR 분할 (현재 상태 — 2026-04-30 갱신)
| PR | 브랜치 | 상태 |
|---|---|---|
| 1 | feature/be-0430-monthly-quota | ✅ PR #29 머지 |
| 2 | feature/be-0430-oneliner-pregenerate | ❌ 폐기 — PR #26 에서 이미 분석 시점 동시 생성 (analyzer.go JSON 스키마) |
| 3a | feature/be-0501-lazy-reveal | ⏳ 다음 작업 — lazy reveal API + 재진입 무료 |
| 3b | feature/mo-0501-quota-ui-v3 | 대기 (3a 머지 후) |
| 4 | feature/mo-0501-local-unlock-cache | 재검토 — 3a 의 서버 측 revealed 추적과 중복 가능성 |
| 5 | feature/iap-ad-free-pass | 대기 |
PR 1 — feature/be-0430-monthly-quota
목표: 일 단위 → 월 단위 quota + 짧은 공시 면제 + 가입 보너스
Migration 00006_quota_v3.sql:
user_analysis_quota:- 기존
quota_date DATE→quota_month DATE(매월 1일 normalize) - 신규
ad_views_today INT NOT NULL DEFAULT 0 - 신규
ad_views_date DATE NOT NULL DEFAULT CURRENT_DATE - 기존
used_count,ad_bonus유지
- 기존
users:- 신규
signup_bonus_until TIMESTAMPTZ(가입 시 NOW()+30d 세팅) - 기존 row 일괄 update:
signup_bonus_until = COALESCE(created_at, NOW()) + INTERVAL '30 days'
- 신규
Backend 변경:
internal/ai/handler.go:- 분석 요청 시 quota check 로직 변경
disclosures.doc_length < 1000→ 면제 (quota 차감 X, 응답에bypass_reason: "short"포함)- 월 quota 사용 =
monthly_used + ad_bonus + bonus_remaining계산 - 광고 보충 호출 시
ad_views_todaycap 7 검증
internal/quota/handler.go(신규 또는 기존 확장):GET /api/v1/quota응답 포맷 변경:json{ "monthly_used": 2, "monthly_limit": 5, "bonus_remaining": 3, "bonus_until": "2026-05-30T...", "ad_views_today": 1, "ad_views_limit": 7, "ad_free": false }
POST /api/v1/quota/ad-reward:ad_views_todaycap 도달 시429 too_many_ad_views응답- 정상 시
ad_bonus +1,ad_views_today +1
테스트 시나리오:
- 가입 직후 → 보너스 5회 + 기본 5회 = 10회 표시
- 31일 후 → 기본 5회만
- 짧은 공시 분석 → quota 그대로
- 광고 8회째 → 429
- 월 reset → 1일 0시 used_count 초기화
PR 2 — feature/be-0430-oneliner-pregenerate (폐기)
feature/be-0430-oneliner-pregenerate폐기 사유 (2026-04-30): PR #26 에서 vLLM 분석 호출이 이미 one_liner 를 JSON 스키마에 포함하도록 구현됨 (analyzer.go:19, repository.go:134 MarkCompleted). 분석 완료 시점에 한줄요약이 자동 저장되므로 별도 사전 생성 단계 불필요. push 타이밍 조정만 필요하면 추후 작은 PR 로 분리.
(원래 계획 — 참고용)
목표: 신규 공시 자동 한줄요약 + push 타이밍 조정
Migration 00007_oneliner_disclosures.sql:
- 옵션 A:
disclosures.one_liner VARCHAR(100),disclosures.one_liner_status TEXT(PENDING/PROCESSING/DONE/FAILED) - 또는 옵션 B:
analysis_cache활용 (현재 풀 분석 후 생성). 별도 row 만들고one_liner_only플래그 - 결정: 옵션 A (disclosures 테이블에 직접) — list 쿼리 단순, 인덱스 효율
Backend 변경:
internal/scheduler/:- 신규 공시 발견 직후 한줄요약 큐 enqueue
- 한줄요약 vLLM 호출 (짧은 prompt:
한줄로 요약. 100자 이내.) - 결과 →
disclosures.one_linerUPDATE
- push 발송 로직:
- 현재: 신규 공시 발견 즉시 발송
- 변경:
one_liner_status = 'DONE'이후만 발송 - 실패/타임아웃 (60s+) 시: 한줄요약 없이 발송 fallback (사용자 차단 방지)
GET /api/v1/disclosures응답에one_liner,one_liner_status추가GET /api/v1/disclosures/:id분석 응답에analysis_status: PENDING|PROCESSING|DONE|FAILED명시
테스트 시나리오:
- 신규 공시 → 한줄요약 생성 → push
- vLLM 다운 → fallback push (한줄요약 없이)
- 백필: 기존 disclosures의 one_liner = NULL 그대로
PR 3a — feature/be-0501-lazy-reveal (신규, 다음 작업)
근거: 5명 패널 3라운드 토론 (2026-04-30). 자동 quota 차감 → 명시적 reveal 모델로 전환. 사용자 신뢰 + quota 정합성 + 수익 모델 가치 인식 강화.
Migration 00007_user_revealed_disclosures.sql:
CREATE TABLE dartbrief.user_revealed_disclosures (
user_id BIGINT NOT NULL REFERENCES dartbrief.users(id) ON DELETE CASCADE,
rcept_no TEXT NOT NULL,
revealed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (user_id, rcept_no)
);
CREATE INDEX idx_revealed_user ON dartbrief.user_revealed_disclosures(user_id, revealed_at DESC);API 변경 — internal/ai/handler.go:
GET /api/v1/ai/analyze/{rceptNo} (메타만, 차감 X):
{
"status": "COMPLETED|PENDING|UNAVAILABLE",
"doc_length": 850,
"is_short": true, // doc_length < SHORT_DOC_THRESHOLD
"already_revealed": false,
"one_liner": "자사주 50만주 소각 결정", // 한줄요약은 무료 노출
"result": null // is_short=true 일 때만 자동 동봉
}is_short=true→result자동 포함 (FE 가 reveal 호출 안 해도 됨)already_revealed=true→result자동 포함 (재진입 무료)- 그 외 → FE 가 명시적으로 POST reveal 호출 필요
POST /api/v1/ai/analyze/{rceptNo}/reveal (신규, 차감 O):
- 트랜잭션:
analysis_cachestatus=COMPLETED 확인. 아니면 409 NOT_READYuser_revealed_disclosures에 INSERT ... ON CONFLICT DO NOTHING- INSERT 새로 발생한 경우만 quota 차감 (
IncrementUsed) - 짧은 공시면 차감 스킵
- result 반환
- quota 0 + 광고 cap 도달 → 429 QUOTA_EXHAUSTED
- quota 소진 → 429 QUOTA_EXHAUSTED +
can_watch_ad: true(항상 — cap 없음)
GET /api/v1/quota (이미 V3 구조 유지) — 변경 없음
메모리 캐시 변경:
- 현재
analysisCacheEntry는 결과를 process 내 캐시. lazy reveal 에서도 유지 가능. - GET 호출 시 always 메타만, POST reveal 시에만 result 반환.
테스트 시나리오:
- 신규 사용자 GET → already_revealed=false, result=null
- POST reveal → 200 + result, used_count +1
- 같은 공시 재 reveal → 200 + result, used_count 변화 X
- 짧은 공시 GET → result 자동 포함, 차감 X
- quota 0 상태에서 reveal → 429
- 분석 PENDING 상태에서 reveal → 409
- 통합 테스트: t.Cleanup + CASCADE FK 패턴 (PR #29 동일)
PR 3b — feature/mo-0501-quota-ui-v3
목표: 모바일 list 한줄요약 + 분석 카드 lazy reveal + quota 카운터
Mobile 변경 (mobile):
모델/repo 업데이트 (
features/disclosure/data/):DashboardDisclosure에oneLiner필드 추가AnalysisStatesealed class 확장:Gated({remaining, bonusRemaining, adRemaining, resetAt, canWatchAd})신규Revealing신규 (POST 진행 중)- 기존
Result/Pending/Unavailable/QuotaExhausted유지
disclosure_repository.dart:fetchAnalysisMeta(rceptNo)— GET → status, is_short, already_revealed, one_liner, result(짧은 공시면)revealAnalysis(rceptNo)— POST → result + 갱신된 quota
quota_repository.dart(신규):GET /api/v1/quota
Quota 인디케이터 (
features/disclosure/presentation/widgets/quota_indicator.dart, 신규):- AI 분석 카드 위에 배치
- 텍스트:
이번 달 무료 분석 N회 남음 (+ 보너스 M회)매월 1일 초기화또는 보너스 만료일- quota 0 시:
광고 보고 1회 더 보기 (오늘 K/7회 가능)
- 시각: 작은 카드 형태, 분석 카드와 시각적 구분
분석 카드 lazy reveal (
features/disclosure/presentation/widgets/analysis_card.dart):- 상태 머신:
Gated→ 큰 "AI 분석 보기 (1회 차감)" 버튼 + 미리보기 흐림 처리 (선택)Revealing→ 스피너 + "분석을 불러오는 중"Pending/Processing→ shimmer + 단계 텍스트 (기존 유지)Result→ 결과 표시 + "✓ 이 분석을 봤어요" 뱃지QuotaExhausted→ 광고 시청 버튼 (기존 유지)
- 짧은 공시: 진입 즉시
Result로 자동 표시 + "📄 짧은 공시 — 무료" 표시
- 상태 머신:
Dashboard list 한줄요약 (
features/watchlist/presentation/widgets/disclosure_item.dart):- 제목 아래
oneLiner1~2줄 표시 - one_liner == null → shimmer 라인 (분석 진행 중 시그널)
- 제목 아래
온보딩 안내 (1회):
- 첫 분석 카드 노출 시 toast/coachmark: "AI 분석은 보기 버튼을 눌러야 카운트가 차감돼요"
- SharedPreferences 로 1회 표시 후 dismiss
테스트 시나리오:
- 신규 공시 list → 한줄요약 표시 (또는 shimmer)
- 일반 공시 진입 → Gated → 보기 클릭 → Revealing → Result, 카운터 -1
- 짧은 공시 진입 → 자동 Result, 카운터 변화 X
- 같은 공시 재진입 → 자동 Result, 카운터 변화 X (already_revealed)
- quota 0 진입 → Gated 자리에 광고 버튼 → AdMob 시청 → 카운터 회복 → 보기
- 광고 7/7 도달 → "오늘 광고 한도 도달" 안내
PR 4 — feature/mo-0501-local-unlock-cache (재검토 필요)
재검토 사유 (2026-04-30): PR 3a user_revealed_disclosures 테이블이 서버 측 source of truth 가 됨. 로컬 캐시는 다음 두 가지 가치만 남음:
- (a) 콜드 스타트 시 즉시 "이미 본 공시" 뱃지 표시 (서버 round-trip 없이)
- (b) 오프라인 진입 시 결과 표시
(a) 는 메타 GET 한 번이면 충분 (already_revealed=true 라벨), (b) 는 결과까지 캐싱해야 의미 있음. 결정: PR 3a/3b 머지 후 실제 사용 패턴 보고 재논의. 현재는 보류.
(원래 계획 — 참고용)
목표: 같은 공시 재시청 시 광고/quota 차감 X
Mobile 변경:
core/storage/unlocked_cache.dart(신규):- Hive box
unlocked_disclosures(Set<String>of disclosure IDs) markUnlocked(id),isUnlocked(id)
- Hive box
- 분석 페이지 진입 시:
isUnlocked(id) == true→ quota check 스킵, 백엔드/api/v1/disclosures/:id/analysis직접 호출 (캐시 hit)- 처음 분석 → 광고 시청 또는 quota 차감 → 성공 시
markUnlocked(id)
- 짧은 공시도 첫 진입 시 markUnlocked (일관성)
Edge cases:
- 앱 재설치 → unlock 캐시 초기화 (수용)
- 동일 사용자 다른 기기 → 각 기기별 unlock (수용, 어뷰징 가치 낮음)
- 캐시 한도 (예: 1,000개 초과) → LRU 제거 (옵션, 초기엔 무한)
테스트 시나리오:
- 광고 보고 분석 → 캐시 → 같은 공시 재진입 → 광고 X
- 앱 재시작 → 캐시 유지
- 앱 재설치 → 캐시 사라짐, 다시 광고 필요
PR 5 — feature/iap-ad-free-pass
목표: ad_free_pass ₩2,900 일회성 IAP
스토어 등록 (수동):
- App Store Connect → 앱 내 구입 →
ad_free_passNon-Consumable ₩2,900 - Play Console → 인앱 상품 →
ad_free_passManaged ₩2,900 - Sandbox/License Tester 계정 등록
Migration 00008_ad_free_pass.sql:
users.ad_free BOOLEAN NOT NULL DEFAULT FALSEiap_receipts테이블 (영수증 저장 + 환불 추적):id UUID,user_id,platform(apple/google),product_id,original_transaction_id,purchase_date,refunded_at,raw_payload JSONB
Backend 변경:
internal/iap/handler.go(신규):POST /api/v1/iap/verify(영수증 검증)- Apple: App Store Server API (StoreKit 2)
- Google: Google Play Developer API
- 검증 성공 →
users.ad_free = TRUE,iap_receiptsinsert
- Webhook:
POST /api/v1/webhooks/apple(Server Notifications V2)POST /api/v1/webhooks/google(Pub/Sub RTDN)- 환불 이벤트 →
users.ad_free = FALSE,iap_receipts.refunded_at기록
Mobile 변경:
pubspec.yaml:in_app_purchase추가features/iap/:iap_service.dart: 상품 조회, 구매, 영수증 전달presentation/pages/my_plan_page.dart: "내 플랜" 화면
- 광고 prompt 로직:
quota.ad_free == true면 광고 안 띄우고 바로 분석 (quota 차감 X) - 설정 화면 → "내 플랜" 진입
테스트 시나리오:
- Sandbox 결제 → 검증 → ad_free TRUE → 광고 prompt 사라짐
- 환불 webhook → ad_free FALSE → 광고 prompt 다시 등장
- 앱 재설치 → 영수증 복원 → ad_free 유지
진행 원칙
- 각 PR origin/main 에서 분기, main 으로 PR (GitHub Flow)
- 커밋 전:
gofmt,go vet(백),flutter analyze(모) - 머지 후 다음 PR 시작 (병렬 X — 백엔드 응답 변경이 모바일에 영향)
- PR 1 머지 후 → 배포 후보 (PR 5까지 묶어
git tag v0.0.4가능)
보류 사항
- 슬롯 결제 (
feature/slot-purchase): 종목 1개 ₩1,490 가치 약함, MAU 500+ 후 재검토 - 푸시 토큰 서버 등록: PR 2 한줄요약 push와 함께 처리 검토
- Apple 로그인: iOS 심사 직전
V4 변경 (2026-05-06)
결정
- 월 5+5 → 주 10 (가입 보너스 폐기)
quota_month(DATE 1일) →quota_week_start(DATE 월요일 KST)ad_bonus컬럼 제거 — 광고 보상은used_count -= 1(음수 허용) 로 표현.ad_reward_transactions테이블은 SSV idempotency 전용 (어뷰징 차단), quota 계산에 참조 안 함, UI 노출 없음- 마이그레이션 없이
user_analysis_quotadrop & recreate (서비스 미가동 단계)
비용 프레이밍 정정 (V3 까지 잘못 표기됐던 부분)
LLM 호출은 공시당 1회 (스케줄러 분석 시). 사용자 reveal 은 analysis_cache DB 조회로 LLM 추가 호출 없음. 즉 사용자 reveal 횟수는 LLM 비용과 무관.
따라서 V3 의 "월 5 회" 한도 근거였던 "LLM compute 비용 통제" 는 부정확한 프레이밍이었고, 실제 cap 의 의미는:
ad_free_passIAP funnel hook- 다중 계정 어뷰즈 ROI 보조 하향 (App Check / SSV 가 1차)
- 인지 가치 시그널
이 정정 위에서 "처음부터 너무 짠하면 첫 경험 거부감 → 마음에 들면 IAP 결제" 가설로 한도를 후하게 (월 5 → 주 10 ≈ 4.3배) 가져감.
5명 패널 토론 요약 (2 라운드, 2026-05-06)
- PM: 일관된 주 10 ✓ (V3 의 5+5 → 5 절벽이 더 거부감)
- GROWTH: "이번 주 X회 + D일 후 +10" 풍족 카피 ✓
- ABUSE: 주 10 OK, App Check strict 전제 + 가입 보너스 제거가 다중 계정 ROI 직접 깎음 (이중 효과)
- FE: 구현 0 비용, 주 reset 타이밍 = 월요일 00:00 KST
- DEVIL: 월 30 vs 주 10 → 어닝 시즌 burst 흡수에 주 단위가 우세, 단 ad_bonus 도 주 reset 명시 필요
영향
- 백엔드:
policy.go,reveal.go,quota_repository.go,ai/handler.go(legacy adReward), migration drop & recreate - DB:
user_analysis_quota재구성 —user_id, used_count, quota_week_start만 (ad_bonus컬럼 삭제,quota_month→quota_week_start) - Flutter: quota indicator 카피 ("이번 주 N회 남음 · D일 후 +10"),
ad_bonus필드 의존 코드 제거 - env:
MONTHLY_FREE_QUOTA,SIGNUP_BONUS_QUOTA→WEEKLY_FREE_QUOTA