목차
개요
이번 블로그 포스트에서는 React의 렌더링이란 무엇인지, 그리고 React가 컴포넌트의 렌더링을 어떻게 처리하는지에 대한 기본 개념을 다룹니다.
1. 렌더링이란 무엇인가?
렌더링의 정의
렌더링은 “React가 컴포넌트에게 현재의 props와 state의 조합에 기반해서, 지금 UI의 어떤 부분을 어떻게 표시하고 싶은지 설명해줘라고 요청하는 프로세스”입니다.
React는 렌더링 중에 컴포넌트 트리를 순회하면서 다음과 같은 것들을 수행합니다.
- 업데이트가 필요하다고 플래그가 설정된 컴포넌트를 찾습니다.
- 변경 플래그가 설정된 컴포넌트에 대해
FunctionComponent(props)또는classComponentInstance.render()를 호출하여, DOM 업데이트가 필요한지 확인합니다. - 다음 단계(커밋 페이즈)를 위해 2번에서 실행한 내용의 출력을 저장합니다.
렌더링의 출력 예시
children이 있는 컴포넌트의 경우 렌더링의 결과는 다음과 같습니다.
// JSX:
return <MyComponent a={42} b="testing">Text here</MyComponent>
// 다음과 같이 변환됩니다:
return React.createElement(MyComponent, {a: 42, b: "testing"}, "Text Here")
// 이렇게 React 요소 오브젝트가 생성됩니다:
{type: MyComponent, props: {a: 42, b: "testing"}, children: ["Text Here"]}
children이 없는 경우는 다음과 같습니다.
// 실제로 동작하는 컴포넌트
function Greeting({ name, age }) {
return <div>Hello {name}, you are {age} years old</div>;
}
function App() {
// 이 JSX는...
return <Greeting name="John" age={25} />;
}
// ...이러한 오브젝트를 생성합니다:
{ type: Greeting, props: { name: "John", age: 25 } }
참고: React 팀은 최근 Virtual DOM이라는 용어를 사용하지 않으려고 합니다.
Virtual DOM이라고 하면 HTML의 DOM Tree를 떠올려서, DOM 트리와 같은 것을 메모리에 가지고 있다고 생각하기 때문입니다. 하지만 이는 다릅니다.
React 팀에서는 Value UI라는 용어를 사용하고 있습니다. React에서는 UI 요소도 String이나 Array와 같이 Value로 다룰 수 있습니다. 따라서 이 값을 변수에 저장하거나, 어딘가로 전달하거나, JavaScript로 제어할 수 있습니다.
React의 Virtual DOM은 DOM 트리가 아니라, 컴포넌트 트리(Fiber 트리, JavaScript의 Object 형태를 가진 트리)를 다룹니다.
2. 렌더 페이즈와 커밋 페이즈
React는 화면을 표시하기 위해 2가지 개념적 페이즈로 나누어 처리합니다.
렌더 페이즈 (Render Phase)
렌더 페이즈에서는 다음과 같은 것들을 수행합니다.
- 컴포넌트의 렌더링과 변경 사항 계산
- 새로운 컴포넌트 트리를 기존 트리와 비교
- “재조정(reconciliation)“이라는 프로세스를 통해 DOM 변경이 필요한 것을 수집
커밋 페이즈 (Commit Phase)
커밋 페이즈에서는 다음과 같은 것들을 수행합니다.
- 업데이트를 DOM에 적용
- 모든 변경 사항은 동기적으로 적용됩니다
DOM 업데이트 이후
DOM 업데이트 이후(화면에 표시된 이후)에는 패시브 이펙트 페이즈(Passive Effects Phase)라는 페이즈가 실행됩니다.
- ref의 참조 대상을 업데이트합니다.
- 클래스 컴포넌트:
componentDidMount와componentDidUpdate를 동기적으로 실행합니다. 함수 컴포넌트:useLayoutEffect훅을 실행합니다. - 함수 컴포넌트: 그 후
useEffect훅을 실행합니다
중요한 포인트
“렌더링” ≠ “DOM 업데이트”
컴포넌트는 다음과 같은 경우에 렌더링은 되지만, DOM 업데이트를 하지 않는 경우가 있습니다.
- 컴포넌트가 동일한 출력을 반환한 경우
- React가 동시 렌더링 중에 작업을 폐기한 경우
3. React는 렌더링을 어떻게 처리하는가?
React에서는 다양한 메커니즘이 리렌더링을 트리거하고, 이렇게 트리거된 렌더링은 큐(Que)에 추가하여 처리합니다.
React에서 리랜더링은 다음과 같은 경우, 트리거됩니다.
함수 컴포넌트:
useState의 setteruseReducer의 dispatch
클래스 컴포넌트:
this.setState()this.forceUpdate()
기타:
- ReactDOM의 최상위
render(<App>)호출 useSyncExternalStore의 업데이트- https://ko.react.dev/reference/react/useSyncExternalStore
useSyncExternalStore는 외부 스토어에 대한 구독을 가능하게 하는 React 훅입니다.
함수 컴포넌트에는 forceUpdate가 없지만, 다음과 같이 forceUpdate의 역할을 구현할 수 있습니다.
const [, forceRender] = useReducer((c) => c + 1, 0);
4. 표준적인 렌더링 동작
가장 중요한 규칙
React에서는 부모가 렌더링되면, 그 안의 모든 자식 컴포넌트가 재귀적으로 렌더링됩니다.
예시: A > B > C > D
- 사용자가 B의 버튼을 클릭합니다.
setState()가 B의 리렌더링을 큐에 추가합니다.- React는 루트부터 검색(Render Pass)을 시작합니다.
- A에 변경 플래그가 없으므로 그대로 통과합니다.
- B에 변경 플래그가 있으므로 렌더링합니다. B는
C를 반환합니다. - C에는 원래 변경 플래그가 없지만, B가 렌더링되었으므로, React는 C도 렌더링합니다. C는
D를 반환합니다. - D에도 원래 변경 플래그가 없지만, C가 렌더링되었으므로, React는 D도 렌더링합니다.
중요한 포인트
“React는 props가 변경되었는지 신경 쓰지 않습니다. 부모가 렌더링되면, 자식 컴포넌트를 무조건 렌더링합니다.”
예를 들어, 루트
<App>에서setState()를 호출하면, 트리 내의 모든 컴포넌트가 리렌더링됩니다.React는 어떤 DOM 변경이 필요한지 판단하기 위해 렌더링합니다. (DOM 변경이 필요해서 렌더링하는 것이 아닙니다.)
5. React의 렌더링 규칙
렌더링은 “순수”해야 한다
렌더링에는 부작용(side effect)이 없어야 합니다.
렌더 로직에서 해서는 안 되는 것:
- 기존 변수/오브젝트를 변경하는 것
- 랜덤 값을 생성하는 것 (
Math.random(),Date.now()) - 네트워크 요청을 보내는 것
- state 업데이트를 큐에 추가하는 것
렌더 로직에서 할 수 있는 것:
- 새로 생성된 오브젝트를 변경하는 것
- 에러를 throw하는 것
- 아직 생성되지 않은 데이터를 lazy 초기화하는 것
예시: 순수하지 않은 렌더링 (X)
let renderCount = 0; // 외부 변수
function ImpureComponent() {
renderCount++; // ❌ 기존 변수를 변경
// ❌ 렌더링 중에 네트워크 요청
fetch('/api/data').then((data) => console.log(data));
return <div>Render #{renderCount}</div>;
}
// ❌ 같은 함수를 실행하지만, 결과가 변한다.
function RandomNumber() {
const random = Math.random(); // ❌ 랜덤 값 생성
return <p>{random}</p>;
}
예시: 순수한 렌더링 (O)
function PureComponent({ items }) {
// ✅ 새로운 배열을 생성하여 수정
const sortedItems = [...items].sort((a, b) => a.name.localeCompare(b.name));
// ✅ 조건부 에러
if (!items || items.length === 0) {
throw new Error('Items are required');
}
// ✅ lazy 초기화 (한 번만 실행)
const [data] = useState(() => {
return expensiveCalculation();
});
return (
<ul>
{sortedItems.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
// 부작용은 이벤트 핸들러나 useEffect에서 처리
function ProperComponent() {
const [data, setData] = useState(null);
// ✅ 네트워크 요청은 useEffect에서
useEffect(() => {
fetch('/api/data')
.then((res) => res.json())
.then(setData);
}, []);
// ✅ 랜덤 값은 이벤트 핸들러에서
const handleClick = () => {
const randomValue = Math.random();
console.log('Random:', randomValue);
};
return (
<div>
<button onClick={handleClick}>Generate Random</button>
{data && <pre>{JSON.stringify(data, null, 2)}</pre>}
</div>
);
}
6. Fiber 오브젝트
Fiber란?
React는 컴포넌트 인스턴스를 추적하기 위한 내부 데이터 구조를 가지고 있습니다. 이 구조가 바로 “Fiber” 오브젝트입니다.
렌더링 프로세스 중, React는 이 Fiber 오브젝트의 트리를 순회하면서, 새로운 렌더링 결과를 계산하고, 업데이트된 트리를 생성합니다.
Fiber 오브젝트에 포함되는 것
- 컴포넌트 타입 정보
- 현재의 props와 state
- 부모, 형제, 자식 컴포넌트에 대한 포인터
- 내부 렌더링 메타데이터
Fiber (간략화)
const fiberNode = {
tag: 0, // 컴포넌트의 종류를 나타냄 (함수 컴포넌트, 클래스, DOM 요소 등)
type: MyComponent, // 실제 React 컴포넌트 함수, 또는 태그명 ('div' 등)
key: null, // key 속성 (리스트의 식별용)
stateNode: null, // 실제 DOM 노드 또는 클래스 인스턴스
// Fiber 트리 구조를 형성하기 위한 포인터
return: null, // 부모 Fiber를 가리킴
child: null, // 첫 번째 자식 Fiber를 가리킴
sibling: null, // 다음 형제 Fiber를 가리킴
pendingProps: { name: 'React' }, // 업데이트 중인 props (아직 반영되지 않음)
memoizedProps: null, // 이전 렌더에서 사용된 props
memoizedState: null, // useState나 useReducer 등으로 관리되는 state
alternate: null, // 이전 또는 다음 Fiber를 가리킴 (더블 버퍼링 구조)
};
React의 컴포넌트는 React의 Fiber 오브젝트에 대한 외관(facade)이라고 생각하면 좋을 것 같습니다.
7. Fiber와 렌더링
React는 기존의 컴포넌트 트리와 DOM 구조를 최대한 재사용하여, 가능한 한 효율적으로 리렌더링을 진행하고자 합니다.
React가 같은 타입의 컴포넌트 또는 HTML 노드를 트리의 같은 위치에 렌더링해야 하는 경우, React는 새로 컴포넌트 인스턴스를 생성하는 대신 기존의 것을 재사용하려고 합니다.
즉, 같은 위치에 같은 타입의 컴포넌트를 렌더링하라는 요청이 들어온 경우, React는 기존 컴포넌트 인스턴스를 계속 유지합니다.
- 클래스 컴포넌트의 경우, 기존 컴포넌트 인스턴스와 완전히 동일한 인스턴스를 사용합니다.
- 함수 컴포넌트의 경우, 클래스 컴포넌트처럼 인스턴스를 가지고 있지 않으므로, Fiber가 인스턴스 대신의 역할을 하여, “이 타입의 컴포넌트는 여기에 표시된다”고 나타냅니다.
컴포넌트 타입 비교
React는 === 참조 비교를 사용하여 Fiber 안에 있는 type 필드로 요소를 비교합니다.
요소의 타입이 변경된 경우(예: <ComponentA>에서 <ComponentA'>로), React는 기존 트리 섹션 전체를 파괴하고, 처음부터 재생성합니다
렌더링 중에는 새로운 컴포넌트를 생성하지 마세요!
랜더링 중에 새로운 컴포넌트를 생성하면, 랜더링때마다 매번 새로운 컴포넌트를 생성하므로 React가 효율적으로 랜더링을 관리할 수 없게 됩니다.
나쁜 예시 (X):
function ParentComponent() {
// 렌더링할 때마다 새로운 컴포넌트 타입이 생성됩니다!
function ChildComponent() {
return <div>Hi</div>;
}
return <ChildComponent />;
}
따라서 다음과 같이 자식 컴포넌트를 따로 생성할 수 있도록 해야, React가 랜더링을 효율적으로 관리할 수 있습니다.
function ChildComponent() {
return <div>Hi</div>;
}
function ParentComponent() {
return <ChildComponent />;
}
8. Key와 렌더링
Key란 무엇인가?
React에서 key는 React에 대한 지시(가이드라인)이며, 실제 prop이 아닙니다(자식 컴포넌트에 전달되지 않습니다).
React는 key를 컴포넌트 타입의 특정 인스턴스를 식별하기 위한 식별자로 취급합니다.
key는 특히 가변 데이터를 가진 리스트를 렌더링할 때 중요합니다.
가변 데이터와 Key
“key는 가능한 한 데이터에서 가져온 ID를 사용해야 합니다. 배열의 인덱스는 되도록 사용하지 마세요!”
배열 인덱스를 사용하면 다음과 같은 문제가 발생할 수 있습니다.
인덱스 key를 가진 10개의 아이템 리스트에서 아이템 6-7을 삭제하고, 3개의 새로운 요소를 추가한 경우,
- [0, 1, 2, 3, 4, 5,
6, 7, 8, 9] → [0, 1, 2, 3, 4, 5, 6, 7] → [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] React는 [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - React는 단순히 1개의 아이템이 추가되었다고 판단하여, 실제로는 삭제해야 할 6, 7의 컴포넌트 인스턴스를 재사용합니다.
- 같은 인스턴스를 사용하지만, 전혀 다른 데이터를 가지게 됩니다.
- 데이터와 컴포넌트가 일치하지 않을 수 있습니다.
따라서 다음과 같이 고유한 ID를 사용하도록 해야 합니다.
// ✅ 고유한 ID를 사용
todos.map((todo) => <TodoListItem key={todo.id} todo={todo} />);
Key의 다른 활용
key는 어떤 컴포넌트에서도 사용할 수 있으며, key를 업데이트함으로써 컴포넌트를 강제로 재생성할 수 있습니다. (인스턴스를 교체)
예시: key로 컴포넌트를 리셋하기
function ProfileForm({ userId }) {
const [formData, setFormData] = useState({ name: '', email: '' });
// 유저가 바뀔 때마다 폼이 리셋됩니다 (key 변경에 의해)
// useEffect로 리셋할 필요가 없습니다!
return (
<form>
<input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="Name"
/>
<input
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
placeholder="Email"
/>
</form>
);
}
function UserProfile() {
const [selectedUserId, setSelectedUserId] = useState(1);
return (
<div>
<button onClick={() => setSelectedUserId(1)}>User 1</button>
<button onClick={() => setSelectedUserId(2)}>User 2</button>
{/* key가 변경되면 컴포넌트가 완전히 재생성됩니다 */}
<ProfileForm key={selectedUserId} userId={selectedUserId} />
</div>
);
}
9. 렌더 배치 처리
렌더 배치 처리란 여러 setState() 호출이 약간의 지연을 두고 큐에 들어가, 단일 렌더 패스로 렌더링되는 것을 의미합니다.
React 17 이전
React 17 이전 버전에서는 이벤트 핸들러에서만 배치 처리됩니다. 따라서, 다음과 같이 이벤트 핸들러 이외의 업데이트는 개별적으로 실행됩니다.
setTimeout내await이후- 일반 JS 핸들러
React 18 이후
이벤트 루프 틱(Event Loop Tick) 내의 모든 업데이트가 자동 배칭되도록 변경되었습니다.
랜더 배치 예시
const [counter, setCounter] = useState(0);
const handleClick = async () => {
setCounter(0);
setCounter(1);
const data = await fetchSomeData();
setCounter(2);
setCounter(3);
};
- React 17: 3번의 렌더 패스 (처음 2개가 배치, await 이후 각각 개별)
- React 18: 2번의 렌더 패스 (호출 0-1이 함께, await 이후 호출 2-3이 함께 배치)
10. 비동기 렌더링, 클로저, state 스냅샷
다음과 같이 클로저를 신경쓰지 않아 문제가 발생하는 경우가 있습니다.
function MyComponent() {
const [counter, setCounter] = useState(0);
const handleClick = () => {
setCounter(counter + 1);
console.log(counter); // ❌ 원래 값을 로그에 기록
};
}
이는 다음과 같은 이유로 문제가 발생합니다.
handleClick은 정의된 시점에 존재하던 변수를 참조하는 클로저입니다.- 이 렌더링 동안
counter는 특정 값을 가집니다. setCounter()가 미래의 렌더링을 큐에 추가합니다.- 미래의 렌더링은 새로운
counter변수와 새로운handleClick함수를 생성합니다. - 하지만 현재의 복사본은 그 새로운 값을 참조할 수 없습니다.
핵심 개념
“이 state 변수들은 그 당시의 스냅샷입니다.”
다음은 클로저와 state 스냅샷의 예제입니다.
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
// ❌ 이것은 기대대로 동작하지 않습니다
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// count는 여전히 이 렌더링의 값(예: 0)
// 3번 호출해도 결과는 1입니다 (0 + 1 = 1, 3번 모두)
};
const handleClickCorrect = () => {
// ✅ 함수형 업데이트를 사용
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
setCount((prev) => prev + 1);
// 각 업데이트가 이전 값을 사용하므로 결과는 3 증가
};
const handleAsyncLog = () => {
setCount(count + 1);
setTimeout(() => {
// ❌ 클릭 시점의 count 값을 로그에 기록 (새로운 값이 아님)
console.log('Count in timeout:', count);
}, 3000);
};
const handleAsyncLogCorrect = () => {
setCount((prev) => {
const newCount = prev + 1;
setTimeout(() => {
// ✅ 업데이트된 값을 로그에 기록
console.log('Count in timeout:', newCount);
}, 3000);
return newCount;
});
};
return (
<div>
<p>Count: {count}</p>
<button onClick={handleClick}>❌ +3 (동작하지 않음)</button>
<button onClick={handleClickCorrect}>✅ +3 (올바른 방법)</button>
<button onClick={handleAsyncLog}>❌ Log (이전 값)</button>
<button onClick={handleAsyncLogCorrect}>✅ Log (새로운 값)</button>
</div>
);
}
정리
- 렌더링 결과는 JavaScript 오브젝트 형태입니다.
- Virtual DOM은 HTML의 DOM tree가 아니라 컴포넌트 트리를 의미합니다.
- React에서 중요한 포인트는 Virtual DOM이 아니라, Value UI라는 것입니다.
- React에서 화면 묘사를 위해 렌더 페이즈와 커밋 페이즈를 가지고 있습니다.
- “렌더링” ≠ “DOM 업데이트”
- 부모가 렌더링되면, React는 그 안의 모든 자식 컴포넌트를 재귀적으로 렌더링합니다.
- React는 props가 변경되었는지 신경 쓰지 않습니다. 부모가 렌더링되면, 자식 컴포넌트를 무조건 렌더링합니다.
- React는 어떤 DOM 변경이 필요한지 판단하기 위해 렌더링합니다.
- 렌더링은 “순수”해야 한다
- 렌더링 프로세스 중, React는 Fiber 오브젝트의 트리를 순회하여, 새로운 렌더링 결과를 계산하고, 업데이트된 트리를 생성합니다.
- 렌더링 중에 새로운 컴포넌트 타입을 생성하면 안됩니다.
제 블로그가 도움이 되셨나요? 하단의 댓글을 달아주시면 저에게 큰 힘이 됩니다!
앱 홍보
Deku가 개발한 앱을 한번 사용해보세요.Deku가 개발한 앱은 Flutter로 개발되었습니다.관심있으신 분들은 앱을 다운로드하여 사용해 주시면 정말 감사하겠습니다.