React 기초

컴포넌트, State, Props, Hook으로 UI를 구성한다

React는 컴포넌트 기반으로 UI를 선언적으로 구축하는 JavaScript 라이브러리이다. JSX 문법, 컴포넌트 설계, Props와 State, Hook(useState, useEffect, useCallback), 이벤트 처리, 조건부 렌더링, 리스트 렌더링을 다루어 AI Agent 프론트엔드 개발에 필요한 최소한의 React 지식을 정리한다.

Engineering
저자

Kwangmin Kim

공개

2026년 05월 05일

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는 참조가 바뀌어야 변경을 감지한다.

// 잘못된 방식 — 기존 배열을 직접 수정
messages.push(newMessage);        // React가 변경을 감지하지 못한다
setMessages(messages);

// 올바른 방식 — 새 배열을 생성
setMessages([...messages, newMessage]);

// 또는 함수형 업데이트
setMessages((prev) => [...prev, newMessage]);

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 개발 서버와 프록시 개념을 소개한다

후속 주제

다른 카테고리 연결

Subscribe

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