-
React의 성능을 올려보자!React 2021. 1. 18. 23:16
@ 이 글은 TypeScript (4.2+), React(17.0.1+), styled-components (5.1+) 로 구성된 간단 예제입니다.
리액트의 성능을 올려보는 방법들은 생각보다 꽤나 많았다.
단순히 리액트 DOM의 성능부터 gpu관련까지 있었다.
오늘은 성능 향상하는 방법을 기록해두려 한다.
예제는 막 거창한건 아니고 단순하게 즉석에서 생각해봤다.
0. 사전 지식과 사전 준비
1) 확장 프로그램 설치
먼저 크롬 웹스토어에서 React Deveolper Tools를 설치한다.
설치됐으면 F12로 개발자도구를 열어서 Profiler 탭을 누르고 오른쪽 톱니바퀴 설정을 눌러서
Highlight updates when components render 이 부분을 체크한다.
이것은 리렌더링될 때마다 어디가 리렌더링되었는지 파란색 선으로 표시해주고
리렌더링이 연속적인 리렌더링이면 노란색으로 표시해준다.
리렌더링이 되지 않으면 아예 표시하지 않는다.
리렌더링은 브라우저 동작원리와 관련이 있는데,
DOM요소가 변경되거나 레이아웃이 변경되거나 색상이 변경될 때 등과 같은 상황이 생기면
브라우저는 DOM 트리와 CSSOM을 다시 생성하고 브라우저가 렌더트리를 다시 생성하여 레이아웃을 재계산하고
화면에 다시 그린다. 이것을 리렌더링이라 한다.
{리플로우와 리페인트}
레이아웃을 재계산하고 화면에 다시 그리는 것을 리플로우라고 하고
레이아웃은 재계산하지 않고 화면만 다시 그리는 것을 리페인트라 한다
css에서 구분하자면
text-align, width, height 등 레이아웃을 변경하는 것들은 리플로우를 발생시키고
background-color, color 등 색상을 변경하는 것들은 리페인트를 발생시킨다
리액트는 이러한 것을 Virtual DOM이란 것을 생성하여 이전 DOM과 현재 DOM을 비교해서 다른 점을 찾아내서 변경된 부분만 리렌더링한다
매우 효율적이지만 아무리 효율적이라도 리렌더링이 많이 일어나면 과부하가 생겨 성능이 크게 저하가 된다
그래서 성능 최적화를 통해 리렌더링을 줄여 높은 사용자 경험줄 수 있다
2) 성능 분석 도구 - LightHouse
브라우저에서는 성능 측정하고 분석할 수 있는 도구를 제공한다
크롬을 예로 들면, LightHouse탭에서 성능 점수를 받을 수 있다.
우리는 데스크톱 환경에서 성능을 분석할 것이기 때문에 다음과 같이 세팅하고 Generate report를 눌러주면 된다.
글을 수정하는 와중에 성능 측정을 진행해보았다
성능 점수가 48점이 나왔다
녹색은 아주 좋은 성능, 주황색 사각형은 그래도 좀 느리니까 개선하는 것을 권장하는 것이고
적색 삼각형은 이러이러한 항목에서 느리니까 개선하라는 경고이다. 굳이 해야할 필요는 없지만 이것을 해결하면 크게 성능을 올릴 수 있다는 얘기다
Opportunities는 아래의 항목을 개선해주면 로딩 성능을 올릴 수 있다는 항목인 것이고
Diagnostics는 아래의 항목을 개선해주면 렌더링 성능을 올릴 수 있다는 항목이다
Opportunities에서 Serve images in next-gen formats 최신 세대의 이미지 포맷을 쓰라는 것인데
상세하게 눌러보면
Image formats like JPEG 2000, JPEG XR, and WebP often provide better compression than PNG or JPEG, which means faster downloads and less data consumption
JPEG 2000, JPEG XR, WebP와 같은 이미지 확장자는 PNG, JPEG보다 압축률도 좋고 다운로드 속도가 빠르니 사용하라는 것이다
Remove unused JavaScript 항목은 사용하지 않는 자바스크립트 코드를 모두 제거하세요 라는 것인데
불필요한 코드가 많다면, 아무래도 빌드시간도 걸릴 것이고 파일이 길어져 로딩하는데 시간이 걸린다는 의미인 것 같다
이 2가지 항목을 개선해주면 페이지 로딩이 더 빠르다는 것이다
다음 렌더링 성능을 올릴 수 있는 Diagnostics로 가보면
Avoid document.write() 가 있는데 document.write()는 기존 항목을 모두 날려버리고 새로 작성하기 때문에 연산도 많아 비추천하는 모양이다
Does not use passive listeners to imporve scrolling performance => 이벤트 리스너에 passive를 사용하지 않았다
Consider marking your touch and wheel event listeners as `passive` to improve your page's scroll performance
휠, 터치 이벤트 리스너에 passive 속성을 사용해서 스크롤 성능을 올려보세요 라는 것이다.
passive 속성은 렌더링 처리과정에서 이벤트를 받는 Composite 스레드에서 메인스레드의 처리를 기다리지 않고 바로 합성해버리기 때문에 스크롤링 성능은 올릴 수 있다
그러나 내부에서는 e.preventDefault()같은 것들을 사용할 수 없기 때문에 주의하자
window.addEventListener('scroll', function () { console.log('wheel!'); }, { passive: true }, );
그리고 가끔 개발환경에서 성능 테스트를 하면 Minify JavaScript라는 것도 볼 수 있는데 production환경으로 빌드하면
자동으로 자바스크립트 코드가 최소화되니 신경쓰지 않아도 될 것 같다
실제로 개발환경보다 빌드된 환경에서 성능 테스트를 하면 훨씬 빠르고 사용자는 production환경에서 사용하므로 개발환경에서 성능 측정하는 것보다 production 환경에서 성능 측정하고 개선하는 것이 더 맞는 것 같다
3) 성능 분석 도구 - Performance
개발자 도구에서 Performace 탭을 열어보면 다음과 같은 화면(아래 스크린샷과 같이 보자)이 나온다
왼쪽 녹화버튼: 녹화 중단할 때까지 페이지를 녹화하고 녹화를 중단하면 녹화된 시간동안 페이지를 분석하여 결과를 보여준다
새로고침 버튼: 페이지를 새로고침하면서 로딩과 렌더링할 때의 성능을 보여준다
금지 표시 버튼: 이전까지 기록된 성능 분석 결과들을 모두 지운다
업로드/다운로드 표시 버튼: 기록된 성능 결과를 브라우저에 업로드/다운로드하여 성능 분석 도구에 표시/저장한다
screenshots: LightHouse탭에서 페이지 로딩/렌더링할 때처럼 스크린 샷을 찍어 보여준다
memory: 메모리를 얼마나 사용하는지를 보여주는 체크박스이다
휴지통: 강제로 GC를 실행한다
오른쪽 톱니바퀴를 누르면 Disable JavaScript samples 부터 시작해서 4개의 항목이 나온다
Disable JavaScript samples => 시스템 상에서 돌아가는 함수 호출들을 보여주지 않는다
Enable advanced paint instrumentations (slow) => 레이어에 관련된 것들을 더 상세하게 보여준다
Network => 인터넷 속도를 강제로 조정할 수 있다
CPU => CPU에 강제로 스로틀링을 걸어 CPU를 느리게 만들 수 있다
1. 리액트 코드에서 memo, useMemo, useCallback 사용
tip) 가능하면 상수와 관련된 것들은 hooks의 아래에 작성하거나 참조를 덜 하게 컴포넌트 외부로 빼놓는다
import React, { useMemo, useState } from 'react'; type AnythingUsers = { name: string; id: number; }; const users: AnythingUsers[] = [ { name: 'a', id: 1 }, { name: 'b', id: 2 }, { name: 'c', id: 3 }, { name: 'd', id: 4 }, ]; function biggerThanOne(user: AnythingUsers[]) { return user.filter((e) => e.id > 1).length; } export default function Anything() { const [inputText, setInputText] = useState<string>(''); const count = useMemo<number>(() => biggerThanOne(users), []); return ( <div> <input value={inputText} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setInputText(e.target.value)} /> <h1>id가 1보다 큰 사람은 {count}명</h1> {users.map((element, index) => ( <p key={index}> id: {element.id}, name: {element.name} </p> ))} </div> ); }
위와 같은 예제에서 input태그에 입력할 때마다 처음엔 파란색 하이라이트가, 연속 입력시엔 노란색 하이라이트가 계속 생길 것이다.
리렌더링을 최소화하려면 useCallback을 쓰거나 memo를 통해서 컴포넌트 자체를 메모이제이션하는 것이다.
useCallback, useMemo, memo는 함수, 값, 컴포넌트의 props, state를 비교해서 바뀐 것이 없다면 리렌더링을 하지 않는다.
import React, { useCallback, useMemo, useState } from 'react'; type AnythingUsers = { name: string; id: number; }; const users: AnythingUsers[] = [ { name: 'a', id: 1 }, { name: 'b', id: 2 }, { name: 'c', id: 3 }, { name: 'd', id: 4 }, ]; function biggerThanOne(user: AnythingUsers[]) { return user.filter((e) => e.id > 1).length; } export default function Anything() { const [inputText, setInputText] = useState<string>(''); const count = useMemo<number>(() => biggerThanOne(users), []); const onChangeInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => setInputText(e.target.value), []); return ( <div> <input value={inputText} onChange={onChangeInput} /> <h1>id가 1보다 큰 사람은 {count}명</h1> {users.map((element, index) => ( <p key={index}> id: {element.id}, name: {element.name} </p> ))} </div> ); }
input의 onChange 이벤트에 있는 함수를 useCallback을 사용해보자
useCallback을 사용하지 않았을 때는 계속 함수를 재생성해서 메모리에 부담이 가지만 useCallback을 사용하면 기존에 있던 함수를 재사용(메모이제이션)해서 메모리를 더 적게 사용한다. 하지만 컴포넌트 자체는 아직도 리렌더링이 된다.
useMemo는 useCallback과 같다.
const onChangeInput1 = useCallback((e: React.ChangeEvent<HTMLInputElement>) => setInputText(e.target.value), []); const onChangeInput2 = useMemo(() => (e: React.ChangeEvent<HTMLInputElement>) => setInputText(e.target.value), []); onChangeInput1 === onChangeInput2
이제 컴포넌트를 memo로 감싸보자
export default memo(function Anything() { const [inputText, setInputText] = useState<string>(''); const count = useMemo<number>(() => biggerThanOne(users), []); const onChangeInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => setInputText(e.target.value), []); return ( <div> <input value={inputText} onChange={onChangeInput} /> <h1>id가 1보다 큰 사람은 {count}명</h1> {users.map((element, index) => ( <p key={index}> id: {element.id}, name: {element.name} </p> ))} </div> ); });
첫 이미지와 똑같지만 아무리 입력해도 하이라이트는 표시되지 않는다.
리렌더링을 하지 않는다는 것이다.
2. 이미지 사이즈 최적화
지금은 로컬 이미지여서 로딩이 빠르지만 실제 서버에서 4k 이미지를 로딩하거나 용량이 많은 이미지를 로딩할 때에는
부분적으로 보이다가 점차 로딩이 완료될 것이다 => 사진이 느리게 나온다
이런 경우에는 이미지를 작은 사이즈로 리사이징하면 더 빠른 속도로 로딩이 가능하다
용량과 사이즈가 작기 때문에 렌더링할 때 드는 비용이 적은 것이다
프론트에서 처리하는 방법은 대표적으로 이미지 리사이징 cdn을 이용하는 것이다
데모에서 그대로 가져왔기 때문에 잘 작동되진 않지만 그래도 크게 빠르게 로딩되는 것을 볼 수 있다.
특히 썸네일 같은 것을 보여줄 때 효과적이다
export default memo(function Anything() { const [inputText, setInputText] = useState<string>(''); const count = useMemo<number>(() => biggerThanOne(users), []); const onChangeInput = useCallback((e: React.ChangeEvent<HTMLInputElement>) => setInputText(e.target.value), []); return ( <div> <img src={`https://ik.imagekit.io/demo/tr:w-300,h-300/${imageSrc}`} alt='anyimage' /> </div> ); });
3. 코드의 연산횟수 줄이기
이렇게 많은 반복을 하면 브라우저는 연산하느라 렌더링을 하지 못하기 때문에 렌더링되는데에는 꽤나 많은 시간이 소요되거나 멈추거나... 비록 단일 반복문이라 하더라도 연산이 많아 렌더링하는데 꽤나 많은 시간이 소요된다
export default memo(function Anything() { const [inputText, setInputText] = useState<string>(''); let x = 0; for (let i = 0; i < 1e308; i++) { x += 1; } for (let j = 0; j < 1e308; j++) { x -= 0.5; } return ( <div> <p>{x}</p> </div> ); });
위 예제를 실행하다가 너무 오래걸려서 브라우저를 강제종료했다.
export default memo(function Anything() { let x = 1e308 - 0.5 * 1e308; return ( <div> <p>{x}</p> </div> ); });
바로 로딩이 된다
4. lazy 로딩 + code splitting
react의 lazy함수와 suspense 컴포넌트를 이용하여 코드를 분할하는 방법이다.
코드를 분할하게 되면 큰 조각들을 한 번에 로딩하는 것보다 여러 조각들을 로딩하는 것이 시간을 더 줄일 수 있다
사용하는 모듈별로 분할하는 방법이 있고 페이지별로 분할하는 방법이 있다.
import React, { lazy, Suspense } from 'react'; import { Route, Switch } from 'react-router-dom'; import Everything from './Everything'; const Anythings = lazy(() => import('./Anything')); function App() { return ( <> <Switch> <Route component={Everything} path={'/any'} /> <Suspense fallback={<div>loading...</div>}> <Route exact component={Anythings} path={'/'} /> </Suspense> </Switch> </> ); } export default App;
컴포넌트를 분할했다
이 때 lazy를 사용하고 반드시 Suspense 컴포넌트로 감싸줘야한다 그렇지 않으면 에러가 발생
fallback props는 로딩 중에 어떤 화면을 보여줄 것인지를 작성해주면 된다
지연 로딩이기 때문에 새로고침을 연속적으로 하게 되면 loading...이라는 것이 보일 수 밖에 없다
아무것도 보여주고 싶지 않다면 fallback에 null을 주면 된다
(마치 React컴포넌트에서 null을 반환하면 아무것도 보여주지 않는 것처럼)
캐시가 적용되면 더 적은 시간으로 보여줄 수 있다
CRA에서의 번들 분석 -> module과 컴포넌트의 번들 사이즈를 알 수 있다.
https://www.npmjs.com/package/cra-bundle-analyzer
실제로 코드를 분할하면 모듈들이 서로 분리가 되어 필요한 때에만 적절하게 불러올 수 있다
5. 애니메이션 최적화
애니메이션은 리플로우와 리페인트를 발생시키는 원인 중에 하나다
이러한 것들은 모두 cpu에서 일어나는 것들인데 그래픽 처리를 gpu에게 위임하는 것으로 성능을 올릴 수 있다
const Graph = styled.div<{width: number}>` width: ${props => props.width}%; transition: width 1.5s ease; `;
너비만큼 애니메이팅하는 것인데 이런 경우에는 너비를 변경할 때마다 리플로우가 발생하기 때문에
성능이 저하될 수 밖에 없다
const Graph = styled.div<{width: number;}>` width: 100%; transform: scaleX(${({ width }) => width / 100}); transition: transform 1s ease; `;
변형 함수(transform)를 사용하는 경우에는 GPU에서 처리하기 때문에 CPU에 들어가는 부담이 줄어 더 나은 성능을 기대할 수 있다
6. 텍스트 압축
빌드할 때 serve에 -u옵션을 주면 텍스트 압축을 하지 않으니 해당 옵션이 있는지 확인하고 있다면 제거하고 빌드를 하면 되겠다.
@@@@@
'React' 카테고리의 다른 글
Recoil (0) 2021.04.28 React Hooks (0) 2021.01.02 React 알아보기 (0) 2021.01.01