컴포넌트가 화면에 보이기까지, React 안에서는 꽤 많은 일이 일어납니다.
우리는 보통 setState를 호출하거나 root.render()를 부르면서도
지금 React 안에서 정확히 무슨 일이 일어나고 있을까? 를 깊게 생각하지 않고 지나가는 경우가 많죠.
이번 아티클에서는 렌더링(rendering) 과 virtual DOM을 중심으로
React가 어떤 흐름으로 UI를 만들고 바꾸는지 정리해볼게요 !
먼저 용어부터 정리해봅시다.
브라우저 렌더링: DOM이 바뀐 뒤, 브라우저가 화면을 다시 그리는 과정(레이아웃 계산, 페인트 등).
React의 렌더링: 컴포넌트 함수를 호출해서 어떤 UI를 그릴지 계산하는 과정.
즉, JSX를 만드는 단계라고 보면 됩니다.
React 입장에서 렌더링은 다음 질문에 답하는 과정이에요.
현재 state와 props를 기준으로 이 컴포넌트는 어떤 JSX를 화면에 보여줘야 하지?
이때 실제 DOM을 건드리는 건 아직 아닙니다.
DOM이 수정되는 건 커밋(commit) 단계에서예요.
React 팀이 자주 쓰는 비유처럼 React 앱을 레스토랑이라고 생각해 볼게요.
이때 UI가 한 번 갱신될 때마다 React는 세 가지 단계를 거칩니다.
1. 렌더링 트리거 (Trigger)
2. 컴포넌트 렌더링 (Render)
컴포넌트를 호출해서 JSX를 계산
3. DOM에 커밋 (Commit)
이제 각 단계를 자세히 뜯어볼게요.
1단계: 렌더링이 언제 트리거될까 ?
React가 렌더링을 “해야겠다”라고 결정하는 시점은 크게 두 가지입니다.
1-1 앱이 처음 시작될 때 (초기 렌더링)
앱이 최초로 화면에 나타날 때는 루트 컴포넌트를 한 번 그려야 해요.
보통 이렇게 시작해요:
// index.js
import { createRoot } from "react-dom/client";
import App from "./App.js";
const root = createRoot(document.getElementById("root"));
root.render(<App />);
여기서 일어나는 일:
만약 root.render() 호출을 주석 처리하면?
→ React가 렌더링을 시작하지 않으니 화면에 아무것도 보이지 않게 되죠.
1-2. State가 업데이트될 때 (리렌더링)
초기 렌더링 이후에는 보통 state 업데이트가 렌더링을 다시 트리거합니다.
function Counter() {
const [count, setCount] = useState(0);
return (
<>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
</>
);
}
여기서 setCount(count + 1)를 호출하면:
2단계 - React가 컴포넌트를 렌더링한다는 것
트리거가 발생했으면 이제 진짜 렌더링이 시작됩니다.
이 단계에서 React는 컴포넌트 함수를 직접 호출합니다.
2-1. 초기 렌더링: 루트에서 시작해 아래로 타고 내려가기
예를 들어 밑에와 같은 코드가 있을 때,
export default function Gallery() {
return (
<section>
<h1>Inspiring Sculptures</h1>
<Image />
<Image />
<Image />
</section>
);
}
function Image() {
return (
<img
src="https://i.imgur.com/ZF6s192.jpg"
alt="A huge metallic flower sculpture"
/>
);
}
초기 렌더링에서는
React가 Gallery()를 호출해서 JSX를 얻고
JSX 안에 <Image /> 가 보이면 -> Image()도 호출하고
또 그 안에 다른 컴포넌트가 있으면 계속 내려가며 재귀적으로 호출합니다.
이 과정을 통해 React는
<section><h1><img> × 3개에 해당하는 가상의 트리(React 요소 트리)를 만들어내요.
2-2. 리렌더링: 다시 계산만 하는 단계
state가 바뀌어 리렌더링이 발생할 때도 똑같이 컴포넌트 함수를 다시 호출합니다.
다만 중요한 점은:
이 시점에는 DOM을 바로 건드리지 X
새로 계산한 JSX와 이전 JSX를 비교해서 어디가 달라졌는지를 기억해 두기만 함
실제 DOM을 수정하는 건 다음 단계(커밋)에서 이루어짐
React는 렌더링 단계에서 컴포넌트를 계속 호출하며 UI를 계산합니다.
그래서 이 단계의 함수(컴포넌트)는 순수 함수여야 해요.
DOM 조작 (document.querySelector로 무언가 수정)
전역 변수 수정
네트워크 요청, 타이머 설정 등 부수효과(side effect) → 이런 것들은 렌더링 동안 하지 말아야 함
이게 지켜지지 않으면 ?
개발 모드에서 StrictMode를 켜면 React는 렌더링 단계에서 컴포넌트 함수를 두 번 호출합니다.
이유는 간단합니다.
이 함수가 순수하지 않으면 두 번 호출했을 때 이상한 일이 일어날 것이기에 개발자가 알 수 있을 것이다.
즉, 일부러 2번 호출해서 순수하지 않은 렌더링 로직을 조기에 드러내기 위한 안전장치라고 보면 됩니다.
렌더링 단계에서 React는 각 컴포넌트를 호출해 최종적으로 어떤 UI가 나와야 하는지 를 계산했습니다.
이제 이를 실제 DOM에 반영해야겠죠?
이 과정이 커밋(commit) 단계입니다.
초기 렌더링의 커밋
앱이 처음 렌더링될 때
React는 <div>, <h1>, <img> 같은 DOM 노드를 새로 만듬
appendChild()와 같은 DOM API로 실제 페이지에 추가
이때 사용자는 처음으로 React 앱의 UI를 보게 됩니다.
리렌더링의 커밋: “바뀐 부분만” 수정
state 업데이트로 인해 리렌더링이 일어나면:
예를 들어 이런 컴포넌트가 있다고 해볼게요.
export default function Clock({ time }) {
return (
<>
<h1>{time}</h1>
<input />
</>
);
}
부모 컴포넌트에서 매초 새로운 time을 넘기면 Clock은 계속 리렌더링됩니다.
하지만 사용자가 <input>에 입력한 내용은 사라지지 않죠.
왜냐하면
<input>은 이전과 같은 위치의 같은 요소라고 판단<h1>의 텍스트 노드만 교체하는 선에서 끝=> React의 DOM 최소 변경 전략 덕분 !
커밋이 끝나 DOM이 변경되면 이제 공은 브라우저에게 넘어갑니다.
브라우저는 변경된 DOM과 스타일을 바탕으로 레이아웃을 다시 계산하고, 픽셀을 화면에 그립니다.
이 과정을 흔히 브라우저 렌더링이 라고 부르기도 하지만,
React의 렌더링과 헷갈리기 쉬우니
여기서는 페인트(Paint)라고 구분해서 부르는 편이 좋습니다.
정리하면
React 렌더링 → JSX 계산 (컴포넌트 함수 호출)
React 커밋 → DOM 반영
브라우저 페인트 → 화면에 실제로 그리기
아티클 초반에 봤던 핵심 문장 중 하나가 이거였죠.
렌더링이 항상 DOM 업데이트를 의미하는 것은 아니다.
그 이유는 렌더링은 계산이고, 커밋은 적용이기 때문입니다.
리렌더링을 했더라도 이전과 JSX 구조가 같고 속성 값도 그대로인 부분은 React가 DOM을 건드릴 필요가 없습니다.
즉 다시 정리해서 얘기해보면
라는 두 단계가 분리되어 있기에 렌더링 = DOM 재생성이 아닌 것이죠.
이 구조 덕분에
불필요한 DOM 변경을 피하고 브라우저 페인트 비용을 줄여 성능이 향상됩니다.
React 앱에서 화면이 한 번 업데이트될 때마다 항상 이 세 단계를 거칩니다.
1. 트리거 (Trigger)
초기 root.render(<App />) 호출
또는 setState / useState의 setter 호출 등으로
“렌더링이 필요하다”는 신호가 발생
2. 렌더링 (Render)
React가 컴포넌트 함수를 호출해 JSX를 계산
재귀적으로 하위 컴포넌트도 호출
이 단계에서는 DOM을 건드리지 않음
반드시 순수해야 하는 단계
3. 커밋 (Commit)
이전 렌더링 결과와 비교해 변경 사항만 DOM에 반영
초기 렌더링이면 DOM 노드를 생성·추가
리렌더링이면 꼭 필요한 최소 변경만 수행
그리고 커밋까지 끝난 뒤에는 브라우저가 레이아웃 계산과 페인트를 수행해
사용자가 실제로 변화된 UI를 보게 됩니다.
마지막으로 기억해두면 좋은 포인트
Strict Mode에서 React는 일부러 렌더링을 더 자주 호출해
순수하지 않은 컴포넌트를 조기에 찾음
렌더링 결과가 이전과 같다면 React는 DOM을 건드리지 않음 -> 성능 최적화의 핵심
렌더링이라는 말을 쓸 때
React 렌더링(컴포넌트 호출) 과
브라우저 렌더링(페인트) 를 구분해서 생각하면
내부 동작을 훨씬 명확히 설명할 수 있습니다.
여기까지는 렌더링 -> 커밋 -> 브라우저 페인트라는 흐름을 위주로 봤다면,
이제는 이 과정이 React 안에서는 어떤 데이터 구조로 표현되는지를 살펴볼 차례입니다.
바로 많이 들어본 Virtual DOM(VDOM) 이야기예요.
Virtual DOM(VDOM)은
UI가 어떻게 생겼는지를 JavaScript 객체 형태로 메모리에 들고 있는 가상 DOM 트리
라고 생각하면 편합니다.
조금 더 풀어보면 실제 브라우저 DOM을 바로 조작하는 대신 React는 이렇게 생긴 UI가 됐으면 좋겠다라는 가상 상태를 JS 객체 트리(요소 트리, element tree)로 표현해 두고 이 트리를 기준으로 실제 DOM과 동기화합니다.
이때 이 가상 트리를 만드는 과정이 바로 렌더링(Render) 단계고
가상 트리와 실제 DOM을 맞추는 동기화 작업이 커밋(Commit) 단계입니다.
이 전체 과정을 React에서는 재조정(Reconciliation) 이라고 부릅니다.
즉 Virtual DOM은 단순히 복제된 DOM이라기보다는 React가 선언적 UI를 가능하게 만들기 위해 사용하는 중간 표현 이라고 보는 게 더 정확합니다.
우리는 React에게 이렇게 말하죠
지금 UI 상태는 이런 모습이 됐으면 좋겠어(JSX).
실제 DOM은 네가 알아서 맞춰줘.
VDOM이 있기 때문에 우리는 DOM 조작, 이벤트 핸들링, 수동 업데이트 같은 세부 구현을
컴포넌트 내부에서 전부 신경 쓰지 않고, 상태 기준으로 UI를 선언적으로 작성할 수 있습니다.
재밌는 점은 Virtual DOM이라는 말이 엄밀한 하나의 기술 명세가 아니라는 거예요.
사람마다 쓰는 의미가 조금씩 다르고, 라이브러리마다 구현 방식도 다릅니다.
React 세계에서 Virtual DOM이라고 할 때는 보통 두 가지를 묶어서 이야기합니다.
JSX를 babel이 변환하면, 결국 이런 형태의 객체가 됩니다.
const element = {
type: "h1",
props: { children: "Hello" },
// ...
};
React 16부터 도입된 React Fiber는
이 Fiber 객체 트리를 기반으로 렌더링을 잘게 쪼개고, 우선순위를 주고, 중단/재개할 수 있게 해주는 새로운 재조정 엔진입니다.
그래서 React 입장에서의 Virtual DOM은 화면을 설명하는 요소 트리(React elements) 와, 그걸 실제로 다루는 내부 구조인 Fiber 트리까지 포함한 전체적인 패턴이라고 보는 게 자연스럽습니다.
개념들이 간단명료하게 정리되어있어서 정말 읽기 편했습니다 !
저는 특히 렌더링 단계의 함수 반드시 순수 함수여야 한다는 점, 그리고 리액트가 StrictMode에서 컴포넌트를 2번 호출하는 이유가 순수성을 검사하기 위한 안전장치였다는 설명이 좋았습니다 (´▽`ʃ♡ƪ)
StrictMode로 인해서 렌더링이 2번된다!는 것은 알고 있었는데, 왜 2번 렌더링이 되는지 그 이유를 렌더링 프로세스와 연결해서 명확하게 알게 되었습니다!! 수고 많으셨어요~!!!!