MINERVA React 프론트엔드

7개 페이지와 타입 안전 API 클라이언트

MINERVA의 React 프론트엔드는 7개 페이지(QnA, Data/Code Standardizer, Monitoring, Records, Tests, Home)로 구성되며, 타입 안전 fetch 래퍼로 FastAPI 백엔드와 통신한다. 페이지 구조, 라우팅, SSE 스트리밍 수신, Vite Proxy 설정, lib/ 유틸 11개의 책임 분담, 컴포넌트 설계 패턴을 정리한다.

Agent
저자

Kwangmin Kim

공개

2026년 05월 05일

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

DataStandardizerCodeStandardizer는 같은 백엔드 엔드포인트(/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가 발생하지 않는다.

Vite는 개발 서버다 — 운영에 그대로 쓰면 안 된다

npm run dev로 띄운 Vite는 개발용 서버다. HMR(Hot Module Replacement), 즉시 모듈 변환, 소스 맵 같은 기능을 위해 매 요청마다 TypeScript를 JavaScript로 변환해 브라우저로 보낸다. 운영 배포는 두 단계로 나눠 이 변환을 빌드 시점에 한 번만 수행한다:

  1. 빌드: npm run buildfrontend/dist/에 정적 파일(HTML/JS/CSS) 생성 (변환·번들·minify 1회)
  2. 정적 서빙: 그 dist/를 Nginx 또는 FastAPI StaticFiles로 서빙 (변환 없이 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.1 FeedbackButtons

사용자가 에이전트 응답에 피드백을 제공하는 컴포넌트이다.

// components/FeedbackButtons.tsx
import { useState } from "react";
import { submitFeedback } from "../api/client";

interface Props {
  runId: string;
}

export function FeedbackButtons({ runId }: Props) {
  const [submitted, setSubmitted] = useState<"up" | "down" | null>(null);

  const handleFeedback = async (type: "up" | "down") => {
    await submitFeedback(runId, type);
    setSubmitted(type);
  };

  if (submitted) {
    return <span className="feedback-done">피드백 완료</span>;
  }

  return (
    <div className="feedback-buttons">
      <button onClick={() => handleFeedback("up")}>도움이 됐다</button>
      <button onClick={() => handleFeedback("down")}>개선이 필요하다</button>
    </div>
  );
}

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)

후속 주제

다른 카테고리 연결

  • CORS와 Proxy – Vite Proxy와 프로덕션 배포 차이
  • SSE – ReadableStream 스트리밍 수신

Subscribe

Enjoy this blog? Get notified of new posts by email: