과거에는 자바스크립트로 화면을 업데이트할 때, 문서 객체 모델(DOM)을 직접 찾아서 수정하는 명령형(Imperative) 방식을 사용했습니다. 이는 애플리케이션 규모가 커질수록 관리하기 어렵고 성능이 저하되는 원인이 되었습니다.
리액트(React)는 가상 DOM(Virtual DOM)과 선언적(Declarative) 프로그래밍이라는 두 가지 핵심 무기를 통해 이 문제를 해결했습니다. 개발자는 '화면이 어떻게 보여야 하는지' 상태(State)만 선언하면, 리액트가 가장 효율적인 방법으로 화면을 그려줍니다.
명령형 프로그래밍(기존 JS): "벽돌을 10cm 올리고, 시멘트를 2번 발라라"처럼 건축 과정을 일일이 지시하는 방식입니다.
선언적 프로그래밍(리액트): "나는 이런 모양의 2층 집(설계도)을 원해"라고 선언만 하면, 뛰어난 건축가(Virtual DOM 알고리즘)가 알아서 가장 빠르고 안전하게 집을 지어주는 방식입니다.
| 특징 | Real DOM | Virtual DOM |
|---|---|---|
| 업데이트 방식 | 느림 (매번 화면을 처음부터 다시 그림) | 빠름 (메모리에서 변경점만 찾고 한 번에 반영) |
| 성능 제어 | 개발자가 직접 최적화 코드를 짜야 함 | 리액트 엔진이 알아서 최적화 (Batching) |
| 메모리 소모 | 상대적으로 적음 | 가상 돔 트리를 메모리에 유지해야 하므로 더 높음 |
<div class="app-container">
<h2>명령형 vs 선언적 UI 비교</h2>
<div class="compare-section">
<!-- 바닐라 자바스크립트용 컨테이너 -->
<div id="vanilla-root" class="box">
<h3>바닐라 JS (명령형)</h3>
<div id="vanilla-content">
<p>상태: <span id="vanilla-status">오프라인</span></p>
</div>
<button id="vanilla-btn">상태 전환하기</button>
</div>
<!-- 리액트용 컨테이너 (실제로는 브라우저 단독 실행이 안되지만 개념적 시뮬레이션입니다) -->
<div id="react-simulation-root" class="box">
<h3>리액트 (선언적)</h3>
<div id="react-content">
<!-- JS 로직에 의해 그려집니다 -->
</div>
<button id="react-btn">상태 전환하기</button>
</div>
</div>
</div>
Create React App (CRA)를 표준처럼 사용했습니다. 그러나 웹팩(Webpack) 기반의 CRA는 프로젝트 규모가 커질수록 빌드 및 로컬 서버 시작 속도가 느려지는 치명적인 단점이 있습니다.npm create vite@latest : 가장 최신 버전의 Vite 툴을 실행하여 스캐폴딩(초기 세팅)을 시작합니다.my-react-app : 생성될 프로젝트의 폴더 이름입니다. 원하는 이름으로 자유롭게 변경 가능합니다.-- : npm에게 "이 뒤에 나오는 옵션들은 npm 자체가 아닌, 실행할 패키지(Vite)에게 직접 전달해라"라는 의미를 가지는 관례적인 구분자(Separator)입니다.--template react : 여러 지원 환경 중 순수 React 템플릿을 사용하여 자동 구성하겠다는 옵션 명령어입니다. (TypeScript를 쓰고 싶다면 react-ts를 입력합니다)main.jsx 파일을 보면 <StrictMode> 컴포넌트가 전체 앱을 감싸고 있는 것을 볼 수 있습니다.useEffect)을 의도적으로 두 번씩 실행합니다.import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)
리액트의 모든 화면은 컴포넌트(Component)라는 독립적이고 재사용 가능한 조각들로 구성됩니다. 헤더, 사이드바, 버튼 하나조차도 모두 컴포넌트로 만들어 조립할 수 있습니다.
이러한 컴포넌트를 만들기 위해 리액트는 JSX (JavaScript XML)라는 특수한 문법을 사용합니다. 자바스크립트 코드 안에 HTML 마크업을 자연스럽게 섞어 쓸 수 있게 해주어 UI 개발을 직관적이고 즐겁게 만들어줍니다.
완성된 거대한 성(웹사이트)은 수많은 크고 작은 레고 블록(컴포넌트)들이 모여 만들어집니다.
그리고 JSX는 어떤 블록을 어떻게 꽂아야 하는지 설명해 주는 직관적인 조립 설명서와 같습니다. 자바스크립트라는 언어로 그림(HTML)을 그리듯 편하게 코딩할 수 있습니다.
| 규칙 | 설명 | 올바른 예시 |
|---|---|---|
| 1. 최상위 요소는 단 하나! | 리턴되는 모든 태그는 반드시 하나의 부모 태그로 감싸져야 합니다. (의미 없는 태그는 Fragment <></> 사용) |
<> <h1>안녕</h1> <p>반가워</p> </> |
| 2. 닫는 태그 필수 | HTML에서는 열린 채로 두었던 <img>, <br> 태그도 JSX에서는 무조건 닫아야 합니다. |
<img src="img.jpg" /> |
| 3. class 대신 className | JS 예약어와의 충돌을 피하기 위해 HTML의 class 속성은 className으로 적어야 합니다. |
<div className="title"> |
<!-- JSX가 어떻게 렌더링되는지 확인하기 위한 껍데기 영역 -->
<div id="root">
<div class="card-container" id="mock-react-mount">
<!-- 자바스크립트가 실행되면서 이 곳을 채웁니다 -->
</div>
</div>
리액트의 핵심인 단방향 데이터 흐름(One-way Data Flow)을 이해하고, 부모-자식 컴포넌트 간에 데이터를 전달하는 방법(Props)과 컴포넌트 내부에서 변경되는 데이터(State)를 관리하는 방법을 마스터합니다. '상태 끌어올리기(Lifting State Up)' 패턴과 배열/객체 상태의 불변성(Immutability) 유지 방법도 함께 익힙니다.
리액트에서 데이터는 항상 위에서 아래로(부모 컴포넌트 -> 자식 컴포넌트)만 흐릅니다. 부모가 가진 데이터를 자식에게 물려주는 매개체를 Props라고 합니다.
setState)'를 자식에게 Props로 넘겨주고, 자식이 버튼 클릭 등의 이벤트가 발생했을 때 이 함수를 대신 호출하는 방식을 사용합니다.useState를 다룰 때, 특히 객체나 배열의 경우 push()나 obj.key = value와 같이 원본 자체를 수정하면 안 됩니다. 리액트는 성능 최적화를 위해 객체의 모든 속성을 하나하나 비교하지 않고, 메모리의 주소값(참조값)이 바뀌었는지만 확인하는 '얕은 비교(Shallow Compare)'를 수행하기 때문입니다.
💡 중요:
push()를 사용해 배열에 요소를 추가하면, 배열의 내용물은 바뀌었지만 주소값은 그대로이기 때문에 리액트는 "상태가 바뀌지 않았다"고 판단하여 화면을 다시 그리지 않습니다.따라서,
...(스프레드 연산자)나filter(),map()등을 활용해 완전히 새로운 객체/배열을 만들어setState에 전달해야 합니다.
import React, { useState } from 'react';
import { UserCard } from './UserCard';
import { Container } from './Container';
import { CounterButton } from './CounterButton';
// 부모 컴포넌트 (상태 관리자)
export function PropsExample() {
// 상태 끌어올리기 (Lifting State Up)
// 자식 컴포넌트들이 공유해야 할 상태는 부모가 가지고 있어야 합니다.
const [globalCount, setGlobalCount] = useState(0);
return (
<Container title={"팀원 목록 (총 " + globalCount + "명 조회)"}>
<UserCard name="홍길동" job="프론트엔드 개발자" />
<UserCard name="김철수" /> {/* job을 안 넘기면 기본값이 들어갑니다 */}
{/* 함수를 Props로 전달하여 자식이 부모의 상태를 바꿀 수 있게 합니다 */}
<CounterButton onIncrease={() => setGlobalCount(prev => prev + 1)} />
</Container>
);
}
onClick 핸들러 등에 전달합니다.
onClick={handleClick()})을 넣으면 컴포넌트가 렌더링될 때 즉시 실행되어 무한 루프에 빠질 수 있습니다. 반드시 함수 그 자체(onClick={handleClick})를 전달하거나, 익명 화살표 함수(onClick={() => handleClick(id)}) 형태로 전달해야 합니다.
onClick: 클릭 발생 시onChange: Input 태그의 값이 변경될 때마다 (실시간 타이핑 감지)onSubmit: Form 내부의 버튼이 클릭되어 제출될 때import React, { useState } from 'react';
export default function LoginForm() {
const [userId, setUserId] = useState('');
const handleInputChange = (e) => {
setUserId(e.target.value);
};
const handleSubmit = (e) => {
e.preventDefault();
alert(`로그인 시도: ${userId}`);
};
const handleSpecialClick = (msg) => {
console.log("전달받은 메시지:", msg);
};
return (
<form onSubmit={handleSubmit} style={{ padding: '20px', background: '#f8fafc', borderRadius: '8px' }}>
<label style={{ display: 'block', marginBottom: '10px' }}>
아이디:
<input
type="text"
value={userId}
onChange={handleInputChange}
placeholder="아이디를 입력하세요"
style={{ marginLeft: '10px', padding: '5px' }}
/>
</label>
<button type="submit" style={{ padding: '8px 16px', background: '#3b82f6', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', marginRight: '10px' }}>로그인</button>
<button type="button" onClick={() => handleSpecialClick('비밀번호 찾기')} style={{ padding: '8px 16px', background: '#64748b', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
비밀번호를 잊으셨나요?
</button>
</form>
);
}
리액트로 화면을 만들 때 가장 많이 하는 두 가지 작업이 있습니다. 상황에 따라 화면을 다르게 보여주는 조건부 렌더링(Conditional Rendering)과 데이터 배열을 반복해서 화면에 그려주는 리스트 렌더링(List Rendering)입니다.
이 두 가지는 자바스크립트의 기본 문법인 삼항 연산자(?:), 논리 연산자(&&), 그리고 배열의 map() 함수를 그대로 활용하기 때문에 자바스크립트에 익숙할수록 리액트를 다루기가 훨씬 편해집니다.
조건부 렌더링 (신호등): 빨간불일 때는 멈춤 표시, 초록불일 때는 걷기 표시를 띄우듯, 데이터의 참/거짓에 따라 다른 UI 컴포넌트를 스위칭합니다.
리스트 렌더링 (붕어빵 기계): map() 함수는 팥, 슈크림, 고구마(데이터)를 넣으면 똑같은 모양의 붕어빵(컴포넌트 UI) 3개로 찍어내어 배열로 반환해 주는 기계입니다.
| 문법 / 키워드 | 용도 | 예시 |
|---|---|---|
| 조건 ? A : B | 참이면 A, 거짓이면 B를 렌더링 (택 1) | {isLogin ? <LogoutBtn/> : <LoginBtn/>} |
| 조건 && A | 참일 때만 A를 렌더링 (아니면 렌더링 안함) | {hasMsg && <p>새 메시지</p>} |
| 배열.map() | 배열 안의 데이터 개수만큼 컴포넌트 반복 렌더링 | {users.map(u => <li key={u.id}>{u.name}</li>)} |
<div class="app-container">
<h3>동적 렌더링 시뮬레이션</h3>
<div class="toolbar">
<button id="toggleLoginBtn" class="btn">로그인 상태 변경</button>
</div>
<div class="dashboard">
<!-- 조건부 렌더링 및 리스트 렌더링 결과가 여기에 표시됩니다 -->
<div id="render-area"></div>
</div>
</div>
useEffect는 렌더링이 화면에 반영된 직후에 비동기적으로 이러한 작업들을 수행할 수 있게 해주는 훅입니다.useEffect가 딱 한 번 실행됩니다. 주로 백엔드 API 데이터를 불러오거나 이벤트를 등록할 때 쓰입니다.useEffect(() => { ... }) : 렌더링될 때마다 매번 실행됩니다. (거의 안 씀)useEffect(() => { ... }, []) : 빈 배열. 컴포넌트가 처음 화면에 나타날 때(Mount) 단 한 번만 실행됩니다. API 초기 호출에 주로 씁니다.useEffect(() => { ... }, [상태]) : 배열 안의 상태가 변경될 때마다 실행됩니다.return으로 정리 함수를 반환하면 됩니다.useRef는 리액트 컴포넌트 생애주기 동안 유지되는 변경 가능한 객체(.current)를 생성합니다. useState와 비슷해 보이지만 가장 큰 차이점은 값이 변경되어도 컴포넌트가 리렌더링되지 않는다는 점입니다.
getElementById처럼 실제 DOM 노드에 직접 접근해야 할 때 사용합니다.import React, { useState, useEffect } from 'react';
function Timer() {
const [seconds, setSeconds] = useState(0);
useEffect(() => {
// 1. Mount 시점에 1초마다 동작하는 타이머 세팅
console.log("타이머 시작!");
const intervalId = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
// 2. Cleanup 함수 반환
// 컴포넌트가 화면에서 사라질 때(Unmount) 반드시 타이머를 해제해야 메모리 누수를 막습니다.
return () => {
console.log("타이머 정리됨");
clearInterval(intervalId);
};
}, []); // 빈 배열이므로 처음 한 번만 실행됨
return <p style={{ fontSize: '1.2rem', fontWeight: 'bold', color: '#0f172a' }}>경과 시간: {seconds}초</p>;
}
export default function App() {
const [show, setShow] = useState(true);
return (
<div style={{ padding: '20px', background: '#f8fafc', borderRadius: '8px' }}>
<h3 style={{ marginTop: 0 }}>타이머 마운트/언마운트 예제</h3>
<button
onClick={() => setShow(!show)}
style={{ padding: '8px 16px', background: show ? '#ef4444' : '#10b981', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', marginBottom: '15px' }}
>
{show ? '타이머 숨기기 (Unmount)' : '타이머 켜기 (Mount)'}
</button>
<div style={{ minHeight: '50px', padding: '15px', background: '#fff', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
{show ? <Timer /> : <p style={{ color: '#64748b' }}>타이머가 화면에서 사라졌습니다.</p>}
</div>
</div>
);
}
리액트는 하나의 빈 HTML 페이지를 로드한 뒤 자바스크립트로 화면을 갈아끼우는 SPA(Single Page Application) 방식을 사용합니다. 브라우저가 실제로 새 페이지를 요청하지 않으므로 화면 깜빡임이 없고 매우 빠릅니다.
<a href="..."> 태그를 사용하면 브라우저가 페이지를 완전히 새로고침합니다. 이 경우 메모리에 저장되어 있던 리액트의 모든 State(상태값)가 날아가버리므로 절대 사용해서는 안 됩니다.<Link>는 사용자가 명시적으로 클릭하는 메뉴바나 네비게이션에 사용합니다. 반면 useNavigate()는 '로그인 버튼 클릭 후 성공하면 메인으로 이동'과 같이 프로그래밍 방식(특정 로직 내부)에서 주소를 이동할 때 씁니다.하지만 사용자는 주소(URL)에 따라 다른 화면을 보고 싶어 하고, 뒤로가기 버튼도 작동해야 합니다. 이를 구현하기 위해 브라우저의 History API를 가로채어 가짜 페이지 이동을 구현하는 표준 라이브러리가 바로 react-router-dom입니다.
BrowserRouter: 최상위를 감싸 라우팅 환경을 활성화합니다.Routes / Route: URL 경로(path)에 따라 보여줄 컴포넌트(element)를 매핑합니다.Link: HTML의 <a> 태그를 대체합니다. 페이지 새로고침 없이 URL만 변경합니다.
기존에는 CSS 파일을 별도로 작성하고 className을 지어주는 과정(BEM 방법론 등)이 매우 번거로웠습니다. Tailwind CSS는 flex, text-center, pt-4처럼 미리 정의된 유틸리티 클래스들을 조립하여 별도의 CSS 파일 없이 HTML(JSX) 안에서 직접 디자인을 완성하는 프레임워크입니다.
<Button />과 같은 컴포넌트로 분리하기 때문에 지저분한 클래스명을 한 곳에 캡슐화(숨김)할 수 있습니다.m-4 (margin: 1rem), px-2 (padding-x: 0.5rem) - 1단위는 보통 0.25rem(4px)bg-blue-500, text-white, border-gray-200hover:bg-blue-600, md:flex (화면이 md 크기 이상일 때 flex 적용)// Tailwind CSS가 설정된 환경이라고 가정합니다.
function TailwindCard() {
return (
// 부모 컨테이너: 최대 너비 설정, 중앙 정렬, 상하 여백
<div className="max-w-sm mx-auto mt-10">
{/* 카드 래퍼: 흰색 배경, 그림자, 둥근 모서리, 패딩, 테두리 */}
<div className="bg-white rounded-xl shadow-lg border border-gray-100 p-6 overflow-hidden">
{/* 태그 뱃지 */}
<span className="bg-indigo-100 text-indigo-800 text-xs font-semibold px-2.5 py-0.5 rounded-full">
New Course
</span>
{/* 제목 텍스트 */}
<h2 className="mt-3 text-xl font-bold text-gray-800">
Tailwind CSS 완벽 가이드
</h2>
<p className="mt-2 text-gray-600 text-sm leading-relaxed">
별도의 CSS 파일 없이 오직 클래스 조합만으로 아름다운 반응형 UI를 구축하는 비법을 배웁니다.
</p>
{/* 액션 버튼: flex 박스 정렬, 배경색, 호버 효과, 트랜지션 */}
<div className="mt-5 flex justify-end">
<button className="bg-blue-500 hover:bg-blue-600 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200">
수강하기
</button>
</div>
</div>
</div>
);
}
순수 자바스크립트는 런타임(브라우저 실행 중)에 에러가 터지기 전까지 문제를 모르는 경우가 많습니다. React에 TypeScript를 결합하면 컴포넌트의 Props나 State가 어떤 형태의 데이터를 받아야 하는지 미리 엄격하게 정의(Type Definition)하여 버그를 사전에 차단할 수 있습니다.
React.FC: 함수형 컴포넌트 타입 지정 (최근에는 잘 쓰지 않고 매개변수에 직접 지정하는 추세)React.MouseEvent<HTMLButtonElement>: 버튼 클릭 이벤트 객체 타입React.ReactNode: children prop으로 들어올 수 있는 모든 렌더링 가능한 노드 타입// TypeScript 환경 (.tsx 파일)
import React, { useState } from 'react';
// 1. Props의 형태를 interface로 명확히 정의합니다.
interface UserCardProps {
name: string;
age: number;
// ?를 붙이면 선택적(Optional) 속성이 됩니다.
email?: string;
isOnline: boolean;
}
// 2. 컴포넌트 매개변수에 타입을 적용합니다.
function UserProfile({ name, age, email, isOnline }: UserCardProps) {
return (
<div style={{ border: '1px solid #ccc', padding: '1rem', borderRadius: '8px' }}>
<h2>
{name} ({age}세)
<span style={{ color: isOnline ? 'green' : 'gray', marginLeft: '8px' }}>
●
</span>
</h2>
{email && <p>이메일: {email}</p>}
</div>
);
}
function App() {
// State에도 제네릭(<>) 문법으로 타입을 명시할 수 있습니다.
const [users, setUsers] = useState<UserCardProps[]>([]);
// 만약 props 타입에 맞지 않는 데이터를 넘기면 VScode 편집기에서 에디터가 빨간 줄로 경고해줍니다!
return (
<div>
<UserProfile name="Minstudio" age={28} isOnline={true} />
</div>
);
}리액트에서 폼(Form)의 상태를 효율적으로 제어하는 방식과 실무 표준인 react-hook-form 및 zod를 결합하여 복잡한 유효성 검사를 쉽고 빠르게 구현하는 방법을 배웁니다.
z.string().email())으로 손쉽게 정의할 수 있습니다.import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
// 1. Zod를 사용하여 폼의 스키마(규칙) 정의
const loginSchema = z.object({
email: z.string().email("올바른 이메일 형식을 입력해주세요."),
password: z.string().min(6, "비밀번호는 최소 6자 이상이어야 합니다."),
});
type LoginFormInputs = z.infer<typeof loginSchema>;
export default function LoginForm() {
// 2. useForm에 zodResolver를 연결하여 검증 위임
const { register, handleSubmit, formState: { errors } } = useForm({
resolver: zodResolver(loginSchema),
});
const onSubmit = (data) => {
console.log("검증 성공! 전송된 데이터:", data);
alert(JSON.stringify(data, null, 2));
};
return (
<div style={{ padding: '20px', background: '#f8fafc', borderRadius: '8px' }}>
<form onSubmit={handleSubmit(onSubmit)} style={{ display: 'flex', flexDirection: 'column', gap: '15px' }}>
<div>
<input
{...register("email")}
placeholder="이메일"
style={{ width: '100%', padding: '10px', borderRadius: '4px', border: '1px solid #cbd5e1' }}
/>
{/* 에러 메시지 출력 */}
{errors.email && <p style={{ color: '#ef4444', fontSize: '0.875rem', marginTop: '5px' }}>{errors.email.message}</p>}
</div>
<div>
<input
type="password"
{...register("password")}
placeholder="비밀번호"
style={{ width: '100%', padding: '10px', borderRadius: '4px', border: '1px solid #cbd5e1' }}
/>
{errors.password && <p style={{ color: '#ef4444', fontSize: '0.875rem', marginTop: '5px' }}>{errors.password.message}</p>}
</div>
<button
type="submit"
style={{ padding: '10px', background: '#3b82f6', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold' }}
>
로그인
</button>
</form>
</div>
);
}
useState만으로는 여러 개의 상태가 얽혀있거나 복잡한 로직을 처리하기가 어렵습니다. 이럴 때 useReducer를 사용하면, 상태 업데이트 로직을 컴포넌트 외부의 Reducer 함수로 분리하여 코드를 훨씬 깔끔하고 예측 가능하게 만들 수 있습니다.
| 용어 | 설명 | 비유 |
|---|---|---|
| Action | 어떤 상태 업데이트를 해야 할지 설명하는 객체 { type: 'DEPOSIT', payload: 1000 } |
은행 창구에 제출하는 "입금 신청서" |
| Dispatch | Action 객체를 Reducer로 전달(발송)하는 함수 | 신청서를 창구 직원에게 "전달"하는 행위 |
| Reducer | 현재 State와 Action을 받아서 새로운 State를 반환하는 함수 | 신청서를 보고 실제로 계좌 잔액을 업데이트하는 "은행원" |
Reducer 내부에서 직접 외부 상태를 변경하거나(Side Effect), 비동기 통신(API 호출)을 하면 안 됩니다. 반드시 매개변수로 받은 State와 Action만으로 예측 가능한 새로운 State 객체를 반환해야 합니다.
위의 순수 자바스크립트 개념을 실제 React 컴포넌트(JSX)로 적용하면 다음과 같습니다. useReducer 훅을 호출하여 [상태, 디스패치] 배열을 반환받아 사용합니다.
위에서 살펴본 React의 useReducer가 내부적으로 어떻게 상태를 관리하고 화면을 업데이트(리렌더링)하는지 궁금하신가요?
바로 아래에 준비된 대화형 코드 에디터는 순수 자바스크립트(Vanilla JS)만으로 Reducer 패턴을 직접 구현해 본 예제입니다.
JS 탭의 코드를 살펴보면서 dispatch 함수가 어떻게 상태를 변경하고 콘솔(Console)에 로그를 찍는지 직접 확인해 보세요!
<div class="app-container">
<h3>은행 계좌 시스템 (useReducer 예제)</h3>
<div class="account-card">
<div class="balance">잔액: <span id="balanceDisplay">0</span>원</div>
<div class="input-group">
<input type="number" id="amountInput" placeholder="금액 입력" value="10000" />
</div>
<div class="action-buttons">
<button id="depositBtn" class="btn btn-green">입금하기 (DEPOSIT)</button>
<button id="withdrawBtn" class="btn btn-red">출금하기 (WITHDRAW)</button>
</div>
</div>
</div>
Context API는 이러한 문제를 해결하는 리액트 내장 전역 상태 관리 도구입니다. 최상위에서 Provider로 감싸고 값을 공급하면, 하위의 어떤 컴포넌트든 깊이와 상관없이 useContext 훅을 이용해 해당 값을 바로 꺼내 쓸 수 있습니다.<Provider>로 앱을 감쌀 필요가 없으며, 깊숙한 곳에 있는 컴포넌트라도 훅(Hook)을 통해 스토어에 직접 연결(Subscribe)되어 최상의 렌더링 성능을 보장합니다.
| 개념/메서드 | 역할 및 설명 |
|---|---|
| create | Zustand 스토어를 생성하는 가장 기본적인 함수입니다. 이 함수 안에 상태(데이터)와 액션(상태를 변경하는 함수)을 정의한 객체를 반환하는 콜백 함수를 넣습니다. 반환된 훅(Hook)을 컴포넌트에서 호출하여 사용합니다. |
| set | 상태를 업데이트(변경)하는 함수입니다. 기존 상태를 복사할 필요 없이, 변경할 부분만 객체로 넘겨주면 Zustand가 알아서 기존 상태와 얕은 병합(Shallow Merge)을 수행하여 업데이트합니다. |
| get | 현재 스토어의 전체 상태값을 조회하는 함수입니다. 스토어 내부의 어떤 액션 함수에서, 다른 상태값을 읽어와서 로직에 활용해야 할 때 유용하게 사용됩니다. |
| state | 스토어에 저장되어 있는 상태 객체 그 자체입니다. 주로 set((state) => ({ count: state.count + 1 })) 처럼 상태를 업데이트할 때, 이전 상태값을 참조하기 위한 매개변수로 콜백에 전달됩니다. |
지금까지 배운 useState, useEffect, 비동기 fetch/axios를 총동원하여 실제 외부 API와 통신하고 데이터를 화면에 뿌려주는 과정을 실습합니다.
사용자 경험(UX)을 위해서는 데이터를 가져올 때 발생할 수 있는 3가지 상태를 반드시 모두 처리해야 합니다.
서버에서 데이터를 가져오기 위해 useState와 useEffect를 조합하면 앞서 배운 로딩 상태 관리, 에러 처리, 캐싱, 데이터 재요청 등 복잡한 코드를 직접 다 짜야 합니다. 클라이언트 상태(UI 토글 등)와 서버 상태(DB 데이터)를 분리하는 것이 모던 프론트엔드의 핵심 트렌드입니다.
TanStack Query (구 React Query)는 API 데이터를 훅 한 줄로 가져오고, 알아서 캐싱하며, 백그라운드에서 최신 데이터로 동기화(Stale-while-revalidate)해주는 강력한 라이브러리입니다.
Stale-while-revalidate 원리:
리액트 컴포넌트는 상태(State)나 프롭스(Props)가 변경될 때마다 다시 렌더링됩니다. 이때 컴포넌트 내부에서 선언된 함수들도 매번 새롭게 생성됩니다. useCallback은 이러한 함수 생성 과정을 캐싱(메모이제이션)하여, 불필요한 함수 재생성과 하위 컴포넌트의 리렌더링을 방지해주는 강력한 성능 최적화 Hook입니다.
자바스크립트에서 함수는 객체(Object)입니다. 내용이 완전히 똑같더라도, 새로 만들어진 함수는 이전 함수와 메모리 주소가 다릅니다. 이 때문에 자식 컴포넌트에게 함수를 Props로 넘겨줄 때, 자식 입장에서는 매번 "새로운 프롭스가 들어왔네?"라고 착각하여 불필요하게 렌더링을 수행하게 됩니다.
useCallback은 두 번째 인자로 의존성 배열을 받습니다. 이 배열 안에 있는 값이 변할 때만 함수를 새로 생성합니다. useEffect의 동작 방식과 완전히 동일합니다.
| 형태 | 설명 | 언제 함수가 새로 생성되나요? |
|---|---|---|
[] (빈 배열) |
최초 렌더링 시 한 번만 생성됨 | 절대 재생성되지 않음 (상태 업데이트 시 주의) |
[a, b] (의존성 포함) |
배열 안의 값이 바뀔 때만 생성됨 | a 또는 b 값이 변경될 때 |
| 배열 생략 | 렌더링될 때마다 매번 생성됨 | 매 렌더링마다 (useCallback을 쓰는 의미가 없음) |
모든 함수를 무작정 useCallback으로 감싸면 오히려 독이 될 수 있습니다. 메모이제이션 자체도 비용(메모리 할당 및 비교 연산)이 들기 때문입니다.
👉 사용을 권장하는 경우: 자식 컴포넌트에 Props로 함수를 넘길 때 (특히 자식이 React.memo로 최적화되어 있을 때)
👉 권장하지 않는 경우: 단순한 계산이나 가벼운 컴포넌트 내부에서만 쓰이는 함수
import React, { useState, useCallback, memo } from 'react';
// 자식 컴포넌트는 React.memo로 감싸서, props가 변경될 때만 리렌더링됩니다.
const LightSwitch = memo(({ room, onToggle }) => {
console.log(`[렌더링] ${room} 스위치`);
return (
<div className="p-4 border border-slate-700 rounded-xl bg-slate-800 flex items-center justify-between shadow-lg">
<span className="text-slate-200 font-medium">{room} 조명</span>
<button
onClick={onToggle}
className="px-4 py-2 bg-indigo-500 hover:bg-indigo-600 text-white rounded-lg transition-colors font-medium"
>
토글
</button>
</div>
);
});
export default function SmartHome() {
const [kitchenOn, setKitchenOn] = useState(false);
const [livingRoomOn, setLivingRoomOn] = useState(false);
// ❌ 나쁜 예: 렌더링될 때마다 새로운 함수가 생성되어 자식 컴포넌트가 불필요하게 리렌더링됨
// const toggleKitchen = () => setKitchenOn(!kitchenOn);
// ✅ 좋은 예: useCallback으로 함수를 메모이제이션하여 재사용
const toggleKitchen = useCallback(() => {
setKitchenOn(prev => !prev);
}, []);
const toggleLivingRoom = useCallback(() => {
setLivingRoomOn(prev => !prev);
}, []);
return (
<div className="p-8 bg-slate-900 rounded-2xl max-w-md mx-auto my-8 border border-slate-700">
<h2 className="text-xl font-bold text-slate-100 mb-6 flex items-center gap-2">
🏠 스마트홈 컨트롤 패널
</h2>
<div className="space-y-4 mb-8">
<LightSwitch room="주방" onToggle={toggleKitchen} />
<LightSwitch room="거실" onToggle={toggleLivingRoom} />
</div>
<div className="p-4 bg-slate-800 rounded-xl border border-slate-700 text-sm text-slate-300">
<p><strong>상태:</strong></p>
<ul className="list-disc pl-5 mt-2 space-y-1">
<li>주방: {kitchenOn ? '💡 켜짐' : '⚫ 꺼짐'}</li>
<li>거실: {livingRoomOn ? '💡 켜짐' : '⚫ 꺼짐'}</li>
</ul>
</div>
</div>
);
}
useState와 useEffect를 조합한 비슷한 코드가 계속 반복된다면 어떻게 해야 할까요? 리액트에서는 이러한 상태 관리 로직만 따로 빼내어 재사용 가능한 함수로 만들 수 있습니다. 이를 커스텀 훅(Custom Hook)이라고 부릅니다.
use로 시작해야 합니다. (예: useFetch, useInput). 그래야만 리액트가 이 함수 내부에서 다른 Hook의 사용을 허용하고, 라이프사이클을 정상적으로 추적할 수 있습니다.
복잡한 수학 공식을 풀어야 한다고 상상해보세요. 어제 "745 × 382"의 정답을 힘들게 계산해서 영수증(메모장)에 적어두었습니다.
오늘 누군가 또 "745 × 382"를 물어본다면? 다시 계산할 필요 없이 영수증에 적힌 답을 그대로 읽어주면 됩니다. (이것이 useMemo 입니다!)
하지만 "745 × 383"을 물어본다면? 조건(의존성 배열)이 달라졌으므로 영수증을 버리고 새로 계산해야 합니다.
useMemo: 비싼 계산의 결과값(답안지)을 캐싱하여, 조건이 안 바뀌면 재사용합니다.useCallback: 함수 자체(계산하는 방법론)를 캐싱하여, 컴포넌트가 렌더링될 때마다 함수가 새로 생성되는 것을 막습니다.React.memo: 컴포넌트 자체를 통째로 씌워, 부모가 렌더링되어도 내가 받는 Props가 안 바뀌었으면 나는 렌더링 안 하겠다고 선언합니다.import ResponsiveApp from './ResponsiveApp';
export default function App() {
return (
<div style={{ padding: '20px', background: '#f8fafc', borderRadius: '8px' }}>
<ResponsiveApp />
</div>
);
}
부모 컴포넌트의 DOM 계층 구조를 벗어나 렌더링하는 createPortal을 활용한 모달(Modal) 구현법과, 부모 컴포넌트가 자식 커스텀 컴포넌트 내부의 DOM 요소에 직접 접근할 수 있게 해주는 forwardRef의 활용법을 다룹니다.
overflow: hidden이나 z-index에 막혀 화면에 제대로 표시되지 않는 경우가 많습니다. createPortal을 사용하면 논리적인 React 트리 구조는 유지하면서, 실제 DOM은 최상단 <body> 직속으로 빼내어 렌더링하므로 이러한 CSS 충돌을 완벽히 해결할 수 있습니다.ref를 전달하고 싶을 때, 기본적으로 커스텀 컴포넌트는 ref를 받지 못합니다. forwardRef로 자식 컴포넌트를 감싸주면 부모가 넘긴 ref를 내부의 실제 <input> 등의 DOM에 바로 꽂아넣어 포커스 제어 등 직접적인 조작이 가능해집니다.import React, { useRef } from 'react';
import CustomInput from './CustomInput';
import Modal from './Modal';
export default function App() {
const inputRef = useRef(null);
const focusInput = () => {
// 부모 컴포넌트에서 자식 컴포넌트 내부의 DOM 함수(focus)를 직접 호출
inputRef.current?.focus();
};
return (
<div style={{ padding: '20px', background: '#f8fafc', borderRadius: '8px' }}>
<CustomInput ref={inputRef} label="사용자 이름" />
<button
onClick={focusInput}
style={{ marginTop: '16px', background: '#3b82f6', color: 'white', padding: '8px 16px', border: 'none', borderRadius: '4px', cursor: 'pointer' }}>
포커스 이동하기
</button>
<Modal isOpen={true}>
<h2 style={{marginTop: 0}}>이것은 포탈로 열린 모달입니다!</h2>
</Modal>
</div>
);
}
기존에는 컴포넌트 내부에서 if (isLoading) return <Spinner /> 방식으로 로딩을 처리했습니다. React 18부터는 Suspense를 통해 부모 컴포넌트에서 비동기 로딩 UI를 선언적으로 위임받아 처리할 수 있습니다. 또한 Error Boundary를 활용하면 하위 컴포넌트의 에러가 앱 전체를 무너뜨리지 않도록 방어할 수 있습니다.
React Query 등에서 suspense: true 옵션을 켜면 데이터 패칭 로직과 UI 로딩 처리를 완전히 분리할 수 있습니다.
<div class="app-container">
<h3>사용자 프로필 로딩 시뮬레이터</h3>
<div class="toolbar">
<button id="btnLoad" class="btn btn-blue">프로필 불러오기</button>
<button id="btnError" class="btn btn-red">에러 발생시키기</button>
</div>
<div id="render-area" class="card-area">
<div class="placeholder">버튼을 눌러주세요</div>
</div>
</div>무거운 리스트 필터링 작업 때문에 사용자의 타이핑이 뚝뚝 끊기는 경험을 해본 적이 있나요? React 18의 useTransition과 useDeferredValue를 사용하면, 무거운 작업의 렌더링 우선순위를 낮추어 사용자 입력 같은 중요한 상호작용을 먼저 처리(블로킹 방지)할 수 있습니다.
| Hooks | 주 사용 목적 | 특징 |
|---|---|---|
| useTransition | 상태(State) 업데이트 함수의 우선순위를 낮춤 | isPending 상태를 제공하여 로딩 UI 표시 가능 |
| useDeferredValue | 값(Value) 자체의 업데이트를 지연시킴 | Prop으로 넘겨받는 값을 디바운스처럼 지연시킬 때 유리함 |
<div class="app-container">
<h3>타이핑 최적화 (useTransition 시뮬레이션)</h3>
<div class="input-group">
<input type="text" id="searchInput" placeholder="검색어를 빠르게 입력해보세요" />
</div>
<p id="pendingText" class="pending">무거운 렌더링 중...</p>
<div class="stats">
<div>타이핑 지연시간: <strong id="typingLag">0</strong>ms</div>
</div>
<div id="listArea" class="list-area"></div>
</div>
안정적인 프론트엔드 애플리케이션을 보장하기 위한 단위 테스트(Unit Test) 작성법입니다. 가장 빠르고 현대적인 Vitest와 실제 사용자의 동작을 모방하는 React Testing Library(RTL)를 사용하여 견고한 컴포넌트를 설계합니다.
import React from 'react';
import Counter from './Counter';
export default function App() {
return (
<div style={{ padding: '20px', background: '#f8fafc', borderRadius: '8px', textAlign: 'center' }}>
<h2 style={{ marginTop: 0 }}>Vitest 테스트 대상 컴포넌트</h2>
<p style={{ color: '#64748b', marginBottom: '20px' }}>아래의 카운터 컴포넌트가 테스트 대상입니다.</p>
<Counter />
</div>
);
}