React에서 API 호출

fetch, 타입 안전 클라이언트, 에러 처리 패턴

React 앱에서 FastAPI 백엔드를 호출하는 방법을 정리한다. 브라우저 내장 fetch API, 타입 안전 래퍼 함수 설계, 로딩/에러/성공 상태 관리, SSE 스트리밍 수신, 커스텀 Hook 패턴을 다룬다.

Engineering
저자

Kwangmin Kim

공개

2026년 05월 05일

1 fetch API 기초

fetch는 브라우저에 내장된 HTTP 클라이언트이다. 별도 설치 없이 사용할 수 있다.

1.1 GET 요청

const response = await fetch("/health");
const data = await response.json();
console.log(data.status); // "ok"

1.2 POST 요청 (JSON 본문)

const response = await fetch("/agents/qna_chatbot/run", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({ text: "RAG란?", history: [] }),
});

const data = await response.json();
console.log(data.response.text);

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 관련 주제

선행 지식

후속 주제

다른 카테고리 연결

  • FastAPI 입문 – 호출 대상 백엔드의 엔드포인트 구조
  • Pydantic – TypeScript 인터페이스와 Pydantic 모델의 대응
  • CORS와 Proxy – fetch 호출 시 상대경로 vs 절대경로

Subscribe

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