1년반전 즈음 더욱더 주니어였을 시기에 했던 서비스의 피드 성능 개선기를 회고해보려 합니다.
1. 피드 리스트 성능 이슈#
당시 기준 몇달 전 서비스 대규모 리뉴얼이 있었고 직후 피드에 심각한 성능 문제가 발생했습니다.
어느 페이지 구간부터는 로드에 짧게는 3초, 길게는 7초 등 적지 않은 시간이 걸렸고 슬랙에서 공론화되기까지 했습니다.
리뉴얼 중 피드를 담당했던 인력이 한달 전 퇴사를 하면서 대응에 공백이 생겼었는데 다행히도? 당시 브라우저 성능 최적화를 위해서 해보고 싶었던 시도가 있었던지라 업무 카드를 겟했습니다.
성능에 얼마나 하자가 있는지 실제 지표로 보려고 성능 모니터 탭을 켜보니
아니 웬걸.. 고작 7번째 페이지를 불러왔음에도 브라우저가 사용하는 CPU가 100%까지 치솟는 것으로 보아 렌더링을 하는데에 너무나 많은 연산이 이루어지는 것으로 예상되었습니다.
2. 원인 찾기#
인피니티 스크롤이 몇차례 되었을 때 문제가 부각되는 것으로 보아 두 가지를 예상했습니다
1) 백엔드 API의 레이턴시🤔#
고려해볼 법한 원인으로 오프셋 페이지네이션 체계에서 조회하려는 데이터의 앞 데이터를 SKIP하는 과정에서 느려지지 않을까 생각도 해보았는데요.
하지만 그렇다고 하기엔 고작 10번도 안되는 스크롤부터 바로 문제가 발생했었고, 순서상 앞에 있는 데이터가 최대 100개 밖에 안되는데 이렇게 느릴리는 없다고 판단하여 제외하였습니다.
네트워크창에서 봐도 실제로 문제가 될만큼 레이턴시가 발생하진 않았기도 하구요.
2) 과다 재연산 이슈✅#
새로운 리스트 데이터를 받아오면서 리스트 상태가 갱신되고, 이로 인해 부모인 리스트 컴포넌트가 리렌더링되면서 자식인 아이템 컴포넌트가 전부 리렌더링되는 것이 문제였습니다.
다만 흔한 프론트엔드 문제 패턴으로 이런 형태의 결점이 있다고 해서 이렇게까지 심각하게 버벅이는 경우는 잘없었는데 각 피드 아이템이 꽤나 많은 상태 관리 로직과 무거운 로직의 함수를 갖고 있던 것이 원흉이었습니다.
즉 아이템 컴포넌트에는 재연산 시 부담이 있는 좋아요, 싫어요 토글함수나 답변 보상 배율 계산같은 무거운 로직이 있어서 리렌더링 1회당 비용도 비싼 편이었고,
또한 아이템 컴포넌트에는 좋아요, 싫어요, 토픽, 유저정보 총 4개의 지역 상태가 존재했는데 부모에서 내려주는 이 값을 자식에서 상태로 관리하고 있었습니다. 따라서 데이터의 정합성을 맞추기 위해 자식에서 useEffect로 이 상태들을 업데이트하는 로직이 있었고 이로 인해 자식당 2번의 리렌더링이 발생하고 있었습니다.
- derived state 패턴(= 파생 상태)이라고 불리며 리액트는 이를 지양할 것을 강조합니다.
그리고 인피니티 스크롤이 트리거되어 부모의 리스트 상태가 갱신되면 리렌더링되면서 자식인 아이템 컴포넌트들이 모두 영향을 받는데 이 때 새로 불러올 아이템 외에 기존에 있었던 아이템들도 리렌더링이 발생하게 되는 것도 성능 악화에 큰 지분을 차지한다고 생각했습니다. 기존에 있었던 것들은 UI가 그대로 유지되어 리렌더링이 불필요한데도 부모로 인해 영향을 받게 되는 것이죠
즉, 인피니티 스크롤로 6번째 리스트를 불러온다고 예시를 들었을 때
- 기존 1~50번째 아이템 컴포넌트
- 새로 불러온 51~60번째 아아템 컴포넌트
- 자식에서 상태 정합성을 위해 사용한 useEffect
로 인해 총 (50 + 10) * 2 = 120번의 리렌더링이 발생할 수 있게 됩니다.
차차 문제를 해결했던 스토리를 풀어보면
3. 리액트 derived 패턴 제거#
부모에서 상태 갱신으로 자식이 리렌더링되는 것은 자연스러운 흐름이므로 가장 최후의 개선 요소라고 판단하여 자식 자체에서 발생하는 리렌더링 원인을 제거하기로 하였습니다.
좋아요, 싫어요, 유저정보, 토픽 총 4개의 값 모두 부모 리스트에서 뿌려준 데이터로 아이템에서 각각 useState 와 useEffect로 관리중이었으나 그렇게 관리할 필요가 없었습니다.
유저정보나 토픽은 런타임에서 AJAX 처리될 필요 없는 정보이므로 상태로 다룰 필요가 전혀 없었고
좋아요나 싫어요는 토글 기능이 필요하므로 상태로 컨트롤 되긴해야 하나 리스트 데이터가 이미 tanstack-query에 의해 캐시(상태)처리 중이므로 이 캐시를 조작하면 되어 useState와 useEffect를 사용할 필요가 없어져 제거했습니다.
그 결과 아이템당 리렌더링 횟수가 2회에서 1회로 줄어 6번째 리스트를 불러온다고 하면 총 리렌더링 횟수를 벌써 절반인 60회로 줄였습니다.
동시에 코드 가독성도 굉장히 개선됐구요.
4. 리스트를 새로 받아오면서 발생하는 리렌더링 현상 제거#
1) memo로 컴포넌트 메모이징 적용#
6번째 리스트를 불러올 때의 리렌더링 횟수를 120번 -> 60번으로 줄였지만 여전히 아쉬운 점이 있죠.
새로 불러올 데이터는 51~60번째 컴포넌트에 해당하는 데이터이지만 기존의 1~50번째 컴포넌트는 전혀 달라진 사항이 없음에도 부모 컴포넌트의 리스트 상태값이 갱신되었다는 이유로 다시 리렌더링 되어야 합니다.
따라서 기존 컴포넌트의 데이터가 변하지 않았다면 리렌더링하지 않도록 memo를 적용합니다.
memo는 부모가 리렌더링되었더라도 그에 속하는 자식 컴포넌트의 prop값이 변하지 않았다면 리렌더링 없이 이전 값을 그대로 사용할 수 있는 기법으로 특히나 이러한 리스트성 UI에서 큰 빛을 발휘합니다.
2) 리스트 데이터를 메모이징할 때 흔한 함정#
memo를 적용해보면? 예상대로 동작하지 않습니다. 분명 부모가 리렌더링되기 이전과 똑같은 prop값을 받았을텐데 리액트는 다른 값을 prop으로 받았다고 인식한 것이죠. 왜일까요?
이 현상을 이해하려면 자바스크립트의 원시타입과 참조타입에 대해서 이해를 해야합니다.
const a = 'foo'
const b = 'foo'
라는 원시타입의 값을 할당한 변수가 있을 때 a === b 는 true입니다. foo === foo를 비교하는 것이므로 값이 같기 때문이죠.
그럼 아래 코드는 어떨까요?
const a = {
foo: 'bar'
}
const b = {
foo: 'bar'
}
라는 코드가 있을 때 a === b 는 false입니다.
겉으로는 값이 같아보이지만 내부적으로는 변수 a, b가 각자 가진 객체값은 별도의 메모리에 저장되고, 각 변수는 메모리 주소를 참조하게 되기 때문이죠
즉 a === b는 내부적으로 메모리주소A === 메모리주소B를 비교하는게 되어 false가 나오게 되는 것입니다.
이러한 것이 참조타입의 특징으로 자바스크립트에서는 객체, 배열, 함수가 참조타입으로서 취급됩니다.
따라서 부모의 리스트 상태값이 바뀌어 리렌더링되면 자식이 prop으로 내려받고 있던 함수도 재연산되어 메모리주소가 이전과 달라지므로 리액트는 이미 존재하던 리스트 아이템들이 이전과 다른 prop 값을 받았다고 판단하게 되어 리렌더링을 해버리게 되는 것입니다.
그럼 어떻게 해야되는 걸까요? 이런 참조타입의 데이터 자체를 메모이징해서 내려줘야 합니다. 즉, 부모의 상태가 갱신되어 리렌더링되어도 동일한 레벨에 존재하는 함수의 재연산을 막는 방법인 것이죠.
useCallback을 이용해서 의존성 배열에 넣은 값에 변화가 생겼을 때에만 함수가 재연산되도록 하면, 이제 부모 리스트 상태값이 바뀌더라도 함수는 로직에 영향을 미치는 값이 변하지 않는 이상 재연산되지 않아 이전 메모리주소 값을 유지할 수 있게 되어 정상적으로 memo가 동작하게 됩니다.
이제 120번에 달했던 렌더링 횟수가 딱 새로 불러온 개수만큼인 10번으로 줄일 수 있게 되었습니다.
5. 마무리#
1) 브라우저 성능 최적화 확인#
보이듯 약 15페이지까지 계속 스크롤을 내렸을 때 크롬 성능 모니터 탭 기준으로
As Is
- CPU 최고 100%, 메모리 최고 220MB
To Be
- CPU 최고 20%, 메모리 최고 80MB
정도로 상당히 개선이 되었습니다.
브라우저 이외 웹뷰 환경에서도 유의미하게 줄어든 것을 볼 수 있었습니다.
웹뷰의 경우 자원을 과하게 사용하게 되면 앱이 크래시되거나 하얀 화면이 되어버리는 문제가 있었는데 운영환경 배포 후에 관련 CS가 줄어드는 결과도 얻을 수 있었습니다.
2) 메모이징이 능사는 아니다#
메모이징은 참 좋아보입니다. 하지만 그렇다고 남용은 있어선 안돼요. 왜냐하면 연산한 값을 '메모리'에 저장해둔 후 리렌더링/재연산이 될 때 추가 연산없이 메모리에서 꺼내 재활용하는 방식이기에
결국 메모리 자원을 잡아먹으며 매 렌더마다 이전과 비교하기 위해 CPU도 사용하게 됩니다.
지금처럼 리스트 형태인 경우 대개 데이터가 몇 백개씩 렌더링에 관여될 때 관련 값들의 재연산으로 인해 CPU 부하가 심해질 수 있을 때에 도움이 되는 것이죠.
즉 대부분의 경우에서는 재연산을 하는 것이 성능적으로 우월할 때가 대다수입니다. 따라서 메모이징이 도움이 되는 경우는 예외적인 케이스라는 것입니다.
그러므로 정말 무거운 작업임이 확실한 상황에서만 도입을 고려해야 합니다.