프로젝트를 진행하며 경험한 성능 최적화 방식을 정리했다. 성능 측정에는 lighthouse를 사용했다.
목적: 리플로우를 최대한 적게 발생시키면서 빠르게 화면 그리기
리플로우 발생시 브라우저가 전체 픽셀을 다시 계산해야하기 때문에 성능에 좋지 않아 줄여야한다.
.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``
<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로 옮김
대규모 데이터를 그릴 때 실제 표시 영역만 렌더링해 렌더링 속도를 높이는 기술
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>
동일한 계산 반복시 이전 값을 메모리에 저장해 재계산을 방지하여 속도를 높이는 기술
// events값이 같다면 groupEvent함수를 통해 eventGroups를 다시 연산하지 않아 계산비용 축소
const eventGroups = useMemo(() => groupEvent(events), [events]);
/* 상위 컴포넌트 */
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);
❓이유: 함수형 컴포넌트에서 컴포넌트 리렌더링마다 같은 계산을 반복하는 경우가 생겨 이를 방지하고자 사용
❗해결: 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초
<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로 변경 ⇒ 네트워크 페이로드 감소
번들 파일을 여러 묶음으로 쪼개는 기법. 당장 필요하지 않은 코드를 분리하고 필요한 시점에 가져온다.
// 기존
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를 사용해 구현
http://blog.wystan.net/2007/08/01/img-tag-with-width-and-height