목표
리엑트의 성능을 개선하기 위해서 리렌더링 최적화가 무엇인지에 대해 이해하고 방법을 알아본다.
1. 리렌더링의 조건
2. memo의 기능과 작동 조건
3. useMemo, useCallback
4. 모든 컴포넌트에 memo를 사용하면 될까?
5. children props
6. 참고. 리엑트 앱의 성능 분석 방법: react dev tool
1. 리렌더링의 조건
컴포넌트에서 리렌더링이 발생하는 조건은 크게 두 가지다.
- 부모 컴포넌트에서 리렌더링이 발생할 때
- 컴포넌트의 state가 업데이트될 때(단, 동일한 원시값이나 동일한 참조의 객체값으로 변경되면 업데이트 되지 않음)
두 번째 경우에는 state와 연결된 UI가 변해야 하니 당연히 리렌더링이 필요할 수 밖에 없다. 하지만, 첫번째 경우는 다르다. 부모 컴포넌트에서 일어난 변화로 인해, 아무 변화도 없는 자식 컴포넌트까지 리렌더링이 일어난다면 그저 연산 비용만 소모하는 것이기 때문이다. 더군다나, 무거운 연산을 하고 있는 자식 컴포넌트라면 불필요한 리렌더링이 성능에 크게 악영향을 끼친다.
그래서 필요한 것이 리엑트의 리렌더링 최적화다.
참고. 자식 컴포넌트란 구체적으로 무슨 의미인가 (합성 컴포넌트와 children 컴포넌트의 리렌더링)
아래 코드는 <App/> 내부에 합성 컴포넌트 <ParentComposite/>가 있고, children props로 <Child/> 컴포넌트를 넘겨준다.
만약, ParentComposite의 state가 변경되어 ParentComposite가 리렌더링된다면, Child는 리렌더링될까.
function App() {
return (
<div className="App">
<ParentComposite>
<Child />
</ParentComposite>
</div>
);
}
export default App;
function ParentComposite({ children }) {
console.log("리렌더링: parent");
const [count, setCount] = useState(0);
return (
<div>
ParentComposite
<Count count={count} setCount={setCount} />
{children}
</div>
);
}
function Count({ count, setCount }) {
return (
<div>
{count}
<button onClick={() => setCount((prev) => prev + 1)}>click</button>
</div>
);
}
function Child() {
console.log("리렌더링: child");
return <div>Child</div>;
}
정답은 'ParentComposite이 state 변경으로 리렌더링되어도 Child는 리렌더링되지 않는다'
왜냐하면 Child를 리엑트 엘리먼트(react element)로 생성한 주체는 ParentComposite가 아니라, App이기 때문이다.
만약 이 문제를 틀렸다면, 아마 당신의 판단은, Child가 ParentComposite의 내부에 children으로 주입되어있으므로 자식 컴포넌트이고 자식 컴포넌트는 부모 컴포넌트가 리렌더링되면 따라서 리렌더링된다고 생각했을 것이다.
이런 방식으로 작동하지 않은 이유는 자식 컴포넌트의 정의를 살펴봐야 한다.
- JSX는 React.createElement 메소드의 sugar syntax 이다.
- createElement는 실제 dom 객체의 일부를 모방한 객체이며, 실제 돔이 아니다.
리엑트는 createElement으로 생성된 객체를 모아 v-dom을 형성, v-dom은 실제 dom에 반영되는 식으로 컴포넌트를 생성한다. - 컴포넌트 관리를 위해서 state가 변경되면, 해당 state를 선언한 컴포넌트a를 기준으로 a가 createElement 호출한 모든 리엑트 엘리먼트를 재평가한다. 이 과정은 하위 리엑트 엘리먼트까지 재귀적으로 이어진다.
- 즉, "부모 컴포넌트의 리렌더링은 자식 컴포넌트의 리렌더링을 일으킨다"는 명제는 "컴포넌트 a가 리렌더링되면 a가 생성한 리엑트 엘리먼트들이 재귀적으로 리렌더링된다"는 명제와 동일한 의미다.
- 다시 원래의 문제로 돌아오자.
- Child를 리엑트 엘리먼트로 호출한 컴포넌트는 App이다.
- ParentComposite는 Child를 주입받아서 반환 값의 일부로 포함시키는 컴포넌트일 뿐, Child의 호출 주체가 아니다.
- 따라서, ParentComposite가 리렌더링되어도, Child는 호출 주체인 App이 리렌더링되지 않거나 자신의 state가 변하지 않는다면, 리렌더링되지 않는다.
응용 문제. 아래는 <App/> 내에 <ParentFoc/>, <Child/>가 존재하고, ParentFoc가 Child를 주입받는 방식은 컴포넌트 디자인 패턴에서 function of children 이라 부르는 방식이다.
만약, ParentFoc의 state가 변경되어 리렌더링된다면, Child는 리렌더링될까.
function App() {
return (
<div className="App">
<ParentFoc>{() => <Child />}</ParentFoc>
</div>
);
}
function ParentFoc({ children }) {
console.log("리렌더링: ParentFoc");
const [count, setCount] = useState(0);
return (
<div>
ParentFoc
<Count count={count} setCount={setCount} />
{children({ text: "hello ParentFoc" })}
</div>
);
}
function Count({ count, setCount }) {
return (
<div>
{count}
<button onClick={() => setCount((prev) => prev + 1)}>click</button>
</div>
);
}
function Child() {
console.log("리렌더링: child");
return <div>Child</div>;
}
정답은 'ParentComposite이 state 변경으로 리렌더링되면 Child는 리렌더링된다'
왜냐하면 Child를 리엑트 엘리먼트(react element)로 생성하는 익명함수(() => <Child />)는 ParentFoc에서 호출되기 때문이다.
2. memo의 기능과 작동 조건
리엑트의 memo는 부모 컴포넌트의 리렌더링될 시, 자식 컴포넌트는 props가 변경되지 않았다면 리렌더링을 건너뛸 수 있다.
방법
memo로 리렌더링을 건너뛸 컴포넌트를 감싸준다. 그러면 새로운 컴포넌트를 반환하고, 이 컴포넌트를 사용하면 된다.
// @SomeComponent.tsx
import { memo } from 'react';
function SomeComponent(props) {
// ...
}
const MemoComponent = memo(SomeComponent,arePropsEqual?);
export default MemoComponent
// @App.tsx
import MemoComponent from "./component"
export default function App (){
return (
<div>
<MemoComponent/>
</div>
)
}
memo로 레핑된 컴포넌트는 리렌더링으로 새 가상돔을 만들 시, props가 변하지 않는 이상 리렌더링되지 않고 이전 가상돔의 컴포넌트 값을 가져온다.
두 번째 인자: arePropsEqual
- 옵션 인자이며 이전 props와 새 props 두 인자를 받는 함수다. true를 반환할 시 리렌더링이 일어나지 않고, false를 반환하면 리렌더링이 발생한다.
- 일반적으로는 지정하지 않는 경우가 많다. 이 때는 Object.is(prevProps,newProps)로 두 값이 비교된다.
3. useMemo, useCallback
문제
memo 기능이 작동하려면 props는 이전의 리렌더링 시 props와 동일해야 한다. 문제는 리렌더링이 시작될 때 컴포넌트 함수를 호출하고 함수 내 실행 컨텍스트를 재구성한다는 점이다. (이 부분은 리엑트의 작동과정으로 아래 글 참조)
2024.03.04 - [react] - [React] 리엑트 살펴보기1: 가상 돔과 리엑트의 렌더링 과정
[React] 리엑트 살펴보기1: 가상 돔과 리엑트의 렌더링 과정
본 글은 리엑트를 왜 사용하는가에 대해 알기 위해서 "가상 돔과 리엑트의 렌더링 과정"을 중심으로 살펴본다. 목차 1. 브라우저의 동작 과정1: 최초 렌더링 2. 브라우저의 동작 과정2: 업데이트와
chartist1206.tistory.com
따라서 변수에 할당된 객체나 함수는 참조 주소가 늘 새로 할당된다. 그런데, memo의 props 비교 알고리즘은 Object.is()으로 이뤄지고 이는 얕은 비교이기 때문에 참조값이 다르면 서로 다른 props로 인식하게 된다.
즉, 객체나 함수를 props로 넘겨주면 memo가 작동할 수 없다.
해결방법: useMemo, useCallback
이에 대한 해결 방안으로 useMemo와 useCallback을 사용하면 된다. 둘은 각각 값과 함수를 메모리제이션할 수 있는 훅으로, 객체나 함수를 이들로 감싸주면 최초 렌더링 시에 이들을 파이버에 저장한다. 그러면 리렌더링 시 이들을 재선언하지 않고 파이버에서 가져오므로 이전의 가상돔에서 할당한 참조 주소와 새 가상돔에서 할당한 참조주소는 동일하다. 따라서 props로 넘겨줄 시에 동일한 것으로 인식된다.
useMemo 사용법
const visibleTodos = useMemo(
() => filterTodos(todos, tab),
[todos, tab]
);
- 첫 번째 인자 | 메모리제이션할 값, 순수함수의 형태이며 아무런 인자를 가지지 않고 어떤 값을 리턴해야 한다.
- 두 번째 인자 | 의존성 배열, 의존성 배열 내에 값이 변경되면 useMemo의 값을 재계산한다. 해당 배열이 비어있으면 최초의 렌더링 때만 값이 계산되고 리렌더링부터는 동일한 값을 사용한다.
- 다른 용도 | useMemo는 동일한 값을 기억할 때 외에도, 무거운 연산을 생략하고 의존성을 만들 때 사용가능하다.
- 주의사항 | memo는 다른 리엑트 훅과 마찬가지로 함수형 컴포넌트의 최상위 맥락에서만 호출할 수 있다. 루프나 조건문 등에서 호출할 수 없다,
useCallback사용법
사실, useCallback은 useMemo로도 대체가 가능하다. 단지, 가독성을 위해서 useCallback으로 대체한다.
// useMemo를 사용해서 함수를 기억하는 방법
const handleSubmit = useMemo(() => {
//기억할 함수
return (orderDetails) => {});
};
}, [productId, referrer]);
// useCallback를 사용해서 함수를 기억하는 방법
const handleSubmit = useCallback((orderDetails) => {
//..내부 코드
}, [productId, referrer]);
- 첫 번째 인자 | 캐싱할 함수, 무엇이든 인자로 가지고 리턴 가능하다.
- 두 번째 인자 | 의존성 배열, 의존성 배열 내에 값이 변경되면 useCallback 의 값을 재계산한다. 해당 배열이 비어있으면 최초의 렌더링 때만 값이 계산되고 리렌더링부터는 동일한 값을 사용한다.
- 반환 | 함수 선언을 캐싱하고 반환할 뿐, 호출하진 않는다.
- 다른 용도 | useCallback은 useEffect에서 의존성인 함수를 호출할 때에도 사용한다. 리렌더링 과정에서 함수의 참조주소가 매번 변경되면 useEffect가 불필요하게 실행될 수 있기 때문이다.
function Component() {
const [message, setMessage] = useState('');
const func = useCallback(() => {}, []);
useEffect(() => {
const a = func()
}, [func]);
// func을 useCallback으로 기억하지 않으면, 리렌더링마다 func의 참조주소가 달라서 useEffect가
재실행될 것이다.
4. 모든 컴포넌트에 memo를 사용하면 될까?
일반 컴포넌트와 비교했을 때 memo된 컴포넌트는 props 변경여부를 조건으 삼아 리렌더링을 건너뛰고 성능을 향상시켜준다. 그렇다면, 모든 컴포넌트를 memo로 만드는 건 어떨까. 그러면 알아서 성능 최적화가 되지 않을까?
답은 그렇지 않다. 왜냐하면 memo 컴포넌트는 리렌더링 과정에서 이전 props와 새 props 비교 알고리즘 때문에 작업비용이 더 소모되기 때문이다. 특히, props의 갯수가 늘어날 수록 비교 비용이 증가한다. 게다가 어차피 props가 자주 변경되는 컴포넌트라면, 그저 불필요한 props 비교 작업만 추가하게 된 셈이다.
따라서, memo는 다음 경우의 컴포넌트에 대해서만 사용하는 것이 현명하다.
- 부모 컴포넌트의 호출에 의해 리렌더링이 자주 일어난다.
- 하지만, props가 자주 변경되지 않는다.
- 리렌더링 시 무거운 작업을 한다.
- props의 갯수가 많지 않아 비교 알고리즘이 가볍다.
이 부분에 대해서는 성능 비교와 자세한 정보를 제공하는 다른 분의 좋은 글이 있어 링크한다.
https://mycodings.fly.dev/blog/2024-02-09-why-react-do-not-memoization-their-components
왜 React는 기본적으로 Component를 메모(memo)화하지 않는 걸까?
왜 React는 기본적으로 Component를 메모(memo)화하지 않는 걸까?
mycodings.fly.dev
5. children props
리엑트의 함수 컴포넌트는 props로 children이란 프로퍼티를 자동으로 가진다. 이는 부모 컴포넌트 내에 자식 컴포넌트를 넣을 시(=JSX문에서 부모 태그가 자식 태그를 감쌀 시), 자식 컴포넌트가 부모 컴포넌트의 children props로 전달된다.
const Child = () =>{
return <p>child</p>
}
const Parent = ({children}) =>{
return <div>{children}</div>
}
const Container =() =>{
return <div>
<Parent>
<Child/>
</Parent>
</div>
}
children 프롭스는 사용 시, 주의사항이 있다. 먼저, 리엑트는 리렌더링 과정에서 각 컴포넌트 함수를 재호출하여 새롭게 리엑트 엘리먼트 객체를 만든다. 즉, 위 경우에서 Parent 요소의 children인 Child 요소는 재렌더링때 마다 새로운 참조값을 가진 오브젝트가 된다. 따라서, Parent 요소의 props인 children이 매번 변하므로 memo를 적용할 수 없다.
chidren을 잘 사용하려면 내부 동작 구조에 대해 알아야 한다. 아래 예시를 보자.
import React, { useState } from "react";
const Answer = () => {
console.log("answer");
return (
<div>
<p>hello</p>
</div>
);
};
const Parent = ({ children }: any) => {
console.log("parent");
const [state, setState] = useState(true);
return (
<>
<div>
{children}
<button onClick={() => setState(!state)}>change Parent</button>
</div>
</>
);
};
function App() {
const [state, setState] = useState(true);
return (
<div className="App">
<Parent children={<Answer />} />
<button onClick={() => setState(!state)}>change App</button>
</div>
);
}
export default App;
Answer 컴포넌트는 parent 컴포넌트 내부에 있으므로, 마치 parent 컴포넌트의 하위 컴포넌트인 것 같지만, 실제로는 App 컴포넌트에서 pros를 통해 Parent로 전달되었다. 따라서, Parent 컴포넌트가 리렌더링될 때는 Answer이 리렌더링되지 않는다. 반면, App 컴포넌트가 리렌더링 되면, Answer 역시 리렌더링된다.
memo와 children props 중에 선택 기준
children 컴포넌트는 memo를 제대로 작동시키지 않기 때문에 사용에 둘 중 하나를 선택해야 한다. children은 재사용되는 공용 컴포넌트에서 사용하기 적합하다. 반면, memo는 빈번하게 리렌더링이 일어나지만 props는 변하지 않는 컴포넌트를 최적화할 때 적합하다.
6. 참고. 리엑트 앱의 성능 분석 방법: react dev tool
위에서 리엑트 메모의 사용 경우를 보면 알 수 있듯이, memo는 최적화를 위해 모든 컴포넌트에 적절한 정답은 아니다. 따라서, 적용 전과 적용 후에 성능을 비교해보는 것이 바람직하다. 이를 위해 "react dev tool"이라는 크롬 익스텐션을 사용하는 게 좋다.
'react' 카테고리의 다른 글
[React] 리엑트 살펴보기4: useState()의 동작 방식 (0) | 2024.03.10 |
---|---|
[React] 리엑트 살펴보기3: 컴포넌트와 hooks의 등장배경(클래스 컴포넌트, 함수 컴포넌트) (0) | 2024.03.10 |
[React] 리엑트 살펴보기1: 가상 돔과 리엑트의 렌더링 과정 (0) | 2024.03.04 |
[React] 컴포넌트 외부에 변수 선언과 컴포넌트 내부에 변수 선언 간 차이 (0) | 2024.03.04 |
[React] pagination: 무한 스크롤 구현하기(+SWR 라이브러리) (0) | 2024.02.19 |