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 | 측정 먼저, 최적화는 그 다음 |