안녕하세요, 리심스 1주차 아티클 작성하러 온 김한서입니다.
💥 Virtual DOM
리액트에서는 렌더링시 가상 DOM을 이용한다. 가상 DOM이란, 실제 DOM을 조작하는 방식이 아닌, 실제 DOM을 모방한 가상의 DOM을 구성해 원래 DOM과 비교하여 달라진 부분을 리렌더링하는 방식으로 작동한다. 이 때 가상 DOM을 잘 이해해야만 리액트의 상태에 대해 잘 이해하고 다룰 수 있다.
DOM(Document Object Model) 이란?
웹 페이지를 이루는 태그들을 자바스크립트가 이용할 수 있게끔 브라우저가 트리구조로 만든 객체 모델을 의미한다.
밑의 그림처럼, HTML과 스크립팅언어(Javascript)를 서로 이어주는 역할을 한다고 할 수 있다.

지난 2주차 과제에서 모두 경험해봤듯이, 자바스크립트는 document라는 객체에 접근하여 실제 DOM을 조작할 수 있다. DOM은 브라우저가 화면을 렌더링하는 데 필요한 모든 정보가 포함되어 있어 구조가 복잡하다.
DOM 조작의 예를 들어보자면, 웹 페이지의 특정 요소의 스타일을 변경하려면 해당 요소를 찾고 스타일을 변경한 후, 그 변경 사항을 적용하기 위해 해당 요소부터 하위 요소까지 브라우저가 화면을 다시 그리는데 많은 비용이 발생한다. (즉, 리플로우와 리페인트에 대한 비용 발생)
이처럼 DOM 조작은 메모리와 CPU 사용량이 큰 작업이기 때문에, DOM 업데이트가 많아질수록 성능 저하가 발생할 가능성이 높다.
이러한 성능 문제를 개선하기 위해, Virtual DOM이 등장하였다.
Virtual DOM은 실제 DOM과 같은 내용을 담고 있는 복사본이라고 생각하면 된다. 이는 자바스크립트 객체 형태로 메모리 안에 저장되어 있다.
리액트는 다음과 같은 2개의 가상 DOM을 갖고 있다.
- 렌더링 이전 화면 구조를 나타내는 가상 DOM
- 렌더링 이후에 보이게 될 화면 구조를 나타내는 가상 DOM
리액트는 state가 변할 때마다 리렌더링이 발생하는데, 실제 브라우저가 그려지기 전인 이 시점마다 새로운 내용을 담은 가상 DOM을 생성한다. 두 가상 DOM을 비교하여 변경된 부분만 실제 DOM에 반영한다.
💥 가상 DOM 동작원리, 재조정(Reconciliation)
두 가상 DOM의 비교과정에서는 비교(Diffing) 알고리즘이 사용된다.
비교 알고리즘 가정
하나의 트리를 가지고 다른 트리로 변환하기 위한 최소한의 연산 수를 구하는 알고리즘으로, 두 가지 가정에 기반하여 O(n) 복잡도의 휴리스틱 알고리즘을 구현했다.
- 서로 다른 타입의 두 엘리먼트는 서로 다른 트리를 만들어낸다.
- 개발자가 key prop을 통해, 여러 렌더링 사이에서 어떤 자식 엘리먼트가 변경되지 않아야 할지 표시해 줄 수 있다.
이전 가상돔 트리와 새로운 가상돔 트리를 비교하는데,
- 루트 노드에서 시작해서 이전과 새로운 노드를 비교
- 노드 엘리먼트의 타입이 다르면 그 자식 노드들도 모두 사라지고 새로 생성
- 두 노드의 엘리먼트가 같은 유형이면 속성을 비교해서 변경된 것이 있는지 확인
- 변경된 속성(ex: className, style 등)이 있다면 해당 속성만 변경
- React는 새로운 엘리먼트의 내용을 반영하기 위해 현재 컴포넌트 인스턴스의 props를 갱신
다음으로 render() 메서드가 호출되고 비교 알고리즘이 이전 결과와 새로운 결과를 재귀적으로 처리하며 새로운 트리를 형성하는데, 이 과정을 재조정(Reconciliation)이라고 한다.
가상 DOM 트리가 비교 후 재조정되는 예시를 살펴보자.
<ul>
<li>first</li>
<li>second</li>
</ul>
<ul>
<li>first</li>
<li>second</li>
<li>third</li>
</ul>
위의 코드처럼 자식의 끝에 엘리먼트를 추가하면, 두 트리 사이의 변경은 잘 작동할 것이다.
차례로 순회 후 마지막에 <li>third</li>만 추가해주면 되기 때문이다.
<ul>
<li>Duke</li>
<li>Villanova</li>
</ul>
<ul>
<li>Connecticut</li>
<li>Duke</li>
<li>Villanova</li>
</ul>
그러나 이렇게 리스트의 맨 앞에 엘리먼트를 추가하는 경우 성능이 좋지 않다. React는 <li>Duke</li>와 <li>Villanova</li> 종속 트리를 그대로 유지하는 대신 모든 자식을 변경해야 하기 때문에 비효율적이다.
이러한 문제를 해결하기 위해, React는 key 속성을 지원한다. 자식들이 key를 가지고 있다면, React는 key를 통해 기존 트리와 이후 트리의 자식들이 일치하는지 확인한다.
<ul>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
<ul>
<li key="2014">Connecticut</li>
<li key="2015">Duke</li>
<li key="2016">Villanova</li>
</ul>
이제 React는 '2014' key를 가진 엘리먼트가 새로 추가되었고, '2015'와 '2016' key를 가진 엘리먼트는 그저 이동만 하면 되는 것을 알 수 있다.
💡재조정 과정에서 기타 고려해볼만한 사항
알고리즘은 다른 컴포넌트 타입을 갖는 종속 트리들의 일치 여부를 확인하지 않는다.
예를 들면 똑같은 테이블 형태의 결과물을 출력하지만 div 태그로 구현한 것과 table 태그로 구현한 것은 알고리즘이 자식 노드의 일치 여부를 비교하지 않는다.
👉 즉, 비슷한 결과물을 출력하는 두 컴포넌트를 교체하고 있다면, 그 둘을 같은 타입으로 만드는 것이 더 나을 수도 있다. 알고리즘이 비교하도록 해서 달라진 부분만 교체시킬 수 있도록
key는 반드시 변하지 않고, 예상 가능하며, 유일해야 한다. 변하는 key(Math.random()으로 생성된 값 등)를 사용하면 많은 컴포넌트 인스턴스와 DOM 노드를 불필요하게 재생성하여 성능이 나빠지거나 자식 컴포넌트의 state가 유실될 수 있다.
🏄♀️ 재조정 관련 공식문서
https://ko.legacy.reactjs.org/docs/reconciliation.html
재조정 (Reconciliation) – React
A JavaScript library for building user interfaces
ko.legacy.reactjs.org
💥 SnapShot으로서의 상태
리액트는 state를 설정하면 렌더링이 동작한다. 렌더링은 그 시점의 스냅샷을 찍는다고 할 수 있다.
렌더링이란, React가 컴포넌트, 즉 함수를 호출한다는 뜻이다. 해당 컴포넌트에서 반환하는 JSX는 시간상 UI의 스냅 샷과 같다. 리액트는 이 스냅샷과 일치하도록 화면을 업데이트하고 이벤트 핸들러를 연결한다.
prop, 이벤트 핸들러, 로컬 변수는 모두 렌더링 당시의 state를 사용해 계산된다.
즉, 리액트가 컴포넌트를 다시 렌더링할 때의 과정:
1. React가 함수를 다시 호출함.
2. 함수가 새로운 JSX 스냅샷을 반환하고
3. 반환한 스냅샷과 일치하도록 DOM tree를 업데이트
이렇게 항상 React가 컴포넌트를 호출하면 특정 렌더링에 대한 state의 스냅샷을 제공하는데, 컴포넌트는 해당 렌더링의 state 값을 사용해 계산된 새로운 props 세트와 이벤트 핸들러가 포함된 UI의 스냅샷을 JSX에 반환한다.
다음 예시를 한 번 보자.
import { useState } from 'react';
export default function Counter() {
const [number, setNumber] = useState(0);
return (
<>
<h1>{number}</h1>
<button onClick={() => {
setNumber(number + 5);
setTimeout(() => {
alert(number);
}, 3000);
}}>+5</button>
</>
)
}
alert창에 어떤 숫자가 떴을 것이라고 예상했는지??
setNumber(0 + 5);
setTimeout(() => {
alert(0);
}, 3000);
이것은 alert창에 전달된 state의 스냅샷이다.
state 변수의 값은 이벤트 핸들러의 코드가 비동기적이더라도 렌더링 내에서 절대 변경되지 않는다. 위의 값은 컴포넌트를 호출해 React가 UI의 스냅샷을 찍을 때 고정된 값이다.
즉, React는 렌더링의 이벤트 핸들러 내에서 state 값을 고정(렌더링 시점의 값)으로 유지한다. 코드가 실행되는 동안 state가 변경되었는지 걱정할 필요가 없다.
그럼 이 스냅샷을 어떻게 활용할 수 있을까?
메모 작성 기능을 예시로 들자면, 작성하다 실수로 삭제했을 시 스냅샷을 이용하여 마지막으로 저장했던 상태로 복원할 수 있다.
💥 Context
Context는 컴포넌트 간의 데이터를 전달하는 또 다른 방법으로, 데이터를 하위에 있는 컴포넌트들에게 공급해줄 수 있다.
기존의 Props가 가지고 있던 Props drilling이라는 단점을 해결할 수 있다.
Props drilling이란?
props는 컴포넌트 간 부모 → 자식 단방향으로만 데이터를 전달할 수 있는데, 필요한 데이터가 깊은 단계에 있는 컴포넌트에 도달할 때까지 props를 계속해서 전달해야 하는 경우 발생하는 현상을 말한다.
Context는 데이터를 전역적으로 관리할 수 있어 중간 컴포넌트를 거치지 않고 바로 필요한 컴포넌트에 데이터를 전달한다.
💡Context 사용법
- createContext()으로 Context 생성
- Provider로 Context를 넘겨줄 컴포넌트를 감싸주고, 하위 컴포넌트로 넘겨줄 데이터인 value값 설정해주기
- useContext()로 하위 컴포넌트에서 context 데이터 접근
자세한 사용법은 공식문서에 아주 잘 나와있으니 확인해보자.
https://ko.react.dev/learn/passing-data-deeply-with-context
Context를 사용해 데이터를 깊게 전달하기 – React
The library for web and native user interfaces
ko.react.dev
props 넘겨주는 거 안 그래도 귀찮았는데 아예 모든 데이터를 Context로 생성해버리면 안되나 하는 생각이 들 수도 있다.
그러나 Context의 무분별한 사용은 성능에 악영향을 줄 수 있다. Context의 값이 업데이트될 때마다 Context를 구독하고 있는 모든 컴포넌트가 다시 렌더링되기 때문이다. 특히 많은 컴포넌트가 Context에 의존하고 있을 수록, 성능에 특히 좋지 않다.
이를 방지하기 위해 Context 사용시 사용할 데이터를 잘 분리해서 사용하는 것이 좋다. 컴포넌트가 리렌더링될 때마다 value값에 준 객체가 다시 생성이 될 것이므로, Provider로 감싸줄 자식 컴포넌트 안에서 해당 데이터가 변경될 것인지를 고려한 후 useMemo등을 이용하여 변경이 필요없는 데이터들은 렌더링마다 생성되는 것을 막아주는 것이 좋다.
💡 그럼 Context는 주로 언제 사용하는지?
- 다크모드 등 테마 지정할 때 : provider를 앱 최상단에 두고 시각적으로 조정이 필요한 컴포넌트에서 context를 사용
- 현재 계정: 로그인한 사용자를 알아야 하는 컴포넌트가 많을 수 있기 때문에, context에 놓으면 트리 어디에서든 편하게 알아낼 수 있다.
트리의 다른 부분에서 멀리 떨어져 있는 컴포넌트들이 같은 정보가 필요하다는 것은 context를 사용하기 좋은 경우라고 한다.
'리액트 심화 스터디' 카테고리의 다른 글
🏄♀️ 리심스 3주차 - 리액트 렌더링 최적화 (2) | 2024.11.12 |
---|---|
[Week 3] Automatic batching, windowing, useMemo, useCallback (2) | 2024.11.12 |
🏄♀️ 리심스 2주차 - 리액트 렌더링 (2) | 2024.11.05 |
[Week 2] 리액트 렌더링 프로세스, React fiber (2) | 2024.11.05 |
[Week 1] snap state, Context, Virtual Dom, Reconciliation (3) | 2024.10.28 |