1 fetch API 기초
fetch는 브라우저에 내장된 HTTP 클라이언트이다. 별도 설치 없이 사용할 수 있다.
1.1 GET 요청
1.2 POST 요청 (JSON 본문)
1.3 fetch의 특이점
fetch는 HTTP 에러 상태(404, 500 등)에서 예외를 던지지 않는다. 네트워크 장애(서버 다운, DNS 실패 등)에서만 예외가 발생한다.
const response = await fetch("/agents/unknown/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "test" }),
});
// 404여도 예외 없이 여기까지 온다
console.log(response.ok); // false
console.log(response.status); // 404
// 수동으로 에러를 처리해야 한다
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail);
}2 타입 안전 API 클라이언트
매번 fetch + JSON.stringify + 에러 체크를 반복하면 코드가 길어지고 실수하기 쉽다. 공통 로직을 래퍼 함수로 추출한다.
2.1 기본 래퍼
// src/api/client.ts
class ApiError extends Error {
constructor(public status: number, message: string) {
super(message);
this.name = "ApiError";
}
}
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: "알 수 없는 에러" }));
throw new ApiError(response.status, error.detail || `HTTP ${response.status}`);
}
return response.json();
}이 래퍼가 해결하는 문제:
Content-Type헤더를 자동 추가한다- HTTP 에러를 예외로 변환한다 (fetch의 특이점 해결)
- 제네릭
<T>로 반환 타입을 지정한다 - 서버 에러 메시지(
detail)를 추출한다
2.2 엔드포인트별 함수
// src/api/client.ts
// 타입 정의 (FastAPI의 Pydantic 모델과 대응)
interface RunRequest {
text: string;
history?: Array<{ role: string; content: string }>;
user_id?: string;
}
interface Citation {
source: string;
page?: number;
section?: string;
}
interface AgentResponse {
text: string;
citations: Citation[];
run_id: string;
latency_ms: number;
}
interface RunResponse {
response: AgentResponse;
experiment_id?: string;
arm_id?: string;
}
interface HealthResponse {
status: string;
}
interface MetricsResponse {
agent: string;
period: string;
data: Array<{ date: string; count: number; avg_latency: number }>;
}
// API 함수
export async function checkHealth(): Promise<HealthResponse> {
return request<HealthResponse>("/health");
}
export async function runAgent(agentName: string, body: RunRequest): Promise<RunResponse> {
return request<RunResponse>(`/agents/${agentName}/run`, {
method: "POST",
body: JSON.stringify(body),
});
}
export async function getMetrics(agent?: string, period?: string): Promise<MetricsResponse> {
const params = new URLSearchParams();
if (agent) params.set("agent", agent);
if (period) params.set("period", period);
const query = params.toString();
return request<MetricsResponse>(`/monitoring/metrics${query ? `?${query}` : ""}`);
}이 패턴의 이점:
- 호출하는 쪽에서 URL, 메서드, 헤더를 알 필요가 없다
- TypeScript가 요청 본문과 응답 타입을 컴파일 타임에 검증한다
- FastAPI의 Pydantic 모델과 대응하는 인터페이스를 정의하면 프론트엔드-백엔드 간 타입 일관성이 보장된다
3 React에서 API 호출 패턴
3.1 페이지 로드 시 데이터 가져오기
import { useState, useEffect } from "react";
import { getMetrics, MetricsResponse } from "../api/client";
function MonitoringPage() {
const [data, setData] = useState<MetricsResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const result = await getMetrics("qna_chatbot", "7d");
setData(result);
} catch (e) {
setError(e instanceof Error ? e.message : "데이터를 불러올 수 없다");
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error}</div>;
if (!data) return null;
return (
<div>
<h1>모니터링 ({data.agent})</h1>
<table>
<thead>
<tr><th>날짜</th><th>요청 수</th><th>평균 지연</th></tr>
</thead>
<tbody>
{data.data.map((row) => (
<tr key={row.date}>
<td>{row.date}</td>
<td>{row.count}</td>
<td>{row.avg_latency}ms</td>
</tr>
))}
</tbody>
</table>
</div>
);
}loading / error / data 세 가지 상태로 API 호출의 모든 경우를 처리한다. 이 패턴은 모든 데이터 페칭에서 반복된다.
3.2 사용자 액션에 의한 API 호출
import { useState, useCallback } from "react";
import { runAgent, RunResponse } from "../api/client";
function ChatPage() {
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([]);
const [isLoading, setIsLoading] = useState(false);
const sendMessage = useCallback(async (text: string) => {
setMessages((prev) => [...prev, { role: "user", content: text }]);
setIsLoading(true);
try {
const result = await runAgent("qna_chatbot", {
text,
history: messages,
});
setMessages((prev) => [
...prev,
{ role: "assistant", content: result.response.text },
]);
} catch (e) {
setMessages((prev) => [
...prev,
{ role: "error", content: e instanceof Error ? e.message : "에러 발생" },
]);
} finally {
setIsLoading(false);
}
}, [messages]);
return (
<div>
<MessageList messages={messages} />
{isLoading && <div>응답 생성 중...</div>}
<InputArea onSend={sendMessage} disabled={isLoading} />
</div>
);
}3.3 커스텀 Hook으로 추출
반복되는 loading/error/data 패턴을 Hook으로 추출한다.
// src/hooks/useApi.ts
import { useState, useEffect, useCallback } from "react";
interface UseApiResult<T> {
data: T | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
export function useApi<T>(fetcher: () => Promise<T>, deps: unknown[] = []): UseApiResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const execute = useCallback(async () => {
try {
setLoading(true);
setError(null);
const result = await fetcher();
setData(result);
} catch (e) {
setError(e instanceof Error ? e.message : "알 수 없는 에러");
} finally {
setLoading(false);
}
}, deps);
useEffect(() => {
execute();
}, [execute]);
return { data, loading, error, refetch: execute };
}// 사용 — 모니터링 페이지가 간결해진다
function MonitoringPage() {
const [period, setPeriod] = useState("7d");
const { data, loading, error } = useApi(
() => getMetrics("qna_chatbot", period),
[period]
);
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러: {error}</div>;
return (
<div>
<select value={period} onChange={(e) => setPeriod(e.target.value)}>
<option value="7d">7일</option>
<option value="30d">30일</option>
</select>
<MetricsTable data={data!.data} />
</div>
);
}period가 변경되면 useApi가 자동으로 API를 다시 호출한다.
4 SSE 스트리밍 수신
AI Agent의 토큰 스트리밍을 fetch + ReadableStream으로 수신하는 패턴이다.
// src/api/client.ts
export async function streamAgent(
agentName: string,
body: RunRequest,
onToken: (token: string) => void,
onDone: () => void,
onError: (error: 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) {
throw new ApiError(response.status, "스트리밍 연결 실패");
}
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(JSON.parse(data));
break;
case "done":
onDone();
break;
case "error":
onError(JSON.parse(data).message);
break;
}
}
}
}4.1 React 컴포넌트에서 사용
function StreamingChat() {
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([]);
const [streaming, setStreaming] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const sendMessage = useCallback(async (text: string) => {
setMessages((prev) => [...prev, { role: "user", content: text }]);
setIsStreaming(true);
setStreaming("");
let accumulated = "";
await streamAgent(
"qna_chatbot",
{ text, history: messages },
(token) => {
accumulated += token;
setStreaming(accumulated);
},
() => {
setMessages((prev) => [...prev, { role: "assistant", content: accumulated }]);
setStreaming("");
setIsStreaming(false);
},
(error) => {
setMessages((prev) => [...prev, { role: "error", content: error }]);
setIsStreaming(false);
}
);
}, [messages]);
return (
<div>
{messages.map((msg, i) => (
<div key={i} className={msg.role}>{msg.content}</div>
))}
{isStreaming && <div className="assistant">{streaming}</div>}
<InputArea onSend={sendMessage} disabled={isStreaming} />
</div>
);
}setStreaming이 토큰마다 호출되어 화면에 글자가 하나씩 나타나는 효과를 만든다.
5 에러 처리 전략
5.1 에러 종류별 분류
| 에러 종류 | HTTP 상태 | 사용자 메시지 | 처리 |
|---|---|---|---|
| 입력 검증 실패 | 422 | “입력이 올바르지 않다” | 입력 필드에 에러 표시 |
| 인증 실패 | 401 | “로그인이 필요하다” | 로그인 페이지로 이동 |
| 리소스 없음 | 404 | “에이전트를 찾을 수 없다” | 목록 페이지로 안내 |
| 서버 에러 | 500 | “서버 오류가 발생했다” | 재시도 버튼 표시 |
| LLM 타임아웃 | 503 | “응답 시간이 초과되었다” | 재시도 안내 |
| 네트워크 에러 | - | “서버에 연결할 수 없다” | 네트워크 확인 안내 |
5.2 에러 바운더리
React 컴포넌트의 렌더링 중 발생하는 에러를 잡는다.
import { Component, ReactNode } from "react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div>
<h2>문제가 발생했다</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
다시 시도
</button>
</div>
);
}
return this.props.children;
}
}
// 사용
function App() {
return (
<ErrorBoundary>
<ChatPage />
</ErrorBoundary>
);
}6 관련 주제
선행 지식
- API 기초 – HTTP 메서드, 상태 코드, JSON
- React 기초 – useState, useEffect, useCallback
- React Router – 페이지별 API 호출 분리
후속 주제
- SSE – 실시간 스트리밍 – ReadableStream 상세
다른 카테고리 연결
- FastAPI 입문 – 호출 대상 백엔드의 엔드포인트 구조
- Pydantic – TypeScript 인터페이스와 Pydantic 모델의 대응
- CORS와 Proxy – fetch 호출 시 상대경로 vs 절대경로