들어가며
얼마전 기술면접 준비를 하면서 React에서의 메모이제이션에 대해 다시한번 짚어보게 되었습니다.
일반적으로 메모이제이션을 사용하는 것이 항상 좋지만은 않다고 알고 있지만, 정확히 왜 그러한지 좀 더 알아보고 싶어져서 포스팅을 해보려고 해요.
메모이제이션이란?
React에서 메모이제이션은 불필요한 재계산과 리렌더링을 방지하기 위해 존재하는 기술입니다.
이전 계산 결과를 캐싱한다음, 재사용을 하게 하는 매커니즘을 가지고있어요.
따라서 대규모 데이터 처리를 하는 경우에 성능 향상에 결정적인 역할을 할 수 있습니다.
메모이제이션의 여러 방법들
React에서 제공하는 메모이제이션의 대표적인 방법들은 여러가지가 있는데요.
하나씩 알아보겠습니다.
React.memo
먼저 React.memo 입니다.
const MemoizedComponent = React.memo(function MyComponent({ props }) {
/* 렌더링 로직 */
}, arePropsEqual?);
기본적으로 props를 비교해서 이전 props와 새로운 props가 동일한 경우 리렌더링을 건너뜁니다.
두 번째 매개변수인 arePropsEqual은 선택적으로 사용할 수 있는 비교 함수로, props의 비교 로직을 커스터마이징 할 수 있습니다.
특히 자주 리렌더링되는 컴포넌트이면서 동일한 props에 대해 동일한 결과를 렌더링하는 순수 컴포넌트의 경우에 유용합니다.
useMemo
다음으로는 useMemo입니다.
const sortedList = useMemo(() =>
largeArray.sort(complexSortLogic),
[largeArray]
);
위에서 언급한 React.memo가 컴포넌트를 최적화하는 거였다면, useMemo는 연산 값을 최적화하는 개념이에요.
의존성 배열 안에 있는 요소가 변경될 때에만 재계산을 하게 됩니다.
보통 복잡한 계산이나 무거운 연산을 수행할 때, 특히 대규모 배열의 정렬이나 필터링, 데이터 변환 작업을 수행할 때 사용됩니다.
또한 자식 컴포넌트에 전달되는 객체나 배열 타입의 props를 메모이제이션할 때도 유용합니다.
단, 간단한 연산의 경우 메모이제이션 자체의 비용이 더 클 수 있으므로 신중하게 사용해야 합니다.
useCallback
그리고 useCallback도 사용할 수 있어요.
const handleSubmit = useCallback(
() => apiCall(formData),
[formData]
);
보통 React 프로젝트에서는 컴포넌트가 리렌더링될 때마다 함수를 재생성하게 되는데요.
이러면 자식 컴포넌트에 함수를 props로 전달할 때 불필요한 리렌더링이 발생할 수 있기 때문에, useCallback을 적용함으로써 컴포넌트 리렌더링 시에 동일 함수를 참조할 수 있도록 해요.
어떤식으로, 어디에 저장하는 걸까?
React의 메모이제이션은 컴포넌트의 렌더링 사이클 동안 메모리에 저장되는데요.
📌 렌더링 사이클이란?
렌더링 사이클은 React 컴포넌트가 화면에 그려지는 전체 과정을 의미합니다.
크게 다음 단계로 이루어진다고 해요.
- Render Phase: 컴포넌트의 변경사항을 계산하는 단계
- Commit Phase: 실제 DOM에 변경사항을 적용하는 단계
- Cleanup Phase: 이전 렌더링의 효과를 정리하는 단계 </aside>
여기서 말하는 메모리는 React가 사용하는 JavaScript 힙 메모리입니다.
React 애플리케이션이 실행되는 동안 사용할 수 있는 동적 메모리 영역이라고 할 수 있어요.
useMemo를 적용한 예시를 한번 보겠습니다.
function ExpensiveComponent({ data }) {
const memoizedValue = useMemo(() => {
return heavyCalculation(data);
}, [data]);
return <div>{memoizedValue}</div>;
}
위 예시에서 ExpensiveComponent가 처음 렌더링되면, React는 Fiber 노드라는 내부 데이터 구조에 값을 메모이제이션합니다.
📌 Fiber 노드란?
Fiber 노드는 React 16에서 도입된 새로운 재조정(reconciliation) 엔진의 작업 단위이며, React가 실행되는 JavaScript 힙 메모리 내에 저장됩니다.
Fiber 노드는 각 컴포넌트에 대해 다음과 같은 정보를 포함해요.
- 컴포넌트의 상태
- 메모이제이션된 값들
- 작업 우선순위
- 업데이트 큐
아래는 Fiber 노드의 간단화된 버전으로 나타내본 코드입니다.
type Fiber = { type: any, key: null | string, stateNode: any, child: Fiber | null, sibling: Fiber | null, return: Fiber | null, memoizedState: any, // ... }
이 값들은 해당하는 컴포넌트 인스턴스와 연결되어있으며, 이 값들은 컴포넌트가 언마운트될 때까지는 메모리에 유지됩니다.
📌 컴포넌트 인스턴스랑 어떤식으로 연결되어있는걸까?
각 Fiber 노드는 해당 컴포넌트 인스턴스에 대한 참조를 stateNode 속성에 저장합니다.
메모이제이션된 값은 memoizedState 속성에 연결 리스트 형태로 저장됩니다.
이전에 마주했던 데이터인지 어떻게 알아볼까?
그러면 React는 메모이제이션된 값인지, 어떻게 식별할까요?
사실 위에서 메모이제이션의 여러 방식들에 대해 설명하면서 살짝 언급했지만, 조금 더 구체적으로 알아보겠습니다.
먼저 useMemo와 useCallback와 같은 경우에는 의존성 배열을 비교하는 식으로 이루어집니다.
Object.is() 비교를 통해서 동일한 의존성 배열 값인지 체크를 하고, 모든 의존성이 이전과 같다면 저장된 값을 재사용하는 식이죠.
📌 Object.is() 란?
Object.is()는 JavaScript에서 제공하는 비교 연산자로, 두 값이 같은지 비교합니다. ===와 비슷하지만 다음과 같은 차이가 있습니다.
// === 와의 차이점 Object.is(0, -0) // false 0 === -0 // true Object.is(NaN, NaN) // true NaN === NaN // false
React.memo의 경우는 조금 다른데요.
컴포넌트의 props를 얕은 비교로 수행하게 됩니다.
📌 얕은 비교(Shallow Compare)란?
얕은 비교는 객체의 첫 번째 레벨에서만 값을 비교하는 방식입니다.
기본적으로, JavaScript에서 객체와 함수는 참조 타입입니다.
// 원시 타입(primitive type)의 비교 const number1 = 42; const number2 = 42; console.log(number1 === number2); // true // 객체의 비교 const obj1 = { name: 'John' }; const obj2 = { name: 'John' }; console.log(obj1 === obj2); // false // 함수의 비교 const func1 = () => {}; const func2 = () => {}; console.log(func1 === func2); // false
위 예시에서 볼 수 있듯이, 같은 내용의 객체나 함수더라도 새로 생성을 하면 다른 메모리 주소를 참조하기 때문에 === 로 비교하면 다르다고 판단해요.
<MemoizedComponent user={{ name: 'John' }} onUpdate={() => {}} />
따라서 위와 같은 컴포넌트가 있다고 가정했을 때, { name: 'John' } 은 렌더링을 할 때마다 다른 메모리 주소를 차지하게 될거예요.
반면에 얕은 비교는 객체의 ‘첫 번째 레벨’의 값만 비교를 합니다.
const obj1 = { name: 'John', age: 30, address: { city: 'Seoul', country: 'Korea' } }; const obj2 = { name: 'John', age: 30, address: { city: 'Seoul', country: 'Korea' } };
위와 같은 상황에서 첫 번째 레벨에 해당하는 name, age를 직접 비교하게 돼요.
하지만 city, country는 첫 번째 레벨에 해당하지 않아서 비교하지 않습니다.
address와 같은 객체는 내부에 값이 있긴 하지만, 참조값만 비교합니다.
이러한 특성 때문에 주의해야하는 점이 있는데, 이어서 구체적으로 설명하겠습니다.
React.memo 사용할 때에 주의해야하는 점
얕은 비교는 아래와 같이 실행됩니다.
원시값(string, number, boolean 등)인 경우
→ 값 자체를 비교함
객체나 배열 같은 참조형 값인 경우
→ 메모리 주소(참조값)을 비교함
따라서 객체 자체를 props 로 전달하면 매번 새로운 객체를 생성하는 꼴이고, 이 객체들은 참조값이 다르기 때문에 React.memo는 ‘props가 변경되었구나’ 라고 판단을 해버립니다.
그러면 원치 않는 리렌더링이 발생하겠죠.
// 잘못된 사용 예시
function ParentComponent() {
return (
<MemoizedComponent
user={{ name: 'John' }} // 매번 새로운 메모리 주소가 할당됨
/>
);
}
// 올바른 사용 예시
function ParentComponent() {
// useMemo를 사용하여 동일한 메모리 주소 유지
const user = useMemo(() => ({ name: 'John' }), []);
return (
<MemoizedComponent
user={user} // 항상 같은 메모리 주소를 참조
/>
);
}
따라서 객체나 배열 타입의 props인 경우에는 useMemo나 useCallback로 메모이제이션을 해야 똑같은 참조값을 유지할 수 있습니다.
메모이제이션을 하는게 항상 좋을까?
캐싱된 값을 재사용한다는 점에서, 개인적으로 항상 이득일 것처럼 느껴집니다.
하지만 모든 메모이제이션이 되는 값들은 메모리에 저장이 되는데, 결국 메모리라는 리소스를 사용하는 것이기 때문에 무분별하게 사용하면 메모리 사용량이 너무 많이 증가할 수 있다고 해요.
📌 메모리 사용량이 급증하는게 구체적으로 왜 안좋을까?
메모리 사용량이 급증하면 다음과 같은 문제가 발생할 수 있어요.
- 가비지 컬렉션 빈도 증가로 인한 성능 저하
- 브라우저의 전반적인 반응성 저하
- 특히 모바일 기기에서 심각한 성능 문제 발생
또한 아주 간단한 계산임에도 메모이제이션을 해버린다면, 오히려 간단한 연산을 재계산하는 것보다 메모이제이션을 하는 비용이 더 들 수도 있겠죠.
계산이 무거운지 어떻게 알 수 있을까?
메모이제이션이 필요한지 판단하기 위해서는 실제로 해당 계산이 얼마나 무거운지 측정해볼 필요가 있습니다.
일반적으로 수천 개의 객체를 생성하거나 반복하지 않는 계산이라면, 그리 무겁지 않을 가능성이 높습니다.
하지만 정확히 알고 싶다면 다음과 같이 console.time을 사용해서 측정해볼 수 있어요.
// 측정하고 싶은 계산 앞뒤로 console.time 추가
console.time('계산 시간 측정');
const visibleTodos = filterTodos(todos, tab);
console.timeEnd('계산 시간 측정');
이렇게 하면 콘솔에 실제 걸린 시간이 출력됩니다. (예: 계산 시간 측정: 0.15ms)
React 공식문서에 따르면, 보통 전체 계산 시간이 1ms 이상 걸린다면 메모이제이션을 고려해볼 만 하다고합니다.
실험적으로 useMemo를 적용해보고 시간이 얼마나 단축되는지 비교해볼 수도 있어요.
console.time('메모이제이션 적용');
const visibleTodos = useMemo(() => {
return filterTodos(todos, tab);// todos와 tab이 변경되지 않았다면 이 계산을 건너뜀
}, [todos, tab]);
console.timeEnd('메모이제이션 적용');
하지만 개발자의 컴퓨터는 일반적으로 사용자의 기기보다 성능이 좋기 때문에, Chrome의 CPU Throttling 같은 기능을 사용해 인위적으로 성능을 낮춰서 테스트해보는 것이 좋다고합니다.
맺음말
처음에는 기술 면접 준비를 하면서 React의 메모이제이션에 대해 정리해보고자 시작한 글이었는데, 글을 쓰다 보니 생각보다 더 많은 것들을 공부하게 되었네요.
메모이제이션이라는 주제로 시작했지만, JavaScript의 참조 타입과 얕은 비교, Object.is()와 같은 JavaScript의 기본적인 개념들도 함께 정리하게 되었고, React의 내부 동작 방식인 Fiber 노드를 접하게 되면서 더 깊이 공부해보고 싶다는 생각이 들었습니다.
특히 Fiber 노드는 React의 핵심적인 동작 방식과 관련이 있는 것 같은데, 이번 포스팅에서는 깊이 있게 다루지 못했네요.
다음에는 Fiber 아키텍처에 대해서도 한번 자세히 파헤쳐보는 포스팅을 써봐야겠습니다.
결국 하나의 개념을 제대로 이해하기 위해서는 관련된 여러 기초 개념들을 함께 알아야 한다는 걸 새삼 깨닫게 되었습니다.
앞으로도 다른 React 개념들을 공부하면서, 이런 식으로 기초부터 차근차근 정리해보는 시간을 가져보려고 해요.
ref: