Context-Query: React 상태 관리 라이브러리 개발 여정
Context API와 React Query의 장점을 결합한 최적화된 상태 관리 솔루션
React 16에서 도입된 Fiber 아키텍처는 React의 렌더링 엔진을 근본적으로 재설계한 핵심 개선사항입니다. 이전의 Stack Reconciler는 재귀 기반으로 동작하여 한 번 시작된 렌더링을 중단할 수 없었지만, Fiber는 작업을 중단하고 재개할 수 있는 능력을 갖춰 동시성 렌더링(Concurrent Rendering)을 가능하게 합니다.
이 글에서는 Fiber의 데이터 구조, Render Phase와 Commit Phase의 동작 원리, Time Slicing 메커니즘, 그리고 최적화 전략을 상세히 분석합니다.
React 15 이전의 Stack Reconciler는 재귀 함수 기반으로 동작했습니다.
Stack Reconciler의 동작 방식:
// React 15 Stack Reconciler (개념적 구조)
function reconcile(element) {
// 재귀 시작 → 중단 불가능
if (element.children) {
element.children.forEach(child => {
reconcile(child); // 깊이 우선 탐색
});
}
updateDOM(element);
// 전체 트리를 순회할 때까지 메인 스레드 차단
}문제점:
실제 영향:
// 1000개 항목 렌더링
const LargeList = () => (
<ul>
{items.map(item => (
<ExpensiveComponent key={item.id} data={item} />
))}
</ul>
);
// React 15: 약 150ms 동안 메인 스레드 차단 → 화면 멈춤
// React 18 (Fiber): 5ms씩 나눠서 처리 → 부드러운 UIReact Element (임시적):
// JSX → React.createElement() → React Element
const element = {
type: 'div',
props: {
className: 'container',
children: 'Hello'
}
};
// 렌더링 시마다 재생성, 상태 없음Fiber Node (영구적):
// Fiber Node의 실제 구조 (간소화)
const fiber = {
// 타입 정보
type: 'div', // 컴포넌트 타입
elementType: 'div',
// 상태 저장
memoizedState: null, // Hook 상태 저장
memoizedProps: { className: 'container' },
pendingProps: { className: 'container' },
// 트리 구조
return: parentFiber, // 부모 Fiber
child: firstChildFiber, // 첫 번째 자식
sibling: nextSiblingFiber, // 다음 형제
// 작업 정보
flags: Update | Placement, // 수행할 작업 플래그
lanes: SyncLane, // 우선순위
// DOM 참조
stateNode: divElement, // 실제 DOM 노드
// 더블 버퍼링
alternate: workInProgressFiber, // 대체 Fiber (현재/작업 중)
};트리 탐색 메커니즘:
App
↓
Header
↙ ↘
Logo Nav
↓
Link
Fiber 트리:
App (return: null, child: Header, sibling: null)
↓
Header (return: App, child: Logo, sibling: null)
↓
Logo (return: Header, child: null, sibling: Nav)
→
Nav (return: Header, child: Link, sibling: null)
↓
Link (return: Nav, child: null, sibling: null)
탐색 순서:
// Fiber 트리 순회 (깊이 우선)
function performUnitOfWork(fiber) {
// 1. 현재 Fiber 처리
beginWork(fiber);
// 2. 자식이 있으면 자식으로
if (fiber.child) {
return fiber.child;
}
// 3. 자식이 없으면 형제로
while (fiber) {
completeWork(fiber);
if (fiber.sibling) {
return fiber.sibling;
}
// 4. 형제도 없으면 부모로 올라감
fiber = fiber.return;
}
}Render Phase는 중단 가능한(interruptible) 작업 루프로 동작합니다.
// React 내부 Work Loop (간소화)
function workLoopConcurrent() {
// shouldYield(): 메인 스레드를 양보해야 하는지 확인
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
// shouldYield()가 true면 중단 → 브라우저에게 제어권 반환
// 다음 프레임에서 재개
}
function shouldYield() {
const currentTime = performance.now();
return currentTime >= deadline; // 5ms 초과 시 true
}Time Slicing 동작:
프레임 1 (0ms ~ 16ms):
├─ 0-5ms: React 렌더링 (Fiber 1-10 처리)
├─ 5-6ms: 브라우저 이벤트 처리
├─ 6-11ms: React 렌더링 (Fiber 11-20 처리)
└─ 11-16ms: 브라우저 페인팅
프레임 2 (16ms ~ 32ms):
└─ 16-21ms: React 렌더링 (Fiber 21-30 처리)
...계속
beginWork는 Fiber 트리를 위에서 아래로 순회하며 변경 사항을 확인합니다.
// beginWork 내부 로직 (개념적)
function beginWork(current, workInProgress) {
// 1. Props 비교 (얕은 비교)
if (current !== null &&
current.memoizedProps === workInProgress.pendingProps &&
!hasContextChanged()) {
// Props 변경 없음 → 자식 건너뛰기 (Bailout)
return bailoutOnAlreadyFinishedWork(current, workInProgress);
}
// 2. 컴포넌트 타입에 따라 업데이트
switch (workInProgress.tag) {
case FunctionComponent:
return updateFunctionComponent(current, workInProgress);
case ClassComponent:
return updateClassComponent(current, workInProgress);
case HostComponent: // <div>, <span> 등
return updateHostComponent(current, workInProgress);
}
}
function updateFunctionComponent(current, workInProgress) {
const Component = workInProgress.type;
const props = workInProgress.pendingProps;
// 함수 컴포넌트 실행
const children = Component(props);
// 자식 Fiber 생성/업데이트
reconcileChildren(current, workInProgress, children);
return workInProgress.child;
}더티 체크와 Bailout:
// React.memo의 동작 원리
const MemoizedComponent = React.memo(ExpensiveComponent);
// beginWork에서 처리
function beginWork(current, workInProgress) {
if (workInProgress.type._isMemoType) {
const prevProps = current.memoizedProps;
const nextProps = workInProgress.pendingProps;
// Props 얕은 비교
if (shallowEqual(prevProps, nextProps)) {
// 변경 없음 → 렌더링 건너뛰기
return bailoutOnAlreadyFinishedWork(current, workInProgress);
}
}
// Props 변경됨 → 렌더링 계속
return updateFunctionComponent(current, workInProgress);
}completeWork는 트리의 아래에서 위로 순회하며 DOM 노드를 생성/업데이트합니다.
// completeWork 내부 로직 (개념적)
function completeWork(current, workInProgress) {
const newProps = workInProgress.pendingProps;
switch (workInProgress.tag) {
case HostComponent: {
if (current === null) {
// 1. 신규 DOM 노드 생성 (오프스크린)
const instance = createInstance(
workInProgress.type,
newProps
);
// 2. 자식 DOM 연결
appendAllChildren(instance, workInProgress);
workInProgress.stateNode = instance;
// 3. Props 설정
finalizeInitialChildren(instance, newProps);
} else {
// 기존 DOM 노드 업데이트 (플래그만 마킹)
const instance = workInProgress.stateNode;
updateHostComponent(current, workInProgress, instance);
}
// Flags 설정 (Commit Phase에서 사용)
if (needsUpdate) {
workInProgress.flags |= Update;
}
break;
}
}
}오프스크린 DOM 생성:
// 오프스크린 렌더링 예시
function createInstance(type, props) {
const element = document.createElement(type);
// 실제 DOM에 아직 추가되지 않음!
Object.keys(props).forEach(key => {
if (key === 'children') return;
element[key] = props[key];
});
return element; // 메모리에만 존재
}
// Commit Phase에서 실제 DOM에 추가
function commitWork(fiber) {
const parent = fiber.return.stateNode;
parent.appendChild(fiber.stateNode); // ← 여기서 화면에 반영
}Commit Phase는 중단 불가능(uninterruptible) 하며, 실제 DOM을 동기적으로 업데이트합니다.
// Commit Phase의 3단계
function commitRoot(root) {
// 준비: Effect 리스트 수집
const finishedWork = root.finishedWork;
// 1단계: Before Mutation
commitBeforeMutationEffects(finishedWork);
// 2단계: Mutation (DOM 변경)
commitMutationEffects(finishedWork);
// 3단계: Layout (useLayoutEffect 실행)
commitLayoutEffects(finishedWork);
// Passive Effects 스케줄링 (useEffect)
schedulePassiveEffects(finishedWork);
}1. Before Mutation 단계:
function commitBeforeMutationEffects(fiber) {
// getSnapshotBeforeUpdate 실행 (Class Component)
if (fiber.flags & Snapshot) {
const instance = fiber.stateNode;
instance.getSnapshotBeforeUpdate(prevProps, prevState);
}
}2. Mutation 단계:
function commitMutationEffects(fiber) {
const flags = fiber.flags;
// Placement: 새 노드 추가
if (flags & Placement) {
const parent = getParentFiber(fiber);
parent.stateNode.appendChild(fiber.stateNode);
}
// Update: 기존 노드 업데이트
if (flags & Update) {
const instance = fiber.stateNode;
updateDOMProperties(instance, fiber.memoizedProps, fiber.pendingProps);
}
// Deletion: 노드 제거
if (flags & Deletion) {
const parent = getParentFiber(fiber);
parent.stateNode.removeChild(fiber.stateNode);
}
}3. Layout 단계:
function commitLayoutEffects(fiber) {
// componentDidMount/Update 실행 (Class Component)
// useLayoutEffect 실행 (Function Component)
if (fiber.flags & Update) {
const create = fiber.updateQueue.create;
const destroy = create(); // useLayoutEffect 콜백 실행
fiber.updateQueue.destroy = destroy; // cleanup 저장
}
}useLayoutEffect vs useEffect:
function Example() {
const [width, setWidth] = useState(0);
const ref = useRef();
// ❶ Layout Effect: DOM 변경 직후, 페인팅 이전
useLayoutEffect(() => {
const measured = ref.current.getBoundingClientRect().width;
setWidth(measured); // 페인팅 전에 반영 → 깜빡임 없음
}, []);
// ❷ Passive Effect: 페인팅 이후 (비동기)
useEffect(() => {
fetch('/api/data').then(setData); // 화면 업데이트 차단 안 함
}, []);
return <div ref={ref}>Width: {width}px</div>;
}실행 순서:
1. Render Phase
↓
2. Commit Phase 시작
↓
3. Before Mutation (getSnapshotBeforeUpdate)
↓
4. Mutation (DOM 변경)
↓
5. Layout (useLayoutEffect 실행) ← 동기
↓
6. 브라우저 페인팅
↓
7. Passive Effects (useEffect 실행) ← 비동기
React 18부터 우선순위는 Lanes 모델을 사용합니다.
// Lane 우선순위 (비트마스크)
const SyncLane = 0b0000000000000000000000000000001; // 최우선
const InputContinuousLane = 0b0000000000000000000000000000100; // 입력 이벤트
const DefaultLane = 0b0000000000000000000000000010000; // 기본
const TransitionLane = 0b0000000000000000000000010000000; // Transition
const IdleLane = 0b0100000000000000000000000000000; // 유휴 시간
// 사용 예시
function scheduleUpdate(fiber, lane) {
fiber.lanes |= lane; // 비트 OR 연산으로 우선순위 마킹
// 루트까지 전파
let parent = fiber.return;
while (parent !== null) {
parent.childLanes |= lane;
parent = parent.return;
}
}우선순위별 처리:
// 높은 우선순위 작업이 진행 중인 낮은 우선순위 작업 중단
function workLoopConcurrent() {
while (workInProgress !== null && !shouldYield()) {
// 더 높은 우선순위 업데이트 발생 시
if (hasHigherPriorityUpdate()) {
// 현재 작업 중단
workInProgress = null;
// 높은 우선순위 작업 시작
performHighPriorityWork();
}
performUnitOfWork(workInProgress);
}
}import { startTransition } from 'react';
function SearchResults() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
function handleChange(e) {
const value = e.target.value;
// 높은 우선순위: 입력 즉시 반영
setQuery(value);
// 낮은 우선순위: 무거운 계산은 나중에
startTransition(() => {
const filtered = expensiveFilter(data, value);
setResults(filtered);
});
}
return (
<>
<input value={query} onChange={handleChange} />
<ResultsList results={results} />
</>
);
}실행 흐름:
사용자 입력 "React"
↓
① setQuery("React") 실행 (SyncLane)
→ Work Loop: query 업데이트 우선 처리
→ Input 값 즉시 변경 (16ms 내)
↓
② startTransition(() => setResults(...)) (TransitionLane)
→ Work Loop: 유휴 시간에 처리
→ 5ms씩 나눠서 expensiveFilter 실행
→ 입력이 계속되면 버려지고 새 작업 시작
React는 두 개의 Fiber 트리를 유지합니다.
Current Tree (화면에 표시 중) WorkInProgress Tree (작업 중)
App App'
↓ ↓
Header Header'
↓ ↓
Title Title'
current.alternate = workInProgress
workInProgress.alternate = current
더블 버퍼링 동작:
// Render Phase: WorkInProgress 트리 작업
function prepareFreshStack(root) {
const current = root.current;
// Current의 alternate를 WorkInProgress로 사용
const workInProgress = current.alternate || createWorkInProgress(current);
workInProgressRoot = root;
workInProgressRootFiber = workInProgress;
}
// Commit Phase: WorkInProgress → Current로 전환
function commitRoot(root) {
// ... Commit 작업 수행
// 포인터 전환 (O(1) 연산!)
root.current = root.finishedWork;
// 이제 WorkInProgress가 새로운 Current
}메모리 효율성:
// Fiber 재사용으로 GC 압력 감소
function createWorkInProgress(current) {
let workInProgress = current.alternate;
if (workInProgress === null) {
// 최초 생성
workInProgress = createFiber(current.type);
} else {
// 기존 Fiber 재사용
workInProgress.pendingProps = current.pendingProps;
workInProgress.flags = NoFlags;
}
// 양방향 연결
workInProgress.alternate = current;
current.alternate = workInProgress;
return workInProgress;
}// React.memo로 Props 비교
const ExpensiveList = React.memo(({ items }) => {
console.log('렌더링!');
return (
<ul>
{items.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});
function App() {
const [count, setCount] = useState(0);
const items = useMemo(() => [
{ id: 1, name: 'Item 1' },
{ id: 2, name: 'Item 2' }
], []); // 빈 의존성 → 항상 동일한 참조
return (
<>
<button onClick={() => setCount(count + 1)}>Count: {count}</button>
<ExpensiveList items={items} /> {/* Props 불변 → Bailout */}
</>
);
}beginWork에서 발생:
setCount 호출
↓
App Fiber: beginWork 진입
→ count 변경 → 렌더링 필요
↓
ExpensiveList Fiber: beginWork 진입
→ Props 비교: items === items (참조 동일)
→ Bailout! (렌더링 건너뛰기)
import { useDeferredValue } from 'react';
function SearchPage({ query }) {
const deferredQuery = useDeferredValue(query);
// query: 즉시 업데이트 (입력 필드용)
// deferredQuery: 지연 업데이트 (검색 결과용)
return (
<>
<input value={query} />
<SearchResults query={deferredQuery} />
</>
);
}내부 동작:
// useDeferredValue 구현 개념
function useDeferredValue(value) {
const [deferredValue, setDeferredValue] = useState(value);
useEffect(() => {
startTransition(() => {
setDeferredValue(value); // TransitionLane으로 스케줄
});
}, [value]);
return deferredValue;
}// [주의] 잘못된 사용: 인덱스를 key로 사용
{items.map((item, index) => (
<Item key={index} data={item} />
))}
// 배열 순서 변경 시 모든 Item 재렌더링
// [권장] 올바른 사용: 고유 ID를 key로 사용
{items.map(item => (
<Item key={item.id} data={item} />
))}
// 순서 변경 시 Fiber 재사용 → DOM 이동만 발생Fiber 매칭 알고리즘:
// reconcileChildren 내부
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren) {
let existingChildren = new Map();
// 1. 기존 Fiber를 key로 Map에 저장
let oldFiber = currentFirstChild;
while (oldFiber !== null) {
if (oldFiber.key !== null) {
existingChildren.set(oldFiber.key, oldFiber);
}
oldFiber = oldFiber.sibling;
}
// 2. 새 Element와 key 매칭
for (let i = 0; i < newChildren.length; i++) {
const newChild = newChildren[i];
const matchedFiber = existingChildren.get(newChild.key);
if (matchedFiber && matchedFiber.type === newChild.type) {
// Fiber 재사용 (DOM 유지)
cloneFiber(matchedFiber, newChild.props);
} else {
// 새 Fiber 생성
createFiber(newChild);
}
}
}import { Profiler } from 'react';
function onRenderCallback(
id,
phase, // "mount" | "update"
actualDuration, // 렌더링 소요 시간
baseDuration, // 메모이제이션 없을 때 예상 시간
startTime,
commitTime
) {
console.log(`${id} ${phase}: ${actualDuration}ms`);
}
<Profiler id="App" onRender={onRenderCallback}>
<App />
</Profiler>DevTools Profiler 탭 사용:
1. React DevTools 설치
2. Profiler 탭 → 녹화 시작
3. 앱 조작
4. 녹화 중지
확인 사항:
- Flamegraph: 컴포넌트별 렌더링 시간
- Ranked: 가장 느린 컴포넌트 순위
- Why did this render?: 렌더링 원인 분석
Components 탭 → 컴포넌트 선택 → Fiber 아이콘 클릭
확인 가능한 정보:
- Fiber 타입 (FunctionComponent, HostComponent 등)
- Props, State, Hooks
- Rendered by (부모 컴포넌트)
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}Render Phase:
Commit Phase:
결론: 단순한 컴포넌트의 리렌더링은 오버헤드가 미미합니다.
// [권장] 최적화 필요: 무거운 계산
const HeavyChart = React.memo(({ data }) => {
const processed = useMemo(() => {
return expensiveDataProcessing(data); // 10ms
}, [data]);
return <Chart data={processed} />;
});
// [주의] 과도한 최적화: 단순한 컴포넌트
const SimpleButton = React.memo(({ onClick }) => {
return <button onClick={onClick}>Click</button>;
});
// React.memo 비교 비용 > 렌더링 비용import { unstable_batchedUpdates } from 'react-dom';
// [주의] 여러 번 렌더링 트리거
function handleScroll(e) {
setScrollY(e.scrollY);
setIsScrolling(true);
setLastScrollTime(Date.now());
// 3번의 렌더링 발생 (모바일에서 배터리 소모)
}
// [권장] Batching으로 1번만 렌더링
function handleScroll(e) {
unstable_batchedUpdates(() => {
setScrollY(e.scrollY);
setIsScrolling(true);
setLastScrollTime(Date.now());
});
// 1번의 렌더링만 발생
}React 18 자동 Batching:
// React 18에서는 자동으로 batching
setTimeout(() => {
setCount(1);
setFlag(true);
// 자동으로 1번만 렌더링 (이전에는 2번)
}, 1000);React Fiber 아키텍처는 렌더링을 작은 단위로 나누어 중단/재개 가능하게 만들어, 동시성 렌더링의 기반을 제공합니다.
핵심 권장사항:
Fiber의 동작 원리 이해:
최적화 전략:
성능 측정:
Concurrent Features 활용:
Fiber는 React의 선언적 API를 유지하면서도 내부적으로 정교한 스케줄링을 수행하여, 복잡한 UI에서도 부드러운 사용자 경험을 제공합니다.
Context API와 React Query의 장점을 결합한 최적화된 상태 관리 솔루션
React lazy와 Next.js dynamic의 SSR 동작 차이와 최적의 코드 스플리팅 전략
React가 클래스 컴포넌트의 한계를 극복하고 함수형 컴포넌트와 Hooks를 도입한 기술적 배경과 설계 철학 완벽 분석