[React] Virtual DOM과 재조정
📌 출처
Virtual DOM과 Internals – React (reactjs.org)
Virtual DOM과 Internals – React
A JavaScript library for building user interfaces
ko.legacy.reactjs.org
https://callmedevmomo.medium.com/virtual-dom-react-%ED%95%B5%EC%8B%AC%EC%A0%95%EB%A6%AC-bfbfcecc4fbb
Virtual DOM (React) 핵심정리
리액트가 수많은 개발자들 사이에서 엄청난 사랑을 받는 이유중 한가지는 바로 빠른 속도입니다.
callmedevmomo.medium.com
https://react.dev/learn/preserving-and-resetting-state
Preserving and Resetting State – React
The library for web and native user interfaces
react.dev
📋 Virtual DOM
Virtual DOM(VDOM)은 UI의 가상적인 표현을 메모리에 저장하고 ReactDOM과 같은 라이브러리에 의해 실제 DOM과 동기화하는 프로그래밍 개념이다.
➡️ 이 과정을 재조정 이라고 한다.
React에게 원하는 UI의 상태를 알려주면 DOM이 그 상태와 일치하도록 하는 것이다.
이 방식은 앱 구축에 사용해야 하는 attribute 조작, 이벤트 처리, 수동 DOM 업데이트를 추상화한다.
❓ 추상화
바닐라 JS를 이용할 때에는 HTML 요소의 속성 조작, DOM 업데이트 등을 DOM에서 직접 요소를 가져와 무언가를 변화시키고 다시 DOM에 삽입하는 형태로 수동적으로 이루어져야 했다.
but React를 이용하면 복잡하게 DOM 조작을 위와 같이 직접 다룰 필요 없이 JSX 문법으로 HTML 코드 안에 JS 코드를 작성함으로써 UI를 간단하게 구축하고 관리할 수 있다.
이렇게 복잡한 작업을 개발자로부터 숨기고 단순화하여 개발을 용이하게 만드는 것을 추상화라고 한다.
📍 Virtual DOM 파헤치기
➡️ 일반 DOM과 Virtual DOM
Virtual DOM은 실제 DOM의 복사본으로, 실제 DOM의 모든 element와 속성을 공유하고 실제 DOM이 아닌 JS 객체 형태로 메모리 안에 저장되어 있다.
차이점은 Virtual DOM을 이용해서는 브라우저에 있는 문서에 직접적으로 접근할 수 없다는 점이다.
이렇게 Virtual DOM에서는 실제 DOM과 다르게 직접적으로 브라우저 화면의 UI를 조작할 수 있게 하는 API를 제공하지 않는 JS 객체에 불과하므로 가상 DOM에 접근하고 수정하는 것은 실제 브라우저에 접근하는 것보다 더 가볍고 빠른 작업이 된다.
➡️ 가상 DOM
리액트는 2개의 가상 DOM 객체를 가지고 있는데, 하나는 렌더링 이전 화면 구조를 나타내는 가상 돔이고, 하나는 렌더링 이후에 보이게 될 화면 구조를 나타내는 가상 돔이다.
리액트에서 State가 변경되면 re-rendering이 일어나고, 실제 브라우저에 그려지기 전인 이 시점마다 새로운 내용이 담긴 가상 돔을 생성하게 된다.
Diffing을 통해 이전 화면 내용에 해당하는 첫번째 가상 DOM과 State 업데이트 이후의 내용을 담은 두 번 째 가상 DOM을 비교해 어떤 Element가 변했는지를 비교하는데, 리액트에서는 이를 신속하게 파악한다.
이렇게 하여 발견된 차이점이 있는 부분만 브라우저상의 실제 DOM에 적용하게 된다.
이 과정을 재조정이라고 한다.
이것이 효율적인 이유는 변경된 모든 Element들을 집단화시켜 한번에 실제 DOM에 적용하는 Batch Update 방식으로 이루어지기 때문이다.
✔️ ex) 일반적인 DOM 조작에서 변경된 요소를 별개로 그려주는 방식
일반적으로 DOM 조작은 브라우저의 렌더링 엔진에 의해 수행되며, 각각의 변경이 일어날 때마다 브라우저는 이를 즉시 처리하고 변경된 부분에 대해 화면을 다시 그린다.
만약 리스트의 각 항목이 순차적으로 추가되거나 변경될 때, 브라우저는 각 항목을 개별적으로 업데이트하고 여러번 DOM 조작을 수행하여 화면을 다시 그린다. 이는 비용이 많이 드는 작업이다.
✔️ ex) React의 Virtual DOM 사용 시 Batch Update 방식
여러 변경 사항을 한 번에 처리하여 렌더링 엔진이 한 번에 업데이트를 수행하도록 하여 불필요한 렌더링이나 화면의 반복적인 그리기를 최소화하여 성능을 향상시킨다.
위의 예시에서의 경우와 같이 여러 요소들의 변경이 발생해도 React는 이를 한꺼번에 받아와 가상 DOM에 적용한 후 최종 변경 사항을 실제 DOM에 한꺼번에 반영하여 화면을 업데이트하므로 DOM 조작의 횟수가 줄어든다.
❗ but React를 사용하더라도 컴포넌트 최적화 부족, 불필요한 데이터 가져오기, 비효율적인 상태관리, 복잡한 UI 렌더링 등의 행위를 한다면 성능 저하가 발생할 수 있다.
📋 재조정 (Reconciliation)
위에서 재조정의 개념을 알아보았으니 실제로 재조정이 어떻게 이루어지는지 코드를 통해 살펴보자.
📍 JSX 태그가 한 개만 존재해도 다른 위치에서 렌더링된다.
// https://react.dev/learn/preserving-and-resetting-state 에서 코드 일부만 발췌
export default function App() {
const counter = <Counter />;
return (
<div>
{counter}
{counter}
</div>
);
}
위에서 <Counter />가 2번 렌더링 될 수 있도록 설정해 주었는데 이 둘 각각은 위와 같이 트리에서 자체적인 위치에 렌더링되기 때문에 별도의 카운터이다.
이것들이 화면에 렌더링되면 각 구성 요소는 완전히 격리되고 고유한 상태를 가지게 된다.
위와 같이 하나의 Counter에 대한 count 상태가 업데이트 되면 해당 구성 요소의 상태만 업데이트 되는 것이다. 두 카운터는 상태에 대하여 서로 영향을 주지 않는다.
이번에는 두 번째 카운터가 사라질 수 있도록 코드를 짜 보자.
// https://react.dev/learn/preserving-and-resetting-state 에서 코드 일부만 발췌
export default function App() {
const [showB, setShowB] = useState(true);
return (
<div>
<Counter />
<!-- showB가 true이면 2번째 Counter가 렌더링되고 false이면 사라진다. -->
{showB && <Counter />}
<label>
<!-- input checkbox 생성, 체크했다 해제했다를 할 수 있다. -->
<input
type="checkbox"
onchange={e => {
setShowB(e.target.checked)
}}
/>
Render the second counter
</label>
</div>
);
}
두 번째 카운터 렌더링을 중지하는 순간 해당 상태는 완전히 사라진다. React가 두 번째 카운터에 대한 구성 요소를 DOM에서 제거함으로써 해당 상태도 파괴되기 때문이다.
다시 두 번째 카운터 렌더링을 선택하여 나타나게 하면 두 번째 카운터 Counter와 해당 상태가 초기화되어 count가 다시 0이 되고 DOM에 추가된다.
📍 하나의 상태를 업데이트해도 UI 트리의 동일한 위치에 컴포넌트가 계속 존재하고 있다면 또 다른 상태가 재설정되지 않는다.
React는 UI 트리의 해당 위치에서 '렌더링되는 동안' 구성 요소의 상태를 유지한다.
해당 요소가 제거되거나 다른 구성 요소가 동일한 위치에 렌더링되어야 하는 경우에 해당 상태를 삭제하게 된다.
// https://react.dev/learn/preserving-and-resetting-state 에서 코드 일부만 발췌
export default function App() {
// isFancy가 true일 경우 Counter UI를 좀 더 화려하게 보이도록 한다.
// Counter 컴포넌트에는 count State가 존재하여 클릭을 하면 count 숫자가 늘어나게 되어 있다.
// checkbox를 통해 isFancy를 적용할지 안할지 결정할 수 있다.
const [isFancy, setIsFancy] = useState(false);
return (
<div>
{isFancy ? (
<Counter isFancy={true} />
) : (
<Counter isFancy={false} />
)}
<label>
<input
type="checkbox"
checked={isFancy}
onChange={e => {
setIsFancy(e.target.checked)
}}
/>
Use fancy styling
</label>
</div>
);
}
Use fancy styling 체크박스를 선택하거나 선택 취소할 경 counter의 숫자가 그대로 유지되는 것을 볼 수 있다. isFancy의 상태가 true인지 false인지 여부와 관계 없이 카운터 상태는 카운터를 증가시킨 만큼 그대로 유지된다.
위의 경우 해당 위치에서 요소가 제거되거나 다른 요소가 들어가게 되는 것이 아니기 때문에 isFacny State를 업데이트해도 요소(div와 그 하위 요소)가 동일한 '위치'에 유지되고 있어 App 컴포넌트가 재설정되는 것이 아니다. 따라서 counter State가 그대로 유지되게 된다.
📍 동일한 위치에 다른 구성요소를 넣었을 때 상태는 재설정된다.
// https://react.dev/learn/preserving-and-resetting-state 에서 코드 일부만 발췌
export default function App() {
// Take a break 체크박스를 눌러 isPaused가 true가 될 경우 Counter 컴포넌트가 아닌 See you later!가
// 렌더링되게 됨.
// 체크박스를 해제하여 isPaused가 false가 될 경우 다시 Counter 컴포넌트가 렌더링됨.
const [isPaused, setIsPaused] = useState(false);
return (
<div>
{isPaused ? (
<p>See you later!</p>
) : (
<Counter />
)}
<label>
<input
type="checkbox"
checked={isPaused}
onChange={e => {
setIsPaused(e.target.checked)
}}
/>
Take a break
</label>
</div>
);
}
초기 상태에서는 체크박스에 체크가 되어 있지 않으므로 <div> 하위 요소로 Counter 컴포넌트가 들어가게 된다.
but 체크박스를 클릭하여 하위 요소를 p로 교체하게 되면 UI 트리에서 Counter가 제거되고 그 동일한 자리에 p가 들어가는 것이므로 Counter의 상태가 파괴되게 된다.
마찬가지로 체크박스를 다시 해제하면 p가 div의 하위 요소에서 빠지게 되고 그 자리에 Counter 컴포넌트가 들어가게 되는데, 이미 counter State는 파괴되어 초기값으로 재설정되었으므로 count로 0이 화면에 뜨는 것을 볼 수 있다.
이렇게 동일한 위치에 다른 구성 요소를 렌더링하면 하위 트리의 상태가 전체적으로 재설정되게 된다.
두 번째로, 위에서 보았던 isFancy 예제를 변형한 것을 살펴보자.
여기에서 중요한 것은 JSX 마크업이 어떻게 생겼는지가 아니라, UI 트리의 위치와 구성이다.
단순히 JSX 마크업이 변경되었다고 해서 무조건 상태가 재설정, 초기화 되는 것은 아니다.
// https://react.dev/learn/preserving-and-resetting-state 에서 코드 일부만 발췌
export default function App() {
// 위 예시와 마찬가지로 체크박스를 클릭하여 isFancy가 true가 될 경우 화려한 UI가 적용되고, false일
// 경우 일반적인 UI가 적용된다.
const [isFancy, setIsFancy] = useState(false);
return (
<div>
{isFancy ? (
<div>
<Counter isFancy={true} />
</div>
) : (
<section>
<Counter isFancy={false} />
</section>
)}
<label>
<input
type="checkbox"
checked={isFancy}
onChange={e => {
setIsFancy(e.target.checked)
}}
/>
Use fancy styling
</label>
</div>
);
}
but 위에서 봤던 isFancy 예제와 다른 점은 이전 예시에서는 Counter 컴포넌트가 isFancy가 true 이거나 false이거나에 상관 없이 Counter 컴포넌트가 항상 <div>의 하위 요소였는데 이번 예시에서는 isFancy가 true일 경우에는 Counter 컴포넌트가 <div>의 하위 요소로 반환되고, false일 경우 <section>의 하위 요소로 반환된다는 것이다.
이에 따라 Use fancy styling 체크박스를 클릭하면 원래 <div> 였던 자리에 <section>이 들어가게 되면서 <div> 에 종속되어 있던 Counter 컴포넌트가 사라지고 <section>에 종속되어 있는 Counter 컴포넌트가 자리잡게 된다. 이 과정에서 counter State가 초기화되어 fancy style이 적용됨과 동시에 count가 다시 0으로 렌더링 되는 것을 볼 수 있다.
체크박스를 해제했을 때도 마찬가지이다.
즉, 재렌더링 시 상태를 유지하고 싶다면 트리 '구조'가 일치해야 한다.
React가 트리에서 구성 요소를 제거하거나 바꾸어서 트리 구조가 바뀐다면 상태를 파괴하기 때문이다.
📍 컴포넌트 함수 정의를 중첩하여 하면 안 되는 이유
대부분의 경우 우리가 원치 않은 결과를 얻을 수 있다.
다음과 같이 어떤 컴포넌트 안에서 또 다른 컴포넌트를 정의한다고 해보자.
// https://react.dev/learn/preserving-and-resetting-state 에서 코드 일부만 발췌
export default function MyComponent() {
const [counter, setCounter] = useState(0);
// 주목! input을 반환하는 MyTextField 컴포넌트가 MyComponent 안에 선언되어 있다.
function MyTextField() {
const [text, setText] = useState('');
return (
<input
value={text}
onChange={e => setText(e.target.value)}
/>
);
}
return (
<>
<MyTextField />
<button onClick={() => {
setCounter(counter + 1)
}}>Clicked {counter} times</button>
</>
);
}
button을 클릭하여 counter State가 증가하는 것으로 변경되면 MyComponent에서 반환하는 JSX 코드대로 다시 렌더링이 된다. 즉, MyTextField에서 input에 기록되었던 값 또한 다시 렌더링되게 된다는 것이다.
그런데 MyTextField 컴포넌트는 MyComponent 안에서 정의가 되어 있으므로 <MyTextField />가 렌더링되어야 하는 시점에서 MyTextField 함수가 다시 실행되어 text State가 다시 '' 로 초기화되어 input 값이 빈 것으로 나타나게 된다.
count는 MyComponent에서의 State이므로 정상적으로 이전 값에서 1 증가하게 된다.
이전에 작성해 두었던 input 값을 버튼 클릭 시에도 유지하고 싶다면 MyTextField를 MyComponent 컴포넌트 밖으로 빼서 작성해 주면 된다. 컴포넌트 함수를 최상위 수준에서 선언하는 것이다.
💻 해결법
function MyTextField() {
const [text, setText] = useState('');
return (
<input
value={text}
onChange={e => setText(e.target.value)}
/>
);
}
export default function MyComponent() {
const [counter, setCounter] = useState(0);
return (
<>
<MyTextField />
<button onClick={() => {
setCounter(counter + 1)
}}>Clicked {counter} times</button>
</>
);
}
📍 그렇다면 UI 트리의 동일한 위치에서 상태 재설정을 하는 방법은?
React에서는 동일한 위치에 있는 동안 구성 요소의 상태를 유지하는 것을 살펴볼 수 있었다.
하나의 상태를 초기화시키지 않고 유지하면서 또 다른 상태는 바꾸고 싶을 경우에는 어떻게 해야 하는지 아래 코드를 통해 알아보자.
// https://react.dev/learn/preserving-and-resetting-state 에서 코드 일부만 발췌
export default function Scoreboard() {
// isPlayerA 상태가 true이면 Counter의 props로 person="Taylor"가 전달되고,
// false이면 Counter의 props로 person="Sarah"가 전달됨.
// Next player! 버튼을 누르면 isPlayerA의 상태가 반대로 전환됨.
const [isPlayerA, setIsPlayerA] = useState(true);
return (
<div>
{isPlayerA ? (
<Counter person="Taylor" />
) : (
<Counter person="Sarah" />
)}
<button onClick={() => {
setIsPlayerA(!isPlayerA);
}}>
Next player!
</button>
</div>
);
}
Counter 컴포넌트에서는 점수에 해당하는 score State가 기록된다.
위의 경우 Next player! 버튼을 눌러서 플레이어를 변경(isPlayerA 상태 변경)해도 두 경우의 Counter 컴포넌트 자리에 다른 컴포넌트가 들어가거나 삭제되는 것이 아니여서 React는 이를 props만 변경된 것으로 보므로 트리에서 동일한 위치에 있다고 간주해 점수가 계속 그대로 유지되게 된다.
처음에 isPlayerA의 초기값이 true이므로 Talyor의 점수를 증가시키는 것에서 시작하였다가 Next player! 버튼을 클릭해 isPlayerA의 상태값을 false로 전환해 Sarah로 player를 바꾸어도 score State는 초기화되지 않고 그대로 유지되어 Talyor에서 증가시켜 두었던 점수 그대로 가지고 갈 수 있는 것이다.
그렇다면 만약 Sarah's score와 Talyor's score을 독립적으로 만들고 싶다면 어떻게 해야 할까?
1️⃣ 다른 위치에서 구성 요소 렌더링
// https://react.dev/learn/preserving-and-resetting-state 에서 코드 일부만 발췌
export default function Scoreboard() {
const [isPlayerA, setIsPlayerA] = useState(true);
// 아래와 같이 독립적으로 Counter를 렌더링하는 방법이 있다.
return (
<div>
{isPlayerA &&
<Counter person="Taylor" />
}
{!isPlayerA &&
<Counter person="Sarah" />
}
<button onClick={() => {
setIsPlayerA(!isPlayerA);
}}>
Next player!
</button>
</div>
);
}
바로 위에서 봤던 예시랑 비슷해 보이지만 바로 위 예시는 삼항 연산자를 이용해 트리의 한 위치에서 <Counter person="Talyor" />를 렌더링 할것인지 <Counter person="Sarah" />를 렌더링 할 것인지가 State인 isPlayerA의 값에 따라 달라지는 것이였다.
but 이번 예시의 경우 애초에 두 컴포넌트가 렌더링 될 위치를(트리에서의 위치) 독립적으로 분리시켜 놓았다. 따라서 Next player! 버튼을 눌러 player를 변경하면 한 위치에서의 컴포넌트가 사라지고(DOM에서 사라짐, 상태도 같이 파괴됨) 다른 위치에서의 컴포넌트가 추가되게 되는 것이므로 score State도 초기값인 0으로 재설정되게 된다.
즉, Sarah의 score과 Talyor의 score State는 독립적이게 되고, player를 바꾸면 score가 다시 0부터 시작된다.
이러한 방법은 구성 요소가 몇 개 되지 않을 때 사용하는 것이 편리하다.
2️⃣ 키를 사용하여 상태 재설정
key를 사용하는 것은 단지 <li>에서만을 위한 것이 아니다.
key를 사용하여 React가 구성 요소를 구별하도록 할 수 있고, 단순히 첫 번째 컴포넌트, 두 번째 컴포넌트 이런 식이 아닌 특정 컴포넌트임을 React에게 알릴 수 있다. (ex) Taylor의 Counter, Sarah의 Counter)
이렇게 key를 설정해 주면 같은 위치에 나타나더라도 상태를 공유하지 않고 독립적인 상태를 가지게 된다.
// https://react.dev/learn/preserving-and-resetting-state 에서 코드 일부만 발췌
export default function Scoreboard() {
const [isPlayerA, setIsPlayerA] = useState(true);
// Counter에 key를 통해 어떤 것이 Taylor의 Counter인지, Sarah의 Counter인지를 React에게
// 알려 주었다.
return (
<div>
{isPlayerA ? (
<Counter key="Taylor" person="Taylor" />
) : (
<Counter key="Sarah" person="Sarah" />
)}
<button onClick={() => {
setIsPlayerA(!isPlayerA);
}}>
Next player!
</button>
</div>
);
}
위에서 두 Counter는 DOM에서 동일한 위치에 렌더링되게 되지만 key를 다르게 설정해 주었으므로 상태가 공유되지 않고 독립적으로 동작한다.
isPlayer의 상태가 바뀌면 score State도 다시 0부터 시작하는 것이다.
💡 키는 전체 어플리케이션에서(전역적으로) 고유해야 할 필요는 없고, 해당 키가 있는 컴포넌트의 형제들 사이에서 고유해야 한다. 따라서 동일한 부모 컴포넌트 내에서만 고유해야 한다. 이 규칙을 준수하면 React가 올바르게 컴포넌트를 식별하고 관리하는 것이 가능하다.