리액트를 사용할 때, 성능 최적화는 중요한 과제 중 하나입니다. 작은 규모의 애플리케이션에서는 크게 체감되지 않겠지만, 컴포넌트 구조가 복잡해지고 데이터가 자주 변동하는 상황에서는 불필요한 렌더링이 사용자 경험에 영향을 미칠 수 있습니다. 이런 이유로 성능 최적화가 필요한 시점을 잘 판단하고, 적절한 기법을 사용하는 것이 중요합니다.
그렇다면 여러분들은 어떻게 최적화 하시나요? 여러분들의 머릿속에 위와 같은 상황에서 사용할만한 방식들은 여러가지일 수 있지만, 저는 리액트와 관련되어서 최적화에 대한 진실을 배우고 이에 대한 실제로 어떤 진실이 있는지 빨간약을 먹기 위해 글을 작성해보곘습니다.
리액트 애플리케이션에서 최적화가 필요한 대표적인 상황은 다음과 같습니다:
이런 상황에서 자주 사용하는 것이 바로 useCallback
과 useMemo
입니다.
21년도 React Conference에서 소개된 테마 색상을 변경할 수 있는 기능이 추가된 간단한 Todo List에서의 경우를 보여드리겠습니다.
위의 코드에서 themeColor가 드래그를 통해 변경하면 themeColor를 상속 받고 있는 TodoList가 불필요하게 계속해서 재렌더링되는 성능 이슈가 발생합니다.
이에 대해서 memoization
이나 debounce
등의 테크닉을 활용하는게 좋아 보인다고 생각하고 우리는 열심히 구현할 것입니다.
구현 예시 : React Conf: React without memo
React로 개발하면서 위에 제시된 최적화가 필요한 상황을 맞이하였을 때, 생각할 수 있는 것은 여러가지가 있을 수 있습니다. Throttle
, debounce
같은 처리방식도 있고, React 환경에서는 useCallback
, useMemo
와 같은 메모이제이션 훅을 통해 성능을 향상시킬 수 있습니다.
간단하게 두 개의 훅이 어떤 것인지 정리해보겠습니다.
- useCallback
useCallback
은 함수를 메모이제이션하여, 의존성 배열이 변경되지 않는 한 동일한 함수 객체를 반환합니다. 주로 자식 컴포넌트에 함수를 props로 넘길 때, 불필요한 리렌더링을 방지하기 위해 사용합니다
const memoizedCallback = useCallback(() => {
doSomething(a, b);
}, [a, b]);
- useMemo
useMemo
는 값을 메모이제이션하여, 의존성 배열이 변경되지 않는 한 계산을 다시 하지 않고 저장된 값을 반환합니다. 주로 비용이 많이 드는 계산을 반복하지 않도록 할 때 사용합니다.
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
둘 다 성능 최적화 용도로 메모이제이션을 활용해서 사용되지만, useCallback은 함수 재생성 방지에, useMemo는 값 재계산 방지에 초점이 맞춰져 있습니다.
메모이제이션은 성능 최적화를 위해 자주 사용되는 기술이지만, 그 이면에는 메모리 사용과 관련된 숨은 비용이 존재합니다. 특히 클로저와 메모리 관리 측면에서 주의할 필요가 있습니다.
React의 useMemo
와 useCallback
훅은 컴포넌트가 재렌더링될 때마다 불필요한 재계산을 방지하기 위해 메모이제이션을 사용합니다. 이를 위해 클로저를 활용해 이전 값을 기억하고, 필요 시 저장된 값을 다시 사용할 수 있게 합니다.
아래 useMemo
의 React 내부 구현 예시에서 클로저가 어떻게 작동하는지 살펴보겠습니다.
function mountMemo<T>(
nextCreate: () => T, // 메모이제이션할 값을 생성하는 함수
deps: Array<mixed> | void | null, // 의존성 배열
): T {
const hook = mountWorkInProgressHook();
// 현재 훅의 상태를 가져옵니다. 이 훅의 상태는 클로저로 묶인 데이터입니다.
const nextDeps = deps === undefined ? null : deps;
// 의존성 배열을 처리합니다. 이 배열은 클로저 안에서 기억되고, 렌더링마다 참조됩니다.
const nextValue = nextCreate();
// `nextCreate`는 클로저로 감싸져 이전 환경에 있는 값들에 접근할 수 있습니다.
// 여기서 새로운 값을 계산합니다.
hook.memoizedState = [nextValue, nextDeps];
// 새로 계산된 값(nextValue)과 의존성 배열(nextDeps)을 저장하여, 다음 렌더링 시 사용합니다.
return nextValue;
}
위 코드에서 nextCreate
함수가 클로저를 통해 의존성 배열과 현재 상태를 기억합니다. 그 결과, 값과 의존성 배열은 메모리에 저장되고, 이후 렌더링에서도 동일한 값을 사용하거나, 의존성 배열이 변할 때만 재계산을 수행합니다.
클로저는 함수가 선언된 환경을 기억하기 때문에, React의 훅 시스템에서 메모이제이션할 값이나 함수를 기억하고 그 값들이 다시 필요할 때 사용될 수 있습니다.
function updateMemo<T>(
nextCreate: () => T, // 메모이제이션할 값을 생성하는 함수
deps: Array<mixed> | void | null, // 의존성 배열
): T {
const hook = updateWorkInProgressHook();
// 현재 훅의 상태를 가져옵니다. 클로저를 통해 이전 값과 의존성 배열을 참조할 수 있습니다.
const nextDeps = deps === undefined ? null : deps;
// 새로운 의존성 배열이 전달되었는지 확인합니다.
const prevState = hook.memoizedState;
// 이전에 메모이제이션된 값과 의존성 배열을 가져옵니다.
if (nextDeps !== null) {
const prevDeps: Array<mixed> | null = prevState[1];
if (areHookInputsEqual(nextDeps, prevDeps)) {
return prevState[0];
// 클로저를 통해 이전 값과 의존성 배열을 기억하고, 값이 변경되지 않았으면 메모이제이션된 값을 반환합니다.
}
}
const nextValue = nextCreate();
// 의존성 배열이 변경되었으므로 새로운 값을 계산합니다.
hook.memoizedState = [nextValue, nextDeps];
// 새로 계산된 값과 새로운 의존성 배열을 메모이제이션합니다.
return nextValue;
}
이처럼 클로저는 메모이제이션된 값과 그 값이 사용된 환경을 메모리에 계속해서 저장합니다. 의존성 배열이 변경되지 않으면 이 값들이 계속 메모리에 남아 있게 되므로, 메모리 사용량이 증가할 수 있습니다.
이러한 동작은 특히 다음과 같은 상황에서 문제가 될 수 있습니다:
따라서 메모이제이션을 사용할 때는 클로저가 불필요한 데이터를 메모리에 계속 유지하는 상황을 방지하기 위해, 의존성 배열과 계산 비용을 신중하게 고려해야 합니다.
위 내용을 통해 useMemo의 메모이제이션을 활용하게 되면 불필요한 데이터 유지를 통해 메모리 누수가 발생할 수 있다는 것을 알았습니다. 이후 또 하나의 의구심으로 useMemo
를 사용할 때 성능 이점을 얻으려면 데이터가 얼마나 복잡하거나 커야 useMemo
를 사용하는 이점이 있을까? 를 고민해보았습니다.
구글링을 통해 발견한 useMemo
에 대한 벤치마크를 테스트한 블로그를 약간의 소개를 드리고 저의 생각을 적어보겠습니다.
블로그에서 한 테스트를 아래와 같이 요약했습니다.
블로그 테스트 요약
- 컴포넌트가 다시 렌더링될 때 이전에 계산된 값을 재사용함으로써 불필요한 재계산을 방지
실험 내용- 처리할 데이터의 복잡도
(n)
에 따라useMemo
가 언제 효과적인지 실험
실험결과- 복잡도 n = 1:
useMemo
가 성능에 별다른 이점을 제공하지 못하고, 오히려 초기 렌더링이 19% 느려졌습니다.- 복잡도 n = 100: 초기 렌더링이 62% 느려지지만, 후속 렌더링에서 거의 성능 차이가 없거나 약간 빠릅니다.
- 복잡도 n = 1000: 초기 렌더링이 183% 느려졌으나, 후속 렌더링은 37% 더 빠릅니다.
- 복잡도 n = 5000: 초기 렌더링이 545% 느려지지만, 후속 렌더링에서 최대 609%까지 성능이 향상됩니다.
useMemo 훅에 대한 렌더링 최적화의 기능은 1000번 이상의 복잡한 연산이 필요할 때 부터 유의미한 성능을 보일 수 있다는 것을 알 수 있었습니다.
그래서 저는 "useMemo 사용해야할까?"에 대한 질문에 이렇게 말하는게 좋을 것 같습니다.
useMemo... 정말 필요할까?
실제로 로직을 짜면서 1000번 이상의 반복적인 연산이 필요하다는 것을 자각했다면, 이 코드는 무엇인가 잘못되었거나 대처가 필요하다는 생각을 하는 것이 중요하다고 생각합니다.
useMemo
에 대해서 단순히 쓰면 어떨까로 시작하기 보다는 지금 있는 로직에 useMemo
를 쓸 필요가 있을지를 연산 횟수가 나의 머릿속에서 반복되는 것을 알고 사용해야하지 않을까 합니다.
단순히 "여러번 호출"이라는 애매모호한 기준보다는 실제로 높은 횟수의 렌더링에서 효과적일 것이라고 생각합니다.
혹시라도 여러분들이 useCallback
과 useMemo
에 대해 많은 고민을 하고 계신다면, 조금 더 기다려보시면 좋을 것도 같습니다. React Forget
이라는 자동적인 메모이제이션을 도입해주는 기능을 현재 Meta에서 개발 중이라고 합니다. 물론 완성도와 모든 개별적 상황에 대해 어떻게 처리할지는 고민이 많아지지만, 잘 지켜보는게 좋을 것 같습니다.
이번 글을 쓰기 위해 다양한 글을 찾아보고 메모이제이션 동작과 최적화의 빨간약을 먹어보았습니다. 실제 제가 진행하는 프로젝트에서 useMemo
와 useCallback
을 통해 최적화가 필요한게 보이지 않더라고요. 정말 특수한 상황에 필요하다는 것을 알게 되었습니다. 위 두 개의 훅은 마법으로 최적화가 되는 것이 아닌 실제로 메모리라는 비용을 지불해서 만들어진 기술임을 인지하게된 좋은 탐구였습니다.(딥다이브 정도는 아니였지만 가볍게 들어갔다 나왔습니다.)
그리고 위에 나온 블로그의 테스트를 보면서 생각했지만 저정도의 렌더링에서 발생하게 되는 초기 렌더링 비용이 무지막지하게 높더군요? 과연 저런 초기렌더링도 잡는 것은 결국 어떤식으로 대처할지에 대해 조금 찾아보니 NextJS에서 초기 렌더링을 최적화하는 방식은 없을지 (결국 캐시로 귀결되지 않을까 싶지만) 좀 더 패턴을 찾아보고 다음 글 주제로 가지 않을까 합니다.
아래 제가 이 글을 쓰기 위해 참고자료가 너무 좋았던 기억에 들고왔으니 혹시라도 저의 글이 부족하다고 느껴지시다면 아래에서 더 가져가시면 좋을 것 같습니다.
참고자료
useCallback과 closure에 대한 더 깊은 이해를 원하신다면?
useMemo 벤치마크 테스트
useMemo에 대한 딥다이브를 원하신다면?
useMemo 소스코드가 궁금하다면?