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

13 KiB
Raw Permalink Blame History

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 필드였지만 실제로는 전용 상태 문서로 분리:

// 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 4060%↑' 류 마케팅 수치는 검증에서 기각.)
  • 굿즈→sink: 실물 교환은 재고·원가가 있어 포인트 인플레를 자연히 막는 천연 소모처.
  • v1을 가볍게: 문서의 무거운 요소는 5인 페르소나가 각자 최대치로 민 '북극성 메뉴'였고, 실제 v1은 클라 요구(롤링 스트릭)+원래 의도(굿즈)면 충분하다는 결론.

부록 3. 5인 브레인스토밍 출처(관점)

박직관(팬덤/직관문화) · 이데이터(그로스/데이터) · 최리워드(게이미피케이션/재화경제) · 정라이트(신규·라이트 UX) · 한커뮤(커뮤니티/소셜). Part B 항목 대부분은 이들의 확장 아이디어에서 왔다.