리액트를 배우게 되면 가장 먼저 접하는 훅은 useState일 것이다. state는 리액트의 가장 기본적인 개념이고 다른 훅 보다 쉬워서 첫 번째로 배우는 줄 알았다. 하지만, 더 배우고 나니 state는 리액트가 사용자에게 빠르게 피드백을 줄 수 있는 리렌더링의 트리거가 되는 중요한 개념이고, 리액트가 선언적이고 예측 가능한 방식으로 UI를 관리할 수 있게 해주는 핵심 메커니즘이기 때문에 가장 먼저 배우는 거라고 생각이 들었다.
# State란
let과 var로 선언된 데이터는 아무리 변경해도 리렌더링은 일어나지 않고 외부의 요인으로 리렌더링이 일어나더라도 변경된 값을 유지 하지 못하고 초기값으로 다시 되돌아간다. 하지만, state는 리렌더링이 일어나도 변경된 값을 유지할 수 있다. state는 컴포넌트의 기억 저장소라고 한다. 리액트는 state의 변화를 감지하고 리렌더링을 유발하는데 만약 state가 let과 var처럼 값이 초기화된다면 리렌더링이 일어날 때마다 이전 상태를 잃어버려서 지속적인 상태 변화를 UI에 반영할 수 없을 거다.
# 리렌더링이란
state가 변경되면 리렌더링을 유발한다고 한다. 리렌더링이란 무엇일까? 정확히는 컴포넌트 함수가 변경된 state 값을 가지고 새로 계산을 해서 React Elements(JSX)를 반환하는 과정만을 리렌더링이라고 말한다.
React Elements는 우리가 virtual DOM이라고 부르는 DOM을 코드화한 트리 구조의 데이터를 말한다. 예전에는 virtual DOM과 실제 DOM을 비교해서 변경된 트리만 감지해서 업데이트를 한다고 배웠지만 실제로는 리액트는 실제 DOM과 virtual DOM을 비교하지 않는다.
리렌더링을 통해 반환된 React Elements와 변경 전 스냅샷으로 관리되고 있던 이전 React Elements를 비교(Diffing)하고 변경된 노드만 실제 DOM에 반영하는 과정으로 UI를 업데이트한다.
# 리액트가 UI 업데이트를 하는 3단계
나는 리렌더링이 리액트가 UI를 업데이트하는 모든 단계를 일컫는 단어라고 알고 있었다. 하지만 리렌더링은 리액트가 UI를 업데이트하는 단게 중 첫 번째 단계만을 일컫는다. 리액트는 공식적으로 3단계의 UI 업데이트 단계를 제공한다.
1. Render Phase
우리가 리렌더링이라고 말하는 과정이다. 이 단계에서는 컴포넌트 함수가 실행되고 새로 계산된 React Elements (JSX)가 생성된다. 순수 함수로 동작하므로 부수 효과는 없다. 단순히 컴포넌트 함수를 다시 실행 시키고 JSX를 반환 이뿐이다.
2. Reconciliation (재조정 = Diffing)
Render Phase 단계에서 새로 계산된 JSX와 리액트에서 스냅샷으로 관리하고 있던 이전 JSX를 비교하는 과정이다. 비교를 하는 과정은 리액트가 최적화를 해서 최소한의 비교 로직을 통해 계산 된다고 한다. 그 후, 실제 DOM 조작 계획을 수립한다.
3. Commit Phase (커밋 단계)
이 단계는 실제 DOM에 변경 사항을 반영하는 단계다. 브라우저가 화면에 UI를 그리는 단계라고 생각하면 된다. 이때 useEffect와 useLayoutEffect가 실행된다.
Render Phase = 컴포넌트 함수 재실행 → 새로운 JSX(React Elements) 반환
↓
Reconciliation = 이전 React Elements 스냅샷 ↔ 새로운 React Elements 비교
↓
Commit Phase = 변경된 부분만 실제 DOM 반영
# snapshot
state 변수는 읽고 쓸 수 있는 일반 자바스크립트 변수처럼 보일 수 있지만, 스냅샷처럼 동작한다. 스냅샷이란 마치 사진을 찍어 놓은 듯 당시 데이터를 그대로 보관하고 있다는 의미이다. 컴포넌트 함수가 렌더링을 하면 우리는 이제 React Elemens를 반환한다는 것을 알고 있다. 시간상 UI의 스냅샷을 찍게 되면 state의 값도 그 당시의 값으로 저장이 되어있게 된다. 스냅샷으로 관리되고 있는 state가 변경되면 리렌더링이 일어나서 새로운 스냅샷을 반환받기 전에는 저장된 값을 유지하고 있게 된다. 아무리 업데이트를 해도 리렌더링이 끝나기 전에는 데이터가 변경되지 않는다.
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 1);
setNumber(number + 1);
setNumber(number + 1);
}}>+3</button>
</>
)
}
예시로 +3 버튼을 클릭하면 결과는 0 + 1 = 1 로 업데이트 된다. state가 변경되어 리렌더링이 일어나기 전에는 number 는 0이다. setNumber(number + 1)를 3번 호출하면 setNumber(0 + 1)를 3번 한 것과 동일한 결과가 되는 거다.
스냅샷이 찍히는 시점이 궁금해서 찾아보니, 스냅샷은 Render Phase가 끝날 때 저장되고 이 값은 다음 Render Phase에서 사용된다. 즉, Render Phase가 시작될 때 이전에 저장된 스냅샷을 가져와서 새로 계산하여 업데이트된 JSX를 반환할때 새로 계산된 state가 다음 렌더링에 사용되기 위해 예약이 되는 것이다.
# 마치며
공식 문서를 읽으면서 큰 도움을 받았다. 머리 속에 흩어져 있던 정보들이 문서를 읽고 글을 작성하면서 정리가 되어가고 있다. 완벽하게 이해하지는 못했지만 조금씩 명확해지는 것을 느낄 수 있었다. 이번에 다룬 state 개념은 분량이 적어 State: 컴포넌트의 기억 저장소, 렌더링 그리고 커밋, 스냅샷으로서의 state이 3파트를 한 번에 정리해 보았다. 기본 개념을 다루는 내용이라 다시 읽어보며 궁금했던 부분들을 추가로 찾아보았다. ‘리렌더링’이라는 말을 자주 사용했지만 정확한 의미를 모르고 있었다는 걸 깨달았고, React 팀이 ‘Virtual DOM’이라는 용어를 선호하지 않다고 하는데 현대적인 설명 방식이 더 직관적이어서, 왜 그런 용어를 피하는지 이해할 수 있었다.