5-2. 렌더링과 재조정
한 줄 요약: 이 문서를 읽고 나면 렌더/커밋의 2단계 파이프라인과 재조정 휴리스틱(타입 비교, key)으로 "이 상태 변경에서 무엇이 다시 렌더되고 어떤 상태가 살아남는가"를 예측할 수 있다.
이 문서는 React 19 기준이다.
학습 목표
- 렌더 단계와 커밋 단계의 분리를 설명하고, 각 단계에서 일어나는 일과 일어나면 안 되는 일을 구분할 수 있다.
- 재조정이 O(n³) 트리 diff를 O(n)으로 낮추기 위해 채택한 두 가정(타입, key)과 그 가정이 깨지는 상황을 설명할 수 있다.
- "입력값이 엉뚱한 행에 남아 있다", "타이핑하면 포커스가 날아간다" 류의 사고를 재조정의 신원 판정으로 진단할 수 있다.
- 리렌더 전파 규칙("부모가 렌더하면 자식도 렌더한다")과 전파가 멈추는 조건을 근거와 함께 설명할 수 있다.
배경: 왜 이것이 존재하는가
5-1에서 렌더는 새 요소 트리(서술)를 만드는 일이라고 했다. 남은 문제는 두 서술 사이의 변환이다: 이전 트리를 새 트리로 만드는 최소 DOM 조작 집합을 찾아야 한다.
이것은 알려진 문제다 — 트리 편집 거리(tree edit distance). 일반해는 O(n³)으로, 노드 1,000개짜리 트리에서 상태가 바뀔 때마다 10억 번 규모의 비교를 해야 한다. 어떤 UI 프레임워크도 이 비용을 낼 수 없으므로, React는 정확한 최소해를 포기하고 두 가정 위에서 O(n) 휴리스틱을 쓴다.
- 타입이 다르면 다른 트리다.
<div>가<section>으로 바뀌면 내부를 비교하지 않고 서브트리를 통째로 버리고 새로 만든다. 실무에서 타입이 바뀌는데 내부가 대부분 같은 경우는 드물다는 관찰에 근거한다. - 형제 간의 신원은 key로 선언된다. 목록에서 어떤 항목이 "같은 항목"인지는 위치가 아니라 개발자가 준 key로 판정한다.
휴리스틱이라는 사실이 이 문서의 핵심이다. 가정이 맞는 동안 재조정은 보이지 않는 최적화지만, 가정이 깨지는 코드(렌더 중 컴포넌트 정의, index key)를 쓰면 상태가 사라지거나 엉뚱한 곳에 남는, 원인을 짐작하기 어려운 사고가 난다. DB에 빗대면 재조정은 옵티마이저다 — 쿼리(JSX)만 쓰는 동안은 존재를 잊어도 되지만, 실행 계획이 틀어지는 순간(인덱스를 안 타는 순간) 옵티마이저의 가정을 아는 사람만 원인을 찾는다. key는 그 옵티마이저에게 주는 힌트다.
핵심 개념
렌더 단계와 커밋 단계
상태 갱신에서 화면 반영까지는 두 단계로 나뉜다.
setState → [렌더 단계] → [커밋 단계]
컴포넌트 함수 호출(요소 트리 생성) DOM 삽입/갱신/삭제
이전 트리와 비교(diff) ref 연결, 이펙트 예약
= 순수 계산, 중단/폐기/재시도 가능 = 동기 일괄 실행, 중단 불가렌더 단계는 계산이다. 컴포넌트 함수를 호출해 새 요소 트리를 만들고, 이전 트리와 비교해 "무엇을 바꿔야 하는가"의 목록(effect list)을 만든다. DOM은 건드리지 않는다. 순수 계산이므로 React는 이 단계를 잘게 쪼개 수행하거나, 더 급한 갱신이 오면 하던 것을 버리고 다시 시작할 수 있다(동시성 렌더링 — 아래 더 깊이). 5-1에서 컴포넌트의 순수성을 전제라고 한 이유가 이 "폐기 가능성"이다.
커밋 단계는 반영이다. 변경 목록을 실제 DOM에 일괄 적용하고, ref를 연결하고, 이펙트를 예약한다. 이 단계는 동기로 한 번에 끝난다 — 절반만 적용된 DOM을 사용자가 보는 일이 없도록.
이 분리를 직접 관찰할 수 있다. 렌더 로그는 찍히는데 DOM이 안 바뀌는 경우(diff 결과 변경 없음)가 그 증거다:
function Badge({ count }) {
console.log('render'); // 렌더 단계의 증거
return <span>{count > 99 ? '99+' : count}</span>;
}
// count가 100 → 150으로 바뀌면: "render"는 찍히지만(렌더 수행)
// 두 서술 모두 <span>99+</span>이므로 DOM 변경은 없다(커밋할 것 없음)리렌더 ≠ DOM 갱신이라는 이 구분은 5-5 성능 판단의 출발점이다. DevTools Performance 패널에서 확인하면, 렌더는 JS 실행(함수 호출 스택)으로, 커밋은 그 뒤의 DOM 변경과 이어지는 스타일/레이아웃 작업으로 나타난다.
신원 판정 1 — 타입 비교와 그 귀결
재조정은 트리를 위에서 아래로 내려가며 같은 위치의 이전/새 요소를 비교한다.
- 같은 타입(예: 둘 다
<div>, 둘 다Counter): "같은 것"으로 판정. 호스트 요소면 달라진 속성만 갱신하고, 컴포넌트면 인스턴스(훅 상태)를 유지한 채 새 props로 함수를 다시 호출한다. 그리고 children으로 내려가 재귀한다. - 다른 타입(
<div>→<section>,Counter→Timer): 비교 중단. 이전 서브트리를 통째로 언마운트(상태 파괴, 클린업 실행)하고 새 서브트리를 처음부터 마운트한다.
이 규칙의 유명한 함정이 렌더 중 컴포넌트 정의다.
// ❌ 부모가 렌더될 때마다 Inner가 "새 함수"로 다시 정의된다
function Outer() {
const [n, setN] = useState(0);
function Inner() { // 매 렌더 새로운 함수 객체 = 새로운 타입
const [text, setText] = useState('initial');
return <input value={text} onChange={e => setText(e.target.value)} />;
}
return (
<div>
<button onClick={() => setN(n + 1)}>{n}</button>
<Inner />
</div>
);
}Inner에 "typed"를 입력한 뒤 버튼을 누르면 입력이 "initial"로 돌아간다. 재조정 관점에서 보면 당연하다: 타입 비교는 함수의 참조 동일성으로 하는데, Outer가 재실행되면 Inner는 새 함수 객체다. 이전 Inner와 새 Inner는 다른 타입이므로 서브트리 언마운트 → 재마운트, 상태는 초기값으로. 실제로 실행해 보면:
// 입력 후: <input> 값 = "typed"
// 부모 리렌더 후: <input> 값 = "initial" ← 상태 파괴 + 포커스 소실증상이 "타이핑할 때마다 포커스가 날아간다"로 나타나는 경우(입력이 부모 상태를 갱신 → 부모 렌더 → Inner 재마운트)가 실무에서 가장 흔하다. 수정은 컴포넌트를 모듈 최상위로 옮기는 것뿐이다. 렌더 간에 같은 함수 참조가 유지되는 위치라면 어디든 된다 — 바꿔 말하면, 컴포넌트 정의는 렌더보다 오래 사는 스코프에 있어야 한다.
신원 판정 2 — key
형제가 여러 개일 때 위치 기반 비교는 목록 조작에서 무너진다. key는 "위치가 아니라 이 값이 신원이다"라는 선언이다.
index를 key로 쓰면 선언이 무의미해진다(위치 = index이므로 위치 비교와 동일). 맨 앞 삽입에서 무슨 일이 나는지 직접 관찰하면:
function List() {
const [items, setItems] = useState(['사과', '바나나']);
return (
<>
<button onClick={() => setItems(['체리', ...items])}>맨 앞에 체리 추가</button>
{/* ❌ index key: 위치가 신원이 된다 */}
<ul>{items.map((item, i) => <li key={i}><input defaultValue={item} /></li>)}</ul>
</>
);
}
// 첫 번째 입력을 "사과(수정함)"으로 고친 뒤 버튼을 누르면 —
// 데이터: ['체리', '사과', '바나나']
// 화면의 입력: ['사과(수정함)', '바나나', '바나나'] ← 실측 결과재조정의 판정을 따라가면: key=0인 이전 <li>(사과 입력)와 key=0인 새 <li>(체리)가 "같은 것"으로 매칭되어 DOM 입력(수정한 값 포함)이 유지되고, defaultValue는 마운트 시에만 적용되므로 화면은 옛 값 그대로다. key=2는 새로 등장했으므로 마운트되어 defaultValue="바나나"가 찍힌다. 결과: 데이터와 화면이 어긋난 채 에러 하나 없이 동작한다. 삭제·정렬에서도 같은 메커니즘으로 상태(입력값, 체크 상태, 열림 상태, 컴포넌트 훅 상태)가 한 칸씩 밀린다.
// ✅ 데이터의 안정적 신원을 key로: 삽입/삭제/정렬에도 각 항목의 상태가 항목을 따라간다
{items.map(item => <li key={item.id}><input defaultValue={item.name} /></li>)}key의 요건은 형제 안에서 유일 + 렌더 간 안정이다. Math.random()이나 렌더 중 생성한 crypto.randomUUID()는 매 렌더 다른 key가 되어 전 항목이 매번 재마운트된다 — index key의 반대 방향으로 최악이다.
같은 메커니즘을 의도적으로 쓸 수도 있다. key를 바꾸면 같은 위치·같은 타입이라도 강제로 재마운트되므로, "사용자를 전환하면 프로필 폼을 완전히 리셋"이 <ProfileForm key={userId} /> 한 줄로 된다. 상태를 하나하나 초기화하는 이펙트보다 이 쪽이 선언적이고 누락이 없다.
리렌더 전파 규칙
리렌더가 어디까지 번지는지는 하나의 규칙으로 결정된다: 컴포넌트가 렌더되면 그 반환 트리 안의 모든 컴포넌트도 렌더된다. props가 바뀌었는지는 보지 않는다.
function Child() {
console.log('Child render');
return <span>child</span>;
}
function Parent() {
const [n, setN] = useState(0);
console.log('Parent render', n);
return <div onClick={() => setN(n + 1)}><Child /></div>;
}
// 클릭 시 출력:
// Parent render 1
// Child render ← Child의 props는 아무것도 없는데도 렌더된다"props가 안 바뀌면 리렌더 안 된다"는 통념은 틀렸다. 이유는 비용 구조에 있다: props가 바뀌었는지 판정하려면 이전/새 props를 비교해야 하는데, 렌더는 대개 그 비교보다 싸다(함수 호출 + diff에서 대부분 "변경 없음"으로 끝난다). 그래서 React의 기본값은 "일단 렌더하고 diff가 걸러낸다"이고, 비교로 렌더를 생략하는 것(memo)은 비교가 이득인 곳에만 명시적으로 켠다 — 이 판단이 5-5의 주제다.
전파가 저절로 멈추는 중요한 예외가 하나 있다. 렌더에서 새로 만들어지지 않은 요소 — 즉 props로 받은 요소는, 이전 렌더와 같은 객체 참조이므로 React가 "서술이 그대로다"라고 판정하고 그 서브트리를 건너뛴다.
function Wrapper({ children }) {
const [n, setN] = useState(0);
console.log('Wrapper render', n);
return <div onClick={() => setN(n + 1)}>{children}</div>;
}
// 사용: <Wrapper><Child /></Wrapper>
// 클릭 시 출력:
// Wrapper render 1
// (Child render 없음) ← children은 Wrapper 밖에서 만들어진 같은 요소 참조<Child /> 요소는 Wrapper의 부모가 렌더될 때 만들어졌고, Wrapper가 자기 상태로 리렌더될 때 children prop은 같은 객체다. 5-1에서 본 "요소는 불변 객체"라는 사실이 여기서 배당금을 준다 — 같은 참조 = 같은 서술이 보장되므로 건너뛰기가 안전하다. 이 "children 패턴"은 memo 없이 전파를 끊는 구조적 수단으로 5-5와 5-6(Provider가 children을 받는 구조)에서 반복 등장한다.
실무 관점
재조정 사고의 진단 절차
증상 → 원인의 대응표. 셋 다 에러 없이 동작이 이상해지는 유형이라 모르면 오래 헤맨다.
| 증상 | 원인 | 수정 |
|---|---|---|
| 타이핑하면 포커스가 날아간다 / 상태가 초기화된다 | 렌더 중 컴포넌트 정의(매 렌더 새 타입 → 재마운트) | 컴포넌트를 모듈 최상위로 |
| 목록 조작 후 입력값·체크·열림 상태가 옆 항목에 가 있다 | index key(위치가 신원) | 데이터의 안정적 id를 key로 |
| 목록이 조작할 때마다 전부 깜빡인다 / 애니메이션이 다시 돈다 | 렌더마다 생성되는 key(random, uuid) | 렌더 밖에서 만든 안정적 id |
| 조건 분기에서 상태가 의도와 다르게 유지/파괴된다 | 같은 위치·같은 타입은 인스턴스 유지 | 의도적 리셋이 필요하면 key 부여 |
네 번째 항목의 예: cond ? <Input placeholder="A" /> : <Input placeholder="B" />는 분기가 바뀌어도 같은 위치·같은 타입이라 입력값이 유지된다. 이것이 의도라면 그대로 두고, 리셋이 의도라면 각각 다른 key를 준다.
key에 대한 흔한 오해 정리
- key는 성능 힌트이기 이전에 정확성 장치다. "경고를 없애려고 index를 넣는다"는 것은 경고의 목적(신원 선언 요구)을 무효화하는 것이다.
- index key가 안전한 조건은 셋이 동시에 성립할 때뿐이다: 목록이 재정렬·삽입·삭제되지 않고, 항목에 상태(입력, 훅 상태)가 없고, 필터링되지 않는다. 이 조건은 리팩터링 한 번에 깨지므로, 처음부터 id를 쓰는 것이 방어적이다.
- key는 형제 스코프에서만 의미가 있다. 전역 유일할 필요는 없고, 다른 목록의 같은 key와 충돌하지 않는다.
렌더가 "다시 도는 것"을 두려워하지 않기
이 문서의 규칙을 처음 접하면 "이렇게 다 다시 렌더되면 느린 것 아닌가"라는 걱정이 따라온다. 순서를 지키자: 렌더는 계산이고 커밋은 diff가 걸러낸 것만 반영하므로, 대부분의 리렌더는 "함수 몇 개 실행"으로 끝난다. 성능 문제는 리렌더의 존재가 아니라 넓은 전파 × 무거운 서브트리의 곱에서 온다. 걱정은 계측(React DevTools Profiler)으로 대체하고, 실제로 문제일 때의 대응은 5-5에서 다룬다. 리렌더를 없애려고 상태를 React 밖으로 빼는 식의 우회는 그보다 훨씬 뒤의 선택지다(5-6).
더 깊이
Fiber — 중단 가능한 렌더의 자료구조
"렌더 단계는 중단·폐기 가능하다"를 구현 수준에서 지탱하는 것이 Fiber 아키텍처(React 16+)다. 재귀 함수 호출로 트리를 내려가면 콜 스택에 묶여 중간에 멈출 수 없으므로, React는 트리를 child/sibling/return 포인터를 가진 연결 리스트형 노드(Fiber) 로 펼쳐 두고 루프로 순회한다. 루프는 노드 하나 처리할 때마다 "더 급한 일이 있는가"를 확인하고 양보할 수 있다 — 3-5의 이벤트 루프 양보와 같은 원리를 프레임워크 내부에 재구축한 것이다.
각 컴포넌트의 Fiber 노드가 5-1에서 말한 "인스턴스"다. 훅 상태는 Fiber의 연결 리스트에 살고(5-3), React는 현재 화면의 Fiber 트리(current)와 작업 중 트리(workInProgress) 두 벌을 유지하며 커밋 시점에 포인터를 바꾼다(더블 버퍼링). 렌더 폐기가 싼 이유가 이 구조다 — workInProgress를 버리면 그만이고 current는 무사하다.
이것은 구현 세부다. React 팀은 Fiber의 구조에 의존하지 말라고 명시하며, 실제로 내부 필드는 버전마다 바뀐다. 여기서 가져갈 것은 구조 자체가 아니라 "렌더는 중단 가능해야 한다"는 요구가 아키텍처를 결정했다는 설계 인과다.
동시성 렌더링과 우선순위
렌더가 중단 가능해지면 갱신에 우선순위를 매길 수 있다. React 18+의 startTransition은 "이 갱신은 급하지 않다"는 선언으로, 급한 갱신(타이핑)이 오면 진행 중이던 전환 렌더를 버리고 급한 것부터 처리한 뒤 전환을 재시작한다. 검색 입력 + 무거운 결과 목록에서 타이핑 반응성을 지키는 도구다. 이 문서에서는 "렌더/커밋 분리가 이것의 전제"라는 위치만 확인한다 — 렌더가 부수 효과를 가지면 "버리고 재시작"이 관찰 가능한 오동작이 되므로, 순수성 전제(5-1)가 다시 등장하는 지점이다.
재조정의 목록 diff 알고리즘
형제 목록 비교의 실제 구현은 2패스다: 앞에서부터 key가 일치하는 동안 순서대로 소비하고, 어긋나는 지점부터는 이전 자식들을 key → Fiber 맵에 넣고 새 목록을 순회하며 맵에서 찾는다. 찾으면 재사용(필요시 이동 표시), 못 찾으면 새로 마운트, 맵에 남은 것은 삭제. LIS(최장 증가 부분 수열) 기반 최소 이동 계산은 하지 않는다 — 이 역시 정확한 최소해보다 단순한 O(n)을 택한 트레이드오프다(맨 앞 삽입 시 index key가 전 항목을 "변경"으로 만드는 비용이 여기서 나온다).
정리
- 상태 갱신은 렌더 단계(요소 트리 생성 + diff, 순수 계산, 중단/폐기 가능)와 커밋 단계(DOM 일괄 반영, 동기)로 나뉜다. 리렌더 ≠ DOM 갱신이다.
- 재조정은 O(n³) 트리 diff를 두 가정으로 O(n)에 타협한 휴리스틱이다: 타입이 다르면 다른 트리(서브트리 재마운트), 형제 간 신원은 key.
- 타입 비교는 참조 동일성이므로 렌더 중에 정의한 컴포넌트는 매 렌더 새 타입이 되어 상태를 파괴한다. 컴포넌트는 렌더보다 오래 사는 스코프에 정의한다.
- key는 신원 선언이다. index key는 위치를 신원으로 만들어 삽입/삭제/정렬에서 상태를 밀리게 하고, 렌더마다 새 key는 전 항목을 재마운트시킨다. 반대로 key 변경은 의도적 상태 리셋 도구가 된다.
- 부모가 렌더되면 자식은 props와 무관하게 렌더된다. 전파는 memo, 또는 같은 요소 참조(children 패턴)에서 멈춘다.
확인 문제
Q1. 채팅 앱에서 메시지 목록을 messages.map((m, i) => <Message key={i} data={m} />)로 렌더한다. 새 메시지가 목록 맨 아래에 추가될 때는 문제가 없다가, "지난 메시지 불러오기"로 맨 위에 50개가 삽입되자 각 메시지에 달아 둔 "펼쳐진 답글" 상태가 전부 엉뚱한 메시지로 옮겨 갔다. 두 경우의 차이를 재조정의 판정 과정으로 설명하라.
정답과 해설
index key에서 신원은 위치다. 맨 아래 추가는 기존 항목들의 index가 그대로이므로(0..n-1 불변, n만 신규) 위치 = 신원 가정이 우연히 성립한다 — key=0..n-1이 전부 이전과 같은 항목에 매칭되고, key=n만 새로 마운트된다. 맨 위 삽입은 기존 모든 항목의 index가 50씩 밀린다. 재조정은 key=0인 이전 Fiber(옛 첫 메시지, 펼침 상태 포함)를 key=0인 새 요소(새로 불러온 메시지)와 매칭시키므로, 훅 상태·DOM 상태가 내용과 무관하게 "그 위치에 온 새 데이터"에 붙는다. 결과적으로 펼침 상태가 50칸 밀린 것처럼 보인다. 수정은 key={m.id} — 신원이 데이터를 따라가므로 삽입 방향과 무관해진다.
Q2. 다음 코드에서 버튼을 누를 때마다 ExpensiveChart의 렌더 로그가 찍힌다. props가 없는데 왜 렌더되는가? 그리고 memo를 쓰지 않고 구조 변경만으로 렌더를 막는 방법을 제시하라.
function Dashboard() {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>메뉴</button>
{open && <Menu />}
<ExpensiveChart />
</div>
);
}정답과 해설
전파 규칙 때문이다: 컴포넌트가 렌더되면 반환 트리 안의 컴포넌트는 props 변화와 무관하게 렌더된다. setOpen으로 Dashboard가 재실행되면 <ExpensiveChart /> 요소가 새로 만들어지고(새 객체), React는 새 서술이므로 함수를 호출한다.
구조적 해법은 상태와 무거운 트리를 분리하는 것. ① 상태 내리기: open을 쓰는 부분(button + Menu)을 MenuToggle 컴포넌트로 추출하면 리렌더 범위가 그 안으로 좁아진다. ② children 패턴: MenuToggle이 children을 받게 하고 <MenuToggle><ExpensiveChart /></MenuToggle>로 조합하면, MenuToggle이 자기 상태로 리렌더되어도 children은 부모 렌더에서 만들어진 같은 요소 참조이므로 React가 서브트리를 건너뛴다. 두 방법 모두 "요소가 다시 만들어지지 않으면 렌더되지 않는다"는 같은 원리를 쓴다.
Q3. 사용자 전환 드롭다운이 있는 프로필 편집 화면에서, 사용자를 바꿔도 편집 중이던 폼 입력이 남아 있는 버그가 있다. <ProfileForm userId={userId} />의 내부는 useState로 폼 상태를 관리한다. 왜 상태가 유지되는가? 이펙트로 초기화하는 방법과 key로 해결하는 방법을 비교하라.
정답과 해설
유지되는 이유: userId가 바뀌어도 같은 위치의 같은 타입(ProfileForm)이므로 재조정이 "같은 인스턴스"로 판정, 훅 상태를 유지한 채 새 props로 재렌더만 한다. props 변경은 상태를 초기화하지 않는다.
이펙트 방식(useEffect(() => { setName(user.name); setBio(user.bio); ... }, [userId]))은 상태 필드마다 초기화 코드를 나열해야 해서 필드 추가 시 누락 위험이 있고, 한 렌더는 옛 상태로 그려진 뒤 초기화 렌더가 한 번 더 돈다(깜빡임 + 불필요 렌더). key 방식(<ProfileForm key={userId} userId={userId} />)은 재조정에게 "userId가 다르면 다른 것"이라고 신원을 선언하는 것으로, 언마운트 + 재마운트가 일어나 모든 훅 상태·비제어 입력·자식 상태가 한 번에 초기값으로 돌아간다. 누락이 구조적으로 불가능하고 렌더도 한 번이다. 폼 상태가 "그 사용자에 종속된 상태"라는 사실을 트리 구조로 표현하는 쪽(key)이 선언적이다.
참고 자료
- react.dev — Render and Commit — 렌더/커밋 파이프라인의 공식 서술.
- react.dev — Preserving and Resetting State — 위치·타입·key에 따른 상태 보존/리셋 규칙의 공식 문서. 이 문서 핵심 개념의 1차 자료.
- react.dev — Rendering Lists — key 요건의 공식 설명.
- React 저장소 — ReactChildFiber.js — 목록 diff(2패스, key 맵)의 실제 구현. 구현 세부임에 유의.
- acdlite — React Fiber Architecture — Fiber의 설계 동기(중단 가능한 렌더) 정리. React 코어 팀원의 문서.