Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
11 commits
Select commit Hold shift + click to select a range
b3f26fc
개발자 디버그 HUD FAB 구현 : feat : DevProvider context 및 API 로그 저장소 구현 https…
Cassiiopeia Apr 27, 2026
271ac7a
개발자 디버그 HUD FAB 구현 : refactor : registerApiLogHook 중복 등록 경고 추가 https:…
Cassiiopeia Apr 27, 2026
cbd81d2
개발자 디버그 HUD FAB 구현 : feat : apiFetch 로그 후킹 콜백 연결 https://github.com/p…
Cassiiopeia Apr 27, 2026
1091b76
개발자 디버그 HUD FAB 구현 : refactor : logId 불필요한 타입 캐스트 제거 및 stale 주석 삭제 ht…
Cassiiopeia Apr 27, 2026
ec7deec
개발자 디버그 HUD FAB 구현 : feat : ApiLogPanel, AiDebugPanel, StorePanel 구현 …
Cassiiopeia Apr 27, 2026
5668e1a
개발자 디버그 HUD FAB 구현 : refactor : AiDebugPanel 메서드 하드코딩 제거, StorePanel …
Cassiiopeia Apr 27, 2026
1018053
개발자 디버그 HUD FAB 구현 : feat : DevFab Speed Dial 및 패널 오버레이 구현 https://gi…
Cassiiopeia Apr 27, 2026
c6378bc
개발자 디버그 HUD FAB 구현 : refactor : DevFab 슬라이드업 애니메이션, bg-white → bg-sur…
Cassiiopeia Apr 27, 2026
ae4c9d4
개발자 디버그 HUD FAB 구현 : feat : Settings 개발자 모드 토글 추가 및 콘솔 중복 출력 제거 https…
Cassiiopeia Apr 27, 2026
c760638
개발자 디버그 HUD FAB 구현 : refactor : Settings 개발자 섹션 stagger 애니메이션 인덱스 추가 …
Cassiiopeia Apr 27, 2026
9fff7ad
개발자 디버그 HUD FAB 구현 : refactor : prod 크래시 수정(DevContext 폴백), _callLogH…
Cassiiopeia Apr 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -81,5 +82,14 @@ const router = createBrowserRouter([
]);

export default function App() {
return <RouterProvider router={router} />;
// prod 빌드에서는 DevProvider를 완전히 제외해 번들 크기 영향 없음
if (!import.meta.env.DEV) {
return <RouterProvider router={router} />;
}
return (
<DevProvider>
<RouterProvider router={router} />
<DevFab />
</DevProvider>
);
}
46 changes: 45 additions & 1 deletion client/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -45,6 +58,7 @@ async function fetchOnce<T>(
const method = options.method ?? "GET";
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const startedAt = Date.now(); // 소요시간 측정용

const headers: Record<string, string> = {
"Content-Type": "application/json",
Expand All @@ -56,7 +70,10 @@ async function fetchOnce<T>(
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}`, {
Expand All @@ -67,15 +84,19 @@ async function fetchOnce<T>(

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);
}

// 204 No Content 또는 body가 없는 201 응답은 JSON 파싱 없이 반환
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);
Expand Down Expand Up @@ -141,3 +162,26 @@ export async function apiFetch<T>(
}
}
}

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);
}
1 change: 0 additions & 1 deletion client/src/api/mock-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 91 additions & 0 deletions client/src/dev/DevFab.tsx
Original file line number Diff line number Diff line change
@@ -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: <Activity size={16} />, label: "API 로그" },
{ type: "ai", icon: <Terminal size={16} />, label: "AI 디버거" },
{ type: "store", icon: <Database size={16} />, label: "스토어" },
];

export function DevFab() {
const { devEnabled } = useDevContext();
const [open, setOpen] = useState(false);
const [activePanel, setActivePanel] = useState<PanelType | null>(null);

// 개발자 모드 비활성화 시 렌더링 스킵
if (!devEnabled) return null;

const handleDialItem = (type: PanelType) => {
setActivePanel(type);
setOpen(false);
};

return (
<>
{/* Speed Dial FAB — 오른쪽 하단 고정, 탭바(56px) 위에 위치 */}
<div className="fixed bottom-24 right-4 z-50 flex flex-col items-end gap-2">
{open &&
PANELS.map(({ type, icon, label }) => (
<div key={type} className="flex items-center gap-2">
{/* 패널 이름 툴팁 레이블 */}
<span className="text-xs bg-toast-bg text-white px-2 py-1 rounded-lg whitespace-nowrap shadow-md">
{label}
</span>
<button
type="button"
className="btn btn-circle btn-sm bg-surface-card border border-border text-text-primary hover:bg-brand hover:text-white hover:border-brand shadow-md transition-colors"
onClick={() => handleDialItem(type)}
>
{icon}
</button>
</div>
))}

{/* 메인 FAB — 열림 상태에 따라 색상 전환 */}
<button
type="button"
className={`btn btn-circle shadow-lg transition-colors ${
open
? "bg-toast-bg text-white border-toast-bg"
: "bg-brand text-white border-brand"
}`}
onClick={() => setOpen((v) => !v)}
aria-label="개발자 HUD"
>
{open ? <X size={20} /> : <Bug size={20} />}
</button>
</div>

{/* 패널 슬라이드업 오버레이 — 하단에서 50vh 높이로 표시 */}
{activePanel && (
<div className="fixed bottom-0 left-0 right-0 z-40 bg-surface-card border-t border-border shadow-lg rounded-t-2xl h-[50vh] animate-slide-up">
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
<span className="text-sm font-semibold text-text-primary">
{PANELS.find((p) => p.type === activePanel)?.label}
</span>
<button
type="button"
onClick={() => setActivePanel(null)}
className="w-8 h-8 flex items-center justify-center text-text-caption hover:text-text-primary transition-colors rounded-lg hover:bg-surface"
>
<X size={16} />
</button>
</div>
{/* 패널 헤더(52px)를 제외한 나머지 높이에서 스크롤 */}
<div className="h-[calc(50vh-52px)] overflow-hidden">
{activePanel === "api" && <ApiLogPanel />}
{activePanel === "ai" && <AiDebugPanel />}
{activePanel === "store" && <StorePanel />}
</div>
</div>
)}
</>
);
}
77 changes: 77 additions & 0 deletions client/src/dev/DevProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<DevContextValue | null>(null);

export function DevProvider({ children }: { readonly children: ReactNode }) {
const { enabled: devEnabled, toggle: toggleDev } = useDevMode();
const [logs, setLogs] = useState<readonly ApiLogEntry[]>([]);
// 단조 증가 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 (
<DevContext.Provider value={{ devEnabled, toggleDev, logs, clearLogs }}>
{children}
</DevContext.Provider>
);
}

export function useDevContext(): DevContextValue {
const ctx = useContext(DevContext);
if (!ctx) throw new Error("useDevContext must be used inside DevProvider");
return ctx;
}
24 changes: 24 additions & 0 deletions client/src/dev/hooks/useDevMode.ts
Original file line number Diff line number Diff line change
@@ -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";
}
7 changes: 7 additions & 0 deletions client/src/dev/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading