리액트 컴포넌트의 렌더링 과정에서 발생할 수 있는 성능 문제와 이를 해결하기 위한 주요 최적화 도구를 두 편의 포스팅에 걸쳐 살펴보려고 한다.
이번 포스팅에서는 useMemo훅이 어떤 성능 문제를 어떤 아이디어로 개선하는지 살펴보자 👀
성능 최적화 도구에서 가장 포괄적으로 사용될 수 있는 훅이 바로 useMemo이다.
useMemo는 메모이제이션(이전에 계산한 값을 재사용)을 통해 불필요한 계산을 방지하기 위해 사용된다고 볼 수 있다.
특히 계산 비용이 높은 작업이나, 컴포넌트가 리렌더링될 때마다 불필요하게 다시 계산되는 값들을 최적화하는데 유용하게 사용된다.
useMemo의 기본적인 구조는 useEffect와 비슷하게 의존성 배열을 listening한다.
useMemo 구조
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
위 코드처럼 useMemo는 두 가지 매개변수를 받는다.
useEffect와 비슷하게 의존성 배열의 값이 변경되면 콜백 함수가 수행되어 새로운 값을 계산한다.
이러한 방식으로 불필요한 리렌더링을 방지할 수 있다.
useMemo의 콜백 함수는 렌더링 과정에서 실행된다는 점을 반드시 기억해야 한다.
즉, 데이터 패칭과 같은 작업은 useMemo를 통해 성능 최적화를 이룰 수 없다.
왜냐하면 데이터 패칭 로직이 렌더링 과정을 오히려 방해하기 때문이다.
(컴포넌트 생명주기에서 DOM이 완성된 직후, componentDidMount()에서 데이터 패칭이 수행된다는 것을 다시한번 생각해보자)
우리가 useMemo를 사용해야하는 순간을 간단한 예시를 통해 살펴보자
❌ 잘못된 사용 예시
- 렌더링을 방해할 수 있는 비동기 작업
const userData = useMemo(() => { fetch('api/user').then(res => res.json()); }, []);
✅ 올바른 사용 예시
- 무거운 연산 작업
const expensiveCalculation = useMemo(() => { return someHeavyComputation(prop1, prop2); }, [prop1, prop2]);
이처럼 데이터 패칭 로직이 아닌, 무거운 연산 작업이 리렌더링이 발생할 때마다 불필요하게 수행되는 경우 사용하는 것이 일반적이다.
또한 의존성 배열을 어떻게 설정하느냐에 따라 메모이제이션의 동작이 달라진다.
Sample Code
// 매 렌더링마다 다시 계산됨 (useMemo를 사용할 이유가 없음) const value = useMemo(() => expensiveCalculation()); // list가 변경될 때만 다시 계산 const value = useMemo(() => expensiveCalculation(), [list]); // 컴포넌트 마운트 시 한 번만 계산 const value = useMemo(() => expensiveCalculation(), []);
의존성 배열을 사용하는 훅의 경우 useEffect와 동일한 생명주기에 동작한다는 것을 기억하자!
숫자 목록의 평균을 계산하는 간단한 예시를 통해 useMemo의 효과를 살펴보자.
우선 훅을 사용하지 않았을 때를 확인해보자
Sample Code ❌
import { useState } from "react"; export default function App() { const [input, setInput] = useState(""); const [numbers, setNumbers] = useState([]); const onChangeInput = (e) => { setInput(e.target.value); }; const onClickBtn = (e) => { e.preventDefault(); setInput(""); setNumbers([...numbers, parseInt(input)]); }; const getAverage = () => { console.log("getAverage 호출"); const sum = numbers.reduce((acc, val) => (acc += val), 0); return numbers.length === 0 ? 0 : Number(sum / numbers.length); }; return ( <div> <form onSubmit={onClickBtn}> <input value={input} onChange={onChangeInput} /> <button type="submit">등록</button> </form> <p>입력한 숫자의 평균 : {getAverage()}</p> <div> <p>입력한 숫자 리스트</p> <ul> {numbers.map((number, index) => ( <li key={index}>{number}</li> ))} </ul> </div> </div> ); }
Console View
위 Sample Code는 input 태그에서 숫자를 입력받고, 이전까지 입력된 숫자들의 평균을 화면에 렌더링한다.
하지만, 콘솔 화면에서는 인풋을 입력하는 과정에서도 getAverage가 계속 호출되는 모습을 확인할 수 있다.
(원하는 동작은 등록 버튼을 클릭했을 때만 평균을 구하는 것이다!)
그 이유는 Input 태그에 숫자를 하나씩 입력할 때마다 리렌더링이 발생되어 getAverage가 계속 호출되기 때문이다.
이러한 문제는 useMemo 훅을 사용하여 해결할 수 있다.
사용 예시 (2)의 코드에서 useMemo를 사용하여 리팩토링 해보자
Sample Code ✅
import { useMemo, useState } from "react"; export default function App() { const [input, setInput] = useState(""); const [numbers, setNumbers] = useState([]); const onChangeInput = (e) => { setInput(e.target.value); }; const onClickBtn = (e) => { e.preventDefault(); setInput(""); setNumbers([...numbers, parseInt(input)]); }; const getAverage = useMemo(() => { console.log("getAverage 호출"); const sum = numbers.reduce((acc, val) => (acc += val), 0); return numbers.length === 0 ? 0 : Number(sum / numbers.length); }, [numbers]); return ( <div> <form onSubmit={onClickBtn}> <input value={input} onChange={onChangeInput} /> <button type="submit">등록</button> </form> <p>입력한 숫자의 평균 : {getAverage}</p> <div> <p>입력한 숫자 리스트</p> <ul> {numbers.map((number, index) => ( <li key={index}>{number}</li> ))} </ul> </div> </div> ); }
Console View
이전 코드에서 2군데를 수정했는데, 바로 getAverage와 getAverage를 호출하는 부분이다.
첫번째로 getAverage 함수를 정의하는 부분에서 useMemo훅을 사용하여 불필요한 값 재생성을 방지했다.
다음으로 getAverage를 호출하는 부분을 수정했다.
이전에는 HTML 태그에서 함수를 직접 호출했다면, useMemo훅을 사용하여 리팩토링된 경우에는 함수의 실행 결과를 그대로 출력하도록 수정했다.
이 부분에서 정확한 이해가 필요한데, useMemo훅에 콜백 함수를 넘기면 useMemo의 반환값은 콜백 함수의 return값이다.
즉, 함수가 아니라 값이 반환된다. (return문이 반환된다)
만약 이전과 같이 함수를 호출하는 형태로 사용하고 싶다면 useMemo의 콜백 함수에서 함수를 반환하는 형태로 수정하면 된다.
함수를 반환하도록 수정
... const getAverage = useMemo(() => { console.log("getAverage 호출"); return () => { const sum = numbers.reduce((acc, val) => (acc += val), 0); return numbers.length === 0 ? 0 : Number(sum / numbers.length); }; }, [numbers]); ... return ( ... <p>입력한 숫자의 평균 : {getAverage()}</p> ... )
첨부한 콘솔 화면과 같이 등록 버튼을 눌러서 평균값 연산이 필요한 순간에만 getAverage가 호출되는 모습을 볼 수 있다.
이러한 방식으로 성능 최적화를 이뤄낼 수 있었다!
useMemo는 특정 값의 변경을 감지하여 필요한 시점에만 계산을 수행하도록 함으로써, 불필요한 연산을 줄이고 애플리케이션의 성능을 개선하는데 도움을 준다는 것을 알아봤다.
사실 훅이라는 것도 함수이므로 호출하는데 비용이 발생한다.
따라서, 아주 간단한 연산이라면 그냥 리렌더링이 계속 발생하도록 두는게 오히려 성능이 더 좋을 수 있다 🙌
(무분별하게 사용하지 말자!!)