1 프론트엔드 아키텍처
MINERVA 프론트엔드는 React 19 + TypeScript + Vite로 구축한 SPA(Single Page Application)이다.
frontend/
├── src/
│ ├── main.tsx # 진입점, 라우터 설정
│ ├── App.tsx # 레이아웃 (Header + Sidebar + Outlet)
│ ├── types.ts # 공통 타입 (Query, Response, Citation, ...)
│ ├── pages/ # 7개 페이지 컴포넌트
│ │ ├── Home.tsx
│ │ ├── QnaChatbot.tsx
│ │ ├── DataStandardizer.tsx
│ │ ├── CodeStandardizer.tsx
│ │ ├── Monitoring.tsx
│ │ ├── Records.tsx
│ │ └── Tests.tsx
│ ├── components/ # 재사용 컴포넌트
│ │ ├── FeedbackButtons.tsx
│ │ ├── QnaChatEmbed.tsx # 다른 페이지에 QnA를 임베드 (Records/Tests에서 활용)
│ │ └── TimeseriesChart.tsx
│ ├── lib/ # 11개 유틸 (도메인 로직)
│ │ ├── citationMarking.ts # 인용된 parent chunk 안 매칭 구간 <mark> 강조
│ │ ├── referencePanel.ts # 참조 패널 (참조 맵·문서 목차·주제 태그)
│ │ ├── conversations.ts # localStorage 대화 히스토리 영속화
│ │ ├── anonymousUser.ts # 사용자 ID 익명 생성 (브라우저당 고정)
│ │ ├── markdownFix.ts # 마크다운 렌더링 보정 (잘린 표 등)
│ │ ├── markdownTable.ts # 표 파싱·재구성
│ │ ├── exportTable.ts # CSV 내보내기
│ │ ├── clipboard.ts # 복붙 헬퍼
│ │ ├── time.ts # 시간 포맷
│ │ ├── relevance.ts # display_score → UI 표시 변환
│ │ └── admin.ts # ?admin=1 토글 관리
│ └── api/
│ └── client.ts # 타입 안전 API 클라이언트
├── vite.config.ts # Proxy, 빌드 설정
├── package.json
└── tsconfig.json
MINERVA에서 페이지(pages/)는 레이아웃·이벤트 핸들링만 책임지고, 도메인 로직(lib/)이 별도로 분리된다. 예를 들어 인용 강조는 QnaChatbot뿐 아니라 Records 페이지의 드릴다운에서도 동일하게 쓰이므로 citationMarking.ts로 추출했다. 페이지가 두꺼워질수록 lib/로 이동시킬 후보가 명확해진다.
2 라우팅 구조
// src/main.tsx
import { createBrowserRouter, RouterProvider } from "react-router-dom";
const router = createBrowserRouter([
{
path: "/",
element: <App />,
children: [
{ index: true, element: <Home /> },
{ path: "agents/qna_chatbot", element: <QnaChatbot /> },
{ path: "agents/data_standardizer", element: <DataStandardizer /> },
{ path: "agents/code_standardizer", element: <CodeStandardizer /> },
{ path: "monitoring", element: <Monitoring /> },
{ path: "records", element: <Records /> },
{ path: "tests", element: <Tests /> },
],
},
]);
createRoot(document.getElementById("root")!).render(
<RouterProvider router={router} />
);DataStandardizer와 CodeStandardizer는 같은 백엔드 엔드포인트(/agents/data_standardizer/{run,stream})를 호출한다. 두 페이지가 분리된 이유는 입력 형태(표 vs 코드 블록)와 출력 표시(매핑 표 vs 표준화 코드 + 주석)가 충분히 달라 같은 페이지에서 다루면 UI가 복잡해지기 때문이다. 백엔드에서는 agent_params: {"mode": "data" | "code"}로 분기하거나 입력 본문에서 코드 마커를 자동 감지한다.
2.1 레이아웃
// src/App.tsx
import { Outlet } from "react-router-dom";
function App() {
return (
<div className="app">
<Header />
<div className="layout">
<Sidebar />
<main className="content">
<Outlet />
</main>
</div>
</div>
);
}Header와 Sidebar는 모든 페이지에서 유지된다. <Outlet />에 현재 URL에 매칭되는 페이지 컴포넌트가 렌더링된다.
3 페이지별 구현
3.1 QnA Chatbot 페이지
채팅 UI의 핵심 구현이다. SSE 스트리밍으로 토큰을 실시간 수신한다.
// pages/QnaChatbotPage.tsx
import { useState, useCallback, useRef, useEffect } from "react";
import { streamAgent } from "../api/client";
import { FeedbackButtons } from "../components/FeedbackButtons";
interface Message {
role: "user" | "assistant" | "error";
content: string;
runId?: string;
citations?: Citation[];
}
function QnaChatbotPage() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [streaming, setStreaming] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
// 새 메시지 시 스크롤 이동
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, streaming]);
const sendMessage = useCallback(async () => {
if (!input.trim() || isStreaming) return;
const userText = input;
setInput("");
setMessages((prev) => [...prev, { role: "user", content: userText }]);
setIsStreaming(true);
setStreaming("");
let accumulated = "";
let citations: Citation[] = [];
await streamAgent(
"qna_chatbot",
{ text: userText, history: messages.map((m) => ({ role: m.role, content: m.content })) },
(token) => {
accumulated += token;
setStreaming(accumulated);
},
(citationData) => {
citations.push(citationData);
},
(runId) => {
setMessages((prev) => [
...prev,
{ role: "assistant", content: accumulated, runId, citations },
]);
setStreaming("");
setIsStreaming(false);
},
(error) => {
setMessages((prev) => [...prev, { role: "error", content: error }]);
setIsStreaming(false);
}
);
}, [input, isStreaming, messages]);
return (
<div className="chat-page">
<div className="messages">
{messages.map((msg, i) => (
<div key={i} className={`message ${msg.role}`}>
<p>{msg.content}</p>
{msg.citations?.length > 0 && (
<div className="citations">
{msg.citations.map((c, j) => (
<span key={j} className="citation">
[{c.index}] {c.metadata?.source_name} {c.section}
{c.display_score && <span className="score">
{(c.display_score * 100).toFixed(0)}%
</span>}
</span>
))}
</div>
)}
{msg.role === "assistant" && msg.runId && (
<FeedbackButtons runId={msg.runId} />
)}
</div>
))}
{isStreaming && <div className="message assistant">{streaming}</div>}
<div ref={bottomRef} />
</div>
<form onSubmit={(e) => { e.preventDefault(); sendMessage(); }}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendMessage(); }
}}
placeholder="질문을 입력하세요..."
disabled={isStreaming}
/>
<button type="submit" disabled={isStreaming}>전송</button>
</form>
</div>
);
}3.2 Monitoring 페이지
시계열 메트릭을 차트로 시각화한다.
// pages/MonitoringPage.tsx
import { useState } from "react";
import { useApi } from "../hooks/useApi";
import { getMetrics } from "../api/client";
import { TimeseriesChart } from "../components/TimeseriesChart";
function MonitoringPage() {
const [agent, setAgent] = useState("all");
const [period, setPeriod] = useState("7d");
const { data, loading, error } = useApi(
() => getMetrics(agent, period),
[agent, period]
);
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error}</div>;
return (
<div className="monitoring-page">
<div className="filters">
<select value={agent} onChange={(e) => setAgent(e.target.value)}>
<option value="all">전체</option>
<option value="qna_chatbot">QnA Chatbot</option>
<option value="data_standardizer">Data Standardizer</option>
</select>
<select value={period} onChange={(e) => setPeriod(e.target.value)}>
<option value="7d">7일</option>
<option value="30d">30일</option>
<option value="90d">90일</option>
</select>
</div>
<TimeseriesChart
data={data!.data}
xKey="date"
yKey="count"
title="일별 요청 수"
/>
<TimeseriesChart
data={data!.data}
xKey="date"
yKey="avg_latency"
title="평균 응답 시간 (ms)"
/>
</div>
);
}4 API 클라이언트
4.1 타입 안전 래퍼
// src/api/client.ts
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
}
}
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: "Unknown error" }));
throw new ApiError(response.status, error.detail);
}
return response.json();
}4.2 SSE 스트리밍 클라이언트
export async function streamAgent(
agentName: string,
body: RunRequest,
onToken: (token: string) => void,
onCitation: (citation: Citation) => void,
onDone: (runId: string) => void,
onError: (message: string) => void
): Promise<void> {
const response = await fetch(`/agents/${agentName}/stream`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok || !response.body) {
onError(`HTTP ${response.status}`);
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const blocks = buffer.split("\n\n");
buffer = blocks.pop() || "";
for (const block of blocks) {
if (!block.trim()) continue;
const eventMatch = block.match(/^event:\s*(.+)$/m);
const dataMatch = block.match(/^data:\s*(.+)$/m);
if (!dataMatch) continue;
const eventType = eventMatch?.[1] || "message";
const data = dataMatch[1];
switch (eventType) {
case "token": onToken(data); break;
case "citation": onCitation(JSON.parse(data)); break;
case "done": onDone(JSON.parse(data).run_id || ""); break;
case "error": onError(JSON.parse(data).message); break;
}
}
}
}모든 API 호출은 상대 경로(/agents/...)를 사용한다. Vite Proxy가 이 요청을 FastAPI(localhost:8000)로 전달한다.
5 Vite Proxy 설정
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
"/agents": {
target: "http://localhost:8000",
changeOrigin: true,
},
"/monitoring": {
target: "http://localhost:8000",
changeOrigin: true,
},
"/health": {
target: "http://localhost:8000",
changeOrigin: true,
},
"/records": {
target: "http://localhost:8000",
changeOrigin: true,
},
"/feedback": {
target: "http://localhost:8000",
changeOrigin: true,
},
// /monitoring 하위에 monitoring/metrics와 monitoring/ab/* (A/B 실험)가 함께 있다.
// 별도 prefix를 두지 않고 하나의 루트로 통합 — 운영 대시보드에서 사용량과
// 실험 결과를 한 화면에 노출하기 위한 의도적 설계.
},
},
});API 엔드포인트별로 프록시를 설정한다. 브라우저는 localhost:5173에만 요청하므로 CORS가 발생하지 않는다.
npm run dev로 띄운 Vite는 개발용 서버다. HMR(Hot Module Replacement), 즉시 모듈 변환, 소스 맵 같은 기능을 위해 매 요청마다 TypeScript를 JavaScript로 변환해 브라우저로 보낸다. 운영 배포는 두 단계로 나눠 이 변환을 빌드 시점에 한 번만 수행한다:
- 빌드:
npm run build→frontend/dist/에 정적 파일(HTML/JS/CSS) 생성 (변환·번들·minify 1회) - 정적 서빙: 그
dist/를 Nginx 또는 FastAPIStaticFiles로 서빙 (변환 없이 OS의sendfile로 전송)
5.1 Vite vs Nginx 차이를 두 차원으로 분리해서 보기
흔히 “Vite는 동시 N명 한계, Nginx는 더 많은 명 가능”이라는 비교를 듣지만, 두 차원이 섞이면 오해가 생긴다.
| 차원 | Vite dev server | Nginx 정적 서빙 | 차이의 본질 |
|---|---|---|---|
| (A) 동시 접속 한계 | 운영에 쓰면 변환 부하 누적으로 수십 명 한계 | 정적 파일 sendfile로 수천 명 가능 | 운영 정상화의 문제 (Vite는 처음부터 운영용 아님) |
| (B) 페이지 로드 속도·CPU 부하 | 매 요청 TS→JS 변환 ms 단위 | 변환 0, CPU ~0 | 실재 차이 (요청당 수~수십 ms) |
(A)는 “Vite를 운영에 쓰면” 발생하는 문제로, 잘못된 사용을 정상화하면 사라진다. (B)는 정상 운영 중에도 페이지 로드와 서버 CPU에 영향을 주는 실재 차이다.
그러므로 운영 정상화(build + Nginx) 후의 동시 접속 한계는 거의 항상 백엔드 LLM 호출에서 결정된다. 시나리오별 한계 추정과 50명·40명 같은 수치 분석은 07편 프로덕션 배포의 동시 접속자 한계 섹션에서 다룬다.
5.2 DS 친화 비유
TypeScript→JavaScript 변환을 ML 파이프라인에 비유하면 직관이 쉽다:
- Vite dev server = 매 요청마다 모델을 컴파일(예: TorchScript trace, ONNX export)해서 추론하는 격
- Nginx + build = 미리 컴파일된 ONNX 파일을 디스크에서 읽어 그대로 추론에 사용하는 격
.pth 가중치를 매번 학습하는 것이 아니라, 한 번 컴파일된 모델 자산을 재사용한다는 점이 핵심이다.
6 재사용 컴포넌트
3개 컴포넌트가 페이지 간 중복 로직을 흡수한다.
| 컴포넌트 | 사용처 | 역할 |
|---|---|---|
FeedbackButtons |
QnaChatbot, DataStandardizer, CodeStandardizer | run_id 기반 helpful/unhelpful + 코멘트 전송 |
QnaChatEmbed |
Records, Tests | QnA UI 자체를 다른 페이지에 임베드 (드릴다운 시 같은 질문을 재실행) |
TimeseriesChart |
Monitoring, Tests | Recharts 기반 시계열 차트 (지표·arm 비교 공통) |
6.2 TimeseriesChart
Recharts 기반 시계열 차트이다.
// components/TimeseriesChart.tsx
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
interface Props {
data: Array<Record<string, unknown>>;
xKey: string;
yKey: string;
title: string;
}
export function TimeseriesChart({ data, xKey, yKey, title }: Props) {
return (
<div className="chart-container">
<h3>{title}</h3>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={xKey} />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey={yKey} stroke="#8884d8" />
</LineChart>
</ResponsiveContainer>
</div>
);
}7 관련 주제
선행 지식 (Phase A)
- React 기초 – 컴포넌트, State, Hook
- React Router – SPA 라우팅
- React에서 API 호출 – fetch, 커스텀 Hook
후속 주제
- A/B 실험 프레임워크 – Tests 페이지의 백엔드 연동
- 프로덕션 배포 – React 빌드와 정적 파일 서빙
다른 카테고리 연결
- CORS와 Proxy – Vite Proxy와 프로덕션 배포 차이
- SSE – ReadableStream 스트리밍 수신