React 심화 — Hooks 동작 원리, 상태 관리, 성능 최적화
React Hooks가 내부적으로 어떻게 동작하는지, 상태 관리는 어떤 기준으로 고르는지, 렌더링 성능은 어떻게 잡는지 — 자주 헷갈리는 주제들을 한 곳에 모았습니다.
Hooks 규칙 — 왜 조건문 안에서 못 쓰나
React Hooks에는 두 가지 절대 규칙이 있어요.
- 최상위(top level)에서만 호출할 것 — 반복문, 조건문, 중첩 함수 안에서 호출 금지
- React 함수 컴포넌트 또는 커스텀 Hook 안에서만 호출할 것
이 규칙이 왜 존재하는지 이해하려면, React가 Hooks를 내부적으로 어떻게 저장하는지 알아야 합니다.
Linked List 기반 구현
React는 각 컴포넌트의 Hooks를 호출 순서 에 의존하는 연결 리스트(linked list)로 관리합니다. 컴포넌트가 처음 마운트될 때 Hook이 호출되면, React는 내부적으로 { memoizedState, next } 형태의 노드를 생성해서 순서대로 연결해요.
Hook 1 (useState) → Hook 2 (useEffect) → Hook 3 (useMemo) → null
재렌더링 시에는 같은 순서로 이 리스트를 순회하면서 각 Hook의 상태를 꺼내옵니다. 그런데 만약 조건문 안에 Hook이 있으면 어떻게 될까요?
// 절대 이렇게 하면 안 된다
function BadComponent({ isLoggedIn }) {
const [name, setName] = useState('');
if (isLoggedIn) {
useEffect(() => {
fetchProfile();
}, []);
}
const [count, setCount] = useState(0);
// ...
}
isLoggedIn이 true일 때와 false일 때 Hook 호출 순서가 달라집니다. React는 "세 번째로 호출된 Hook은 useState(0)이겠지"라고 가정하고 리스트를 순회하는데, 조건에 따라 두 번째가 될 수도 있으니 상태가 엉키게 되는 거예요.
// 올바른 방법 — Hook은 항상 호출하되, 내부에서 분기
function GoodComponent({ isLoggedIn }) {
const [name, setName] = useState('');
useEffect(() => {
if (isLoggedIn) {
fetchProfile();
}
}, [isLoggedIn]);
const [count, setCount] = useState(0);
}
핵심 포인트: "React가 Hook을 linked list로 관리하고 호출 순서에 의존하기 때문에, 조건부 호출 시 인덱스가 어긋나서 상태 불일치가 발생합니다."
useState — 상태 업데이트 배칭과 함수형 업데이트
기본 사용
const [count, setCount] = useState(0);
간단해 보이지만, 내부 동작을 모르면 실수하기 쉽습니다.
배칭 (Batching)
React 18부터 자동 배칭(Automatic Batching) 이 도입됐습니다. 이전에는 이벤트 핸들러 안에서만 배칭이 됐는데, 이제는 setTimeout, Promise, 네이티브 이벤트 핸들러 등 어디서든 배칭돼요.
function handleClick() {
setCount(c => c + 1); // 리렌더링 안 함
setFlag(f => !f); // 리렌더링 안 함
setName('React'); // 여기까지 모아서 한 번만 리렌더링
}
배칭을 강제로 풀고 싶다면 flushSync를 쓸 수 있지만, 정말 특수한 경우 아니면 쓸 일 없습니다.
import { flushSync } from 'react-dom';
function handleClick() {
flushSync(() => {
setCount(c => c + 1);
});
// 여기서 이미 DOM 업데이트 완료
flushSync(() => {
setFlag(f => !f);
});
}
함수형 업데이트
이전 상태를 기반으로 새 상태를 계산할 때는 반드시 함수형 업데이트를 써야 합니다.
// 잘못된 예 — 같은 값을 세 번 set하는 셈
function handleTripleClick() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// count가 0이었으면, 결과는 1 (3 아님)
}
// 올바른 예 — 이전 상태를 받아서 계산
function handleTripleClick() {
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
// count가 0이었으면, 결과는 3
}
배칭이 되더라도 함수형 업데이트는 큐에 쌓여서 순서대로 실행됩니다. count + 1은 클로저 시점의 count 값을 캡처하기 때문에 세 번 호출해도 같은 값으로 세팅되는 거예요.
핵심 포인트:
setState는 비동기적으로 동작하며, React 18부터는 모든 컨텍스트에서 배칭된다. 이전 상태 기반 업데이트는 함수형 업데이트를 사용해야 정확하다.
useEffect — 의존성 배열, cleanup, 실행 타이밍
기본 구조
useEffect(() => {
// Side effect 로직
const subscription = someAPI.subscribe(data);
return () => {
// Cleanup 함수
subscription.unsubscribe();
};
}, [dependency1, dependency2]);
의존성 배열의 세 가지 형태
// 1. 매 렌더링마다 실행
useEffect(() => { /* ... */ });
// 2. 마운트 시 한 번만 실행
useEffect(() => { /* ... */ }, []);
// 3. 특정 값이 변경될 때 실행
useEffect(() => { /* ... */ }, [userId, page]);
빈 배열 []을 넣으면 마운트 시에만 실행되는데, 이건 클래스 컴포넌트의 componentDidMount와 비슷하면서도 다릅니다. useEffect의 클로저는 마운트 시점의 props/state를 캡처하기 때문에, 이후 변경된 값을 참조하지 못해요.
Cleanup 실행 순서
cleanup은 언제 실행될까요? 이 부분을 헷갈려 하는 경우가 많습니다.
useEffect(() => {
console.log('effect 실행: ', count);
return () => {
console.log('cleanup 실행: ', count);
};
}, [count]);
count가 0 → 1 → 2로 바뀔 때 콘솔 출력 순서:
effect 실행: 0
cleanup 실행: 0 ← 이전 effect의 cleanup이 먼저
effect 실행: 1
cleanup 실행: 1
effect 실행: 2
cleanup은 다음 effect가 실행되기 직전 에, 이전 렌더링 시점의 값 으로 호출됩니다. 언마운트 시에도 마지막 cleanup이 호출돼요.
실행 타이밍 — paint 이후
useEffect는 브라우저가 화면을 그린 후(paint 이후) 비동기적으로 실행됩니다. 렌더 → 커밋 → 브라우저 페인트 → useEffect 순서예요. 그래서 useEffect 안에서 DOM을 읽어도 사용자는 이미 화면을 보고 있는 상태라, 깜빡임(flicker)이 발생할 수 있습니다.
useLayoutEffect vs useEffect
렌더링 → DOM 업데이트 → useLayoutEffect → 브라우저 paint → useEffect
| 구분 | useEffect | useLayoutEffect |
|---|---|---|
| 실행 시점 | paint 이후 | paint 이전 |
| 동기/비동기 | 비동기 | 동기 |
| 용도 | 데이터 fetch, 구독, 로깅 | DOM 측정, 레이아웃 계산 |
| 주의점 | 일반적인 side effect | 여기서 오래 걸리면 화면이 멈춤 |
function Tooltip({ text, targetRef }) {
const [position, setPosition] = useState({ top: 0, left: 0 });
// useLayoutEffect를 쓰는 대표적인 케이스
// DOM 측정 후 위치를 잡아야 깜빡임 없이 표시됨
useLayoutEffect(() => {
const rect = targetRef.current.getBoundingClientRect();
setPosition({
top: rect.bottom + 8,
left: rect.left,
});
}, [targetRef]);
return (
<div style={{ position: 'absolute', ...position }}>
{text}
</div>
);
}
useEffect를 썼다면 tooltip이 (0, 0)에 한 프레임 보였다가 올바른 위치로 이동하는 깜빡임이 생깁니다. 하지만 99%의 경우 useEffect로 충분하고, useLayoutEffect는 정말 DOM 레이아웃을 측정해서 즉시 반영해야 할 때만 쓰면 돼요.
useRef — DOM 참조, 값 유지
useRef가 반환하는 객체는 { current: initialValue } 형태입니다. 이 current 값을 바꿔도 리렌더링이 발생하지 않는다 는 게 핵심이에요.
DOM 참조
function TextInput() {
const inputRef = useRef(null);
const handleFocus = () => {
inputRef.current.focus();
};
return (
<>
<input ref={inputRef} />
<button onClick={handleFocus}>포커스</button>
</>
);
}
리렌더링 없이 값 유지
useState와 달리 값이 바뀌어도 컴포넌트가 다시 그려지지 않습니다. 타이머 ID, 이전 값 저장, 렌더링 횟수 추적 같은 용도에 적합해요.
function Timer() {
const intervalRef = useRef(null);
const [seconds, setSeconds] = useState(0);
const start = () => {
if (intervalRef.current !== null) return;
intervalRef.current = setInterval(() => {
setSeconds(s => s + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
intervalRef.current = null;
};
useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);
return (
<div>
<p>{seconds}초</p>
<button onClick={start}>시작</button>
<button onClick={stop}>정지</button>
</div>
);
}
이전 값 저장 패턴
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
});
return ref.current;
}
function Counter() {
const [count, setCount] = useState(0);
const prevCount = usePrevious(count);
return (
<p>
현재: {count}, 이전: {prevCount}
</p>
);
}
useEffect는 렌더링 이후에 실행되므로, ref.current를 반환하는 시점에는 아직 이전 값이 들어있고, 렌더링이 끝나면 새 값으로 업데이트됩니다. 이 타이밍 차이를 이용한 패턴이에요.
useMemo vs useCallback — 언제 써야 하나
둘 다 메모이제이션이지만, 대상이 다릅니다.
// useMemo — 값을 메모이제이션
const sortedList = useMemo(() => {
return items.sort((a, b) => a.price - b.price);
}, [items]);
// useCallback — 함수를 메모이제이션
const handleClick = useCallback((id) => {
setSelected(id);
}, []);
useMemo(() => fn, deps)와 useCallback(fn, deps)는 사실상 같은 것입니다. useCallback은 useMemo의 함수 버전 단축형일 뿐이에요.
언제 써야 하나
여기서 많은 분들이 실수하는데, 무조건 쓰는 건 오히려 성능을 해칩니다.
쓸 필요 없는 경우:
- 단순한 계산 (배열 필터링 몇 개, 문자열 조합 등)
- 자식에게 전달하지 않는 콜백
- 이미 충분히 빠른 컴포넌트
쓸 가치가 있는 경우:
React.memo로 감싼 자식에게 전달하는 콜백 (참조 동일성 유지)- 정말 무거운 계산 (수천 개 아이템 정렬, 복잡한 필터링)
- 다른 Hook의 의존성 배열에 들어가는 값/함수
// 이건 과하다 — useMemo의 비교 비용이 오히려 더 클 수 있음
const greeting = useMemo(() => `안녕 ${name}`, [name]);
// 이건 의미가 있다 — 자식 컴포넌트의 불필요한 리렌더링 방지
const MemoizedChild = React.memo(ChildComponent);
const handleSubmit = useCallback(() => {
submitForm(formData);
}, [formData]);
return <MemoizedChild onSubmit={handleSubmit} />;
과도한 메모이제이션은 코드 복잡도만 올리고 실제 성능 개선 효과는 미미하다. React 공식 문서에서도 "먼저 프로파일링하고, 병목이 확인되면 그때 적용하라"고 권장한다.
useReducer — 복잡한 상태 로직
useState로 감당이 안 될 정도로 상태 전환 로직이 복잡해지면 useReducer가 답입니다.
const initialState = {
status: 'idle', // 'idle' | 'loading' | 'success' | 'error'
data: null,
error: null,
};
function fetchReducer(state, action) {
switch (action.type) {
case 'FETCH_START':
return { ...state, status: 'loading', error: null };
case 'FETCH_SUCCESS':
return { status: 'success', data: action.payload, error: null };
case 'FETCH_ERROR':
return { status: 'error', data: null, error: action.payload };
case 'RESET':
return initialState;
default:
throw new Error(`알 수 없는 액션: ${action.type}`);
}
}
function UserProfile({ userId }) {
const [state, dispatch] = useReducer(fetchReducer, initialState);
useEffect(() => {
dispatch({ type: 'FETCH_START' });
fetchUser(userId)
.then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
.catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message }));
}, [userId]);
if (state.status === 'loading') return <Spinner />;
if (state.status === 'error') return <Error message={state.error} />;
if (state.status === 'success') return <Profile data={state.data} />;
return null;
}
useState vs useReducer 선택 기준
| 상황 | 추천 |
|---|---|
| 독립적인 상태 1~2개 | useState |
| 서로 연관된 상태 여러 개 | useReducer |
| 상태 전환에 비즈니스 로직이 필요 | useReducer |
| 다음 상태가 이전 상태에 의존 | useReducer |
useReducer의 dispatch는 리렌더링 사이에 identity가 변하지 않아서, 의존성 배열에 넣어도 불필요한 재실행을 일으키지 않는다는 장점도 있습니다.
Custom Hook — 로직 재사용, 관심사 분리
Custom Hook은 use로 시작하는 함수일 뿐이지만, 컴포넌트 로직을 재사용 가능한 단위로 추출할 수 있게 해줍니다.
활용 예시: API 호출 Hook
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, { signal: controller.signal })
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') {
setError(err.message);
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// 사용
function UserList() {
const { data: users, loading, error } = useFetch('/api/users');
if (loading) return <Spinner />;
if (error) return <p>에러: {error}</p>;
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
활용 예시: 디바운스 Hook
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 검색에 적용
function SearchInput() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
useEffect(() => {
if (debouncedQuery) {
searchAPI(debouncedQuery);
}
}, [debouncedQuery]);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
활용 예시: 로컬스토리지 상태 동기화
function useLocalStorage(key, initialValue) {
const [storedValue, setStoredValue] = useState(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback((value) => {
const valueToStore = value instanceof Function ? value(storedValue) : value;
setStoredValue(valueToStore);
localStorage.setItem(key, JSON.stringify(valueToStore));
}, [key, storedValue]);
return [storedValue, setValue];
}
Custom Hook의 핵심은 관심사 분리 입니다. 컴포넌트는 "무엇을 보여줄 것인가"에만 집중하고, "데이터를 어떻게 가져오고 관리할 것인가"는 Hook에 위임하는 구조예요.
상태 관리 — Context API 한계와 라이브러리 비교
Context API의 한계
Context는 원래 상태 관리 도구가 아닙니다. 의존성 주입(DI) 메커니즘이에요. 테마, 로케일, 인증 정보처럼 자주 바뀌지 않는 값을 전달하기에는 좋지만, 빈번하게 변하는 상태를 넣으면 문제가 생깁니다.
const AppContext = React.createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
return (
<AppContext.Provider value={{ user, theme, notifications, setUser, setTheme, setNotifications }}>
{children}
</AppContext.Provider>
);
}
이렇게 하나의 Context에 여러 상태를 넣으면, notifications만 바뀌어도 theme만 쓰는 컴포넌트까지 전부 리렌더링됩니다. Provider의 value가 새 객체로 바뀌기 때문이에요.
해결법은 Context를 쪼개거나, useMemo로 value를 감싸는 것이지만, 상태가 많아지면 Provider 지옥(Provider Hell)이 됩니다.
// Provider 지옥
<AuthProvider>
<ThemeProvider>
<NotificationProvider>
<CartProvider>
<App />
</CartProvider>
</NotificationProvider>
</ThemeProvider>
</AuthProvider>
라이브러리 비교
| 항목 | Redux (Toolkit) | Zustand | Jotai | Recoil |
|---|---|---|---|---|
| 철학 | Flux, 단일 스토어 | 단일 스토어, 간결한 API | atomic, bottom-up | atomic, React 네이티브 |
| 보일러플레이트 | 중간 (RTK로 줄어듦) | 매우 적음 | 매우 적음 | 적음 |
| 번들 크기 | ~11kB (RTK) | ~1.5kB | ~3kB | ~20kB |
| DevTools | 강력함 | Redux DevTools 연동 | 별도 | 별도 |
| 비동기 처리 | RTK Query, thunk | 내장 | 내장 | selector |
| 학습 곡선 | 높음 | 낮음 | 낮음 | 중간 |
| React 외부 사용 | 가능 | 가능 | 불가 | 불가 |
Zustand 예시
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
reset: () => set({ count: 0 }),
}));
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
셀렉터로 필요한 상태만 구독하기 때문에 불필요한 리렌더링이 없습니다. Provider도 필요 없고, 코드량도 확연히 줄어들어요.
Jotai 예시
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
const doubleAtom = atom((get) => get(countAtom) * 2); // 파생 atom
function Counter() {
const [count, setCount] = useAtom(countAtom);
const [doubled] = useAtom(doubleAtom);
return (
<div>
<p>{count} x 2 = {doubled}</p>
<button onClick={() => setCount(c => c + 1)}>+1</button>
</div>
);
}
Jotai는 atom 단위로 구독이 이뤄지니까, 해당 atom을 쓰는 컴포넌트만 리렌더링됩니다. useState 감각으로 쓸 수 있어서 러닝 커브가 거의 없어요.
어떤 걸 써야 하나
- 규모가 크고 팀이 많다면: Redux Toolkit — 패턴이 정해져 있어 일관성 유지에 유리
- 빠르게 만들고 싶다면: Zustand — 보일러플레이트 최소, 직관적
- 컴포넌트 단위 상태가 많다면: Jotai — atom 기반이라 세밀한 구독 가능
- Recoil: Meta에서 만들었지만 업데이트가 느려서 신규 프로젝트에는 추천하기 어렵습니다
렌더링 최적화
리렌더링이 발생하는 조건
- state가 변경 됐을 때
- props가 변경 됐을 때
- 부모 컴포넌트가 리렌더링 됐을 때 — 이게 가장 큰 원인
- Context value가 변경 됐을 때
3번이 핵심입니다. 부모가 리렌더링되면 자식은 props가 안 바뀌어도 무조건 다시 그려져요.
React.memo
const ExpensiveList = React.memo(function ExpensiveList({ items, onSelect }) {
console.log('ExpensiveList 렌더링');
return (
<ul>
{items.map(item => (
<li key={item.id} onClick={() => onSelect(item.id)}>
{item.name}
</li>
))}
</ul>
);
});
React.memo는 props를 얕은 비교(shallow comparison)해서, 바뀌지 않았으면 리렌더링을 건너뜁니다. 그런데 주의할 점이 있어요.
function Parent() {
const [count, setCount] = useState(0);
// 이러면 React.memo가 무력화됨 — 매 렌더링마다 새 함수 생성
const handleSelect = (id) => console.log(id);
// 이러면 React.memo가 무력화됨 — 매 렌더링마다 새 배열 생성
const items = data.filter(d => d.active);
return (
<ExpensiveList items={items} onSelect={handleSelect} />
);
}
콜백은 useCallback, 계산 결과는 useMemo로 감싸야 React.memo가 제대로 동작합니다.
function Parent() {
const [count, setCount] = useState(0);
const handleSelect = useCallback((id) => {
console.log(id);
}, []);
const items = useMemo(() => {
return data.filter(d => d.active);
}, [data]);
return (
<ExpensiveList items={items} onSelect={handleSelect} />
);
}
컴포넌트 분리 전략
메모이제이션보다 효과적인 건 컴포넌트 구조를 잘 잡는 것 입니다.
// 나쁜 구조 — 마우스 위치가 바뀔 때마다 HeavyComponent도 리렌더링
function Page() {
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e) => setMousePos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);
return (
<div>
<p>마우스: {mousePos.x}, {mousePos.y}</p>
<HeavyComponent />
</div>
);
}
// 좋은 구조 — 마우스 추적을 별도 컴포넌트로 분리
function MouseTracker() {
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
useEffect(() => {
const handler = (e) => setMousePos({ x: e.clientX, y: e.clientY });
window.addEventListener('mousemove', handler);
return () => window.removeEventListener('mousemove', handler);
}, []);
return <p>마우스: {mousePos.x}, {mousePos.y}</p>;
}
function Page() {
return (
<div>
<MouseTracker />
<HeavyComponent />
</div>
);
}
상태를 가진 부분만 따로 빼면, React.memo나 useMemo 없이도 불필요한 리렌더링을 막을 수 있어요.
또 하나 잘 쓰이는 패턴이 children을 활용한 상태 격리 입니다.
function ScrollTracker({ children }) {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const handler = () => setScrollY(window.scrollY);
window.addEventListener('scroll', handler);
return () => window.removeEventListener('scroll', handler);
}, []);
return (
<div>
<header style={{ opacity: scrollY > 100 ? 0.5 : 1 }}>헤더</header>
{children}
</div>
);
}
// children은 부모에서 이미 생성된 엘리먼트이므로
// ScrollTracker가 리렌더링돼도 children은 다시 생성되지 않는다
function App() {
return (
<ScrollTracker>
<HeavyContent />
</ScrollTracker>
);
}
children은 ScrollTracker가 아니라 App에서 생성된 React element이기 때문에, ScrollTracker의 state 변경에 영향받지 않습니다.
React DevTools Profiler 활용
성능 최적화는 감이 아니라 측정에서 시작해야 합니다.
Profiler 기본 사용법
- React DevTools 설치 (크롬 확장)
- Profiler 탭 선택
- 녹화 시작 → 앱 조작 → 녹화 종료
- Flamegraph 에서 각 컴포넌트의 렌더링 시간 확인
체크할 것들
- 회색 컴포넌트: 렌더링되지 않음 (좋은 것)
- 노란색/빨간색 컴포넌트: 렌더링 시간이 긴 컴포넌트 (최적화 대상)
- "Why did this render?": 설정에서 켜면 리렌더링 원인을 알려줍니다
Profiler 컴포넌트로 코드에서 측정
import { Profiler } from 'react';
function onRenderCallback(
id, // Profiler 트리의 id
phase, // "mount" | "update"
actualDuration, // 렌더링에 걸린 시간 (ms)
baseDuration, // 메모이제이션 없이 걸리는 예상 시간
startTime,
commitTime
) {
if (actualDuration > 16) { // 60fps 기준 한 프레임 16ms
console.warn(`느린 렌더링: ${id} — ${actualDuration.toFixed(2)}ms`);
}
}
function App() {
return (
<Profiler id="UserList" onRender={onRenderCallback}>
<UserList />
</Profiler>
);
}
주의할 점
Suspense와 lazy loading
import { lazy, Suspense } from 'react';
const Dashboard = lazy(() => import('./Dashboard'));
const Settings = lazy(() => import('./Settings'));
function App() {
return (
<Suspense fallback={<Spinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
);
}
lazy는 동적 import()를 감싸서 해당 컴포넌트가 실제로 필요할 때만 번들을 로드합니다. Suspense는 그 로딩 중에 fallback UI를 보여주는 경계(boundary) 역할이에요.
React 18부터는 데이터 fetching에도 Suspense를 쓸 수 있게 되고 있습니다. 아직 완전한 공식 API는 아니지만, 프레임워크(Next.js 등)에서는 이미 활용 중이에요.
Error Boundary
Error Boundary는 클래스 컴포넌트 로만 구현할 수 있습니다. getDerivedStateFromError와 componentDidCatch를 사용해요.
class ErrorBoundary extends React.Component {
state = { hasError: false, error: null };
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// 에러 리포팅 서비스로 전송
logErrorToService(error, errorInfo);
}
render() {
if (this.state.hasError) {
return (
<div>
<h2>문제가 발생했습니다</h2>
<button onClick={() => this.setState({ hasError: false, error: null })}>
다시 시도
</button>
</div>
);
}
return this.props.children;
}
}
// 사용
<ErrorBoundary>
<UserProfile />
</ErrorBoundary>
주의: Error Boundary는 렌더링 중 발생한 에러 만 잡습니다. 이벤트 핸들러, 비동기 코드(setTimeout, fetch), 서버 사이드 렌더링에서 발생한 에러는 잡지 못해요.
실제로는 react-error-boundary 라이브러리를 쓰면 함수형 컴포넌트에서도 편하게 쓸 수 있습니다.
Server Components vs Client Components
React Server Components(RSC)는 React 18에서 도입된 개념으로, Next.js 13+ App Router에서 본격적으로 쓰이고 있습니다.
| 구분 | Server Component | Client Component |
|---|---|---|
| 실행 환경 | 서버에서만 | 브라우저 (+ 서버 SSR) |
| 번들 포함 | 미포함 | 포함 |
| 상태/이벤트 | 사용 불가 (useState, onClick 등) | 사용 가능 |
| DB/파일 접근 | 직접 가능 | API를 통해서만 |
| 선언 방법 | 기본값 (아무 지시어 없으면) | 'use client' 선언 |
// ServerComponent.jsx — 기본값이 Server Component
async function UserList() {
// 서버에서 직접 DB 쿼리 가능
const users = await db.query('SELECT * FROM users');
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);
}
// ClientComponent.jsx — 'use client' 지시어 필요
'use client';
import { useState } from 'react';
function LikeButton() {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? '좋아요 취소' : '좋아요'}
</button>
);
}
Server Component의 핵심 장점은 번들 크기 제로 입니다. 서버에서 렌더링되고 결과(RSC Payload)만 클라이언트로 전달되니까, 해당 컴포넌트의 JavaScript는 브라우저에 전송되지 않아요.
핵심 포인트: "Server Component는 JavaScript 번들에 포함되지 않아서 초기 로딩 성능에 유리하고, DB 접근 같은 서버 작업을 컴포넌트 안에서 직접 수행할 수 있습니다. 대신 상태나 이벤트 핸들러는 사용할 수 없어서, 인터랙션이 필요한 부분은 Client Component로 분리해야 합니다."
파생 개념 연결
이 글에서 다룬 내용과 연결되는 주제들:
- Virtual DOM과 Reconciliation — React가 어떻게 변경 사항을 감지하고 실제 DOM에 반영하는지. Fiber 아키텍처와 diffing 알고리즘을 이해하면 렌더링 최적화의 근거가 명확해진다.
- 웹 성능 최적화 — Core Web Vitals (LCP, FID, CLS), 코드 스플리팅, 이미지 최적화, 번들 분석 등 React 바깥에서의 성능 개선.
- Next.js와 SSR/SSG — Server Components의 실제 활용 환경. SSR, SSG, ISR의 차이와 각각의 적합한 사용 사례.
정리
| 주제 | 핵심 키워드 |
|---|---|
| Hooks 규칙 | linked list, 호출 순서 의존 |
| useState | 배칭, 함수형 업데이트, 클로저 |
| useEffect | paint 이후, cleanup 타이밍, 의존성 |
| useLayoutEffect | paint 이전, DOM 측정 |
| useRef | 리렌더링 없이 값 유지, .current |
| useMemo/useCallback | 참조 동일성, 과도한 사용 주의 |
| useReducer | 복잡한 상태 전환, dispatch identity |
| Custom Hook | 로직 재사용, 관심사 분리 |
| 상태 관리 | Context 한계, Zustand/Jotai 추천 |
| 렌더링 최적화 | 컴포넌트 분리 > memo > 메모이제이션 |
| Profiler | 측정 먼저, 최적화는 그 다음 |