리액트를 배우다 보면 리렌더라는 말을 무슨 큰일 나는 사고처럼 여기게 된다. 리렌더가 일어났다 하면 화면이 느려지고 앱이 버벅인다고 지레 겁을 먹는 것이다. 그래서 배운 지 얼마 안 된 사람이 React.memo니 useCallback이니 하는 걸 온 컴포넌트에 도배하는 광경을 나는 여러 번 봤다. 정작 리렌더가 뭔지도 모른 채로 말이다. 결론부터 말하면 리렌더는 대부분 싸다. 문제는 리렌더 자체가 아니라, 안 해도 될 리렌더가 눈에 띄게 쌓일 때다. 그걸 가려내려면 먼저 리렌더가 언제, 왜 일어나는지부터 정확히 알아야 한다.
리렌더. 컴포넌트 함수를 다시 실행하는 것뿐이다. 리액트에서 컴포넌트는 결국 함수다. 리렌더란 그 함수를 리액트가 한 번 더 호출하는 것을 말한다. 함수가 다시 돌면서 새 결과물(화면 설계도)을 만들고, 리액트는 그걸 직전 결과와 비교해 실제로 바뀐 부분만 골라 진짜 DOM에 반영한다. 여기서 오해가 갈린다. 함수가 다시 불렸다고 해서 화면 전체가 처음부터 다시 그려지는 게 아니다. 함수 실행은 값싼 자바스크립트 연산이고, 정작 비싼 건 그 뒤의 DOM 조작인데 리액트는 바뀐 것만 건드린다. 그러니 리렌더 한 번을 무조건 낭비로 볼 이유가 없다.
리렌더는 두 경우에 일어난다. 상태가 바뀌거나, 부모가 다시 그려지거나. 자기 state나 props가 바뀌면 그 컴포넌트는 다시 그려진다. 여기까진 다들 예상한다. 함정은 두 번째다. 부모가 리렌더되면 그 아래 자식들은 자기 값이 하나도 안 바뀌었어도 전부 따라서 다시 그려진다. 이게 초보가 못 보는 흐름이다. 간단한 예로 확인해 보자.
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Child />
</div>
);
}
function Child() {
return <p>나는 아무 상태도 안 쓴다</p>;
}
버튼을 누르면 count가 바뀌고 Parent가 리렌더된다. 그런데 Child는 count를 쓰지도 않고 넘겨받은 props도 없는데, 부모가 다시 돌았다는 이유만으로 같이 리렌더된다. 버튼을 누를 때마다 Child 함수가 꼬박꼬박 다시 불리는 것이다. 자식이 가벼우면 이건 아무 문제도 아니다. 하지만 자식이 무거운 목록이거나 수백 개라면 이야기가 달라진다.
React.memo. props가 그대로면 자식 렌더를 건너뛴다. 부모가 다시 그려져도 자식에게 넘어가는 props가 이전과 같다면, 굳이 자식을 다시 그릴 필요가 없다. React.memo로 컴포넌트를 감싸면 리액트가 이걸 알아서 판단한다. 넘어온 props를 직전 것과 비교해서, 같으면 렌더를 통째로 건너뛴다.
const Child = React.memo(function Child() {
return <p>나는 아무 상태도 안 쓴다</p>;
});
이제 버튼을 눌러 count가 바뀌어도, Child는 넘어온 props가 없으니 비교 결과가 늘 같고 렌더를 건너뛴다. Parent만 다시 그려지고 Child 함수는 다시 불리지 않는다. 여기까지 보면 만능처럼 보인다. 그런데 이 memo는 어이없을 만큼 쉽게 무력화된다.
함수와 객체를 props로 넘기면 memo가 도로 풀린다. 자바스크립트에서 객체와 함수는 내용이 같아도 새로 만들면 서로 다른 값으로 취급된다. {} === {}는 false다. 문제는 컴포넌트가 리렌더될 때마다 본문의 함수 리터럴과 객체 리터럴이 매번 새로 생성된다는 점이다. 그래서 아래처럼 넘기면 memo가 있으나 마나다.
<Child onClick={() => console.log("hi")} style={{ color: "red" }} />
Parent가 리렌더될 때마다 이 화살표 함수와 { color: "red" } 객체는 새 주소를 가진 새 값으로 다시 만들어진다. memo가 이전 props와 비교하면 "함수가 바뀌었네, 객체가 바뀌었네" 하고 판단해 렌더를 건너뛰지 못한다. 나는 이걸 모르고 memo를 씌워놓고 왜 여전히 다시 그려지냐며 한참을 노려봤다. 범인은 부모가 매 렌더마다 새로 찍어내던 콜백 함수였다.
useCallback. 함수의 정체성을 렌더 사이에 붙들어둔다. 이 문제를 풀려면 함수가 렌더마다 새로 만들어지지 않게, 같은 함수를 계속 재사용하도록 붙들어야 한다. 그 도구가 useCallback이다. 두 번째 인자인 의존성 배열에 든 값이 바뀌지 않는 한, 처음 만든 함수를 그대로 돌려준다.
const handleClick = useCallback(() => {
console.log("hi");
}, []);
의존성 배열이 []라 이 함수는 처음 한 번만 만들어지고, 이후 리렌더에서도 같은 함수가 넘어간다. 이제 이 handleClick을 memo가 씌워진 자식에게 넘기면, props가 진짜로 안 바뀌었으니 렌더를 제대로 건너뛴다. 주의할 건 useCallback은 memo된 자식에게 함수를 넘길 때라야 의미가 있다는 점이다. 그냥 아무 함수에나 씌우면 붙드는 비용만 들고 얻는 건 없다.
useMemo. 비싼 계산을 다시 하지 않게 값을 기억한다. useCallback이 함수를 붙든다면, useMemo는 계산 결과를 붙든다. 렌더할 때마다 큰 배열을 정렬하거나 걸러내는 무거운 연산이 있다면, 입력이 그대로일 때 그 결과를 다시 계산하지 않고 기억해 둔 값을 쓴다.
const sorted = useMemo(() => {
return items.slice().sort((a, b) => a.price - b.price);
}, [items]);
items가 바뀌지 않았다면, 다른 상태가 바뀌어 리렌더가 일어나도 정렬을 다시 돌리지 않고 지난번 결과를 그대로 내놓는다. 만 개짜리 목록을 매 렌더마다 정렬하던 걸 이걸로 막을 수 있다. 덤으로 useMemo가 돌려준 배열은 참조가 고정되므로, 그 배열을 memo된 자식에게 props로 넘길 때도 도움이 된다.
리액트 18은 상태 갱신을 알아서 묶는다. 우리가 손으로 최적화하기 전에, 리액트가 이미 낭비를 상당히 줄여준다는 것도 알아둘 만하다. 한 함수 안에서 setState를 여러 번 불러도 리액트 18은 그걸 모아 한 번만 리렌더한다. 이걸 자동 배칭(여러 상태 변경을 한 번의 렌더로 묶는 것)이라 한다.
function handleClick() {
setCount(c => c + 1);
setName("kim");
setActive(true);
}
상태를 세 번 바꿨지만 화면은 세 번이 아니라 한 번만 다시 그려진다. 예전 버전은 이벤트 핸들러 안에서만 이렇게 묶고, 프로미스나 setTimeout 안에서 부른 갱신은 각각 따로 리렌더했다. 18부터는 그런 경우까지 전부 묶는다. 즉 상태를 여러 개 나눠 관리한다고 리렌더가 그 수만큼 늘어날까 걱정할 필요가 없다는 뜻이다.
언제 쓰지 말아야 하나. 이게 더 중요하다. 여기까지 읽고 세 도구를 전부 도배하고 싶어졌다면, 잠깐 멈추자. memo도 useMemo도 useCallback도 공짜가 아니다. 매 렌더마다 props를 비교하고 의존성 배열을 대조하는 비용, 기억해 둔 값을 붙들고 있는 메모리가 든다. 대부분의 컴포넌트는 리렌더가 워낙 싸서, 이 도구들을 씌우면 오히려 비교 비용만 늘고 손해다. 순서는 이렇다. 먼저 그냥 만든다. 앱이 실제로 버벅이면 리액트 개발자 도구의 프로파일러(렌더 시간을 재는 기능)로 어디가 느린지 측정한다. 그 지점이 명확할 때만 그 자리에 최적화를 얹는다. 감으로 미리 바르는 최적화는 대개 코드만 지저분하게 만들고 속도엔 아무 보탬이 안 된다. 리렌더를 무서워하지 말고, 안 해도 될 리렌더가 진짜로 문제가 될 때 그때 잡으면 된다.