본문 바로가기

코드스테이츠 SEB FE 41기/Section 별 내용 정리

section4/Unit4/[React] 심화(11/25)

반응형

React 심화

 

Virtual DOM

React에는 Virtual DOM이라고 하는 가상의 DOM 객체가 있다. 이는 실제 DOM의 사본 같은 개념으로, React는 실제 DOM 객체에 접근하여 조작하는 대신 이 가상의 DOM 객체에 접근하여 변화 전과 변화 후를 비교하고 바뀐 부분을 적용한다. 

Virtual DOM이 나오게 된 배경

Virtual DOM은 Real DOM의 가벼운 사본과 같다. DOM을 Real DOM으로 부르는 이유는 Virtual DOM과 구분하기 위해서라고 생각하면 된다.

Real DOM (DOM)

Virtual DOM이 나오게 된 배경을 보기 전에, DOM의 개념을 다시 정리해 보도록 하자. DOM은 Document Object Model의 약자로, 뜻을 그대로 풀자면 문서 객체 모델을 의미한다. 여기서 문서 객체란 브라우저가 JavaScript와 같은 스크립팅 언어가 <html>, <head>, <body>와 같은 태그들에 접근하고 조작할 수 있도록 태그들을 트리 구조로 객체화 시킨 것을 의미한다. 다시 말하자면 DOM은 브라우저가 트리 구조로 만든 객체 모델이다.

DOM 객체는 트리 구조로 되어 있다.

이렇게 트리 구조로 DOM 객체가 이뤄져 있기 때문에 JavaScript는 쉽게 DOM 객체에 접근할 수 있고, DOM 객체를 조작할 수 있게 된다. 이렇게 프로그래밍 언어로 조작하는 DOM은 애플리케이션의 UI 상태가 변경될 때마다 해당 변경 사항을 나타내기 위해 업데이트가 된다. 만약 이런 DOM을 조작하는 정도가 잦다면 성능에 영향을 미치게 될 것이고, DOM의 렌더링은 브라우저의 파워, 즉 브라우저의 구동 능력에 의존하기 때문에 DOM의 조작 속도는 느려지게 된다.

 

DOM의 조작 속도가 느려지는 이유

DOM의 구조는 앞서 설명했듯이 계층적 구조로 되어 있는 트리이다. 자료구조 중에서 특히 트리는 “데이터 저장"의 의미보다는 “저장된 데이터를 더 효과적으로 탐색”하기 위해 사용되므로, 빠른 자료 탐색 성능이 장점인 자료구조라고 볼 수 있습니다. 그렇기 때문에 그런 트리 구조로 된 DOM은 JavaScript와 같은 스크립팅 언어가 접근하고 탐색하는 속도가 빠르기 때문에 변경 및 업데이트 속도 또한 빠르다.

트리구조로 된 DOM은 JavaScript 같은 스크립팅 언어가 접근하고 탐색하는 속도가 빠르다.

그러나 DOM이 변경되고 업데이트가 된다는 것은 결국 브라우저의 렌더링 엔진 또한 리플로우(Reflow)한다는 것을 의미한다. 즉 업데이트 된 요소와 그에 해당하는 자식 요소들에 의해 DOM 트리를 재구축함으로써 재랜더링 과정을 거쳐 UI를 업데이트 해야 한다. 브라우저의 리플로우와 리페인트 과정은 다시금 레이아웃 및 페인트에 해당하는 재연산을 해야 하기 때문에 속도가 그만큼 느려지게 된다.

DOM의 변경 및 업데이트는 브라우저 또한 화면을 리플로우(Reflow)한다는 것을 의미한다.

따라서 JavaScript로 조작하는 DOM의 요소가 많을수록 모든 DOM 업데이트에 대하여 리플로우를 해야 하므로 DOM의 업데이트에 대한 비용이 많이 들게 된다.

DOM 조작은 모던 웹의 인터랙티브 요소에 빠질 수 없는 것이지만, 대부분의 JavaScript 프레임워크는 필요 이상으로 DOM을 업데이트 시킨다.

JavaScript의 DOM 조작 예시

여기 예를 들어 지금 6개의 컨텐츠가 있는데 그 컨텐츠 중 단 1개의 컨텐츠만 색상이 바뀌는 동작을 한다고 가정해보자. 나머지 5개의 컨텐츠는 내버려두고 그 1개의 컨텐츠만 색상을 바꿔 그리는 것이 가장 효율적이라는 걸 알지만, 대부분의 JavaScript 프레임워크는 그 1개의 컨텐츠만 색상을 바꿔 그릴 뿐 아니라 나머지 컨텐츠도 다시 그린다.

JavaScript 프레임워크는 나머지 DOM 요소까지도 업데이트를 시킨다.

이 예시는 브라우저의 성능에 크게 문제를 끼치지 않겠지만, 모던 웹은 이 외에도 엄청난 양의 DOM을 조작할 가능성이 있다. 계속해서 이런 비효율적인 업데이트를 반복한다면 극단적인 예로 프레임 드랍(frame drop)과 같은 치명적인 UX 문제가 발생할 수 있다.

결국 “바뀐 부분만 비교해서 그 부분만 렌더링을 할 수는 없을까?“ 라는 아이디어를 기반으로 React는 Virtual DOM을 세상에 내놓게 된 것이다.

 

Virtual DOM이란

출처 : React and the Virtual DOM

그렇기 때문에 React에는 모든 DOM 객체에 대응하는 가상의 DOM 객체가 있다. 상대적으로 무거운 DOM에 비하여 React의 가상 DOM 객체는 실제 DOM 객체와 동일한 속성을 가지고 있음에도 “훨씬 가벼운 사본”이라고 표현할 수 있다. 다만 가상 DOM 객체는 화면에 표시되는 내용을 실제 DOM 객체처럼 직접 변경하는 것은 아니다.

가상 DOM은 가상의 UI 요소를 메모리에 유지시키고, 그 유지시킨 가상의 UI 요소를 ReactDOM과 같은 라이브러리를 통해 실제 DOM과 동기화시킨다. 실제 DOM을 조작하는 것은 실제로 브라우저 화면에 그리기 때문에 느리지만, 가상 DOM을 조작하는 것은 실제 DOM처럼 브라우저 화면에 그리는 것이 아니기 때문에 훨씬 속도가 빠르다.

실제 DOM을 조작하는 것과 가상 DOM을 조작하는 것의 차이

가상 DOM을 조작하는 것과 실제 DOM을 조작하는 것의 차이가 잘 이해가 안 간다면, 집을 이사할 때를 떠올려 보자. 실제로 집을 이사하고 짐을 옮기는 과정(실제 DOM을 조작하는 것)은 힘이 많이 들고 복잡한 일이지만, 이사하기 전에 머릿속으로 이사하고 짐을 옮기는 장면을 그리는 것(가상 DOM을 조작하는 것)은 그보다 더 쉬운 일이다.

 

어떻게 가상 DOM이 더 빠를까?

React는 새로운 요소가 UI에 추가되면 트리 구조로 표현이 되는 가상의 DOM이 만들어진다. 이러한 요소의 상태가 변경이 되면 다시 새로운 가상의 DOM 트리가 만들어진다. 그리고 이전의 가상의 DOM과 이후의 가상의 DOM의 차이를 비교한다. 이 작업이 완료가 되면 가상의 DOM은 실제 DOM에 변경을 수행할 수 있는 최상의 방법을 계산하기 시작한다. 이렇게 하면 실제 DOM은 최소한의 작업만 수행해도 렌더링을 할 수 있게 된다. 따라서 실제 DOM의 업데이트 비용을 줄일 수 있게 된다. 업데이트 비용을 줄일 수 있다는 것은 브라우저의 파워를 덜 쓴다는 의미이므로, 더 빠른 렌더링이 가능해진다.

아래 그림을 보면서 가상 DOM 트리가 실제 DOM 트리에 적용되는 과정을 보도록 하자.

가상 DOM이 실제 DOM에 적용되는 과정

노란색 원들은 업데이트된 노드, 즉 UI 요소를 의미한다. React에서는 “상태가 변경된” UI 요소로 볼 수 있다. 그런 다음 가상의 DOM 트리의 이전 버전과 현재 가상 DOM 트리 간의 차이가 계산된다. 그런 다음 트리가 업데이트된 UI를 제공하기 위해 부분적으로 리렌더링 된다. 이렇게 업데이트된 트리는 실제 DOM으로 한꺼번에 업데이트가 된다.

 

Virtual DOM의 형태

가상 DOM은 추상화된 자바스크립트 객체의 형태를 가지고 있다. DOM 트리 하나를 예시로 들어 보자.

이 DOM 트리는 JavaScript 객체로도 표현할 수 있다.

const vDom = {
	tagName: "html",
	children: [
		{ tagName: "head" },
		{ tagName: "body",
			children: [
				tagName: "ul",
				attributes: { "class": "list"},
				children: [
					{
						tagName: "li",
						attributes: { "class": "list_item" },
						textContent: "List item"
					}
				]
			]
		}
	]
}

이것을 가상 DOM이라고 생각해 보자. 실제 DOM과 마찬가지로 가상 DOM 또한 HTML 문서 객체를 기반으로 한다. 또한 추상화만 되었을 뿐 평범한 자바스크립트 객체이므로 실제 DOM을 건드리지 않고도 필요한 만큼 자유롭게 조작할 수 있다.

 

React Diffing Algorithm

React가 기존 가상 DOM과 변경된 새로운 가상 DOM을 비교할 때, React 입장에서는 변경된 새로운 가상 DOM 트리에 부합하도록 기존의 UI를 효율적으로 갱신하는 방법을 알아낼 필요가 있었다. 즉 하나의 트리를 다른 트리로 변형을 시키는 가장 작은 조작 방식을 알아내야만 했는데, 알아낸 조작 방식 알고리즘은 O(n^3)의 복잡도를 가지고 있었다.

만약 이 알고리즘을 그대로 React에 적용한다면 1000개의 엘리먼트를 실제 화면에 표시하기 위해 10억(1000^3)번의 비교 연산을 해야만 한다. 사실 이것은 너무 비싼 연산이기 때문에 React는 두 가지의 가정을 가지고 시간 복잡도 O(n)의 새로운 휴리스틱 알고리즘(Heuristic Algorithm)을 구현해낸다.

두 가지 가정은 이것이다.

  1. 각기 서로 다른 두 요소는 다른 트리를 구축할 것이다.
  2. 개발자가 제공하는 key 프로퍼티를 가지고, 여러 번 렌더링을 거쳐도 변경되지 말아야 하는 자식 요소가 무엇인지 알아낼 수 있을 것이다.

실제 이 두 가정은 거의 모든 실제 사용 사례에 들어맞게 된다. 여기서 React는 비교 알고리즘(Diffing Algorithm)을 사용한다.

 

React가 DOM 트리를 탐색하는 방법

React는 기존의 가상 DOM 트리와 새롭게 변경된 가상 DOM 트리를 비교할 때, 트리의 레벨 순서대로 순회하는 방식으로 탐색힌다. 즉 같은 레벨(위치)끼리 비교한다는 뜻이다. 이는 너비 우선 탐색(BFS)의 일종이라고 볼 수 있다.

React의 DOM 트리 순회 방식

React는 이런 식으로 동일 선상에 있는 노드를 파악한 뒤 다음 자식 세대의 노드를 순차적으로 파악해 나간다.

 

다른 타입의 DOM 엘리먼트인 경우

그런데 DOM 트리는 각 HTML 태그마다 각각의 규칙이 있어 그 아래 들어가는 자식 태그가 한정적이라는 특징이 있다. (예를 들어 <ul> 태그 밑에는 <li> 태그만 와야 한다던가, <p> 태그 안에 <p> 태그를 또 쓰지 못하는 것) 자식 태그의 부모 태그 또한 정해져 있다는 특징이 있기 때문에, 부모 태그가 달라진다면 React는 이전 트리를 버리고 새로운 트리를 구축해버린다.

<div>
	<Counter />
</div>

//부모 태그가 div에서 span으로 바뀝니다.
<span>
	<Counter />
</span>

이렇게 부모 태그가 바뀌어버리면, React는 기존의 트리를 버리고 새로운 트리를 구축하기 때문에 이전의 DOM 노드들은 전부 파괴된다. 부모 노드였던 <div>가 <span>으로 바뀌어버리면 자식 노드인 <Counter />는 완전히 해제된다. 즉 이전 <div> 태그 속 <Counter />는 파괴되고 <span> 태그 속 새로운 <Counter />가 다시 실행된다. 새로운 컴포넌트가 실행되면서 기존의 컴포넌트는 완전히 해제(Unmount)되어버리기 때문에 <Counter />가 갖고 있던 기존의 state 또한 파괴된다.

 

같은 타입의 DOM 엘리먼트인 경우

반대로 타입이 바뀌지 않는다면 React는 최대한 렌더링을 하지 않는 방향으로 최소한의 변경 사항만 업데이트합니다. 이것이 가능한 이유는 앞서 React가 실제 DOM이 아닌 가상 DOM을 사용해 조작하기 때문이다. 업데이트 할 내용이 생기면 virtual DOM 내부의 프로퍼티만 수정한 뒤, 모든 노드에 걸친 업데이트가 끝나면 그때 단 한번 실제 DOM으로의 렌더링을 시도한다.

<div className="before" title="stuff" />

//기존의 엘리먼트가 태그는 바뀌지 않은 채 className만 바뀌었습니다.
<div className="after" title="stuff" />

React는 두 요소를 비교했을 때 className만 수정되고 있다는 것을 알게 된다. className before와 after는 각자 이런 스타일을 갖고 있다고 해보자.

//className이 before인 컴포넌트
<div style={{color: 'red', fontWeight: 'bold"}} title="stuff" />

//className이 after인 컴포넌트
<div style={{color: 'green', fontWeight: 'bold"}} title="stuff" />

두 엘리먼트를 비교했을 때 React는 정확히 color 스타일만 바뀌고 있음을 눈치챈다. 따라서 React는 color 스타일만 수정하고 fontWeight 및 다른 요소는 수정하지 않는다. 이렇게 하나의 DOM 노드를 처리한 뒤 React는 뒤이어서 해당 노드들 밑의 자식들을 순차적으로 동시에 순회하면서 차이가 발견될 때마다 변경한다. 이를 재귀적으로 처리한다고 표현한다.

 

자식 엘리먼트의 재귀적 처리

예를 들면 이렇게 자식 엘리먼트가 변경이 된다고 가정하자.

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

//자식 엘리먼트의 끝에 새로운 자식 엘리먼트를 추가했습니다.
<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

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

이렇게 React는 위에서 아래로 순차적으로 비교하기 때문에, 이 동작 방식에 대해 고민하지 않고 리스트의 처음에 엘리먼트를 삽입하게 되면 이전의 코드에 비해 훨씬 나쁜 성능을 내게 된다.

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

//자식 엘리먼트를 처음에 추가합니다.
<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

이렇게 구현하게 되면 React는 우리의 기대대로 최소한으로 동작하지 못하게 된다. React는 원래의 동작하던 방식대로 처음의 노드들을 비교하게 된다.

처음의 자식 노드를 비교할 때, <li>Duke</li> 와 <li>Connecticut</li>로 자식 노드가 서로 다르다고 인지하게 된 React는 리스트 전체가 바뀌었다고 받아들인다. 즉 <li>Duke</li>와 <li>Villanova</li>는 그대로기 때문에 두 자식 노드는 유지시켜도 된다는 것을 깨닫지 못하고 전부 버리고 새롭게 렌더링 해버린다. 이는 굉장히 비효율적인 동작 방식이다.

그래서 React는 이 문제를 해결하기 위해 key라는 속성을 지원한다. 이는 효율적으로 가상 DOM을 조작할 수 있도록 한다. 만일 개발할 당시 key라는 속성을 사용하지 않으면 React 에서 key값을 달라고 경고문을 띄우는 것도 이 때문인 것이다. key값이 없는 노드는 비효율적으로 동작할 수 있기 때문이다.

 

키(key)

만약 자식 노드들이 이 key를 갖고 있다면, React는 그 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>
</ul>

React는 key 속성을 통해 ‘2014’라는 자식 엘리먼트가 새롭게 생겼고, ‘2015’, ‘2016’ 키를 가진 엘리먼트는 그저 위치만 이동했다는 걸 알게 된다. 따라서 React는 기존의 동작 방식대로 다른 자식 엘리먼트는 변경하지 않고 추가된 엘리먼트만 변경한다. 이 key 속성에는 보통 데이터 베이스 상의 유니크한 값(ex. Id)을 부여해주면 된다. 키는 전역적으로 유일할 필요는 없고, 형제 엘리먼트 사이에서만 유일하면 된다.

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


React Hooks

 

Component와 Hook

Hook은 함수 컴포넌트에서 사용하는 메소드이다. 함수 컴포넌트 이전에는 클래스(class) 컴포넌트가 있었다. 많은 React 개발자들이 이 클래스 컴포넌트를 사용하여 React 앱을 개발해왔다. React의 클래스 컴포넌트는 조금 생소한 개념일 수 있다.

 

Class Component

여기 <Counter /> 컴포넌트를 클래스로 작성한 간단한 코드를 보자.

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>
       ) 
    }
}

여러분이 보기에 이게 간단한 코드라고? 라고 생각할 수 있지만 함수 컴포넌트 이전의 클래스 컴포넌트로 작성할 때에는 이 정도는 작성을 해야지 앱이 정상적으로 동작할 수 있었다.

단순히 카운팅을 하는 기능만 구현한 것

이런 클래스 컴포넌트는 복잡해질수록 이해하기 어려워졌고, 컴포넌트 사이에서 상태 로직을 재사용하기 어렵다는 단점이 있었다. 또한 React의 클래스 컴포넌트를 사용하기 위해서는 JavaScript의 this 키워드가 어떤 방식으로 동작하는지 알아야 하는데, 이는 문법을 정확히 알지 못하면 동작 방식 자체를 정확히 이해하기 어렵게 만들곤 했다.

그래서 React는 점진적으로 클래스 컴포넌트에서 함수 컴포넌트로 넘어갔다. 다만 이전까지의 함수 컴포넌트는 클래스 컴포넌트와는 다르게 상태 값을 사용하거나 최적화할 수 있는 기능들이 조금 미진했는데, 그 부분들을 보완하기 위해 Hook이라는 개념을 도입하였다.

 

Function Component

이번에는 <Counter /> 컴포넌트를 함수형 컴포넌트로 작성해보자.

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>
    )
}

함수형 컴포넌트는 클래스형 컴포넌트에 비해 훨씬 더 직관적이고, 보기 쉽다는 특징이 있다. 이 Counter 컴포넌트에서 숫자를 올리기 위해 상태값을 저장하고 사용할 수 있게 해주는 useState() 가 있는데, 익히 알고 있는 이 메서드가 바로 Hook이다.

다시 말하자면, Counter 컴포넌트에서 useState() Hook을 호출함수 컴포넌트(function component) 안에 state를 추가한 형태이다. 이 state는 컴포넌트가 리렌더링 되어도 그대로 유지될 것이다. 또한 해당 컴포넌트에서 State Hook은 하나만 사용했지만 때에 따라서 여러 개 사용할 수 있다.

 

Hook이란?

React의 공식문서를 보면 Hook에 대해 이런 문구가 있다.

Hook은 React 16.8에 새로 추가된 기능입니다. Hook은 class를 작성하지 않고도 state와 다른 React의 기능들을 사용할 수 있게 해줍니다.

Hook은 다르게 말하면 함수형 컴포넌트에서 상태 값 및 다른 여러 기능을 사용하기 편리하게 해주는 메소드를 의미한다. Hook은 class가 아닌 function으로만 React를 사용할 수 있게 해주는 것이기 때문에 클래스형 컴포넌트에서는 동작하지 않는다.

...
render(){
	/* 클래스 컴포넌트는 render() 안에서 변수를 작성할 수 있습니다. */
       const [counter, setCounter] = useState(0);
...
}

클래스 컴포넌트에서는 useState() Hook을 호출할 수 없다는 에러

억지로 호출을 해보려고 해도 해당 방식은 React에서 허락하지 않는 호출 방식이기 때문에 위와 같은 에러를 브라우저 전면에 띄운다. 해당 에러를 삭제하면 컴포넌트 자체가 렌더링이 되지 않는 것을 볼 수 있다.

 

Hook 사용 규칙

Hook을 사용할 때는 두 가지 규칙을 준수해야만 한다. Hook을 직접 만들거나, 혹은 작성하면서 자주 볼 수 있는 에러들과 함께 규칙들을 살펴보도록 하자.

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

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

...
if(counter) {
    const [sample, setSample] = useState(0);
}
...

예를 들어 count가 있을 때 sample이라는 state를 사용하고 싶어서 조건문 안에 useState() hook을 불러왔다고 가정해보자.(이런 식의 가정은 애초부터 틀린 가정이다. 예시를 위해 이러한 가정을 한 것이다.) 이런 식으로 호출을 하게 되면 React의 동작 방식에 거스르기 때문에 React는 에러를 출력한다.

useState를 조건문 안에서 호출하면 안 된다는 Error

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

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

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

이는 리액트 함수형 컴포넌트나 커스텀 Hook이 아닌 다른 일반 JavaScript 함수 안에서 호출해서는 안 된다는 의미이다.

...
window.onload = function () {
    useEffect(() => {
        // do something...
    }, [counter]);
}
...

예를 들어 window의 요소가 모두 준비가 되면 useEffect()가 호출되었으면 좋겠다고 생각해서 함수를 작성했다고 가정해보자.(이런 식의 가정은 애초부터 틀린 가정이다. 예시를 위해 이러한 가정을 한 것이다.) 이 또한 React의 동작 방식에 위배되기 때문에 React는 에러를 출력한다.

window.onload라는 함수에서 useEffect를 호출하면 안 된다는 error

애초에 Hook은 React의 함수 컴포넌트 내에서 사용되도록 만들어진 메소드이기 때문에 근본적으로 일반 JavaScript 함수 내에서는 정상적으로 돌아가지 않는다. 따라서 이 규칙 또한 반드시 준수해야 하는 규칙이다.

 

useMemo

컴포넌트는 기본적으로 상태가 변경되거나 부모 컴포넌트가 렌더링이 될 때마다 리렌더링을 하는 구조로 이루어져 있다. 그러나 너무 잦은 리렌더링은 앱에 좋지 않은 성능을 끼친다.

너무 많은 렌더링은 앱에 안 좋은 성능을 미치는 극단적인 예

React Hook은 함수 컴포넌트가 상태를 조작하고 및 최적화 기능을 사용할 수 있게끔 하는 메소드라고 했다. 그 중 렌더링 최적화를 위한 Hook도 존재하는데, useCallbackuseMemo가 바로 그 역할을 하는 Hook이다.

 

useMemo란?

useMemo은 특정 값(value)를 재사용하고자 할 때 사용하는 Hook이다.

아래 코드를 보면서 useMemo에 대해 좀 더 알아보자.

function Calculator({value}){

	const result = calculate(value);

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

해당 컴포넌트는 props로 넘어온 value값을 calculate라는 함수에 인자로 넘겨서 result 값을 구한 후, <div> 엘리먼트로 출력을 하고 있다.

만약 여기서 calculate가 내부적으로 복잡한 연산을 해야 하는 함수라 계산된 값을 반환하는 데에 시간이 몇 초 이상 걸린다고 가정해 본다. 그렇다면 해당 컴포넌트는 렌더링을 할 때마다 이 함수를 계속해서 호출할 것이고, 그 때마다 시간이 몇 초 이상 소요가 될 것이다. 이 몇 초의 지연은 렌더링에도 영향을 미칠 것이고, 사용자는 “앱의 로딩 속도가 느리네?”라는 생각을 하게 될 것이다.

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

function Calculator({value}){

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

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

여기 value 를 인자로 받는 Calculator 컴포넌트가 있다. value 는 일종의 값으로서, 이 값이 계속 바뀌는 경우라면 어쩔 수 없겠지만, 렌더링을 할 때마다 이 value값이 계속 바뀌는 게 아니라고 생각해보자. 그럼 이 을 어딘가에 저장을 해뒀다가 다시 꺼내서 쓸 수만 있다면 굳이 calculate 함수를 호출할 필요도 없을 것이다. 여기서 useMemo Hook을 사용할 수 있다.

useMemo는 2개의 매개변수를 필요로 하는데, 첫 번째 매개변수에는 어떻게 연산할지 정의하는 함수(calculate())를 넣어주고 두 번째 매개변수에는 deps 배열([value])을 넣어준다. 이 배열 안에 넣은 내용이 바뀌면 첫 번째 매개변수에 넣은 함수를 호출해서 값을 연산해주고, 만약에 내용이 바뀌지 않는다면 이전에 연산한 값을 재사용하게 된다. 즉, 값이 변경됐는지 안 됐는지 감지하는 부분이므로 해당 부분은 반드시 인식해야 할 값을 넣어줘야 한다.

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

 

Memoization

메모이제이션(Memoization)은 알고리즘에서 자주 나오는 개념이다. 기존에 수행한 연산의 결과값을 메모리에 저장을 해두고, 동일한 입력이 들어오면 재활용하는 프로그래밍 기법을 말한다. 이 메모이제이션을 적절히 사용한다면 굳이 중복 연산을 할 필요가 없기 때문에 앱의 성능을 최적화할 수 있다.

useMemo는 바로 이 개념을 이용하여 복잡한 연산의 중복을 피하고 React 앱의 성능을 최적화시킨다. 직접 메모이제이션 개념을 이용하여 로직을 구현할 수도 있겠으나, useMemo Hook을 호출한다면 이런 로직을 직접 구현하는 것을 대신해주기 때문에 훨씬 간편하다.

 

실습

import React, { useState, useMemo } from "react";
import "./styles.css";
import { add } from "./add";

export default function App() {
  const [name, setName] = useState("");
  const [val1, setVal1] = useState(0);
  const [val2, setVal2] = useState(0);
  const answer = add(val1, val2);
  // const answer = useMemo(() => add(val1, val2), [val1, val2]);

  return (
    <div>
      <input
        className="name-input"
        placeholder="이름을 입력해주세요"
        value={name}
        type="text"
        onChange={(e) => setName(e.target.value)}
      />
      <input
        className="value-input"
        placeholder="숫자를 입력해주세요"
        value={val1}
        type="number"
        onChange={(e) => setVal1(Number(e.target.value))}
      />
      <input
        className="value-input"
        placeholder="숫자를 입력해주세요"
        value={val2}
        type="number"
        onChange={(e) => setVal2(Number(e.target.value))}
      />
      <div>{answer}</div>
    </div>
  );
}

 

위의 컴포넌트에서 실제로 연산 로직에 영향을 주는 값은 `val1`과 `val2`이다. 현재는 이름 상태가 변화하면 `add` 함수가 계속 같은 결괏값을 리턴함에도 불구하고 불필요하게 계속 호출되고 있다. 즉 이름만 변경해도 리렌더링되며 'add' 함수가 계속 호출된다.

※ useMemo 이용해서 최적화해주기

import React, { useState, useMemo } from "react";
import "./styles.css";
import { add } from "./add";

export default function App() {
  const [name, setName] = useState("");
  const [val1, setVal1] = useState(0);
  const [val2, setVal2] = useState(0);
  // const answer = add(val1, val2);
  const answer = useMemo(() => add(val1, val2), [val1, val2]);

  return (
    <div>
      <input
        className="name-input"
        placeholder="이름을 입력해주세요"
        value={name}
        type="text"
        onChange={(e) => setName(e.target.value)}
      />
      <input
        className="value-input"
        placeholder="숫자를 입력해주세요"
        value={val1}
        type="number"
        onChange={(e) => setVal1(Number(e.target.value))}
      />
      <input
        className="value-input"
        placeholder="숫자를 입력해주세요"
        value={val2}
        type="number"
        onChange={(e) => setVal2(Number(e.target.value))}
      />
      <div>{answer}</div>
    </div>
  );
}

useMemo 이용

useMemo`를 이용하여 `add `함수의 호출을 최소화한다. 즉 이름을 입력할 때는 `add` 함수가 호출되지 않아 최적화가 되었다고 볼 수 있다.

 

useCallback

useCallback 또한 useMemo와 마찬가지로 메모이제이션 기법을 이용한 Hook이다. useMemo값의 재사용을 위해 사용하는 Hook이라면, useCallback함수의 재사용을 위해 사용하는 Hook이다.

아래 코드를 보자.

function Calculator({x, y}){

	const add = () => x + y;

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

현재 이 Calculator 컴포넌트 내에는 add라는 함수가 선언이 되어 있는 상태이다. 이 add 함수는 props로 넘어온 x와 y 값을 더해 <div> 태그에 값을 출력하고 있다. 이 함수는 해당 컴포넌트가 렌더링 될 때마다 새롭게 만들어질 것이다.

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을 사용하기가 좋다.

 

useCallback과 참조 동등성

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

아래 코드를 보자.

function doubleFactory(){
    return (a) => 2 * a;
}
  
const double1 = doubleFactory();
const double2 = doubleFactory();
  
double1(8); // 16
double2(8); // 16
  
double1 === double2;  // false
double1 === double1;  // true

double1과 double2는 같은 함수를 할당했음에도 메모리 주소 값이 다르기 때문에 같다고 보지 않는다. JavaScript에서 함수는 객체이다. 따라서 두개의 함수는 동일한 코드를 공유하더라도 메모리 주소가 다르기 때문에, 메모리 주소에 의한 참조 비교 시 다른 함수로 본다.

이는 React 또한 같다. React는 리렌더링 시 함수를 새로이 만들어서 호출한다. 새로이 만들어 호출된 함수는 기존의 함수와 같은 함수가 아니다. 그러나 useCallback을 이용해 함수 자체를 저장해서 다시 사용하면 함수의 메모리 주소 값을 저장했다가 다시 사용한다는 것과 같다고 볼 수 있다. 따라서 React 컴포넌트 함수 내에서 다른 함수의 인자로 넘기거나 자식 컴포넌트의 prop으로 넘길 때 예상치 못한 성능 문제를 막을 수 있다.

 

실습

import { 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"
  };

  const getItems = () => {
    return [input + 10, input + 100];
  };

  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>
    </>
  );
}

input 창에 숫자를 입력하면 그러면 콘솔에 “아이템을 가져옵니다.” 가 출력되는 것이 보인다. 정상적인 동작이지만, 이번에는 옆의 button 'dark mode' button을 눌러도 “아이템을 가져옵니다.”가 콘솔에 출력되는 걸 볼 수 있다.

이 동작의 이유는 버튼을 누를 때도 앱이 리렌더링 되므로, App 내부의 getItems() 함수가 다시 만들어진다. 새로이 만들어진 함수는 이전의 함수와 참조 비교시 다른 함수이기 때문에 List.js 내에서 useEffect Hook은 setItems를 호출하고 종속성이 변경됨에 따라 “아이템을 가져옵니다.”를 출력하는 것이다.

※ useCallback 이용해서 최적화해주기 

import { useState, useCallback } 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"
  };

  // const getItems = () => {
  //   return [input + 10, input + 100];
  // };
  const getItems = useCallback(() => [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>
    </>
  );
}
 

getItems함수에 useCallback Hook을 사용하면 더 이상 버튼을 눌러도 콘솔이 뜨지 않는다. 

반응형