React 완벽 가이드 | Fiber·가상 DOM·훅·동시성·프로덕션 패턴
이 글의 핵심
React는 선언적 UI를 컴포넌트와 상태로 표현하고, 내부적으로 Fiber 조정(reconciliation) 과 가상 DOM 비교(diffing) 을 통해 실제 DOM 변경을 최소화합니다. 이 글은 기본 사용법과 함께 Fiber 작업 루프, 자식 리스트 재조정, 훅이 Fiber에 매달리는 방식, Concurrent 스케줄링, 프로덕션에서 통하는 패턴을 연결해 설명합니다.
실무 맥락: 대시보드·채팅·테이블처럼 업데이트가 잦은 UI에서는 “리렌더 범위 줄이기”와 “동시성·서스펜스로 체감 성능 올리기”가 곧 사용자 만족으로 이어집니다.
1. React란 무엇인가
React는 UI = f(state) 라는 관점에서, 상태가 바뀌면 다음 UI 표현(React element tree) 을 다시 계산하고, 이전 트리와 비교해 필요한 부분만 실제 DOM(또는 네이티브 뷰)에 반영합니다. 라이브러리 본체는 렌더링 대상에 대해 추상화되어 있어, react-dom, react-native, Canvas/WebGL 등 다양한 렌더러로 확장됩니다.
한 줄 요약: 개발자는 “어떤 화면이어야 하는지”에 집중하고, React는 “어떻게 효율적으로 바꿀지”를 담당합니다.
2. 프로젝트 시작
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
npm run dev
React 18 이상에서는 루트를 createRoot로 생성합니다.
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
);
StrictMode는 개발 모드에서 이중 호출·레거시 API 경고 등 숨은 문제를 드러내기 위한 도구입니다. 프로덕션 빌드에서는 부작용이 없습니다.
3. 컴포넌트·JSX·기본 훅
함수 컴포넌트는 훅으로 상태와 부수 효과를 표현합니다.
import { useState, useEffect, useMemo, useCallback } from 'react';
type User = { id: string; name: string };
export function UserPanel({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const res = await fetch(`/api/users/${userId}`);
const data: User = await res.json();
if (!cancelled) setUser(data);
})();
return () => {
cancelled = true;
};
}, [userId]);
const title = useMemo(() => (user ? `Hello, ${user.name}` : 'Loading…'), [user]);
const onRetry = useCallback(() => {
setUser(null);
}, []);
return <section aria-label={title}>{user?.name ?? '…'}</section>;
}
useEffect의 의존성 배열은 “이 값들이 바뀔 때만 effect를 다시 실행”한다는 계약입니다.userId가 바뀌면 이전 요청을 취소(cleanup)하는 패턴은 경쟁 조건(race)을 줄입니다.useMemo/useCallback은 참조 동일성이 중요한 자식(React.memo)이나 의존 배열 안정화에 쓰입니다. 남용하면 오히려 가독성만 떨어지므로 측정 후 적용하는 것이 좋습니다.
4. Fiber 조정 알고리즘 (심화)
React 16부터 재조정(Reconciliation) 은 Stack reconciler가 아니라 Fiber reconciler로 동작합니다. 목표는 (1) 작업을 잘게 쪼개 메인 스레드를 오래 점유하지 않게 하고, (2) 우선순위에 따라 업데이트를 재배치하며, (3) 중단·재개·폐기 가능한 렌더링 파이프라인을 만드는 것입니다.
4.1 렌더 단계 vs 커밋 단계
- 렌더(재조정) 단계:
workInProgressFiber 트리를 만들고, 어떤 변경이 필요한지 계산합니다. 순수해야 하며(입력 props/state만 사용), 중단 가능합니다. - 커밋 단계: 실제 DOM 변경, 레이아웃·페인트에 영향을 주는 부분을 수행합니다. 동기적이며 중단하지 않습니다.
이 분리 덕분에 React는 렌더 도중 더 급한 업데이트(예: 입력)가 들어오면 진행 중이던 렌더를 버리고 새 작업을 우선할 수 있습니다(Concurrent 모드에서 특히 중요).
4.2 Fiber 노드와 연결 리스트 트리
각 React 컴포넌트 인스턴스·DOM 노드에 대응하는 Fiber는 대략 다음 정보를 가집니다(개념적 모델).
child/sibling/return: 트리를 연결 리스트로 순회하기 위한 포인터입니다. 깊이 우선 탐색을 스택 없이 구현해, 작업 단위를 나누기 쉽습니다.alternate: 현재 트리(current) 와 진행 중 트리(workInProgress) 를 오가며 더블 버퍼링합니다. 한쪽에서 작업이 끝나면 포인터를 스왑해 커밋합니다.memoizedProps/memoizedState: 이번 렌더에서 확정된 props와 훅 리스트의 머리 등 상태 슬롯이 들어갑니다.
즉, Fiber는 “화면에 그려진 결과”만이 아니라 스케줄러가 붙잡을 수 있는 작업 카드입니다.
4.3 workLoop와 시간 분할
스케줄러는 message channel·requestIdleCallback 계열 기법으로 프레임마다 할당된 시간 안에서 Fiber 작업을 처리합니다. 시간이 부족하면 yield하고 브라우저가 입력·페인트를 처리한 뒤 이어서 렌더링합니다. 이것이 Time slicing의 기반이 됩니다.
정리하면, Fiber 조정은 “트리 비교”를 한 번에 끝내는 알고리즘이 아니라, 작업 큐를 순회하며 조금씩 진행하는 루프에 가깝습니다.
4.4 beginWork / completeWork와 자식 재조정
렌더 단계는 각 Fiber에 대해 대략 beginWork(무엇을 할지 결정·자식으로 진입) 과 completeWork(부모로 거슬러 올라가며 후처리) 로 나뉩니다. 호스트 컴포넌트(div 등)는 속성 diff 후 자식 Fiber를 붙이고, 클래스/함수 컴포넌트는 함수 본문을 실행해 자식 element를 얻습니다.
자식 배열을 맞출 때 React는 키가 있는 자식과 없는 자식을 다르게 처리합니다. 키가 없으면 위치 기반으로 짝을 맞추고, 키가 있으면 이전 자식 맵을 만들어 O(n)에 가깝게 매칭합니다. 그래서 리스트에는 항상 안정적인 key 를 권장합니다.
4.5 이펙트 리스트와 커밋 서브단계
커밋은 한 번에 끝나지 않고, DOM 변형 → 레이아웃(useLayoutEffect) → 페인트 이후(useEffect) 순서로 나뉩니다. 레이아웃을 읽거나 동기적으로 DOM을 맞춰야 할 때만 useLayoutEffect를 쓰고, 대부분의 구독·로깅·fetch 트리거는 useEffect에 두는 것이 메인 스레드 블로킹을 줄입니다.
5. 가상 DOM과 diffing (심화)
가상 DOM은 메모리 상의 React element 트리(경량 객체)입니다. 렌더마다 새 트리를 만들고, 이전 Fiber/props와 비교해 최소 변경 집합을 냅니다.
5.1 엘리먼트 타입이 다르면
루트 타입이 div → section처럼 바뀌거나, 컴포넌트 함수 참조가 달라지면 React는 해당 서브트리를 대체(replace) 합니다. 내부 상태는 대부분 유실되므로, 같은 위치에 다른 컴포넌트 타입을 조건부로 넣는 패턴은 주의가 필요합니다.
5.2 같은 타입의 DOM 엘리먼트
같은 태그명이면 속성만 갱신합니다. className, style, 이벤트 핸들러 등은 diff 알고리즘이 효율적으로 패치합니다.
5.3 자식 리스트와 key
형제 노드들은 key 로 안정적인 동일성을 표현합니다. key는 React에 “이 항목이 이전의 어떤 항목과 같은지”를 알려 주는 힌트입니다.
- 배열 인덱스를
key로 쓰면, 재정렬·삽입 시 잘못된 재사용으로 입력 포커스가 튀거나 내부 상태가 엉킬 수 있습니다. - 같은 데이터의 안정적인 ID(DB id, 자연키)를 쓰는 것이 안전합니다.
5.4 재조정의 한계와 비용
diffing은 휴리스틱입니다(O(n) 수준을 목표로 설계). 그래도 깊은 트리 전체를 자주 다시 reconcile하면 비용이 큽니다. 그래서 실무에서는 상태 범위 줄이기, children 메모, 가상 스크롤, 코드 분할로 트리 탐색량 자체를 줄입니다.
5.5 “같은 위치” 규칙과 컴포넌트 동일성
재조정은 부모 아래 자식 슬롯을 기준으로 이루어집니다. 같은 슬롯에 같은 컴포넌트 함수 참조가 오면 “같은 인스턴스”로 보고 state를 유지합니다. 조건에 따라 완전히 다른 두 컴포넌트를 같은 슬롯에 넣으면 타입이 바뀐 것으로 보아 state가 초기화됩니다. UI를 바꿀 때 state를 보존하려면 한 컴포넌트 안에서 분기하거나, key로 의도적으로 인스턴스를 분리하세요.
6. 훅 구현 관점 (심화)
훅은 마법이 아니라 Fiber의 memoizedState 슬롯에 걸린 연결 리스트입니다. 각 훅 호출은 노드 하나를 만들고, 다음 훅이 next로 이어집니다.
6.1 useState와 디스패처
useState는 Fiber에 현재 값·업데이트 큐·다음 훅 포인터를 저장합니다. 이벤트 핸들러에서 setState를 호출하면 해당 Fiber를 업데이트 대상으로 표시하고, 스케줄러가 렌더를 예약합니다.
6.2 훅의 순서가 깨지면 안 되는 이유
조건문·반복문 안에서 훅을 호출하면 렌더마다 훅 노드 개수가 달라져 연결 리스트가 깨집니다. 그 결과 이전 렌더와 엉뚱한 훅 데이터가 짝지어져 치명적 버그가 납니다. 이것이 Rules of Hooks의 이유입니다.
6.3 useEffect 큐
useEffect는 렌더 이후 비동기로 실행되며, 의존성 비교 후 구독 해제·재설정이 일어납니다. 레이아웃 측정이 필요하면 useLayoutEffect(동기, 커밋 직후)를 고려합니다.
6.4 마운트 vs 업데이트 디스패처
React 내부에는 렌더 단계마다 현재 디스패처가 있고, useState 등은 첫 렌더(마운트) 와 이후 렌더(업데이트) 에 서로 다른 구현 경로를 탑니다. 마운트 시에는 초기값으로 슬롯을 만들고, 업데이트 시에는 기존 큐에 업데이트를 쌓습니다. 커스텀 훅도 결국 같은 규칙으로 호출되므로, 커스텀 훅 안에서만 조건부 훅을 쓰는 것도 금지입니다—호출 수가 바뀌면 Fiber의 훅 리스트와 어긋납니다.
6.5 useReducer와 배치 업데이트
useReducer는 복수 액션을 한 번에 처리하기 좋고, React 18의 자동 배칭은 Promise·타이머 안에서도 기본적으로 적용됩니다. 동일 이벤트에서 나온 여러 setState는 한 번의 리렌더로 합쳐지는 경우가 많아, 상태 구조를 쪼개되 불필요한 중간 렌더는 줄일 수 있습니다.
7. Concurrent 렌더링 (심화)
React 18의 createRoot는 동시성 기능을 켭니다. 모든 업데이트가 Concurrent인 것은 아니며, 우선순위(Lane) 로 나뉩니다.
- 긴급 업데이트: 입력, 클릭 등 즉각적인 피드백이 필요한 것.
- 전환(transition) 업데이트:
startTransition으로 표시한, 늦어도 되는 렌더링.
useTransition / useDeferredValue는 사용자 입력은 즉시 반영하고, 무거운 결과 렌더는 뒤로 미루는 데 유용합니다. Suspense와 결합하면 데이터·코드 로딩 경계를 선언적으로 표현할 수 있습니다.
주의: Concurrent 환경에서는 렌더가 여러 번 시도되거나 중간에 폐기될 수 있으므로, 렌더 단계에서 부수 효과를 내지 말아야 합니다. 데이터 패칭은 Effect나 라이브러리(React Query 등)에 맡기고, StrictMode 개발 모드의 이중 호출에도 안전하게 작성합니다.
7.2 Lane 모델과 기아(starvation) 방지
내부적으로 업데이트는 Lane 단위로 묶입니다. 입력 같은 긴급 작업이 계속 들어오면 무거운 transition이 뒤로 밀릴 수 있지만, 스케줄러는 오래 기다린 작업에 결국 턴을 주는 방향으로 조정합니다. 그래도 너무 큰 트랜지션(거대 리스트 전부 다시 그리기)은 useDeferredValue만으로는 부족할 수 있어, 데이터 청크·가상화가 함께 가야 합니다.
7.3 useSyncExternalStore와 외부 스토어
전역 스토어(Redux, Zustand 등)를 React에 붙일 때 tearing(한 화면에서 스냅샷이 달라 보이는 현상)을 막으려면 useSyncExternalStore 패턴이 권장됩니다. Concurrent 렌더링과 함께 쓸 때 구독 일관성이 중요해진 이유입니다.
8. 프로덕션 React 패턴
8.1 경계와 복구
- Error Boundary: 하위 트리의 렌더·라이프사이클 오류를 가로채 fallback UI를 보여 줍니다(이벤트 핸들러 내부 에러는 잡지 못하므로 별도 처리 필요).
- Suspense: 로딩 경계. 라우터·데이터 레이어와 함께 쓰면 폴더 구조·라우트 단위 코드 분할이 쉬워집니다.
8.2 상태 설계
- 서버 상태(캐시·재검증)와 UI 상태(모달, 입력)를 분리합니다.
- 불필요한 전역화를 피하고, 상태를 가장 가까운 공통 부모에 둡니다.
8.3 성능 습관
- 목록: 안정적인
key, 가능하면 정규화된 데이터 + ID 조회. - 메모:
memo/useMemo는 측정된 병목에 적용. props가 자주 바뀌는 컴포넌트에memo만 붙이면 효과가 없습니다. - 번들: 동적
import(), 라우트 단위 스플리팅, 트리 쉐이킹 친화적인 import(lodash-es등).
8.4 접근성·폼
- 시맨틱 태그, 키보드 포커스,
aria-*속성을 기본값으로 검토합니다. - 복잡한 폼은 제어/비제어를 구분하고, 검증·에러 메시지·서버 오류를 일관되게 매핑합니다(
react-hook-form등).
8.5 합성 패턴·컨텍스트·성능
- 합성(Composition):
children이나 슬롯 prop으로 유연하게 조립하면 props drilling을 줄이면서도 타입을 좁힐 수 있습니다. - Context: 자주 바뀌는 값을 거대 Context에 넣으면 구독한 모든 소비자가 리렌더됩니다. 자주 변하는 값은 작은 Context로 쪼개거나, 외부 스토어 +
useSyncExternalStore를 검토합니다. - 렌더 props / 커스텀 훅: UI 재사용은 “로직만 공유”할 때 훅으로 빼면 테스트와 재사용이 쉬워집니다.
8.6 서버 컴포넌트·프레임워크와의 역할 분담
Next.js App Router 등에서 React Server Components를 쓰면 데이터 패칭·큰 의존성을 서버로 옮겨 클라이언트 번들을 줄일 수 있습니다. 클라이언트 컴포넌트는 상호작용·브라우저 API에 집중하고, 경계를 명확히 그으면 Fiber 트리와 번들 양쪽 모두가 가벼워집니다.
8.7 테스트·관측 가능성
- 단위: 훅과 순수 유틸은 Jest/Vitest로, 컴포넌트는 Testing Library로 “사용자처럼” 검증합니다.
- E2E: Playwright/Cypress로 핵심 플로우를 고정합니다.
- 프로덕션: Error Boundary + 로깅(Sentry 등), Core Web Vitals, React DevTools Profiler로 렌더 병목을 확인합니다.
9. 정리
React는 선언적 UI를 넘어, Fiber 기반 중단 가능한 렌더링, 휴리스틱 diff, 훅으로 표현된 상태 머신, Concurrent 스케줄링이 맞물려 동작합니다. 이 내부를 이해하면 “왜 렌더가 두 번 보이는가(StrictMode)”, “왜 훅 순서가 고정인가”, “언제 transition을 써야 하는가” 같은 질문에 설계 관점으로 답할 수 있습니다. 그 위에 경계(Error/Suspense)·상태 분리·측정 기반 최적화를 얹으면 프로덕션에서도 읽기 쉽고 견고한 코드가 됩니다.
더 읽을 거리
- 같은 블로그의 React 18 심화 가이드에서 Concurrent API와 Suspense를 더 깊게 다룹니다.
- TypeScript와 함께 쓸 때는 TypeScript 가이드와 함께 보시면 타입 설계가 수월합니다.
내부 동작과 핵심 메커니즘
이 글의 주제는 「React 완벽 가이드 | Fiber·가상 DOM·훅·동시성·프로덕션 패턴」입니다. 앞선 튜토리얼을 구현·런타임 관점에서 다시 압축합니다. 시스템·런타임 경계(스케줄링, I/O, 메모리, 동시성)를 기준으로 “입력이 어디서 검증되고, 핵심 연산이 어디서 일어나며, 부작용(I/O·네트워크·디스크)·동시성이 어디서 터지는가”를 한 장면으로 그리면 장애 분석이 빨라집니다.
처리 파이프라인(개념도)
flowchart TD A[입력·요청·이벤트] --> B[파싱·검증·디코딩] B --> C[핵심 연산·상태 전이] C --> D[부작용: I/O·네트워크·동시성] D --> E[결과·관측·저장]
경계에서의 지연·실패(시퀀스 관점)
sequenceDiagram participant C as 클라이언트/호출자 participant B as 경계(프로세스·런타임·게이트웨이) participant D as 의존성(외부 API·DB·큐) C->>B: 요청/이벤트 B->>D: 조회·쓰기·RPC D-->>B: 지연·부분 실패·재시도 가능 B-->>C: 응답 또는 오류(코드·상관 ID)
알고리즘·프로토콜·리소스 관점 체크포인트
- 불변 조건(Invariant): 각 단계가 만족해야 하는 조건(버퍼 경계, 프로토콜 상태, 트랜잭션 격리, 파일 디스크립터 상한)을 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
- 결정성: 동일 입력에 동일 출력이 보장되는 순수 층과, 시간·네트워크·스레드 스케줄에 의해 달라질 수 있는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
- 경계 비용: 직렬화/역직렬화, 문자 인코딩, syscall 횟수, 락 경합, GC·할당, 캐시 미스처럼 누적 비용을 의심 목록에 넣습니다.
- 백프레셔: 생산자가 소비자보다 빠를 때(소켓 버퍼, 큐 깊이, 스트림) 어디서 어떤 신호로 속도를 줄일지 정의합니다.
프로덕션 운영 패턴
실서비스에서는 기능과 함께 관측·배포·보안·비용·규제가 동시에 요구됩니다.
| 영역 | 운영 관점 질문 |
|---|---|
| 관측성 | 요청 단위 상관 ID, 에러율/지연 분위수(p95/p99), 의존성 타임아웃·재시도가 대시보드에 보이는가 |
| 안전성 | 입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가 |
| 신뢰성 | 재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가 |
| 성능 | 캐시 계층·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가 |
| 배포 | 롤백 룬북, 카나리/블루그린, 마이그레이션 호환성·플래그가 문서화되어 있는가 |
| 용량 | 피크 트래픽·디스크·파일 디스크립터·스레드 풀 상한을 주기적으로 검증하는가 |
스테이징은 데이터 양·네트워크 RTT·동시성을 가능한 한 프로덕션에 가깝게 맞추는 것이 재현율을 높입니다.
확장 예시: 엔드투엔드 미니 시나리오
「React 완벽 가이드 | Fiber·가상 DOM·훅·동시성·프로덕션 패턴」을 실제 배포·운영 흐름으로 옮긴 체크리스트형 시나리오입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.
- 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드 표를 API 또는 이벤트 경계에 둔다.
- 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 한 화면(로그+메트릭+트레이스)에서 추적한다.
- 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
- 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지(또는 피처 플래그) 확인한다.
- 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값이 기대 범위인지 본다.
의사코드 스케치(프레임워크 무관)
handle(request):
ctx = newCorrelationId()
validated = validateSchema(request) // 경계에서 거절
authorize(validated, ctx) // 권한·테넌트
result = domainCore(validated) // 순수에 가까운 규칙
persistOrEmit(result, idempotentKey) // I/O: 멱등·재시도 정책
recordMetrics(ctx, latency, outcome)
return result
문제 해결(Troubleshooting)
| 증상 | 가능 원인 | 조치 |
|---|---|---|
| 간헐적 실패 | 레이스, 타임아웃, 외부 의존성 불안정, DNS | 최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검 |
| 성능 저하 | N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스 | 프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거 |
| 메모리 증가 | 캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납 | 상한·TTL·힙/FD 스냅샷 비교 |
| 빌드·배포만 실패 | 환경 변수, 권한, 플랫폼 차이, lockfile | CI 로그와 로컬 diff, 런타임·이미지 버전 핀 |
| 설정이 로컬과 다름 | 프로필·시크릿·기본값, 지역 리전 | 단일 소스(예: 스키마 검증된 설정)와 배포 매트릭스 표준화 |
| 데이터 불일치 | 비멱등 재시도, 부분 쓰기, 캐시 무효화 누락 | 멱등 키·아웃박스·트랜잭션 경계 재검토 |
권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.