1 왜 React인가
전통적인 웹 개발에서는 HTML로 구조를 만들고, JavaScript로 DOM(Document Object Model — 브라우저가 HTML 문서를 메모리상 객체 트리로 표현한 것)을 직접 조작하여 화면을 변경한다. 데이터가 바뀔 때마다 어떤 DOM 요소를 찾아 어떻게 수정할지를 개발자가 직접 지시해야 한다.
// 전통 방식 — DOM 직접 조작
document.getElementById("message").textContent = "새 메시지";
document.getElementById("count").textContent = count + 1;
document.getElementById("list").innerHTML += "<li>새 항목</li>";React는 선언적(declarative) 방식을 택한다. “현재 데이터 상태에서 화면이 어떻게 보여야 하는지”를 선언하면, React가 실제 DOM 변경을 알아서 처리한다.
// React 방식 — 상태를 선언하면 UI가 자동으로 반영된다
function MessageDisplay({ message, count, items }) {
return (
<div>
<p>{message}</p>
<p>{count}</p>
<ul>
{items.map((item, i) => <li key={i}>{item}</li>)}
</ul>
</div>
);
}이 방식의 이점은 데이터와 UI의 동기화를 React가 보장한다는 것이다. 개발자는 데이터만 관리하면 되고, DOM 조작을 신경 쓸 필요가 없다.
2 프로젝트 생성과 구조
2.1 Vite로 프로젝트 생성
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
# http://localhost:5173 에서 확인Vite는 빌드 도구이다. React 프로젝트의 개발 서버, 번들링, HMR(Hot Module Replacement)을 담당한다.
2.2 프로젝트 구조
my-app/
├── index.html # 진입 HTML (React가 마운트되는 곳)
├── src/
│ ├── main.tsx # React 앱 진입점
│ ├── App.tsx # 루트 컴포넌트
│ ├── pages/ # 페이지 컴포넌트
│ ├── components/ # 재사용 컴포넌트
│ └── api/ # API 호출 함수
├── public/ # 정적 파일
├── package.json # 의존성 관리
├── tsconfig.json # TypeScript 설정
└── vite.config.ts # Vite 설정 (프록시 등)
2.3 진입점
<!-- index.html -->
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>// src/main.tsx
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")!).render(<App />);React는 #root div 하나를 점유하고, 그 안에서 모든 UI를 관리한다. 이것이 SPA(Single Page Application)의 핵심이다. 페이지 전환도 이 div 안에서 컴포넌트를 교체하는 방식으로 이루어진다.
3 JSX
JSX는 JavaScript 안에 HTML과 유사한 마크업을 작성할 수 있게 하는 문법이다. 브라우저가 직접 이해하지 못하므로 빌드 시 JavaScript 함수 호출로 변환된다.
// JSX
const element = <h1 className="title">안녕하세요</h1>;
// 빌드 후 변환 결과
const element = React.createElement("h1", { className: "title" }, "안녕하세요");3.1 JSX 규칙
function Example() {
const name = "React";
const isLoggedIn = true;
return (
// 1. 최상위 요소는 하나여야 한다 (Fragment <> 사용 가능)
<>
{/* 2. 중괄호로 JavaScript 표현식을 삽입한다 */}
<h1>{name} 앱</h1>
{/* 3. HTML의 class → className, for → htmlFor */}
<div className="container">
{/* 4. 스타일은 객체로 전달한다 */}
<p style={{ color: "blue", fontSize: "16px" }}>
텍스트
</p>
{/* 5. 조건부 렌더링 */}
{isLoggedIn && <p>환영한다</p>}
{isLoggedIn ? <p>로그인 상태</p> : <p>로그인 필요</p>}
</div>
</>
);
}JSX는 HTML이 아니다. JavaScript 표현식이므로 if문은 사용할 수 없고, &&이나 삼항 연산자로 조건부 렌더링을 한다.
4 컴포넌트
React에서 UI의 최소 단위는 컴포넌트이다. 함수가 JSX를 반환하면 컴포넌트가 된다.
// 컴포넌트 정의
function Greeting() {
return <h1>안녕하세요</h1>;
}
// 컴포넌트 사용
function App() {
return (
<div>
<Greeting />
<Greeting />
</div>
);
}컴포넌트는 대문자로 시작해야 한다. 소문자로 시작하면 React가 HTML 태그로 인식한다.
4.1 컴포넌트 분리 기준
AI Agent 프론트엔드를 예로 들면:
App
├── Header # 네비게이션 바
├── Sidebar # 에이전트 목록
└── ChatPage # 채팅 페이지
├── MessageList # 메시지 목록
│ └── Message # 개별 메시지
│ └── Citation # 인용 표시
├── InputArea # 입력창
└── FeedbackButtons # 피드백 버튼
컴포넌트를 분리하는 기준:
- 재사용: 여러 곳에서 같은 UI가 반복되면 컴포넌트로 추출한다
- 독립성: 하나의 역할만 담당하는 단위로 분리한다
- 데이터 흐름: 서로 다른 데이터를 사용하는 영역은 분리한다
5 Props: 부모 → 자식 데이터 전달
Props는 컴포넌트에 전달하는 속성이다. 함수의 인자와 같은 역할을 한다.
// 타입 정의
interface MessageProps {
role: "user" | "assistant";
content: string;
timestamp?: string;
}
// Props를 받는 컴포넌트
function Message({ role, content, timestamp }: MessageProps) {
return (
<div className={`message ${role}`}>
<p>{content}</p>
{timestamp && <span>{timestamp}</span>}
</div>
);
}
// 사용
function MessageList() {
return (
<div>
<Message role="user" content="RAG란?" />
<Message role="assistant" content="RAG는 검색 증강 생성이다." timestamp="14:30" />
</div>
);
}Props는 읽기 전용이다. 자식 컴포넌트에서 props를 수정하면 안 된다. 데이터는 항상 부모 → 자식 방향으로 흐른다 (단방향 데이터 흐름).
5.1 Children Props
컴포넌트 태그 사이에 넣은 내용은 children prop으로 전달된다.
interface CardProps {
title: string;
children: React.ReactNode;
}
function Card({ title, children }: CardProps) {
return (
<div className="card">
<h2>{title}</h2>
<div className="card-body">{children}</div>
</div>
);
}
// 사용
function App() {
return (
<Card title="에이전트 정보">
<p>QnA Chatbot</p>
<p>상태: 실행 중</p>
</Card>
);
}6 State: 컴포넌트 내부 상태
State는 컴포넌트가 기억하고 변경할 수 있는 데이터이다. State가 변경되면 React가 해당 컴포넌트를 다시 렌더링한다.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>클릭 횟수: {count}</p>
<button onClick={() => setCount(count + 1)}>증가</button>
<button onClick={() => setCount(0)}>초기화</button>
</div>
);
}useState는 [현재값, 설정함수] 쌍을 반환한다. setCount를 호출하면 React가 컴포넌트를 다시 실행하고 새 count 값으로 화면을 업데이트한다.
6.1 채팅 UI에서의 State
function ChatPage() {
const [messages, setMessages] = useState<Array<{ role: string; content: string }>>([]);
const [input, setInput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const sendMessage = async () => {
if (!input.trim()) return;
const userMessage = { role: "user", content: input };
setMessages((prev) => [...prev, userMessage]);
setInput("");
setIsLoading(true);
const response = await fetch("/agents/qna_chatbot/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: input }),
});
const data = await response.json();
setMessages((prev) => [...prev, { role: "assistant", content: data.response.text }]);
setIsLoading(false);
};
return (
<div>
<div className="messages">
{messages.map((msg, i) => (
<Message key={i} role={msg.role} content={msg.content} />
))}
{isLoading && <p>응답 생성 중...</p>}
</div>
<input value={input} onChange={(e) => setInput(e.target.value)} />
<button onClick={sendMessage} disabled={isLoading}>전송</button>
</div>
);
}이 예제에서 세 가지 State가 각각의 역할을 한다:
messages: 대화 이력 (누적 데이터)input: 현재 입력 중인 텍스트 (일시 데이터)isLoading: API 호출 중 여부 (UI 상태)
6.2 State 불변성
State를 업데이트할 때 기존 객체를 직접 수정하면 안 된다. React는 참조가 바뀌어야 변경을 감지한다.
7 Hook
Hook은 함수 컴포넌트에서 상태 관리, 부수 효과, 메모이제이션 등 React 기능을 사용하는 함수이다. use로 시작한다.
7.1 useState
위에서 다룬 상태 관리 Hook이다.
7.2 useEffect: 부수 효과
컴포넌트가 렌더링된 후 실행할 작업을 정의한다. API 호출, 이벤트 리스너 등록, 타이머 설정 등에 사용한다.
import { useState, useEffect } from "react";
function AgentStatus({ agentName }: { agentName: string }) {
const [status, setStatus] = useState("loading");
useEffect(() => {
// 컴포넌트가 마운트될 때 실행
const checkStatus = async () => {
const response = await fetch(`/agents/${agentName}/health`);
const data = await response.json();
setStatus(data.status);
};
checkStatus();
const interval = setInterval(checkStatus, 30000);
// 클린업 함수 — 컴포넌트가 언마운트될 때 실행
return () => clearInterval(interval);
}, [agentName]); // 의존성 배열 — agentName이 변경될 때만 재실행
return <span>{agentName}: {status}</span>;
}의존성 배열의 동작:
| 의존성 배열 | 실행 시점 |
|---|---|
[] |
마운트 시 1회만 |
[value] |
마운트 시 + value 변경 시 |
| 생략 | 매 렌더링마다 (거의 사용하지 않음) |
7.3 useCallback: 함수 메모이제이션
매 렌더링마다 새 함수가 생성되는 것을 방지한다. 자식 컴포넌트에 함수를 Props로 전달할 때 불필요한 재렌더링을 막는다.
import { useCallback } from "react";
function ChatPage() {
const [messages, setMessages] = useState([]);
// useCallback 없이 — 매 렌더링마다 새 함수 생성
// const sendMessage = async (text) => { ... };
// useCallback으로 — 의존성이 변경될 때만 새 함수 생성
const sendMessage = useCallback(async (text: string) => {
const response = await fetch("/agents/qna_chatbot/run", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
const data = await response.json();
setMessages((prev) => [...prev, data]);
}, []); // 빈 배열: 함수가 외부 변수에 의존하지 않음
return <InputArea onSend={sendMessage} />;
}7.4 useMemo: 값 메모이제이션
비용이 큰 계산 결과를 캐시한다.
import { useMemo } from "react";
function MetricsDashboard({ data }: { data: MetricEntry[] }) {
// data가 변경될 때만 재계산
const summary = useMemo(() => {
return {
totalRequests: data.length,
avgLatency: data.reduce((sum, d) => sum + d.latency, 0) / data.length,
errorRate: data.filter((d) => d.error).length / data.length,
};
}, [data]);
return (
<div>
<p>총 요청: {summary.totalRequests}</p>
<p>평균 지연: {summary.avgLatency}ms</p>
<p>에러율: {(summary.errorRate * 100).toFixed(1)}%</p>
</div>
);
}7.5 useRef: DOM 접근과 값 유지
렌더링에 영향을 주지 않는 값을 유지하거나, DOM 요소에 직접 접근할 때 사용한다.
import { useRef, useEffect } from "react";
function ChatMessages({ messages }: { messages: Message[] }) {
const bottomRef = useRef<HTMLDivElement>(null);
// 새 메시지가 추가되면 스크롤을 아래로 이동
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
return (
<div className="messages">
{messages.map((msg, i) => (
<div key={i}>{msg.content}</div>
))}
<div ref={bottomRef} />
</div>
);
}8 이벤트 처리
React에서 이벤트 핸들러는 camelCase로 작성하고, 함수를 전달한다.
function InputArea({ onSend }: { onSend: (text: string) => void }) {
const [input, setInput] = useState("");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
onSend(input);
setInput("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<form onSubmit={handleSubmit}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="질문을 입력하세요..."
/>
<button type="submit">전송</button>
</form>
);
}e.preventDefault()는 폼 제출 시 페이지 새로고침을 방지한다. SPA에서는 페이지를 새로고침하면 앱 상태가 초기화되므로 이 호출이 필수적이다.
9 리스트 렌더링
배열 데이터를 map으로 변환하여 렌더링한다.
interface Agent {
name: string;
status: "running" | "stopped";
description: string;
}
function AgentList({ agents }: { agents: Agent[] }) {
return (
<ul>
{agents.map((agent) => (
<li key={agent.name}>
<strong>{agent.name}</strong>
<span className={`status ${agent.status}`}>{agent.status}</span>
<p>{agent.description}</p>
</li>
))}
</ul>
);
}key는 React가 리스트 항목을 효율적으로 업데이트하기 위해 필요한 고유 식별자이다. 배열 인덱스보다 데이터의 고유 ID를 사용하는 것이 좋다. key가 없거나 중복되면 성능 문제와 버그가 발생할 수 있다.
10 조건부 렌더링
function ResponseArea({ isLoading, error, response }: {
isLoading: boolean;
error: string | null;
response: string | null;
}) {
// 로딩 상태
if (isLoading) {
return <div className="loading">응답 생성 중...</div>;
}
// 에러 상태
if (error) {
return <div className="error">{error}</div>;
}
// 응답 없음
if (!response) {
return <div className="empty">질문을 입력하세요</div>;
}
// 정상 응답
return <div className="response">{response}</div>;
}11 커스텀 Hook
반복되는 로직을 커스텀 Hook으로 추출한다.
function useFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
setData(json);
} catch (e) {
setError(e instanceof Error ? e.message : "알 수 없는 에러");
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return { data, loading, error };
}
// 사용
function HealthPage() {
const { data, loading, error } = useFetch<{ status: string }>("/health");
if (loading) return <p>로딩 중...</p>;
if (error) return <p>에러: {error}</p>;
return <p>서버 상태: {data?.status}</p>;
}커스텀 Hook의 이름은 use로 시작해야 한다. 이 규칙을 따라야 React가 Hook 규칙(최상위에서만 호출, 조건문 안에서 호출 금지)을 검사할 수 있다.
12 관련 주제
선행 지식
- API 기초 – fetch로 호출할 API의 구조
- CORS와 Proxy – 이 포스트에서 사용하는 Vite 개발 서버와 프록시 개념을 소개한다
후속 주제
- React Router – 페이지 간 전환
- React에서 API 호출 – fetch 래퍼, 타입 안전 클라이언트
다른 카테고리 연결
- SSE – 실시간 스트리밍 – ReadableStream으로 토큰 스트리밍 수신 (프론트엔드 섹션)
- FastAPI 입문 – React가 호출하는 백엔드 서버