03. 렌더링 최적화(1) Memoization을 통한 리렌더링 최소화
0. Performace / React-Profiler를 통한 퍼포먼스 확인
열심히 기능을 구현했지만 뭔가 반응이 살짝 느려서 리액트 개발자 툴인 profiler로 확인을 해보았다.
마우스를 옮길 때 마다 자그마치 400ms가 넘는 미친 반응속도가 나오고 있었다. 물론 npm run start로 실행하고 있다고는 하지만 너무나도 심각한 수준이었다. 조금 더 자세히 살펴보자, 그 이유를 알 것도 같았다. (사실 예상은 했었지만 이정도일줄은 몰랐다.)
180 x 90. 그러니까 만 육천개가 넘는 component들이 마우스가 이동 할 때 마다, 조금 더 정확하게는 마우스가 가리키는 나라가 변할 때 마다 모두다 새로 렌더링 되고 있었다...!
마우스 호버링 이벤트가 일어나면 상위 컴포넌트인 WorldMap의 state인 country가 바뀌고, state가 바뀌면 다시 리렌더링이 일어나면서 모든 하위 Grid 컴포넌트들 다시 렌더링이 되고 있던 것이다.
다시 렌더링 될 필요가 없는 컴포넌트들마저 다시 그리고 있으니 느릴 수 밖에 없었다. 해당 UX를 개선하기 위해 정말 다양한 방법들을 시도했다.
1. React Memo 사용하기
사실 country state가 변경되었을 때, 수 많은 컴포넌트들 중 업데이트(리렌더링) 되어야 할 컴포넌트들은 1. 기존에 하이라이트 되어있던 나라, 2. 새롭게 하이라이트 되어야 할 나라 두 나라에 해당하는 Grid들 뿐이다. 그렇다면 과연 어떻게 꼭 다시 렌더링을 해야할 필요가 있는 컴포넌트들만 리렌더링을 시킬 수 있을까? 바로 memoization을 활용 하는 것이다. memeoization이란 동일한 결과값을 가지는 함수를 재 사용할 때 다시 계산하지 않기 위해 캐싱해 두고 캐싱된 데이터를 읽어오는 기법을 말한다.
react에서는 함수형 컴포넌트를 메모이제이션 할 수 있도록 react memo 라는 기능을 제공한다. React.memo 함수에 인자로 메모이제이션 시킬 함수형 컴포넌트를 넣어서 export하면 된다.
const Grid = () => { return <> </>;}
export default React.memo(Grid, (prev, next) => ...)
여기서 중요한 부분은 바로 두번째 인자부분이다! 두 번째 인자에는 true, 혹은 false를 반환하는 함수를 넣어 주는데 이 함수는 직전 props와 새로 변경된 props를 인자로 갖고 있다. 그리고 해당 함수가 반환하는 값이 false 일 때에만 다시 렌더링이 된다.
때문에 나는 해당 국가가 point된 국가인지를 나타내는 변수를 가지고 체크를 했다.
export default React.memo(Grid, (prev, next) => (prev.point === next.point));
이렇게 메모이제이션을 하고 나니, 성능이 눈에 띄게 좋아졌다! (400ms > 90ms)
위의 처참했던 Profiler사진의 아래쪽과는 달리 현재 사진의 아래쪽을 보면 Grid 부분에 빗금이 쳐져 있는데 메모이제이션이 되어서 캐싱된 데이터를 불러 왔다는 뜻이다.
그렇다면 모든 컴포넌트에 메모이제이션을 하면 성능이 좋아질까? 답은 아니오다. 항상 스타일이 바뀌는 컴포넌트 인 경우에는 memoization을 위해 불필요한 메모리가 사용된다던지, 메모이제이션 경우를 체크하기 위한 불필요한 비용이 발생하기 때문이다. 때문에 아래와 같은 경우에 React.memo를 사용하는 것이 좋다.
- 항상 같은 값을 반환하는 함수형 컴포넌트의 경우
- UI element의 양이 많은 컴포넌트의 경우
2. SeaGrid 따로 분리하기
지도에서 사실 렌더링 하는 것은 없지만 빈 칸을 차지하고 있는 것이 바로 SeaGrid였다. SeaGrid는 마우스가 가리키는 국가가 어디든 상관없이 변함없는 결과를 반환한다. 그래서 스타일드 컴포넌트로 나눴던 SeaGrid 와 LandGrid를 따로 만들어 분리시켰다.
(Sea Grid 자체를 생성하지 않으면 마우스가 바다쪽을 향했을 때 기존에 선택된 나라의 하이라이트가 계속 남아있는 문제 때문에 Grid를 만들었다)
그리고 SeaGrid는 처음 한 번 생성되면 항상 동일한 결과물을 내보내는 component 기 때문에 아예 Memoized 된 컴포넌트를 export 시켰다.
export const GridSea = ({ address, setCountry, point }) => {
return <Sea
onMouseOver={() => !point && setCountry(address)}
/>
}
export const MemoizedGridSea = React.memo(GridSea);
3. Component 수 줄이기
성능 이슈의 가장 큰 원인은 바로 1만개가 넘는 어마무시한 컴포넌트들이다. Mouse Hover 이벤트를 각 컴포넌트들에서 다루기도 하고, 메모이제이션을 했다고 해도, 리렌더링을 하는 컴포넌트 자체가 너무 많기 때문이다. 그래서 나는 Grid의 갯수는 유지하되 컴포넌트의 수를 줄이는 방법을 생각해냈다.
1. 연속되는 값의 배열을 나라 이름과 길이로 묶기
2. Flex Box에서 Grid Layout으로 변경하기
3. 해당 나라 이름과 길이 데이터를 Grid Layout위에 순서대로 그리기
하나씩 풀어서 설명 해 보자면, 우선 내가 갖고있는 json 파일의 데이터는 180 x 90의 이차원 배열에 각 index마다
i, j (위도, 경도) 에 해당하는 나라를 포함하고 있다. 이 배열에서 연속되는 값은 하나로 묶어버리는 것이다.
예를 들어 기존에는
[sea, sea, sea, sea, sea, korea, korea, sea, japan, japan] 이라는 배열을 통해 10개의 컴포넌트를 만들었다면,
[[sea, 5], [korea, 2], [sea, 1], [japan, 2]] 라는 배열로 바꿔서 5개의 컴포넌트만 생성하는 것이다..!
그리고, 해당 컴포넌트의 두번 째 인자가 연속하는 Grid의 개수이기 때문에, flexBox에서 gridLayout으로 바꾼 뒤
grid-column-start와 grid-column-end로 원하는 grid의 구역만큼 설정을 했다. 물론 해당 컴포넌트를 다시 Grid 개수만큼 나눠서 격자형식으로 만들어 주는것도 잊지 않았다.
와... 드디어 봐줄만한 반응속도가 되었다... 참고로 react-profiler는 react 앱을 npm run start로 페이지를 열었을 때에만 확인이 가능하고, build 후 배포 버전 페이지의 성능은 React-Profiler가아닌 기본적으로 브라우저가 제공하는 Performance탭을 통해 확인 가능하다.
mouseover이벤트가 발생했을때 배포버전이 훨씬 빠른것을 볼 수 있다. 기뻐하던 찰나, 문제가 하나 발견되었다.
앱을 이용하는데 체감되는 불편함음 없지만 한 렌더링이 될 때마다 평균적으로 40ms가 넘는 Recalculate Style 시간이 발생한다. 렌더링 최적화의 길은 멀고도 험한듯 하다...
그래서 다음번에는 이 문제를 해결할 수 있는 방법을 찾아봐야겠다!