왜 React는 함수를 메모이징 할까? (feat. useCallback)

Juno·2022년 5월 31일
3

✨ 들어가기

최근에 파트너센터의 판매량부터 즐겨찾기까지 각종 채널별 및 국가별 데이터를 보여주는 작업을 진행했습니다. chart.js 라는 라이브러리를 사용했고 해당 라이브러리의 인터페이스에 맞게 데이터를 가공하는 작업 또한 프론트단에서 처리해주었습니다. 많은 양의 데이터를 입맛에 맞게 가공하는 작업이 있다보니 다시 연산되어야 할 때 이외에는 메모이징 작업이 필요하였습니다. 특히, 해당 차트가 그려지는 페이지가 메인 페이지이다보니 파트너센터의 전체적인 사용자 경험을 결정할 수도 있는 부분이라고 생각하여 최적화에 좀 더 신경써보고자 하였습니다.

useMemo

이때 대부분의 경우는 값에 관련된 연산을 메모이징해야 했으므로 React의 useMemo 훅을 사용하였습니다.

import { useMemo } from 'react';

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

useMemo로 전달된 첫번째 인자는 두번째 인자로 전달된 deps 배열의 값이 바뀌는 경우에만 재 연산이 이루어 집니다. 위의 예시에서 보면 a 또는 b 값이 변하지 않으면 memoizedValue 는 메모이징 됩니다.

useMemo는 값을 메모이제이션 해주는 React hook으로써, 모든 렌더링시의 고비용 계산을 방지해 줍니다.

즉, 제가 작업하면서 useMemo 훅을 사용했던 이유는 많은 양의 데이터를 가공한 값을 사용할 때 있어서 고비용 연산이 수반되고, 이를 의존하는 값이 변경된 것 이외의 리렌더링 될 경우에 고비용 연산을 방지해주고자 사용한 것입니다.

import { useMemo } from 'react';
import { useQuery } from 'react-query';

const MENUS = [];
const INITIAL_MENU = '';

export default function ExamplePage() {
	const [selectedMenu, setSelectedMenu] = useState(INITIAL_MENU);
	const { isLoading, data } = useQuery();

	const derivedData = useMemo(() =>
		computeExpensiveValue(data) 
	// ex) 데이터를 가공하는 로직에 reduce, map, filter등 배열을 순회하는 api들을 여러번 사용할 경우
  , [data]);
	
	if(isLoading) <p>Loading...</p>

	return(
		<>
			<Menu menus={MENUS} onSelect={setSelectedMenu} />
			<Chart data={derivedData} />
		</>);
};

대략적인 예시는 다음과 같이 간략화 해볼 수 있을 것 같아요.

Chart 뷰가 존재하고, useQuery훅을 통해서 받아온 데이터를 chart의 인터페이스에 맞게 가공해줍니다.

이때 가공한 데이터를 derivedData 라고 하고 이는 useQuery로 받아온 data에 의존하게 됩니다. (의존성 배열에 적어줍니다.)

이때 같은 페이지에 메뉴가 존재하는데, 메뉴를 변경했을때도 Chart의 내용은 똑같이 보여져야 합니다. 이를 위해 useMemo 훅으로 derviedData를 감싸주는 것 입니다.

하지만, 만약 derivedDatauseMemo 훅으로 감싸지 않았다면, 다음과 같은 일이 벌어집니다.

  1. 메뉴를 변경하면 유저 이벤트에 의해 selectedMenu가 변경됩니다.
  2. React의 상태인 selectedMenu가 변경됨으로 인해 컴포넌트가 리렌더링 됩니다.
  3. useQuery의 data는 queryKey로 관리되므로 리렌더링이 되더라도 동일한 값을 보장합니다.
  4. derviedData는 이전 렌더링과 같은 값을 보장하지만, 고비용의 계산을 한번 더 실행합니다.

💁🏻‍♂️ 함수는 왜 메모이징 해야 할까?(feat. useCallback)

앞서서 고비용 연산을 위해 값을 메모이징 하는 useMemo에 대해서 알아봤습니다. 사실 useMemo를 사용하는 이유는 정말 직관적이었습니다. 고비용 연산을 메모이징 하기 위함 입니다.

하지만, 값이 아닌 함수일 경우는 어떨까요?

사실 이 부분이 가장 어려웠던 부분인 것 같습니다. React 컴포넌트는 리렌더링이 일어날 때 마다 컴포넌트가 가지고 있는 요소들을 새로 생성합니다. 이때 함수는 사실 새로 생성하는 것은 거의 비용이 발생하지 않는다고 React 팀에서 설명합니다.

⁉️ 그렇다면 왜 함수를 메모이징 하는 훅이 존재하는 걸까요?

뭔가 뚜렷한 해소는 되지 않은 채 우연히 PR을 확인하다가 useCallback을 자주 사용하는 팀원을 발견하고, 이를 여쭤보면서 이 훅이 왜 필요했는지를 공부해보게 되었습니다.

😤 컴포넌트를 메모이징 하다: React.memo

import React, { useState } from 'react';

export default function Example() {
	const [value, setValue] = useState<string>('');
	const [count, setCount] = useState<number>(0);

	const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
		setName(e.target.value);
	}
	const increment = () => {
		setCount(prev => prev +1);
	}

	return (
		<>
			<input value={name} onChange={onChange}/>
			<Counter count={count} increment={increment} />
		</>
	);
}

const Counter = ({ count, increment }) => {
	console.log(rendered!);
	return (
	<>
		<span>{count}</span>
		<button onClick={increment}> + </button>
	</>
	);
};

Example 컴포넌트에선 input창에 value를 입력할 수 있고, Counter 컴포넌트를 렌더링 하는 역할을 가지고 있습니다. input을 업데이트 할 경우, value 라는 상태가 업데이트 되면서 Example 컴포넌트가 리렌더링 됩니다. React는 부모 컴포넌트가 리렌더링 될 경우 자식 컴포넌트 또한 같이 리렌더링 되기 때문에 콘솔창에 ‘rendered!’ 가 찍히게 됩니다.

Counter의 역할은 말그대로 버튼을 클릭했을때 카운트가 올라가는 역할만 가지고 있는데, 부모컴포넌트의 상태가 변경된다고 해서 같이 리렌더링 되는게 싫어서 이를 메모이징 하고 싶다면, React.memo 를 사용해볼 수 있습니다. React.memo 는 부모 컴포넌트의 렌더링과 관계없이, 넘겨받은 props들만 각각 얕은 비교(===) 를 통해서 하나라도 변경된 값이 있으면 리렌더링이 발생합니다.

하지만, 위의 코드는 다음과 같이 memo를 사용하더라도 또 render가 찍히게 됩니다. Counter에 넘겨받은 props는 count, increment 두가지 인데 count는 변경된 사항이 없지만, increment 함수가 문제입니다.

👀 console을 열고 input창에 입력하면서 rendered! 가 어떻게 찍히는지 확인해 보세요!

👉 useCallback은 이렇게 사용돼요

const func = () => {};
console.log(func === func) // true

console.log((() => {}) === (() => {})) // false

함수는 자바스크립트의 객체입니다. 원시타입의 값이라면 리렌더링이 되더라도 같은 값으로 판단하지만(state 값 역시 같음을 보장합니다), 참조타입인 객체는 리렌더링 될 때마다 재 생성되어 React는 이를 다른 값으로 판단하게 됩니다.

따라서 예시에서는 다음과 같은 흐름 때문에 위와 같은 상황이 발생합니다.

  1. 유저가 인풋을 입력합니다. onChange 핸들러를 통해 value 라는 상태를 변경합니다.
  2. 상태가 변경되었으므로 Example 컴포넌트가 리렌더링 됩니다.
  3. Counter 컴포넌트는 count와 increment가 이전의 값과 달라졌는지 비교하여 리렌더링 여부를 결정합니다. 이때, count는 이전의 값과 동일하지만 increment는 참조타입으로 값이 달라져 의도와는 다르게 리렌더링 됩니다.

드디어 여기서 useCallback 이 필요한 이유가 나오게 됩니다.

const memoizedCallback = useCallback(
  () => {
    doSomething(a, b);
  },
  [a, b],
);

useCallback은 리렌더링이 되었을 때, 의존성 배열에 있는 값이 바뀌었을 경우에만 함수의 참조값이 변경됩니다. (생성은 똑같이 되지만, 의존성 배열의 값이 변하지 않으면 메모이징된 기존의 값을 사용합니다.)

따라서 increment를 useCallback으로 감싸주게 되면 참조동일성을 보장하게 되고, increment가 바뀌지 않기 때문에 위의 3번 과정에서 Counter의 props가 모두 변경되지 않기 때문에 React.memo 의 메모이징이 의도대로 동작하게 됩니다!

👀 console을 열고 input창에 입력하면서 rendered! 가 어떻게 찍히는지 확인해 보세요!

🤔 그럼 무조건 좋은걸까요?

🙅🏻‍♂️ 아니요, 그렇지 않습니다!

사실 본문에서 언급한 api들을 사용하지 않아도 문제없이 기능을 구현할 수 있습니다. 최적화를 신경써야할 필요성이 생겼을 때 이를 사용하는걸 고민해도 늦지 않다고 생각해요. 예를 들어 성능 이슈가 발생했을때 말이죠.

하지만 이슈가 발생하기 이전에 해당 메모이제이션 훅을 남발하게 될 경우 개발하는 도중에 의존성이 얽혀서 원하던 결과를 도출해내지 못할수도 있고 복잡성이 커지게 됩니다. 실제로 코드가 복잡해지는 경우 미처 신경쓰지 못한 의존성 때문에 버그를 야기하는 경우도 종종 있었거든요..🥲 또한, 오히려 필요없는 곳에 사용할 경우 useMemo나 useCallback을 사용하기 위해 필요한 비용이 커서 오히려 쓰지 않은 경우보다 좋지 않을 수도 있기 때문입니다.

✍🏻 요약하기

이번 글에서는 미루고 미뤘던 React의 memoization api들을 정리해보았습니다. 사실 useCallback의 경우 이벤트 핸들러로써 사용될 경우 react state와 묶여 의존성 배열에 해당 state를 넣어주다 보면 의도대로 동작시키지 못할 경우가 많이 발생됩니다. 이럴 경우 useRef를 활용해서 참조동일성을 보장할 수 있는데, 이를 커스텀 훅으로 구현한 useEvent 라는 api를 React팀에서 RFC 에 추가하였습니다. 요런것도 있다(?)고만 알아주시면 좋을 것 같고, 해당 훅에 대한 이야기 그리고 useCallback의 좀 더 다양한 사용처를 다음 포스팅에서 이어가 보겠습니다!

긴 글 읽어주셔서 감사합니다 :)

profile
사실은 내가 보려고 기록한 것 😆

0개의 댓글