[TIL] React : Virtual DOM, useMemo, useCallback

ㅜㅜ·2022년 11월 25일
1

Today I learn

목록 보기
60/77
post-thumbnail

Virtual DOM 🌐

❓ what is Virtual DOM

가상의 DOM객체로 실제 DOM의 사본과 같은 개념.
React는 실제 DOM 객체에 접근해 조작하는 대신 이 가상 DOM 객체에 접근해 변화 전과 변화 후를 비교하고 바뀐 부분만 적용.
Virtual DOM은 추상화된 자바스크립트 객체의 형태를 가짐.

💡Remind DOM(real DOM)
Document Object Model의 약자로, 브라우저가 JS같은 스크립팅 언어가 태그들에 접근하고 조작할 수 있게 태그들을 트리 구조로 객체화 시킨 것을 말한다. (=트리구조로 만든 문서 객체 모델)

트리 구조는 저장된 데이터를 효과적으로 탐색할 수 있는 자료구조이므로 DOM은 JS와 같은 스크립팅 언어가 접근하고 탐색하는 속도가 빠르기 때문에 업데이트 속도도 빠른 편이다.

하지만 DOM의 업데이트는 필연적으로 브라우저의 렌더링이 리플로우(reflow) 됨을 의미하는데,
이렇게 브라우저가 리플로우, 리페인트 과정을 거치게 되면 레이아웃, 페인트에 해당하는 재연산이 필요하기 때문에 속도가 그만큼 느려지게 된다.

자바스크립트로 조작하는 DOM 요소가 많을수록 모든 DOM 업데이트에 대한 리플로우 때문에 DOM 업데이트에 대한 비용이 커진다. 모든 요소들을 다시 업데이트 하게 되는 것이 결국 엄청난 양의 DOM을 조작하게 하고, '프레임 드랍'과 같은 치명적인 UX 문제를 발생시킴.

그러므로 모든 요소를 다시 업데이트 하지말고, '바뀐 부분만 비교해 그 부분만 렌더링하자!'가 React의 Virtual DOM의 필요성이다.



🤔 How does React operate with Virtual DOM?

가상 DOM은 실제와 동일한 속성 가지지만 상대적으로 가벼운 사본이다.

  1. 트리 구조 가상의 DOM 만들어짐
  2. 새로운 요소가 UI에 추가
  3. 요소의 상태가 변경이 되면 다시 새로운 가상의 DOM 트리가 만들어짐
  4. 이전의 가상의 DOM과 이후의 가상의 DOM의 차이를 비교.
  5. 가상의 DOM은 실제 DOM에 변경을 수행할 수 있는 최상의 방법을 계산.
    (이렇게 하면 실제 DOM은 최소한의 작업만 수행해도 렌더링을 할 수 있게 된다)
  6. 실제 DOM의 업데이트 비용을 줄일 수 있게 됩니다.
    (업데이트 비용을 줄일 수 있다는 것은 브라우저의 파워를 덜 쓴다는 의미이므로, 더 빠른 렌더링이 가능.)


the way React search DOM Tree

트리의 레벨 순서(동일 레벨)대로 순회하는 방식 = 일종의 너비 우선 탐색 (BFS)으로 동일 선상에 있는 노드 파악한 다음 자식 세대 노드를 순차적으로 파악해나감.

다른 타입의 DOM 요소인 경우

DOM 트리는 각 HTML 태그마다 각각의 규칙이 있어 그 아래 들어가는 자식 태그가 한정적이라는 특징이 있음. (<ul>태그 밑에는 <li> 태그만 와야 한다던가, <p>태그 안에 <p>태그를 또 쓰지 못하는 것)

자식 태그의 부모 태그 또한 정해져 있다는 특징이 있기 때문에, 부모 태그가 달라진다면 React는 이전 트리를 버리고 새로운 트리를 구축함.

같은 타입의 DOM요소인 경우

타입이 바뀌지 않는다면 React는 최대한 렌더링을 하지 않는 방향으로 최소한의 변경 사항만 업데이트함.

업데이트 할 내용이 생기면 virtual DOM 내부의 프로퍼티만 수정한 뒤, 모든 노드에 걸친 업데이트가 끝나면 그때 단 한번 실제 DOM으로의 렌더링을 시도함.

이렇게 하나의 DOM 노드를 처리한 뒤 React는 뒤이어서 해당 노드들 밑의 자식들을 순차적으로 동시에 순회하면서 차이가 발견될 때마다 변경합니다. 이를 재귀적으로 처리한다고 함.


React는 기존 <ul>과 새로운 <ul>을 비교할 때 자식 노드를 순차적으로 위에서부터 아래로 비교하면서 바뀐 점을 찾는데, 그렇기 때문에 첫 번째 자식 노드들과 두 번째 자식 노드들이 일치하는 걸 확인한 뒤 세 번째 자식 노드를 추가함.

이렇게 순차적으로 비교하기 때문에, 이 동작 방식에 대해 고민하지 않고 리스트의 처음에 엘리먼트를 삽입하게 되면 이전의 코드에 비해 훨씬 나쁜 성능을 냄. (모든 리스트 전체가 바뀌었다고 받아들이고 새롭게 랜더링함.)

이 문제를 해결하기 위해 Key라는 속성을 지원함. 자식 노드들이 이 key를 갖고 있다면, React는 그 key를 이용해 기존 트리의 자식과 새로운 트리의 자식이 일치하는지 아닌지 확인할 수 있다.



🔑 why is 'key' so important?

//key가 없을 때 

<ul>
  <li>first</li>
  <li>second</li>
</ul>

//자식 엘리먼트의 처음에 새로운 자식 엘리먼트를 추가한 경우.
<ul>
  <li>third</li>
  <li>first</li>
  <li>second</li>
</ul>

//새롭게 랜더링 함 
//key가 있을 때 

<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

//key가 2014인 자식 엘리먼트를 맨 앞에 추가한 경우.
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>

React는 기존 <ul>과 새로운 <ul>을 비교할 때 자식 노드를 순차적으로 위에서부터 아래로 비교하면서 바뀐 점을 찾는데, 그렇기 때문에 첫 번째 자식 노드들과 두 번째 자식 노드들이 일치하는 걸 확인한 뒤 세 번째 자식 노드를 추가함.

이렇게 순차적으로 비교하기 때문에, 이 동작 방식에 대해 고민하지 않고 리스트의 처음에 엘리먼트를 삽입하게 되면 이전의 코드에 비해 훨씬 나쁜 성능을 냄. (모든 리스트 전체가 바뀌었다고 받아들이고 새롭게 랜더링함.)

이 문제를 해결하기 위해 Key라는 속성을 지원함. 자식 노드들이 이 key
를 갖고 있다면, React는 그 key를 이용해 기존 트리의 자식과 새로운 트리의 자식이 일치하는지 아닌지 확인할 수 있다.

key속성에는 보통 데이터 베이스 상의 유니크한 값(ex. Id)을 부여해주면 됨. 키는 전역적으로 유일할 필요는 없고, 형제 엘리먼트 사이에서만 유일하면 됨.

유니크한 값이 없다면 최후의 수단으로 배열의 인덱스를 key로 사용할 수 있지만 배열이 다르게 정렬될 경우가 생긴다면 배열의 인덱스를 key로 선택했을 경우는 비효율적으로 동작함. 왜냐하면 배열이 다르게 정렬되어도 인덱스는 그대로 유지되기 때문인데, 인덱스는 그대로지만 그 요소가 바뀌어버린다면 React는 배열의 전체가 바뀌었다고 받아들일 것이고, 기존의 DOM 트리를 버리고 새로운 DOM 트리를 구축해버린다.





React Hooks : useMemo, useCallback 🪝

❓ what is Hook?

Hook은 함수형 컴포넌트에서 상태 값 및 다른 여러 기능을 사용하기 편리하게 해주는 메소드를 의미한다. (클래스형 컴포넌트에서는 동작하지 않음.)

Hook의 사용규칙

  1. 리액트 함수의 최상위에서만 호출해야 함.

: 반복문, 조건문, 중첩된 함수 내에서 Hook을 실행하면 예상한 대로 동작하지 않을 수 있음.

컴포넌트 안에는 useState나 useEffect 같은 Hook들이 여러 번 사용될 수 있는데, React는 이 Hook을 호출되는 순서대로 저장함.

그런데 조건문, 반복문 안에서 Hook을 호출하게 되면 호출되는 순서대로 저장을 하기 어려워지고, 결국 예기치 못한 버그를 초래할 수 있다.

  1. 오직 리액트 함수 내에서만 사용되어야 함.

: 리액트 함수형 컴포넌트나 커스텀 Hook이 아닌 다른 일반 Js 함수 안에서 호출하면 에러 발생.

Hook은 React의 함수 컴포넌트 내에서 사용되도록 만들어진 메소드이기 때문에 근본적으로 일반 JavaScript 함수 내에서는 정상적으로 돌아가지 않는다.



❓class Component vs. function Component

함수 컴포넌트 이전에 클래스 컴포넌트가 있었는데, 클래스 컴포넌트는 복잡할수록 이해하기 어려워졌고, 컴포넌트 사이에서 상태 로직을 재사용하기 어렵다는 단점이 있었다. (+JS에서 this키워드가 어떤 방식으로 동작하는지 알아야 클래스 컴포넌트의 동작 방식을 이해할 수 있었음.)

//클래스 컴포넌트로 카운트 만들기
class Counter extends Component {
    constructor(props) {
        super(props);
        this.state = {
            counter: 0
        }
        this.handleIncrease = this.handleIncrease.bind(this);
    }

    handleIncrease = () => {
        this.setState({
            counter: this.state.counter + 1
        })
    }
	
    render(){
       return (
            <div>
                <p>You clicked {this.state.counter} times</p>
                <button onClick={this.handleIncrease}>
                    Click me
                </button>
            </div>
       ) 
    }
}
//함수 컴포넌트로 카운터 작성 

function Counter () {
    const [counter, setCounter] = useState(0);

    const handleIncrease = () => {
        setCounter(counter + 1)
    }

    return (
        <div>
            <p>You clicked {counter} times</p>
            <button onClick={handleIncrease}>
                Click me
            </button>
        </div>
    )
} 

함수형 컴포넌트는 클래스형 컴포넌트에 비해 훨씬 더 직관적이고 보기 쉽다는 특징이 있음.

위 카운터 컴포넌트에서는 useState()라는 Hook(매서드)을 호출해 함수 컴포넌트 안에 State를 추가한 형태인데, 이 state는 컴포넌트가 리렌더링 되더라도 그대로 유지된다. (이러한 state hook은 필요에 따라 여러 개 사용할 수도 있음)



📝 useMemo

렌더링 최적화를 위한 Hook(메소드) ⇒ useCallback, useMemo
특정 값을 재사용하고자 할 때 사용하는 Hook이 useMemo

function Calculator({value}){

	const result = calculate(value);

	return <>
      <div>
					{result}
      </div>
  </>;
}

Calculator 컴포넌트는 props로 받은 value 값을 calculate라는 함수에 인자로 넘겨 result 값을 구한 뒤 div 엘리먼트로 출력함.

만약 calculate가 복잡한 연산을 하는 함수라 값을 반환하는 데 시간이 오래 걸린다면, 렌더링 할 때마다 다시 계산을 하게 되는 것이 렌더링에도 영향을 미칠 수 있음.

렌더링을 할 때마다 value값이 계속 바뀌는 게 아니라서 이 값을 어딘가에 저장을 해뒀다가 다시 꺼내서 쓸 수만 있다면 굳이 calculate함수를 호출할 필요도 없음. 여기서 useMemoHook을 사용할 수 있다.

/* useMemo를 사용하기 전에는 꼭 import해서 불러와야 합니다. */
import { useMemo } from "react";

function Calculator({value}){

	const result = useMemo(() => calculate(value), [value]);

	return <>
      <div>
					{result}
      </div>
  </>;
}

이런 식으로 useMemo를 호출하여 calculate를 감싸주면, 이전에 구축된 렌더링과 새로이 구축되는 렌더링을 비교해 value값이 동일할 경우에는 이전 렌더링의 value값을 그대로 재활용할 수 있다. 이는 메모이제이션(Memoization) 개념과 긴밀한 관계가 있음.



💭 Memoization

알고리즘에서 자주 나오는 개념으로 기존에 수행한 연산의 결과값을 메모리에 저장해두고 동일 입력이 들어오면 재활용하는 프로그래밍 기법.

메모이제이션의 적절한 활용으로 중복 연산을 줄여주면 앱의 성능을 최적화할 수 있다.

useMemo는 이런 메모이제이션을 이용해 복잡한 연산의 중복을 피하고 리액트 앱 성능을 최적화 한다.

useMemo는 첫번째 인자로 콜백 함수를, 두 번째 인자로 의존성 배열을 받는다.

두번째 인자인 배열 요소 값이 업데이트 될 때만 콜백함수를 다시 호출해 memoization된 값을 업데이트 후 다시 memoization한다.

빈배열을 넘겨주면 맨 처음 컴포넌트가 마운트 되었을 때만 값을 계산하고 이후에는 항상 memoization된 값을 꺼내 와 사용함.

값을 재활용하기 위해서이지만 메모리를 소비해 저장하기 때문에 불필요한 값을 memoization 해버리면 성능이 안 좋아질 수 있음.

📢 useCallback

useMemo와 마찬가지로 메모이제이션 기법을 이용한 Hook.

usaCallback은 값의 재사용이 아닌 ‘함수의 재사용’을 위해 사용하는 Hook.

function Calculator({x, y}){

	const add = () => x + y;

	return <>
      <div>
					{add()}
      </div>
  </>;
}

위 예시에서 add 함수는 Calculator 컴포넌트가 렌더링 될 때마다 새롭게 만들어짐.

useMemo와 마찬가지로, 해당 컴포넌트가 리렌더링 되더라도 그 함수가 의존하고 있는 값인 x와 y가 바뀌지 않는다면 함수 또한 메모리 어딘가에 저장해 뒀다가 다시 꺼내서 쓸 수 있을 것임.

useCallback Hook을 사용하면 그 함수가 의존하는 값들이 바뀌지 않는 한 기존 함수를 계속해서 반환함. 즉 x와 y값이 동일하다면 다음 렌더링 때 이 함수를 다시 사용한다.

/* useCallback를 사용하기 전에는 꼭 import해서 불러와야 합니다. */
import React, { useCallback } from "react";

function Calculator({x, y}){

	const add = useCallback(() => x + y, [x, y]);

	return <>
      <div>
					{add()}
      </div>
  </>;
}

사실 useCallback만 사용해서는 useMemo에 비해 효용성을 체감하기 힘든데 useCallback은 함수를 호출을 하지 않는 Hook이 아니라, 그저 메모리 어딘가에 함수를 꺼내서 호출하는 Hook이기 때문이다.

단순히 컴포넌트 내에서 함수를 반복해서 생성하지 않기 위해 useCallback을 사용하는 것은 큰 의미가 없거나 오히려 손해인 경우도 있음.

자식 컴포넌트의 props로 함수를 전달해줄 때 useCallback을 사용하면 좋음!!!!

참조 동등성

React는 JavaScript 언어로 만들어진 오픈소스 라이브러리이기 때문에 기본적으로 JavaScript의 문법을 따른다. JavaScript에서 함수는 객체인데, 객체는 메모리에 저장할 때 값을 저장하는 게 아니라 값의 주소를 저장하기 때문에, 반환하는 값이 같을 지라도 일치연산자로 비교했을 때 false가 출력됨.

React는 리렌더링 시 함수를 새로이 만들어서 호출함. 새로이 만들어 호출된 함수는 기존의 함수와 같은 함수가 아님.

useCallback을 이용해 함수 자체를 저장해서 다시 사용하면 함수의 메모리 주소 값을 저장했다가 다시 사용한다는 것과 같다고 볼 수 있어서 React 컴포넌트 함수 내에서 다른 함수의 인자로 넘기거나 자식 컴포넌트의 prop으로 넘길 때 예상치 못한 성능 문제를 막을 수 있다.

아래 예시를 통해 useCallback을 사용해보자

useCallback 사용 예시

//App.js 
import { useCallback, useState} from "react";
import "./styles.css";
import List from "./List";

export default function App() {
  const [input, setInput] = useState(1);
  const [light, setLight] = useState(true);

  const theme = {
    backgroundColor: light ? "White" : "grey",
    color: light ? "grey" : "white"
  };

//input이 바뀌는 경우에 기억되어 있는 getItems 함수를 호출하도록 함.
//이 함수는 List 컴포넌트에 props로 전달된다. 
  const getItems = useCallback( () => {
    return [input + 10, input + 100];
  },[input])

  const handleChange = (event) => {
    if (Number(event.target.value)) {
      setInput(Number(event.target.value));
    }
  };

  return (
    <>
      <div style={theme} className="wall-paper">
        <input
          type="number"
          className="input"
          value={input}
          onChange={handleChange}
        />
        <button
          className={(light ? "light" : "dark") + " button"}
          onClick={() => setLight((prevLight) => !prevLight)}
        >
          {light ? "dark mode" : "light mode"}
        </button>
        <List getItems={getItems} />
      </div>
    </>
  );
}

-------------------------------------------------------

//List.js
import { useState, useEffect} from "react";

//props로 getItems를 App.js에서 받아옴 
function List({ getItems }) {
 
  const [items, setItems] = useState([]);


 
  useEffect(() => {
    console.log("아이템을 가져옵니다.");
    setItems(getItems());
  }, [getItems]);

 
  return (
    <div>
      {items.map((item) => (
        <div key={item}>{item}</div>
      ))}
    </div>
  );
}

export default List;
profile
다시 일어나는 중

0개의 댓글