React
공부방 자료는 keysub가 AI를 활용해 작성·편집했습니다.
1. react 프로젝트 생성 - 펼치기 / 접기
CRA(Create React App)는 React 프로젝트를 만드는 과거의 표준 방식으로, 기본 JavaScript 프로젝트와 TypeScript 템플릿을 지원합니다. 프로젝트 생성 시 필요한 패키지도 함께 설치합니다.
이 강좌에서는 Vite와 TypeScript를 사용합니다. Vite는 빠르고 가벼운 개발 서버와 빌드 도구입니다.
| 단계 | 명령어 및 결과 |
|---|---|
| 1. 프로젝트 생성 | npm create vite@latest 프로젝트이름 -- --template react-ts |
| 2. 프로그램 설치 | npm install |
| 3. 서버 켜(실행) | npm run dev |
| 4. 빌드 | npm run build 실행 시 dist/ 폴더가 생김 (npm run preview 로 확인가능) |
2. 리엑트의 기초 - 펼치기 / 접기
React 프로젝트에는 화면을 만드는 라이브러리와 개발·빌드를 돕는 도구가 함께 설치됩니다. package.json에서 패키지와 실행 명령을 확인할 수 있습니다.
| 구분 | 설명 | 예 |
|---|---|---|
dependencies |
앱 실행에 필요한 라이브러리 | react, react-dom |
devDependencies |
개발, 타입 검사, 빌드에 사용하는 도구 | vite, typescript, @vitejs/plugin-react |
scripts |
자주 실행하는 명령을 등록하는 항목 | npm run dev, npm run build |
npm install은 package.json에 선언된 패키지를 설치하고, package-lock.json은 설치된 패키지 버전을 기록하여 개발 환경 간 차이를 줄여 줍니다.
2.1 폴더 구조와 설명 - 펼치기 / 접기
Vite와 React, TypeScript 템플릿으로 만든 기본 프로젝트는 다음과 같은 구조를 가집니다. 프로젝트 설정이나 추가 기능에 따라 파일과 폴더는 달라질 수 있습니다.
프로젝트/
├── public/ 정적 파일 (빌드 과정에서 그대로 복사)
├── src/ React 애플리케이션 소스
│ ├── assets/ 컴포넌트에서 사용하는 이미지 등의 자원
│ ├── App.tsx 최상위 애플리케이션 컴포넌트
│ ├── main.tsx 애플리케이션 진입점
│ └── ... 기능별 컴포넌트와 스타일
├── index.html 브라우저가 처음 받아 보는 HTML
├── package.json 패키지와 npm 명령
├── package-lock.json 설치 패키지 버전 기록
├── tsconfig*.json TypeScript 설정
└── vite.config.ts Vite 설정
2.1.1 개별 파일 설명
2.1.1.1 시작 페이지: index.html
Vite 프로젝트에서는 index.html이 public/ 안이 아니라 프로젝트 루트에 있습니다. 브라우저가 처음 표시할 HTML이며, React가 화면을 넣을 DOM 요소와 TypeScript 진입 파일을 지정합니다. 반면 CRA(Create React App)의 기본 구조에서는 시작 HTML이 public/index.html에 있었습니다.
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
public/에는 앱 코드에서 import하지 않고, 정해진 URL로 직접 제공할 정적 파일을 둡니다. 예를 들어 파비콘(favicon.ico), 검색엔진 크롤링 규칙 파일(robots.txt), PWA 매니페스트(manifest.webmanifest), 직접 경로로 사용할 이미지 등을 둘 수 있습니다.
public/
├── favicon.ico
├── robots.txt
└── images/
└── logo.png
Vite는 public/ 안의 파일을 빌드 결과물인 dist/에 경로와 파일명을 유지한 채 복사합니다. 따라서 public/images/logo.png는 앱에서 /images/logo.png 경로로 참조할 수 있습니다.
<img src="/images/logo.png" alt="로고" />
컴포넌트에서 직접 import해 사용할 이미지 등은 보통 src/assets/에 둡니다. 이 파일들은 Vite의 빌드 과정에서 처리되며, 결과 파일명이나 경로가 달라질 수 있습니다. 정리하면 public/은 URL과 파일명을 그대로 유지해야 하는 파일에, src/assets/는 소스 코드에서 가져와 사용하는 자원에 적합합니다.
2.1.1.2 src/main.tsx: 앱 진입점과 렌더링
index.html이 불러온 main.tsx는 React 앱의 시작점입니다. 이 파일은 #root 요소를 찾아 React 루트를 만들고, 그 안에 최상위 컴포넌트인 <App />을 렌더링합니다.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
createRoot는 React가 관리할 DOM 영역을 설정하고, render는 그 영역에 전달한 React 요소를 화면에 표시합니다. <StrictMode>는 개발 중 잠재적인 문제를 찾도록 돕는 검사 기능이며, 사용자에게 보이는 별도의 UI를 만들지는 않습니다.
2.1.1.3 src/App.tsx: 최상위 컴포넌트
App.tsx는 앱의 주요 화면과 하위 컴포넌트를 조합하는 시작 컴포넌트입니다. JSX를 반환하며, main.tsx가 <App />을 렌더링하면 React는 App과 그 아래 컴포넌트들을 화면에 반영합니다.
function App() {
return (
<main>
<h1>안녕하세요, React!</h1>
</main>
);
}
export default App;
렌더링은 컴포넌트가 반환한 JSX를 바탕으로 화면을 계산하고 DOM에 반영하는 과정입니다. 처음 앱을 시작할 때뿐 아니라 state가 바뀌는 등 화면에 영향을 주는 변화가 생기면 React가 컴포넌트를 다시 실행해 필요한 부분을 갱신합니다. 컴포넌트 함수가 다시 실행되는 것과 브라우저 DOM 전체를 새로 만드는 것은 다릅니다. 리렌더링이 발생하는 시점은 3장 「컴포넌트 개발」의 리렌더링 타이밍을 참고하세요.
실행 흐름을 정리하면 다음과 같습니다.
index.html → src/main.tsx → src/App.tsx → 화면에 표시
2.1.2 JavaScript와 TypeScript 파일의 차이
JavaScript 파일은 보통 .js 또는 .jsx, TypeScript 파일은 .ts 또는 .tsx 확장자를 사용합니다. JSX 문법을 함께 쓰는 React 컴포넌트는 각각 .jsx, .tsx로 작성합니다.
- TypeScript: 개발 중 타입을 검사하므로 잘못된 데이터 형태를 미리 확인할 수 있습니다.
- JavaScript: 별도의 타입 검사가 없으므로 데이터 형태가 맞지 않는 문제를 실행 중에야 발견할 수 있습니다.
TypeScript의 타입 표기는 빌드 과정에서 제거되고 JavaScript로 변환됩니다. 결국 브라우저는 JavaScript를 실행합니다.
2.2 컴포넌트란? - 펼치기 / 접기
컴포넌트는 화면을 구성하는 재사용 가능한 단위입니다. 버튼이나 제목처럼 작은 UI부터 페이지 전체까지 컴포넌트로 만들 수 있으며, 여러 컴포넌트를 조합해 화면을 구성합니다.
이 강좌에서는 함수로 컴포넌트를 정의하는 함수 컴포넌트를 사용합니다. 함수 컴포넌트는 JSX를 반환하고, 컴포넌트 이름은 대문자로 시작합니다.
2.2.1 SPA에서 컴포넌트로 화면 만들기
React 앱은 보통 SPA(Single-Page Application, 단일 페이지 애플리케이션) 방식으로 동작합니다. 전통적인 다중 페이지 웹사이트에서는 다른 페이지로 이동할 때 브라우저가 서버에 새 HTML 문서를 요청하고, 받은 문서로 화면을 다시 표시하는 경우가 많습니다. SPA는 처음에 HTML 문서를 불러온 뒤, 화면이 바뀔 때마다 문서 전체를 다시 요청하는 대신 앱 안에서 필요한 컴포넌트를 렌더링하거나 교체해 화면을 구성합니다.
예를 들어 목록 화면에서 상세 화면으로 이동할 때 목록 컴포넌트를 내리고 상세 컴포넌트를 렌더링하거나, 두 컴포넌트를 조합해 일부 화면만 바꿀 수 있습니다. 화면 전환은 라우터가 현재 주소에 맞는 컴포넌트를 선택하는 방식으로 구현할 수 있습니다. 다만 SPA도 서버에 데이터를 요청할 수 있으며, 브라우저 새로고침이나 외부 페이지로 이동할 때는 새 문서를 요청할 수 있습니다.
function Greeting() {
return <h1>안녕하세요, React!</h1>;
}
2.2.2 컴포넌트 이름은 왜 대문자로 시작할까?
JSX에서 소문자로 시작하는 태그는 <div>, <button>처럼 HTML 요소로 해석됩니다. 반면 대문자로 시작하는 태그는 JavaScript에 정의된 컴포넌트로 해석됩니다. 따라서 직접 만든 컴포넌트는 대문자로 시작하는 이름을 사용해야 합니다.
function Greeting() {
return <p>반갑습니다.</p>;
}
function App() {
return (
<main>
<Greeting />
</main>
);
}
<Greeting />은 Greeting 컴포넌트를 화면에 포함한다는 뜻입니다. <greeting />처럼 소문자로 쓰면 React 컴포넌트가 아니라 HTML 태그로 처리됩니다.
2.2.3 컴포넌트는 JSX에서 태그처럼 사용한다
컴포넌트는 일반 함수처럼 직접 호출하기보다 <Greeting />과 같은 JSX 태그 형태로 사용합니다. JSX는 이 표기를 React가 해석할 수 있는 요소로 변환하고, React는 그 요소를 화면에 렌더링합니다.
function Greeting() {
return <p>안녕하세요!</p>;
}
function App() {
return (
<section>
<h1>인사말</h1>
<Greeting />
<Greeting />
</section>
);
}
같은 컴포넌트를 여러 번 배치할 수 있고, 컴포넌트가 반환하는 JSX 안에 다른 컴포넌트를 조합할 수도 있습니다. <Greeting />처럼 슬래시로 닫는 표기는 내용이 없는 컴포넌트에 사용하는 JSX 문법입니다.
컴포넌트를 Greeting()처럼 직접 호출하면 React가 컴포넌트를 관리하는 흐름을 건너뛰게 되므로 JSX 태그로 사용해야 합니다. 컴포넌트는 React가 렌더링하도록 <Greeting /> 형태로 배치합니다.
2.2.4 export와 import
컴포넌트를 다른 파일에서도 사용하려면 해당 파일에서 export하고, 사용할 파일에서 import합니다. 같은 파일 안에서만 쓰는 컴포넌트라면 반드시 내보낼 필요는 없습니다.
// Greeting.tsx
export default function Greeting() {
return <p>안녕하세요!</p>;
}
// App.tsx
import Greeting from './Greeting';
function App() {
return <Greeting />;
}
export default App;
export default는 파일에서 기본으로 내보낼 값을 지정하고, import Greeting from './Greeting'은 그 값을 가져옵니다. export는 컴포넌트를 다른 파일에서 사용할 수 있게 하는 JavaScript 모듈 문법이지, 컴포넌트가 되기 위한 필수 조건은 아닙니다.
2.2.5 함수 컴포넌트의 반환값
컴포넌트는 화면에 표시할 JSX를 반환합니다. 여러 요소를 반환하려면 하나의 부모 요소로 감싸거나 <>...</> 문법의 Fragment로 묶습니다.
function Profile() {
return (
<>
<h2>홍길동</h2>
<p>React 학습 중</p>
</>
);
}
컴포넌트는 조건에 따라 아무것도 표시하지 않을 때 null을 반환할 수도 있습니다. JSX를 반환하는 함수라고 이해하면 시작하기에 충분합니다.
2.2.6 함수 컴포넌트와 클래스 컴포넌트
React 컴포넌트는 함수 또는 클래스로 정의할 수 있습니다. 과거에는 클래스 컴포넌트가 상태와 생명주기 기능을 담당했지만, 현재는 함수 컴포넌트와 Hook을 사용하는 방식이 일반적입니다. 이 강좌에서는 함수 컴포넌트를 기준으로 설명합니다.
2.2.7 JSX 문법과 규칙 - 펼치기 / 접기
JSX(JavaScript XML)는 JavaScript 코드 안에서 HTML과 비슷한 문법으로 UI 구조를 표현할 수 있게 해 주는 JavaScript 확장 문법입니다. React를 만들던 Facebook(현재 Meta)에서 도입했으며, JSX 자체가 브라우저가 이해하는 HTML이나 JavaScript인 것은 아닙니다. Vite와 같은 빌드 도구가 JSX를 브라우저가 실행할 수 있는 JavaScript로 변환합니다.
const title = 'React';
function Welcome() {
return <h1>안녕하세요, {title}!</h1>;
}
JSX 안에서 JavaScript 표현식을 사용하려면 중괄호 {}로 감쌉니다. 변수나 함수 호출, 산술 표현식 등을 넣을 수 있지만, if 문이나 for 문처럼 문(statement)을 직접 넣을 수는 없습니다. 조건에 따라 다른 내용을 표시할 때는 삼항 연산자나 &&를 사용할 수 있습니다.
function Greeting({ isLoggedIn }: { isLoggedIn: boolean }) {
return <p>{isLoggedIn ? '환영합니다!' : '로그인해 주세요.'}</p>;
}
JSX를 작성할 때는 다음 규칙을 지킵니다.
- 반환하는 JSX 요소는 하나의 부모 요소로 감쌉니다. 실제 DOM 요소를 추가하고 싶지 않다면 Fragment(
<>...</>)를 사용할 수 있습니다. - 모든 태그는 닫아야 합니다. 내용이 없는 태그는
<img />,<input />처럼 작성합니다. - HTML 속성 이름과 다른 JSX 속성 이름을 사용합니다. 예를 들어
class대신className,for대신htmlFor를 사용합니다. - 여러 요소로 된 JSX를
return할 때는 괄호로 감싸는 것을 권장합니다. 필수 규칙은 아니지만 줄바꿈이나 들여쓰기를 바꿀 때 JavaScript의 자동 세미콜론 삽입으로 생길 수 있는 오류를 예방하고 구조를 읽기 쉽게 합니다.
function Profile() {
return (
<section className="profile">
<h2>홍길동</h2>
<label htmlFor="email">이메일</label>
<input id="email" />
</section>
);
}
2.2.7.1 JSX 변환과 React.createElement
JSX는 브라우저에서 바로 실행되지 않으므로 빌드 도구가 JavaScript로 변환합니다. 예를 들어 과거의 JSX 변환 방식은 다음 JSX를 React.createElement 호출과 비슷한 코드로 바꿨습니다.
const element = <h1 className="title">안녕하세요</h1>;
const element = React.createElement(
'h1',
{ className: 'title' },
'안녕하세요',
);
이는 변환 원리를 이해하기 위한 예입니다. 최신 React 프로젝트는 자동 JSX 변환을 사용하는 경우가 많으며, 내부적으로 react/jsx-runtime의 함수들을 사용하므로 소스 코드에서 매번 React.createElement를 직접 작성하거나 React를 JSX 때문에 import할 필요는 없습니다.
JSX 변환 결과를 확인하고 싶다면 Babel의 온라인 변환기에 JSX 코드를 입력해 볼 수 있습니다. 실제 프로젝트에서는 Vite가 빌드 과정에서 자동으로 변환합니다.
2.2.7.2 JSX의 이스케이프 처리와 HTML 삽입 주의
JSX의 텍스트 위치에 문자열이나 값을 넣으면 React는 기본적으로 이를 HTML로 해석하지 않고 일반 텍스트로 표시합니다. 이때 HTML 특수 문자를 이스케이프하므로, 사용자 입력이 <script> 같은 태그로 실행되지 않도록 보호하는 데 도움이 됩니다.
const userInput = '<script>alert("hello")</script>';
function Message() {
return <p>{userInput}</p>;
}
위 코드는 문자열을 태그로 실행하지 않고 글자로 표시합니다. HTML 문자열을 실제 요소로 삽입해야 하는 특별한 경우에는 dangerouslySetInnerHTML을 사용할 수 있습니다.
function Article() {
const html = '<strong>중요한 안내</strong>';
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
dangerouslySetInnerHTML의 값은 { __html: 문자열 } 형태의 객체입니다. 신뢰할 수 없는 입력을 그대로 넣으면 스크립트 삽입(XSS) 취약점이 생길 수 있으므로, 필요한 경우에도 입력을 검증·정화한 뒤 사용하고 일반적인 화면 출력에는 JSX의 텍스트 렌더링을 사용합니다.
2.2.7.3 JSX에서 주석 작성하기
JSX 바깥의 JavaScript 영역에서는 일반 JavaScript 주석을 쓰고, JSX 요소 사이에 주석을 넣을 때는 중괄호 안에 주석을 작성합니다.
function Notice() {
// JavaScript 영역의 주석
return (
<section>
{/* JSX 안의 주석 */}
<p>공지사항입니다.</p>
</section>
);
}
2.2.8 컴포넌트 분리와 조합
화면이 커지면 하나의 컴포넌트에 모든 JSX를 작성하기보다, 역할이 분명한 작은 컴포넌트로 나누는 편이 읽고 관리하기 쉽습니다. 이렇게 UI를 나누는 것을 컴포넌트 분리라고 합니다. 분리한 컴포넌트는 부모 컴포넌트의 JSX에서 태그처럼 배치해 하나의 화면으로 구성할 수 있으며, 이를 컴포넌트 조합이라고 합니다.
type GreetingCardProps = {
name: string;
};
function PageHeader() {
return <header><h1>React 학습 노트</h1></header>;
}
function GreetingCard({ name }: GreetingCardProps) {
return (
<section>
<h2>환영합니다, {name}님!</h2>
<p>오늘도 React를 함께 배워 봅시다.</p>
</section>
);
}
function PageFooter() {
return <footer>학습을 응원합니다.</footer>;
}
function App() {
return (
<main>
<PageHeader />
<GreetingCard name="민지" />
<PageFooter />
</main>
);
}
App은 페이지의 큰 구조를 담당하고, PageHeader, GreetingCard, PageFooter는 각자 맡은 UI를 표현합니다. GreetingCard는 name을 props로 전달받아 다른 이름으로도 재사용할 수 있습니다. 이처럼 부모 컴포넌트는 자식 컴포넌트를 조합하고, 필요한 데이터는 props로 전달합니다. props의 자세한 사용법은 3장 「컴포넌트 개발」에서 다룹니다.
처음부터 모든 요소를 잘게 나눌 필요는 없습니다. 화면의 한 부분이 독립된 역할을 가지거나, 반복해서 사용되거나, 별도로 읽고 수정하기 편리해지는 경우에 컴포넌트로 분리하는 것이 좋습니다. 작은 컴포넌트가 지나치게 많아지면 오히려 전체 구조를 파악하기 어려울 수 있으므로, 역할과 재사용성을 기준으로 적절한 크기를 선택합니다.
2.3 조건, 분기, 반복처리
2.3.1 조건부 렌더링
조건부 렌더링은 조건의 결과에 따라 화면에 표시할 JSX를 선택하는 방법입니다. JavaScript의 if 문은 JSX 바깥에서 사용할 수 있고, JSX 안에서는 삼항 연산자나 &&를 자주 사용합니다.
type GreetingProps = {
isLoggedIn: boolean;
};
function Greeting({ isLoggedIn }: GreetingProps) {
if (!isLoggedIn) {
return <p>로그인해 주세요.</p>;
}
return <p>환영합니다!</p>;
}
두 결과 중 하나를 JSX 안에서 선택할 때는 삼항 연산자를 사용할 수 있습니다.
function LoginStatus({ isLoggedIn }: GreetingProps) {
return <p>{isLoggedIn ? '로그인 상태' : '로그아웃 상태'}</p>;
}
function Inbox({ unreadCount }: { unreadCount: number }) {
return (
<section>
<h2>받은 편지함</h2>
{unreadCount > 0 && <p>읽지 않은 메시지: {unreadCount}</p>}
</section>
);
}
어느 방법을 쓸지는 조건이 복잡한지, 렌더링할 내용이 얼마나 되는지에 따라 선택하고, 복잡한 조건은 JSX 바깥에서 계산하면 읽기 쉽습니다.
2.3.2 목록 렌더링
배열에 들어 있는 데이터를 화면에 반복해서 표시할 때는 보통 map()으로 각 데이터를 JSX 요소로 변환합니다. 목록이 비어 있을 때의 화면도 조건부로 처리할 수 있습니다.
type Course = {
id: number;
title: string;
};
const courses: Course[] = [
{ id: 1, title: '컴포넌트 기초' },
{ id: 2, title: '조건부 렌더링' },
];
function CourseList() {
if (courses.length === 0) {
return <p>강좌가 없습니다.</p>;
}
return (
<ul>
{courses.map((course) => (
<li key={course.id}>{course.title}</li>
))}
</ul>
);
}
filter()로 조건에 맞는 데이터만 추린 뒤 map()으로 표시할 수 있습니다. sort()는 배열 자체의 순서를 바꾸므로, state나 공유 중인 원본 배열을 직접 정렬하지 않도록 복사본을 정렬합니다.
const visibleCourses = courses
.filter((course) => course.title.includes('렌더링'))
.sort((a, b) => a.title.localeCompare(b.title));
위 예제에서 filter()는 새 배열을 만들고, 이어서 호출한 sort()는 그 새 배열을 정렬하므로 원래 courses 배열은 변경되지 않습니다. 원본 배열에 곧바로 courses.sort(...)를 호출하는 것은 피합니다.
map() 결과에서 각 항목이 여러 JSX 요소로 이루어진 경우에는 Fragment로 묶을 수 있습니다. 이때 목록 항목을 구분하는 key를 지정하려면 축약 문법인 <>...</> 대신 Fragment를 명시적으로 사용합니다.
import { Fragment } from 'react';
function CourseDetails() {
return courses.map((course) => (
<Fragment key={course.id}>
<h3>{course.title}</h3>
<hr />
</Fragment>
));
}
2.3.2.1 리스트 렌더링의 key
key는 React가 목록의 각 항목을 서로 구분하고, 항목이 추가·삭제·이동되었을 때 어떤 요소가 바뀌었는지 파악하는 데 사용하는 특별한 값입니다. 목록을 렌더링할 때는 map()이 반환하는 최상위 요소에 key를 지정해야 합니다.
가능하면 데이터에 포함된 고유하고 안정적인 식별자(예: 데이터베이스 ID)를 사용합니다. 배열의 인덱스는 항목의 순서가 바뀌거나 중간 항목이 추가·삭제될 수 있는 목록에서 적절한 key가 아닙니다. 이런 경우 React가 기존 항목과 새 항목을 잘못 연결해 입력값이나 컴포넌트 상태가 엉뚱한 항목에 남을 수 있습니다. 목록이 고정되어 순서와 항목이 바뀌지 않는 경우에만 인덱스를 key로 쓰는 것을 고려합니다.
{courses.map((course) => (
<li key={course.id}>{course.title}</li>
))}
같은 목록 안에서 key는 서로 달라야 하며, 렌더링할 때마다 임의의 값을 새로 만들어 key로 사용해서는 안 됩니다. key는 React가 목록을 관리하기 위한 값이므로 컴포넌트의 일반 prop으로 전달되지 않습니다. 컴포넌트 안에서도 ID가 필요하다면 key와 별도로 전달합니다.
3. 컴포넌트 개발 - 펼치기 / 접기
3.1 리렌더링 타이밍
리렌더링은 React가 컴포넌트를 다시 실행해 화면에 표시할 내용을 새로 계산하는 과정입니다. 보통 다음과 같은 경우에 시작됩니다.
- 컴포넌트의 state가 바뀐 경우
state 설정 함수로 값을 갱신하면 React가 해당 컴포넌트를 다시 렌더링합니다. 새 값이 이전 값과 같다고 판단되면 렌더링을 건너뛸 수도 있습니다. - 부모 컴포넌트가 다시 렌더링된 경우
부모가 다시 렌더링되면 자식도 기본적으로 다시 렌더링됩니다. 이때 자식은 부모가 전달한 props를 받아 화면을 다시 계산합니다.memo를 사용하면 props가 같을 때 자식의 렌더링을 건너뛸 수 있지만, state나 context 변경 등 다른 이유가 있으면 다시 렌더링될 수 있습니다. - 사용 중인 context 값이 바뀐 경우
useContext()로 context 값을 읽는 컴포넌트는 그 값을 제공하는 Provider의 값이 바뀌면 다시 렌더링됩니다. Context와useContext()의 사용 방법은 6.4절 「useContext」에서 설명합니다.
props 변경은 부모 렌더링과 별개의 시작 원인이라기보다, 부모가 다시 렌더링될 때 자식에게 전달되는 값이 달라지는 상황으로 이해하면 쉽습니다. 예를 들어 부모가 다시 실행되어 자식에게 전달하는 이름이 바뀌면, 자식은 새 이름을 사용해 화면을 다시 계산합니다.
리렌더링은 화면 전체를 처음부터 다시 만드는 작업을 뜻하지 않습니다. React는 컴포넌트가 새로 계산한 결과를 이전 결과와 비교한 뒤, 실제로 달라진 부분만 브라우저 DOM에 반영합니다. 즉, 컴포넌트가 다시 실행되는 것과 화면의 모든 요소가 다시 만들어지는 것은 서로 다른 일입니다.
3.2 props - 펼치기 / 접기
props(properties)는 부모 컴포넌트가 자식 컴포넌트에 전달하는 값들을 담은 객체입니다. JSX에서 <Greeting name="민지" />처럼 작성하면 name은 prop의 이름이고, "민지"는 그 prop에 전달하는 값입니다. React는 이 값을 { name: '민지' } 형태의 객체로 Greeting에 전달합니다.
type GreetingProps = {
name: string;
};
function Greeting(props: GreetingProps) {
return <p>안녕하세요, {props.name}님!</p>;
}
function App() {
return <Greeting name="민지" />;
}
이 예제에서 GreetingProps는 전달될 객체의 모양을 검사하는 TypeScript 타입 이름일 뿐, 전달되는 값 자체는 아닙니다. props는 컴포넌트가 받은 객체이고 props.name은 그 객체에서 꺼낸 name 값입니다. 자식은 props를 직접 바꾸지 않으며, 값 변경은 부모가 관리하는 state를 갱신해 새 props를 전달하는 방식으로 처리합니다. 다음 절에서는 props의 데이터 유형과 구조 분해, 기본값 등을 살펴봅니다.
3.2.1 props의 데이터 유형
props에는 문자열뿐 아니라 숫자, 불리언, 배열, 객체, 함수 등 다양한 JavaScript 값을 전달할 수 있습니다.
<UserCard
name="민지"
age={20}
isActive={true}
hobbies={['독서', '산책']}
user={{ id: 1, role: 'admin' }}
/>
문자열 props에는 name="민지"처럼 따옴표를 사용할 수 있습니다. 숫자나 불리언, 배열, 객체, 변수와 함수 등 JavaScript 표현식을 전달할 때는 중괄호를 사용합니다.
3.2.2 props 구조 분해와 기본값
구조 분해 할당은 객체에서 지정한 프로퍼티의 값을 꺼내 같은 이름의 변수에 담는 JavaScript 문법입니다. 컴포넌트 매개변수에서 사용하면 props 객체를 받은 뒤 필요한 값을 바로 꺼내 쓸 수 있습니다.
type GreetingProps = {
name: string;
};
// props 객체를 매개변수로 받고, 그 안에서 name을 구조 분해합니다.
function Greeting({ name }: GreetingProps) {
return <h2>안녕하세요, {name}님!</h2>;
}
function App() {
// JSX에서 name prop을 Greeting 컴포넌트에 전달합니다.
return <Greeting name="민지" />;
}
App은 <Greeting name="민지" />로 name prop을 전달합니다. React가 Greeting에 전달하는 객체는 { name: '민지' }와 같은 모양입니다. 매개변수의 { name }이 그 객체에서 name 프로퍼티를 꺼내 로컬 변수 name에 넣으므로, 함수 안에서 props.name 대신 name으로 사용할 수 있습니다. 구조 분해를 사용하지 않으면 function Greeting(props: GreetingProps)로 객체를 받은 뒤 props.name에 접근할 수 있습니다.
prop을 생략했을 때 사용할 기본값도 매개변수의 구조 분해 할당에서 지정할 수 있습니다.
type GreetingProps = {
name?: string;
};
function Greeting({ name = '손님' }: GreetingProps) {
return <h2>안녕하세요, {name}님!</h2>;
}
function App() {
return <Greeting />;
}
이 예제에서 <Greeting />은 name을 전달하지 않으므로 name은 기본값인 '손님'이 됩니다. 기본값은 prop이 생략되거나 undefined일 때 적용됩니다. React가 모든 props에 공통 기본값을 정해 두는 것은 아니므로, 컴포넌트에 필요한 값을 직접 지정합니다.
3.2.3 함수 props와 이벤트 처리
부모 컴포넌트는 함수를 props로 전달해 자식 컴포넌트에서 부모의 동작을 실행하게 할 수 있습니다. 이벤트 처리 함수를 전달할 때는 보통 onClick, onChange, onSave처럼 on으로 시작하는 이름을 사용합니다.
type ActionButtonProps = {
onAction: () => void;
};
function ActionButton({ onAction }: ActionButtonProps) {
return <button onClick={onAction}>실행</button>;
}
function App() {
function handleAction() {
console.log('버튼을 눌렀습니다.');
}
return <ActionButton onAction={handleAction} />;
}
onClick={handleAction}은 클릭할 때 실행할 함수를 전달합니다. onClick={handleAction()}처럼 괄호를 붙이면 렌더링 중에 함수를 바로 호출하므로 주의합니다. React 이벤트의 종류와 전파, 기본 동작 취소는 4장 「이벤트 처리」에서 자세히 설명합니다.
3.2.4 children
JSX에서 <Card>처럼 컴포넌트 이름만 감싼 부분을 여는 태그, </Card>처럼 이름 앞에 /를 붙여 닫는 부분을 닫는 태그라고 합니다. 두 태그 사이에 넣은 내용은 특별한 prop인 children으로 컴포넌트에 전달됩니다. 카드처럼 바깥 모양은 같고 안에 들어갈 내용만 달라지는 UI를 만들 때 유용합니다.
import type { ReactNode } from 'react';
type CardProps = {
// ReactNode는 React가 렌더링할 수 있는 내용을 나타내는 타입입니다.
children: ReactNode;
};
// children prop으로 받은 내용을 카드 안에 표시합니다.
function Card({ children }: CardProps) {
return <section className="card">{children}</section>;
}
function App() {
// <Card>와 </Card> 사이의 JSX가 children으로 전달됩니다.
return (
<Card>
<h2>공지사항</h2>
<p>새로운 소식이 있습니다.</p>
</Card>
);
}
이 예제에서 <Card>가 여는 태그이고 </Card>가 닫는 태그입니다. 제목과 문단을 두 태그 사이에 작성했기 때문에 이 내용이 children으로 전달됩니다. Card 컴포넌트는 {children}을 카드의 <section> 안에 넣어 화면에 표시합니다. 그래서 카드의 바깥 모양은 재사용하면서 안에 넣는 내용은 사용하는 곳마다 다르게 구성할 수 있습니다.
3.3 state 기본 useState - 펼치기 / 접기
state는 컴포넌트가 렌더링 사이에 기억하는 값입니다. 화면에 영향을 주는 값을 state로 관리하면, 그 값을 갱신했을 때 React가 새 값으로 화면을 다시 계산합니다. 함수 컴포넌트에서는 useState Hook으로 state와 갱신 함수를 만듭니다.
import { useState } from 'react';
function Counter() {
// 0은 첫 렌더링에서 사용할 초기 state입니다.
const [count, setCount] = useState(0);
function handleClick() {
// setter로 다음 state 값을 요청합니다.
setCount(count + 1);
}
return <button onClick={handleClick}>클릭한 횟수: {count}</button>;
}
useState(0)은 [현재 state, state setter] 형태의 값을 반환합니다. 배열 구조 분해로 첫 번째 값은 count, 두 번째 값은 setCount에 담습니다. 0은 처음 렌더링할 때 사용할 초기값입니다. 버튼을 누르면 setCount(count + 1)이 다음 값을 요청하고, React가 갱신된 count로 컴포넌트를 다시 렌더링합니다. state 변수는 함수가 아니라 현재 렌더링의 값을 담으며, 직접 대입하지 않고 setter로 갱신합니다. setter 이름은 보통 set 뒤에 state 이름을 붙입니다(예: count와 setCount).
state는 해당 값을 사용하는 가장 가까운 컴포넌트에서 관리합니다. 여러 자식이 같은 state를 공유해야 하면 공통 부모로 state를 올리고, 자식이 변경을 요청할 때는 부모가 함수를 props로 전달할 수 있습니다. 이 패턴은 3.2.3 함수 props와 이벤트 처리에서 다룬 방식입니다.
3.3.1 함수형 업데이트
state를 바꿀 때 setter에는 새 값을 바로 전달하거나, 이전 state를 받아 다음 값을 반환하는 함수를 전달할 수 있습니다. 이전 값에 따라 새 값을 계산할 때는 함수형 업데이트를 사용합니다.
function Counter() {
const [count, setCount] = useState(0);
function increaseFromCurrentRender() {
// 세 번 모두 현재 렌더링의 같은 count를 기준으로 계산합니다.
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}
function increaseWithUpdater() {
// 각 함수가 앞선 업데이트의 결과를 이어받아 계산합니다.
setCount((current) => current + 1);
setCount((current) => current + 1);
setCount((current) => current + 1);
}
return (
<>
<p>현재 값: {count}</p>
<button onClick={increaseFromCurrentRender}>현재 값으로 세 번 증가</button>
<button onClick={increaseWithUpdater}>함수형으로 세 번 증가</button>
</>
);
}
초기값이 0일 때 각 버튼을 한 번 누르면 다음처럼 동작합니다.
| 방식 | 세 번의 호출이 계산하는 값 | 화면에 남는 최종 값 |
|---|---|---|
setCount(count + 1) |
1, 1, 1 |
1 |
setCount(current => current + 1) |
1, 2, 3 |
3 |
직접 값을 전달하는 방식은 세 호출이 모두 같은 렌더링의 count를 사용하므로 1을 요청합니다. 함수형 업데이트는 앞선 업데이트의 결과를 다음 함수가 이어받아 1, 2, 3을 차례로 계산합니다. 이 숫자들이 화면에 각각 따로 표시되는 것은 아니며, 버튼을 한 번 누른 뒤 화면에 표시되는 최종 값이 각각 1과 3입니다. 아래 예제는 두 버튼이 같은 count를 사용하므로, 결과를 비교할 때는 각 방식을 초기값 0에서 따로 실행해야 합니다.
같은 방식을 사용하는 버튼을 초기값 0에서 두 번 연속 눌렀을 때의 결과도 비교해 보겠습니다. 두 방식은 각각 따로 실행한 결과입니다.
| 방식 | 첫 번째 클릭 | 두 번째 클릭 | 두 번 클릭 후 최종 값 |
|---|---|---|---|
setCount(count + 1) |
1, 1, 1을 요청하여 1 |
2, 2, 2를 요청하여 2 |
2 |
setCount(current => current + 1) |
1 → 2 → 3 |
4 → 5 → 6 |
6 |
값을 특정 숫자로 바꾸는 것처럼 이전 state가 필요 없으면 setCount(10)처럼 값을 전달해도 됩니다. 이전 값에 의존하는 계산, 한 이벤트 안의 연속 업데이트에는 setCount(current => ...) 형태가 알맞습니다.
3.3.2 State 불변성
객체나 배열을 state로 사용할 때는 기존 값을 직접 수정하지 않고, 바뀐 내용을 담은 새 객체나 배열을 만들어 setter에 전달합니다. 이를 state의 불변성을 지킨다고 합니다.
type Profile = {
name: string;
city: string;
};
function ProfileCard() {
const [profile, setProfile] = useState<Profile>({ name: '민지', city: '서울' });
function changeCity() {
// 기존 객체를 수정하지 않고, 변경할 값이 담긴 새 객체를 만듭니다.
setProfile((currentProfile) => ({ ...currentProfile, city: '부산' }));
}
return <button onClick={changeCity}>{profile.name}: {profile.city}</button>;
}
{ ...currentProfile, city: '부산' }은 기존 프로퍼티를 복사하고 city만 바꾼 새 객체를 만듭니다. 반대로 profile.city = '부산'처럼 기존 객체를 직접 수정해 같은 객체를 다시 전달하면 React가 변경을 제대로 감지하지 못할 수 있습니다. 배열도 같은 원칙으로 새 배열을 전달합니다.
setItems((currentItems) => [...currentItems, newItem]);
setItems((currentItems) => currentItems.filter((item) => item.id !== targetId));
첫 번째 예제는 기존 항목을 복사하고 새 항목을 추가하며, 두 번째 예제는 조건에 맞는 항목만 담은 새 배열을 만듭니다. 객체 안의 객체를 바꾸는 경우에도 변경 경로에 있는 객체들을 복사해 새 값을 만들어야 합니다.
4. 이벤트 처리 - 펼치기 / 접기
4.1 React 이벤트와 핸들러
이벤트는 클릭, 입력, 키보드 조작처럼 사용자의 동작이 브라우저에서 발생한 것입니다. React에서는 JSX 요소에 onClick, onChange처럼 이벤트 이름을 지정하고, 실행할 함수를 전달해 처리합니다. 이벤트 이름은 카멜 케이스로 쓰며, 함수는 호출하지 않고 전달합니다.
| 이벤트 prop | 발생하는 상황 |
|---|---|
onClick |
요소를 클릭할 때 |
onChange |
입력값이 변경될 때 |
onSubmit |
폼을 제출할 때 |
onKeyDown |
키를 누를 때 |
onFocus, onBlur |
요소에 포커스가 들어오거나 벗어날 때 |
function SaveButton() {
function handleClick() {
console.log('저장 버튼을 눌렀습니다.');
}
// onClick에 실행할 함수를 전달합니다.
return <button onClick={handleClick}>저장</button>;
}
onClick={handleClick}은 클릭할 때 실행하도록 함수를 전달합니다. onClick={handleClick()}처럼 괄호를 붙이면 렌더링 중에 바로 호출하므로 주의합니다. 이벤트 핸들러에는 발생한 이벤트 정보가 전달되며, 이를 이용해 입력값 등을 읽을 수 있습니다.
4.2 이벤트 전파
이벤트가 발생한 요소의 바깥쪽 요소에도 같은 이벤트 핸들러가 있으면, 이벤트가 부모 요소로 전달될 수 있습니다. 이를 이벤트 전파라고 합니다. 중첩된 요소에서는 이 동작 때문에 자식 클릭에 부모의 핸들러도 실행될 수 있습니다.
4.2.1 캡처와 버블 단계
이벤트는 바깥 요소에서 안쪽 요소로 내려오는 캡처 단계를 거친 뒤, 실제 이벤트 대상에서 바깥으로 올라가는 버블 단계를 거칩니다. React의 onClick은 기본적으로 버블 단계에서 실행되고, onClickCapture는 캡처 단계에서 실행됩니다.
function ClickOrder() {
return (
<div
onClickCapture={() => console.log('부모 캡처')}
onClick={() => console.log('부모 버블')}
>
<button
onClickCapture={() => console.log('자식 캡처')}
onClick={() => console.log('자식 버블')}
>
클릭
</button>
</div>
);
}
버튼을 클릭하면 부모 캡처 → 자식 캡처 → 자식 버블 → 부모 버블 순서로 실행됩니다. onClickCapture는 캡처 단계에서 먼저 처리해야 하는 로직이 있을 때 사용합니다.
4.2.2 stopPropagation으로 전파 중단
이벤트가 부모 핸들러까지 전달되지 않게 하려면 핸들러에서 event.stopPropagation()을 호출합니다.
function NestedClick() {
return (
<div onClick={() => console.log('부모 클릭 핸들러')}>
<button
onClick={(event) => {
// 클릭 이벤트가 부모 요소로 전파되지 않게 합니다.
event.stopPropagation();
console.log('자식 클릭 핸들러');
}}
>
부모 동작 없이 클릭
</button>
</div>
);
}
버튼을 눌러도 자식 핸들러만 실행되고 부모 핸들러는 실행되지 않습니다. stopPropagation()은 이벤트 전달을 막을 뿐, 링크 이동 같은 브라우저 기본 동작까지 취소하지는 않습니다.
4.3 preventDefault로 기본 동작 취소
링크 이동이나 폼 제출처럼 브라우저가 기본으로 수행하는 동작을 막으려면 event.preventDefault()를 사용합니다.
function DetailLink() {
return (
<a
href="/detail"
onClick={(event) => {
// 링크의 기본 이동 동작을 취소합니다.
event.preventDefault();
console.log('이동 대신 다른 처리를 합니다.');
}}
>
상세 보기
</a>
);
}
이 링크를 클릭해도 /detail로 이동하지 않고 핸들러만 실행됩니다. preventDefault()는 브라우저 기본 동작을 막고, stopPropagation()은 이벤트가 부모로 전파되는 것을 막습니다. 둘은 목적이 다르며 필요하면 함께 사용할 수 있습니다.
5. 폼 - 펼치기 / 접기
폼은 사용자가 입력한 값을 모아 검증하고 제출하는 UI입니다. React에서는 입력값을 state로 관리하는 제어 컴포넌트와 DOM에 입력값을 맡기는 비제어 컴포넌트를 사용할 수 있습니다. 어느 방식을 선택할지는 입력값을 화면에서 즉시 활용해야 하는지, 폼 라이브러리를 쓰는지 등에 따라 달라집니다.
5.1 폼 입력값 관리 - 펼치기 / 접기
5.1.1 target과 current는 무엇이 다른가?
event.target은 이벤트가 실제로 시작된 요소이고, event.currentTarget은 현재 핸들러가 연결된 요소입니다. 입력 요소에 핸들러를 직접 연결하면 둘 다 보통 그 입력 요소를 가리킵니다. 폼에서는 핸들러가 연결된 입력 요소를 명확히 가리키는 currentTarget을 쓰면 타입과 동작을 이해하기 쉽습니다.
| 구분 | 제어 컴포넌트 | 비제어 컴포넌트 |
|---|---|---|
| 입력값 보관 | React state | DOM 입력 요소 |
| 입력값 읽기 | event.currentTarget.value 등 |
제출 시 ref의 current.value |
| 기본값 | value 또는 checked |
defaultValue 또는 defaultChecked |
| 입력 중 화면 반영 | state가 바뀌면 다시 렌더링 | 입력 자체는 state 갱신을 일으키지 않음 |
5.1.2 제어 컴포넌트
제어 컴포넌트는 입력값을 React state에 연결합니다. 입력값이 바뀔 때마다 state를 갱신하므로, 입력값에 따라 화면을 즉시 바꾸거나 검증할 때 적합합니다.
import { useState } from 'react';
function ControlledName() {
const [name, setName] = useState('');
return (
<label>
이름
{/* value는 state가 제어하고, onChange가 새 입력값을 state에 반영합니다. */}
<input
value={name}
onChange={(event) => setName(event.currentTarget.value)}
/>
<p>입력 중: {name}</p>
</label>
);
}
value={name}은 입력 요소에 현재 state를 표시하고, onChange는 사용자가 입력한 값을 setName으로 state에 저장합니다. 따라서 state와 입력창의 값이 함께 갱신됩니다.
5.1.3 비제어 컴포넌트
비제어 컴포넌트는 입력값을 DOM이 보관합니다. 입력할 때마다 React state를 갱신하지 않고, 값이 필요할 때 ref를 통해 읽습니다.
import { useRef } from 'react';
import type { FormEvent } from 'react';
function UncontrolledName() {
// ref는 실제 <input> 요소를 가리키며, 처음에는 아직 연결되지 않아 null입니다.
const nameRef = useRef<HTMLInputElement>(null);
// 제출 이벤트가 발생한 요소는 <form>입니다.
function handleSubmit(event: FormEvent<HTMLFormElement>) {
// 브라우저가 폼을 제출하며 페이지를 다시 여는 기본 동작을 막습니다.
event.preventDefault();
console.log(nameRef.current?.value ?? '');
}
return (
<form onSubmit={handleSubmit}>
<label>
이름
{/* defaultValue는 초기 입력값이고, ref의 타입은 HTMLInputElement입니다. */}
<input ref={nameRef} defaultValue="민지" />
</label>
<button type="submit">확인</button>
</form>
);
}
defaultValue는 처음 표시할 값이고, 사용자가 수정한 뒤에는 DOM의 입력값이 기준이 됩니다. 체크박스에는 defaultChecked를 사용할 수 있습니다. React에서 state를 통해 입력값을 제어하지 않는다는 뜻이지, 컴포넌트가 다른 이유로 렌더링되는 것까지 막는 것은 아닙니다.
5.2 입력 요소에 따른 폼 구현 예제 - 펼치기 / 접기
5.2.1 입력 요소를 state로 관리하기
텍스트 입력, 여러 줄 입력, 선택 상자, 체크박스는 제어 컴포넌트로 만들 때 각각 value 또는 checked와 onChange를 연결합니다. 체크박스의 값은 value가 아니라 checked에서 읽습니다.
import { useState } from 'react';
import type { FormEvent } from 'react';
type ContactFormState = {
name: string;
message: string;
topic: string;
agreed: boolean;
};
function ContactForm() {
const [form, setForm] = useState<ContactFormState>({
name: '',
message: '',
topic: 'question',
agreed: false,
});
function handleSubmit(event: FormEvent<HTMLFormElement>) {
// 제출 후 페이지가 새로고침되는 브라우저 기본 동작을 막습니다.
event.preventDefault();
console.log(form);
}
return (
<form onSubmit={handleSubmit}>
<label>
이름
<input
value={form.name}
onChange={(event) =>
setForm((current) => ({ ...current, name: event.currentTarget.value }))
}
/>
</label>
<label>
문의 내용
<textarea
value={form.message}
onChange={(event) =>
setForm((current) => ({ ...current, message: event.currentTarget.value }))
}
/>
</label>
<label>
문의 종류
<select
value={form.topic}
onChange={(event) =>
setForm((current) => ({ ...current, topic: event.currentTarget.value }))
}
>
<option value="question">질문</option>
<option value="feedback">의견</option>
</select>
</label>
<label>
<input
type="checkbox"
checked={form.agreed}
onChange={(event) =>
setForm((current) => ({ ...current, agreed: event.currentTarget.checked }))
}
/>
개인정보 수집에 동의합니다.
</label>
<button type="submit">보내기</button>
</form>
);
}
각 입력 요소는 state의 해당 필드를 value 또는 checked에 연결하고, 변경 시 새 state 객체를 만들어 갱신합니다. 폼 제출 시 handleSubmit은 현재 state를 사용할 수 있습니다. 실제 서비스에서는 제출 전에 필수 입력과 형식을 검증하고, 서버 요청의 성공·실패도 사용자에게 알려야 합니다.
5.2.2 중첩 객체 state 업데이트
폼 state 안에 객체나 배열이 들어 있어도 기존 값을 직접 바꾸지 말고 새 객체·배열을 만듭니다. 예를 들어 중첩 객체의 한 필드를 바꿀 때는 바뀌는 경로의 각 객체를 복사합니다.
setForm((current) => ({
...current,
address: {
...current.address,
city: '부산',
},
}));
5.2.3 배열 state 업데이트
React는 state 변경을 확인할 때 이전 값과 새 값의 참조도 비교합니다. 배열을 직접 수정하면 같은 배열 객체를 유지하므로 변경을 감지하지 못해 화면이 갱신되지 않을 수 있습니다. 따라서 배열 항목을 추가·수정·삭제할 때는 원본 배열을 바꾸는 push() 대신 새 배열을 만들어 state에 전달합니다. 아래 예제는 items, setItems, 각 항목의 id, newItem, updatedItem, targetId가 이미 정의되어 있다고 가정합니다.
// 추가: 기존 항목과 새 항목을 담은 배열을 만듭니다.
setItems((current) => [...current, newItem]);
// 수정: 대상 항목만 새 객체로 바꿔 담습니다.
setItems((current) =>
current.map((item) => (item.id === updatedItem.id ? updatedItem : item)),
);
// 삭제: 남길 항목만 담은 새 배열을 만듭니다.
setItems((current) => current.filter((item) => item.id !== targetId));
5.2.4 Immer로 중첩 state 업데이트
중첩 객체를 여러 단계 복사하는 코드가 복잡해지면 Immer를 사용할 수 있습니다. use-immer 패키지의 useImmer는 draft를 직접 수정하는 것처럼 작성해도 변경된 새 state를 만들어 줍니다.
import { useImmer } from 'use-immer';
type Profile = {
name: string;
address: { city: string };
};
function ProfileEditor() {
const [profile, updateProfile] = useImmer<Profile>({
name: '민지',
address: { city: '서울' },
});
function changeCity() {
// draft는 Immer가 updater에 제공하는 임시 작업용 객체입니다.
// 여기서 수정한 내용은 Immer가 새 불변 state로 반영합니다.
updateProfile((draft) => {
draft.address.city = '부산';
});
}
return <button onClick={changeCity}>{profile.address.city}</button>;
}
Immer의 draft는 일반 state 객체가 아니므로, 이 방식은 useImmer의 updater 안에서만 사용합니다. 기본 useState의 객체나 배열을 직접 수정해서는 안 됩니다.
5.3 React Hook Form - 펼치기 / 접기
5.3.1 설치
React Hook Form은 입력값 등록, 폼 제출, 검증을 도와주는 라이브러리입니다. 입력이 많은 폼에서 각 필드의 state와 변경 핸들러를 반복해서 작성하는 일을 줄일 수 있습니다.
Vite 프로젝트에서 사용하려면 패키지를 설치합니다.
npm install react-hook-form
5.3.2 기본 사용법
import { useForm } from 'react-hook-form';
type SignupValues = {
email: string;
password: string;
};
function SignupForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<SignupValues>({
defaultValues: { email: '', password: '' },
});
function onSubmit(values: SignupValues) {
console.log(values);
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<label>
이메일
<input
{...register('email', { required: '이메일을 입력해 주세요.' })}
type="email"
/>
</label>
{errors.email && <p role="alert">{errors.email.message}</p>}
<label>
비밀번호
<input
{...register('password', {
required: '비밀번호를 입력해 주세요.',
minLength: { value: 8, message: '8자 이상 입력해 주세요.' },
})}
type="password"
/>
</label>
{errors.password && <p role="alert">{errors.password.message}</p>}
<button type="submit">가입</button>
</form>
);
}
register()는 각 입력을 React Hook Form에 등록하고, handleSubmit()은 제출 시 검증을 거쳐 성공한 값을 onSubmit에 전달합니다. 이 과정에서 폼의 기본 페이지 이동도 처리하므로 예제에서 별도로 preventDefault()를 호출하지 않습니다. errors에는 검증 실패 정보가 들어 있습니다. 비밀번호 길이 검증은 사용자 편의를 위한 것이며, 실제 보안 검증은 서버에서도 수행해야 합니다.
6. Hook 활용 - 펼치기 / 접기
6.1 useState: state와 세터
useState는 렌더링 사이에 유지할 값을 만들고, [현재 값, 갱신 함수]를 반환합니다. 자세한 함수형 업데이트와 불변성은 3.3절을 참고합니다.
const [count, setCount] = useState(0);
Hook은 컴포넌트나 사용자 정의 Hook의 최상위에서 호출하며, 조건문이나 반복문 안에서는 호출하지 않습니다.
6.2 useEffect: 외부 시스템과 동기화
useEffect는 타이머, 이벤트 구독, 네트워크 연결처럼 React 바깥의 시스템과 컴포넌트를 동기화할 때 사용합니다.
useEffect(() => {
// 외부 시스템과 동기화할 작업
}, [의존성]);
첫 번째 인자는 실행할 함수이고, 두 번째 인자는 그 함수가 사용하는 props와 state 등 반응형 값의 배열(의존성 배)입니다.
의존성 배열을 생략하면 렌더링이 커밋될 때마다 실행되고,
[]이면 처음 화면에 나타난 뒤 실행되어 일반적인 리렌더링에는 다시 실행되지 않습니다.
[value]처럼 값을 넣으면 처음 실행된 뒤 그 값이 바뀔 때마다 다시 실행됩니다.
Effect에서 반환하는 함수는 정리 함수입니다. 정리란 Effect가 시작한 작업을 끝내거나 해제하는 것입니다. 예를 들어 타이머를 시작했다면 정리 함수에서 타이머를 멈춥니다.
useEffect(() => {
const timer = setInterval(() => setCount((current) => current + 1), 1000);
return () => clearInterval(timer);
}, []);
React는 Effect를 다시 실행하기 전에 이전 정리 함수를 호출하고, 컴포넌트가 화면에서 제거될 때도 호출합니다. 개발 환경 Strict Mode에서는 정리 로직을 확인하기 위해 초기 설정과 정리가 추가로 실행될 수 있습니다.
import { useEffect, useState } from 'react';
function PageTitle({ title }: { title: string }) {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `${title}: ${count}`;
}, [title, count]);
return <button onClick={() => setCount((current) => current + 1)}>{count}</button>;
}
document는 브라우저가 제공하는 DOM API로 React 컴포넌트 바깥의 시스템입니다. 이 예제는 브라우저 탭 제목(document.title)을 React의 title prop과 count state에 맞춰 동기화합니다. 두 값을 의존성 배열에 넣었으므로 처음 실행된 뒤 둘 중 하나가 바뀌면 Effect가 다시 실행됩니다.
6.3 useRef: 렌더링과 분리된 값 참조
useRef는 렌더링 사이에 유지되는 { current } 객체를 반환합니다. DOM 요소를 참조하거나 타이머 ID처럼 화면에 표시되지 않는 값을 보관할 때 사용합니다. current를 바꿔도 다시 렌더링되지 않으므로 화면에 보여 줄 값은 state로 관리합니다. input ref 예제는 5.1.3절을 참고합니다.
6.4 useContext: 깊은 컴포넌트에 값 전달
Context는 여러 단계 아래에서 필요한 값을 prop으로 계속 전달하지 않도록 돕습니다. useContext는 가장 가까운 상위 Provider의 값을 읽으며, 그 값이 바뀌면 이를 사용하는 컴포넌트가 다시 렌더링됩니다.
import { createContext, useContext } from 'react';
const ThemeContext = createContext('light');
function Toolbar() {
const theme = useContext(ThemeContext);
return <div className={theme}>도구 모음</div>;
}
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
Toolbar는 상위 Provider가 제공한 "dark" 값을 읽어 <div className="dark">를 렌더링합니다. Context는 값을 전달할 뿐, 클래스에 스타일을 적용하지는 않습니다. 어두운 배경과 밝은 글자를 표시하려면 CSS 규칙을 정의해야 합니다.
.dark {
background-color: #222;
color: white;
}
Context는 테마나 로그인 사용자처럼 여러 깊이에서 공유할 값에 쓰고, 모든 state를 무조건 옮기지는 않습니다.
6.5 useReducer: 복잡한 상태 갱신
useReducer는 관련된 여러 상태 변경 규칙을 reducer 함수 한곳에 모으고 dispatch(action)으로 갱신합니다. 상태 전이가 복잡하거나 여러 이벤트가 같은 상태를 바꿀 때 유용하며, 단순한 값에는 useState가 더 간단합니다.
6.6 성능 최적화 Hook
useMemo는 계산 결과를, useCallback은 함수를 의존성 값이 바뀔 때까지 재사용합니다. 성능 문제가 확인된 계산이나 최적화된 자식 컴포넌트에 전달하는 값처럼 필요한 경우에만 사용합니다. 불필요한 메모이제이션은 코드를 복잡하게 만들 수 있고, React Compiler를 사용하는 프로젝트에서는 수동 사용이 줄어들 수 있습니다.
6.7 useId: 접근성 ID 생성
useId는 입력 요소와 설명을 htmlFor, id, aria-describedby로 연결할 고유 ID를 만들 때 사용합니다. 목록의 key를 만들기 위한 Hook은 아닙니다.
6.8 우선순위가 있는 UI 업데이트
useTransition과 useDeferredValue는 검색 결과나 큰 목록처럼 무거운 화면 갱신을 늦춰 입력 등 긴급한 UI가 먼저 반응하도록 돕습니다. 네트워크 요청을 일정 시간 지연시키는 디바운스와는 다른 기능입니다.
6.9 React 19의 Action Hook
React 19 이상에서는 useActionState로 Action의 결과와 진행 상태를 관리하고, useOptimistic으로 요청이 끝나기 전 낙관적 UI를 표시할 수 있습니다. 프로젝트의 React 버전을 확인한 뒤 해당 기능이 필요한 경우에 사용합니다.
7. 임베디드 컴포넌트 - 펼치기 / 접기
7.1 Fragment
Fragment는 여러 JSX 요소를 실제 DOM에 불필요한 부모 요소를 추가하지 않고 묶습니다. 짧은 표기는 <>...</>이며, key가 필요하면 Fragment를 명시적으로 가져와 사용합니다.
import { Fragment } from 'react';
function CourseDetails() {
return (
<>
<h2>React 기초</h2>
<p>컴포넌트와 JSX를 배웁니다.</p>
</>
);
}
function CourseRows({ courses }: { courses: { id: string; title: string }[] }) {
return courses.map((course) => (
<Fragment key={course.id}>
<h3>{course.title}</h3>
<hr />
</Fragment>
));
}
축약 표기에는 key를 지정할 수 없습니다. 목록의 key는 2.3.2.1절을 참고합니다.
7.2 StrictMode
StrictMode는 개발 중 컴포넌트의 잠재적인 문제를 찾도록 추가 검사를 수행하는 React 컴포넌트입니다. 프로덕션 빌드의 동작이나 화면에는 영향을 주지 않습니다. 개발 환경에서는 순수하지 않은 렌더링을 찾기 위해 컴포넌트를 추가로 렌더링하고, Effect와 ref 정리 누락을 찾기 위해 관련 처리를 다시 실행할 수 있습니다.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
개발 중 로그나 Effect가 두 번 실행되는 것처럼 보여도 검사를 끄기보다 렌더링을 순수하게 유지하고 Effect의 정리 함수를 올바르게 작성했는지 확인합니다. Strict Mode가 프로덕션에서 Effect를 두 번 실행하는 것은 아닙니다.
7.3 Profiler
Profiler는 감싼 컴포넌트 트리의 렌더링 시간을 측정하는 React 컴포넌트입니다. 성능 문제가 의심되는 영역을 측정할 때 사용하며, 측정 없이 성능 최적화부터 적용하지 않습니다.
import { Profiler } from 'react';
function CoursePage() {
return (
<Profiler
id="CourseList"
onRender={(id, phase, actualDuration) => {
console.log(`${id} ${phase}: ${actualDuration}ms`);
}}
>
<CourseList />
</Profiler>
);
}
onRender는 감싼 트리의 렌더링이 커밋될 때 호출됩니다. actualDuration은 해당 업데이트에서 트리를 렌더링하는 데 걸린 시간입니다. 측정에는 오버헤드가 있고 기본 프로덕션 빌드에서는 프로파일링이 비활성화되어 있으므로, 개발 중에는 React Developer Tools의 Profiler도 활용할 수 있습니다.
7.4 Suspense
Suspense는 자식 컴포넌트가 로딩을 위해 렌더링을 잠시 멈추는(suspend) 동안 fallback UI를 보여 주는 경계입니다. 로딩 UI를 표시할 범위를 감싸고, 준비가 끝나면 자식 화면을 보여 줍니다.
import { Suspense } from 'react';
function App() {
return (
<Suspense fallback={<p role="status">화면을 불러오는 중입니다...</p>}>
<LazyCoursePage />
</Suspense>
);
}
Suspense는 모든 비동기 작업을 자동으로 감지하지 않습니다. lazy로 불러오는 컴포넌트나 Suspense를 지원하는 프레임워크·데이터 로딩 방식에서 동작합니다. useEffect 안의 fetch()는 Suspense를 자동으로 활성화하지 않으며, 이 강좌에서는 이를 직접 상태로 처리합니다(7.7절). 로딩 fallback과 오류 화면도 별개이며, 오류 처리는 Error Boundary 등으로 구성합니다.
7.5 컴포넌트 지연 로드하기
lazy()는 컴포넌트 코드를 처음 렌더링할 때 불러오도록 미룹니다. 초기 화면에 당장 필요하지 않은 큰 화면이나 기능의 코드를 나누어 불러올 때 유용합니다. 지연 로드 중에는 가장 가까운 Suspense 경계의 fallback이 표시됩니다.
import { lazy, Suspense } from 'react';
const SettingsPage = lazy(() => import('./SettingsPage'));
function App() {
return (
<Suspense fallback={<p role="status">설정 화면을 불러오는 중입니다...</p>}>
<SettingsPage />
</Suspense>
);
}
lazy() 선언은 컴포넌트 바깥에 둡니다. 동적 import()가 반환하는 모듈은 기본 내보내기(default export)로 React 컴포넌트를 제공해야 합니다. 지연 로드한 모듈을 불러오지 못한 오류는 Suspense가 처리하지 않으므로 Error Boundary 등으로 오류 UI를 별도로 마련합니다.
7.6 네트워크를 통해 데이터 가져오기
네트워크 요청은 비동기 작업입니다. 사용자 클릭처럼 특정 동작에 대한 요청은 이벤트 핸들러에서 시작하고, 화면에 필요한 데이터를 외부 시스템과 동기화하는 요청은 Effect나 프레임워크의 데이터 로딩 기능에서 처리할 수 있습니다. props와 state로 계산할 수 있는 값을 가져오기 위해 Effect를 추가하지는 않습니다.
fetch()는 네트워크 오류에는 reject되지만, 404나 500 응답만으로는 reject되지 않으므로 response.ok를 확인해야 합니다.
async function fetchCourses() {
const response = await fetch('/api/courses');
if (!response.ok) {
throw new Error(`강좌 요청 실패 (${response.status})`);
}
return response.json();
}
실제 응답 데이터는 TypeScript 타입 선언만으로 검증되지 않으므로 런타임에도 응답 모양을 확인해야 합니다. 요청 취소, 로딩·오류·빈 결과 표시까지 포함한 예제는 다음 7.7절에서 다룹니다. 앱 규모가 커지면 캐시, 중복 요청, 재검증을 제공하는 프레임워크나 데이터 요청 라이브러리를 고려합니다.
7.7 로딩·오류·빈 결과 처리
네트워크에서 데이터를 가져오는 화면은 요청 중, 요청 실패, 결과 없음, 데이터 표시 상태를 구분해야 합니다. 로딩 중에는 진행 중임을 알리고, 오류는 빈 결과와 구분해 메시지를 표시합니다. 필요하다면 재시도 기능도 제공합니다. 아래 예제는 /api/courses가 { id, title } 형태의 강좌 배열을 반환한다고 가정합니다.
import { useEffect, useState } from 'react';
type Course = { id: string; title: string };
type LoadState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'success'; courses: Course[] };
function isCourseList(data: unknown): data is Course[] {
return (
Array.isArray(data) &&
data.every(
(item) =>
item !== null &&
typeof item === 'object' &&
'id' in item &&
typeof item.id === 'string' &&
'title' in item &&
typeof item.title === 'string',
)
);
}
function CourseList() {
const [state, setState] = useState<LoadState>({ status: 'loading' });
useEffect(() => {
const controller = new AbortController();
async function loadCourses() {
try {
const response = await fetch('/api/courses', { signal: controller.signal });
if (!response.ok) {
throw new Error(`강좌를 불러오지 못했습니다. (${response.status})`);
}
const data: unknown = await response.json();
if (!isCourseList(data)) {
throw new Error('서버 응답 형식이 올바르지 않습니다.');
}
setState({ status: 'success', courses: data });
} catch (error: unknown) {
if (controller.signal.aborted) return;
setState({
status: 'error',
message: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.',
});
}
}
void loadCourses();
return () => controller.abort();
}, []);
if (state.status === 'loading') return <p role="status">강좌를 불러오는 중입니다...</p>;
if (state.status === 'error') return <p role="alert">{state.message}</p>;
if (state.courses.length === 0) return <p>등록된 강좌가 없습니다.</p>;
return (
<ul>
{state.courses.map((course) => (
<li key={course.id}>{course.title}</li>
))}
</ul>
);
}
LoadState를 판별 유니언으로 정의해 각 화면 상태에 필요한 값만 갖도록 했습니다. fetch()는 네트워크 실패에는 reject되지만, 404·500 같은 HTTP 응답만으로는 reject되지 않으므로 response.ok를 확인합니다. 응답 JSON은 unknown으로 받고 모양을 검사해 잘못된 데이터가 정상 결과처럼 사용되지 않게 합니다.
Effect의 정리 함수는 AbortController로 요청을 취소합니다. 컴포넌트가 사라진 뒤에는 결과를 상태에 반영하지 않으며, 요청 실패와 취소를 구분합니다. 실제 서비스에서는 재시도, 캐싱, 중복 요청 처리 등이 필요할 수 있으므로 데이터 요청 라이브러리나 프레임워크 기능을 고려합니다. 이 예제처럼 Effect 안에서 직접 가져오는 요청은 그 자체로 Suspense의 로딩 화면을 보여 주지 않으며, 여기서는 상태에 따라 UI를 직접 렌더링합니다.
8. 컴포넌트 개발에서의 스타일 - 펼치기 / 접기
컴포넌트 스타일은 별도의 CSS 파일, CSS Modules, CSS-in-JS 라이브러리 등으로 작성할 수 있습니다. CSS-in-JS는 JavaScript/TypeScript 코드에서 스타일을 정의하고 컴포넌트와 함께 관리하는 방식입니다. 라이브러리마다 프레임워크 지원, 서버 렌더링 설정, 번들 크기와 사용 문법이 다르므로 프로젝트 환경에 맞춰 선택합니다.
8.1 Styled JSX
Styled JSX는 주로 Next.js에서 사용하는 CSS-in-JS 도구입니다. 컴포넌트 안의 <style jsx>에 CSS를 작성하면 해당 컴포넌트 범위에 적용됩니다. 일반 Vite React 프로젝트에는 기본 포함되어 있지 않으므로, Next.js가 아닌 환경에서는 별도 설정과 의존성이 필요합니다.
function Greeting() {
return (
<section>
<h2>안녕하세요</h2>
<style jsx>{`
h2 {
color: navy;
}
`}</style>
</section>
);
}
컴포넌트 스타일의 범위를 분리하기 편하지만, 전역 스타일은 별도의 global 스타일 문법이 필요합니다. Next.js App Router에서 서버 렌더링과 함께 사용할 때는 스타일 레지스트리 설정이 필요할 수 있으므로 프레임워크 공식 문서를 확인합니다.
8.2 styled-components
styled-components는 태그드 템플릿 리터럴로 CSS를 작성하고, 스타일이 연결된 React 컴포넌트를 만드는 라이브러리입니다.
npm install styled-components
import styled from 'styled-components';
const Button = styled.button`
padding: 0.5rem 1rem;
border: 0;
border-radius: 0.25rem;
background: #2457a7;
color: white;
&:hover {
background: #173d78;
}
`;
function SaveButton() {
return <Button>저장</Button>;
}
Button은 실제 <button>을 렌더링하면서 연결된 스타일을 적용합니다. props나 ThemeProvider를 이용해 상태와 테마에 따라 스타일을 바꿀 수도 있습니다. 서버 렌더링을 사용하는 프레임워크에서는 스타일 추출·주입 설정이 필요할 수 있으므로 해당 프레임워크의 설치 지침을 따릅니다.
8.3 Emotion
Emotion은 CSS를 JavaScript에서 작성하도록 돕는 CSS-in-JS 라이브러리입니다. React에서는 @emotion/styled로 styled-components와 비슷한 컴포넌트를 만들 수 있고, @emotion/react의 css prop으로 요소에 스타일을 지정할 수도 있습니다. 여기서는 설정이 간단한 @emotion/styled 방식을 사용합니다.
npm install @emotion/react @emotion/styled
import styled from '@emotion/styled';
const Message = styled.p`
margin: 0;
color: #444;
font-weight: 600;
`;
function Status() {
return <Message>저장되었습니다.</Message>;
}
Emotion은 문자열 템플릿과 객체 스타일, css prop 등 여러 작성 방식을 제공합니다. css prop은 JSX 변환 설정이 필요한 경우가 있으므로 프로젝트의 빌드 도구와 Emotion 문서를 확인합니다. Next.js App Router처럼 서버 컴포넌트·스트리밍을 사용하는 환경은 라이브러리별 지원 상태와 설정을 먼저 확인해야 합니다.
세 방법 모두 컴포넌트 단위로 스타일을 구성할 수 있지만 문법과 프레임워크 통합 방식은 다릅니다. 한 프로젝트에서는 특별한 이유가 없다면 스타일 도구를 여러 개 섞기보다 기존 방식과 일관성을 유지합니다.
9. 리액트 라우터 - 펼치기 / 접기
9.1 SPA
SPA(Single-Page Application)는 처음에 받은 HTML 문서를 유지하면서 JavaScript가 화면의 필요한 부분을 갱신하는 방식입니다. React Router는 URL과 React 컴포넌트를 연결해 새 HTML 문서를 매번 요청하지 않고 화면을 전환하며, 브라우저의 뒤로·앞으로 이동과 주소 공유를 지원합니다. 서버는 앱의 경로로 직접 접근해도 앱 진입 문서를 반환하도록 설정되어야 합니다.
9.2 라우터 없이 컴포넌트 배치
작은 화면은 state에 따라 컴포넌트를 조건부 렌더링할 수 있습니다.
import { useState } from 'react';
function App() {
const [page, setPage] = useState<'home' | 'courses'>('home');
return (
<>
<button onClick={() => setPage('home')}>홈</button>
<button onClick={() => setPage('courses')}>강좌</button>
{page === 'home' ? <h1>홈</h1> : <h1>강좌 목록</h1>}
</>
);
}
이 방식만으로는 페이지 상태가 URL에 반영되지 않아 주소 공유나 브라우저 뒤로 가기를 직접 구현해야 합니다. 화면과 경로를 연결해야 하는 앱에서는 라우터를 사용합니다.
9.3 라우터를 사용해서 배치
이 예제는 React Router v7의 선언형 API를 사용합니다. 패키지를 설치하고 앱의 상위에 BrowserRouter를 둔 다음, Routes 안에 URL과 화면을 연결하는 Route를 선언합니다.
npm install react-router
// src/main.tsx
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import App from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
{/* 앱 전체에서 URL 기반 라우팅을 사용할 수 있도록 감쌉니다. */}
<BrowserRouter>
<App />
</BrowserRouter>
</StrictMode>,
);
// src/App.tsx
import { Route, Routes } from 'react-router';
import { Navigation } from './Navigation';
function App() {
// 현재 URL에 맞는 Route를 선택합니다.
return (
<>
{/* 공통 내비게이션은 경로에 따라 바뀌는 Routes 바깥에 둡니다. */}
<Navigation />
<Routes>
{/* 홈 경로에 홈 화면을 연결합니다. */}
<Route path="/" element={<Home />} />
{/* 강좌 목록 경로에 목록 화면을 연결합니다. */}
<Route path="/courses" element={<CourseList />} />
{/* :courseId는 URL에서 읽을 경로 매개변수입니다. */}
<Route path="/courses/:courseId" element={<CourseDetail />} />
{/* 위 경로와 일치하지 않을 때 표시하는 기본 경로입니다. */}
<Route path="*" element={<p>페이지를 찾을 수 없습니다.</p>} />
</Routes>
</>
);
}
BrowserRouter는 브라우저 주소와 라우팅 상태를 연결합니다. Routes는 현재 URL에 맞는 경로를 찾고, 일치하는 Route의 element를 렌더링합니다. * 경로는 일치하는 경로가 없을 때 표시할 화면입니다. 설치 방식과 import 경로는 React Router 버전에 따라 달라질 수 있으므로 프로젝트 버전의 공식 문서를 확인합니다.
state로 화면을 직접 바꾸는 방식은 간단하지만, URL과 연결되지 않으면 화면 주소 공유나 브라우저 뒤로·앞으로 이동을 별도로 구현해야 합니다. 각 화면을 고유한 URL로 열고 브라우저 기록과 연동하려면 Route로 URL과 화면을 연결합니다.
9.4 경로 매개변수
경로의 :courseId처럼 콜론으로 시작하는 부분은 매개변수입니다. useParams()로 값을 읽어 해당 데이터의 상세 화면을 표시할 수 있습니다.
import { useParams } from 'react-router';
const courses = [
{ id: 'react-basics', title: 'React 기초' },
{ id: 'react-hooks', title: 'React Hooks' },
];
function CourseDetail() {
const { courseId } = useParams();
const course = courses.find((item) => item.id === courseId);
if (!course) return <p>강좌를 찾을 수 없습니다.</p>;
return <h1>{course.title}</h1>;
}
라우트 패턴 /courses/:courseId에서 :courseId는 실제 값이 들어갈 자리를 나타냅니다. 실제 URL /courses/react-basics에는 콜론이 없으며, useParams()는 courseId 값으로 "react-basics"를 반환합니다. 경로 매개변수는 문자열이므로 숫자 ID라면 변환과 유효성 검사를 거친 뒤 사용합니다.
9.5 Link로 페이지 이동
Link는 앱 안에서 경로를 이동하는 링크입니다. 클릭하면 URL을 바꾸고 전체 문서를 다시 요청하지 않도록 라우터에 이동을 맡깁니다. 현재 경로에 맞는 활성 스타일을 자동으로 제공하지는 않습니다. 활성 상태가 필요한 내비게이션에는 NavLink를 사용합니다.
import { Link } from 'react-router';
function Home() {
return <Link to="/courses">강좌 목록 보기</Link>;
}
앱 밖의 사이트나 다운로드 파일 등 일반 브라우저 이동에는 <a>를 사용합니다.
9.6 NavLink로 현재 위치 표시
NavLink는 Link처럼 이동하면서 현재 URL과 경로가 일치하는지도 알려 주는 링크입니다. 활성 상태에 따라 스타일을 바꾸기 쉽고, 일치하면 접근성을 위해 aria-current="page"도 설정됩니다. 클릭하면 NavLink 자체가 페이지 컴포넌트를 렌더링하는 것은 아닙니다. URL이 바뀌면 Routes가 일치하는 Route의 element를 표시합니다.
// src/Navigation.tsx
import { NavLink } from 'react-router';
export function Navigation() {
return (
<nav>
<NavLink to="/" end className={({ isActive }) => (isActive ? 'active' : '')}>
홈
</NavLink>
<NavLink to="/courses" className={({ isActive }) => (isActive ? 'active' : '')}>
강좌
</NavLink>
</nav>
);
}
isActive가 true일 때 active 클래스가 적용됩니다. 홈 링크의 end는 / 경로만 정확히 활성 상태로 처리해 다른 경로에서도 홈 링크가 활성화되는 것을 방지합니다.
클래스가 화면에서 색상이나 모양을 바꾸려면 CSS 규칙도 정의해야 합니다.
공식 문서: React Router 선언형 라우팅.
10. 포털 - 펼치기 / 접기
10.1 Portal로 DOM 위치 분리하기
Portal은 React 컴포넌트 트리의 자식을 DOM의 다른 위치에 렌더링합니다. 모달이나 툴팁처럼 상위 요소의 overflow나 쌓임 맥락에 가리지 않게 표시할 때 유용합니다. Portal로 렌더링해도 React 트리의 자식이라는 점은 유지되어 Context를 사용할 수 있고, 이벤트는 DOM 위치가 아니라 React 트리를 따라 전파됩니다.
import { createPortal } from 'react-dom';
function Modal({ onClose }: { onClose: () => void }) {
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<section
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={(event) => event.stopPropagation()}
>
<h2 id="modal-title">알림</h2>
<p>Portal로 document.body에 렌더링한 대화상자입니다.</p>
<button onClick={onClose}>닫기</button>
</section>
</div>,
document.body,
);
}
createPortal(children, domNode)의 두 번째 인자로 실제 DOM 노드(document.body 등)를 전달합니다. 배경을 누르면 닫히게 하고 대화상자 내부 클릭은 전파를 막았습니다. 실제 모달에는 키보드 포커스 관리, Escape 처리 등 접근성 동작도 구현해야 하며, Portal 자체가 이를 자동 제공하지는 않습니다.
10.2 Error Boundary로 렌더링 오류 처리
Error Boundary는 하위 컴포넌트의 렌더링 과정에서 발생한 오류를 잡아 앱 전체가 중단되는 대신 대체 UI를 표시하는 경계입니다. 현재 React에서 Error Boundary는 클래스 컴포넌트로 구현하며, getDerivedStateFromError로 fallback 상태를 정하고 componentDidCatch에서 오류를 기록할 수 있습니다.
import { Component, type ErrorInfo, type PropsWithChildren } from 'react';
type ErrorBoundaryState = { hasError: boolean };
class ErrorBoundary extends Component<PropsWithChildren, ErrorBoundaryState> {
state: ErrorBoundaryState = { hasError: false };
static getDerivedStateFromError(): ErrorBoundaryState {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('화면 렌더링 오류:', error, info.componentStack);
}
render() {
if (this.state.hasError) {
return <p role="alert">화면을 표시하는 중 문제가 발생했습니다.</p>;
}
return this.props.children;
}
}
오류가 격리되길 원하는 하위 영역을 <ErrorBoundary>로 감쌉니다. 경계를 여러 곳에 두면 한 화면의 오류가 앱 전체를 대체하지 않도록 범위를 나눌 수 있습니다.
<ErrorBoundary>
<CoursePage />
</ErrorBoundary>
Error Boundary는 하위 컴포넌트의 렌더링 오류를 처리하지만, 이벤트 핸들러에서 발생한 오류, 일반 비동기 작업의 오류, 경계 자신이 발생시킨 오류는 자동으로 잡지 않습니다. 이런 오류는 try/catch나 요청 상태 처리 등 발생한 위치에 맞는 방식으로 처리합니다.
11. 리엑트 라이브러리 활용하기 - 펼치기 / 접기
React 생태계에는 UI를 빠르게 구성하거나 컴포넌트를 독립적으로 개발하고, 서버 데이터를 관리하도록 돕는 라이브러리가 있습니다. 필요한 기능과 프로젝트의 React·빌드 도구 환경을 확인하고 선택합니다.
11.1 MUI Material로 UI 구성
MUI Material은 버튼, 입력, 메뉴, 대화상자 등 재사용 가능한 UI 컴포넌트와 테마 시스템을 제공하는 React 라이브러리입니다. 프로젝트에서 일관된 디자인의 화면을 빠르게 만들 때 활용할 수 있습니다. MUI의 기본 스타일 엔진은 Emotion입니다.
npm install @mui/material @emotion/react @emotion/styled
import Button from '@mui/material/Button';
function SaveButton() {
return (
<Button variant="contained" color="primary" onClick={() => console.log('저장')}>
저장
</Button>
);
}
variant는 버튼 모양을, color는 테마의 색상 역할을 선택합니다. MUI 컴포넌트는 접근성 속성과 상호작용을 제공하지만, 화면에 맞는 라벨과 사용 흐름은 앱에서 구성해야 합니다. React와 React DOM은 MUI의 peer dependency이므로 프로젝트에 설치되어 있어야 합니다.
공식 문서: MUI Material 설치, Button.
11.2 Storybook에서 컴포넌트 개발
Storybook은 컴포넌트를 앱의 나머지 화면과 분리해 실행하고, 여러 입력값에 따른 UI 상태를 확인·문서화하는 개발 도구입니다. React와 Vite를 사용하는 프로젝트에서는 프로젝트 루트에서 초기화 명령을 실행합니다.
npx storybook@latest init
초기화가 끝나면 Storybook이 프로젝트에 추가한 스크립트로 실행합니다. 기본 설정에서는 npm run storybook으로 개발 서버를 시작하고, npm run build-storybook으로 정적 결과물을 빌드합니다.
Story 파일은 컴포넌트의 기본 정보(meta)와 개별 상태(story)를 내보냅니다. 다음 예제는 Button 컴포넌트가 label과 disabled props를 받는다고 가정합니다.
// src/components/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from './Button';
const meta = {
title: 'Components/Button',
component: Button,
args: { label: '저장' },
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Primary: Story = {
args: { disabled: false },
};
export const Disabled: Story = {
args: { disabled: true },
};
각 story의 args는 컴포넌트 props로 전달됩니다. Storybook 화면에서 컴포넌트 상태를 독립적으로 확인하고, Controls가 설정되어 있으면 props를 바꿔 보며 동작을 살펴볼 수 있습니다. Story 파일은 보통 실제 앱 화면의 일부가 아니라 개발·문서화 도구에서 사용합니다.
공식 문서: Storybook 시작하기, 스토리 작성.
11.3 TanStack Query로 서버 상태 관리
TanStack Query는 이전 이름이 React Query인 서버 상태 관리 라이브러리입니다. 서버에서 가져온 데이터의 요청, 캐시, 동기화와 갱신을 관리합니다. 입력창 값이나 열림 여부 같은 클라이언트 UI state를 관리하는 useState와 목적이 다릅니다.
npm install @tanstack/react-query
앱을 QueryClientProvider로 감싸면 하위 컴포넌트에서 query 기능을 사용할 수 있습니다.
// src/main.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { createRoot } from 'react-dom/client';
import App from './App';
const queryClient = new QueryClient();
createRoot(document.getElementById('root')!).render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
);
useQuery는 queryKey로 데이터를 식별하고, queryFn으로 가져옵니다. 아래 예제에서 요청·오류·빈 결과·성공 화면을 구분합니다.
import { useQuery } from '@tanstack/react-query';
type Course = { id: string; title: string };
async function fetchCourses(): Promise<Course[]> {
const response = await fetch('/api/courses');
if (!response.ok) {
throw new Error(`강좌 요청 실패 (${response.status})`);
}
return response.json();
}
function CourseList() {
const { data, isPending, isError, error } = useQuery({
queryKey: ['courses'],
queryFn: fetchCourses,
});
if (isPending) return <p role="status">강좌를 불러오는 중입니다...</p>;
if (isError) return <p role="alert">{error.message}</p>;
if (data.length === 0) return <p>등록된 강좌가 없습니다.</p>;
return (
<ul>
{data.map((course) => (
<li key={course.id}>{course.title}</li>
))}
</ul>
);
}
같은 queryKey를 사용하는 요청 결과는 QueryClient가 캐시하고 재사용할 수 있습니다. 실제 API 응답의 데이터 모양은 TypeScript 선언만으로 검증되지 않으므로 런타임 검증도 고려합니다. 로딩·오류·빈 결과 처리와 응답 검증의 자세한 내용은 7.7절을 참고합니다.
공식 문서: TanStack Query 개요, 빠른 시작.
12. 테스트 - 펼치기 / 접기
테스트는 코드가 요구사항대로 동작하는지 확인하는 방법입니다. 단위 로직부터 실제 브라우저의 사용자 흐름까지 목적에 맞는 수준에서 검사합니다.
12.1 테스트의 종류
- 단위 테스트: 작은 함수나 모듈이 기대한 결과를 내는지 확인합니다.
- 통합 테스트: 컴포넌트와 상태, 이벤트처럼 여러 부분이 함께 동작하는지 확인합니다.
- E2E 테스트: 실제 브라우저에서 사용자의 핵심 흐름이 작동하는지 확인합니다.
React 컴포넌트 테스트는 화면과 사용자 동작을 검사하므로 통합 테스트의 성격도 가질 수 있습니다. 테스트 수준의 경계는 절대적이지 않습니다.
12.2 테스트 도구 선택
- Jest 또는 Vitest: 테스트를 실행하고 결과를 검증하는 프레임워크입니다. 기존 프로젝트 도구를 유지하거나, Vite 프로젝트에서 Vitest를 선택할 수 있습니다.
- React Testing Library: 컴포넌트를 화면에 렌더링해 사용자 관점으로 확인하도록 돕는 도구입니다.
- Playwright: 실제 브라우저에서 사용자 흐름을 검사하는 E2E 테스트 도구입니다. Cypress도 대안으로 사용할 수 있습니다.
- Mock Service Worker (MSW): 네트워크 응답을 모의해 API 성공·오류 상황을 테스트하도록 돕습니다.
공식 문서: Jest, Vitest, React Testing Library, Playwright, MSW.
12.3 실무에서 테스트 고르기
단위·통합 테스트는 여러 동작을 빠르게 확인하고, E2E 테스트는 중요한 사용자 흐름을 브라우저에서 검증합니다. 모든 동작을 E2E로 검사하기보다 중요도와 위험도에 따라 적절한 수준을 선택합니다.
- 내부 구현보다 요구사항과 사용자가 확인하는 결과를 테스트합니다.
- 중요한 테스트를 CI에서 실행해 변경으로 인한 회귀를 확인합니다.
- 커버리지는 참고 지표로 보고, 중요한 동작을 제대로 검증하는지 살핍니다.