React의 컴포넌트는 props라고 하는 입력을 받아 화면을 나타내는 JSX 엘리먼트를 반환합니다. 이러한 컴포넌트의 조합을 통해 우리가 보는 페이지를 구성하고, 하나의 완성된 결과물을 출력할 수 있게 됩니다.
리액트는 상황에 따라 다양한 화면을 보여주기 위해 지속적으로 컴포넌트를 갱신하는데, 이를 리렌더링이라고 합니다. 리렌더링 덕분에 컴포넌트는 정적인 반환에서 벗어나 많은 역할을 할 수 있었습니다.
컴포넌트가 사용자가 원하는 화면을 반영하려면 언제 리렌더링이 일어나야 할까요? 가장 확실한 방법으로는, 매 밀리초마다 컴포넌트를 렌더링 하면 정보의 누락없이 항상 최신 상태의 컴포넌트를 보여줄 수 있습니다. 하지만 그 방법은 너무 많은 리렌더링이 일어나고, 많은 리렌더링은 나쁜 사용자 경험으로 이어지기 때문에 현실적으로는 어렵겠지요. 따라서 필요없는 리렌더링이 최대한 일어나지 않도록 막아주어야 했고, 리액트는 정보의 변경이 일어났을 때를 기준으로 컴포넌트를 업데이트 하도록 설계되었습니다.
컴포넌트의 리렌더링 조건
컴포넌트의 리렌더링은 기본적으로 정보의 변경과 함께 일어납니다. 실제로 컴포넌트를 리렌더링 시키는 아래의 조건들은 갱신이 필요한 대부분의 경우를 포함합니다.
- 컴포넌트 내부의 state가 변경될 때
- 컴포넌트가 입력받는 props가 변경될 때
- 컴포넌트의 상위 컴포넌트가 리렌더링 될 때
- 개발자가 강제로 리렌더링을 시킬 때
이번 포스트에서는 컴포넌트가 입력받는 props가 변경될 때의 경우를 생각해보겠습니다. 컴포넌트가 props를 입력받는다는 것은 이 props를 이용하여 어떠한 동작을 하거나, 혹은 그대로 출력해준다는 의미입니다. 따라서 입력받는 props가 변경되면 그 컴포넌트의 반환도 바뀔 확률이 매우 높습니다. 바로 이런 경우에 리렌더링이 필요해집니다.
하지만, 뒤집어서 생각해보면 props가 변경되지 않을때는 리렌더링이 필요하지 않은 경우도 존재한다는 뜻이 됩니다. 이런 경우에는 불필요한 리렌더링을 제한해서 더 좋은 사용자 경험을 제공할 수 있습니다.
React.Memo
React.Memo를 사용하면 이런 경우에 발생하는 불필요한 리렌더링을 해결할 수 있습니다. 컴포넌트의 리렌더링 조건에 따라 상위 컴포넌트가 리렌더링 되는 경우엔 하위 컴포넌트도 리렌더링 되는 것이 일반적입니다. 이를 React.Memo를 사용하여 props가 변경되지 않는 경우에 한 해 리렌더링을 막아보겠습니다.
ParentComponent.jsx
import { useState } from "react";
import ChildrenComponent from "./ChildrenComponent";
function ParentComponent() {
const [parentState, setParentState] = useState(false);
console.log("부모 컴포넌트 렌더링 발생", parentState);
return (
<>
<h2>저는 부모 컴포넌트입니다.</h2>
<ChildrenComponent />
<button
onClick={() => {
console.log("부모 상태 변경 클릭");
setParentState(!parentState);
}}
>
부모 상태 변경
</button>
</>
);
}
export default ParentComponent;
ChildrenComponent.jsx
import React from "react";
import { useState } from "react";
function ChildrenComponent() {
const [childState, setChildState] = useState(false);
console.log("자식 컴포넌트 렌더링 발생", childState);
return (
<>
<h3>저는 자식 컴포넌트입니다.</h3>
<button
onClick={() => {
console.log("자식 상태 변경 클릭");
setChildState(!childState);
}}
>
자식 상태 변경
</button>
</>
);
}
export default ChildrenComponent;
현재 React.Memo가 적용되지 않은 코드입니다. 이 코드는 부모 컴포넌트의 리렌더링이 일어나면 자식 컴포넌트의 리렌더링도 반드시 발생합니다.
여기서, ChildrenComponent를 React.Memo를 통해 감싸주겠습니다.
export default React.memo(ChildrenComponent);
이번에는 부모 컴포넌트가 리렌더링 되더라도 자식 컴포넌트는 리렌더링 되지 않을 것입니다.
그런데 여기엔 한 가지 문제가 있습니다. props가 같으면 자식 컴포넌트를 리렌더링 하지 않아야 하는데, 실제로는 꼭 그렇지가 않습니다. 아래 경우를 보겠습니다.
function ParentComponent() {
const [parentState, setParentState] = useState(false);
const notChangeObject1 = {
bread: "bread"
};
const notChangeObject2 = {
bread: "bread"
};
console.log("부모 컴포넌트 렌더링 발생", parentState);
return (
<>
<h2>저는 부모 컴포넌트입니다.</h2>
<ChildrenComponent
value={parentState ? notChangeObject1 : notChangeObject2}
/>
<button
onClick={() => {
console.log("부모 상태 변경 클릭");
setParentState(!parentState);
}}
>
부모 상태 변경
</button>
</>
);
}
ParentComponent를 약간 수정하여 ChildrenComponent에 props로 값이 같은 notChangeObject들을 전달하도록 하였습니다. 그리고 실행 결과를 확인해보면,
React.Memo는 props가 같다면 리렌더링을 일어나지 않게 한다고 알고 있습니다. 그런데 어째서 이런 결과가 나오게 된걸까요?
그 이유는 React.Memo가 Props를 비교하는 방식에 있습니다. React.Memo는 Props를 비교할때 실제 데이터가 같은지 확인하는게 아닌, 참조를 통해서 같은 값인지를 판단합니다. 즉, 얕은 비교를 통해서 동일성을 판단합니다.
따라서 우리가 보기에는 같은 값이지만, 리액트가 보기엔 참조값이 다르므로 같은 Props가 아니다 라고 판단하여 컴포넌트를 리렌더링 시키는 결과를 불러옵니다.
이러한 현상을 방지하기 위해, React.Memo는 개발자가 Props를 직접 비교할 수 있도록 두 번째 인자에 비교 함수를 넣을 수 있도록 설계되었습니다.
직접 객체 내부 값을 비교할 수 있는 함수를 만들어서 인자에 넣어보겠습니다.
function customCompare(prevProps, nextProps) {
const prev = JSON.stringify(prevProps);
const next = JSON.stringify(nextProps);
return prev === next;
}
...
export default React.memo(ChildrenComponent, customCompare);
이제 부모 상태를 변경해보면,
그림으로 나타내면 이렇게 중간 과정이 하나 추가됩니다.
React.Memo를 이용하면 이렇게 렌더링 횟수를 최적화 시킬 수 있습니다.
Props가 함수일 때
Props가 함수이면 같은 내용이더라도 일반적인 방법으로는 비교할 수가 없습니다. 실제로 위의 코드에서 콜백 함수를 넘겨주게 되면 정상적으로 작동하지 않습니다.
const notChangeObject1 = {
bread: () => "bread"
};
const notChangeObject2 = {
bread: () => "bread"
};
결과를 보니 리렌더링이 일어나지 않았지만, 사실 그래서 문제가 됩니다.
실제로 넘겨주는 함수가 달라지더라도 리렌더링이 일어나지 않기 때문입니다.
const notChangeObject1 = {
bread: () => "bread1"
};
const notChangeObject2 = {
bread: () => "bread2"
};
비교 함수에서 콘솔 로그를 찍어 확인해보겠습니다.
이건 JSON.stringify()의 스펙 때문에 일어나는 현상입니다. 이 메서드는 비교 대상으로 함수나 심볼이 들어 올 경우, 그대로 삭제해버립니다.
이처럼 내용이 같으나 함수가 다른 경우엔 자바스크립트의 특성 상 기존의 비교 함수로는 어쩔 수 없습니다. 하지만, 적어도 같은 함수가 다른 인스턴스로 생성되어 props로 전달되는 것을 막을 방법은 있습니다. 바로 useCallback() 입니다.
const notChangeFunction = useCallback(() => "bread", []);
useCallback을 사용하면 의존하는 값이 변하지 않는 이상 같은 인스턴스를 보장하기 때문에, 적어도 같은 함수인데 참조가 달라 다른 함수로 판단하게 될 일은 없겠네요.
마무리
React.Memo를 사용하면 메모이제이션을 통해 props 미변경시의 불필요한 리렌더링을 막을 수 있습니다. 하지만 React.Memo에 너무 의존하기보다, 자연스럽게 불필요한 리렌더링이 일어나지 않도록 컴포넌트 구조를 설계하는게 선행되어야 좋은 결과로 이어질 것 같습니다. 좋은 구조를 찾아가는 과정은 언제나 쉽지 않은 것 같네요.
참고 문서
React 최상위 API #React.memo - React docs
React.memo() 현명하게 사용하기 by Dmitri Pavlutin (원문) / Toast_UI (번역)
'이론 > Frontend' 카테고리의 다른 글
자바스크립트의 index.js (4) | 2022.08.23 |
---|---|
JSX 알아보기 (0) | 2022.07.24 |
리액트의 에러 경계 (React Error Boundaries) (1) | 2022.06.05 |
리액트의 추가 훅 (React Additional Hooks) (0) | 2022.05.17 |
리액트의 기본 훅 (React Basic Hooks) (2) | 2022.05.13 |
댓글