Skip to content

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 UXshimmer + 단계별 텍스트, 30s+ 시 push 약속
같은 공시 재시청디바이스 로컬 unlock 캐시 (서버 저장 X)
유료 슬롯 + 광고 결합슬롯 결제 도입 시 재논의 (현재 보류)
재조정 트리거6개월 후 또는 MAU 1,000 도달 시

PR 분할 (현재 상태 — 2026-04-30 갱신)

PR브랜치상태
1feature/be-0430-monthly-quota✅ PR #29 머지
2feature/be-0430-oneliner-pregenerate폐기 — PR #26 에서 이미 분석 시점 동시 생성 (analyzer.go JSON 스키마)
3afeature/be-0501-lazy-reveal다음 작업 — lazy reveal API + 재진입 무료
3bfeature/mo-0501-quota-ui-v3대기 (3a 머지 후)
4feature/mo-0501-local-unlock-cache재검토 — 3a 의 서버 측 revealed 추적과 중복 가능성
5feature/iap-ad-free-pass대기

PR 1 — feature/be-0430-monthly-quota

목표: 일 단위 → 월 단위 quota + 짧은 공시 면제 + 가입 보너스

Migration 00006_quota_v3.sql:

  • user_analysis_quota:
    • 기존 quota_date DATEquota_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_today cap 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_today cap 도달 시 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 (폐기)

폐기 사유 (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_liner UPDATE
  • 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:

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):

json
{
  "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=trueresult 자동 포함 (FE 가 reveal 호출 안 해도 됨)
  • already_revealed=trueresult 자동 포함 (재진입 무료)
  • 그 외 → FE 가 명시적으로 POST reveal 호출 필요

POST /api/v1/ai/analyze/{rceptNo}/reveal (신규, 차감 O):

  • 트랜잭션:
    1. analysis_cache status=COMPLETED 확인. 아니면 409 NOT_READY
    2. user_revealed_disclosures 에 INSERT ... ON CONFLICT DO NOTHING
    3. INSERT 새로 발생한 경우만 quota 차감 (IncrementUsed)
    4. 짧은 공시면 차감 스킵
    5. 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):

  1. 모델/repo 업데이트 (features/disclosure/data/):

    • DashboardDisclosureoneLiner 필드 추가
    • AnalysisState sealed 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
  2. Quota 인디케이터 (features/disclosure/presentation/widgets/quota_indicator.dart, 신규):

    • AI 분석 카드 위에 배치
    • 텍스트:
      • 이번 달 무료 분석 N회 남음 (+ 보너스 M회)
      • 매월 1일 초기화 또는 보너스 만료일
      • quota 0 시: 광고 보고 1회 더 보기 (오늘 K/7회 가능)
    • 시각: 작은 카드 형태, 분석 카드와 시각적 구분
  3. 분석 카드 lazy reveal (features/disclosure/presentation/widgets/analysis_card.dart):

    • 상태 머신:
      • Gated → 큰 "AI 분석 보기 (1회 차감)" 버튼 + 미리보기 흐림 처리 (선택)
      • Revealing → 스피너 + "분석을 불러오는 중"
      • Pending/Processing → shimmer + 단계 텍스트 (기존 유지)
      • Result → 결과 표시 + "✓ 이 분석을 봤어요" 뱃지
      • QuotaExhausted → 광고 시청 버튼 (기존 유지)
    • 짧은 공시: 진입 즉시 Result 로 자동 표시 + "📄 짧은 공시 — 무료" 표시
  4. Dashboard list 한줄요약 (features/watchlist/presentation/widgets/disclosure_item.dart):

    • 제목 아래 oneLiner 1~2줄 표시
    • one_liner == null → shimmer 라인 (분석 진행 중 시그널)
  5. 온보딩 안내 (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)
  • 분석 페이지 진입 시:
    • 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_pass Non-Consumable ₩2,900
  • Play Console → 인앱 상품 → ad_free_pass Managed ₩2,900
  • Sandbox/License Tester 계정 등록

Migration 00008_ad_free_pass.sql:

  • users.ad_free BOOLEAN NOT NULL DEFAULT FALSE
  • iap_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_receipts insert
  • 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_quota drop & recreate (서비스 미가동 단계)

비용 프레이밍 정정 (V3 까지 잘못 표기됐던 부분)

LLM 호출은 공시당 1회 (스케줄러 분석 시). 사용자 reveal 은 analysis_cache DB 조회로 LLM 추가 호출 없음. 즉 사용자 reveal 횟수는 LLM 비용과 무관.

따라서 V3 의 "월 5 회" 한도 근거였던 "LLM compute 비용 통제" 는 부정확한 프레이밍이었고, 실제 cap 의 의미는:

  1. ad_free_pass IAP funnel hook
  2. 다중 계정 어뷰즈 ROI 보조 하향 (App Check / SSV 가 1차)
  3. 인지 가치 시그널

이 정정 위에서 "처음부터 너무 짠하면 첫 경험 거부감 → 마음에 들면 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_monthquota_week_start)
  • Flutter: quota indicator 카피 ("이번 주 N회 남음 · D일 후 +10"), ad_bonus 필드 의존 코드 제거
  • env: MONTHLY_FREE_QUOTA, SIGNUP_BONUS_QUOTAWEEKLY_FREE_QUOTA