리액트에서 성능 최적화를 한다면 정말 많은 부분이 있겠지만
오늘은 렌더링 최적화에 대해 고민해보며 공부하는 시간을 가지기로 했다.
💜 성능 최적화.
리액트에서 성능 최적화라고 한다면 가장 먼저 '렌더링 최적화' 를 떠올려 볼 수 있었다.
아무래도 리액트에서 성능 최적화 얘기만 나오면 정말 빠지지 않는 이름들.
예를 들면, useMemo, useCallback 등 이 있을 것이다.
문제는 이 친구들을 무지성으로 가져다 쓴다고 최적화가 되느냐..! 한다면 그건 또 아니라는 점에서
'그러면 도대체 언제 써야하는 건가' 라는 의문을 남겨둔 채 사용하지 않고 있었다.
(함부로 못 쓰고 있다는 게 더 정확하긴 하지만.)
내가 리액트를 접한 지 얼마 안 됐을 때 useCallback을 써서 함수를 캐싱했던 적이 있다.
나름 뭐 어디서 주워 들은 건 있어서 '최적화'라는 명목하에 써본다고 나댔었는데 상황이 정확히 기억 나는 건 아니지만,
자식 컴포넌트에게 특정 함수를 넘겨주고 있어서 useCallback을 썼던 것은 어렴풋하게 기억난다.
문제는 의존성 배열 설정을 제대로 하지 않았던건지, 당시 원하는 동작이 제대로 이뤄지지 않아 결국 useCallback을 지워버렸던 경험이 있다. ㅋㅋㅋ
지금 생각해보면 참.. 몸에 좋다는 말에 뭔지도 모른 채 주워먹는 것과 다를 게 없던 상황이었다.
(나 자식ㅠ 제발 제대로 알고 써라..😥)
결론적으로 '성능(렌더링) 최적화에 좋대요~'라고 주워 들어서 useCallback 과 같은 것을 생각없이 썼다면
오히려 성능이 더 느려지거나, 최적화가 전혀 이루어지지 않을 수도 있다는 의미다.
리액트 공식 문서에서도
Don’t optimize prematurely! (일찍 최적화 하지마라!)
라고 말하고 있다.
💜 리액트는 언제 리렌더링이 이루어지는가
나는 최적화를 시도하기 전, 어느 순간에 컴포넌트 리렌더링이 이루어지는지 정확히 알 필요가 있다고 판단했다.
1. props나 state가 변경됐을 때
2. 부모 컴포넌트가 리렌더링되면 자식 컴포넌트도 함께 리렌더링
3. context가 변경됐을 때 context를 사용하는 자식 컴포넌트도 리렌더링
4. 컴포넌트를 강제로 리렌더링 할 때 (forceUpdate)
4번 같은 경우는 리렌더링이 언제 이루어지는지 찾아보던 중, forceUpdate 라는 게 있다는 것을 처음 알게 되었다.
함수 컴포넌트에서는 사용되지 않고 클래스형 컴포넌트에서 사용되는 메서드인듯하다.
리액트의 생명 주기 메서드를 건너뛰기 때문에 성능에 영향을 미칠 수 있으므로 강제 리렌더링을 시키지 않는 것이 좋다.
forceUpdate라는 게 존재는 한다..정도만 알고 사용은 하지 않는 게 좋겠다.
사용할 일이 없을 가능성이 더 크려나. 레거시 코드가 아닌 이상 훅스를 사용할테니.
💜 렌더링 최적화를 해볼 수 있는 것은 무엇이 있을까?
함수 컴포넌트를 기준으로 유명한 3종 세트를 떠올려 볼 수 있었다.
존재는 알고는 있었지만, 단순히 최적화 할 때 쓴다 외에는 정확히 무엇이고 어떤 상황에 적합한지 알지 못하고 있었던 것 같아 이번 기회에 정리해보았다.
useCallback()
특정 함수를 새로 생성하지 않고 재사용할 수 있도록 한다. (함수를 캐싱한다.)
의존성 배열에 넣은 값이 변경될 때만 함수를 새로 생성한다.
불필요한 렌더링을 줄여서 성능을 개선할 때 사용될 수 있다.
자세한 내용은 아래 공식문서에서 확인해보자.
const cachedFn = useCallback(fn, dependencies)
(공식 문서에 있는 코드를 줍줍했다. 🙄)
useCallback의 두 번째 인자인 dependencies 배열이 빈 배열이 아닌 경우,
dependencies 배열에 넣은 값을 리액트의 비교 알고리즘을 통해 이전 값과 비교한다.
만약 의존성 배열에 넣은 값이 변경되었다면, useCallback으로 감싼 함수를 새로 생성한다.
변경되지 않았다면 새롭게 함수를 생성하지 않고 캐싱된 함수를 사용한다.
자식 컴포넌트의 프롭스로 함수가 전달되면 렌더링 될 때마다 해당 함수가 재생성된다.
함수가 간단하거나 의존성이 없을 때는 사용하지 않는 것이 나을 수 있다.
useMemo()
useCallback은 함수를 Memoization했다면, useMemo는 결과를 캐싱한다.
같은 결과 값을 사용하는 함수를 여러 번 호출하는 것 보다 어차피 같은 값을 사용할 것이라면 결과 값을 캐싱해놓고
이 캐싱된 값을 사용하는 것이 효율이 높기 때문이다.
(같은 값을 사용하는 연산이 많이 수행되는 경우 사용을 고려해볼 수 있겠다.)
자세한 내용은 아래 공식문서에서 확인하자.
const cachedValue = useMemo(calculateValue, dependencies)
useMemo는 콜백 함수, 의존성 배열을 인자로 받는다.
콜백 함수가 리턴하는 값이 useMemo가 리턴하는 값이 된다.
의존성 배열에 넣은 값이 변경되었을 때만 useMemo를 갱신한다.
React.memo()
리액트의 HOC(고차 컴포넌트) 중 하나로 React.memo() 사용 시 최적화 된 컴포넌트(메모이제이션 된 컴포넌트)를 반환해준다.
React.memo로 감싼 컴포넌트의 프롭스가 변경이 되었는지 확인하고, 변경이 없을 경우 이전 렌더링 결과를 재사용한다.
단, React.memo는 props의 변화만 감지하기 때문에 state, context가 변경되었을 때의 리렌더링은 막지 못한다.
어딘가에 memoization 하는 것은
메모리를 사용한다는 것이므로 무분별한 사용은 오히려 독이 된다.
useCallback, useMemo를 사용했는데도 리렌더링이 발생할 때
useCallback, useMemo를 사용하더라도 리렌더링이 될 수 있다.
이때는 이것 뿐만 아니라 하위 컴포넌트를 React.memo로 감싸주어야 한다.
왜 그런지 알기 위해서는 리액트 렌더링 프로세스를 알아야 했는데
자세한 내용은 공식문서를 참고하자.
리액트 렌더링 프로세스
간단히 내가 공부한 내용을 적어보겠다. (간단하진 않은가..?)
리액트의 렌더링 프로세스에는 Render Phase와 Commit Phase가 존재한다.
함수 컴포넌트를 기준으로 '렌더링'은 함수가 호출됨을 의미한다.
즉, 함수가 호출 될 때 내부의 변수나 함수가 새롭게 생성이 되고 리액트의 렌더링 프로세스 과정을 거치게 되는데
먼저 리액트의 렌더 페이즈에서 이전 VDOM과 현재 VDOM의 차이점을 비교하여 체크하게 된다.
(리액트 컴포넌트의 state나 props의 변경을 감지하는 것을 더티 체킹(dirty checking)이라고 하는 것 같다. 리액트 공식 용어는 아닌 것 같지만.)
아무튼, 리액트의 핵심 알고리즘인 diff 알고리즘을 사용하여 렌더 페이즈에서 이전 VDOM과 현재 VDOM을 비교하여 체크를 해서 변경점을 식별했다면, 커밋 페이즈가 렌더 페이즈에서 식별한 변경점을 가지고 실제 DOM에 반영하는 역할을 수행한다.
렌더 페이즈나 커밋 페이즈를 이해하는 건 어렵진 않았지만, 실제로 useCallback, useMemo를 사용하더라도 리렌더링이 발생하는 경우 React.memo까지 사용해야 하는 이유를 정확히 이해하지 못하고 있다는 것을 알게 되었다.
따라서 이 포스트를 쓰면서 조금 더 찾아보고 정리한 결과는 다음과 같다.
예를 들어, 어떤 함수를 메모이제이션하기 위해 useCallback을 사용했다고 가정해보자.
리렌더링되어 함수 컴포넌트가 호출되더라도 useCallback으로 감싼 함수는 의존성 배열이 변경되지 않는 이상 변경점이 감지되지 않으므로 새로 생성되지 않는다. 문제는 이렇게 함수를 감쌌지만, 해당 함수를 프롭으로 전달받고 있는 자식 컴포넌트의 리렌더링은 아직도 발생한다는 점인데 "왜 useCallback으로 감쌌는데도 리렌더링이 발생하지?" 라는 물음에 다음과 같은 답변을 할 수 있을 것 같다.
이는 해당 컴포넌트의 state나 props가 변경되었거나, 부모 컴포넌트가 리렌더링되었기 때문이다.
리렌더링이 발생하는 원인은 그것만 있는 것이 아니기 때문이다. (본문 상단의 리렌더링이 언제 이루어지는가를 참고하자)
간단한 예제를 하나 만들어보았다.
위 코드는 부모 컴포넌트이다. 이 컴포넌트는 자식 컴포넌트로 카드 컴포넌트를 가지고 있고 cardData라는 일반 변수(객체)와 onClick 함수를 props로 전달하고 있다.
카드 컴포넌트는 이렇게 생겼다.
카운트를 누르면 숫자가 증가한다. 카운트 버튼은 부모 컴포넌트에 위치해있고 자식 컴포넌트(카드)와는 전혀 상관이 없는데도 불구하고 카운트 버튼을 누르면 부모와 카드 컴포넌트가 리렌더링되고 있음을 확인할 수 있다.
왜 카드가 리렌더링 되는 것일까?
부모 컴포넌트 내부의 코드 중 카드 컴포넌트를 리렌더링 시키는 원인으로 추정되는 용의자를 찾아보자.
우선 첫번째 용의자 cardData의 경우 일반 변수(객체)의 형태이다.
부모 컴포넌트가 리렌더링 될 때 해당 변수가 새로 생성된다.
새로 생성되는 것이 문제되는 것이 아니라 현재 카드 컴포넌트가 이 변수를 프롭스로 내려받고 있고 무엇보다
이 cardData는 레퍼런스 타입이라는 점이다. (프리미티브 타입이면 용의 선상에서 제거된다.)
레퍼런스 타입이라서 리렌더링을 유발한다.
레퍼런스 타입은 단순히 값이 아닌, 주소를 비교하여 똑같은 변수인지를 판단한다.
우리 집과 옆 집의 생김새가 똑같다고 생각해보자. 생긴게 똑같으면 다 우리 집인가?
새로 생성된 레퍼런스 타입은 생긴 것만 똑같을 뿐 집의 위치가 다른 것으로 볼 수 있는데 자식 컴포넌트가 cardData를 props로 전달받고 있으므로 다른 cardData라고 인식하여 리렌더링이 된다.
따라서 이 cardData는 리렌더링을 유발하는 범인이라고 할 수 있겠다.
범인을 체포하기 위해 같은 변수를 재생성하지 않기 위해 useMemo를 사용하여 값을 메모이제이션시키자.
이제 리렌더링되도 cardData 변수는 새로 생성되지 않을 것이다.
만약 새로 생성시켜야 하는 상황이 있다면 의존성 배열에 추가해주면 된다.
다시 카운트 버튼을 눌러보자.
당연한거지만 아직 카드가 리렌더링된다.
onClick도 리렌더링 공범이라서 잡아줘야 한다.
이 친구는 함수니까 useCallback을 사용해서 메모이제이션하면 된다.
다시 카운트 버튼을 누른다.
문제가 될 것으로 예상되는 변수와 함수를 메모이제이션했지만 아직도 리렌더링이 발생하고 있다.
왜냐하면, 해당 변수와 함수가 메모이제이션된 것은 리렌더링 될 때 새로 생성되지 않을 뿐
부모 컴포넌트가 리렌더링 될 때 자식 컴포넌트가 리렌더링 되는 것을 막아주진 않는다.
이제 마지막으로 카드 컴포넌트에게 찾아가서
부모가 리렌더링된다고 같이 리렌더링되지 말고 네가 가진 props가 변경됐을 때만 리렌더링 해라고 알려줘야 한다.
React.memo로 컴포넌트를 감싸주자.
참고로 React.memo로 감싼 컴포넌트는 프롭스의 변경 여부를 얕은 비교(shallow comparison)한다.
깊게 비교할 경우 객체의 경우 재귀적으로 타고 타고 들어가야하기 때문에 성능적으로 이슈가 발생할 수 있기 때문으로 보인다.
모든 범인을 체포했다. 🎉🎉
이제 더이상 카운트 버튼을 눌러도 카드 컴포넌트가 리렌더링되지 않는다.
다음으로 검증할 것은 처음에 내가 예상했던대로 동작하는지이다. (억울한 피해자가 있으면 안되니까)
useCallback과 useMemo를 사용한 변수와 함수를 메모이제이션하지 않는다면 카드 컴포넌트가 리렌더링되는지 확인해보자.
onClick의 useCallback을 없앴다. 카드 컴포넌트가 리렌더링이 되는 것으로 보아 예상대로 동작하는 것을 확인했다.
다음은 onClick에 다시 useCallback을 걸고 cardData의 useMemo를 제거해보자.
예상대로 동작한다.
예제가 그렇게 좋은 코드는 아닌 것 같지만 개념을 익히기엔 충분했던 것 같다.
오늘 하루 종일 얘네랑 놀아서 이제 useCallback, useMemo, React.memo가 꿈에 나올 것 같은 기분.
원래는 사이드 프로젝트의 렌더링을 최적화하고 싶어서
리액트 컴포넌트 렌더링 최적화에 대해 고민해보는 시간을 가지게 된 거 였는데
'왜 이렇게 느리지? 성능 최적화를 해야겠다..' 가 아니라
'모든 하위 컴포넌트가 새로 생성되는 게 뭔가 불편한데..' 였기 때문에
당장의 렌더링 최적화는 불필요한 최적화가 될 수도 있겠다는 생각이 들었다.
겸사겸사 리액트와 조금 더 친해진 기분이 든다.
생각없이 사용했던 부분에 대해서도 다시 고민해볼 수 있었고!
결론
시간 낭비가 될 수도 있으니 무조건 렌더링 최적화부터 하지 말고
성능이 느리다고 체감됐을 때 렌더링 최적화를 고민해보자.
렌더링 말고 다른 부분에서 최적화 할 수 있는 부분을 먼저 고민해봐야겠다.
다음은 네트워크~? 아니면 CSS 리플로우/리페인트~? 가 될진 모르겠지만 뭐든 재미있을 것 같다.
그나저나 티스토리 왜이렇게 불안정하지. 게시글 쓰지를 못하겠네. 😐😑
참고 자료
리액트 공식문서
'오늘의 개발' 카테고리의 다른 글
express 없이 Node.js로 간단한 투두 앱 만들기 (DB 연동, 투두 추가하기) (0) | 2023.05.18 |
---|---|
손코딩을 해보자. (1부터 N까지의 합 구하기) (0) | 2023.05.11 |
[리팩토링] 클린 코드를 위해 기능 단위로 함수를 분리해보자. (0) | 2023.05.08 |
사이드 프로젝트 UX/UI 개선 및 반응형 작업, 캐러셀 라이브러리(react-slick) 사용하기 #2 (메인 페이지) (0) | 2023.05.04 |
TSX 프로젝트 PWA 세팅, AWS S3와 Cloud Front, Git action 배포 자동화 경험기 (feat. 서비스워커 파일을 삭제해버린 자의 최후) (0) | 2023.05.03 |
댓글