프론트엔드 React 성능최적화 정리

suyeonKim·2023년 4월 2일
1
post-thumbnail

프로젝트를 진행하며 경험한 성능 최적화 방식을 정리했다. 성능 측정에는 lighthouse를 사용했다.

💡 렌더링 최적화

목적: 리플로우를 최대한 적게 발생시키면서 빠르게 화면 그리기

리플로우 발생시 브라우저가 전체 픽셀을 다시 계산해야하기 때문에 성능에 좋지 않아 줄여야한다.

— CSS 최적화

간결한 스타일 작성 ⇒ 복잡한 셀렉터 지양

.list .item { ... } // X
.item { ... } // O

❓이유: 브라우저는 .item의 조상이 .list를 가지고 있는지 찾기 위해 더 많은 DOM 검색 시간을 소요해 렌더링 성능에 영향

❗해결: 예시의 아랫줄처럼 단일 셀렉터에 스타일 작성

⇒ styled-components는 해싱을 통해 고유한 클래스명을 만들어주므로 클래스명 오염 걱정 없이 단일 셀렉터로 사용 가능

⇒ styled component 내부에 사용된 자손 셀렉터는 별도의 styled component로 분리함

// X
const StyledComponent = styled.div`
	span{
		// 생략
	}
`

// O
const StyledComponent = styled.div``
const StyledSpan = styled.span``

— HTML 최적화

인라인 스타일 제거

<div style="background: red"></div> // X

// O, css사용
<div class="red"></div>
.red{
	background: red;
}

// O, styled component
<Red></Red>
const Red = styled.div`
	background: red
`

❓이유: 인라인 스타일은 웹 페이지가 그려지면서 레이아웃에 영향을 미쳐 추가 리플로우 발생 + 유지보수 측면

❗해결: 스타일은 스타일시트에 작성한다. 또는 인라인 스타일들을 styled component로 옮김

— JavaScript 최적화

스크롤 가상화

대규모 데이터를 그릴 때 실제 표시 영역만 렌더링해 렌더링 속도를 높이는 기술

  • react-virtualized 패키지 사용(+react-table)
import { WindowScroller, List } from 'react-virtualized';
const tableInstance = useTable(
    { 
      defaultColumn: { disableSortBy: true, sortDescFirst: true },
      columns: props.columns,
      data: props.data ?? [], 
      initialState, 
      autoResetSortBy: false,
    },
    useBlockLayout,
    useSortBy
  );

const { getTableProps, getTableBodyProps, headerGroups, rows, prepareRow } = tableInstance;

const RenderRow = React.useCallback(
    ({ index, style }: any) => {
      const row = rows[index];
      prepareRow(row);
      const rowHeight = props.rowHeight;
      const height = typeof rowHeight === 'function' ? rowHeight(row.original, index) : rowHeight;
      return (
        <TableBodyRow
          {...row.getRowProps({
            style,
          })}
          height={height}
          noBorder={props.noBorder}
        >
          {row.cells.map((cell) => { 
            return (
              <div
                {...cell.getCellProps({
                  style: { 
                    ...(cell.column as TableColumn<D>).rowStyles,
                  },
                })}
              >
                <>{(cell.column as TableColumn<D>).index ? index + 1 : cell.render('Cell')}</>
              </div>
            );
          })}
        </TableBodyRow>
      );
    },
    [prepareRow, rows, props.noBorder, props.rowHeight]
);

<WindowScroller>
    {({ height, scrollTop, isScrolling, onChildScroll }) => (
         <List
              autoHeight
              height={height}
              width={LIST_WIDTH}
              isScrolling={isScrolling}
              overscanRowCount={0}
              onScroll={onChildScroll}
              scrollTop={scrollTop}
              rowCount={rows.length}
              rowHeight={ROW_HEIGHT}
              rowRenderer={RenderRow}
         />
    )}
</WindowScroller>

Memoization

동일한 계산 반복시 이전 값을 메모리에 저장해 재계산을 방지하여 속도를 높이는 기술

  • useMemo 사용
// events값이 같다면 groupEvent함수를 통해 eventGroups를 다시 연산하지 않아 계산비용 축소
const eventGroups = useMemo(() => groupEvent(events), [events]);
  • useCallback + React.memo 사용
    ⇒ useCallback(상위 컴포넌트에서 전달되는 메소드) + React.memo(리렌더링 막을 하위 컴포넌트에 씌움)
	/* 상위 컴포넌트 */
const handleChange = useCallback(() => {
    setState((s) => !s);
}, []);

// props로 전달되는 handleChange에 useCallback을 씌워야 React.memo()로 감쌌을 때
// handleChange이 변하지 않았다면 리렌더링 방지 가능. 안 씌우면 변하지 않아도 리렌더링됨
<MemoizedBtn handleChange={handleChange} />

	/* 하위 컴포넌트인 MemorizedBtn 파일 내부 */ 
function Btn({ handleChange }: Props) {
	// 생략
}

// React.memo로 감싸기
export const MemoizedBtn = React.memo(Btn);

❓이유: 함수형 컴포넌트에서 컴포넌트 리렌더링마다 같은 계산을 반복하는 경우가 생겨 이를 방지하고자 사용

  • 상위컴포넌트 리렌더링시 하위 컴포넌트가 같은 props라면 다시 렌더링되지 않도록 함 ⇒ 하위 컴포넌트의 불필요한 리렌더링 방지

❗해결: useMemo, useCallback+React.memo 사용

💡 로딩 최적화

목적: 로딩 시간을 줄여 사용자 경험(UX) 향상하거나 불필요한 자원 낭비 방지

렌더링 차단 리소스 제거

❓이유: index.html에 사용한 라이브러리 스크립트 로딩을 기다린 후 html 파싱해 첫 화면 뜨는 시점(FCP), 상호 작용 가능한 시점(TTI)에 영향을 줌

❗해결: defer 속성 추가 / 단, 해당 스크립트 로드 후 동작해야하는 코드가 있어 동적으로 스크립트 추가

/* 기존 방식 */
// index.html
<!-- sns 로그인 카카오 -->
<script src="https://developers.kakao.com/sdk/js/kakao.js"></script>
<!-- sns 로그인 네이버-->
<script
  type="text/javascript"
  src="https://static.nid.naver.com/js/naveridlogin_js_sdk_2.0.2-nopolyfill.js"
></script>
<!-- sns 로그인 애플 -->
<script
  type="text/javascript"
  src="https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js"
></script>

// App.tsx
useEffect(() => { 
  kakaoInitialize();
  appleInitialize();
}, []);
/* 변경 방식 */
// App.tsx
const addScript = (src: string, id: string, callback?: () => void) => {
    const existingScript = document.getElementById(id);
    if (!existingScript) {
      const script = document.createElement('script');
      script.src = src;
      script.id = id;
      script.type = 'text/javascript';
      script.defer = true;
      document.head.appendChild(script);

      script.onload = () => {
        if (callback) callback();
      };
    }
    if (existingScript && callback) callback();
};

useEffect(() => {
  addScript('https://developers.kakao.com/sdk/js/kakao.js', 'kakaoLoginScript', kakaoInitialize);
  addScript('https://static.nid.naver.com/js/naveridlogin_js_sdk_2.0.2-nopolyfill.js', 'naverLoginScript');
  addScript(
    'https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js',
    'appleLoginScript',
    appleInitialize
  );
}, []); 

[적용 전] FCP 1.4초 / TTI 2.4초

[적용 후] FCP 1.2초 / TTI 2.2초

— 이미지 최적화

lazy loading: 지연 로딩

<img src="image.png" loading="lazy" alt="" />

❓이유: 페이지 로드시 현재 보이지 않는 불필요한 이미지들까지 한번에 로드 후 렌더링해 불필요한 자원 낭비

❗해결: 실제 화면에 보이는 이미지만 불러오기 ⇒ img 태그에 loading=”lazy”를 넣어 lazy loading 처리

[적용 전] 1000밀리초

[적용 후] 800밀리초

⇒ 200밀리초 단축

가로 세로 크기 지정

<img src="image.png" width="120" height="84" alt="" />

❓이유: 브라우저가 HTML을 가져왔을 때 각 이미지가 차지하는 영역을 알 수 있어 이미지 로드 전에 먼저 페이지를 그릴 수 있다.

❗해결: img 태그 width, height 속성 명시

webp 도입

  • 커뮤니티 미리보기 이미지 webp로 변경 ⇒ 네트워크 페이로드 감소

    • webp: 인터넷에서 이미지 로딩 시간 단축 위해 구글이 출시한 파일 포맷
    • 적용 결과: 평균 54.2% / 최고 98% 축소, 최저 18% 축소
    • 22.2kb => 18.2kb(4kb 감소, 18%)
    • 192kb => 2.8kb(189.2kb 감소, 98%)

    • 5.2kb => 2.9kb(2.3kb 감소, 44%)
    • 10.2kb => 5.4kb(4.8kb 감소, 47%)
    • 4.2kb => 1.5kb(2.7kb 감소, 64%)
          

— 자바스크립트 최적화

코드 스플리팅

번들 파일을 여러 묶음으로 쪼개는 기법. 당장 필요하지 않은 코드를 분리하고 필요한 시점에 가져온다.

// 기존
import MainPage from './MainPage'
import ViewPage from './ViewPage'

<Routes>
	<Route path='/' element={MainPage} />
	<Route path='/view' element={ViewPage} />
</Routes>

// 성능 최적화 후: React.lazy + Suspense
import React, {Suspense} from 'react'
const MainPage = React.lazy(()=> import('./MainPage'))
const ViewPage = React.lazy(()=> import('./ViewPage'))

<Suspense fallback={<div>loading...</div>}> 
	<Routes>
		<Route path='/' element={MainPage} />
		<Route path='/view' element={ViewPage} />
	</Routes>
</Suspense>

❓이유: 사용하지 않는 소스코드를 전부 로딩하는 과정은 불필요하고 소스코드 분리와 필요한 파일만 로딩함으로써 로딩 시간과 용량을 줄여준다.
페이지별 코드 스플리팅이 필요. 한 페이지를 보는 동안 다른 페이지는 쓰이지 않기 때문
❗해결: React.lazy와 Suspense를 사용해 구현

참고

https://ui.toast.com/fe-guide/ko_PERFORMANCE#%EC%9B%B9-%ED%8E%98%EC%9D%B4%EC%A7%80-%EB%A1%9C%EB%94%A9-%EC%B5%9C%EC%A0%81%ED%99%94

http://blog.wystan.net/2007/08/01/img-tag-with-width-and-height

https://iborymagic.tistory.com/90

https://jforj.tistory.com/162

profile
문제 해결을 좋아하는 주니어 프론트엔드 개발자

0개의 댓글