diff --git a/.gitignore b/.gitignore index e9d027930..218c29a34 100644 --- a/.gitignore +++ b/.gitignore @@ -5,8 +5,5 @@ .env -CLAUDE.md -.claude - dailyNote/ diff --git a/docs/experiments.md b/docs/experiments.md new file mode 100644 index 000000000..9e31319f8 --- /dev/null +++ b/docs/experiments.md @@ -0,0 +1,121 @@ +# 프론트엔드 실험(A/B Test) 시스템 가이드 + +## 개요 + +`frontend/src/experiments/`는 클라이언트 사이드 A/B 실험을 위한 경량 구조다. +외부 서비스 없이 localStorage 기반으로 배리언트를 배정하고, React 훅으로 컴포넌트에서 간편하게 사용할 수 있다. + +## 파일 구성 + +``` +frontend/src/experiments/ +├── types.ts # 실험 관련 타입 정의 +├── definitions.ts # 실험 목록 정의 (여기만 수정하면 됨) +├── ExperimentRepository.ts # 배리언트 배정 및 조회 로직 +└── initializeExperiments.ts # 앱 시작 시 일괄 배정 + +frontend/src/hooks/Experiment/ +└── useExperimentVariant.ts # 컴포넌트에서 사용하는 React 훅 +``` + +## 동작 원리 + +1. **앱 시작 시** `initializeExperiments()`가 `ALL_EXPERIMENTS`를 순회하며 각 실험의 배리언트를 배정한다. +2. 이미 배정된 실험은 재배정하지 않는다. (같은 유저는 항상 같은 배리언트를 본다) +3. 배정 결과는 `localStorage`의 `moadong_experiments` 키에 JSON으로 저장된다. +4. 컴포넌트에서는 `useExperimentVariant` 훅으로 배리언트를 읽어 분기한다. + +## 새 실험 추가 방법 (3단계) + +### 1단계 — `definitions.ts`에 실험 정의 추가 + +```typescript +// frontend/src/experiments/definitions.ts + +export const myNewExperiment = { + key: "my_new_experiment_v1", // 전역 고유값. 중복 금지 + variants: ["A", "B"] as const, + defaultVariant: "A", // 배정 실패 또는 초기화 시 기본값 + weights: { + A: 50, + B: 50, + }, +} satisfies ExperimentDefinition<"A" | "B">; + +// ALL_EXPERIMENTS 배열에도 추가 +export const ALL_EXPERIMENTS = [ + mainBannerExperiment, + applyButtonCopyExperiment, + myNewExperiment, // ← 여기 +] as const; +``` + +### 2단계 — 컴포넌트에서 배리언트 분기 + +```typescript +import { useExperimentVariant } from '@/hooks/Experiment/useExperimentVariant'; +import { myNewExperiment } from '@/experiments/definitions'; + +const MyComponent = () => { + const variant = useExperimentVariant(myNewExperiment); + + return variant === 'B' ? : ; +}; +``` + +### 3단계 — Mixpanel 이벤트에 배리언트 속성 포함 (권장) + +실험 결과를 분석하려면 이벤트 전송 시 배리언트 값을 속성으로 넘겨야 한다. + +```typescript +import { trackEvent } from "@/utils/mixpanel"; // 실제 트래킹 유틸 경로 참고 + +trackEvent("클릭 이벤트", { + experiment_key: myNewExperiment.key, + experiment_variant: variant, +}); +``` + +## weights(가중치) 설정 + +`weights`를 생략하면 균등 배분된다. +비율을 조정하고 싶을 때만 명시한다. + +```typescript +// 10%만 B를 보는 실험 +weights: { + A: 90, + B: 10, +}, +``` + +## 배리언트 초기화 (개발/QA용) + +브라우저 콘솔에서 실행하면 배정이 초기화된다. + +```javascript +localStorage.removeItem("moadong_experiments"); +location.reload(); +``` + +또는 코드에서 직접 호출: + +```typescript +import { experimentRepository } from "@/experiments/ExperimentRepository"; +experimentRepository.resetAssignments(); +``` + +## key 네이밍 규칙 + +- 형식: `{실험_대상}_{버전}` (소문자 snake_case) +- 예시: `main_banner_v1`, `apply_button_copy_v1` +- 실험이 종료되고 새로 시작할 때는 버전을 올린다: `main_banner_v2` +- 이미 배포된 key는 절대 재사용하지 않는다. (기존 유저 배정 오염 방지) + +## 실험 종료 후 정리 + +1. `definitions.ts`에서 해당 실험 상수와 `ALL_EXPERIMENTS` 항목을 제거한다. +2. 채택된 배리언트 코드만 남기고 분기 로직을 제거한다. +3. 버려진 배리언트 코드를 삭제한다. + +## 현재 운영 중인 실험 diff --git a/docs/mixpanel-admin-weekly-report-prompts.md b/docs/mixpanel-admin-weekly-report-prompts.md new file mode 100644 index 000000000..9c7b6a636 --- /dev/null +++ b/docs/mixpanel-admin-weekly-report-prompts.md @@ -0,0 +1,96 @@ +# Mixpanel MCP 관리자 주간 리포트 고정 프롬프트 세트 + +## 목적 + +- 관리자(Admin) 사용 흐름을 주 단위로 모니터링한다. +- 설정/수정 관련 행동 데이터를 표준 템플릿으로 축적한다. + +## 운영 원칙 + +- 기간은 항상 `지난주 (월요일 00:00 ~ 일요일 23:59, KST)`로 고정한다. +- 비교 기준은 항상 `직전 주`로 고정한다. +- 관리자 이벤트에는 "클릭" 위주 이벤트가 많으므로, 저장 성공/실패는 별도 백엔드 로그와 함께 해석한다. +- 결과는 아래 "리포트 템플릿" 순서로 정리한다. + +## 고정 프롬프트 8개 (moadong ADMIN_EVENT 치환) + +1. `지난주(월~일, Asia/Seoul) 관리자 핵심 KPI를 보여줘. 다음 이벤트를 기준으로 유저 수와 이벤트 수를 각각 보여줘: "로그인페이지 Visited", "로그인 버튼클릭", "사이드바 탭 클릭", "동아리 기본 정보 수정 버튼클릭", "동아리 모집 정보 수정 버튼클릭", "활동 사진 업로드 버튼클릭", "비밀번호 변경 버튼클릭". 직전 주 대비 증감률(%) 포함.` + +2. `지난주 관리자 진입 퍼널을 보여줘: "로그인페이지 Visited" -> "로그인 버튼클릭" -> "동아리 기본 정보 수정 페이지 Visited"(또는 다른 관리자 탭 Visited). 단계별 전환율/이탈률과 직전 주 대비 변화를 계산해줘.` + +3. `지난주 사이드바 탭 이동 패턴을 분석해줘. "사이드바 탭 클릭" 이벤트의 tab_name 속성 기준으로 탭별 클릭 수, 유저 수, 전주 대비 증감을 표로 보여줘.` + +4. `지난주 동아리 기본 정보 수정 관련 행동을 요약해줘: "동아리 기본 정보 수정 버튼클릭", "분류/분과/자유태그 선택 버튼클릭", "자유태그 입력 초기화 버튼클릭", "SNS 링크 입력 초기화 버튼클릭". 이벤트별 유저 수/이벤트 수와 전주 대비 변화 포함.` + +5. `지난주 모집 정보 수정 관련 행동을 요약해줘: "동아리 모집 정보 수정 버튼클릭", "상시모집 버튼클릭", "모집 시작 날짜 변경", "모집 종료 날짜 변경", "모집 대상 입력 초기화 버튼클릭", "소개글 미리보기/편집 버튼클릭". 이벤트별 추세와 함께 보여줘.` + +6. `지난주 이미지 자산 편집 행동을 비교해줘: "동아리 커버 업로드 버튼클릭", "동아리 커버 초기화 버튼클릭", "동아리 로고 업로드 버튼클릭", "동아리 로고 초기화 버튼클릭", "활동 사진 업로드 버튼클릭", "활동 사진 삭제 버튼클릭". 업로드/초기화/삭제 비율도 계산해줘.` + +7. `지난주 계정 보안 관련 행동을 보여줘: "비밀번호 변경 버튼클릭", "새 비밀번호 입력 초기화 버튼클릭", "확인 비밀번호 입력 초기화 버튼클릭". 전주 대비 증감과 이상 패턴(초기화 클릭 과다 등)을 코멘트해줘.` + +8. `위 1~7 결과를 바탕으로 이번 주 관리자 UX 개선 액션 3개를 제안해줘. 각 액션마다 목표 KPI, 기대효과, 검증방법(A/B 또는 관찰지표), 우선순위를 포함해줘.` + +## 리포트 템플릿 (복붙용) + +```md +# 관리자 주간 리포트 (YYYY-W##) + +## 1) 한 줄 요약 + +- + +## 2) KPI 스냅샷 (지난주 vs 직전 주) + +- 로그인페이지 Visited (users/events): +- 로그인 버튼클릭 (users/events): +- 사이드바 탭 클릭 (users/events): +- 동아리 기본 정보 수정 버튼클릭 (users/events): +- 동아리 모집 정보 수정 버튼클릭 (users/events): +- 활동 사진 업로드 버튼클릭 (users/events): +- 비밀번호 변경 버튼클릭 (users/events): + +## 3) 핵심 변화 3가지 + +1. +2. +3. + +## 4) 원인 가설 + +- + +## 5) 이번 주 액션 3개 + +1. 액션: + 목표 KPI: + 기대효과: + 검증방법: + 우선순위: +2. 액션: + 목표 KPI: + 기대효과: + 검증방법: + 우선순위: +3. 액션: + 목표 KPI: + 기대효과: + 검증방법: + 우선순위: + +## 6) 리스크 / 확인 필요 + +- +``` + +## 커스터마이즈 체크리스트 + +- 실제 운영 탭 이름 확인: `사이드바 탭 클릭`의 `tab_name` 속성 값 +- 수정 성공 지표 보강 여부 결정: 클릭 이벤트 외 백엔드 성공 로그 연동 +- 관리자 계정 식별 기준 확정 (distinct_id 또는 사용자 속성) +- 페이지 체류 지표 활용 여부 결정 (`로그인페이지 Duration`, `동아리 기본 정보 수정 페이지 Duration` 등) + +## 실행 순서 권장 + +1. 프롬프트 1~7 실행 후 사실 데이터 확정 +2. 프롬프트 8 실행으로 액션 도출 +3. 템플릿에 결과 이관 후 팀 공유 diff --git a/docs/mixpanel-reporting.md b/docs/mixpanel-reporting.md new file mode 100644 index 000000000..1e2c9663e --- /dev/null +++ b/docs/mixpanel-reporting.md @@ -0,0 +1,36 @@ +# Mixpanel 리포팅 문서 인덱스 + +## 개요 + +moadong에서 Mixpanel MCP로 주간 리포트를 만들 때 사용하는 문서 모음이다. + +## 문서 바로가기 + +- 사용자(학생) 흐름 리포트: [mixpanel-weekly-report-prompts.md](./mixpanel-weekly-report-prompts.md) +- 관리자(Admin) 흐름 리포트: [mixpanel-admin-weekly-report-prompts.md](./mixpanel-admin-weekly-report-prompts.md) + +## 최근 주간 리포트 + +- 2026-W12 사용자 리포트: [2026-W12-user-mixpanel-report.md](./weekly-reports/2026-W12-user-mixpanel-report.md) + +## 주간 실행 순서 + +1. Mixpanel MCP 연결 상태 확인 + - `codex mcp list` +2. 사용자 리포트 실행 + - `docs/mixpanel-weekly-report-prompts.md`의 고정 프롬프트 1~8 순서대로 실행 +3. 관리자 리포트 실행 + - `docs/mixpanel-admin-weekly-report-prompts.md`의 고정 프롬프트 1~8 순서대로 실행 +4. 각 템플릿에 결과 이관 후 팀 공유 + +## 운영 규칙 + +- 기간 기준 고정: `지난주 (월요일 00:00 ~ 일요일 23:59, KST)` +- 비교 기준 고정: `직전 주` +- 이벤트 누락/속성 불일치 시 추정하지 않고 리스크로 명시 + +## 업데이트 규칙 + +- 신규 이벤트 추가 시, 먼저 `frontend/src/constants/eventName.ts`를 기준으로 이벤트명을 확인한다. +- 이벤트/속성 변경 시 사용자 문서와 관리자 문서를 함께 갱신한다. +- 퍼널 정의가 바뀌면 템플릿 KPI 항목도 함께 수정한다. diff --git a/docs/mixpanel-weekly-report-prompts.md b/docs/mixpanel-weekly-report-prompts.md new file mode 100644 index 000000000..27e06b23a --- /dev/null +++ b/docs/mixpanel-weekly-report-prompts.md @@ -0,0 +1,95 @@ +# Mixpanel MCP 주간 리포트 고정 프롬프트 세트 + +## 목적 + +- 매주 같은 기준으로 KPI를 조회하고, 비교와 해석을 일관되게 남긴다. +- 리포트 품질을 사람 숙련도에 의존하지 않고 템플릿으로 표준화한다. + +## 운영 원칙 + +- 기간은 항상 `지난주 (월요일 00:00 ~ 일요일 23:59, KST)`로 고정한다. +- 비교 기준은 항상 `직전 주`로 고정한다. +- 지표가 비어 있거나 이벤트명이 불일치하면, 추정하지 말고 누락 항목으로 명시한다. +- 결과는 아래 "리포트 템플릿" 순서로 정리한다. + +## 고정 프롬프트 8개 (moadong 이벤트명 1차 치환) + +1. `지난주(월~일, Asia/Seoul) 핵심 KPI를 보여줘. 이벤트는 정확히 다음 기준으로 집계해줘: "MainPage Visited"(유저 수), "ClubDetailPage Visited"(유저 수), "ApplicationFormPage Visited"(유저 수), "Application Form Submitted"(이벤트 수/유저 수). 직전 주 대비 증감률(%)도 포함해줘.` + +2. `지난주(월~일, Asia/Seoul) 지원 퍼널 전환율을 보여줘: "MainPage Visited" -> "ClubCard Clicked" -> "ClubDetailPage Visited" -> "Club Apply Button Clicked" -> "ApplicationFormPage Visited" -> "Application Form Submitted". 각 단계 전환율/이탈률과 직전 주 대비 변화를 함께 보여줘.` + +3. `지난주 "ClubCard Clicked" 사용자 cohort 기준 D1, D7 재방문율을 보여줘. 재방문 기준 이벤트는 "MainPage Visited"로 계산하고, 최근 4주 추세를 표로 정리해줘.` + +4. `지난주 유입 채널 성과를 비교해줘. "MainPage Visited"의 referrer 속성 기준으로 채널을 나누고, 각 채널별 "ClubCard Clicked", "Club Apply Button Clicked", "Application Form Submitted" 전환율을 계산해줘.` + +5. `지난주 디바이스/플랫폼별 성과를 비교해줘. Mixpanel 기본 디바이스 속성($os, $browser)을 사용해서 "MainPage Visited" -> "Application Form Submitted" 전환율을 iOS/Android/Web 기준으로 정리해줘.` + +6. `직전 주 대비 "Application Form Submitted" 변화 원인을 이벤트 기여도로 분해해줘. 후보 이벤트는 "MainPage Visited", "ClubCard Clicked", "Club Apply Button Clicked", "ApplicationFormPage Visited"로 제한하고 증가/감소 기여 Top 5를 해석해줘.` + +7. `지난주 이탈 구간을 진단해줘. "Club Apply Button Clicked" 대비 "ApplicationFormPage Visited", "ApplicationFormPage Visited" 대비 "Application Form Submitted" 전환율을 클럽별(club_id 또는 club_name 속성)로 비교해서 하위 10개를 보여줘.` + +8. `위 1~7 결과를 기반으로 이번 주 실행 액션 3개를 제안해줘. 각 액션마다 목표 KPI(예: "Application Form Submitted" 유저 수), 기대효과, 검증방법(A/B 또는 관찰지표), 우선순위를 포함해줘.` + +## 리포트 템플릿 (복붙용) + +```md +# 주간 리포트 (YYYY-W##) + +## 1) 한 줄 요약 + +- + +## 2) KPI 스냅샷 (지난주 vs 직전 주) + +- MainPage Visited (users): +- ClubDetailPage Visited (users): +- ApplicationFormPage Visited (users): +- Application Form Submitted (events/users): +- D1/D7 재방문율 (ClubCard Clicked cohort): + +## 3) 핵심 변화 3가지 + +1. +2. +3. + +## 4) 원인 가설 + +- + +## 5) 이번 주 액션 3개 + +1. 액션: + 목표 KPI: + 기대효과: + 검증방법: + 우선순위: +2. 액션: + 목표 KPI: + 기대효과: + 검증방법: + 우선순위: +3. 액션: + 목표 KPI: + 기대효과: + 검증방법: + 우선순위: + +## 6) 리스크 / 확인 필요 + +- +``` + +## 커스터마이즈 체크리스트 + +- 사용자 핵심 이벤트 확정: `MainPage Visited`, `ClubCard Clicked`, `ClubDetailPage Visited`, `Club Apply Button Clicked`, `ApplicationFormPage Visited`, `Application Form Submitted` +- KPI 정의를 팀 문서와 일치시킴 (예: 제출 KPI를 이벤트 수로 볼지 유저 수로 볼지) +- 채널 분류 기준 확정 (`referrer` 우선, 필요 시 UTM 속성 추가 수집) +- 플랫폼 구분 기준 확정 (`$os`, `$browser` 사용 여부) +- 관리자 리포트를 별도로 운영할지 결정 (`로그인 버튼클릭`, `동아리 모집 정보 수정 버튼클릭` 등 ADMIN_EVENT 기반) + +## 실행 순서 권장 + +1. 프롬프트 1~7 실행 후 사실 데이터 확정 +2. 프롬프트 8 실행으로 액션 도출 +3. 템플릿에 결과 이관 후 팀 공유 diff --git a/docs/weekly-reports/2026-W12-user-mixpanel-report.md b/docs/weekly-reports/2026-W12-user-mixpanel-report.md new file mode 100644 index 000000000..d6eda2759 --- /dev/null +++ b/docs/weekly-reports/2026-W12-user-mixpanel-report.md @@ -0,0 +1,49 @@ +# 주간 리포트 (2026-W12) + +## 1) 한 줄 요약 + +- 트래픽(Main/Detail) 감소와 중후반 퍼널(Detail→Apply, Apply→Form) 전환 저하가 겹치며 제출 유저가 전주 대비 크게 감소했다. + +## 2) KPI 스냅샷 (지난주 vs 직전 주) + +- MainPage Visited (users): `822` vs `1,287` (`-36.1%`) +- ClubDetailPage Visited (users): `531` vs `893` (`-40.5%`) +- ApplicationFormPage Visited (users): `8` vs `55` (`-85.5%`) +- Application Form Submitted (events/users): `11 / 3` vs `34 / 33` (`-67.6% / -90.9%`) +- D1/D7 재방문율 (ClubCard Clicked cohort): `36% / 6%` (4주 추세 하락) + +## 3) 핵심 변화 3가지 + +1. 퍼널 절대 볼륨 하락: `Main 822 → Card 483 → Detail 133 → Apply 18 → Form 2 → Submit 1` +2. 단계 전환 하락: `Detail→Apply 14%`(전주 `19%`), `Apply→Form 11%`(전주 `18%`) +3. 제출 표본 급감: 제출 유저 `33 → 3`로 감소, 채널/플랫폼별 해석 표본 부족 + +## 4) 원인 가설 + +- 상단 유입 감소(Main/Detail 동반 하락) + 지원 직전 구간 UX/정보 전달 부족이 동시 발생. +- 이벤트 스키마 결측(`Club Apply Button Clicked`의 `club_id/club_name=undefined`)으로 클럽별 병목 탐지가 막혀, 최적화 속도가 느려짐. +- 리텐션 추세(최근 4주) 하락으로 재방문 모수 자체가 줄어든 상태. + +## 5) 이번 주 액션 3개 + +1. 액션: `Club Apply Button Clicked`에 `club_id`, `club_name` 필수 전송 + 목표 KPI: 클럽별 `Apply→Form` 관측 가능률 `100%` + 기대효과: 병목 클럽 정확 식별 + 검증방법: 다음 주 리포트에서 `undefined` 비중 0% 확인 + 우선순위: `P0` +2. 액션: ClubDetail 지원 CTA/가이드 개선(마감, 지원 조건, 외부폼 안내 즉시 노출) + 목표 KPI: `Detail→Apply` 전환율 `+3%p` + 기대효과: 중간 퍼널 회복 + 검증방법: A/B 테스트 또는 주차 비교 + 우선순위: `P1` +3. 액션: ApplicationForm 이탈 방지(필수문항 즉시 표시, 에러메시지 개선, 임시저장 명확화) + 목표 KPI: `Apply→Form`, `Form→Submit` 개선 + 기대효과: 제출 유저 회복 + 검증방법: 단계 전환율 주간 추적 + 우선순위: `P1` + +## 6) 리스크 / 확인 필요 + +- 채널 분석은 `undefined` 비중이 높아 정밀 해석 제한. +- 플랫폼별 제출 표본이 3명으로 매우 작아 결론 신뢰도 낮음. +- `Apply→Form` 클럽별 하위 10은 이벤트 속성 누락으로 산출 불가 (우선 스키마 수정 필요). diff --git "a/frontend/.claude/agents/API\355\233\205\353\266\200\354\204\234.md" "b/frontend/.claude/agents/API\355\233\205\353\266\200\354\204\234.md" new file mode 100644 index 000000000..66057f240 --- /dev/null +++ "b/frontend/.claude/agents/API\355\233\205\353\266\200\354\204\234.md" @@ -0,0 +1,180 @@ +# API Hooks Agent + +React Query 기반 API 훅 생성 및 관리 전담 에이전트 + +## 역할 + +- React Query 훅 생성 및 수정 +- API 레이어와 훅 레이어 간 일관성 유지 +- 쿼리 키 관리 및 캐싱 전략 구현 + +## 작업 프로세스 + +### 1. 새로운 API 훅 생성 시 + +1. **API 함수 확인** + - `src/apis/` 디렉토리에서 해당 도메인의 API 함수 확인 + - API 함수가 없으면 먼저 생성 필요 + +2. **쿼리 키 등록** + - `src/constants/queryKeys.ts`에 쿼리 키 추가 + - 네이밍 컨벤션: `도메인.액션` 형식 (예: `club.list`, `application.detail`) + +3. **훅 파일 생성** + - `src/hooks/Queries/use도메인명.ts` 형식으로 생성 + - 도메인별로 파일 분리 + +4. **훅 구현** + - `useQuery` / `useMutation` 사용 + - 에러 핸들링 포함 + - 타입 안전성 보장 + +### 2. API 레이어 패턴 + +**모든 API 함수는 `apiHelpers.ts`의 헬퍼 사용:** + +```typescript +import { + handleResponse, + secureFetch, + withErrorHandling, +} from '@/apis/utils/apiHelpers'; + +// GET 요청 +export const getClubs = withErrorHandling(async (): Promise => { + const response = await secureFetch(`${BASE_URL}/clubs`); + return handleResponse(response); +}); + +// POST 요청 +export const createClub = withErrorHandling( + async (data: CreateClubRequest): Promise => { + const response = await secureFetch(`${BASE_URL}/clubs`, { + method: 'POST', + body: JSON.stringify(data), + }); + return handleResponse(response); + }, +); +``` + +### 3. React Query 훅 패턴 + +**Query 훅:** + +```typescript +import { useQuery } from '@tanstack/react-query'; +import { getClubs } from '@/apis/club/clubApi'; +import { QUERY_KEYS } from '@/constants/queryKeys'; + +export const useClubs = () => { + return useQuery({ + queryKey: [QUERY_KEYS.club.list], + queryFn: getClubs, + staleTime: 5 * 60 * 1000, // 5분 + gcTime: 10 * 60 * 1000, // 10분 + }); +}; +``` + +**Mutation 훅:** + +```typescript +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { createClub } from '@/apis/club/clubApi'; +import { QUERY_KEYS } from '@/constants/queryKeys'; + +export const useCreateClub = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createClub, + onSuccess: () => { + // 캐시 무효화 + queryClient.invalidateQueries({ + queryKey: [QUERY_KEYS.club.list], + }); + }, + onError: (error) => { + console.error('Club 생성 실패:', error); + }, + }); +}; +``` + +### 4. 쿼리 키 관리 + +**`src/constants/queryKeys.ts` 구조:** + +```typescript +export const QUERY_KEYS = { + club: { + list: 'club.list', + detail: 'club.detail', + members: 'club.members', + }, + application: { + list: 'application.list', + detail: 'application.detail', + }, + // ... +} as const; +``` + +## 주요 규칙 + +### 네이밍 컨벤션 + +- 훅 파일: `use도메인명.ts` (camelCase) +- 훅 함수: `use도메인명액션` (예: `useClubs`, `useCreateClub`) +- 쿼리 키: `도메인.액션` (dot notation) + +### 타입 안전성 + +- API 응답 타입은 `src/types/` 또는 API 파일에 정의 +- 제네릭 활용하여 타입 추론 보장 +- 에러 타입도 명시적으로 처리 + +### 캐싱 전략 + +- `staleTime`: 데이터가 fresh한 시간 (기본: 0) +- `gcTime` (구 cacheTime): 사용하지 않는 캐시 유지 시간 (기본: 5분) +- 자주 변경되지 않는 데이터는 staleTime을 길게 설정 + +### 에러 핸들링 + +- API 레이어에서 `withErrorHandling`으로 기본 에러 처리 +- 훅에서는 `onError` 콜백으로 추가 처리 +- 사용자에게 보여줄 에러는 컴포넌트 레벨에서 처리 + +### 캐시 무효화 + +- Mutation 성공 시 관련 쿼리 무효화 +- `invalidateQueries`로 자동 리페칭 +- 낙관적 업데이트가 필요한 경우 `onMutate` 활용 + +## 체크리스트 + +새 API 훅 생성 시 확인: + +- [ ] API 함수가 `src/apis/`에 존재하는가? +- [ ] 쿼리 키가 `src/constants/queryKeys.ts`에 등록되었는가? +- [ ] 타입이 명시적으로 정의되었는가? +- [ ] 에러 핸들링이 적절한가? +- [ ] 캐싱 전략이 데이터 특성에 맞는가? +- [ ] Mutation의 경우 캐시 무효화가 적절한가? +- [ ] 인증이 필요한 경우 `secureFetch`를 사용하는가? + +## 참고 파일 + +- `src/hooks/Queries/useClub.ts` - Club 관련 훅 예시 +- `src/hooks/Queries/useApplication.ts` - Application 관련 훅 예시 +- `src/apis/utils/apiHelpers.ts` - API 헬퍼 함수 +- `src/constants/queryKeys.ts` - 쿼리 키 중앙 관리 + +## 기술 스택 + +- @tanstack/react-query v5 +- TypeScript +- React 19 +- Zustand (클라이언트 상태 관리) diff --git a/frontend/.claude/commands/create-e2e-test.md b/frontend/.claude/commands/create-e2e-test.md new file mode 100644 index 000000000..45c5a69b2 --- /dev/null +++ b/frontend/.claude/commands/create-e2e-test.md @@ -0,0 +1,12 @@ +# E2E 테스트 생성기 + +너는 지금부터 Playwright 로 E2E 테스트를 생성하는 QA 전문가야. + +## 테스트 방식 + +- $ARGUMENT로 입력한 테스트 요소들을 잘 이해해줘. +- Playwright MCP를 사용해서 테스트를 진행해줘. +- 테스트가 전부 끝나면 E2E 테스트를 작성해줘. +- 작성한 테스트들을 전부 실행해줘. +- 실패한 테스트가 있으면 원인을 분석해서 수정해줘. +- 최대 3회까지만 재시도하고, 계속 실패하면 실패 원인/재현 절차/의심 구간을 리포트로 남겨줘. diff --git a/frontend/.claude/commands/find-e2e-test.md b/frontend/.claude/commands/find-e2e-test.md new file mode 100644 index 000000000..7141b9283 --- /dev/null +++ b/frontend/.claude/commands/find-e2e-test.md @@ -0,0 +1,9 @@ +# E2E 테스트 생성기 + +너는 지금부터 Playwright 로 E2E 테스트를 생성하는 QA 전문가야. + +## 테스트 방식 + +- $ARGUMENT로 입력한 테스트 요소들을 잘 이해해줘. +- Playwright MCP를 사용해서 어떤 요소들을 테스트하면 좋을지 나열해줘. +- 조사가 끝나면 요소들을 자연어로 마크다운 형태로 정리해줘. diff --git a/frontend/.claude/commands/tm/auto-implement-tasks.md b/frontend/.claude/commands/tm/auto-implement-tasks.md new file mode 100644 index 000000000..42d99226f --- /dev/null +++ b/frontend/.claude/commands/tm/auto-implement-tasks.md @@ -0,0 +1 @@ +- Assess test coverage needs diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md new file mode 100644 index 000000000..19e1b19d1 --- /dev/null +++ b/frontend/CLAUDE.md @@ -0,0 +1,220 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 빌드 및 개발 명령어 + +```bash +# 개발 서버 (Vite - 주로 사용) +npm run dev # 포트 3000에서 개발 서버 시작 + +# 빌드 +npm run build # 기본 빌드 (타입 체크 + sitemap 생성) +npm run build:dev # 개발 빌드 +npm run build:prod # 프로덕션 빌드 +npm run preview # 빌드 결과 미리보기 +npm run clean # dist 폴더 삭제 + +# 테스트 +npm run test # 전체 테스트 실행 +npm run coverage # 커버리지 리포트와 함께 테스트 실행 +npx jest path/to/file.test.ts # 단일 테스트 파일 실행 + +# 코드 품질 +npm run lint # ESLint 자동 수정 +npm run format # Prettier 포맷팅 +npm run typecheck # TypeScript 타입 체크만 실행 + +# Storybook +npm run storybook # 포트 6006에서 Storybook 시작 +npm run build-storybook # Storybook 빌드 +npm run chromatic # Chromatic으로 시각적 테스트 배포 + +# 유틸리티 +npm run generate:sitemap # sitemap.xml 생성 +``` + +## 아키텍처 개요 + +### 기술 스택 + +- React 19 + TypeScript +- Vite 번들러 (webpack 설정도 있으나 Vite가 주력) +- styled-components 스타일링 +- TanStack React Query v5 서버 상태 관리 +- Zustand 클라이언트 상태 관리 +- React Router v7 +- date-fns 날짜 처리 +- Framer Motion 애니메이션 +- Swiper 캐러셀 +- react-datepicker 날짜 선택 +- react-markdown 마크다운 렌더링 + +### 외부 서비스 통합 + +- **Mixpanel**: 사용자 분석 및 이벤트 트래킹 +- **Sentry**: 에러 모니터링 및 성능 추적 +- **Channel.io**: 고객 지원 채팅 +- **Kakao SDK**: 카카오 공유 기능 + +모든 SDK는 `src/utils/initSDK.ts`에서 초기화되며, 각각 환경 변수 필요. + +### 환경 변수 + +`.env` 파일에 다음 환경 변수 설정 필요 (모두 `VITE_` 접두사 사용): + +- `VITE_API_URL` - 백엔드 API URL +- `VITE_MIXPANEL_TOKEN` - Mixpanel 프로젝트 토큰 +- `VITE_SENTRY_DSN` - Sentry DSN +- `VITE_SENTRY_RELEASE` - Sentry 릴리즈 버전 +- `VITE_ENABLE_SENTRY_IN_DEV` - 개발 환경에서 Sentry 활성화 여부 (true/false) +- `VITE_CHANNEL_PLUGIN_KEY` - Channel.io 플러그인 키 +- `VITE_KAKAO_JAVASCRIPT_KEY` - Kakao JavaScript 키 + +### 프로젝트 구조 + +**경로 별칭**: `@/*`는 `src/*`로 매핑 + +**주요 디렉토리**: + +- `src/apis/` - 도메인별 API 함수 (club, auth, application, applicants) +- `src/hooks/Queries/` - API를 래핑하는 React Query 훅 (useClub, useApplication, useApplicants) +- `src/store/` - Zustand 스토어 (useCategoryStore, useSearchStore) +- `src/pages/` - 라우트 기반 페이지 컴포넌트 +- `src/components/` - 공용 UI 컴포넌트 +- `src/context/` - React Context 프로바이더 (AdminClubContext - SSE 상태 관리) +- `src/experiments/` - A/B 테스트 실험 정의 및 관리 +- `src/mocks/` - MSW(Mock Service Worker) 핸들러 +- `src/utils/` - 유틸리티 함수 (날짜 파싱, 유효성 검사, 디바운스, WebView 브릿지 등) +- `src/errors/` - 커스텀 에러 클래스 +- `src/types/` - 공용 타입 정의 +- `src/constants/` - 상수 관리 (queryKeys, storageKeys, status, eventName, api, snsConfig 등) + +### API 레이어 패턴 + +API는 `src/apis/utils/apiHelpers.ts`의 헬퍼 함수를 사용하는 일관된 패턴을 따름: + +- `handleResponse()` - 응답 파싱, `{ data: {...} }` 형식 자동 언래핑 +- `withErrorHandling()` - API 호출을 에러 로깅으로 래핑 +- `secureFetch()` - 인증된 요청, 403 시 토큰 자동 갱신 + +쿼리 키는 `src/constants/queryKeys.ts`에 중앙 관리. + +### 인증 플로우 + +- JWT는 localStorage에 저장 (`accessToken` 키, `src/constants/storageKeys.ts`에서 관리) +- 리프레시 토큰은 쿠키로 처리 +- `src/apis/auth/secureFetch.ts`의 `secureFetch()`가 자동 토큰 갱신 담당 +- 어드민 라우트는 `PrivateRoute` 컴포넌트로 보호 + +### 실험(A/B 테스트) 프레임워크 + +`src/experiments/`에서 Mixpanel 기반 실험 관리: + +- `definitions.ts` - 실험 정의 (key, variants, weights) +- `ExperimentRepository.ts` - 실험 할당 및 변형 조회 로직 +- `initializeExperiments.ts` - 앱 시작 시 실험 초기화 +- `useExperiment()` 훅으로 컴포넌트에서 실험 변형 사용 + +**예시**: + +```typescript +const { variant } = useExperiment(mainBannerExperiment); +// variant는 'A' 또는 'B' +``` + +### MSW (Mock Service Worker) + +`src/mocks/`에서 API 모킹 관리: + +- `handlers/` - 도메인별 모킹 핸들러 +- `browser.ts` - MSW 브라우저 워커 설정 +- Storybook 및 개발 환경에서 사용 + +### 주요 유틸리티 함수 + +`src/utils/`에 공용 유틸리티 함수 모음: + +- `formatRelativeDateTime.ts` - 상대적 시간 표시 ("2시간 전") +- `recruitmentDateParser.ts` - 모집 기간 파싱 +- `debounce.ts` - 디바운스 함수 +- `validateSocialLink.ts` - SNS 링크 유효성 검사 +- `isInAppWebView.ts` - 인앱 WebView 감지 +- `webviewBridge.ts` - 네이티브 앱과 통신 +- `initSDK.ts` - 외부 SDK 초기화 (Mixpanel, Sentry, Channel.io, Kakao) + +### 반응형 브레이크포인트 + +`src/styles/mediaQuery.ts`에 정의: + +- mini_mobile: 375px +- mobile: 500px +- tablet: 700px +- laptop: 1280px +- Desktop: 1280px 초과 (기본값) + +### 테마 시스템 + +테마는 `src/styles/theme/`에 colors, typography, transitions로 정의. styled-components `ThemeProvider`를 통해 접근. + +### 상수 관리 + +`src/constants/`에 모든 상수 중앙 관리: + +- `queryKeys.ts` - React Query 쿼리 키 (도메인.액션 형식) +- `storageKeys.ts` - localStorage 키 (`accessToken`, `hasConsentedPersonalInfo`) +- `status.ts` - 지원 상태 정의 (PENDING, APPROVED, REJECTED 등) +- `eventName.ts` - Mixpanel 이벤트명 +- `api.ts` - API 엔드포인트 URL +- `snsConfig.ts` - SNS 플랫폼 설정 +- `applicationForm.ts` - 지원서 폼 설정 +- `uploadLimit.ts` - 파일 업로드 제한 + +### 실시간 업데이트 + +지원자 상태 업데이트를 위해 SSE(Server-Sent Events) 사용, `AdminClubContext`에서 관리. + +### 날짜 처리 + +- `date-fns` 라이브러리 사용 (Moment.js 대신) +- `formatRelativeDateTime` 유틸로 상대 시간 표시 +- `react-datepicker` 컴포넌트로 날짜 입력 + +### 애니메이션 + +- `framer-motion` 라이브러리로 페이지 전환, 모달, 제스처 등 애니메이션 구현 +- `src/styles/theme/transitions.ts`에 공통 트랜지션 정의 + +### 캐러셀 + +- `swiper` 라이브러리로 이미지 슬라이더, 카드 캐러셀 구현 + +## 테스트 + +- Jest + React Testing Library +- MSW로 API 모킹 +- 테스트 파일은 `*.test.ts` 또는 `*.test.tsx` 형식 +- 커버리지 리포트: `npm run coverage` + +## Storybook + +- 컴포넌트 독립 개발 환경 +- MSW addon으로 API 모킹 지원 +- Chromatic으로 시각적 회귀 테스트 + +## Claude Code Agent + +`.claude/agents/` 디렉토리에 전담 agent 정의: + +- `api-hooks-agent.md` - React Query 훅 생성 및 관리 전담 + +Agent 사용 시 해당 문서를 참조하여 일관된 패턴 유지. + +## 코딩 컨벤션 + +- **네이밍**: camelCase (변수, 함수), PascalCase (컴포넌트, 타입) +- **파일명**: 컴포넌트는 PascalCase.tsx, 유틸은 camelCase.ts +- **Import 순서**: 외부 라이브러리 → 내부 모듈 → 타입 → 스타일 +- **스타일**: styled-components 사용, 테마 시스템 활용 +- **타입**: any 금지, 명시적 타입 정의 +- **상수**: UPPER_SNAKE_CASE, `src/constants/`에서 관리 diff --git a/frontend/src/experiments/ExperimentRepository.ts b/frontend/src/experiments/ExperimentRepository.ts new file mode 100644 index 000000000..d9d9fb23b --- /dev/null +++ b/frontend/src/experiments/ExperimentRepository.ts @@ -0,0 +1,100 @@ +import type { + ExperimentAssignments, + ExperimentDefinition, + ExperimentVariant, +} from './types'; + +const ASSIGNMENT_STORAGE_KEY = 'moadong_experiments'; + +const isObjectRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const safeReadAssignments = (): ExperimentAssignments => { + try { + const raw = localStorage.getItem(ASSIGNMENT_STORAGE_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + if (!isObjectRecord(parsed)) return {}; + return Object.fromEntries( + Object.entries(parsed).filter(([, value]) => typeof value === 'string'), + ) as ExperimentAssignments; + } catch { + return {}; + } +}; + +const writeAssignments = (assignments: ExperimentAssignments) => { + try { + localStorage.setItem(ASSIGNMENT_STORAGE_KEY, JSON.stringify(assignments)); + } catch { + // localStorage 쓰기 실패(용량 초과, 권한 거부 등)는 무시하고 진행한다. + // 실패해도 배정값은 메모리에서 유효하며, 다음 새로고침 시 재배정된다. + } +}; + +const pickWeightedVariant = ( + experiment: ExperimentDefinition, +): V => { + if (experiment.variants.length === 0) return experiment.defaultVariant; + if (experiment.variants.length === 1) return experiment.variants[0]; + + const { variants, weights } = experiment; + if (!weights) { + const randomIndex = Math.floor(Math.random() * variants.length); + return variants[randomIndex]; + } + + const totalWeight = variants.reduce( + (sum, variant) => sum + (weights[variant] ?? 0), + 0, + ); + + if (totalWeight <= 0) return experiment.defaultVariant; + + let randomPointer = Math.random() * totalWeight; + for (const variant of variants) { + randomPointer -= weights[variant] ?? 0; + if (randomPointer <= 0) return variant; + } + + return experiment.defaultVariant; +}; + +class ExperimentRepository { + fetchAndAssignExperiments(experiments: readonly ExperimentDefinition[]) { + if (experiments.length === 0) return; + + const assignments = safeReadAssignments(); + + experiments.forEach((experiment) => { + const existing = assignments[experiment.key]; + const isValidExisting = + !!existing && experiment.variants.includes(existing); + + if (isValidExisting) return; + + assignments[experiment.key] = pickWeightedVariant(experiment); + }); + + writeAssignments(assignments); + } + + getVariant( + experiment: ExperimentDefinition, + ): V { + const assignments = safeReadAssignments(); + const assignedVariant = assignments[experiment.key]; + + if (assignedVariant && experiment.variants.includes(assignedVariant as V)) { + return assignedVariant as V; + } + + return experiment.defaultVariant; + } + + resetAssignments() { + localStorage.removeItem(ASSIGNMENT_STORAGE_KEY); + } +} + +export const experimentRepository = new ExperimentRepository(); diff --git a/frontend/src/experiments/definitions.ts b/frontend/src/experiments/definitions.ts new file mode 100644 index 000000000..6d1481338 --- /dev/null +++ b/frontend/src/experiments/definitions.ts @@ -0,0 +1,26 @@ +import type { ExperimentDefinition } from './types'; + +export const mainBannerExperiment = { + key: 'main_banner_v1', + variants: ['A', 'B'] as const, + defaultVariant: 'A', + weights: { + A: 50, + B: 50, + }, +} satisfies ExperimentDefinition<'A' | 'B'>; + +export const applyButtonCopyExperiment = { + key: 'apply_button_copy_v1', + variants: ['A', 'B'] as const, + defaultVariant: 'A', + weights: { + A: 50, + B: 50, + }, +} satisfies ExperimentDefinition<'A' | 'B'>; + +export const ALL_EXPERIMENTS = [ + mainBannerExperiment, + applyButtonCopyExperiment, +] as const; diff --git a/frontend/src/experiments/initializeExperiments.ts b/frontend/src/experiments/initializeExperiments.ts new file mode 100644 index 000000000..ffa363b38 --- /dev/null +++ b/frontend/src/experiments/initializeExperiments.ts @@ -0,0 +1,6 @@ +import { ALL_EXPERIMENTS } from './definitions'; +import { experimentRepository } from './ExperimentRepository'; + +export const initializeExperiments = () => { + experimentRepository.fetchAndAssignExperiments(ALL_EXPERIMENTS); +}; diff --git a/frontend/src/experiments/types.ts b/frontend/src/experiments/types.ts new file mode 100644 index 000000000..af885ff70 --- /dev/null +++ b/frontend/src/experiments/types.ts @@ -0,0 +1,10 @@ +export type ExperimentVariant = string; + +export type ExperimentDefinition = { + key: string; + variants: readonly V[]; + defaultVariant: V; + weights?: Partial>; +}; + +export type ExperimentAssignments = Record; diff --git a/frontend/src/hooks/Experiment/useExperimentVariant.ts b/frontend/src/hooks/Experiment/useExperimentVariant.ts new file mode 100644 index 000000000..de1dc0357 --- /dev/null +++ b/frontend/src/hooks/Experiment/useExperimentVariant.ts @@ -0,0 +1,11 @@ +import { experimentRepository } from '@/experiments/ExperimentRepository'; +import type { + ExperimentDefinition, + ExperimentVariant, +} from '@/experiments/types'; + +export const useExperimentVariant = ( + experiment: ExperimentDefinition, +): V => { + return experimentRepository.getVariant(experiment); +}; diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 0906c2c8e..147899748 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -1,9 +1,11 @@ import ReactDOM from 'react-dom/client'; import App from './App'; +import { initializeExperiments } from './experiments/initializeExperiments'; import { initializeMixpanel, initializeSentry } from './utils/initSDK'; initializeMixpanel(); initializeSentry(); +initializeExperiments(); async function startApp() { if (import.meta.env.DEV) {