React.memo 없이 성능개선하기

며칠 전 리액트의 렌더링 성능 최적화를 위한 diff 알고리즘과 재조정 알고리즘에 대해 공부했었다.

공부한 내용을 바탕으로 TOAST UI에 작성된 리액트 렌더러를 최적화하는 간단한 트릭을 읽고 정리해보았다.

리액트는 diff 알고리즘을 통해 state나 props가 변화할 때 마다 새로운 엘리먼트 트리를 만든다.

이 말은 즉 마지막 렌더링 떄와 동일한 리액트 엘리먼트를 넘긴다면 리액트는 그 엘리먼트를 리랜더링하지 않는다는 의미이다.

예제 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// codesandbox: https://codesandbox.io/s/react-codesandbox-g9mt5

import React from "react";
import ReactDOM from "react-dom";

function Logger(props) {
console.log(`${props.label} rendered`);
return null; // 여기서 반환되는 값은 부적절하다...
}

function Counter() {
const [count, setCount] = React.useState(0);
const increment = () => setCount((c) => c + 1);
return (
<div>
<button onClick={increment}>The count is {count}</button>
<Logger label="counter" />
</div>
);
}

ReactDOM.render(<Counter />, document.getElementById("root"));

위 예제 코드에선 첫 렌더링에 counter rendered가 콘솔에 기록되고,
Counter의 count 값이 변화할 때 마다 counter rendered가 콘솔에 기록될 것이다.
여기서 <Logger label="counter"/> 엘리먼트는 렌더링할 때 변하지 않는 정적인 엘리먼트이다.

UI 기술 객체

counter 함수를 호출시 다음과 같은 ui 기술 객체를 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 몇 가지를 제거한 예제
const counterElement = {
type: "div",
props: {
children: [
{
type: "button",
props: {
onClick: increment, // 클릭 이벤트 핸들러 함수
children: "The count is 0",
},
},
{
type: Logger, // Logger 컴포넌트 함수
props: {
label: "counter",
},
},
],
},
};

이중 버튼을 눌러 count를 변경할 시 바뀌는 값은 button 엘리먼트의 props의 이벤트 핸들러 함수와 children이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const counterElement = {
type: "div", // 불변
props: {
children: [
{
type: "button", // 불변
props: {
onClick: increment, // 변경
children: "The count is 1", // 변경
},
},
{
type: Logger, // 불변
props: {
label: "counter", // 불변
},
},
],
},
};

하지만 리액트는 재조정 알고리즘에 따라 전체를 새로운 것으로 변경한다.(props를 갱신한다.)

모든 엘리먼트들의 type은 동일하고 Logger 엘리먼트의 label 속성도 변하지 않는다.
하지만 객체의 속성이 이전의 props 객체와 같더라도 props 객체 자체는 매 번 렌더링 할 떄마다 변경된다.

이렇게 Logger의 props 객체는 변경되기 떄문에 리액트는 새로운 props 객체에 기반한 JSX 객체를 얻기 위해 Logger 함수를 다시 실행한다.

다음과 같이 이런 점을 반영하여 리액트에 jsx 엘리먼트를 만들어 전달해주면 이러한 과정을 생략할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// codesandbox: https://codesandbox.io/s/react-codesandbox-o9e9f

import React from "react";
import ReactDOM from "react-dom";

function Logger(props) {
console.log(`${props.label} rendered`);
return null; // 여기서 반환되는 값은 부적절하다...
}

function Counter(props) {
const [count, setCount] = React.useState(0);
const increment = () => setCount((c) => c + 1);
return (
<div>
<button onClick={increment}>The count is {count}</button>
{props.logger}
</div>
);
}

ReactDOM.render(
<Counter logger={<Logger label="counter" />} />,
document.getElementById("root")
);

위 코드의 ui 기술 객체는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const counterElement = {
type: "div", // 불변
props: {
children: [
{
type: "button", // 불변
props: {
onClick: increment,
children: "The count is 1",
},
},
// Logger element 자체가 불변한다.
{
type: Logger,
props: {
label: "counter",
},
},
],
},
};

Logger 엘리먼트 자체가 변경되지 않았기 떄문에 리액트는 이러한 최적화를 제공할 수 있다.
떄문에 다시 렌더링 할 필요 없는 Logger 엘리먼트를 렌더링 하지 않는다.

때문에 위 코드는 처음 렌더링시에만 counter rendered를 콘솔에 기록한다.

이 방법은 리액트가 props객체 전체만 체크한다는 것을 제이하고 기본적으로 React.memo 동작 방식과 같다.

정리

게시글에서 제안하는 성능을 개선하기 위한 실용적인 방법은 다음과 같다.

  1. 렌더링 비용이 비싼 컴포넌트는 덜 렌더링 되는 상위요소로 끌어 올린다.

  2. 그리고 해당 컴포넌트를 props로 내려준다.

느낀점

신기한 접근인 것 같다.
마찬가지로 자바스크립트의 메모리 사용 방식으로 인해 가능한 방식인 것 같다.

댓글