namoman.com

디지털 정원과 실험실

한번 계산한 것은 기억하기: React 프론트엔드 최적화의 핵심, 메모이제이션(Memoization)

지난번 인터랙티브 초등 수학 교구 플랫폼인 Math Blocks 개발기에서 3D 공간 투영 및 대규모 연산 보호 기법을 다루며 성능 최적화에 대한 이야기를 살짝 언급했었습니다. 수백 개가 넘는 오브젝트의 기하학적 좌표를 실시간으로 계산하고 애니메이션 상태를 추적해야 하는 웹 애플리케이션일수록 프론트엔드의 성능 관리는 필수적입니다.

오늘은 그 최적화의 중심에 있는 기술이자, 모던 프론트엔드 개발자라면 반드시 마스터해야 하는 메모이제이션(Memoization) 기법에 대해 이야기해 보려 합니다. 개념부터 시작해 많은 개발자가 흔히 저지르는 실수, 그리고 실무에서 언제 어떻게 써야 하는지 가이드라인을 정리하겠습니다.

1. 메모이제이션(Memoization)이란 무엇인가?

컴퓨터 과학에서 메모이제이션의 정의는 명확합니다. “동일한 계산을 반복해야 할 때, 이전에 계산한 값을 메모리에 저장함으로써 동일한 계산의 반복 수행을 제거하여 프로그램 실행 속도를 빠르게 하는 기술”입니다. 한마디로 요약하면 ‘메모리 공간을 조금 더 쓰고 컴퓨터의 CPU 연산 시간을 아끼는 트레이드 오프(Trade-off)’ 전략이라고 볼 수 있죠.

가장 대표적인 예시가 바로 피보나치 수열($F_n = F_{n-1} + F_{n-2}$) 연산입니다. 단순 재귀 함수로 구현하면 트리 구조가 뻗어나가며 동일한 하위 문제들을 기하급수적으로 중복 계산하게 되므로 시간 복잡도가 $O(2^n)$에 달하게 됩니다. 그러나 이미 계산한 값을 배열이나 객체(Cache)에 저장해 두는 메모이제이션을 적용하면 단 한 번씩만 계산을 수행하므로 시간 복잡도를 $O(n)$으로 극적으로 줄일 수 있습니다.

웹 프론트엔드, 특히 React 생태계에서의 메모이제이션은 조금 더 특별한 의미를 가집니다. 복잡한 알고리즘 연산뿐만 아니라, ‘불필요한 UI 리렌더링(Re-rendering) 방지’라는 목적이 크게 작용하기 때문입니다.

2. React가 메모이제이션을 처리하는 3대 축

React 아키텍처는 기본적으로 상태(State)나 프롭스(Props)가 변경되면 해당 컴포넌트와 그 하위의 모든 자식 컴포넌트를 통째로 다시 실행(리렌더링)합니다. 이 과정에서 발생하는 낭비를 막기 위해 세 가지 핵심 메모이제이션 도구를 제공합니다.

① 값의 보존: useMemo

useMemo는 함수의 리턴값을 메모이제이션합니다. 의존성 배열(Dependency Array)에 지정된 값이 변경되지 않는 한, 컴포넌트가 아무리 리렌더링되어도 내부의 연산 로직을 다시 실행하지 않고 기존에 보관해 둔 결괏값만 즉시 반환합니다.

// 컴포넌트가 리렌더링될 때마다 배열을 순회하며 무거운 필터링 연산을 수행함
const expensiveData = useMemo(() => {
  return performHeavyCalculation(rawProblems);
}, [rawProblems]); // rawProblems가 바뀔 때만 재연산

② 함수의 보존: useCallback

자바스크립트에서 함수는 객체입니다. 컴포넌트가 리렌더링될 때마다 내부에 선언된 함수들은 매번 새로운 메모리 주소값으로 재생성됩니다. useCallback은 이 함수의 인스턴스 자체를 메모이제이션하여, 의존성 배열이 바뀌지 않는 한 동일한 참조(Reference Identity)를 유지하도록 보장합니다.

const handleBlockClick = useCallback((blockId) => {
  setSelectedBlock(blockId);
}, []); // 최초 마운트 시에만 함수를 생성하고 참조를 유지

③ 컴포넌트의 보존: React.memo

React.memo는 고차 컴포넌트(HOC)로, 컴포넌트 자체를 메모이제이션합니다. 부모 컴포넌트가 리렌더링되더라도 자신이 전달받는 프롭스(Props)가 이전과 완전히 동일하다면, 자기 자신은 렌더링 과정을 생략하고 직전의 렌더링 결과를 재사용합니다.

3. 가장 흔히 하는 실수와 안티 패턴

“성능이 좋아진다면 모든 곳에 useMemouseCallback을 붙여버리면 되는 것 아닌가요?”라고 생각할 수 있습니다. 하지만 이는 대표적인 안티 패턴입니다. 메모이제이션에도 비용이 들기 때문입니다.

실수 1: 원시 가공 및 가벼운 연산에 useMemo 남발하기

// 안티 패턴: 오히려 손해입니다!
const totalCount = useMemo(() => blocks.length, [blocks]);

단순히 배열의 길이를 가져오거나 1차원 수식을 계산하는 수준의 가벼운 로직은 자바스크립트 엔진 입장에서 마이크로초 단위로 끝나는 극도로 저렴한 연산입니다.

여기에 useMemo를 도입하면 React는 의존성 배열을 비교하는 추가적인 연산 비용과 결괏값을 메모리에 유지하기 위한 메모리 오버헤드를 안게 됩니다. 배보다 배꼽이 더 커지는 셈입니다.

실수 2: 독립적인 자식에게 의미 없는 useCallback 주입하기

자식 컴포넌트가 일반 컴포넌트(React.memo 처리가 안 된 컴포넌트)라면, 부모가 프롭스로 넘겨주는 함수에아무리 useCallback을 감싸두어도 자식 컴포넌트는 부모 리렌더링 시 무조건 함께 리렌더링됩니다. 프롭스의 참조 유지 상태와 상관없이 리렌더링 트리거가 작동하기 때문입니다. 참조 무결성이 의미 있게 쓰이려면 받아주는 자식도 메모이제이션 컴포넌트여야 합니다.

4. 메모이제이션을 반드시 도입해야 하는 ‘골든 타임’

그렇다면 실제 현업에서는 언제 메모이제이션을 꺼내 들어야 가장 유용할까요? 딱 세 가지만 기억하시면 됩니다.

1) 대규모 루프나 정렬, 필터링 연산이 존재할 때

Math Blocks 프로젝트의 시험지 생성기처럼 랜덤으로 20개의 문항을 추출하고, 자릿수 분기를 처리하고, 음수 발생 방지 알고리즘을 거쳐 배열을 다이내믹하게 재가공하는 연산은 무거운 비용을 수반합니다. 이처럼 수백·수천 개의 데이터를 매핑하거나 정렬하는 로직은 무조건 useMemo의 대상이 됩니다.

2) 참조형 데이터(배열, 객체, 함수)를 자식의 의존성 배열로 전달할 때

React의 useEffect나 또 다른 useMemo 내부에서 참조형 데이터를 의존성 배열에 넣어야 할 때가 있습니다. 자바스크립트의 특성상 리렌더링마다 주소값이 바뀌므로 의존성 배열이 매번 변경되었다고 인식하여 무한 루프나 불필요한 이펙트 뷰 트리거가 터질 수 있습니다. 이때 useMemouseCallback으로 참조를 고정해 주어야 합니다.

3) 가상화 리스트나 대규모 DOM 구조를 가진 자식 컴포넌트

아이템이 수십, 수백 개에 달하는 리스트 내의 개별 아이템 컴포넌트나, 수많은 상태 조작 인터랙션이 발생하는 메인 대시보드 하위의 복잡한 차트/그래프 컴포넌트들은 React.memouseCallback을 결합하여 부모의 상태 변화 노이즈로부터 격리시켜 주는 것이 렌더링 프레임 방어(60fps)에 결정적인 기여를 합니다.

5. 맺으며: 무조건적인 최적화보다 더 중요한 것

프로그래밍 격언 중에 “조기 최적화(Premature Optimization)는 만악의 근원이다”라는 말이 있습니다. 애플리케이션의 구조를 처음 잡을 때부터 성능 강박에 사로잡혀 모든 함수와 컴포넌트에 메모이제이션 훅을 도배하면 코드의 가독성이 심각하게 떨어지고 디버깅 난이도가 올라갑니다.

올바른 접근법은 먼저 기능 구현을 깔끔하고 선언적인 코드로 완성하는 것입니다. 이후 크롬 개발자 도구의 React Profiler 탭을 열어 실제로 병목이 생기는 컴포넌트와 낭비되는 리렌더링 횟수를 명확하게 수치로 진단한 뒤, 병목 지점에 정확하게 핀포인트로 메모이제이션을 처방하는 것이 가장 건강한 최적화 프로세스입니다.

동작하는 서비스의 연산 비용과 메모리 비용의 저울질 사이에서 최적의 균형 감각을 찾기는 어려운 문제 같습니다.