[React] 리액트 앱에서 렌더링 최적화하기

2022. 10. 30. 14:54Front-end/React

렌더링 (Rendering)

렌더링이란 화면에 특정한 요소를 그려내는 것을 의미한다. 사실 이 렌더링 과정을 잘 처리해주는 것이 우리가 Vanila JavaScript를 사용하지 않고 React 같은 UI 라이브러리 또는 프레임워크를 사용하는 이유다.

 

브라우저에서 렌더링이란 결국 DOM요소를 계산하고 그려내는 것을 의미한다. HTML과 CSS를 통해서 만들어지고 계산된 DOM과 CSSOM은 결합되고, 위치를 계산하고, 최종적으로 브라우저에 그려진다. 그리고 개발자는 브라우저에서 제공하는 DOM API를 JavaScript를 통해 호출하면서 브라우저에 그려진 화면을 변화시킨다.

 

하지만 Vanila JavaScript를 이용해서 DOM에 직접 접근하고 수정하는 것(명령형), 그리고 이를 최적화하는 것은 애플리케이션의 규모가 커지면 커질수록 관리하기 힘들어진다. 그래서 개발자들은 애플리케이션에서 보여주고 싶은 핵심 UI를 선언하기만 하면 실제로 DOM을 조작해서 UI를 그려내고, 변화시키는 일은 라이브러리나 프레임워크가 대신해주는 방식을 찾게 된다(선언적 개발).

 

이런 니즈에 맞춰서 React, Vue, Angular 등의 라이브러리, 프레임워크가 등장하게 되고 그중에서 React가 현재는 가장 많이 사용되고 있는 것이다. 실제로 React 공식문서를 보면 가장 첫 번째 장점으로 선언형을 내세우고 있다.

 

이처럼, React는 선언형으로 실제 렌더링 과정은 React에서 대신 처리해주고, 개발자는 UI를 설계하는데만 집중하게 해 준다. 하지만 때로는 React 내부에서 처리해주는 렌더링을 최적화해야 되는 상황이 발생한다. 이러한 상황에서는 React 내부에서 렌더링이 언제 발생하는지, 어떤 과정을 거쳐서 이루어지는지를 이해하고 있어야 각 과정에서 렌더링을 최적화할 수 있다.

 

리액트에서 리렌더링이 되는 시점

리액트에서 state를 사용하는 이유는 UI와 상태(state)를 연동시키기 위해서다. 근본적으로 UI는 어떠한 데이터가 있고 그것을 보기 편한 형태로 표현한 것이다. 리액트는 이를 이해하고 UI와 연동되어야 하고, 변할 여지가 있는 데이터들을 state라는 형태로 사용할 수 있게 해 준다. 그리고 데이터가 변경되었을 때 UI가 그에 맞춰서 변화하기 위해서 state를 변경시키는 방법을 제한시키고(setState), 이 함수가 호출될 때마다 리렌더링이 되도록 설계했다.

 

이런 이유로 인해서 리액트에서 리렌더링이 발생하는 시점은 state가 변했을 때로 특정 컴포넌트의 state가 변한다면, 해당 컴포넌트와 해당 컴포넌트의 하위에 있는 모든 컴포넌트들은 리렌더링이 발생한다. 즉, state가 변하면 해당 컴포넌트를 포함한 하위 컴포넌트들은 모두 리렌더링 된다는 명확한 멘탈 모델을 이해하고 있는 것이 리액트를 이용해서 애플리케이션을 설계하고, 최적화하는데 가장 기본이 되는 사항이다.

 

리액트의 렌더링 과정

리액트는 state가 변화했을 때 리렌더링이 발생한다. 이 과정을 좀 더 뜯어보면 state가 변화되고 최종적으로 브라우저 상의 UI에 반영되기까지 각 컴포넌트에서는 크게 아래의 4단계를 거친다.

  1. 기존 컴포넌트의 UI를 재사용할지 확인한다.
  2. 함수형 컴포넌트: 컴포넌트 함수를 호출한다 / 클래스형 컴포넌트: render 메서드를 호출한다.
  3. (2.)의 결과를 통해서 새로운 가상 DOM을 생성한다.
  4. 이전의 가상 DOM과 새로운 가상 DOM을 비교해서 실제 변경된 부분만 DOM에 적용한다.

먼저 4번의 과정을 왜 하는지, 근본적으로 가상 DOM을 왜 사용하는지에 대해 알아보자. 브라우저는 근본적으로 화면을 보여주기 위해서 HTML, CSS, JavaScript를 다운로드하고 그를 처리해서 화면에 픽셀 형태로 그려낸다. 그리고 이 과정을 CRP(Critical Rendering Path, 중요 렌더링 경로)라고 부른다.

 

중요 렌더링 경로, CRP는 기본적으로 아래의 과정을 수행합니다.

  1. HTML을 파싱해서 DOM을 만든다.
  2. CSS를 파싱해서 CSSOM을 만든다.
  3. DOM과 CSSOM을 결합해서 Render Tree를 만든다.
  4. Render Tree와 Viewport의 witdh를 통해서 각 요소들의 위치와 크기를 계산한다. (Layout)
  5. 지금까지 계산된 정보를 이용해 Render Tree상의 요소들을 실제 Pixel로 그려낸다. (Paint)

이후 DOM 또는 CSSOM이 수정될 때마다 위의 과정을 반복한다. 따라서 이 과정을 최적화하는 것이 퍼포먼스상에 중요한 포인트다. 그런데 위 과정 중에서 Layout, Paint 과정은 특히나 많은 계산을 필요로 하는 부분이며, 리액트는 이 CRP가 수행되는 횟수를 최적화하기 위해서 가상 DOM을 사용하는 것이다.

 

UI를 변화하기 위해서는 많은 DOM 조작이 필요하다. 하나하나의 DOM조작마다 CRP가 수행될 것이고 이는 곧 브라우저에게 많은 연산을 요구하게 되고, 퍼포먼스를 저하시키는 요인이 될 수 있다. 리액트는 이를 해결하고자 가상 DOM이란 개념을 도입한 것이다.

 

리액트에서는 UI의 변화가 발생하면 변화에 필요한 DOM조작들을 매번 바로 실제 DOM에 적용하는 것이 아니라, 가상 DOM이란 리액트가 관리하고 있는 DOM과 유사한 객체 형태로 만들어낸다. 그리고 이전의 가상 DOM과 새로운 가상 DOM을 비교해서 실제로 변화가 필요한 DOM 요소들을 찾아내서 한 번에 해당 DOM 요소들을 조작한다.

 

이런 방식을 통해서 브라우저에서 수행되는 CRP의 빈도를 줄일 수 있고, 이게 가상 DOM을 이용해서 리액트가 수행하는 최적화다. 즉, (4.) 이전의 가상 DOM과 새로운 가상 DOM을 비교해서 실제 변경된 부분만 DOM에 적용하는 것이며, 이 과정은 리액트 내부적으로 수행하는 부분으로 리액트를 사용하는 개발자 입장에서는 따로 최적화해줄 것이 없다.

 

리액트를 사용하는 개발자가 할 수 있는 최적화로는 리렌더링 될 컴포넌트의 UI가 이전의 UI와 동일하다고 판단되는 경우 새롭게 컴포넌트 함수를 호출하지 않고 기존 컴포넌트의 UI를 재사용하는 방법과 컴포넌트 함수가 호출되면서 만들어질 가상 DOM의 형태를 비교적 차이가 적은 형태로 만드는 방법이 있다.

 

이 중에서 컴포넌트의 UI를 재사용하는 방법을 집중적으로 알아보자.

 

React.memo

앞서 말했듯이 리액트는 state가 변할 경우 해당 컴포넌트와 하위의 컴포넌트들을 모두 리렌더링 한다. 그런데 state가 변한 컴포넌트의 경우 당연히 UI의 변화가 있으니 리렌더링을 해야 하지만, 하위 컴포넌트의 경우에는 경우에는 props가 변화하지 않았다면 해당 컴포넌트 UI가 변하지 않았을 수도 있다. 이런 경우에는 굳이 새롭게 컴포넌트 함수를 호출할 필요 없이 이전에 저장되어 있던 결과를 그대로 사용하는 것이 효율적이다.

 

하지만 UI가 실질적으로 변화되었는지 매번 리액트가 렌더링 과정에서 일일이 모든 컴포넌트 트리를 순회하면서 검사하는 것은 비효율적이다. 따라서 리액트에서는 개발자에게 이 컴포넌트가 리렌더링이 필요한지 여부를 표현할 수 있는 React.memo 함수를 제공하고 이를 통해 기존의 컴포넌트의 UI를 재사용할지 판단하는 방법을 채택했다.

const Component = React.memo(function Component(props) {
  /* render using props */
});

React.memo는 HOC(Higher Order Component)이다.

HOC란 컴포넌트를 인자로 받아서, 컴포넌트를 리턴하는 컴포넌트다.

function HOC(Component) {
  /* do something */
	return <Component />
}

React.memo로 감싸진 컴포넌트의 경우에는 상위 컴포넌트가 리렌더링 될 경우 무조건 리렌더링 되는 것이 아니라 컴포넌트의 이전의 props와 다음 렌더링 때 사용될 props를 비교해서 차이가 있을 경우에만 리렌더링을 수행한다. 만약 차이가 없다면 리렌더링을 수행하지 않고 기존의 렌더링 결과를 재사용한다. 이를 통해 컴포넌트에서 불필요하게 리렌더링이 되는 경우를 막을 수 있다.

 

이때 중요하게 생각해야 할 것은 props를 비교하는 방식이다. React.memo는 기본적으로 props의 변화를 이전 props와 새로운 props를 얕은 비교(shallow compare)해서 판단한다. 만약 이 기본적인 비교 로직을 사용하지 않고, 직접 로직을 작성하고 싶을 경우를 대비해서 React.memo는 변화를 판단하는 함수를 인자로 받을 수 있도록 설정해뒀다.

function Component(props) {
  /* render using props */
}

function areEqual(prevProps, nextProps) {
  /*
  true를 return할 경우 이전 결과를 재사용
  false를 return할 경우 리렌더링을 수행
  */
}

export default React.memo(Component, areEqual);

React.memo의 두 번째 인자로 함수를 전달할 경우 해당 함수의 인자로는 이전의 props와 새로운 props가 순서대로 인자로 전달되며, 이 함수의 return 값이 true일 경우 이전 결과를 재사용하고, false를 return 할 경우 리렌더링을 수행한다.

 

자바스크립트 데이터 타입

React.memo는 기본적으로 props 객체를 비교하는 방식을 통해서 동작한다. 이를 잘 인식하고 활용하려면 자바스크립트의 데이터 타입에서 기본형 타입과 참조형 타입의 차이에 대해서 명확히 알고 있어야 한다. 자바스크립트의 데이터 타입은 string, number, boolean, null, undefined, object 등 다양하게 있다. 그리고 이를 크게 두 가지로 나누면 기본형 타입과 참조형 타입으로 구분할 수 있다.

 

기본형 타입은 원시형 타입이라는 용어로도 표현한다. 단어 자체에서도 알 수 있듯, 자바스크립트에서 지원하는 원시적이고 기본적인 형태의 데이터 타입이며 다른 데이터 없이 해당 데이터 스스로 온전히 존재할 수 있는 형태다. 원시형 타입의 예시에는 string, number, boolean, null, undefined, bigint, symbol 타입이 있다.

 

참조형 타입은 달리 말해 객체형 타입이라고도 불린다. 즉 원시형 타입을 제외한 Object 가 참조형 타입이라고 할 수 있다. 자바스크립트에서는 Array도 Function도 Object의 한 종류다. 참조형 타입의 가장 큰 특징은 다른 데이터들을 모아서 만들어진 타입이라는 것이다.

const kwakhyun = { name: “kwakhyun”, gender: ”male” }

위 객체를 보면 이 객체는 “kwakhyun”, “male” 이란 두 가지 string 타입을 모아서 만들어진 것을 확인할 수 있다. 참조형 타입과 기본형 타입을 생각할 때 고려해야 할 가장 큰 특징은 불변성이다.

 

불변성

불변성이란 값이 변하지 않는 것을 의미한다. 기본적으로 원시형 타입은 모두 불변하다.

let name = "kwak";

name = "hyun"

위 코드에서는 name에 할당된 “kwak”이란 string을 “hyun”이란 string으로 변경하는 방식으로 이루어지는 것이 아니라 “hyun”이라는 새로운 string을 만들고, name에 할당된 값 자체를 교체하는 식으로 동작한다. 이처럼 자바스크립트에서는 이미 만들어진 원시형 타입 값을 변경할 수 없다. 하지만 참조형 타입의 값은 가변 하다.

const user = { name: "kwakhyun", gender: "male" };

user.name = "tomato";

객체(참조형 타입)는 여러 타입들을 모아서 만들어진 형태다. 따라서 객체 안의 내용물들은 언제든지 어떤 형태로든 변경할 수 있다. 이를 객체가 가변 하다고 한다. 가변성은 메모리를 절약하면서 객체를 유연하게 사용할 수 있게 해 주지만, 때때로 결과를 예상하기 힘들다는 단점과 객체 간의 비교가 어렵다는 단점을 가지고 있다.

 

자바스크립트는 기본적으로 비교 연산자를 수행할 때 해당 데이터의 메모리 주소를 통해서 일치 여부를 판단한다. 원시형 타입의 경우에는 변경할 시 새로운 데이터가 만들어지고 교체되는 방식이기에 메모리 주소가 달라져서 비교 연산자를 활용하기 용이하다. 하지만 객체의 경우에는 안의 내용물이 어떻게 바뀌었는지에 상관없이 해당 객체를 가리키는 메모리 주소는 동일하기에 실질적으로 내용이 변했는지를 판단하기는 어렵다. 또한 내용물이 완벽히 일치하는 두 객체를 비교하더라도 각 객체를 가리키는 메모리 주소가 다르기에 두 객체는 동일하지 않다는 결과가 나오게 된다.

"dog" === "cat" // false

const prev = { name: "prev" };
const next = prev;
next.name = "next";

prev === next // true

const one = { content: "hi" };
const two = { content: "hi" };

one === two // false

이런 동작으로 인해서 실제 객체의 내용물이 같은지 판단하기 위해서는 두 객체 안의 모든 property를 순회하면서 일일이 비교를 해주어야 한다. 만약 property 중에 객체가 존재한다면 또다시 해당 객체를 순회해야 하기에 이 연산을 수행하기 위한 복잡도는 기하급수적으로 늘어난다.

 

객체를 가변 하게 사용하면 이처럼 객체 간의 비교를 하기 힘들어진다. 하지만 한번 선언하고 메모리에 저장해 둔 객체를 변경하면서 활용할 수 있기 때문에 메모리 용량 측면에서는 효율적이다. 하지만 최근에는 과거에 비해 객체를 선언하고 저장하는 데 사용할 수 있는 메모리 용량이 늘어났기에 메모리의 효율을 추구하기보다는 객체 비교의 편리함을 취하기 위해 객체의 불변성을 지키는 방식이 최근에는 많이 사용되고 있다.

const prev = { title: "hello", content: "world" };
const next = { ...prev, title: "hi" };

prev === next // false

객체의 불변성을 지킨다는 것은 말 그대로 한번 만들어진 객체를 수정하지 않는다는 뜻이다. 따라서 객체의 내용이 변해야 할 경우에는 원시형 타입과 마찬가지로 기존의 객체를 수정하지 않고, 위 코드와 같이 새로운 객체를 만든 후 교체하는 방식을 사용한다.

 

memo의 잘못된 활용

앞서 React.memo는 기본적으로 props의 변화를 이전 props와 새로운 props를 얕은 비교(shallow compare)를 해서 판단한다고 했는데, props를 얕은 비교를 한다는 의미는 다음과 같다.

  • props는 객체 형태로 표현된다.
  • props 객체는 매 렌더링마다 새롭게 생성된다.
  • 따라서 props 객체 자체를 비교하는 것은 의미가 없다.
  • 비교해야 하는 것은 props 객체 안의 각 property들이다.
  • 따라서 리액트는 props 객체 안의 각 property들을 === 연산자를 통해서 비교한다.
  • 이 중 하나라도 false가 나올 경우 props가 변경되었다고 판단하고 리렌더링을 수행한다.
<Component title="hello" content="world" />
<Component title="hi" content="world" />

const areEqual = (prevProps, nextProps) => {
	if(prevProps.title !== nextProps.title) return false;
	if(prevProps.content !== nextProps.content) return false;
	return true;
}

이러한 동작과 데이터 타입에 대해서 제대로 이해하지 않으면 memo를 잘못 활용하는 상황이 발생한다.

 

Memoization

메모이제이션은 특정한 값을 저장해 두고 해당 값이 필요할 때 새롭게 계산해서 사용하지 않고 저장해둔 값을 활용하는 테크닉을 의미한다.

함수 컴포넌트는 근본적으로 함수이며, 리액트는 매 렌더링마다 함수 컴포넌트를 다시 호출한다. 함수는 기본적으로 이전 호출과 새로운 호출 간에 값을 공유할 수 없다. 만약 특정한 함수 호출 내에서 만들어진 변수를 다음 함수 호출에도 사용하고 싶다면 그 값을 함수 외부의 특정한 공간에 저장해뒀다가 다음 호출 때 명시적으로 다시 꺼내와야 한다.

 

이것을 직접 구현하는 것은 꽤나 번거로운 일이고, 특히 함수 컴포넌트에서 이를 구현하고 관리하는 것은 많은 노력이 드는 행위다. 리액트에서는 함수 컴포넌트에서 값을 메모이제이션할 수 있도록 API를 제공해주고 있다.

 

useMemo

useMemo는 리액트 Hooks API 중 하나로 값을 메모이제이션할 수 있도록 해주는 함수다.

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

useMemo는 두 가지 인자를 받는다. 첫 번째 인자는 콜백 함수이며 이 함수에서 리턴하는 값이 메모(저장)된다. 두 번째 인자는 의존성 배열이다. 메모이제이션을 사용할 때 주의해야 할 점은 새로운 값을 만들어서 사용해야 하는 상황임에도 불구하고 이전의 결과를 그대로 활용해버리면 버그가 발생할 수 있다는 점이다.

 

위 코드에서 a, b라는 두 가지 변수를 이용해 메모이제이션하기 위한 값을 계산하고 있다. 그런데 만약 a, b라는 값이 변경되었는데 이전의 값을 그대로 활용하면 의도한 결과와 다른 결과가 나오게 될 것이다. 이런 상황을 방지하기 위해서 useMemo에서는 의존성 배열을 인자로 받아, 의존성 배열에 있는 값 중 하나라도 이전 렌더링과 비교했을 때 변경되었다면 메모된 값을 활용하지 않고 새로운 값을 다시 계산한다.

 

useCallback

useCallback은 useMemo를 조금 더 편리하게 사용할 수 있도록 만든 리액트 Hooks API다. 일반적인 값들은 useMemo를 사용해 메모하기 편리하지만, 함수의 경우에는 useMemo를 사용해서 메모하면 콜백 함수에서 또 다른 함수를 반환하는 형태가 된다. 이는 동작상에서는 이상이 없지만 코드 스타일에 따라 문법적으로 다소 보기가 불편해지는 단점이 있다. 그래서 리액트에서는 이러한 동작을 간소화한 useCallback이란 함수를 만들어서 제공해주고 있다.

const memorizedFunction = useMemo(() => () => console.log("Hello World"), []);

const memorizedFunction = useCallback(() => console.log("Hello World"), []);

 

Memoization 사용하기

메모이제이션의 개념만 보았을 때는 굉장히 효율적이고 사용하기만 하면 최적화가 이루어질 것 같은 느낌이 들기도 한다. 하지만 명확한 목적 없이 무작정 메모이제이션을 사용하는 것은 오히려 비효율적이다.

 

리액트에서 제공하는 메모이제이션 함수를 호출해서 특정한 값을 저장해둘 수 있기에 얼핏 쓰기만 하면 효율이 올라갈 것 같지만 메모이제이션을 하기 전에 다음 사항을 생각해봐야 한다.

새로운 값을 만드는 것과 어딘가에 이전의 값을 저장해 두고 메모이제이션 함수를 호출해 의존성을 비교해서 저장한 값의 사용 여부를 판단하는 것 중 어떤 것이 비용이 더 적게 들까?

위 문장에 대한 답은 상황에 따라 다르다. 만약 새로운 값을 만드는 과정이 복잡하다면 메모이제이션을 사용하는 것이 더 효율적일 수 있고, 새로운 값을 만드는 과정이 복잡하지 않다면 메모이제이션을 사용하는 것은 오히려 비용이 더 많이 들 수도 있다. 그리고 컴퓨터 자원의 측면뿐만 아니라 메모이제이션을 쓰면서 코드의 복잡도가 올라간다는 개발적인 측면의 비용도 무시할 수 없다.

 

이처럼 메모이제이션은 무조건 사용하는 것이 좋은 게 아니라, 필요성을 분석하고 필요하다고 판단되는 순간에만 사용해야 한다. 리액트에서 메모이제이션이 필요하다고 판단할 수 있는 요인은 새로운 값을 만드는 연산이 복잡하거나, 함수 컴포넌트의 이전 호출과 다음 호출에서 사용하는 값의 동일성을 보장하고 싶을 때라고 볼 수 있다.

 

만약 아주 많은 요소를 가진 배열이 있다고 생각하면 이 배열을 매번 생성하는 것보다는 메모해서 활용하는 것이 효율적일 것이다. 그리고 함수 컴포넌트의 호출 간 값들의 동일성을 보장하는 이유는 React.memo 와 연동해서 사용하기 위해서이다.

 

앞서 memo의 잘못된 활용 예시에서 props로 전달되는 객체의 동일성이 보장되지 않아 실제 객체의 내용은 똑같아도 얕은 비교(shallow compare)를 통해 다른 객체라고 판단되어 매번 리렌더링이 실행되는 상황을 확인했다. 이런 상황에서 전달되는 객체의 동일성을 보장하기 위해서 메모이제이션을 활용할 수 있다.

 

메모이제이션 된 객체는 새롭게 만들어진 것이 아니라 이전의 객체를 그대로 활용하는 것이기에 얕은 비교에서 동일성을 보장받을 수 있다.


참고하기 좋은 자료

 

중요 렌더링 경로 - Web Performance | MDN

중요 렌더링 경로 (Critical Rendering Path)는 브라우저가 HTML, CSS, Javascript를 화면에 픽셀로 변화하는 일련의 단계를 말하며 이를 최적화하는 것은 렌더링 성능을 향상시킵니다. 중요 렌더링 경로는 Do

developer.mozilla.org

 

메모이제이션 - 위키백과, 우리 모두의 백과사전

위키백과, 우리 모두의 백과사전. 메모이제이션(memoization)은 컴퓨터 프로그램이 동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여

ko.wikipedia.org

 

useMemo

 

Hooks API Reference – React

A JavaScript library for building user interfaces

ko.reactjs.org

useCallback

 

Hooks API Reference – React

A JavaScript library for building user interfaces

ko.reactjs.org

 

 

반응형