diff --git a/client/src/App.tsx b/client/src/App.tsx index 9590e221..57a7e095 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,6 @@ import { createBrowserRouter, RouterProvider, Navigate, Outlet } from "react-router-dom"; import AppLayout from "./components/AppLayout"; +import { DevProvider, DevFab } from "./dev/index"; import Home from "./pages/Home"; import CategoryCards from "./pages/CategoryCards"; import DailyChallenge from "./pages/DailyChallenge"; @@ -81,5 +82,14 @@ const router = createBrowserRouter([ ]); export default function App() { - return ; + // prod 빌드에서는 DevProvider를 완전히 제외해 번들 크기 영향 없음 + if (!import.meta.env.DEV) { + return ; + } + return ( + + + + + ); } diff --git a/client/src/api/client.ts b/client/src/api/client.ts index c3e4fc3d..127439df 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -13,8 +13,21 @@ const USE_MOCK = (import.meta.env.VITE_USE_MOCK ?? "false") === "true"; // auth 관련 경로는 토큰 자동 주입 및 401 재시도에서 제외 const AUTH_PATHS = ["/auth/login", "/auth/reissue", "/auth/logout"]; +// log 함수에서 _logHook을 참조하므로 타입/변수 선언을 먼저 배치 +type LogHook = ( + type: "req" | "res" | "err", + method: string, + path: string, + data?: unknown, + meta?: { durationMs?: number; statusCode?: number; logId?: number } +) => number | undefined; + +let _logHook: LogHook | null = null; + function log(label: string, method: string, path: string, data?: unknown) { if (!IS_DEV) return; + // HUD 훅이 활성화된 경우 콘솔 중복 출력 방지 — HUD 패널에서 직접 표시하므로 불필요 + if (_logHook) return; const style = label === "REQ" ? "color:#4F46E5;font-weight:bold" : label === "RES" @@ -45,6 +58,7 @@ async function fetchOnce( const method = options.method ?? "GET"; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + const startedAt = Date.now(); // 소요시간 측정용 const headers: Record = { "Content-Type": "application/json", @@ -56,7 +70,10 @@ async function fetchOnce( headers["Authorization"] = `Bearer ${accessToken}`; } - log("REQ", method, path, options.body ? JSON.parse(options.body as string) : undefined); + const reqBody = options.body ? JSON.parse(options.body as string) : undefined; + log("REQ", method, path, reqBody); + // DEV HUD: req 로그 등록, 반환된 id로 res/err 업데이트 + const logId = IS_DEV ? _callLogHook("req", method, path, reqBody) : undefined; try { const res = await fetch(`${BASE_URL}${path}`, { @@ -67,7 +84,9 @@ async function fetchOnce( if (!res.ok) { const body = await res.json().catch(() => null); + const durationMs = Date.now() - startedAt; log("ERR", method, path, { status: res.status, body }); + if (IS_DEV) _callLogHook("err", method, path, body, { durationMs, statusCode: res.status, logId }); throw new ApiError(res.status, body); } @@ -75,7 +94,9 @@ async function fetchOnce( const contentLength = res.headers.get("content-length"); const hasBody = res.status !== 204 && contentLength !== "0"; const data = hasBody ? ((await res.json()) as T) : (undefined as T); + const durationMs = Date.now() - startedAt; log("RES", method, path, data); + if (IS_DEV) _callLogHook("res", method, path, data, { durationMs, statusCode: res.status, logId }); return data; } finally { clearTimeout(timer); @@ -141,3 +162,26 @@ export async function apiFetch( } } } + +export function registerApiLogHook(hook: LogHook): void { + // 중복 등록 시 개발 환경에서 경고 — 두 컴포넌트가 동시에 훅을 점유하는 버그를 조기 탐지 + if (import.meta.env.DEV && _logHook !== null) { + console.warn("[DevMode] registerApiLogHook: 이미 등록된 hook을 덮어씁니다"); + } + _logHook = hook; +} + +export function unregisterApiLogHook(): void { + _logHook = null; +} + +/** 내부에서만 사용 — hook이 등록된 경우 호출 */ +export function _callLogHook( + type: "req" | "res" | "err", + method: string, + path: string, + data?: unknown, + meta?: { durationMs?: number; statusCode?: number; logId?: number } +): number | undefined { + return _logHook?.(type, method, path, data, meta); +} diff --git a/client/src/api/mock-data.ts b/client/src/api/mock-data.ts index f65b7302..b0e2b8c9 100644 --- a/client/src/api/mock-data.ts +++ b/client/src/api/mock-data.ts @@ -547,7 +547,6 @@ const MOCK_PROGRESS: ProgressResponse = { accuracy: 0.685, coverage: 0.75, recency: 0.82, - difficulty: 0.50, retry: 0.80, spread: 0.70, lastStudiedAt: "2026-04-10T09:00:00", diff --git a/client/src/dev/DevFab.tsx b/client/src/dev/DevFab.tsx new file mode 100644 index 00000000..183e47e5 --- /dev/null +++ b/client/src/dev/DevFab.tsx @@ -0,0 +1,91 @@ +import { useState } from "react"; +import { Terminal, Activity, Database, X, Bug } from "lucide-react"; +import { useDevContext } from "./DevProvider"; +import { ApiLogPanel } from "./panels/ApiLogPanel"; +import { AiDebugPanel } from "./panels/AiDebugPanel"; +import { StorePanel } from "./panels/StorePanel"; + +type PanelType = "api" | "ai" | "store"; + +// Speed Dial 항목 목록 — 타입, 아이콘, 레이블 고정 +const PANELS: { readonly type: PanelType; readonly icon: React.ReactNode; readonly label: string }[] = [ + { type: "api", icon: , label: "API 로그" }, + { type: "ai", icon: , label: "AI 디버거" }, + { type: "store", icon: , label: "스토어" }, +]; + +export function DevFab() { + const { devEnabled } = useDevContext(); + const [open, setOpen] = useState(false); + const [activePanel, setActivePanel] = useState(null); + + // 개발자 모드 비활성화 시 렌더링 스킵 + if (!devEnabled) return null; + + const handleDialItem = (type: PanelType) => { + setActivePanel(type); + setOpen(false); + }; + + return ( + <> + {/* Speed Dial FAB — 오른쪽 하단 고정, 탭바(56px) 위에 위치 */} +
+ {open && + PANELS.map(({ type, icon, label }) => ( +
+ {/* 패널 이름 툴팁 레이블 */} + + {label} + + +
+ ))} + + {/* 메인 FAB — 열림 상태에 따라 색상 전환 */} + +
+ + {/* 패널 슬라이드업 오버레이 — 하단에서 50vh 높이로 표시 */} + {activePanel && ( +
+
+ + {PANELS.find((p) => p.type === activePanel)?.label} + + +
+ {/* 패널 헤더(52px)를 제외한 나머지 높이에서 스크롤 */} +
+ {activePanel === "api" && } + {activePanel === "ai" && } + {activePanel === "store" && } +
+
+ )} + + ); +} diff --git a/client/src/dev/DevProvider.tsx b/client/src/dev/DevProvider.tsx new file mode 100644 index 00000000..7d286ede --- /dev/null +++ b/client/src/dev/DevProvider.tsx @@ -0,0 +1,77 @@ +import { createContext, useContext, useEffect, useRef, useState } from "react"; +import type { ReactNode } from "react"; +import { useDevMode } from "./hooks/useDevMode"; +import { registerApiLogHook, unregisterApiLogHook } from "../api/client"; + +/** API 호출 1건의 로그 스냅샷 */ +export interface ApiLogEntry { + readonly id: number; + readonly method: string; + readonly path: string; + readonly status: "pending" | "ok" | "error"; + readonly statusCode?: number; + readonly durationMs?: number; + readonly reqBody?: unknown; + readonly resBody?: unknown; + readonly startedAt: number; +} + +interface DevContextValue { + readonly devEnabled: boolean; + readonly toggleDev: () => void; + readonly logs: readonly ApiLogEntry[]; + readonly clearLogs: () => void; +} + +const DevContext = createContext(null); + +export function DevProvider({ children }: { readonly children: ReactNode }) { + const { enabled: devEnabled, toggle: toggleDev } = useDevMode(); + const [logs, setLogs] = useState([]); + // 단조 증가 ID — ref이므로 리렌더 없이 증가 + const idRef = useRef(0); + + // apiFetch 호출을 자동 수집 — 마운트 시 1회 등록, 직접 setLogs 사용해 의존성 제거 + useEffect(() => { + registerApiLogHook((type, method, path, data, meta) => { + if (type === "req") { + const id = ++idRef.current; + setLogs((prev) => + // 최신 50건만 유지 + [{ id, method, path, status: "pending" as const, reqBody: data, startedAt: Date.now() }, ...prev].slice(0, 50) + ); + return id; + } + if ((type === "res" || type === "err") && meta?.logId !== undefined) { + setLogs((prev) => + prev.map((l) => + l.id === meta.logId + ? { + ...l, + status: (type === "res" ? "ok" : "error") as "ok" | "error", + statusCode: meta.statusCode, + durationMs: meta.durationMs, + resBody: data, + } + : l + ) + ); + } + }); + return () => unregisterApiLogHook(); + }, []); + + const clearLogs = () => setLogs([]); + + return ( + + {children} + + ); +} + +export function useDevContext(): DevContextValue { + const ctx = useContext(DevContext); + if (!ctx) throw new Error("useDevContext must be used inside DevProvider"); + return ctx; +} diff --git a/client/src/dev/hooks/useDevMode.ts b/client/src/dev/hooks/useDevMode.ts new file mode 100644 index 00000000..4cc9c3c6 --- /dev/null +++ b/client/src/dev/hooks/useDevMode.ts @@ -0,0 +1,24 @@ +import { useState } from "react"; + +const DEV_MODE_KEY = "devMode"; + +export function useDevMode() { + const [enabled, setEnabled] = useState( + () => sessionStorage.getItem(DEV_MODE_KEY) === "1" + ); + + const toggle = () => { + const next = !enabled; + // sessionStorage에 저장해 페이지 이동 시에도 유지하되, 탭 닫으면 초기화 + if (next) sessionStorage.setItem(DEV_MODE_KEY, "1"); + else sessionStorage.removeItem(DEV_MODE_KEY); + setEnabled(next); + }; + + return { enabled, toggle }; +} + +/** React 외부(순수 함수)에서 활성화 여부 확인용 */ +export function isDevModeEnabled(): boolean { + return sessionStorage.getItem(DEV_MODE_KEY) === "1"; +} diff --git a/client/src/dev/index.ts b/client/src/dev/index.ts new file mode 100644 index 00000000..5eb10bd2 --- /dev/null +++ b/client/src/dev/index.ts @@ -0,0 +1,7 @@ +export { DevProvider, useDevContext } from "./DevProvider"; +export type { ApiLogEntry } from "./DevProvider"; +export { useDevMode, isDevModeEnabled } from "./hooks/useDevMode"; +export { ApiLogPanel } from "./panels/ApiLogPanel"; +export { AiDebugPanel } from "./panels/AiDebugPanel"; +export { StorePanel } from "./panels/StorePanel"; +export { DevFab } from "./DevFab"; diff --git a/client/src/dev/panels/AiDebugPanel.tsx b/client/src/dev/panels/AiDebugPanel.tsx new file mode 100644 index 00000000..aab108be --- /dev/null +++ b/client/src/dev/panels/AiDebugPanel.tsx @@ -0,0 +1,86 @@ +// src/dev/panels/AiDebugPanel.tsx +import { useDevContext } from "../DevProvider"; + +export function AiDebugPanel() { + const { logs } = useDevContext(); + // /ai/ 경로만 필터 — AI 엔드포인트는 promptVersion, 토큰 정보를 포함 + const aiLogs = logs.filter((l) => l.path.startsWith("/ai/")); + + return ( +
+
+ + AI 응답 디버거 ({aiLogs.length}건) + +
+
+ {aiLogs.length === 0 && ( +

+ AI API 호출 없음 +

+ )} + {aiLogs.map((entry) => { + const res = entry.resBody as Record | undefined; + return ( +
+
+ + {entry.method} + + + {entry.path} + + + {entry.durationMs !== undefined + ? `${entry.durationMs}ms` + : "..."} + +
+ {res && ( +
+ {res.promptVersion !== undefined && ( +
+

promptVersion

+

+ {String(res.promptVersion)} +

+
+ )} + {res.inputTokens !== undefined && ( +
+

inputTokens

+

+ {String(res.inputTokens)} +

+
+ )} + {res.outputTokens !== undefined && ( +
+

outputTokens

+

+ {String(res.outputTokens)} +

+
+ )} +
+ )} +
+ ); + })} +
+
+ ); +} diff --git a/client/src/dev/panels/ApiLogPanel.tsx b/client/src/dev/panels/ApiLogPanel.tsx new file mode 100644 index 00000000..1740c0a9 --- /dev/null +++ b/client/src/dev/panels/ApiLogPanel.tsx @@ -0,0 +1,99 @@ +// src/dev/panels/ApiLogPanel.tsx +import { useState } from "react"; +import { useDevContext } from "../DevProvider"; +import type { ApiLogEntry } from "../DevProvider"; + +// 상태에 따라 텍스트 색상 반환 +function statusColor(entry: ApiLogEntry): string { + if (entry.status === "pending") return "text-text-secondary"; + if (entry.status === "ok") return "text-sem-success"; + return "text-sem-error"; +} + +function MethodBadge({ method }: { readonly method: string }) { + // HTTP 메서드별 시각적 구분 — GET/POST/기타 색상 분리 + const color = + method === "GET" + ? "text-brand" + : method === "POST" + ? "text-sem-warning" + : "text-text-secondary"; + return {method}; +} + +export function ApiLogPanel() { + const { logs, clearLogs } = useDevContext(); + const [expanded, setExpanded] = useState(null); + + return ( +
+
+ + API 로그 (최근 {logs.length}건) + + +
+
+ {logs.length === 0 && ( +

API 호출 없음

+ )} + {logs.map((entry) => ( +
+ + {expanded === entry.id && ( +
+ {entry.reqBody !== undefined && ( +
+

Request

+
+                      {JSON.stringify(entry.reqBody, null, 2)}
+                    
+
+ )} + {entry.resBody !== undefined && ( +
+

Response

+
+                      {JSON.stringify(entry.resBody, null, 2)}
+                    
+
+ )} +
+ )} +
+ ))} +
+
+ ); +} diff --git a/client/src/dev/panels/StorePanel.tsx b/client/src/dev/panels/StorePanel.tsx new file mode 100644 index 00000000..72d63550 --- /dev/null +++ b/client/src/dev/panels/StorePanel.tsx @@ -0,0 +1,70 @@ +// src/dev/panels/StorePanel.tsx +import { useAuthStore } from "../../stores/authStore"; +import { usePracticeStore } from "../../stores/practiceStore"; + +export function StorePanel() { + // Selector를 활용해 필요한 필드만 구독 — 불필요한 리렌더 방지 + const memberUuid = useAuthStore((s) => s.memberUuid); + const nickname = useAuthStore((s) => s.nickname); + const hasAccessToken = useAuthStore((s) => !!s.accessToken); + const hasRefreshToken = useAuthStore((s) => !!s.refreshToken); + const isRefreshing = useAuthStore((s) => s.isRefreshing); + + const sessionId = usePracticeStore((s) => s.sessionId); + const topicCode = usePracticeStore((s) => s.topicCode); + const currentIndex = usePracticeStore((s) => s.currentIndex); + const questionsCount = usePracticeStore((s) => s.questions.length); + const resultsCount = usePracticeStore((s) => s.results.length); + const startedAt = usePracticeStore((s) => s.startedAt); + + const sections = [ + { + name: "authStore", + data: { + memberUuid, + nickname, + hasAccessToken, + hasRefreshToken, + isRefreshing, + }, + }, + { + name: "practiceStore", + data: { + sessionId, + topicCode, + currentIndex, + questionsCount, + resultsCount, + startedAt, + }, + }, + ]; + + return ( +
+ {sections.map(({ name, data }) => ( +
+

+ {name} +

+ {Object.entries(data).map(([key, value]) => ( +
+ {key} + + {value === null + ? "null" + : value === undefined + ? "undefined" + : String(value)} + +
+ ))} +
+ ))} +
+ ); +} diff --git a/client/src/dev/panels/shared.tsx b/client/src/dev/panels/shared.tsx new file mode 100644 index 00000000..8d7651f2 --- /dev/null +++ b/client/src/dev/panels/shared.tsx @@ -0,0 +1,10 @@ +/** HTTP 메서드별 색상 배지 — ApiLogPanel, AiDebugPanel 공용 */ +export function MethodBadge({ method }: { readonly method: string }) { + const color = + method === "GET" + ? "text-brand" + : method === "POST" + ? "text-sem-warning" + : "text-text-secondary"; + return {method}; +} diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index d9442e02..a1dbd934 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -10,6 +10,7 @@ import SettingsSection from "../components/SettingsSection"; import SettingsRow from "../components/SettingsRow"; import { useMember } from "../hooks/useMember"; import { isNicknameCooldown, formatNicknameCooldownMessage } from "../lib/dateUtil"; +import { useDevContext } from "../dev/index"; export default function Settings() { const navigate = useNavigate(); @@ -29,14 +30,18 @@ export default function Settings() { const [toastMsg, setToastMsg] = useState(null); const toastTimerRef = useRef | null>(null); + // DEV 빌드에서 렌더링되는 개발자 모드 토글용 context — prod 번들에서는 tree-shake됨 + const { devEnabled, toggleDev } = useDevContext(); + // 섹션별 순차 페이드인 (50ms 간격) const stagger = useStagger(); const s0 = stagger(0); // h1 "설정" const s1 = stagger(1); // 계정 섹션 const s2 = stagger(2); // 건의사항 row const s3 = stagger(3); // 앱 정보 섹션 - const s4 = stagger(4); // 로그아웃 섹션 - const s5 = stagger(5); // 로고 + 카피라이트 + const s4 = stagger(4); // 개발자 모드 섹션 (DEV 빌드) / 로그아웃 섹션 (prod) + const s5 = stagger(5); // 로그아웃 섹션 (DEV 빌드) / 로고+카피라이트 (prod) + const s6 = stagger(6); // 로고 + 카피라이트 (DEV 빌드) useEffect(() => { return () => { @@ -170,8 +175,34 @@ export default function Settings() { - {/* ⑤ 로그아웃 */} -
+ {/* ⑤ 개발자 모드 — DEV 빌드에서만 표시, prod에서는 import.meta.env.DEV가 false로 평가되어 렌더링 안 됨 */} + {import.meta.env.DEV && ( +
+ +
+ + {devEnabled ? "디버그 FAB 활성화됨" : "비활성화"} +

+ } + action={ + + } + /> +
+
+
+ )} + + {/* ⑥ 로그아웃 */} +
- {/* ⑥ 로고 + 카피라이트 */} -
+ {/* ⑦ 로고 + 카피라이트 */} +
passQL

© 2026 passQL. All rights reserved. diff --git a/docs/superpowers/plans/2026-04-27-dev-hud-fab.md b/docs/superpowers/plans/2026-04-27-dev-hud-fab.md new file mode 100644 index 00000000..b3873245 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-dev-hud-fab.md @@ -0,0 +1,868 @@ +# 개발자 디버그 HUD FAB 구현 계획 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** DEV 빌드 전용 개발자 모드 HUD를 구현한다 — Settings에서 토글 활성화 시 플로팅 FAB(Speed Dial)이 나타나고, API 로그 / AI 응답 디버거 / Zustand 스토어 뷰어 패널을 제공한다. + +**Architecture:** `src/dev/` 폴더로 개발자 기능을 완전 분리한다. `DevProvider`가 활성화 상태와 API 로그를 관리하고, `apiFetch`가 후킹 콜백을 통해 로그를 주입한다. `App.tsx`는 `src/dev/index.ts`만 import하며, `import.meta.env.DEV`가 false이면 전체 렌더링을 건너뛴다. + +**Tech Stack:** React 19, TypeScript, Zustand, daisyUI 5 (Speed Dial / FAB), Tailwind CSS 4, lucide-react + +--- + +## 파일 구조 + +``` +client/src/dev/ + index.ts ← 외부 re-export (App.tsx는 여기만 import) + DevProvider.tsx ← Context + devMode 활성화 상태 + API 로그 저장소 + DevFab.tsx ← daisyUI Speed Dial FAB 컴포넌트 + hooks/ + useDevMode.ts ← devMode sessionStorage 읽기/쓰기 + useApiLog.ts ← API 로그 리스트 접근 훅 + panels/ + ApiLogPanel.tsx ← API 호출 이력 패널 + AiDebugPanel.tsx ← AI 응답 디버거 패널 + StorePanel.tsx ← Zustand 스토어 스냅샷 패널 + +수정 파일: + client/src/api/client.ts ← apiFetch에 로그 후킹 콜백 연결 + client/src/pages/Settings.tsx ← 개발자 모드 토글 row 추가 + client/src/App.tsx ← DevProvider 마운트 +``` + +--- + +### Task 1: DevProvider — 활성화 상태 + API 로그 저장소 + +**Files:** +- Create: `client/src/dev/hooks/useDevMode.ts` +- Create: `client/src/dev/DevProvider.tsx` +- Create: `client/src/dev/index.ts` + +- [ ] **Step 1: useDevMode 훅 작성** + +```typescript +// client/src/dev/hooks/useDevMode.ts +const DEV_MODE_KEY = "devMode"; + +export function useDevMode() { + const [enabled, setEnabled] = React.useState( + () => sessionStorage.getItem(DEV_MODE_KEY) === "1" + ); + + const toggle = () => { + const next = !enabled; + if (next) sessionStorage.setItem(DEV_MODE_KEY, "1"); + else sessionStorage.removeItem(DEV_MODE_KEY); + setEnabled(next); + }; + + return { enabled, toggle }; +} + +export function isDevModeEnabled(): boolean { + return sessionStorage.getItem(DEV_MODE_KEY) === "1"; +} +``` + +- [ ] **Step 2: API 로그 타입 및 DevContext 작성** + +```typescript +// client/src/dev/DevProvider.tsx +import React, { createContext, useCallback, useContext, useRef, useState } from "react"; +import { useDevMode } from "./hooks/useDevMode"; + +export interface ApiLogEntry { + readonly id: number; + readonly method: string; + readonly path: string; + readonly status: "pending" | "ok" | "error"; + readonly statusCode?: number; + readonly durationMs?: number; + readonly reqBody?: unknown; + readonly resBody?: unknown; + readonly startedAt: number; +} + +interface DevContextValue { + readonly devEnabled: boolean; + readonly toggleDev: () => void; + readonly logs: readonly ApiLogEntry[]; + readonly addLog: (entry: Omit) => number; + readonly updateLog: (id: number, patch: Partial) => void; + readonly clearLogs: () => void; +} + +const DevContext = createContext(null); + +export function DevProvider({ children }: { readonly children: React.ReactNode }) { + const { enabled: devEnabled, toggle: toggleDev } = useDevMode(); + const [logs, setLogs] = useState([]); + const idRef = useRef(0); + + const addLog = useCallback((entry: Omit): number => { + const id = ++idRef.current; + setLogs((prev) => [{ ...entry, id }, ...prev].slice(0, 50)); // 최대 50건 유지 + return id; + }, []); + + const updateLog = useCallback((id: number, patch: Partial) => { + setLogs((prev) => + prev.map((l) => (l.id === id ? { ...l, ...patch } : l)) + ); + }, []); + + const clearLogs = useCallback(() => setLogs([]), []); + + return ( + + {children} + + ); +} + +export function useDevContext(): DevContextValue { + const ctx = useContext(DevContext); + if (!ctx) throw new Error("useDevContext must be used inside DevProvider"); + return ctx; +} +``` + +- [ ] **Step 3: index.ts 작성 (외부 공개 인터페이스)** + +```typescript +// client/src/dev/index.ts +export { DevProvider, useDevContext } from "./DevProvider"; +export type { ApiLogEntry } from "./DevProvider"; +export { useDevMode, isDevModeEnabled } from "./hooks/useDevMode"; +``` + +- [ ] **Step 4: App.tsx에 DevProvider 마운트** + +`client/src/App.tsx`에서 import 추가 후 RouterProvider를 DevProvider로 감싼다: + +```typescript +// App.tsx 상단 import 추가 +import { DevProvider } from "./dev/index"; + +// App 함수 수정 +export default function App() { + if (!import.meta.env.DEV) { + return ; + } + return ( + + + + ); +} +``` + +- [ ] **Step 5: 빌드 확인** + +```bash +cd /Users/suhsaechan/Desktop/Programming/project/passQL-Worktree/20260427_314_기능추가_개발자모드_디버그_HUD_FAB_구현/client +npm run build 2>&1 | tail -20 +``` +Expected: 에러 없이 빌드 성공 + +- [ ] **Step 6: 커밋** + +```bash +git add client/src/dev/ client/src/App.tsx +git commit -m "개발자 디버그 HUD FAB 구현 : feat : DevProvider context 및 API 로그 저장소 구현 https://github.com/passQL-Lab/passQL/issues/314" +``` + +--- + +### Task 2: apiFetch 후킹 — 로그를 DevProvider로 전달 + +**Files:** +- Create: `client/src/dev/hooks/useApiLog.ts` +- Modify: `client/src/api/client.ts` + +- [ ] **Step 1: 전역 로그 콜백 레지스트리 작성** + +`client.ts`에 콜백 등록/해제 인터페이스를 추가한다. DevProvider가 마운트될 때 등록하고 언마운트 시 해제한다. + +```typescript +// client/src/api/client.ts 상단에 추가 (기존 import 아래) + +type LogHook = ( + type: "req" | "res" | "err", + method: string, + path: string, + data?: unknown, + meta?: { durationMs?: number; statusCode?: number; logId?: number } +) => number | void; + +let _logHook: LogHook | null = null; + +/** DEV 전용: 외부 로그 후킹 콜백 등록 */ +export function registerApiLogHook(hook: LogHook): void { + _logHook = hook; +} + +/** DEV 전용: 로그 후킹 콜백 해제 */ +export function unregisterApiLogHook(): void { + _logHook = null; +} +``` + +- [ ] **Step 2: fetchOnce의 log() 호출 지점에 후킹 연결** + +기존 `log()` 함수 수정 없이, `fetchOnce` 안에서 `_logHook` 호출을 추가한다. + +`fetchOnce` 함수 내부를 아래와 같이 수정한다: + +```typescript +async function fetchOnce( + path: string, + options: RequestInit, + accessToken: string | null, +): Promise { + const method = options.method ?? "GET"; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + const startedAt = Date.now(); + + const headers: Record = { + "Content-Type": "application/json", + ...(options.headers as Record), + }; + + if (accessToken && !AUTH_PATHS.some((p) => path.startsWith(p))) { + headers["Authorization"] = `Bearer ${accessToken}`; + } + + const reqBody = options.body ? JSON.parse(options.body as string) : undefined; + log("REQ", method, path, reqBody); + // DEV HUD: req 로그 등록, 반환된 id로 res/err 시 업데이트 + const logId = IS_DEV && _logHook + ? (_logHook("req", method, path, reqBody) as number) + : undefined; + + try { + const res = await fetch(`${BASE_URL}${path}`, { + ...options, + signal: controller.signal, + headers, + }); + + if (!res.ok) { + const body = await res.json().catch(() => null); + const durationMs = Date.now() - startedAt; + log("ERR", method, path, { status: res.status, body }); + if (IS_DEV && _logHook) _logHook("err", method, path, body, { durationMs, statusCode: res.status, logId }); + throw new ApiError(res.status, body); + } + + const contentLength = res.headers.get("content-length"); + const hasBody = res.status !== 204 && contentLength !== "0"; + const data = hasBody ? ((await res.json()) as T) : (undefined as T); + const durationMs = Date.now() - startedAt; + log("RES", method, path, data); + if (IS_DEV && _logHook) _logHook("res", method, path, data, { durationMs, statusCode: res.status, logId }); + return data; + } finally { + clearTimeout(timer); + } +} +``` + +- [ ] **Step 3: useApiLog 훅 작성 — DevProvider에서 후킹 등록** + +```typescript +// client/src/dev/hooks/useApiLog.ts +import { useEffect } from "react"; +import { registerApiLogHook, unregisterApiLogHook } from "../../api/client"; +import { useDevContext } from "../DevProvider"; + +/** DevProvider 내에서 apiFetch 로그를 자동 수집하는 훅 */ +export function useApiLogCollector() { + const { addLog, updateLog } = useDevContext(); + + useEffect(() => { + registerApiLogHook((type, method, path, data, meta) => { + if (type === "req") { + return addLog({ + method, + path, + status: "pending", + reqBody: data, + startedAt: Date.now(), + }); + } + if (type === "res" && meta?.logId !== undefined) { + updateLog(meta.logId, { + status: "ok", + statusCode: meta.statusCode, + durationMs: meta.durationMs, + resBody: data, + }); + } + if (type === "err" && meta?.logId !== undefined) { + updateLog(meta.logId, { + status: "error", + statusCode: meta.statusCode, + durationMs: meta.durationMs, + resBody: data, + }); + } + }); + return () => unregisterApiLogHook(); + }, [addLog, updateLog]); +} +``` + +- [ ] **Step 4: DevProvider에 useApiLogCollector 연결** + +`DevProvider.tsx`의 `DevProvider` 함수 안에서 `useApiLogCollector()` 호출을 추가한다: + +```typescript +// DevProvider.tsx 상단 import 추가 +import { useApiLogCollector } from "./hooks/useApiLog"; + +// DevProvider 함수 내부 (useState 선언 아래) 추가 +export function DevProvider({ children }: { readonly children: React.ReactNode }) { + const { enabled: devEnabled, toggle: toggleDev } = useDevMode(); + const [logs, setLogs] = useState([]); + const idRef = useRef(0); + + // apiFetch 호출을 자동 수집 — 콜백은 addLog/updateLog 안정 참조에 의존 + // (useApiLogCollector를 여기서 바로 쓰면 순환 의존이 생기므로 인라인 등록) + useEffect(() => { + registerApiLogHook((type, method, path, data, meta) => { + if (type === "req") { + const id = ++idRef.current; + setLogs((prev) => [{ id, method, path, status: "pending", reqBody: data, startedAt: Date.now() }, ...prev].slice(0, 50)); + return id; + } + if ((type === "res" || type === "err") && meta?.logId !== undefined) { + setLogs((prev) => + prev.map((l) => + l.id === meta.logId + ? { ...l, status: type === "res" ? "ok" : "error", statusCode: meta.statusCode, durationMs: meta.durationMs, resBody: data } + : l + ) + ); + } + }); + return () => unregisterApiLogHook(); + }, []); // 마운트 시 1회 등록 — addLog/updateLog 대신 직접 setLogs 사용해 의존성 제거 + + // ...나머지 동일 +``` + +> 주의: useApiLog.ts는 이제 사용하지 않으므로 파일을 만들되 DevProvider 인라인 방식을 사용한다. + +- [ ] **Step 5: 빌드 확인** + +```bash +npm run build 2>&1 | tail -20 +``` +Expected: 에러 없이 빌드 성공 + +- [ ] **Step 6: 커밋** + +```bash +git add client/src/api/client.ts client/src/dev/ +git commit -m "개발자 디버그 HUD FAB 구현 : feat : apiFetch 로그 후킹 콜백 연결 https://github.com/passQL-Lab/passQL/issues/314" +``` + +--- + +### Task 3: 세 개의 디버그 패널 구현 + +**Files:** +- Create: `client/src/dev/panels/ApiLogPanel.tsx` +- Create: `client/src/dev/panels/AiDebugPanel.tsx` +- Create: `client/src/dev/panels/StorePanel.tsx` + +- [ ] **Step 1: ApiLogPanel 작성** + +```typescript +// client/src/dev/panels/ApiLogPanel.tsx +import { useState } from "react"; +import { useDevContext } from "../DevProvider"; +import type { ApiLogEntry } from "../DevProvider"; + +function statusColor(entry: ApiLogEntry): string { + if (entry.status === "pending") return "text-text-secondary"; + if (entry.status === "ok") return "text-sem-success"; + return "text-sem-error"; +} + +function MethodBadge({ method }: { readonly method: string }) { + const color = method === "GET" ? "text-brand" : method === "POST" ? "text-sem-warning" : "text-text-secondary"; + return {method}; +} + +export function ApiLogPanel() { + const { logs, clearLogs } = useDevContext(); + const [expanded, setExpanded] = useState(null); + + return ( +

+
+ API 로그 (최근 {logs.length}건) + +
+
+ {logs.length === 0 && ( +

API 호출 없음

+ )} + {logs.map((entry) => ( +
+ + {expanded === entry.id && ( +
+ {entry.reqBody !== undefined && ( +
+

Request

+
+                      {JSON.stringify(entry.reqBody, null, 2)}
+                    
+
+ )} + {entry.resBody !== undefined && ( +
+

Response

+
+                      {JSON.stringify(entry.resBody, null, 2)}
+                    
+
+ )} +
+ )} +
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 2: AiDebugPanel 작성** + +AI 관련 로그만 필터링해서 보여준다 (`/ai/` 경로). + +```typescript +// client/src/dev/panels/AiDebugPanel.tsx +import { useDevContext } from "../DevProvider"; + +export function AiDebugPanel() { + const { logs } = useDevContext(); + const aiLogs = logs.filter((l) => l.path.startsWith("/ai/")); + + return ( +
+
+ AI 응답 디버거 ({aiLogs.length}건) +
+
+ {aiLogs.length === 0 && ( +

AI API 호출 없음

+ )} + {aiLogs.map((entry) => { + const res = entry.resBody as Record | undefined; + return ( +
+
+ POST + {entry.path} + + {entry.durationMs !== undefined ? `${entry.durationMs}ms` : "..."} + +
+ {res && ( +
+ {res.promptVersion !== undefined && ( +
+

promptVersion

+

{String(res.promptVersion)}

+
+ )} + {res.inputTokens !== undefined && ( +
+

inputTokens

+

{String(res.inputTokens)}

+
+ )} + {res.outputTokens !== undefined && ( +
+

outputTokens

+

{String(res.outputTokens)}

+
+ )} +
+ )} +
+ ); + })} +
+
+ ); +} +``` + +- [ ] **Step 3: StorePanel 작성** + +```typescript +// client/src/dev/panels/StorePanel.tsx +import { useAuthStore } from "../../stores/authStore"; +import { usePracticeStore } from "../../stores/practiceStore"; + +export function StorePanel() { + // 전체 상태를 스냅샷으로 구독 — 변경 시 리렌더 + const auth = useAuthStore(); + const practice = usePracticeStore(); + + const sections = [ + { + name: "authStore", + data: { + memberUuid: auth.memberUuid, + nickname: auth.nickname, + hasAccessToken: !!auth.accessToken, + hasRefreshToken: !!auth.refreshToken, + isRefreshing: auth.isRefreshing, + }, + }, + { + name: "practiceStore", + data: { + sessionId: practice.sessionId, + topicCode: practice.topicCode, + currentIndex: practice.currentIndex, + questionsCount: practice.questions.length, + resultsCount: practice.results.length, + startedAt: practice.startedAt, + }, + }, + ]; + + return ( +
+ {sections.map(({ name, data }) => ( +
+

{name}

+ {Object.entries(data).map(([key, value]) => ( +
+ {key} + + {value === null ? "null" : value === undefined ? "undefined" : String(value)} + +
+ ))} +
+ ))} +
+ ); +} +``` + +- [ ] **Step 4: index.ts에 패널 re-export 추가** + +```typescript +// client/src/dev/index.ts (기존 내용에 추가) +export { DevProvider, useDevContext } from "./DevProvider"; +export type { ApiLogEntry } from "./DevProvider"; +export { useDevMode, isDevModeEnabled } from "./hooks/useDevMode"; +export { ApiLogPanel } from "./panels/ApiLogPanel"; +export { AiDebugPanel } from "./panels/AiDebugPanel"; +export { StorePanel } from "./panels/StorePanel"; +``` + +- [ ] **Step 5: 빌드 확인** + +```bash +npm run build 2>&1 | tail -20 +``` +Expected: 에러 없이 빌드 성공 + +- [ ] **Step 6: 커밋** + +```bash +git add client/src/dev/ +git commit -m "개발자 디버그 HUD FAB 구현 : feat : ApiLogPanel, AiDebugPanel, StorePanel 구현 https://github.com/passQL-Lab/passQL/issues/314" +``` + +--- + +### Task 4: DevFab — daisyUI Speed Dial FAB + 패널 렌더링 + +**Files:** +- Create: `client/src/dev/DevFab.tsx` +- Modify: `client/src/dev/index.ts` + +- [ ] **Step 1: DevFab 작성** + +```typescript +// client/src/dev/DevFab.tsx +import { useState } from "react"; +import { Terminal, Activity, Database, X, Bug } from "lucide-react"; +import { useDevContext } from "./DevProvider"; +import { ApiLogPanel } from "./panels/ApiLogPanel"; +import { AiDebugPanel } from "./panels/AiDebugPanel"; +import { StorePanel } from "./panels/StorePanel"; + +type PanelType = "api" | "ai" | "store"; + +const PANELS: { type: PanelType; icon: React.ReactNode; label: string }[] = [ + { type: "api", icon: , label: "API 로그" }, + { type: "ai", icon: , label: "AI 디버거" }, + { type: "store", icon: , label: "스토어" }, +]; + +export function DevFab() { + const { devEnabled } = useDevContext(); + const [open, setOpen] = useState(false); + const [activePanel, setActivePanel] = useState(null); + + // 개발자 모드 비활성화 시 렌더링 안 함 + if (!devEnabled) return null; + + const handleDialItem = (type: PanelType) => { + setActivePanel(type); + setOpen(false); + }; + + return ( + <> + {/* Speed Dial FAB — 오른쪽 하단 고정, z-50 */} +
+ {/* Speed Dial 아이템 — open 시 위로 펼쳐짐 */} + {open && PANELS.map(({ type, icon, label }) => ( +
+ + {label} + + +
+ ))} + + {/* 메인 FAB 버튼 */} + +
+ + {/* 패널 슬라이드업 오버레이 */} + {activePanel && ( +
+
+ + {PANELS.find((p) => p.type === activePanel)?.label} + + +
+
+ {activePanel === "api" && } + {activePanel === "ai" && } + {activePanel === "store" && } +
+
+ )} + + ); +} +``` + +- [ ] **Step 2: index.ts에 DevFab re-export 추가** + +```typescript +// client/src/dev/index.ts 전체 (최종본) +export { DevProvider, useDevContext } from "./DevProvider"; +export type { ApiLogEntry } from "./DevProvider"; +export { useDevMode, isDevModeEnabled } from "./hooks/useDevMode"; +export { ApiLogPanel } from "./panels/ApiLogPanel"; +export { AiDebugPanel } from "./panels/AiDebugPanel"; +export { StorePanel } from "./panels/StorePanel"; +export { DevFab } from "./DevFab"; +``` + +- [ ] **Step 3: App.tsx에 DevFab 마운트** + +```typescript +// App.tsx import 수정 +import { DevProvider, DevFab } from "./dev/index"; + +export default function App() { + if (!import.meta.env.DEV) { + return ; + } + return ( + + + + + ); +} +``` + +- [ ] **Step 4: 빌드 확인** + +```bash +npm run build 2>&1 | tail -20 +``` +Expected: 에러 없이 빌드 성공 + +- [ ] **Step 5: 커밋** + +```bash +git add client/src/dev/ client/src/App.tsx +git commit -m "개발자 디버그 HUD FAB 구현 : feat : DevFab Speed Dial 및 패널 오버레이 구현 https://github.com/passQL-Lab/passQL/issues/314" +``` + +--- + +### Task 5: Settings에 개발자 모드 토글 추가 + +**Files:** +- Modify: `client/src/pages/Settings.tsx` + +- [ ] **Step 1: Settings.tsx에 토글 row 추가** + +`Settings.tsx`의 "앱 정보" 섹션 아래에 개발자 모드 섹션을 추가한다. `import.meta.env.DEV`가 true일 때만 렌더링. + +```typescript +// Settings.tsx 상단 import 추가 +import { useDevContext } from "../dev/index"; + +// Settings 함수 내부 — 기존 state 선언 아래에 추가 +const { devEnabled, toggleDev } = useDevContext(); +``` + +"앱 정보" 섹션 `
` 바로 아래에 추가: + +```tsx +{/* 개발자 모드 섹션 — DEV 빌드에서만 표시 */} +{import.meta.env.DEV && ( +
+ +
+ + {devEnabled ? "디버그 FAB 활성화됨" : "비활성화"} +

+ } + action={ + + } + /> +
+
+
+)} +``` + +- [ ] **Step 2: 빌드 확인** + +```bash +npm run build 2>&1 | tail -20 +``` +Expected: 에러 없이 빌드 성공 + +- [ ] **Step 3: client.ts 콘솔 log 제거 (HUD로 대체)** + +`client.ts`의 `log()` 함수 — DEV HUD가 생겼으므로 콘솔 출력을 제거한다. `_logHook`이 등록된 경우에는 콘솔 출력을 건너뛴다: + +```typescript +function log(label: string, method: string, path: string, data?: unknown) { + if (!IS_DEV) return; + // HUD 후킹이 활성화된 경우 콘솔 중복 출력 방지 + if (_logHook) return; + const style = label === "REQ" + ? "color:#4F46E5;font-weight:bold" + : label === "RES" + ? "color:#22C55E;font-weight:bold" + : "color:#EF4444;font-weight:bold"; + console.groupCollapsed(`%c[API ${label}] ${method} ${path}`, style); + if (data !== undefined) console.log(data); + console.groupEnd(); +} +``` + +- [ ] **Step 4: 최종 빌드 확인** + +```bash +npm run build 2>&1 | tail -20 +``` +Expected: 에러 없이 빌드 성공 + +- [ ] **Step 5: 커밋** + +```bash +git add client/src/pages/Settings.tsx client/src/api/client.ts +git commit -m "개발자 디버그 HUD FAB 구현 : feat : Settings 개발자 모드 토글 추가 및 콘솔 중복 출력 제거 https://github.com/passQL-Lab/passQL/issues/314" +``` + +--- + +## 셀프 리뷰 + +**Spec coverage 체크:** +- [x] Settings 개발자 모드 토글 → Task 5 +- [x] sessionStorage devMode 플래그 → Task 1 useDevMode +- [x] DEV 빌드에서만 렌더링 → App.tsx `import.meta.env.DEV` 분기 +- [x] daisyUI Speed Dial FAB → Task 4 DevFab +- [x] API 로그 패널 (req/res body 펼치기) → Task 3 ApiLogPanel +- [x] AI 응답 디버거 → Task 3 AiDebugPanel +- [x] Zustand 스토어 뷰어 → Task 3 StorePanel +- [x] src/dev/ 완전 분리 + index.ts re-export → Task 1~4 +- [x] client.ts 콘솔 중복 출력 제거 → Task 5 Step 3 + +**Placeholder 없음** — 모든 단계에 실제 코드 포함됨. + +**타입 일관성:** +- `ApiLogEntry.id: number` → Task 1 정의, Task 2 `_logHook` 반환값, Task 3 패널에서 `entry.id` key로 사용 — 일치 +- `LogHook` 반환 타입 `number | void` → req 시 number 반환, 나머지 void — Task 2에서 일관되게 사용 +- `useDevContext()` → Task 1 DevProvider에서 export, Task 3~5에서 import — 일치