JS/React

React - Rendering 성능 향상 시키기

shin96bc 2024. 3. 9. 23:33

Rendering 성능을 향상시키는 방법은?

Rendering은 React의 동작 방식에서 일반적으로 예상할 수 있는 부분이지만, rendering 작업이 때때로 낭비가 될 수 있다는 것도 사실입니다. 만약 Component의 rendering 출력이 변경되지 않았고, DOM이 해당 부분을 업데이트할 필요가가 없다면 해당 Component를 rendering 시키는 것은 시간낭비이고, 더 나아가서 애플리케이션의 성능에도 영향을 주기 때문에 사용자에게 나쁜 사용자 경험을 주게 될 수도 있습니다.

 

React Component rendering 결과물은 항상 현재 props와 state의 상태를 기반으로 결정되어야 합니다. 따라서 props와 state가 변경되지 않았음을 미리 알고 있다면 rendering 결과물은 동일 할 것이고, 이 Component에 대해 변경이 필요하지 않고 렌더링 작업을 건너 뛸 수도 있다는 것을 알아야 합니다.

 

일반적으로 소프트웨어의 성능을 개선하는 방법은 두가지 접근법이 존재합니다.

  • 동일한 작업을 가능한 더 빨리 수행하는 것
  • 더 적게 작업하는 것

 

React에서 rendering을 최적화하는 것은 주로 Component rendering을 적절하게 건너뛰어서 작업량을 줄여주는 것이 일반적입니다.

 

Component rendering 최적화 기법

React는 Component rendering을 생략할 수 있는 3가지 API를 제공합니다.

 

'React.Component.shouldComponentUpdate'

  • Class Component의 옵셔널 lifecycle method로 'false'를 'return' 하면 React는 Component rendering을 건너뜁니다. 이 method 내부에는 'boolean'을 'return'할 어떤 로직이든 집어넣을 수 있지만, 가장 일반적인 방법은 Component의 'props'와 'state'가 변경되었는지지 확인하고, 변경되지 않았을 때 'false'를 'return' 하는 방법입니다.

'React..PureComponent'

  • 'shouldComponentUpdate'를 구현할 때 'props'와 'state'를 비교하는 것이 가장 일반적인 방법이므로 'PureComponent'를 base class로 구현하면 'Component' + 'shouldComponentUpdate'를 사용하는 것과 같은 효과를 볼 수 있습니다.

'React.Memo()'

  • 내장 고차 Component type으로 Component type을 인자값으로 받고, 새롭게 래핑된 Component를 'return'합니다. 래퍼 Component의 기본 동작은 'props'의 변경이 있는지 확인하고, 변경된 'props'가 없다면 다시 rendering 하지 못하도록 하는 것입니다. Function Component와 Class Component는 모두 'React.Memo()'를 사용하여 래핑할 수 있습니다.

 

위의 3가지 기법은 'shallow equality(얕은 비교)'를 사용합니다. 즉, 서로 다른 객체에 있는 모든 개별 필드를 검사하여 객체의 내용이 같은지 다른지 확인합니다. 쉽게 말하면 'obj1.a === obj2.a && obj1.b === obj2.b && …'를 수행한다고 할 수 있습니다.

 

이것은 Javascript 엔진에서 매우 간단한 작업인 '==='를 사용하므로 매우 빠르게 끝납니다. 그러므로 3가지 방법은 모두 같은 방법론을 사용하는 것입니다. 'const shouldRender = !shallowEqual(newProps, prevProps);'

 

여기에 잘 알려지지 않은 기법도 하나 존재합니다. React가 rendering 결과물을 지난번과 정확히 동일한 참조를 반환한다면, React는 해당 하위 Component의 rendering을 건너뜁니다. 이 기술을 구현하는 방법은 대략 두가지 정도가 있습니다.

  • 결과물에 'props.children'이 있다면 이 Component가 상태 업데이트를 수행해도 elemnent는 동일할 것입니다.
  • 일부 Element를 'useMemo()'로 감싸면 종속성이 변경될 때 까지 동일하게 유지됩니다.

예시 보기

// 1. props.children 예시
function SomeProvider({children}) {
	const [counter, setCounter[ = useState(0);

	return (
		<div>
			<button onClick={() => setCounter(counter + 1)}>Counter: {counter}</button>
			{/* ChildComponent는 state가 변경될 때 마다 다시 렌더링 됩니다. */}
			<ChildComponent />
			{/* state가 업데이트 되어도 props.children으로 들어온 Component는 다시 리랜더링 되지 않습니다. */}	
			{children}
		</div>
	);
}

// 2. useMemo() 예시
function OptimizedParent () {
	const [counter1, setCounter1] = useState(0);
	const [counter2, setCounter2] = useState(0);

	const memoizedElement = useMemo(() => {
		// 이렇게 useMemo를 감싼 ChildComponent는 deps에 들어있는 counter1이 변경될 때만 다시 렌더링 됩니다.
		// (counter2가 변경될 때는 다시 렌더링 하지 않습니다.)
		return <ChildComponent />
	}, [counter1]); 

	return (
		<div>
			<button onClick={() => setCounter1(counter1 + 1)}>
				Counter1: {counter1}
			</button>
			<button onClick={() => setCounter2(counter2 + 1)}>
				Counter2: {counter2}
			</button>
			{memoizedElement}
		</div>
	);
}

 

이러한 모든 기법들에서 Component rendering을 건너뛰면 React는 마찬가지로 하위 Tree의 전체 rendering을 건너뛰어 “재귀적으로 자식을 rendering”하는 동작을 중지하게 됩니다.

 

새로운 props의 참조가 rendering 최적화에 어떻게 영향을 미치는가?

앞서 보았듯이 기본적으로 React는 중첩된 Component의 'props'가 변경되지 않았더라도 다시 rendering을 수행합니다. 이는 하위 Component에 새로운 참조를 'props'로 전달하는 것 또한 문제가 되지 않는다는 것을 의미합니다. 왜냐하면 같은 'props'가 오던 말던 상관없이 rendering을 할 것이기 때문입니다.

 

예시 보기

// ParentComponent가 rendering될 때마다, 하위 자식 Component의 props는 변경되지
// 않았지만, 그것과 상관없이 계속 re-rendering 됩니다.
function ParentComponent() {
	const onClick = () => {
		console.log('클릭');
	};

	const data = {a: 1, b: 2};

	return <ChildComponent onClick={onClick} data={data}/>
}
  • 'ParentComponent'가 매번 rendering될 떄 마다 매번 새로운 'onClick'함수의 참조와 새로운 'data'객체 참조를 만들어서 이를 props로 자식 Component에게 넘겨주게 됩니다.(일반 함수든, 에로우 함수든 동일함)

 

이는 또한 '<div>'나 '<button>'을 'React.memo()'로 래핑하는 것 처럼 호스트 Component에 대해 rendering을 최적화 하는 것이 별 의미가 없다는 것을 뜻합니다.

 

하지만, 하위 Component가 'props'가 변경되었는지 확인하여 rendering을 최적화 하려는 경우, 새 'props'를 전달하면 하위 Component가 rendering을 수행하게 됩니다. 새 'props'의 참조가 실제로 새로운 데이터인 경우에는 이 방법이 유용할 수 있지만, 만약 상위 Component가 단순히 콜백 함수를 전달하는 수준이라면 그것은 무의미한 것일 수 있습니다.

 

예시 보기

const MemoizedChildComponent = React.memo(ChildComponent);

function ParentComponent() {
	const onClick = () => {
		console.log('클릭');
	};

	const data = {a: 1, b: 2};

	return <MemoizedChildComponent onClick={onClick} data={data} />;
}
  • 이제 'parentComponent'가 rendering될 때 마다 'MemoizedChildComponent'는 해당 'props'가 새로운 참조로 변경되었음을 확인하고 다시 rendering을 수행합니다. 'onClick'함수와 'data'객체의 값이 변하지 않았음에도 말입니다.
  • 이러한 과정을 요약하면
    • 'MemoizedChildComponent'는 rendering을 건너뛰고 싶지만, 항상 다시 rendering 될 것입니다.
    • 새로운 참조가 계속해서 생기기 때문에 'props'가 변경되었는지 비교하는 것은 무의미한 일입니다.
  • 비슷한 예시로 아래와 같은 상황에서도 'props.children'이 항상 새로운 참조를 가리키기 때문에 항상 자식 Component를 새로 rendering할 것입니다.
function Component() {
	return (
		<MemoizedChild>
			<Component2 />
		</MemoizedChild>
	);
}

 

Props 참조를 최적화 하기

Class Component는 항상 동일한 참조인 Instance Method를 가질 수 있기 때문에, 실수로 새로운 콜백 함수 참조를 만들어 버릴 걱정을 크게 할 필요는 없습니다. 그러나 별도의 자손 아이템에 유니크한 콜백을 생성하거나, 익명 함수의 값을 캡쳐하여 자식에게 전달하는 경우가 있을 수 있습니다. 이 경우 새로운 참조가 생성되고, rendering 동안 하위 props로 새로운 객체가 만들어져 전달 될 수 있습니다. 안타깝게도 React에는 이러한 경우를 최적화하는데 도움이 되는 기능은 없습니다.

 

Function Component에는 동일한 참조를 재사용하는데 도움이 되는 두가지 Hook이 있습니다. 객체 생성이나 복잡한 계산과 같은 모든 종류의 일반데이터에 사용하는'useMemo', 콜백 함수를 만들 때에 사용하는 'useCallback'이 있습니다.

 

그렇다면 그냥 전부 다 메모이제이션하면 안될까?

결론부터 말하자면 모든 함수와 값을 'useMemo', 'useCallback'으로 감싸서 사용할 필요는 없습니다. 이러한 처리는 단지 자식 Component의 동작에 변화를 만들 뿐입니다. (즉, 'useEffect'에 대한 의존성 배열 비교는 자식이 일관된 'props' 참조를 받기 원하는 경우를 만듦으로써 상황이 더욱 복잡해 질 수 있습니다.)

 

이 글을 읽고 계시는 분들께 질문을 하나 드리자면, React는 왜 모든 것들을 'memo'로 감싸지 않았을까요?

 

Dan Abramov가 계속헤서 지적하는 것은 **'props'를 비교하는 것은 공짜가 아니라는 것**입니다. 그리고 Component가 항상 새로운 'props'를 받기 때문에 'props'의 변화를 체크한다고 하더라도 re-rendering을 막을 수 없는 상황 또한 존재합니다.

 

저는 적절하게 'useMemo', 'useCallback', 'React.memo'를 사용한다면 애플리케이션의 성능이 좋아질 것이라는 것은 확신합니다.

 

React는 완전히 rendering을 기반으로 합니다. 무엇이든 하려면 rendering을 해야만 합니다. 그리고 대부분의 rendering은 그렇게 비싸지 않습니다.

 

그러나 낭비되고 있는 re-rendering을 줄이는 것만이 능사는 아닙니다. 전체 애플리케이션을 다시 re-rendering하는 일도 잦지 않고, DOM 업데이트가 없는 낭비되고 있는 re-rendering은 CPU를 생각보다 혹사시키지는 않습니다. 이것이 대부분의 앱에서 문제가 되고 있는가? 라고 하면 그렇지는 않을 것입니다. 단지 조금 더 나아질 수 있는 가능성이 있다는 것입니다.

 

개발자가 기본적으로 모든 내용을 'memo()'로 감싸서 사용한다면 정말로 성능에 악영향을 미칠까? 라고 생각해보면 그렇지만은 않습니다. 비교에 따르는 앱 성능 낭비가 있을 수도 있지만, 순이익이 존재할 수 도 있습니다.

 

이와 관련된 흥미로운 이슈가 존재합니다.

“메모이제이션을 언제 해야하는지, 그냥 모든 것을 메모이제이션 하는게 정말 나쁜 것인지에 대한 논의가 활발하게 진행중인 것 같습니다. 'useMemo', 'useCallback', 'React.memo'에 대해서는 따로 하나씩 만들 예정입니다.

 

불변성과 렌더링

  • React의 상태 업데이트는 할상 불변적으로 수행되어야 합니다. 그 이유는 2가지가 있습니다.
    • mutate한 값의 대상과 위치에 따라 Component가 rendering되지 않을 수 있습니다.
    • 데이터가 실제로 업데이트 된 시기와 이유에 대해 혼란을 겪을 수 있습니다.
  • 구체적으로 살펴봅시다.
  • 앞서 보았던 것 처럼 'React.memo', 'PureComponent', 'shouldComponentUpdate'는 얕은 비교를 기반으로 이전과 이후의 'props'값을 비교합니다.
    • 'props.value !== prevProps.newValue'와 같이 비교할 것입니다.
  • 만약 값의 불변성을 지키지 않았을 경우에는 'value'는 같은 참조를 가지고 있기 때문에 Component는 아무것도 변경시키지 않았다고 생각할 것입니다.
  • 우리는 불필요한 re-rendering을 방지하여 성능을 최적화 해야 한다는 것을 인지해야 합니다. 'props'가 변경되지 않은 경우 rendering은 불필요하거나 낭비일 뿐입니다. mutate한 값을 사용하면, Component가 아무것도 변하지 않았다고 잘못 생각할 수 있으며, 개발자는 Component가 다시 rendering 되지 않은 이유에 대해서 헷갈릴 수 있습니다.
  • 또 다른 문제는 'useState'와 'useReducer'입니다. 'setState()'나 'dispatch()'가 호출될 때 마다 React는 re-rendering을 queue에 밀어 넣을 것입니다. 그러나 React는 모든 Hook의 상태 업데이트에 새 객체/배열의 참조이거나, 새 원시데이터(string, number등)로 전달하고 반환해야 합니다.
  • React는 rendering 단계 동안 모든 상태 업데이트를 적용합니다. React는 Hook에서 상태 업데이트를 적용하려고 하면, 새 값이 동일한 참조인지 확인합니다. React는 항상 업데이트 대기열에 있는 Component rendering을 끝냅니다. 그러나 이전과 값이 동일한 참조이고, rendering을 해야할 다른 이유(부모 Component의 re-rendering 등)가 없다면 React는 Component에 대한 rendering 결과를 버리고고 rendering pass를 벗어납니다.
  • 예시 보기
// 1. re-rendering 실패 예시
const [state, setState] = useState([
	{a: 1, b: 4},
	{a: 2, b: 5},
	{a: 3, b: 6}, 
]);

const onClick = () => {
	// 이렇게 state 배열안에 있는 object의 a를 그대로 바꾸고 다시 setState로 state에 할당해도
	// React는 참조가 바뀌지 않았기 때문에 re-rendering을 하지 않습니다.
	state.a = 7;
	setState(state);
}

// 2, re-rendering 성공 예시
const [state, setState] = useState([
	{a: 1, b: 4},
	{a: 2, b: 5},
	{a: 3, b: 6}, 
]);

const onClick = () => {
	// 이렇게 스프레드 연산자(...)(전개 연산자라고도 함)를 사용해서 배열을 복사해서 새로운
	// 참조값으로 바꿔준 다음에 setState를 통해서 state에 할당해야 React가 값이 바뀌었다고 
	// 인지해서 re-rendering을 하게 됩니다.
	const newState = [...state];
	state.a = 7;
	setState(state);
}

 

  • 한가지 알아둬야 할 것은, Class Component와 Function Component 사이엔 동작에 뚜렷한 차이가 있다는 것입니다.
    • Class Component는 'this.setState()'를 사용합니다.('this.setState()'는 값이 불변이 아니어도 됩니다. 항상 re-rendering을 합니다.)
// 사실 이것은 빈객체를 넘겨주는 것과 다를게 없습니다.
const {state} = this.state;
state[2].a = 8;
this.setState({state});

// Function Component는 'useState', 'useReducer' Hook을 사용합니다.

 

  • 모든 실제 rendering 동작의 이면에는 불변하지 않는 값은 React의 단방향 데이터 플로우에 혼란을 야기합니다. 불변하지 않는 값은 코드로 하여금 다른 값을 보게 하는데, 기대와는 다르게 동작할 가능성이 큽니다. 이로 인해서 특정 상태가 실제로 업데이트 되어야 하는 시기와 이유, 또 변경사항이 어디에서 발생했는지 알기 어려워집니다.
  • 정리하면 React, 그리고 React의 에코시스템에서는 모든 것이 불변한 update로 간주됩니다. 불변하지 않은 값은 버그를 유발할 수 있습니다.

React Component rendering 성능 측정하기

React DevTools Profiler를 활용하여 어떤 Component가 각 commit 마다 rendering 되는지 살펴봅시다. 예기치 못하게 re-rendering 되는 Component를 찾아서 왜 re-rendering 되었는지, 그리고 어떻게 고칠 수 있는지 확인해봅시다.('React.memo()'로 감싸거나, 부모 Component가 넘겨주는 'props'를 메모이즈 하는 등의 방법을 사용할 수 있습니다.)

 

또한 React는 dev build에서 느리게 실행된다는 점을 기억해야 합니다. development 모드에서는 어떤 Component가 왜 rendering 되었는지 살펴보고, Component가 rendering되는데 소요되는 시간등을 비교할 수 있습니다. 그러나 절대 React development 모드로 rendering 속도를 측정해서는 안됩니다. 반드시 Production 빌드로 rendering 속도를 측정해야 합니다.

 

Reference

https://yceffort.kr/2022/04/deep-dive-in-react-rendering#렌더링-성능-향상시키기