React 컴포넌트 리렌더링 줄이기
Redux 및 커스텀 훅으로부터 발생하는 쓸데없는 리렌더링 이슈를 해결하기 위한 세 가지의 노력 및 느낀 점을 정리하였다.
우리 팀에서 담당하고 있는 프론트엔드는 약간 비효율적으로 리렌더링이 발생하고 있었다. 대외비일 수 있기 때문에 실제로 얼마나 비효율적이었는지 그 스크린샷을 공개할 수는 없지만, 위에 첨부한 최적화 전/후의 flamegraph 비교만으로도 대충 짐작이 가능하리라 생각한다.
리렌더링으로 인해 심각한 퍼포먼스 이슈가 발생한 적은 없었지만, Chrome 내장 퍼포먼스 측정 도구로 분석해본 결과 일부 저성능 컴퓨터 사용자에게는 문제가 될 수도 있겠다 싶은 수준이었다. 그리고 결정적으로, 내가 집에서 쓰는 2014년형 맥북 프로에서 우리 툴에 접속해보면 마치 비행기가 이륙하는 듯한 팬 소음이 발생하는 것이 큰 계기가 되었다.
일반적으로 React.memo()
가 리렌더링 이슈의 덕 테이프와 같은 존재로 여겨지고는 한다. 그러나 우리 프론트엔드 컴포넌트들은 Redux 및 커스텀 훅을 통해 참조되는 상태 값으로 인해 지속적으로 리렌더링을 발생하고 있었다. 따라서 React.memo()
보다는 근본적으로 리렌더링을 유발하는 원인을 찾고, 해결하는 것이 중요했다.
1단계 – 쓸데없이 거대한 Redux 상태를 구독하지 않기
불필요한 리렌더링의 원인 중 하나는 바로, 대부분의 컴포넌트들이 "쓸데없이 거대한" Redux 상태를 구독하고 있다는 점이었다.
우리가 사용하는 Redux 상태 중에 job
이라는 상태가 있었는데, 여기에는 API 통신 성공/실패 여부, 현재 사용자가 작업을 진행하고 있는지 여부, 작업에 쏟은 시간은 얼마나 된 것인지 여부와 같은 다양한 정보가 포함되어 있었다. 이 중에서 timeSpent
라는 이름의, 사용자가 작업에 쏟은 시간이 얼마나 되는지를 저장하는 값은 매 초마다 업데이트되는 특성을 가지고 있었다.
앞서 말했던 대로, 대부분의 컴포넌트들은 자신이 필요로 하는 상태 값은 극히 일부임에도 불구하고, 전체 job
상태를 구독하고 있었다. 그리고 job
상태에 포함된 timeSpent
값은 실제로 해당 컴포넌트에서 사용되지 않음에도 불구하고 매 초마다 업데이트 되면서 리렌더링을 유발하였다.
이 문제는 정말 쉽게 해결할 수 있었는데, 바로 "컴포넌트가 필요한 정보만 구독하는 것"이다. 따라서, 각 컴포넌트가 필요한 정보만을 선택하는 selector 함수를 선언하고, 그 selector를 사용해서 정말 필요한 정보만 구독하도록 변경해주었다.
2단계 – Redux 값을 다시 액션으로 디스패치하지 않기
제목이 잘 이해가 가지 않을 것 같다, 정확하게는 다음의 조건에 해당될 경우 최적화할 필요가 있다:
- 자주 업데이트되는 Redux 상태 값이다.
- 해당 상태 값을 다시 Redux 액션의 파라미터로써 디스패치한다.
- 그 외 용도로는 사용되지 않는 상태 값이다.
예컨대, 앞선 timeSpent
값은 매 초마다 업데이트되는 반면에, 사실상 사용되는 일이 극히 드문 값이었다. 주로 사용되는 목적은 "사용자가 특정한 작업을 얼마나 진행했는지를 서버에 전송하기 위함"이었다.
위에 캡쳐된 코드는 현재 사용자가 진행한 작업을 스킵하기 위한 Redux 액션을 디스패치하는 내용이다. 매 초마다 리렌더링을 유발하는 Redux 상태 값을 "단지 또 다른 Redux 액션을 디스패치하기 위해서" 구독하고 있는 것은 비효율적이라고 생각했다.
이 문제를 해결하는 방법은 세 단계에 걸쳐 진행되었다.
- Redux 액션에 연계된 saga 미들웨어 로직을 확인해야 한다. 기존에 액션 파라미터를 사용하고 있는 코드 로직에서 Redux 상태 값을
console.log
로 찍어보고, Redux로부터 값을 불러와도 괜찮은지 확인해볼 필요가 있다. - 값이 정상적으로 불러와졌다면, 액션 파라미터를 사용하는 부분을 Redux 상태 값을 쓰도록 변경한다. 만약 Redux 상태 값이 정상적으로 불리지 않는다면 관련된 로직을 확인 및 수정할 필요가 있다. (😱 재앙의 시작)
- 최종적으로 관련된 Redux 액션을 수정하고 마무리한다. 🎉
기술적으로 까다로운 점은 없었지만, 이 과정은 나에게 큰 부담으로 다가왔다. 왜냐하면 이 코드는 사용자에게 지불할 비용을 책정하는 데 관련된 코드이기 때문이다. 우리의 서비스는 고객들에게 작업한 시간에 비례해서 비용을 지불하는데, 이 timeSpent
값이 정상적으로 서버에 전송되지 않는다면 고객들이 분노할 것이라고 생각했기 때문이다.
3단계 – 커스텀 훅 참조 최적화하기
결국 또 시간에 관련된 이슈다, 하...
마지막으로 불필요한 리렌더링을 유발하는 원인은 커스텀 훅 때문이었다. 이 커스텀 훅은 "사용자가 현재 진행하고 있는 작업이 만료되었는지 여부"를 반환하는 훅인데, "만료되었는지 여부"를 매 초마다 확인하는 것이 컴포넌트로 하여금 리렌더링을 유발하는 원인이었다.
const useTimeRemaining = (expiredTime: string) => {
const [timeRemaining, setTimeRemaining] = useState<number>();
const updateTimeRemaining = useCallback(() => {
const timeRemaining = DateTime.fromISO(expiredTime)
.diff(DateTime.now())
.toMillis();
setTimeRemaining(timeRemaining);
}, [expiredTime]);
// setInterval()에 기반한 또 다른 커스텀 훅
useInterval(
() => {
updateTimeRemaining();
},
1000
);
return {
isExpired: timeRemaining && timeRemaining < 1000,
};
};
위 코드는 실제 커스텀 훅의 일부 내용을 가져와서 재구성한 것이다. 커스텀 훅에 의해 반환되는 isExpired
값은 (만료 시간이 지나기 전까지) 거의 변하지 않음에도 불구하고, 계산에 사용되는 timeRemaining
값이 매 초마다 바뀌기 때문에 리렌더링을 유발하게 되는 것이다.
이 문제를 해결하기 위해서 가장 먼저 든 생각은 Redux를 사용하는 것이었다. 즉, 실제로 매 초마다 업데이트가 필요한 컴포넌트(예컨대, 사용자에게 현재 남은 시간을 표시하는 컴포넌트)에 isExpired
값을 계산하고 Redux로 업데이트하는 로직을 몰아넣어 놓고, 다른 컴포넌트에서는 Redux를 통해 isExpired
값을 구독하는 방식이다. 하지만 이러한 방식은 가뜩이나 복잡한 Redux 상태 관리를 더 복잡하게 만들 것이라는 문제점이 있었다.
따라서, 다른 부서에 계신 시니어 FE 팀장님께 상황을 설명드리고, 두 가지의 해결 방법을 얻을 수 있었다. 내가 선택한 방법은 constate
라이브러리를 사용한 방법이었는데, 첫 번째 방법은 성능 최적화라는 이득에 비해 timeout을 적절하게 관리하기 위한 리스크가 클 것이라고 생각했다. 반면, 두 번째 방법은 기존 커스텀 훅을 이용하면서 UI 컴포넌트의 리렌더링을 줄일 수 있다는 점이 매력적으로 다가왔다.
constate
라이브러리의 사용법 자체는 매우 간단하다. 필요한 커스텀 훅을 constate(useCustomHook)
과 같이 감싸주면 Context Provider 컴포넌트와 해당 Context hook이 튀어나오는 형태이다. 더불어 constate
의 두 번째 인자로써 필요한 selector를 추가하면 불필요한 리렌더링 없이 해당 값을 사용할 수 있다.
위에서 나온 useTimeRemaining
커스텀 훅을 constate
라이브러리를 통해 Context로 만들어줌과 동시에, useIsExpired
라는 셀렉터를 사용하도록 코드를 수정함으로써 불필요한 리렌더링을 제거할 수 있었다.
이 라이브러리를 사용하게 되면 Context Provider가 리렌더링되는 것은 막을 수 없다. 그럼에도 불구하고 UI 컴포넌트가 리렌더링되는 것보다 Context Provider가 리렌더링되는 것이 비용상으로 더 저렴할 것이라고 생각되었다.
결론
개인적으로 매우 재미있었다. 도전과제 깨는 기분도 들었고, 최적화를 진행하면서 Redux 및 커스텀 훅에 대해 깊게 이해하는 계기가 되었다고 생각한다.