React - 메모이제이션
Memoization
지금까지 포스트를 쭉 읽어왔다면 렌더링이 어떻게 이뤄지고, 변경 사항이 어떻게 적용되는지 알것이다. 하지만 이제까지 기본 지식이였다면 이번에는 약간더 심층있는 내용이다. 개발자라면 같은 동작을 하더라도 더 효율적이고 성능이 좋은 코드를 작성하는 것이 숙명이다. 그래서 우리는 렌더링을 더 효율적으로 만드는 법을 알아볼 것이다.
바로 앞에서 다뤘던 렌더링을 다시 떠올려보자. 컴포넌트에서 변화가 발생하면 해당 컴포넌트는 새롭게 리렌더링된다.
export function Component() { const [counter, setCounter] = useState(0); const handleButtonClick = () => { setCounter((prev) => prev + 1); }; const calculator = () => { //엄청 복잡한 계산 로직 }; return ( <> <label> <div>{counter}</div> <button onClick={handleButtonClick}>+</button> </label> </> ); }
버튼을 클릭했을때 state의 값이 변경되면서 리렌더링되는 상황이라고 가정해보자. 컴포넌트에서 지정한 state에 해당하는 JSX결과만 달라지는 것이 아니라 컴포넌트 내부의 로직까지 리렌더링된다. 간단한 함수는 리렌더링이 되어도 리소스 낭비가 크지 않지만 함수가 크거나 무거운 연산이 동반되는 경우는 얘기가 달라진다. 그래서 리액트에서 지원하는 것이 memoiziation이다. 리액트에서 제공하는 useMemo, useCallback, React.memo는 불필요한 렌더링을 방지하는 역할을 한다.
자세한 용도 및 사용법은 리액트 훅을 다루면서 알아보겠다.
그렇다면 어떤 것들을 메모이제이션을 해야할까? 모든 코드의 최적화를 계산해서 메모이제이션을 처리할지, 렌더링 비용과 메모이제이션 비용 중 어떤 방법이 최선인가와 같은 다양한 문제에 봉착하게 된다. 이 문제는 실제로 여러 커뮤니티에서도 다뤄지는 주제인만큼 각 진영의 주장을 알아보겠다.
1. 필요한 곳에만 메모이제이션
const sum = (a, b) => { return a + b; }
이런 함수를 계속해서 사용하는 상황에서 우리는 선택지가 있다.
- 해당 함수의 결과를 메모이제이션한다.
- 매번 새로운 계산을 실행한다.
결과를 저장하고 동일한 작업을 요청했을때 메모리에서 결과를 꺼내오게 만들면 편할 것 같다. 하지만 위와 같이 간단한 함수는 메모리에서 데이터를 꺼내오는 것보다 직접 작업을 수행하는 것이 더 빠를 수도 있다. 왜냐하면 메모이제이션도 결국 리소스가 필요하기 때문이다. 값을 비교해 재계산 필요 여부 확인 비용, 이전 결과물을 저장해두었다가 꺼내오는 비용 등 이러한 비용이 리렌더링 비용보다 적은지 고민해보아야한다.
클래스 컴포넌트에서는 PureComponent, 함수형 컴포넌트에서는 memo로 사용자에게 옵션을 제공했다. 만약 메모이제이션이 만능이였다면 기본 값을 메모이제이션하는 방향으로 설계했을 것이다. 즉, 이 기능은 양날의 검과 같다는 것이다.
그래서 우리는 메모이제이션을 적용할때 신중해야만 한다. 미리 개발자가 리렌더링을 예측해서 메모이제이션을 설정하는 것이 아닌 useEffect를 통해 어떤 값이 변경될때 리렌더링이 되는지 확인하고 부분적으로 최적화를 진행해줘야만 한다.
2. 모든 곳에서 메모이제이션
위의 의견에 반대로 모든 곳에 메모이제이션을 적용하자는 의견도 있다. 물론 위의 의견대로 흘러간다면 너무 이상적인 기능 활용이 될것이다. 하지만 실제 코드는 많은 state와 props, component가 엮여 리렌더링이 빈번하게 일어나고 그걸 개발자가 일일히 확인해서 최적화를 하는 것은 많은 비용이 든다.
export default function EditDrawer() { const { seatInfo, setSeatInfo } = useDrawerContext(); const [isDrawerOpen, setIsDrawerOpen] = useAtom(isOpenDrawerAtom); const adminPickedDate = useAtomValue(pickedDateAtom); const today = useAtomValue(todayDateAtom); const currentDaysData = today.format("YYYY-MM-DD"); const { success, error } = useToast(); const [selectedDays, setSelectedDays] = useState<string[]>([]); const [repeatEndDate, setRepeatEndDate] = useState<string | null>(null); const [disabledDay, setDisabledDay] = useState<string[]>([]); ...
이 코드는 현재 내가 담당한 코드의 상태과 hook관련 부분만 가져온 것이다. 이 컴포넌트에서만 이렇게 많은 값들이 관리되고 있으며, 이 값들을 기반으로 여러개의 함수가 렌더링되고 있다.
단순히 개발자의 시간이 더 든다는 관점뿐만이 아니다. 메모이제이션을 통해 사용되는 비용은 props에 대한 얕은 비교가 이뤄지면서 발생한다. 하지만 리액트의 경우 이런 작업이 기본적으로 되고 있다. 리액트는 이전 작업물을 저장하고 이전과 현재를 비교해 변경되 부분만 렌더링시키는 작업을 하고 있다. 그렇기 때문에 리액트는 알고리즘에 의해 이전 작업물을 어떻게든 저장하고 있기 때문에 memo를 통해 발생하는 추가 비용이 엄청나게 많지 않다는 것이다.
반면에 memo를 하지 않았을때 발생하는 문제는 여러가지가 있다.
- 렌더링 비용
- 컴포넌트 내부 로직 재실행
- 위의 두가지 상황이 모든 자식 컴포넌트에서 발생
- 리액트의 트리 비교
memo를 모두 적용했을때 발생하는 비용보다 훨씬 많은 비용이 발생될 여지가 있다.
import React, { useEffect, useState } from 'react'; export function useMath(number: number) { const [double, setDouble] = useState(0); const [triple, setTriple] = useState(0); useEffect(() => { setDouble(number * 2); setTriple(number * 3); }, [number]); return { double, triple }; } export default function Component() { const [counter, setCounter] = useState(0); const value = useMath(10); useEffect(() => { console.log('rerender useMath'); }, [value]); const handleClick = () => { setCounter((prev) => prev + 1); }; return ( <> <h1>{counter}</h1> <button onClick={handleClick}>rerender</button> </> ); }
예시 코드를 한번 보자. 여기에서 useMath는 컴포넌트가 리렌더링되면 같이 리렌더링된다. 그래서 value가 변하지 않아도 매번 console이 확인된다.
export function useMath(number: number) { const [double, setDouble] = useState(0); const [triple, setTriple] = useState(0); useEffect(() => { setDouble(number * 2); setTriple(number * 3); }, [number]); return useMemo(() => ({ double, triple }), [double, triple]); }
하지만 이렇게 useMath의 결과를 useMemo를 통해 메모이제이션을 적용시켜주면 같은 고정된 값을 사용하게 되고, 리렌더링이 발생하지 않는다.
두가지 진영에 대한 나만의 결론
그럼 우리는 뭘 어떻게 하면 될까? 나도 정확하게 어떤 방법이 정답이라고는 못한다. 왜냐하면 코딩에는 정답이 없기 때문이다. 내 생각은 필요한 부분에만 적용하는 것이다. 경험상 useEffect나 useCallback에 디펜던시값을 제대로 설정하지 않으면 제대로 리렌더링이 발생하지 않고 내가 원하는 동작이 발생하지 않을 수가 있다. 물론 요즘은 lint에 의해 올바른 디펜던시 값이 설정되지 않으면 알려주지만 미쳐 확인하지 못할 수도 있기 때문이다.
시간이 많이 들겠지만 어떤 state와 props가 변경됬을때 어떤 로직이 리렌더링되는지 확인하고 필요한 곳에만 적용하자는 것이 내 결론이다.
마무리
이번 포스팅은 길거나 깊이가 있는 포스팅은 아니다. 하지만 리액트를 사용하면서 메모이제이션은 활용한 부분이 많기 때문에 알고 넘어가야 한다고 생각한다. 나도 지금은 lint에서 알려주는 내용을 참고해서 메모이제이션을 적용하고 있긴하다. 하지만 내가 먼저 메모이제이션을 적극 활용하고 있다고는 말하기 애매하다. 앞으로 코드의 흐름을 파악해 불필요한 렌더링은 줄여보도록 하겠다.
개의 댓글
1
Memoization
1. 필요한 곳에만 메모이제이션
2. 모든 곳에서 메모이제이션
두가지 진영에 대한 나만의 결론
마무리