React & Next.js 입문 가이드
이 글의 핵심
React를 배우고 바로 Next.js로 넘어가면 이 코드가 브라우저에서 도는지 서버에서 도는지부터 헷갈리기 쉽습니다. 그래서 버전 숫자보다 코드가 실행되는 위치를 먼저 맞추는 순서로 구성했고, 상태 끌어올리기와 useReducer로 복잡한 상태를 정리하는 방법을 거쳐 서버 컴포넌트와 클라이언트 컴포넌트를 나누는 기준까지 이어집니다.
이 글의 핵심
클라이언트 컴포넌트와 Hooks로 UI를 만들고, Next.js 쪽에서는 라우팅·서버 컴포넌트 개념까지 이어지게 정리했습니다. 버전 숫자보다 “어디 코드가 도는지”를 먼저 맞추는 쪽에 맞췄다.
사전 지식 (초보자를 위한 기초)
HTML, CSS, JavaScript 기초
HTML·CSS·JavaScript 기초가 있으면 이 글을 따라가기 쉽습니다.
HTML (구조)
<!DOCTYPE html>
<html>
<head>
<title>My Page</title>
</head>
<body>
<h1>Hello World</h1>
<button id="btn">Click me</button>
</body>
</html>
CSS (스타일)
h1 {
color: blue;
font-size: 24px;
}
button {
background-color: #007bff;
color: white;
padding: 10px 20px;
}
JavaScript (동작)
const button = document.getElementById('btn');
button.addEventListener('click', () => {
alert('Button clicked!');
});
ES6+ 문법
React 코드에서 자주 마주치는 ES6+ 문법입니다.
화살표 함수
// 기존 함수
function add(a, b) {
return a + b;
}
// 화살표 함수
const add = (a, b) => a + b;
구조 분해 할당
// 객체
const user = { name: 'Alice', age: 25 };
const { name, age } = user;
// 배열
const numbers = [1, 2, 3];
const [first, second] = numbers;
스프레드 연산자
// 변수 선언 및 초기화
const arr1 = [1, 2, 3];
const arr2 = [...arr1, 4, 5]; // [1, 2, 3, 4, 5]
const obj1 = { a: 1, b: 2 };
const obj2 = { ...obj1, c: 3 }; // { a: 1, b: 2, c: 3 }
템플릿 리터럴
const name = 'Alice';
const message = `Hello, ${name}!`; // "Hello, Alice!"
npm과 패키지 관리
npm은 Node 생태계에서 패키지를 설치·관리하는 기본 도구다.
# Node.js 설치 확인
node --version
npm --version
# 프로젝트 초기화
npm init -y
# 패키지 설치
npm install react react-dom
# 개발 의존성 설치
npm install --save-dev webpack
Vite나 Next.js가 프로젝트를 만들어 주면 이 명령을 직접 칠 일은 드물지만, package.json의 dependencies와 devDependencies 차이, 그리고 package-lock.json을 커밋해야 하는 이유는 알아 두는 것이 좋습니다. lock 파일이 없으면 팀원마다, 또는 CI에서 설치할 때마다 하위 의존성 버전이 달라져 “내 컴퓨터에서는 되는데”가 생깁니다. CI에서는 npm install 대신 lock 파일을 그대로 따르는 npm ci를 쓰는 이유도 같습니다.
React란 무엇인가?
React의 핵심 개념
React는 Meta가 만든 UI 라이브러리다. 핵심 특징:
1. 컴포넌트 기반
- UI를 재사용 가능한 조각으로 분리
- 레고 블록처럼 조립
2. 선언적 (Declarative)
- "무엇을" 보여줄지 선언
- "어떻게" 구현할지는 React가 처리
3. Virtual DOM
- 변경사항을 효율적으로 업데이트
- 빠른 렌더링
React vs Vanilla JavaScript
Vanilla JavaScript (명령형)
// 카운터 구현
let count = 0;
const button = document.getElementById('btn');
const display = document.getElementById('count');
button.addEventListener('click', () => {
count++;
display.textContent = count; // DOM 직접 조작
});
React (선언형)
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>
Click me
</button>
</div>
);
}
// 상태가 변하면 자동으로 UI 업데이트!
두 코드의 차이는 줄 수가 아니라 “화면을 누가 맞추느냐”입니다. Vanilla 버전은 count를 바꾼 뒤 display.textContent도 직접 바꿔야 하고, 같은 값을 보여 주는 요소가 세 군데라면 세 곳을 모두 기억해서 갱신해야 합니다. React 버전은 “count가 이 값일 때 화면은 이렇다”만 적고, setCount가 호출되면 React가 컴포넌트 함수를 다시 실행해 새 결과와 이전 결과를 비교한 뒤 바뀐 DOM만 고칩니다. 흔히 “Virtual DOM이라서 빠르다”고 하지만, 정확히는 직접 최적화한 DOM 조작보다 빠른 것이 아니라 실수 없이 충분히 빠른 갱신을 자동으로 해 준다는 것이 핵심입니다.
React 시작하기
Vite로 프로젝트 생성
# Vite로 React 프로젝트 생성 (빠름!)
npm create vite@latest my-react-app -- --template react
cd my-react-app
npm install
npm run dev
# 브라우저에서 http://localhost:5173 접속
프로젝트 구조
my-react-app/
├── public/ # 정적 파일
├── src/
│ ├── App.jsx # 메인 컴포넌트
│ ├── main.jsx # 진입점
│ └── index.css # 스타일
├── index.html
├── package.json
└── vite.config.js
첫 번째 컴포넌트
src/App.jsx
function App() {
return (
<div>
<h1>Hello React!</h1>
<p>My first React app</p>
</div>
);
}
export default App;
컴포넌트와 JSX
컴포넌트란?
컴포넌트는 UI를 나누는 재사용 단위다.
// Button 컴포넌트
function Button({ text, onClick }) {
return (
<button onClick={onClick}>
{text}
</button>
);
}
// 여러 곳에서 재사용
function App() {
return (
<div>
<Button text="저장" onClick={() => console.log('저장')} />
<Button text="취소" onClick={() => console.log('취소')} />
<Button text="삭제" onClick={() => console.log('삭제')} />
</div>
);
}
JSX 문법
JSX는 JavaScript 안에서 마크업을 쓰기 위한 문법입니다.
// JSX
const element = <h1>Hello, {name}!</h1>;
// 컴파일 후 JavaScript (개념적으로)
const element = React.createElement('h1', null, 'Hello, ', name, '!');
React 17 이후의 새 JSX 변환은 React.createElement 대신 react/jsx-runtime의 jsx() 함수를 호출하므로, 예전처럼 파일마다 import React from 'react'를 쓰지 않아도 됩니다. 어느 쪽이든 JSX는 결국 객체를 만드는 함수 호출이라는 점이 중요합니다. 그래서 JSX 안의 {}에는 값이 되는 표현식만 들어갈 수 있고, if나 for 같은 문장은 쓸 수 없어 삼항 연산자, &&, map을 쓰게 됩니다. {count && <p>...</p>}에서 count가 0이면 false가 아니라 숫자 0이 그대로 화면에 찍히는 것도 이 때문이라, 숫자 조건은 {count > 0 && ...}처럼 불리언으로 바꿔 쓰는 것이 안전합니다.
JSX 규칙:
// 1. 하나의 부모 요소로 감싸기
// ❌ 에러
function App() {
return (
<h1>Title</h1>
<p>Content</p>
);
}
// ✅ Fragment 사용
function App() {
return (
<>
<h1>Title</h1>
<p>Content</p>
</>
);
}
// 2. JavaScript 표현식은 {} 안에
function App() {
const name = 'Alice';
const age = 25;
return <p>Name: {name}, Age: {age + 1}</p>;
}
// 3. className 사용 (class는 예약어)
<div className="container">Content</div>
// 4. 자체 닫기 태그
<img src="image.jpg" />
<input type="text" />
Hooks 정리
useState (상태 관리)
useState는 컴포넌트에 상태를 붙입니다.
import { useState } from 'react';
function Counter() {
// [현재값, 업데이트 함수] = useState(초기값)
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={() => setCount(count - 1)}>-1</button>
<button onClick={() => setCount(0)}>Reset</button>
</div>
);
}
여러 상태 관리:
function Form() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);
return (
<form>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Name"
/>
<input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="number"
value={age}
onChange={(e) => setAge(Number(e.target.value))}
placeholder="Age"
/>
</form>
);
}
setCount(count + 1)은 “현재 렌더링 시점의 count”를 기준으로 계산합니다. 한 이벤트 핸들러에서 setCount(count + 1)을 두 번 호출해도 결과가 +1인 이유는, 두 호출 모두 같은 count 값을 보고 있기 때문입니다(상태 갱신은 즉시 반영되지 않고 다음 렌더링에 모아서 반영됩니다). 이전 값에 의존하는 갱신은 setCount(c => c + 1)처럼 함수형으로 쓰면 React가 대기 중인 갱신을 순서대로 적용해 주므로, 비동기 콜백이나 연속 호출에서도 값이 어긋나지 않습니다.
useEffect (부수 효과)
useEffect는 렌더 이후에 돌릴 부수 효과(fetch, 구독 등)를 둔다.
import { useState, useEffect } from 'react';
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 컴포넌트 마운트 시 실행
async function fetchUser() {
setLoading(true);
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
setLoading(false);
}
fetchUser();
}, [userId]); // userId 변경 시 재실행
if (loading) return <p>Loading...</p>;
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
이 예제는 입문용으로 단순화되어 있어 실제로 쓰면 두 가지 문제가 생깁니다. 첫째, userId가 빠르게 바뀌면 이전 요청의 응답이 나중에 도착해 새 사용자 화면에 옛 사용자 데이터가 덮어씌워지는 경쟁 상태가 생깁니다. effect 안에 let ignore = false;를 두고 정리 함수에서 ignore = true로 바꾼 뒤, 응답을 받았을 때 if (!ignore) setUser(data)로 확인하거나 AbortController로 이전 요청을 취소하는 것이 React 공식 문서의 권장 패턴입니다. 둘째, 요청이 실패하면 setLoading(false)가 호출되지 않아 로딩 화면에서 멈추므로 try/finally가 필요합니다. 개발 모드의 Strict Mode는 effect를 일부러 두 번 실행해 이런 정리 누락을 드러내는데, “요청이 두 번 간다”는 질문의 대부분이 이 동작입니다. 실무에서는 이런 처리를 매번 직접 짜기보다 TanStack Query나 SWR, 또는 Next.js의 서버 컴포넌트 데이터 가져오기를 쓰는 편이 낫습니다.
의존성 배열:
// 1. 빈 배열: 마운트 시 1번만 실행
useEffect(() => {
console.log('Component mounted');
}, []);
// 2. 의존성 있음: 의존성 변경 시 실행
useEffect(() => {
console.log('Count changed:', count);
}, [count]);
// 3. 의존성 없음: 매 렌더링마다 실행 (비추천)
useEffect(() => {
console.log('Every render');
});
정리 함수 (Cleanup)
useEffect(() => {
// 타이머 시작
const timer = setInterval(() => {
console.log('Tick');
}, 1000);
// 정리 함수 (컴포넌트 언마운트 시 실행)
return () => {
clearInterval(timer);
};
}, []);
의존성 배열은 “언제 다시 실행할지”를 고르는 옵션이 아니라, effect가 읽는 반응형 값(props, state, 그 값으로 만든 변수)을 빠짐없이 적는 목록입니다. 읽는 값을 빼면 effect가 옛 값을 계속 보는 stale closure 버그가 생기고, eslint-plugin-react-hooks의 exhaustive-deps 규칙이 이를 경고합니다. 경고를 없애려고 배열에서 값을 빼기보다, 그 값이 정말 effect 안에 있어야 하는지(이벤트 핸들러로 옮길 수 있는지)를 먼저 검토하는 것이 맞습니다.
useContext (전역 상태)
Context는 props를 깊게 파지 않고 전역에 가깝게 값을 공유할 때 씁니다.
import { createContext, useContext, useState } from 'react';
// Context 생성
const ThemeContext = createContext();
// Provider 컴포넌트
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<Header />
<Content />
</ThemeContext.Provider>
);
}
// Context 사용
function Header() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<header style={{ background: theme === 'dark' ? '#333' : '#fff' }}>
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Toggle Theme
</button>
</header>
);
}
function Content() {
const { theme } = useContext(ThemeContext);
return (
<div style={{ color: theme === 'dark' ? '#fff' : '#000' }}>
<p>Current theme: {theme}</p>
</div>
);
}
Context는 편하지만 Provider의 value가 바뀌면 그 Context를 읽는 모든 컴포넌트가 다시 렌더링됩니다. 위 코드처럼 value={{ theme, setTheme }}로 객체 리터럴을 넘기면 App이 렌더링될 때마다 새 객체가 만들어져, theme이 그대로여도 소비자들이 다시 렌더링됩니다. 값이 자주 바뀌는 상태(입력값, 마우스 위치)를 Context에 넣으면 앱 전체가 느려지기 쉬우므로, Context는 테마·로그인 사용자·로케일처럼 드물게 바뀌는 값에 쓰고, 필요하면 useMemo로 value를 고정하거나 Context를 용도별로 나누는 것이 일반적입니다.
useMemo와 useCallback (성능 최적화)
useMemo: 계산 결과 캐싱
import { useMemo } from 'react';
function ExpensiveComponent({ numbers }) {
// numbers가 변하지 않으면 재계산 안함
const sum = useMemo(() => {
console.log('Calculating sum...');
return numbers.reduce((a, b) => a + b, 0);
}, [numbers]);
return <p>Sum: {sum}</p>;
}
useCallback: 함수 캐싱
import { useCallback } from 'react';
function Parent() {
const [count, setCount] = useState(0);
// count가 변하지 않으면 같은 함수 재사용
const handleClick = useCallback(() => {
console.log('Clicked:', count);
}, [count]);
return <Child onClick={handleClick} />;
}
useCallback은 자식이 React.memo로 감싸져 있을 때만 의미가 있습니다. 함수를 캐싱해도 Child가 memo가 아니라면 부모가 렌더링될 때 자식은 어차피 다시 렌더링되므로, 비용만 늘어납니다. useMemo도 마찬가지로 계산이 실제로 비쌀 때(수천 개 이상의 정렬·필터 등)나, 결과 객체를 다른 Hook의 의존성으로 쓸 때 쓰는 도구입니다. 처음부터 모든 값을 감싸기보다 React DevTools Profiler로 느린 렌더링을 확인한 뒤 적용하는 것이 순서입니다. 자세한 판단 기준은 useMemo·useCallback은 언제 쓰나에 정리했습니다.
상태 관리
Props vs State
Props (속성)
- 부모 → 자식으로 전달
- 읽기 전용 (변경 불가)
function Parent() {
return <Child name="Alice" age={25} />;
}
function Child({ name, age }) {
// props는 변경 불가
// name = "Bob"; // ❌ 에러
return <p>{name} is {age} years old</p>;
}
State (상태)
- 컴포넌트 내부 데이터
- 변경 가능 (setState)
function Counter() {
const [count, setCount] = useState(0);
// state는 변경 가능
const increment = () => setCount(count + 1);
return <button onClick={increment}>{count}</button>;
}
상태 끌어올리기 (Lifting State Up)
// ❌ 각 컴포넌트가 독립적인 상태
function App() {
return (
<>
<Counter /> {/* count: 0 */}
<Counter /> {/* count: 0 */}
</>
);
}
// ✅ 부모에서 상태 관리
function App() {
const [count, setCount] = useState(0);
return (
<>
<Display count={count} />
<Button setCount={setCount} />
</>
);
}
function Display({ count }) {
return <p>Count: {count}</p>;
}
function Button({ setCount }) {
return <button onClick={() => setCount(c => c + 1)}>+1</button>;
}
상태를 끌어올리는 기준은 단순합니다. 두 컴포넌트가 같은 값을 봐야 하면, 그 둘의 가장 가까운 공통 부모로 상태를 옮기고 값은 props로, 변경은 콜백으로 내려보냅니다. 처음 React를 배울 때 가장 많이 하는 실수가 형제 컴포넌트끼리 값을 맞추려고 각자 state를 두고 useEffect로 서로 동기화하는 것인데, 이러면 렌더링이 한 번 더 일어나고 두 값이 잠깐 어긋나는 순간이 생깁니다. “같은 정보는 한 곳에만 둔다(single source of truth)“가 원칙입니다.
복잡한 상태 관리: useReducer
import { useReducer } from 'react';
// Reducer 함수
function reducer(state, action) {
switch (action.type) {
case 'increment':
return { count: state.count + 1 };
case 'decrement':
return { count: state.count - 1 };
case 'reset':
return { count: 0 };
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: 'increment' })}>+1</button>
<button onClick={() => dispatch({ type: 'decrement' })}>-1</button>
<button onClick={() => dispatch({ type: 'reset' })}>Reset</button>
</div>
);
}
useReducer가 useState보다 나은 경우는 여러 상태가 함께 바뀌거나, 다음 상태가 이전 상태와 행동 종류에 따라 복잡하게 결정될 때입니다. 상태 전이 규칙이 컴포넌트 밖의 순수 함수(reducer)로 빠지기 때문에 UI 없이 단위 테스트할 수 있고, 이벤트 핸들러는 “무슨 일이 있었는지”(dispatch({ type: 'increment' }))만 알리면 됩니다. 주의할 점은 reducer가 새 객체를 반환해야 한다는 것입니다. state.count++; return state;처럼 기존 객체를 수정해 같은 참조를 반환하면 React는 변화가 없다고 판단해 화면을 갱신하지 않습니다.
Next.js 소개
Next.js란?
Next.js는 React 위에 라우팅·빌드·SSR 등을 얹은 풀스택 프레임워크다. React vs Next.js:
React (라이브러리):
- UI만 담당
- 라우팅, SSR 등은 직접 구현
Next.js (프레임워크):
- React + 라우팅 + SSR + 빌드 최적화
- 즉시 프로덕션 준비 완료
Next.js 장점:
✅ 파일 기반 라우팅
✅ SSR (Server-Side Rendering)
✅ SSG (Static Site Generation)
✅ API Routes (백엔드 기능)
✅ 이미지 최적화
✅ 자동 코드 분할
Next.js 시작하기
# Next.js 프로젝트 생성
npx create-next-app@latest my-next-app
# 옵션 선택:
# ✓ TypeScript? Yes
# ✓ ESLint? Yes
# ✓ Tailwind CSS? Yes
# ✓ App Router? Yes
cd my-next-app
npm run dev
# http://localhost:3000 접속
App Router와 Server Components
App Router (Next.js 13+)
파일 기반 라우팅:
app/
├── page.js → /
├── about/
│ └── page.js → /about
├── blog/
│ ├── page.js → /blog
│ └── [slug]/
│ └── page.js → /blog/hello-world
└── api/
└── users/
└── route.js → /api/users
예제: 블로그 페이지 app/blog/page.js
export default function BlogPage() {
return (
<div>
<h1>Blog</h1>
<ul>
<li><a href="/blog/first-post">First Post</a></li>
<li><a href="/blog/second-post">Second Post</a></li>
</ul>
</div>
);
}
app/blog/[slug]/page.js
// Next.js 15부터 params는 Promise이므로 await 필요
export default async function BlogPost({ params }) {
const { slug } = await params;
return (
<div>
<h1>Post: {slug}</h1>
<p>Content goes here...</p>
</div>
);
}
목록 페이지에서 <a href> 대신 next/link의 <Link href="/blog/first-post">를 쓰는 것이 권장됩니다. <a>는 클릭할 때마다 전체 페이지를 새로 불러오지만, Link는 클라이언트 측 이동으로 공통 레이아웃과 상태를 유지하고, 화면에 보이는 링크의 페이지를 미리 가져와(prefetch) 이동이 빠릅니다. 또 Next.js 15에서는 params와 searchParams가 Promise로 바뀌어, 14 이전 예제처럼 const { slug } = params;로 바로 꺼내면 개발 모드에서 동기 접근 경고가 나고 이후 버전에서는 동작하지 않습니다.
Server Components
Server Components는 서버에서만 실행·렌더링되는 컴포넌트다.
// app/posts/page.js (Server Component)
async function getPosts() {
const res = await fetch('https://api.example.com/posts');
return res.json();
}
export default async function PostsPage() {
// 서버에서 데이터 fetch (클라이언트에 전송 안 됨)
const posts = await getPosts();
return (
<div>
<h1>Posts</h1>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
);
}
Client Components (상호작용 필요)
'use client'; // 클라이언트 컴포넌트 선언
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
'use client'는 “이 파일은 브라우저에서만 실행된다”는 뜻이 아니라 서버와 클라이언트의 경계를 여기에 긋는다는 표시입니다. 클라이언트 컴포넌트도 첫 요청에서는 서버에서 한 번 렌더링되어 HTML로 내려가고(그래서 window를 렌더링 중에 바로 쓰면 window is not defined 에러가 납니다), 이후 브라우저에서 hydration되어 상호작용이 붙습니다. 또 'use client' 파일이 import하는 모든 모듈이 클라이언트 번들에 포함되므로, 경계는 가능한 한 트리의 잎(버튼, 입력 폼) 쪽에 두는 것이 번들 크기에 유리합니다. 서버 컴포넌트에서 클라이언트 컴포넌트로 넘기는 props는 직렬화할 수 있어야 해서, 함수나 클래스 인스턴스를 넘기면 에러가 납니다.
언제 무엇을 사용할까?
Server Component 사용:
✅ 데이터 fetch
✅ 백엔드 리소스 접근
✅ 민감한 정보 (API 키)
✅ 큰 의존성 (서버에만 로드)
Client Component 사용:
✅ 상호작용 (onClick, onChange)
✅ useState, useEffect 등 Hooks
✅ 브라우저 API (localStorage, window)
데이터 Fetching
Server Component에서 데이터 가져오기
// app/users/page.js
async function getUsers() {
const res = await fetch('https://api.example.com/users', {
cache: 'no-store' // 항상 최신 데이터
});
return res.json();
}
export default async function UsersPage() {
const users = await getUsers();
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
캐싱 전략
// 1. 캐싱 안함 (항상 최신) — Next.js 15의 기본 동작
fetch(url, { cache: 'no-store' });
// 2. 캐싱 (Next.js 14까지는 기본값, 15부터는 명시해야 함)
fetch(url, { cache: 'force-cache' });
// 3. 재검증 (10초마다)
fetch(url, { next: { revalidate: 10 } });
캐싱 기본값은 Next.js 버전에 따라 반대로 바뀌었기 때문에 인터넷 예제를 그대로 따라 하면 혼란스럽습니다. Next.js 13~14의 App Router는 fetch 결과를 기본으로 캐싱해 “데이터를 바꿨는데 화면이 안 바뀐다”는 질문이 많았고, 15부터는 기본이 캐싱하지 않는 쪽으로 바뀌어 필요한 곳에서만 force-cache나 revalidate를 명시하게 되었습니다. 어떤 버전이든 npm run dev의 개발 서버와 next build && next start의 프로덕션 동작이 다르므로, 캐싱 관련 동작은 반드시 프로덕션 빌드로 확인해야 합니다.
Loading과 Error 처리
app/posts/loading.js
export default function Loading() {
return <p>Loading posts...</p>;
}
app/posts/error.js
'use client';
export default function Error({ error, reset }) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
실전 프로젝트: Todo 앱
프로젝트 구조
app/
├── page.js # 홈 (Todo 목록)
├── layout.js # 레이아웃
└── api/
└── todos/
├── route.js # GET, POST → /api/todos
└── [id]/
└── route.js # PATCH, DELETE → /api/todos/1
구현
app/page.js
'use client';
import { useState, useEffect } from 'react';
export default function TodoApp() {
const [todos, setTodos] = useState([]);
const [input, setInput] = useState('');
// 초기 데이터 로드
useEffect(() => {
fetch('/api/todos')
.then(res => res.json())
.then(data => setTodos(data));
}, []);
// Todo 추가
const addTodo = async () => {
if (!input.trim()) return;
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: input })
});
const newTodo = await response.json();
setTodos([...todos, newTodo]);
setInput('');
};
// Todo 완료 토글
const toggleTodo = async (id) => {
const todo = todos.find(t => t.id === id);
await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !todo.completed })
});
setTodos(todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
};
// Todo 삭제
const deleteTodo = async (id) => {
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
setTodos(todos.filter(t => t.id !== id));
};
return (
<div style={{ maxWidth: '600px', margin: '50px auto' }}>
<h1>Todo App</h1>
{/* 입력 폼 */}
<div style={{ display: 'flex', gap: '10px', marginBottom: '20px' }}>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addTodo()}
placeholder="What needs to be done?"
style={{ flex: 1, padding: '10px' }}
/>
<button onClick={addTodo} style={{ padding: '10px 20px' }}>
Add
</button>
</div>
{/* Todo 목록 */}
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map(todo => (
<li
key={todo.id}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px',
borderBottom: '1px solid #eee'
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#999' : '#000'
}}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}
lib/todos.js (두 라우트가 공유하는 메모리 저장소)
export const db = {
todos: [
{ id: 1, text: 'Learn React', completed: false },
{ id: 2, text: 'Build a project', completed: false }
],
nextId: 3
};
app/api/todos/route.js
import { db } from '@/lib/todos';
// GET /api/todos
export async function GET() {
return Response.json(db.todos);
}
// POST /api/todos
export async function POST(request) {
const { text } = await request.json();
const newTodo = { id: db.nextId++, text, completed: false };
db.todos.push(newTodo);
return Response.json(newTodo, { status: 201 });
}
app/api/todos/[id]/route.js
import { db } from '@/lib/todos';
// PATCH /api/todos/:id
export async function PATCH(request, { params }) {
const { id } = await params; // URL의 [id] 세그먼트 (문자열)
const { completed } = await request.json();
const todo = db.todos.find(t => t.id === Number(id));
if (!todo) return Response.json({ error: 'not found' }, { status: 404 });
todo.completed = completed;
return Response.json(todo);
}
// DELETE /api/todos/:id
export async function DELETE(request, { params }) {
const { id } = await params;
db.todos = db.todos.filter(t => t.id !== Number(id));
return Response.json({ success: true });
}
/api/todos/1 같은 경로는 app/api/todos/route.js 하나로는 처리되지 않고 404가 납니다. App Router에서 URL 세그먼트는 폴더와 1:1로 대응하므로, id가 들어가는 요청은 [id] 동적 폴더의 route.js가 받아야 하고, id는 요청 본문이 아니라 두 번째 인자의 params에서 꺼냅니다. 이 예제는 저장소를 모듈 변수에 두기 때문에 개발 서버가 코드 변경으로 다시 로드되거나, Vercel 같은 서버리스 환경에서 요청이 다른 인스턴스로 가면 데이터가 사라지거나 인스턴스마다 달라집니다. 학습용 이상으로 쓰려면 SQLite, PostgreSQL, KV 저장소 같은 외부 저장소가 필요합니다.
클라이언트 쪽 setTodos([...todos, newTodo])도 한 가지 짚어 둘 부분입니다. await 사이에 다른 갱신이 끼어들면 todos가 오래된 값일 수 있으므로 setTodos(prev => [...prev, newTodo])처럼 함수형 갱신이 안전합니다. 또 서버 응답을 기다린 뒤에야 화면을 바꾸므로 느린 네트워크에서는 체크박스가 늦게 반응하는데, 먼저 화면을 바꾸고 실패하면 되돌리는 낙관적 업데이트(React 19의 useOptimistic)가 이 문제를 다루는 방법입니다.
스타일링
CSS Modules
// Button.module.css
.button {
background-color: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
}
.button:hover {
background-color: #0056b3;
}
// Button.jsx
import styles from './Button.module.css';
export default function Button({ children, onClick }) {
return (
<button className={styles.button} onClick={onClick}>
{children}
</button>
);
}
Tailwind CSS
// tailwind.config.js 설정 후
export default function Button({ children, onClick }) {
return (
<button
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
onClick={onClick}
>
{children}
</button>
);
}
폼 처리
Controlled Components
function LoginForm() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const handleSubmit = (e) => {
e.preventDefault();
console.log('Login:', { email, password });
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Email"
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
/>
<button type="submit">Login</button>
</form>
);
}
React Hook Form (추천)
npm install react-hook-form
import { useForm } from 'react-hook-form';
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => {
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input
{...register('email', {
required: '이메일을 입력하세요',
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: '유효한 이메일을 입력하세요'
}
})}
placeholder="Email"
/>
{errors.email && <p>{errors.email.message}</p>}
<input
type="password"
{...register('password', {
required: '비밀번호를 입력하세요',
minLength: {
value: 8,
message: '8자 이상 입력하세요'
}
})}
placeholder="Password"
/>
{errors.password && <p>{errors.password.message}</p>}
<button type="submit">Login</button>
</form>
);
}
상태 관리 라이브러리
Zustand (추천)
npm install zustand
// store.js
import { create } from 'zustand';
export const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
reset: () => set({ count: 0 })
}));
// Counter.jsx
import { useStore } from './store';
export default function Counter() {
const count = useStore((state) => state.count);
const { increment, decrement, reset } = useStore.getState();
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<button onClick={reset}>Reset</button>
</div>
);
}
Zustand 훅에 선택자(selector) 를 넘기는 이유는 리렌더링 범위 때문입니다. useStore()처럼 인자 없이 호출하면 스토어 전체를 구독하므로, 스토어의 어떤 값이 바뀌어도 이 컴포넌트가 다시 렌더링됩니다. useStore((s) => s.count)로 필요한 값만 고르면 그 값이 바뀔 때만 렌더링되고, 액션 함수는 바뀌지 않으므로 getState()로 꺼내 써도 됩니다. 여러 값을 객체로 한 번에 고르고 싶다면 매번 새 객체가 만들어져 무한 렌더링 경고가 날 수 있으니 useShallow로 감싸야 합니다.
성능 최적화
React.memo (불필요한 리렌더링 방지)
import { memo } from 'react';
// ❌ 부모가 리렌더링되면 자식도 리렌더링
function Child({ name }) {
console.log('Child rendered');
return <p>{name}</p>;
}
// ✅ props가 변하지 않으면 리렌더링 안함
const Child = memo(function Child({ name }) {
console.log('Child rendered');
return <p>{name}</p>;
});
memo는 props를 얕은 비교(Object.is)로 확인하므로, 부모가 렌더링마다 새 객체·배열·함수를 props로 넘기면 비교가 항상 실패해 효과가 없습니다. 앞에서 본 useCallback/useMemo가 필요한 이유가 바로 이 조합 때문입니다. 참고로 React 19와 함께 공개된 React Compiler를 켜면 이런 메모이제이션을 빌드 시점에 자동으로 넣어 주므로, 새 프로젝트라면 수동 memo를 늘리기 전에 검토해 볼 만합니다.
코드 분할 (Lazy Loading)
import { lazy, Suspense } from 'react';
// 동적 import (필요할 때만 로드)
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function App() {
return (
<Suspense fallback={<p>Loading...</p>}>
<HeavyComponent />
</Suspense>
);
}
배포
Vercel 배포 (추천)
# Vercel CLI 설치
npm install -g vercel
# 배포
vercel
# 프로덕션 배포
vercel --prod
환경 변수
# .env.local
NEXT_PUBLIC_API_URL=https://api.example.com
DATABASE_URL=postgresql://...
// 클라이언트에서 접근 (NEXT_PUBLIC_ 접두사 필요)
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
// 서버에서만 접근
const dbUrl = process.env.DATABASE_URL;
NEXT_PUBLIC_ 접두사가 붙은 변수는 빌드 시점에 값이 코드에 문자열로 박혀 브라우저로 내려가는 자바스크립트 번들에 그대로 들어갑니다. 누구나 개발자 도구에서 볼 수 있으므로 API 비밀 키, DB 접속 정보는 절대 이 접두사를 붙이면 안 됩니다. 반대로 접두사가 없는 변수를 클라이언트 컴포넌트에서 읽으면 에러 없이 undefined가 되어, “로컬에서는 되는데 화면에서 값이 비어 있다”는 증상으로 나타납니다. 또 빌드 시점에 고정되므로 배포 후 환경 변수를 바꿔도 다시 빌드하기 전까지는 반영되지 않습니다.
FAQ
Q1. React vs Vue vs Angular?
- React: 가장 인기, 생태계 방대, 자유도 높음
- Vue: 배우기 쉬움, 한국 커뮤니티 활발
- Angular: 대규모 엔터프라이즈, 학습 곡선 높음 Q2. Next.js를 꼭 써야 하나? 아니다. 다만 SEO·SSR·파일 기반 라우팅까지 한 번에 가져가고 싶으면 Next.js가 선택지가 됩니다. Vite+React만으로도 충분한 프로젝트가 많습니다.
Q3. TypeScript를 써야 하나? 팀·코드베이스 규모가 커질수록 타입이 도움이 됩니다. 자동 완성·리팩터링 안전성이 체감됩니다. 작은 실험만 할 때는 JavaScript로 시작해도 됩니다.
Q4. 상태 관리 라이브러리가 필요한가요? 프로젝트 규모에 따라:
- 소규모: useState + Context API
- 중규모: Zustand
- 대규모: Redux Toolkit
같이 보면 좋은 글
- Svelte 입문: 컴파일러 기반 반응성, 컴포넌트, 스토어, 트랜지션, SvelteKit
- styled-components 사용법
- CSS 애니메이션 | Transition, Animation, Transform