From 6d7635ff4a726fff9620c8ea40b2dc753c3512d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 20 Jul 2026 13:04:30 +0900 Subject: [PATCH] Add attendance redesign doc (as-built) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 출석·리워드 v1 설계 문서를 실제 구현 스펙 기준으로 기록 - 확정 내용 반영: 일일 +20, 스트릭 5일차 +50(주기 1회)·10일 간격 +100, 월간 보너스 폐기 - 데이터 모델을 구현대로 수정: AttendanceStateDoc 분리, 월렛 엔진, products/orders 컬렉션, PointLedgerType 확정 목록 - 초안의 열린 결정(A6)을 결정 사항 기록 표로 대체, 구현 좌표 갱신 --- docs/attendance-redesign.md | 156 ++++++++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 docs/attendance-redesign.md diff --git a/docs/attendance-redesign.md b/docs/attendance-redesign.md new file mode 100644 index 0000000..d0e0993 --- /dev/null +++ b/docs/attendance-redesign.md @@ -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 항목 대부분은 이들의 확장 아이디어에서 왔다.