TL;DR


 리액트는 상태가 변경될 때마다 전체 UI를 다시 렌더링하는 것처럼 개발자에게 보이지만 실제로는 최소한의 변경만으로 DOM을 업데이트하려고 합니다. 이 최소한의 변경과 비용으로 DOM을 업데이트하는 과정을 재조정(Reconciliation)이라고 하는데요, 재조정 과정에서 리액트는 이전의 가상 DOM 트리와 새로운 가상 DOM 트리를 비교해 실제 DOM에 어떤 변경이 필요한지를 판단합니다.


재조정

1.1 재조정


 이 두 DOM 트리 간 차이점을 찾는 단계에서 리액트의 비교(diffing) 알고리즘을 통해서 최소한의 연산만으로 실제 DOM을 업데이트하는 것이죠. 그러다면 리액트는 두 트리를 비교할 때 어떤 것들을 비교하고 그에 따라 어떤 변경 사항들을 실제 DOM에 반영하는지 살펴보겠습니다.


가상 DOM 비교 과정

 다음 컴포넌트 트리와 같은 코드가 있다고 가정하고 가상 DOM과 실제 DOM의 관점에서 렌더링 과정을 보겠습니다.

index.js
1function Content(props) {
2  return <div className="child">{props.children}</div>;
3}
4
5function Footer() {...}
6
7function Main() {
8  return (
9    <main style={{ backgroundColor: 'red' }}>
10      <h1 className="title">Title</h1>
11      <Content>
12        <span>Some Content</span>
13        // ..
14      </Content>
15      <Footer ... />
16    </main>
17  );
18}
19ReactDOM.render(<Main />, document.getElementById('root'));

앱이 실행되면 리액트는 주어진 컴포넌트 트리를 순회하면서 JSX를 변환하고 가상 DOM 노드를 생성하는데요, 생성된 가상 DOM 트리는 다음과 같이 만들어집니다.

가상돔

1.2 가상 DOM

생성된 가상 DOM 객체 보기
  • 가상 DOM 트리는 실제 DOM의 단순화된 자바스크립트 객체인데 간략하게 객체로 그려보면 다음과 같이 구성됩니다.
index.js
1const virtualDOM = {
2  type: 'main', // 요소의 타입
3  props: {
4    style: { backgroundColor: 'red' }, // 요소의 속성
5    // ..
6  },
7  children: [
8    {
9      type: 'h1',
10      props: {
11        className: 'title',
12      },
13      children: ['Title'],
14      // ..
15    },
16    {
17      type: Content, // 해당 컴포넌트의 함수 또는 클래스
18      // ..
19    },
20    {
21      type: Footer,
22      // ..
23    },
24  ],
25  // ..
26};

가상 DOM 트리가 만들어지면 리액트는 이 가상 DOM을 사용해 모든 컴포넌트와 데이터가 반영된 실제 DOM을 생성하고 사용자는 초기 화면을 볼 수 있습니다.

앱이 실행된 후 사용자와 상호작용 등으로 상태 업데이트가 감지되면 리액트는 변화가 일어난 컴포넌트를 기준으로 재조정을 시작하고 새로운 가상 DOM을 생성합니다.

가상돔

1.3 새로운 가상 DOM


리액트는 이전의 가상 DOM과 새로 생성된 가상 DOM을 비교하며 어떤 요소가 추가되었는지, 제거되었는지 그리고 변경되었는지를 판단합니다. 비교 과정을 통해 실제 DOM에 반영할 필요가 있는 최소한의 변경사항만 파악하는데요, 여러 상태 변경이 한 번에 발생하면 리액트는 일괄적으로 처리해서 실제 DOM에 반영합니다. 업데이트가 완료되면 사용자에게 최신의 화면이 그려지겠죠.

부모 컴포넌트가 렌더링되면 모든 자식 노드들을 재귀적으로 처리합니다. 이 문맥에서 "처리"라고 하는 것은 비교 과정에서 변경 사항들을 찾고 업데이트하는 것을 의미합니다.

앞서 리액트는 이전의 가상 DOM과 현재의 가상 DOM을 비교한다고 했는데 그 비교 과정은 어떻게 이루어질까요?

리액트가 재조정 과정에서 수행하는 비교 알고리즘은 두 가지의 가정하에 연산을 수행하는데요.

  1. 서로 다른 타입의 두 요소는 서로 다른 트리 가져요.
  2. Key를 사용해서 렌더 간 어떤 자식 요소들이 "안정적"일지를 알 수 있어요.

리액트 팀에 따르면 실제로 거의 모든 사용 사례에서 이 가정들은 들어맞습니다.


DOM 요소의 타입이 다른 경우

 재조정 과정 중에 두 DOM 트리 요소의 타입이 다르면 리액트는 이전의 DOM 트리를 버리고 새로운 트리를 생성합니다.

상태에 따라 다른 요소를 출력하는 컴포넌트가 있다고 가정해 보겠습니다.

Component.js
1function Component() {
2  const [isLarge, setIsLarge] = useState(false);
3  return (
4    <>
5      {isLarge ? (
6        <h1 className="largeTitle">Welcome</h1>
7      ) : (
8        <h3 className="smallTitle">Welcome</h3>
9      )}
10    </>
11  );
12}

상태에 따라 이전의 DOM 트리와 새로운 DOM 트리에서 각각 다른 가상 DOM 노드로 만들어지는데요.


가상돔

1.4 가상 DOM - 요소의 타입이 다른 경우


두 가상 DOM 트리 비교 과정 중에 요소의 타입이 바뀌게 되면 이전 DOM 노드(h1 타입의 요소)는 모두 파괴되고 새로운 트리의 DOM 노드(h3 타입의 요소)로 업데이트됩니다.

만약 요소 아래 컴포넌트가 있다면 해당 컴포넌트는 언마운트됨과 동시에 가지고 있는 상태는 초기화되고 다시 마운트됩니다.


가상돔

1.5 가상 DOM - 요소의 타입이 다른 경우



DOM 요소의 타입이 같은 경우

 앞서 리액트에서 재조정 과정은 최소한의 변경 사항만 실제 DOM에 반영한다고 했는데요, 재조정 과정 중에 두 DOM 트리 요소의 타입이 같으면 리액트는 두 요소의 속성을 비교하고 변경된 속성이 있으면 해당 속성들만 업데이트합니다.

요소의 타입이 다른 경우에는 DOM 노드를 파괴하고 새로 그렸었는데 타입이 같으면 동일한 내역은 유지한 채 변경된 값만 갱신합니다.

전/후
1// 상태 변경 전 가상 DOM 노드
2{
3  type: 'div',
4  props: {
5    className: 'container-large',
6    style: {padding: '20px', border: '1px solid black'},
7  }
8}
9// 상태 변경 후 가상 DOM 노드
10{
11  type: 'div',
12  props: {
13    className: 'container-small',
14    style: {padding: '20px', border: '1px solid green'},
15  }
16}

위 예제에서는 요소의 타입이 둘 다 'div'로 동일하기 때문에 className과 style의 border 속성만 업데이트합니다.


같은 타입의 컴포넌트일 경우

 재조정 과정 중에 두 DOM 트리 컴포넌트의 타입이 같을 경우 최소한의 변경 사항만 실제 DOM에 반영하기 위해 컴포넌트의 props만 새로 업데이트합니다.

상태에 따라 props가 다른 컴포넌트가 있다고 가정해 보겠습니다.

index.js
1function Input({ placeholder }) {
2  const [value, setValue] = React.useState('');
3  return (
4    <input
5      value={value}
6      placeholder={placeholder}
7      onChange={e => setValue(e.target.value)}
8    />
9  );
10}
11
12function Component() {
13  const [isLarge, setIsLarge] = useState(false);
14  return (
15    <>
16      {isLarge ? (
17        <Input placeholder="안녕하세요" />
18      ) : (
19        <Input placeholder="hello" />
20      )}
21    </>
22  );
23}

리액트는 위 컴포넌트 트리를 읽고 DOM 노드를 다음과 같은 형태로 생성하는데요.

전/후
1// 상태 변경 전 가상 DOM 노드
2{
3  type: Input,
4  props: {
5    placeholder: '안녕하세요',
6    // ..
7  },
8  // ..
9}
10// 상태 변경 후 가상 DOM 노드
11{
12  type: Input,
13  props: {
14    placeholder: 'hello',
15    // ..
16  },
17  // ..
18}

상태가 업데이트된 후에 생성된 DOM 노드의 타입은 그전과 동일한 참조 값인 Input 컴포넌트를 가리키기 때문에 동일한 내역은 유지한 채 props만 업데이트합니다. 앞서 다룬 "DOM 요소의 타입이 다른 경우"에서는 루트 요소의 타입이 바뀌면 컴포넌트가 언마운트되면서 상태값도 같이 파괴됐는데 "같은 타입의 컴포넌트의 경우"에는 컴포넌트가 언마운트되지 않고 렌더 간 상태가 유지됩니다.


DOM 요소의 타입이 다른 경우, DOM 요소의 타입이 같은 경우 그리고 컴포넌트의 타입이 같은 경우에 재조정이 어떻게 이루어지는지 알아봤는데요, 이어지는 글에서는 "key"를 사용했을 때 재조정이 어떻게 동작하는지 살펴보겠습니다.

출처 : React Docs - Reconciliation