mmday-firebase/docs/attendance-redesign.md
윤정민 6d7635ff4a Add attendance redesign doc (as-built)
- 출석·리워드 v1 설계 문서를 실제 구현 스펙 기준으로 기록
- 확정 내용 반영: 일일 +20, 스트릭 5일차 +50(주기 1회)·10일 간격 +100, 월간 보너스 폐기
- 데이터 모델을 구현대로 수정: AttendanceStateDoc 분리, 월렛 엔진, products/orders 컬렉션, PointLedgerType 확정 목록
- 초안의 열린 결정(A6)을 결정 사항 기록 표로 대체, 구현 좌표 갱신
2026-07-20 13:10:33 +09:00

157 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# mmday 출석체크 설계도
> **목표**: 앱 방문률(리텐션/DAU) 상승. **명분**: 포인트를 모아 **실물 굿즈로 교환**.
> **상태**: Part A(v1)는 **구현 완료**(2026-07). Part A는 실제 구현 스펙을 기록한다. **Part B = 나중 서랍(데이터가 요구할 때만)**. 부록 = 근거/좌표.
---
# Part A — v1 (구현 완료)
리서치·브레인스토밍에서 나온 화려한 요소는 대부분 **Part B로 미뤘다.** v1은 원래 의도 그대로 가볍게 갔다.
## A1. 무엇이 들어갔나
1. **롤링 연속 스트릭 → 5일차·10일 간격 보너스** — 설계 초안의 "7일 마일스톤"은 구현하며 **5일차 1회 +50, 이후 10의 배수마다 +100**으로 확정 (첫 성공 경험을 앞당기고, 장기 구간은 10일 단위로 간격을 벌림).
2. **일일 포인트 +20** — 스트릭 못 채우는 다수의 안전판 (초안의 +10에서 상향).
3. **포인트 → 실물 굿즈 교환** — 상품 카탈로그·재고·주문·월렛 엔진을 갖춘 상점 시스템으로 구현.
4. **(추가) 예측 일일 리워드** — 승부예측 참여 +50 / 적중 +100 / 퍼펙트 보너스 +50. 출석과 함께 포인트 파우셋의 양대 축.
> 스트릭 보호권·decay·경기 기반·팬덤·비시즌 엔진은 v1에 **없다**(Part B).
## A2. 동작 상세 (구현 기준)
### A2.1 롤링 연속 스트릭 (개인)
- 매일 첫 체크인 시: 직전 출석일이 어제면 `currentAttendanceStreak += 1`, 아니면 **1부터 새 주기 시작**(`streakCycleStart` 갱신).
- **5일차 도달 시 1회 +50** (`attendance_streak_5`, 주기당 1회 — 멱등키에 `streakCycleStart` 포함).
- **10의 배수(10, 20, 30…) 도달 시마다 +100** (`attendance_streak_10_interval`).
- `highestAttendanceStreak`를 별도 보존해 최고 기록 표시.
- 스트릭 불안이 낮은 이유: 첫 보상까지 5일, 이후에도 보상 지평이 **항상 10일 이내** + 아래 일일 포인트가 완충. → **보호권/decay 불필요.**
### A2.2 일일 포인트 (모두의 안전판)
- `attendance_daily` **+20**. 상수는 `constants/points.ts`(`ATTENDANCE_DAILY_POINTS`)에서 관리.
- 주 5일쯤 오는 캐주얼은 연속을 자주 끊긴다 → 일일 포인트가 이들도 **매일 굿즈를 향해 쌓게** 해준다.
- 결과적으로 2층 구조: **일일 포인트 = 모두의 굿즈 적립 / 스트릭 보너스 = 꾸준한 유저의 추가 보상.**
### A2.3 굿즈 교환소 (핵심 신규 — 구현 완료)
- **월렛 엔진**: 단일 `balance` 원장 대신 `availableBalance / reservedBalance / totalEarned / totalSpent` 지갑 + reserve→capture 원장(`order_reserve/capture/release/refund`)으로 구현. 주문 시 포인트를 **예약**하고 확정 시 차감, 취소 시 해제.
- **상품 카탈로그** `products/{productId}`: `pointPrice`, 메인 갤러리(`mainImages`)·상세 이미지(`detailImages`), 재고 3필드(`totalStock/reservedStock/safetyStock`), `active/redeemable` 분리. 업로드 이미지는 Storage 트리거가 자동 리사이즈+WebP 변환.
- **주문 상태머신**: `reserved → confirmed → preparing → shipped → delivered / cancelled / refunded`. 멱등키(`uid_clientIdempotencyKey`), 주문 시점 가격·상품명 스냅샷, 클라이언트 금액 무시.
- **굿즈 사다리(near → far)** — 현재 라인업: 아크릴 키링 3,000P → 맥세이프 톡 6,500P → 키링 인형 11,000P(준비 중) → 크로스백 15,000P.
- **정체성 우선**: 같은 값이면 범용 잡화보다 **우리 팀 굿즈**를 전면에(보상이자 '나=팬' 표현).
- **인플레 통제**: 굿즈가 곧 sink. 발행량 대비 가격표는 주기적으로 재조정.
- **운영/보안**: 교환 시 배송 정보 최소수집(이름·연락처·주소·우편번호), 재고·자격·금액 판정은 전부 서버(`/reward/*` API). 관리자 상태 전환·재입고는 admin API.
## A3. 데이터 모델 (구현 기준)
**출석 상태** — 초안은 `User` 필드였지만 실제로는 **전용 상태 문서**로 분리:
```ts
// users/{uid}/attendance/state (AttendanceStateDoc)
lastAttendanceDate: DateString; // 어제였는지로 연속 판정
currentAttendanceStreak: number;
streakCycleStart: DateString; // 5일차 보너스 멱등키에 사용
highestAttendanceStreak: number;
```
> `AttendanceMonthDoc`(`users/{uid}/attendance/{YYYY-MM}`)의 `days: number[]`는 **그대로**.
**`PointLedgerType`(`types/points.ts`) 확정:**
- 출석: `attendance_daily`(+20) · `attendance_streak_5`(+50) · `attendance_streak_10_interval`(+100)
- 예측: `prediction_daily_participation`(+50) · `prediction_daily_success`(+100) · `prediction_daily_perfect`(+50)
- 상점: `order_reserve` · `order_capture` · `order_release` · `order_refund`
- 운영: `event_credit` · `admin_credit` · `admin_debit` · `point_expiry`
- 기존 `attendance_weekly_bonus` · `attendance_monthly_bonus`**폐기 완료** (전부-아니면-전무 제거).
**상점용 컬렉션** — 초안의 `goods/` + `goodsRedemptions` 대신:
- `products/{productId}` — 카탈로그(이름·포인트가·재고·이미지·노출순서).
- `orders/{uid_idempotencyKey}` — 주문(항목 스냅샷·수령인·상태 이력).
- `users/{uid}/pointLedger` + 지갑 문서 — 원장·잔액.
- 보안규칙: 카탈로그는 API 경유 read, 주문·지갑·원장은 서버 전용 write.
## A4. 계측 (출시 전 필수 — 이데이터) — **미착수**
> "베이스라인 못 대면 성공/실패 판정 자체가 불가능."
- **먼저 현재 D1/D7/D30 리텐션 베이스라인** 확보.
- **굿즈 교환율**(적립 대비) + **교환 후 리텐션** — 로열티 프로그램 고전 리스크(받고 이탈). 교환 직후 이탈이 크면 사다리 재설계 신호.
- **공허 출석 비율** = 체크인 후 3초 내 이탈 / 전체 체크인.
- **스트릭 도달 분포** = 유저들이 실제로 며칠까지 가는지(5·10 도달 비율).
- 이벤트 로깅: `check_in`, `streak_reset`(길이 → 이탈위험 태깅), `goods_redeem`.
## A5. 결정 사항 기록 (초안의 열린 결정 → 확정)
| 열린 결정 | 확정 내용 |
|---|---|
| 굿즈 사다리 구성 | 키링 3,000 → 톡 6,500 → 인형 11,000(준비 중) → 크로스백 15,000. 배송정보는 주문 시 최소수집 |
| 스트릭 보너스 크기 | 초안 "7일 마일스톤" 대신 **5일차 +50(주기 1회) + 10일 간격 +100**. 일일은 +20으로 상향 |
| 스트릭 카운터 방식 | 누적 유지(도달 리셋 없음) + `streakCycleStart`로 주기 추적. 끊기면 1부터 새 주기 |
| 월간 보너스 | **완전 폐기** (소프트 마일스톤 도입 안 함) |
---
# Part B — 나중 서랍 (데이터가 요구할 때만)
아래는 전부 **좋은 아이디어이지만 v1에 불필요.** "단순 v1을 냈는데 방문률이 기대만큼 안 오른다"는 신호가 왔을 때 하나씩 꺼내 붙인다. 각 항목은 *무엇 / 왜 미룸 / 다시 열 트리거*.
> ⚠️ 핵심: 이 서랍의 복잡도 대부분은 **"경기 기반 출석" 단 하나의 선택에서 파생**된다. 그걸 안 하는 한 아래 상당수는 열 일이 없다.
### B1. 경기 기반 출석 (파생 복잡도의 뿌리)
- **무엇**: 출석 단위를 '날짜'가 아니라 '우리 팀 경기일'로. 경기일 가중, 시즌 동행률("87/144 함께함").
- **왜 미룸**: v1은 단순 일일 출석이라 경기 데이터가 아예 필요 없음. 이걸 넣는 순간 아래 B1-a~d가 줄줄이 딸려옴.
- **트리거**: "야구스러움"이 차별점으로 필요하거나, 비경기일 출석이 공허하다는 데이터가 나올 때.
- **파생(경기 기반 갈 때만 필요)**:
- **B1-a. 비시즌 엔진**(§스토브리그·개막 카운트다운) — *매일 출석 v1엔 비시즌 공백 문제 자체가 없다.* 경기 기반으로 가야 비로소 생기는 문제.
- **B1-b. 휴식일·우천 자동보호** — `judgmentService.hasMissedGameDayBetween` 패턴 재사용.
- **B1-c. 응원팀 미선택 유저 처리**(빅매치 모드).
- **B1-d. 승리 적립·경기일 가중**(이긴 날 ×1.5, 비대칭·진 날 페널티 없음).
### B2. 행동형 체크인 (예측 = 출석)
- **무엇**: 순수 도장 대신 "오늘 우리 팀 경기 예측 1표"를 출석으로 인정(`voteRepository.getUserDateVotes`). 2-스텝 퍼널(체크인→핵심행동 전환).
- **왜 미룸**: v1은 1탭 체크인으로 충분. 예측 일일 리워드(참여/적중 포인트)가 이미 예측 유인을 별도로 제공 중. 공허 출석이 실제 문제로 측정될 때 도입.
- **트리거**: 공허 출석 비율이 높게 나오거나, 출석→핵심행동 전환을 끌어올려야 할 때.
### B3. 개인 + 팬덤 이중 트랙
- **무엇**: 내 출석이 '우리 구단 오늘 출석 화력'에 +1. 10구단 팬덤 대항 랭킹(RTDB 카운터 `/fandomAttendance/{date}/{teamCode}`). 소수 구단은 '전주 대비 성장률'로 보정.
- **왜 미룸**: 개인 스트릭만으로 v1 검증이 먼저. 사회적 동력은 그다음.
- **트리거**: 개인 동기만으론 리텐션이 약하거나, 커뮤니티 결속을 출석으로 끌고 싶을 때. (A/B: 개인 vs 팬덤)
### B4. 시즌 동행률 · 연간 팬레벨 · 3층 구조
- **무엇**: 일·주(마이크로) 위에 계절(시즌/비시즌 모드) + 연간 누적(팬레벨, 리셋 없음) 층을 얹어 계절을 가로지르는 정체성 유지.
- **왜 미룸**: 경기 기반/비시즌을 안 하면 계절 층 자체가 불필요.
- **트리거**: 경기 기반(B1)을 도입해 시즌/비시즌 전환이 생길 때 함께.
### B5. 경성 재화 · 가챠 · 시즌패스
- **무엇**: '직관코인'(발행 극소, 희소 sink 전용), 변동보상 랜덤 박스+천장(pity), 시즌 배틀패스(무료/유료 트랙, 수익화 입구).
- **왜 미룸**: 연성 포인트→굿즈 하나로 v1 경제는 완결. 재화 2종·가챠는 검증 후 정교화 단계.
- **트리거**: 굿즈만으론 보상 다양성이 부족하거나, 수익화를 붙일 때. (⚠️ faucet 늘리면 sink도 함께 — 인플레 주의)
- **참고**: 현금 결제(토스페이먼츠) 상점 확장 논의 중 — 주문이 reserve→capture 구조라 결제 수단 교체로 대응 가능.
---
# 부록
## 부록 1. 구현 좌표 (v1 기준)
| 용도 | 심볼 |
|---|---|
| 출석 트랜잭션 | `attendanceService.checkIn` |
| 포인트 상수 | `constants/points.ts` (일일 20 · 5일차 50 · 10일 간격 100 · 예측 50/100/50) |
| 포인트 적용 | `pointService.applyPointChangesTx` + `PointLedgerType`(`types/points.ts`) |
| 지갑 조회 | `walletRepository.getWallet` / `getWalletTx` |
| 출석 상태 문서 | `users/{uid}/attendance/state` · `AttendanceStateDoc` |
| 출석 월 문서 | `users/{uid}/attendance/{YYYY-MM}` · `AttendanceMonthDoc` |
| 상품/주문 | `productRepository` · `orderService.createOrder/transitionOrder` |
| 상점 API | `handlers/rewardHandlers.ts` (`/reward/wallet·ledger·products·orders·eligibility`) |
| 이미지 최적화 | `services/imageOptimizeService` + `triggers/onRewardImageUploaded` |
| 오늘 KST / 어제 판정 | `todayKst`, `kboTodayKst`, `addDays`, `daysAgoKst` (`types/dateString.ts`) |
| 알림 토글 | `NotificationKey.Attendance`(`panit.ts`) |
| 보안규칙 | 출석/원장/지갑 = 본인 read, 서버 전용 write (`firestore.rules`) |
| (B1용) 날짜별 경기 | `gameRepository.listByDate` / `createGameDayCache` |
| (B1용) 놓친 경기일 | `judgmentService.hasMissedGameDayBetween` |
| (B2용) 예측 조회 | `voteRepository.getUserDateVotes(uid, date)` (RTDB) |
## 부록 2. 왜 이렇게 정했나 (근거 요약)
- **리서치(다중 소스, 3표 적대검증)**: 순수 연속 스트릭의 '하루 빠지면 0 리셋'이 불안·이탈 주원인(60일+ 끊긴 유저 ~40% 2주 내 이탈). 보상이 '유일한' 이유가 되면 공허한 체크인. → 그래서 **짧은 보상 지평(5일·10일 간격) + 일일 포인트 완충 + 굿즈라는 실물 명분**으로 완화. (주의: 'DAU 40~60%↑' 류 마케팅 수치는 검증에서 기각.)
- **굿즈→sink**: 실물 교환은 재고·원가가 있어 포인트 인플레를 자연히 막는 천연 소모처.
- **v1을 가볍게**: 문서의 무거운 요소는 5인 페르소나가 각자 최대치로 민 '북극성 메뉴'였고, 실제 v1은 클라 요구(롤링 스트릭)+원래 의도(굿즈)면 충분하다는 결론.
## 부록 3. 5인 브레인스토밍 출처(관점)
박직관(팬덤/직관문화) · 이데이터(그로스/데이터) · 최리워드(게이미피케이션/재화경제) · 정라이트(신규·라이트 UX) · 한커뮤(커뮤니티/소셜). Part B 항목 대부분은 이들의 확장 아이디어에서 왔다.