React
는 컴포넌트가 값의 변화를 감지하고 알아서 리렌더링된다는 컨셉을 가지고 있다.
다시 말해 값이 갱신되었을 때 자동으로 화면도 갱신되도록 한다는 것인데, 이를 통해 우리가 값의 갱신
과 DOM의 갱신
을 각각 다루지 않아도 되게 해준다. 이러한 편의성과 함수형 패턴
을 통한 선언적 프로그래밍
이 가능하게 하도록 한 점들은 React
가 가장 인기있는 프론트엔드 렌더링 도구가 되도록 만들어줬다.
하지만 "값이 갱신되면 자동으로 리렌더링"된다는 React
가 가진 강점이 반대로 React
로 만들어진 페이지들의 성능을 저하시킬 때가 있다.
바로 의도하지 않은 값의 갱신
이 일어나는 경우이다.
의도하지 않은 값의 갱신
은 크게 두 가지를 꼽을 수 있는데, 의도하지 않은 리렌더링
과 의도하지 않은 재할당
이라고 이야기할 수 있을 것 같다.
React
컴포넌트는 그 컴포넌트를 포함하고 있는 부모가 리렌더링되거나, 그 컴포넌트가 의존하고 있는 state
또는 props
가 변경되었을 때 리렌더링된다. 즉 React
컴포넌트의 렌더링은 그 컴포넌트의 상태와 부모 컴포넌트에 의존성을 갖는다.
나는 이러한 React
환경에서 개발자가 예상하지 못한 의존성의 변경으로 인하여 컴포넌트가 리렌더링되는 현상을 의도하지 않은 리렌더링
이라 부른다.
React
컴포넌트는 JavaScript
함수이며, 곧 객체이다. 따라서 React
컴포넌트 내부에 작성된 코드는 컴포넌트가 호출될 때마다 새로이 호출된다.
const Component = () => {
const maVar = 'Hello, World!';
return <div>{myVar}</div>;
};
위와 같은 컴포넌트가 렌더링되어 있는데, 어떤 이유로 인하여 리렌더링될 때를 생각해보자. 함수 코드 블럭이 그대로 새로 호출되면서, myVar
는 새로운 메모리 영역에서 선언될 것이다.
지금이야 아주 작은 string 값
이니까 상관이 없겠지만, 만약에 저 변수가 엄청나게 긴 연산의 결괏값이라면 어떻게 될까? 당연히 컴포넌트가 리렌더링될 때마다 그 연산을 새로이 하게 될 것이다.
컴포넌트의 리렌더링은 의도되었을 수 있지만, 그 안의 모든 값들이 새로 할당되고 연산이 새로 이루어지는 것은 의도되지 않을 때가 많다. 나는 이것을 의도하지 않은 재할당
이라 부른다.
일반적인 경우에 React
의 편의성을 느껴보기 위한 작은 사이즈의 프로젝트에서는 이러한 현상이 치명적이지 못하다고 느낀다. 하지만 위의 두 현상은 심각할 경우 서로를 재귀호출
하는 구조로 발생하여 많은 불필요한 연산을 발생시키고, 우리가 만든 앱이 움직이지 않도록 만들 수도 있다.
특정 컴포넌트가 렌더링되면서 아주 긴 비동기 연산이 시작되고, 그 연산이 끝나면서 컴포넌트의 상태가 갱신되어 컴포넌트가 리렌더링되고, 다시 컴포넌트가 렌더링되면서 또 그 끔찍한 연산이 시작되고, ... 하는 구조를 생각해볼 수 있겠다.
해결하기 위한 이상적인 방향을 생각해보자.
만약 모든 자식을 가진 컴포넌트의 렌더링과 상태값의 관리를 완벽하게 할 수 있다면, 의도하지 않은 리렌더링
은 발생하지 않을 것이다.
일반적인 프로젝트에서 상태값과 컴포넌트는 수 십, 많으면 수 천 개에 달한다. 또한 그 사이에서 생겨나는 의존성의 개수는 그보다 훨씬 많을 수 있으며, 이렇게 수많은 논리적 흐름을 단번에 파악하고 모든 렌더링과 상태값의 관리를 완벽하게 하는 것은 불가능에 가깝다.
또한 이렇게 리렌더링을 불가능에 가까운 방법
으로 모두 의도하여 발생시킨다고 하더라도, 리렌더링이 일어났을 때 그 안의 값들이 휘발되어 새로 계산되어야 하는 의도하지 않은 값의 재할당을 막을 방법은 되지 않는다.
따라서 우리는 이러한 현상들을 해결할 또다른 방법을 모색해야 하는데, 다행히도 React에서 이미 이러한 가이드를 탄탄하게 제공하고 있다. 바로 메모이제이션(Memoization)
이다.
메모이제이션
은 컴퓨터 공학에서 사용되는 용어로 특정 연산 결과를 메모(Memoize)
하여 다시 그 연산을 호출하였을 때, 연산 과정을 거치지 않고 그 연산 결과만을 반환하도록 하는 일종의 캐싱(caching)
기법이다.
또한 알고리즘에서도 등장하는 개념인데, 메모이제이션
은 특정 연산을 하기 위해서 그 전 연산을 거쳐야 값을 도출해낼 수 있는 형태의 연산에 대해 연산의 시간 복잡도를 공간 복잡도로 trade off하는 다이나믹 프로그래밍(Dynamic Programming)
의 핵심 기법이다.
React
는 앞서 소개한 두 가지 의도하지 않은 값의 갱신들에 대하여 메모이제이션
을 통한 극복을 제안한다. 간단하게 설명해보자면 아래와 같다.
먼저 컴포넌트가 렌더링되었을 때, 그 안에서 선언된 특정 값의 참조
를 컴포넌트 외부에서 붙잡는다.
그리고 컴포넌트가 리렌더링되면서 다시 그 안의 값이 호출되면, 앞서서 붙잡아뒀던 값의 참조
를 반환하여 컴포넌트는 변경되어도 내부 값의 참조는 유지되도록 하는 것이다. 실제 패턴과는 차이가 있지만 코드로 보자면 아래와 같다.
const myVar = 'Hello, World!';
const Component = () => {
return <div>{myVar}</div>;
};
위의 일관된 방식을 통해서 우리는 앞서 이야기했던 두 가지 유형에 대하여 모두 대응할 수 있다.
먼저 의도하지 않은 리렌더링
은, 부모 컴포넌트가 리렌더링되기 전에 자식 컴포넌트를 메모이제이션
하고, 부모 컴포넌트가 리렌더링되어도 앞서 메모이제이션
해둔 참조값으로 자식 컴포넌트를 유지시키게 한다.
그리고 의도하지 않은 재할당
은, 컴포넌트가 리렌더링되기 전에 내부 값을 메모이제이션하고, 컴포넌트가 리렌더링되어도 앞서 메모이제이션
해둔 참조값으로 내부 값을 유지시키게 한다.
React
는 이러한 두 가지 메모이제이션 유형에 대하여 아래와 같은 API
들을 각각 제공한다.
React.memo()
는 컴포넌트를 파라미터로 입력받아, 컴포넌트를 반환하는 함수이다. ⛓️React.memo
React.memo()
로 반환된 컴포넌트는 렌더링 결과를 메모이제이션
하여 재호출되었을 때 중 props
가 변경되지 않은 경우에는 메모이제이션
된 렌더링 결과를 반환하도록 한다. 동작을 제대로 이해하기 위해서 예제를 한 번 살펴보자.
const Parent = () => {
const [, rerender] = useState({});
const [myState, setMyState] = useState('Hello, World!');
return (
<div>
<Child state={myState}/>
<button onClick={() => rerender({})}>
rerender
</button>
</div>
)
};
const Child = ({state}) => {
console.count('rerender');
return <div>{state}</div>;
};
위의 코드에서 렌더링된 Parent
는 rerender 버튼
을 누를 때마다 값의 재할당으로 인해 리렌더링된다. 그로 인하여 그 자식 컴포넌트도 리렌더링되고, 자식 컴포넌트 내부의 console
문이 버튼을 클릭한 횟수만큼 호출되어 카운트를 증가시킨다.
이 경우 부모 컴포넌트가 리렌더링될 때마다 자식 컴포넌트를 리렌더링시키기 때문에, 자식 컴포넌트가 포함하고 있는 렌더링 로직이 복잡하고, 리렌더링마다 로직을 실행할 필요가 없는 경우 렌더링 로직의 처리 시간만큼 불필요한 연산 속도가 반복적으로 발생한다.
이럴 때 우리는 React.memo()
를 적용하여 렌더링 결과를 메모이제이션
할 수 있다.
const Parent = () => {
const [, rerender] = useState({});
const [myState, setMyState] = useState('Hello, World!');
return (
<div>
<MemoizedChild state={myState}/>
<button onClick={() => rerender({})}>
rerender
</button>
</div>
)
};
const Child = ({state}) => {
console.count('rerender');
return <div>{state}</div>;
};
const MemoizedChild = React.memo(Child)
Child
를 React.memo()
의 파라미터로 입력하여 반환된 MemoizedChild
라는 컴포넌트로 Parent
의 렌더링부에서 호출해주었다. 이 결과 MemoizedChild
는 rerender 버튼
의 onclick 이벤트
로는 리렌더링되지 않게 된다. rerender 버튼
을 눌러서 발생하는 부모의 리렌더링이 MemoizedChild
가 전달받는 prop
인 state
가 변경되도록 하지 않기 때문이다.
원래 React
에서는 아래 두 가지 이유로 컴포넌트 리렌더링이 발생한다.
1. 컴포넌트 내부 state 혹은 전달받는 props의 갱신
2. 부모 컴포넌트의 리렌더링
React.memo()
는 위에서 살펴봤던 것처럼 이 중에서 2번, 부모 컴포넌트의 리렌더링에 대한 렌더링 의존성을 제거해준다.
물론 1번 케이스의 리렌더링 의존성은 그대로 유지된다. 이러한 이유로 React.memo()
가 의도한 대로 동작하지 않는 경우가 간혹 있다. 부모 컴포넌트의 리렌더링 로직이 Memoized Component
에 전달되는 props
이 변경되도록 하거나, Memoized Component
내부의 state
를 변경되도록 하는 경우이다.
부모 컴포넌트의 리렌더링 자체의 의존성(2)을 제거해도, 부모 컴포넌트의 리렌더링으로 인해 props
가 갱신되고, 갱신된 props
로 리렌더링되는 경우(1)인 것이다.
일반적으로 부모 컴포넌트에서 내려주는 props
는 부모 컴포넌트가 리렌더링될 경우 그 참조값이 갱신되기 때문에, Memoized Component
는 부모 컴포넌트에게 부모 컴포넌트의 리렌더링으로 변경되는 props
를 전달받지 않는 순수 컴포넌트
일 경우에 그 의도가 명확히 반영된다.
⛓️ 컴포넌트 순수하게 유지하기
정리하자면, React.memo()
는 순수 컴포넌트를 메모이제이션
하여 부모의 리렌더링에도 리렌더링되지 않는 컴포넌트를 만드는 함수인 것이다.
이번에는 의도하지 않은 재할당을 막아보자. 컴포넌트 내부에서 재할당되는 객체들은 다양하다. 그 대상이 값을 담은 변수일 수도 있고, 함수일 수도 있을 것이다. 그래서 React
에서는 변수와 함수에 대해서 각각 useMemo()
, useCallback()
이라는 메모이제이션
훅을 제공한다.
둘은 거의 같은 형태로 동작한다. 컴포넌트 내부에 useMemo
혹은 useCallback
을 통해 선언된 변수 혹은 함수는, 컴포넌트 마운트시에 그 내용을 연산하여 메모이제이션
하고 리렌더링시에 메모이제이션
된 변수 혹은 함수를 반환하도록 한다. 각 훅은 두 번째 파라미터로 의존성 배열을 입력받고, 그 의존성 배열 내부의 값이 변경되었을 때 새롭게 연산을 수행하여 메모이제이션
을 최신화하도록 한다.
간단한 예제를 통해 useMemo
의 작동 방식을 살펴보자.
const Parent = () => {
const [, rerender] = useState({});
return (
<div>
<Child />
<button
onClick={() => {
console.log("rerender");
rerender({});
}}
>
rerender
</button>
</div>
);
};
const Child = () => {
const start = performance.now(); // 측정 시작
const myVar = (() => {
let a = 0;
for (let i = 0; i < 100000000; i++) a++;
return a;
})();
const end = performance.now(); // 측정 종료
console.log(`time: ${end - start}`); // 실행 시간 출력
return <div>Hello, World!</div>;
};
이전 예제와 거의 비슷한 코드이다. 다만 Child
컴포넌트 내에 컴포넌트가 렌더링될 때마다 그 내부에 특정 값을 반환하는 익명 함수를 호출시켰고, 컴포넌트 렌더링에 소요된 시간을 콘솔에 출력하도록 수정하였다.
Child
는 렌더링마다 myVar
를 계산하기 위한 함수 때문에 렌더링 병목을 발생시킬 것이다.
rerender 버튼
을 누를 때마다 100ms
에 가까운 연산 시간을 필요로 하여 리렌더링되었다는 것을 콘솔에서 확인할 수 있다. 이제 useMemo
를 활용해서 이러한 렌더링 병목을 해결해보자.
const Parent = () => {
const [, rerender] = useState({});
return (
<div>
<Child />
<button
onClick={() => {
console.log("rerender");
rerender({});
}}
>
rerender
</button>
</div>
);
};
const Child = () => {
const start = performance.now(); // 측정 시작
const myVar = useMemo(() => {
let a = 0;
for (let i = 0; i < 100000000; i++) a++;
return a;
}, []);
const end = performance.now(); // 측정 종료
console.log(`time: ${end - start}`); // 실행 시간 출력
return <div>Hello, World!</div>;
};
myVar
에 값을 할당해주는 익명 함수
를 useMemo
로 Wrapping
하여 메모이제이션
해주었다.
Child
는 마운트하면서 myVar
의 값을 할당해주는 콜백함수를 메모이제이션
한다. 그리고 부모의 리렌더링으로 인해서 Child
가 리렌더링되어도, myVar
의 콜백함수를 재실행하여 그 값을 새로 할당하는 것이 아니라, 앞서 메모이제이션
한 값을 그대로 불러와 할당한다.
그 결과 첫 렌더링에서는 앞서와 같이 100ms
에 가까운 연산 시간을 필요로 했지만, rerender 버튼
을 눌러서 발생한 컴포넌트 리렌더링에서는 로그는 출력이 되지만 useMemo
내부 로직이 재실행되지 않고 메모이제이션
된 결괏값을 지속적으로 참조하여, 연산 시간이 0으로 줄어든 것을 확인할 수 있다.
예제에는 없지만 useCallback
의 경우 같은 메모이제이션을 함수에 대해서 수행한다. useMemo
는 콜백함수의 반환값을 메모이제이션
하지만, useCallback
은 콜백함수를 그대로 메모이제이션
한다고 보면 된다. useCallback
은 함수를 생성하는 것 자체에 큰 연산이 필요한 경우에 사용할 수도 있고, 의존성 배열을 토대로 함수의 갱신을 원하는 때에만 하도록 하여 메모이제이션
시점별로 상태를 상수화한 콜백함수를 활용하는 데에도 사용할 수 있을 것이다.
앞서 얘기했던 것처럼 렌더링 의존성
은 한 프로젝트 내에서도 수 백, 수 천 가닥의 논리적 인과로 엮여있고, 메모이제이션
은 이 한 가닥 한 가닥을 컨트롤하는 거기 때문에 잘못된 메모이제이션
활용은 버그로 이어질 확률이 높다. 따라서 우리는 적절한 타이밍에만 메모이제이션
을 적용하여야 한다.
하지만 메모이제이션
을 필요한 곳에만 활용하고, 적절하게 적용하는 것은 쉬운 일이 아니다.
특히 필요한 부분에서만 메모이제이션
을 사용하는 것은, 코드 레벨에서 메모이제이션
이 적용되어 있는 곳과 적용되지 않은 곳의 구분에 대해 일관성을 깨뜨리는 것처럼 보이기도 한다.
그냥 모든 곳에 메모이제이션
을 하면 안되나..?
클래스형 컴포넌트
패턴으로 개발할 경우, 모르면 그냥 PureComponent
로 선언해라.라는 말이 있었다. 요즘에는 메모이제이션
도 이런 얘기가 많다.
모르면 그냥 useMemo
, useCallback
으로 선언해라.
과연 어떨까? 그냥 무지성 메모이제이션
이 렌더링 성능 저하를 방지하는 간결하면서 효과적인 방법으로 활용될 수 있을까?
개발자가 원하는 부분만 리렌더링되도록 해야한다.는 최종 목표에는 두 가지 도달 방법이 있다.
A. 모든 것이 리렌더링되도록 하고 필요한 부분에서 리렌더링이 되지 않도록 하는 것과,
B. 모든 것이 리렌더링되지 않도록 하고 필요한 부분에서 리렌더링이 되도록 하는 것.
이 두 가지 중 어느 방향으로 문제를 해결할 것이냐는 선택사항으로 볼 수 있다. 다만 나는 React
의 설계 의도를 유지하는 방식이 A에 가깝다고 느낀다.
어떤 방향을 선택하느냐에 관련 없이, 우리가 도달해야하는 결론은 쉽지만 어렵다.
필요할 때만 적절하게 메모이제이션
을 적용하자.