Add attendance redesign doc (as-built)
- 출석·리워드 v1 설계 문서를 실제 구현 스펙 기준으로 기록 - 확정 내용 반영: 일일 +20, 스트릭 5일차 +50(주기 1회)·10일 간격 +100, 월간 보너스 폐기 - 데이터 모델을 구현대로 수정: AttendanceStateDoc 분리, 월렛 엔진, products/orders 컬렉션, PointLedgerType 확정 목록 - 초안의 열린 결정(A6)을 결정 사항 기록 표로 대체, 구현 좌표 갱신
This commit is contained in:
parent
12c43e6e9c
commit
6d7635ff4a
156
docs/attendance-redesign.md
Normal file
156
docs/attendance-redesign.md
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
# 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 항목 대부분은 이들의 확장 아이디어에서 왔다.
|
||||||
Loading…
x
Reference in New Issue
Block a user