- Published on
React Fiber 내부 완전 가이드 2025: Reconciler, Scheduler, Concurrent Rendering, Hooks 심층 분석
- Authors

- Name
- Youngju Kim
- @fjvbn20031
들어가며: 가장 많이 쓰이는 라이브러리의 내부
React의 지배력
2013년 Facebook이 공개한 React는 프런트엔드의 표준이 되었다:
- npm 다운로드: 주당 수천만 건.
- Stack Overflow: 가장 많이 질문되는 프레임워크.
- 사용하는 사이트: 수백만 개.
- 기업 채택: Meta, Netflix, Airbnb, Uber, Microsoft, 그리고 수많은 스타트업.
하지만 대부분의 React 개발자는 내부를 모른다. useState가 어떻게 작동하는지, useEffect의 cleanup이 언제 실행되는지, concurrent mode가 정확히 무엇을 하는지.
이 지식 없이도 React를 쓸 수 있다. 하지만 깊은 이해가 있으면:
- 디버깅이 쉬워진다.
- 성능 최적화가 정확해진다.
- 버그 패턴을 미리 피한다.
- 더 좋은 코드를 쓴다.
이 글에서 다룰 것
- 가상 DOM (Virtual DOM): React의 기본 아이디어.
- Reconciliation: 어떻게 차이를 찾나.
- Fiber 아키텍처: React 16의 대변화.
- Scheduler: 언제 무엇을 할지.
- Concurrent Rendering (React 18): Time slicing, Suspense.
- Hooks의 내부: 어떻게 클로저로 구현되나.
- **React 19 **: Compiler, Server Components.
- 성능 최적화: useMemo, useCallback, Memoization.
1. Virtual DOM: React의 시작점
DOM의 문제
DOM (Document Object Model): 브라우저가 HTML을 표현하는 객체 트리.
DOM 조작의 비용:
- 느림: CSS 재계산, 레이아웃, 페인트.
- 직접 조작 복잡:
document.getElementById,innerHTML등. - State 관리 어려움: UI와 state 동기화.
2013년 이전:
- jQuery가 주류.
- 직접 DOM 조작.
- State를 DOM에 저장 (혼란).
React의 아이디어
"UI를 함수처럼 다루자":
- State가 변하면 새 UI.
- 사용자는 UI 선언만, 어떻게 바꿀지는 React가.
Virtual DOM이 이를 가능하게 한다:
- 가상 DOM 트리: JavaScript 객체로 UI 표현.
- State 변경 시 새 가상 DOM 생성.
- 이전 가상 DOM과 비교 (diffing).
- 차이만 실제 DOM에 적용.
예시
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<h1>Count: {count}</h1>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
}
처음:
Virtual DOM:
div
h1: "Count: 0"
button: "+"
이것을 실제 DOM으로 렌더.
클릭 후:
새 Virtual DOM:
div
h1: "Count: 1" ← 변경
button: "+" ← 동일
Diff: h1의 textContent만 바뀜.
실제 DOM 업데이트: h1.textContent = "Count: 1" 만.
Result: 효율적, 선언적, 관리 쉬움.
JSX
JSX: React의 "HTML 같은" 문법.
const element = <h1>Hello</h1>;
Babel이 다음으로 컴파일:
const element = React.createElement('h1', null, 'Hello');
React.createElement의 결과:
{
type: 'h1',
props: { children: 'Hello' },
key: null,
ref: null,
// ...
}
이것이 virtual DOM 노드. 평범한 JavaScript 객체.
2. Reconciliation: 어떻게 비교하나
완벽한 diff는 비싸다
두 트리를 완벽히 비교하는 알고리즘: O(n^3). 수천 노드가 있으면 수 초.
React의 가정들:
1. 다른 타입 → 완전히 다른 트리:
// Before
<div><Counter /></div>
// After
<span><Counter /></span>
div vs span. React는 전체 트리 재구성. Counter도 unmount 후 새로 mount.
왜?: 그런 변화는 드물고, 정확한 diff는 너무 비쌈.
2. Key로 리스트 항목 식별:
{items.map(item => <li key={item.id}>{item.name}</li>)}
Key가 안정적 ID 역할. React가 항목을 추적 가능.
Key 없으면: 인덱스 기반. 삽입/삭제 시 문제.
Diff Algorithm의 복잡도
위 가정들로 O(n) 에 근접. 실용적.
Reconciliation 단계
1. Type 비교:
- 같은 type: 속성만 업데이트.
- 다른 type: 전체 재구성.
2. Props 비교:
- 변경된 props만 적용.
- 이벤트 리스너 추가/제거.
3. Children 비교:
- Key 기반 매칭.
- 순서 변경 감지.
예시: Key의 중요성
Key 없음:
<ul>
<li>Apple</li>
<li>Banana</li>
</ul>
// 앞에 추가:
<ul>
<li>Cherry</li>
<li>Apple</li>
<li>Banana</li>
</ul>
React는 인덱스 기반:
- 0: Apple → Cherry (update)
- 1: Banana → Apple (update)
- 2: (새로 추가) Banana
3개 update/create.
Key 있음:
<ul>
<li key="a">Apple</li>
<li key="b">Banana</li>
</ul>
// 앞에 추가:
<ul>
<li key="c">Cherry</li>
<li key="a">Apple</li>
<li key="b">Banana</li>
</ul>
React는 key 기반:
- "c": 새로 생성.
- "a": 이동만.
- "b": 이동만.
1개만 create. 훨씬 효율적.
특히 중요한 경우:
- 큰 리스트.
- 내부 state가 있는 컴포넌트.
- Animation.
3. Fiber 아키텍처 (React 16)
기존 Reconciler의 문제
React 15 이전의 "Stack Reconciler":
render() {
// 1. 전체 트리 diff
// 2. 전체 트리 업데이트
// 3. 완료까지 차단
}
문제: 중단 불가. 큰 트리 업데이트 시 수백 ms 블로킹. 애니메이션 끊김.
브라우저 프레임: 16.67 ms (60fps). 그 안에 모든 작업 끝내야 부드러움.
React 15: 큰 업데이트면 16ms 초과 → 프레임 드롭.
Fiber의 해결
React 16 (2017): Fiber Reconciler 도입.
핵심 변경:
- 작업을 작은 단위로 쪼갬.
- 각 단위 실행 후 브라우저에 양보.
- 우선순위 높은 작업 (사용자 입력 등)을 먼저.
- 중단과 재개 가능.
결과: 부드러운 UI, 응답성 향상.
Fiber란
Fiber: 작업 단위를 표현하는 JavaScript 객체.
{
type: 'div',
key: null,
props: { ... },
stateNode: (실제 DOM 노드),
// Tree 구조
return: parent_fiber,
child: first_child_fiber,
sibling: next_sibling_fiber,
// 작업 정보
pendingProps: newProps,
memoizedProps: oldProps,
effectTag: Update,
// Alternate: 이전 버전
alternate: previousFiber,
// ... 더 많은 필드
}
Fiber 트리는 virtual DOM 트리에 대응하지만, 더 많은 정보.
Fiber Tree 구조
Linked list 구조:
return: 부모.child: 첫 자식.sibling: 다음 형제.
트리 순회가 iterative 가능. Stack 없이.
A
/ | \
B C D
A.child = B
B.sibling = C
C.sibling = D
B.return = A
C.return = A
D.return = A
Double Buffering
두 개의 Fiber tree:
current: 현재 화면에 반영된 트리. workInProgress: 작업 중인 트리.
작동:
- State 변경.
- workInProgress 트리 생성 (current를 복사).
- 변경 사항 적용.
- 완료 시 swap: workInProgress가 current가 됨.
- 실제 DOM 업데이트.
이점:
- 작업 중에도 현재 UI 보존.
- Commit을 원자적으로.
- Rollback 가능.
Fiber Phases
1. Render Phase (중단 가능):
- Fiber 트리 생성/업데이트.
- Diff 계산.
- Effects 수집.
- 부작용 없음 (DOM 수정 안 함).
- 언제든 중단, 재개, 폐기 가능.
2. Commit Phase (동기):
- 실제 DOM 변경 적용.
- Side effects (useEffect 등) 실행.
- 중단 불가.
- 빠르게 완료되어야.
Render는 여러 번 실행될 수 있지만, commit은 한 번. 이것이 "render는 순수해야 한다"는 이유.
4. Scheduler: 언제 무엇을 할지
Scheduler의 역할
Scheduler: 작업의 우선순위와 타이밍을 관리.
목표:
- 높은 우선순위 작업 먼저.
- 브라우저가 할 일 있으면 양보.
- 60fps 유지.
우선순위 레벨
React 18의 priorities:
- Immediate: 즉시. 동기적으로.
- User-blocking: 250 ms 이내. 사용자 입력 등.
- Normal: 5 초 이내. 일반 업데이트.
- Low: 10 초 이내. 저우선순위.
- Idle: 시간 남을 때.
Time Slicing
작업을 조각내서 실행:
work | check time | if > 5ms, yield | work | check...
각 조각 후 브라우저에 제어권. 브라우저가 이벤트 처리, 페인트 등 수행 후 돌아옴.
requestIdleCallback?
초기 계획은 requestIdleCallback 사용. 하지만:
- 지원 부족 (Safari).
- 지연 너무 김.
대신 MessageChannel 기반 자체 스케줄러:
const channel = new MessageChannel();
channel.port1.onmessage = performWorkUntilDeadline;
channel.port2.postMessage(null); // 다음 틱에 실행
5ms 기본 deadline. 각 조각 후 deadline 확인.
예시
function App() {
const [query, setQuery] = useState('');
const [list, setList] = useState(generateLargeList());
// 비싼 필터링
const filtered = useMemo(() =>
list.filter(item => item.name.includes(query)),
[list, query]
);
return (
<>
<input value={query} onChange={e => setQuery(e.target.value)} />
<BigList items={filtered} />
</>
);
}
문제: 각 글자 입력마다:
- Input 업데이트 (즉시 원함).
- 큰 리스트 re-render (느림).
React 17 이전: 둘 다 같은 우선순위. Input이 버벅임.
React 18 + useDeferredValue:
const deferredQuery = useDeferredValue(query);
const filtered = useMemo(() =>
list.filter(item => item.name.includes(deferredQuery)),
[list, deferredQuery]
);
Input update는 즉시. 리스트는 low priority. Input은 부드럽게, 리스트는 조금 늦게.
5. Concurrent Rendering (React 18)
React 18의 혁명
2022년 3월: React 18 출시. Concurrent Rendering 기본 활성.
주요 기능:
- Automatic batching.
- Transitions (
useTransition,useDeferredValue). - Suspense for data fetching.
- Streaming SSR.
- New APIs:
useId,useSyncExternalStore.
Concurrent vs Blocking
Blocking rendering (React 17):
User input
↓
setState
↓
Render (중단 불가, 200ms)
↓ (UI 멈춤)
Commit
↓
User sees new UI
Concurrent rendering (React 18):
User input
↓
setState (low priority)
↓
Start render (중단 가능)
↓
Another input arrives (high priority)
↓
Abort current render, handle input
↓
Render input result immediately
↓
Resume low priority render
Automatic Batching
React 17:
- Event handler 내 여러 setState: 한 번만 re-render.
- 그 외 (promise, setTimeout): 각각 re-render.
// React 17
setTimeout(() => {
setCount(1); // re-render
setFlag(true); // re-render
}, 100);
React 18:
- 모든 곳에서 batching.
- 한 번의 re-render.
// React 18: 한 번의 re-render
setTimeout(() => {
setCount(1);
setFlag(true);
}, 100);
결과: 불필요한 re-render 감소.
Transitions
중요하지 않은 업데이트를 "transition"으로 표시:
import { useTransition } from 'react';
function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setQuery(e.target.value); // 높은 우선순위 (input)
startTransition(() => {
// 낮은 우선순위 (결과)
setResults(computeExpensiveResults(e.target.value));
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <Spinner />}
<Results data={results} />
</>
);
}
효과:
- Input update 즉시.
- Results computation 백그라운드.
isPending으로 "계산 중" 상태 표시.- 사용자가 계속 타이핑 가능.
Suspense
Suspense: "로딩 상태를 선언적으로".
<Suspense fallback={<Spinner />}>
<UserProfile id={userId} />
</Suspense>
UserProfile이 데이터를 fetch하는 동안 fallback 표시.
이전: 각 컴포넌트가 자기 로딩 상태 관리. 복잡, inconsistent.
Suspense: 컴포넌트 트리 수준에서 로딩. 일관된 UX.
Streaming SSR
서버에서 HTML을 스트리밍으로:
- Skeleton 먼저 전송.
- 데이터 fetch 완료되면 stream으로 추가.
- Progressive rendering.
Next.js 13+ 가 이 기능 적극 활용.
6. Hooks의 내부 구현
Hook이란
Hooks (React 16.8, 2019):
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `Count: ${count}`;
}, [count]);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
함수 컴포넌트에서 state, lifecycle 사용 가능.
왜 혁명적:
- 클래스 없이.
- 재사용 가능한 로직 (custom hooks).
- 더 단순한 코드.
기본 아이디어
Hook은 어떻게 state를 기억하나?
핵심: Fiber에 hook 정보 저장.
각 fiber에 memoizedState 필드. Linked list 형태로 여러 hook 저장.
fiber.memoizedState = {
// useState(0)
memoizedState: 0,
queue: updateQueue,
next: {
// useState('')
memoizedState: '',
queue: updateQueue2,
next: {
// useEffect(...)
memoizedState: { deps: [...] },
next: null,
}
}
}
순서대로 호출되는 hook이 순서대로 저장됨.
Hook 호출의 규칙
왜 "Rules of Hooks"가 있는가:
function Bad() {
if (condition) {
const [a] = useState(1); // ❌ 조건부 호출
}
const [b] = useState(2);
}
이유: React는 호출 순서로 hook을 매칭. 조건부 호출하면 순서가 바뀜.
올바른 방법:
function Good() {
const [a] = useState(1); // 항상 호출
const [b] = useState(2);
if (condition) {
// a 사용
}
}
useState의 구현 (단순화)
let currentFiber = null;
let hookIndex = 0;
function useState(initialValue) {
const fiber = currentFiber;
const index = hookIndex++;
// 이전 값 복원
if (!fiber.memoizedState) {
fiber.memoizedState = [];
}
if (fiber.memoizedState[index] === undefined) {
fiber.memoizedState[index] = initialValue;
}
const setState = (newValue) => {
fiber.memoizedState[index] = newValue;
scheduleRender(fiber);
};
return [fiber.memoizedState[index], setState];
}
단순화된 모델:
- Fiber에 hook 배열.
- 각 useState가 인덱스로 접근.
- setState가 scheduler에게 re-render 요청.
실제 구현은 linked list + updateQueue. 더 복잡.
useEffect의 구현 (단순화)
function useEffect(callback, deps) {
const fiber = currentFiber;
const index = hookIndex++;
const oldDeps = fiber.memoizedState[index]?.deps;
const hasChanged = !oldDeps || deps.some((d, i) => d !== oldDeps[i]);
if (hasChanged) {
fiber.effects.push({
callback,
cleanup: fiber.memoizedState[index]?.cleanup,
});
fiber.memoizedState[index] = { deps };
}
}
Effects는 commit phase 후 실행:
- Render phase: effect 수집.
- Commit phase: DOM 업데이트.
- Effects 실행 (이전 cleanup → 새 effect).
Custom Hooks
Hook을 재사용:
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = () => setCount(c => c + 1);
const decrement = () => setCount(c => c - 1);
return { count, increment, decrement };
}
function MyComponent() {
const { count, increment } = useCounter(10);
return <button onClick={increment}>{count}</button>;
}
내부: Custom hook도 그냥 함수. 내부의 useState가 호출자의 fiber에 저장됨.
순서 중요: Custom hook 호출 순서가 일정해야.
7. React 19: Compiler와 Server Components
React Compiler (2024)
React Compiler (이전 "React Forget"): 자동 메모이제이션.
문제:
function MyComponent({ items }) {
const sorted = items.sort(); // 매 render마다 새 배열
return <List items={sorted} />; // List가 매번 re-render
}
기존 해결:
const sorted = useMemo(() => items.sort(), [items]);
수동으로 useMemo, useCallback, React.memo. 번거로움.
React Compiler: 자동으로 이들을 추가. 개발자는 평범한 코드 작성.
// 개발자가 쓰는 코드
function MyComponent({ items }) {
const sorted = items.sort();
return <List items={sorted} />;
}
// Compiler가 변환
function MyComponent({ items }) {
const sorted = useMemo(() => items.sort(), [items]);
return <List items={sorted} />;
}
자동 최적화. 2024년 실험적, 2025+ 안정화.
Server Components
React Server Components (RSC): 서버에서 렌더링되는 컴포넌트.
// ServerComponent.jsx (.server.jsx)
import db from 'db';
export default async function BlogPost({ id }) {
const post = await db.posts.get(id);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
특징:
- 서버에서만 실행.
- JavaScript가 클라이언트로 전송 안 됨.
- DB, 파일 시스템 직접 접근.
- 제로 번들 크기.
Client Components와 혼합 가능:
// 서버
import Button from './Button.client';
export default function Page() {
return (
<div>
<h1>Server rendered</h1>
<Button /> {/* 클라이언트에서 인터랙티브 */}
</div>
);
}
Next.js 13+, Remix 등이 활용.
React 19 기타
use() hook: Promise 직접 언랩.
function Component() {
const data = use(fetchData()); // Suspense 자동
return <div>{data}</div>;
}
Actions: 폼과 mutation 단순화.
<form action={handleSubmit}>
<input name="title" />
<button type="submit">Save</button>
</form>
useOptimistic: Optimistic updates.
8. 성능 최적화
React 성능의 기본 원칙
"과도한 re-render를 피하라".
Re-render 발생 조건:
- State 변경.
- Props 변경.
- Context 변경.
- 부모 re-render (기본 동작).
React.memo
Component memoization:
const Button = React.memo(({ onClick, children }) => {
console.log('Button rendered');
return <button onClick={onClick}>{children}</button>;
});
Props가 같으면 re-render 스킵 (shallow comparison).
주의:
- 객체, 배열, 함수는 매번 새로 생성 → 항상 다름.
useCallback,useMemo와 함께.
useCallback
함수 참조 안정화:
const handleClick = useCallback(() => {
doSomething(value);
}, [value]);
value가 같으면 같은 함수 참조. Memoized 자식이 re-render 안 함.
useMemo
값 memoization:
const expensiveValue = useMemo(() => {
return computeExpensive(data);
}, [data]);
data가 같으면 같은 값. 재계산 안 함.
과도한 memoization의 함정
memo/useCallback/useMemo는 공짜가 아니다:
- 메모리: 이전 값 저장.
- 비교 비용: 의존성 비교.
- 복잡도 증가: 코드 난해.
규칙:
- 기본은 안 씀.
- 실제 성능 문제 있을 때만 (profiler로 측정).
- 비싼 계산이거나 큰 하위 트리일 때.
React Compiler (React 19) 가 이 모든 걸 자동화해 줄 전망.
Key 주의
{items.map((item, index) => (
<Item key={index} {...item} />
))}
Index를 key로: 삽입/삭제 시 문제. State 섞임.
{items.map((item) => (
<Item key={item.id} {...item} />
))}
안정적 ID: 올바름.
State 위치
**State는 쓰이는 곳에 가깝게:
// ❌ 상위에 있으면 전체 re-render
function App() {
const [inputValue, setInputValue] = useState('');
return (
<div>
<HeavyComponent />
<input value={inputValue} onChange={e => setInputValue(e.target.value)} />
</div>
);
}
// ✅ 입력 컴포넌트로 분리
function Input() {
const [value, setValue] = useState('');
return <input value={value} onChange={e => setValue(e.target.value)} />;
}
function App() {
return (
<div>
<HeavyComponent />
<Input />
</div>
);
}
Input의 state 변경이 Input만 re-render.
Profiler
React DevTools Profiler:
- 각 컴포넌트의 render 시간.
- Why did this render?
- Flamegraph.
성능 문제의 근본 원인을 찾는 최선의 도구.
9. Context와 상태 관리
Context API
Prop drilling 해결:
const ThemeContext = createContext('light');
function App() {
return (
<ThemeContext.Provider value="dark">
<Toolbar />
</ThemeContext.Provider>
);
}
function Toolbar() {
return <ThemedButton />;
}
function ThemedButton() {
const theme = useContext(ThemeContext);
return <button className={theme}>Click</button>;
}
Context 변경 시 모든 consumer re-render.
Context의 함정
과도 사용: 모든 걸 context에. Re-render 폭발.
최적화:
- Context를 세분화.
- Memoization.
useSyncExternalStore(React 18).
예시 문제:
<AppContext.Provider value={{ user, theme, locale, settings }}>
user만 바뀌어도 theme 사용자도 re-render.
해결:
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
...
</ThemeContext.Provider>
</UserContext.Provider>
각 context가 독립. 바뀐 것의 consumer만 re-render.
외부 상태 관리
Redux, Zustand, Jotai, Recoil: 더 복잡한 state에.
왜 필요한가:
- Cross-cutting state.
- 복잡한 데이터 흐름.
- Time-travel debugging.
- Devtools.
간단한 앱: Context + hooks 충분. 복잡한 앱: 전용 라이브러리.
최신 트렌드:
- Zustand: 가볍고 단순.
- Jotai: Atomic state.
- TanStack Query: 서버 상태 (cache, refetch).
Redux는 여전히 쓰이지만 점점 줄어드는 추세.
10. 실전 패턴
Component Composition
// ❌ Props drilling
<Layout user={user} theme={theme}>
<Sidebar user={user} theme={theme} />
<Content user={user} />
</Layout>
// ✅ Composition
<Layout
sidebar={<Sidebar />}
content={<Content />}
/>
Context로 필요한 곳에서 받음.
Container/Presentational
구식 패턴 (Hooks 이전):
- Container: state, logic.
- Presentational: pure render.
현재: Custom hooks로 대체.
// Custom hook
function useUserData(id) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(id).then(setUser);
}, [id]);
return user;
}
// Component
function UserProfile({ id }) {
const user = useUserData(id);
if (!user) return <Spinner />;
return <h1>{user.name}</h1>;
}
Error Boundaries
Error를 catch:
class ErrorBoundary extends Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, errorInfo) {
logError(error, errorInfo);
}
render() {
if (this.state.hasError) {
return <h1>Something went wrong.</h1>;
}
return this.props.children;
}
}
// 사용
<ErrorBoundary>
<MyComponent />
</ErrorBoundary>
주의:
- Class component만 가능.
- Event handler, async code는 catch 안 됨.
Compound Components
공유 state를 가진 관련 컴포넌트들:
<Tabs>
<Tabs.List>
<Tabs.Tab>One</Tabs.Tab>
<Tabs.Tab>Two</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel>Content 1</Tabs.Panel>
<Tabs.Panel>Content 2</Tabs.Panel>
</Tabs.Panels>
</Tabs>
Tabs가 children에 context로 현재 tab 정보 전달.
11. React vs 다른 프레임워크
Vue
차이점:
- Templates: HTML-like, 더 단순.
- Reactivity: Proxy 기반 (자동).
- Two-way binding:
v-model. - Composition API: Hooks와 유사 (Vue 3).
React와의 차이:
- 학습 곡선 낮음.
- 더 "자동".
- React보다 더 opinionated.
Svelte
차이점:
- 컴파일 타임 최적화.
- Virtual DOM 없음.
- 더 작은 bundle.
- 더 적은 코드.
Svelte 5 (Runes): Signal-based reactivity.
Solid.js
차이점:
- Fine-grained reactivity: React의 re-render 없음.
- JSX 유지.
- 매우 빠름.
- Signal 기반.
Qwik
차이점:
- Resumability: SSR 후 hydration 없음.
- 초기 JavaScript 매우 작음.
- Edge computing 친화적.
React가 여전히 1등인 이유
장점:
- 생태계: 가장 큰 커뮤니티, 가장 많은 라이브러리.
- 검증: 수많은 대기업이 검증.
- 고용 시장: 가장 많은 React 개발자.
- React Native: 모바일까지.
- Meta의 지원: 장기적.
단점:
- Virtual DOM 오버헤드.
- Re-render 관리 복잡.
- Bundle 큼.
선택: 대부분의 프로젝트에 React가 안전한 선택. 새로운 것을 원하면 Solid, Svelte 등 실험.
12. 실전 디버깅과 튜닝
React DevTools
필수 도구:
- Components tab: 트리 탐색, props/state 확인.
- Profiler tab: 성능 분석.
- Why did this render?: 원인 파악.
일반적 문제
1. 과도한 re-render:
- Props reference 변경.
- Context 오남용.
- State를 너무 상위에.
해결: Profiler → React.memo, useCallback, useMemo.
2. 메모리 누수:
- useEffect cleanup 안 함.
- Subscription 해제 안 함.
해결:
useEffect(() => {
const subscription = subscribe();
return () => subscription.unsubscribe();
}, []);
3. Stale closures:
useEffect(() => {
const interval = setInterval(() => {
setCount(count + 1); // count가 항상 0
}, 1000);
return () => clearInterval(interval);
}, []); // [] 때문에 count 고정
해결:
setCount(c => c + 1); // functional update
4. Infinite render loops:
useEffect(() => {
setData({ ...data, updated: true }); // data 의존성 → 무한
}, [data]);
해결: 의존성 배열 확인, 논리 재검토.
Best Practices
- Hooks 규칙 준수: ESLint plugin.
- Keys 제대로: ID 사용, index 피하기.
- State 분할: 세밀하게.
- Pure functions: Side effect는 useEffect로.
- Dependencies: 정확히.
- Testing: React Testing Library.
퀴즈로 복습하기
Q1. React Fiber의 가장 큰 이점은 무엇인가?
A.
답: 중단 가능한 rendering (Interruptible rendering).
상세 설명:
Fiber 이전의 문제:
Stack Reconciler (React 15 이하):
- 렌더링이 재귀 함수 호출.
- 시작하면 끝까지 실행.
- 중단 불가.
render(App)
→ render(Header)
→ render(Logo)
→ render(Nav)
→ render(NavItem) × 100
→ render(Main)
→ render(Article) × 50
→ render(Sidebar)
이 전체가 하나의 call stack. 중간에 멈출 수 없음.
큰 업데이트의 재앙:
- 1000개 컴포넌트 업데이트.
- 각 컴포넌트 2 ms.
- 총 2000 ms = 2 초.
- 이 동안 브라우저 완전 블록.
- 사용자 입력 무시.
- 애니메이션 끊김.
- 버벅임.
브라우저의 60 fps:
- 프레임당 16.67 ms.
- 그 안에 JS + layout + paint + composite.
- React가 16 ms 넘으면 프레임 드롭.
- 사용자가 "느림" 인식.
Fiber의 해결:
작업을 작은 단위로 분할:
- 각 fiber가 하나의 작업 단위.
- 하나 처리 후 "양보 여부" 확인.
- 브라우저에 급한 일 있으면 중단.
- 나중에 재개.
구현 방식:
function workLoop(deadline) {
while (deadline.timeRemaining() > 0 && nextUnitOfWork) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (!nextUnitOfWork) {
// 완료: commit
commitRoot();
} else {
// 중단: 다음 idle 시간에 재개
requestIdleCallback(workLoop);
}
}
핵심:
deadline.timeRemaining(): 브라우저가 "쉴 시간" 알려줌.- 시간 남으면 계속 작업.
- 시간 다 쓰면 중단.
- 다음 idle에 재개.
React 18에서는 requestIdleCallback 대신 MessageChannel 기반 자체 scheduler.
두 가지 Phase:
Render phase (중단 가능):
- Fiber 트리 생성/업데이트.
- Virtual DOM diff.
- Effect 수집.
- 부작용 없음. 언제든 버려도 OK.
Commit phase (중단 불가):
- 실제 DOM 수정.
- DOM effect 실행.
- 빠르게 완료.
- 원자적.
이 분리가 중요:
- Render를 여러 번 시도 가능.
- 더 나은 업데이트가 오면 이전 render 폐기.
- Commit만 "진짜" 변경.
이점 1: 부드러운 애니메이션
큰 업데이트 중에도:
- 60fps 유지.
- 애니메이션 끊김 없음.
- 사용자 입력 즉시 반응.
이점 2: 우선순위
높은 우선순위:
- 사용자 입력 (키보드, 마우스).
- 애니메이션.
- 스크롤.
낮은 우선순위:
- 데이터 fetch 후 업데이트.
- 큰 리스트 렌더링.
- 백그라운드 작업.
React가 자동으로 우선순위 처리:
사용자 입력 발생
↓
Scheduler: "high priority task"
↓
현재 render 중단
↓
입력 처리 먼저
↓
입력 완료 후 render 재개
이점 3: Concurrent features
Fiber의 중단/재개 능력이 concurrent mode의 기반:
- useTransition: "이 업데이트는 중요하지 않음".
- useDeferredValue: "이 값은 지연되어도 OK".
- Suspense: "로딩 중엔 fallback".
이 모든 것이 Fiber 없이는 불가능.
실전 예시:
function App() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState([]);
function handleChange(e) {
setQuery(e.target.value); // high priority
startTransition(() => {
setResults(
expensiveSearch(e.target.value) // low priority
);
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <Results items={results} />}
</>
);
}
React 17 이전:
- 각 키 입력이 전체 업데이트 트리거.
- 1 키 = query state + results state 둘 다 업데이트.
- expensiveSearch가 느리면 입력 버벅임.
React 18 (Fiber + Concurrent):
setQuery: 즉시 (input 반영).startTransition안의setResults: 낮은 우선순위.- 사용자가 계속 타이핑 중이면 이전 results 계산 폐기, 새로 시작.
- Input은 항상 부드럽게.
사용자 경험: 이전엔 "Laggy", 지금은 "Smooth".
이점 4: Double Buffering
Fiber는 두 개의 트리 유지:
- current: 현재 화면.
- workInProgress: 작업 중.
작업이 완료되기 전까지 current는 건드리지 않음. 중간에 중단되어도 화면은 그대로.
완료되면 atomic swap. WorkInProgress → current.
이점 5: Error 복구 (미래)
Render 중 에러 발생 시:
- WorkInProgress 폐기.
- Current는 그대로.
- 복구 가능성.
Error Boundary 동작의 기반.
단점과 복잡성:
1. 구현 복잡:
- Linked list 기반 트리 순회.
- 우선순위 관리.
- Double buffering.
- React 팀의 수년간 작업.
2. Memory 증가:
- Alternate tree.
- Effect list.
- 약간의 overhead.
3. Debugging 어려움:
- 비선형 실행.
- DevTools가 이를 숨김.
4. Legacy 호환성:
- 일부 라이브러리가 깨짐.
useSyncExternalStore등으로 해결.
역사적 관점:
2017년: Fiber 출시 (React 16).
- 내부만 변경. 사용자 API 그대로.
- Concurrent features는 opt-in (React 18까지).
2022년: React 18.
- Automatic batching, transitions, Suspense 기본.
- 진짜 concurrent mode.
2024년: React 19.
- React Compiler.
- Server Components 성숙.
Fiber는 5년 이상 걸린 긴 여정의 결과. 사용자는 대부분 몰랐지만, React의 근본 혁신이었다.
교훈:
Fiber의 가치는 "중단 가능성" 이라는 단순한 개념이다. 하지만 이것이:
- 애니메이션 부드러움.
- 입력 반응성.
- 우선순위.
- Concurrent features.
- 자동 최적화.
모두를 가능하게 한다.
"작업을 작게 쪼개서 중단 가능하게 만들라" 는 원칙은 운영체제, 네트워킹, 분산 시스템 어디서나 유효하다. React Fiber는 이 원칙을 UI rendering에 적용한 성공 사례다.
당신이 React 앱을 쓸 때, 부드러운 스크롤, 반응하는 입력, 진행 중 transitioning의 뒤에는 Fiber가 있다. 알아차리지 못할 때 잘 작동하고 있는 것이다.
마치며: 추상화의 예술
핵심 정리
- Virtual DOM: JavaScript 객체로 UI 표현.
- Reconciliation: Diff + 효율적 업데이트.
- Fiber: 중단 가능한 작업 단위.
- Scheduler: 우선순위 기반 작업 관리.
- Concurrent Rendering: Transitions, Suspense.
- Hooks: Fiber에 저장된 state.
- React 19: Compiler, Server Components.
React가 잘한 것
- Declarative: UI = f(state).
- Component-based: 재사용 가능.
- Virtual DOM: 효율적 업데이트.
- Ecosystem: 무한한 라이브러리.
- Evolution: Class → Hooks → Concurrent → Compiler.
실전 조언
- 기본을 신뢰하라: 과도한 최적화 금지.
- Profiler 사용: 실제 병목 찾기.
- State 위치: 쓰이는 곳에 가깝게.
- Keys 제대로: ID 사용.
- Hooks 규칙: ESLint로 강제.
- React 19 기다려: Compiler가 많은 걸 해결.
마지막 교훈
React는 추상화의 힘을 보여준다. 수십억 줄의 DOM 조작 코드를 선언적 컴포넌트로 대체했다.
그 아래에는 엄청난 엔지니어링:
- Fiber 아키텍처.
- 중단 가능한 렌더링.
- 우선순위 스케줄링.
- Hooks의 클로저 마법.
- 컴파일러 최적화.
모든 것이 숨겨져 있다. 개발자는 그저 JSX를 쓴다. 이것이 좋은 추상화다.
하지만 내부를 아는 개발자가 더 나은 코드를 쓴다. 왜 이 렌더가 발생하는지, 언제 memoize해야 하는지, 왜 hook 규칙이 있는지 — 이해하면 모든 것이 명확해진다.
당신이 다음에 setState를 호출할 때, 그 뒤에서 일어나는 일을 상상해 보라:
- State update 스케줄.
- Fiber tree 생성.
- Reconciliation.
- Diff.
- Effect 수집.
- Commit.
- DOM update.
- Effects 실행.
한 줄의 코드가 이 모든 작업을 trigger한다. 마법처럼 보이지만, 실제론 우아한 엔지니어링이다.
React의 교훈: 복잡성을 숨기되, 원할 때 들여다볼 수 있게 만들라. 이것이 좋은 라이브러리의 정의다. React는 이 균형을 10년 넘게 지켜왔고, 앞으로도 계속 진화할 것이다.