Lighthouse 활용해서 성능 개선하기

krkorklo·2022년 12월 15일
0

Lighthouse란?

웹 페이지의 품질을 검사하고 개선할 수 있는 도구이다.

확인할 수 있는 카테고리

categorydescription
Performance웹 페이지의 로딩 속도 등 성능
Progressive Web App웹과 네이티브 앱의 기능 모두의 이점을 가지는 서비스인지 확인
Best Practices권장 사항을 준수하였는지 확인
Accessibility이미지, 폰트 사이즈 등을 통해 접근성을 확인
Search Engine Optimization검색 엔진 수집 최적화를 확인

Performance 측정 지표

metricdescription
First Contentful Paint (FCP)페이지가 로드되기 시작한 시점부터 페이지 콘텐츠의 일부가 화면에 렌더링 될 때까지의 시간
Time To Interactive (TTI)페이지가 로드되기 시작한 시점부터 시각적으로 사용자가 완전히 상호작용할 수 있는 상태가 될 때까지의 시간
Speed Index콘텐츠가 시각적으로 표시될 때까지의 시간 (같은 시간동안 콘텐츠가 그려져도 더 큰 영역을 먼저 채워나가면 SI가 좋다)
Total Blocking Time (TBT)메인 스레드가 긴 시간 중단되어 응답을 받을 수 없을 만큼 차단되었을 때의 시간 (FCP - TTI)
Largest Contentful Paint (LCP)페이지가 로드되기 시작한 시점부터 가장 큰 텍스트 블록 또는 이미지 요소가 화면에 렌더링 될 때까지의 시간
Cumulative Layout Shift (CLS)비동기 동작, 동적 DOM 변경 등으로 웹 페이지의 레이아웃 변경이 발생하는 빈도

Lighthouse 성능 검사

프로토타입이 완성된 후 Lighthouse 성능 검사를 진행해보았는데 생각보다 점수가 높게 나왔다🙌

하지만!! 여기서 끝나는게 아니고 점수를 100점으로 만들어보기 위해 노력해보자.

이미지 접근성 및 SEO 개선

먼저 이미지 관련 결과를 살펴보자. 접근성이 낮았던 이유는 alt가 명시되어있지 않은 이미지들이 있었기 때문에, SEO가 낮았던 이유는 widthheight가 명시되지 않은 이미지가 있었기 때문이었다.

alt는 이미지 콘텐츠를 설명하는 역할이다. 이미지를 텍스트로 설명해야하는 경우(스크린 리더 등) alt 속성을 사용하게 되는데, 이때 명확하게 이미지를 설명하지 않으면 접근성이 떨어진다.

widthheight를 명시하지 않으면 이미지를 로드해오기 전까지 해당 이미지의 크기를 알 수 없고 이미지가 로드 되어야 크기가 결정되므로 중간에 레이아웃이 변경된다. 결국 불필요한 레이아웃 변경이 생기는 문제가 있다. (CLS 문제)

→ 이미지의 크기와 alt 속성을 명시하자!

// Component
<img alt="user profile image" className="user-icon" src={userIcon} />

// css
.user-icon {
	width: 30px;
	height: 30px;

	margin-top: 6px;
}

alt 문제로 인해 점수가 깎였던 검색 엔진 최적화와 width, height로 점수가 깎였던 CLS 부분에서 점수를 회복했다.

번들 사이즈 개선

번들된 자바스크립트 사이즈가 크므로 가능한 절감하라는 권장 사항이 있었다.

현재 프로젝트는 번들 파일을 하나만 생성한다. 하나로 병합된 파일을 첫 렌더링 시에 로드함으로써 한 번에 전체 앱을 로드한다.

몇 십 개의 파일이 하나의 파일로 합쳐졌다는 말은 그만큼 번들의 사이즈가 커졌다는 말이다. 번들의 사이즈가 커지면 웹 페이지에 처음 진입할 때 번들 파일을 로드하는 시간이 길어지고 앞선 지표 FCP, LCP에 시간이 오래 걸리게 된다. 결국 번들을 나누는 code splitting을 진행해 적절하게 사이즈를 줄이고자 한다.

code splitting

하나의 번들링 파일을 만드는 것이 아니라 여러 개로 코드를 분할하면 파일을 불러올 때 현재 필요하지 않은 코드는 로드하지 않을 수 있다. 결국 현재 필요한 파일들만 불러오는 lazy loading이 가능하기 때문에 첫 로드 시간을 개선할 수 있다.

code splitting을 적용하는 가장 좋은 방법은 동적 import를 사용하는 것이다.

import('./moduleA')
  .then(({ moduleA }) => {
    // Use moduleA
  })
  .catch(err => {
    // Handle failure
  });

함수 형태로 import를 사용하게 되면 Promise에서 모듈을 실행할 수 있다. 결국 해당 구문을 만나기 전까지 import가 되지 않기 때문에 무거운 모듈의 경우 동적 import가 효율적인 사용이 될 수 있다.

번들링을 생각해봤을 때는 위와 같이 동적 import를 하게되면 moduleA는 독립된 chunk로 생성되고 해당 코드가 로드되어야 할 시점에 로드가 된다.

code splitting 방식은 다양한데, 가장 기본적으로 페이지 단위로 상호작용을 나눌 수 있기 때문에 라우팅에 적용할 수 있다. (사용자가 상호작용을 한 경우 나타나는 모달창 같은 경우에도 따로 나눠주면 좋을 것 같다)

React.lazy

React에서는 code splitting을 지원하기 위해 lazy 함수를 제공한다. lazy 함수는 컴포넌트를 렌더링하는 시점에 비동기적으로 컴포넌트를 로딩할 수 있게 해준다.

const Component = React.lazy(() => import('./Component'));

lazy 함수는 동적 import를 호출하는 함수를 인자로 가진다. 이때 import 문은 default export로 컴포넌트를 반환해야한다. 아직까지는 default export만 지원한다.

  • lazy를 조금 더 살펴보자!
    react 코드를 뜯어보면,
    export function lazy<T>(
      ctor: () => Thenable<{default: T, ...}>,
    ): LazyComponent<T, Payload<T>> {
      const payload: Payload<T> = {
        // We use these fields to store the result.
        _status: Uninitialized,
        _result: ctor,
      };
    
      const lazyType: LazyComponent<T, Payload<T>> = {
        $$typeof: REACT_LAZY_TYPE,
        _payload: payload,
        _init: lazyInitializer,
      };
    	...
    }
    lazy 함수의 내부 코드는 간략하게 위와 같다. lazy 함수는 인자로 Thenable Type을 반환하는 함수를 받는다. 여기서 Thenable Type을 반환하는 함수는 import가 된다.
    export interface Wakeable {
      then(onFulfill: () => mixed, onReject: () => mixed): void | Wakeable;
    }
    
    interface ThenableImpl<T> {
      then(
        onFulfill: (value: T) => mixed,
        onReject: (error: mixed) => mixed,
      ): void | Wakeable;
    }
    interface UntrackedThenable<T> extends ThenableImpl<T> {
      status?: void;
    }
    
    export interface PendingThenable<T> extends ThenableImpl<T> {
      status: 'pending';
    }
    
    export interface FulfilledThenable<T> extends ThenableImpl<T> {
      status: 'fulfilled';
      value: T;
    }
    
    export interface RejectedThenable<T> extends ThenableImpl<T> {
      status: 'rejected';
      reason: mixed;
    }
    
    export type Thenable<T> =
      | UntrackedThenable<T>
      | PendingThenable<T>
      | FulfilledThenable<T>
      | RejectedThenable<T>;
    Thenable은 위와 같이 정의된 Type이다. 결국 Promise와 같이 pending, fulfilled, rejected 상태를 가진다는 것을 알 수 있다. 동적 import를 하게 되면 import를 수행하고 수행 결과 Promise에 따라 fulfilledrejected 상태를 가지게 된다. fulfilled 되는 경우는 LazyComponent가 반환된다.

또한 Suspense 컴포넌트를 사용해 lazy loading된 컴포넌트가 렌더링되기 전 어떤 화면을 렌더링할 것인지 표시해야한다. Suspense는 감싸고 있는 컴포넌트의 Promise 상태를 받아 렌더링 중이면 fallback props에 있는 JSX를 먼저 보여준다.

<Suspense fallback={<div>Loading...</div>}>
  <div>
    <OtherComponent />
    <AnotherComponent />
  </div>
</Suspense>

Code Splitting 적용해서 번들 사이즈 최적화하기

import Main from '@pages/main';
import Login from '@pages/login';
import Workspace from '@pages/workspace';
import Error from '@pages/error';

function App() {
	return (
		<Routes>
			<Route
				path="/"
				element={
					<ProtectedRoute>
						<Main />
					</ProtectedRoute>
				}
			/>
			<Route path="/login" element={<Login />} />
			<Route path="/workspace/:workspaceId" element={<Workspace />} />
			<Route path="/*" element={<Error />} />
		</Routes>
	);
}

기존에는 위와 같이 기본적인 라우팅 코드로 구성되어있었다.

import { lazy, Suspense } from 'react';

const Main = lazy(() => import('@pages/main'));
const Login = lazy(() => import('@pages/login'));
const Workspace = lazy(() => import('@pages/workspace'));
const Error = lazy(() => import('@pages/error'));

function App() {
	return (
		<Suspense fallback={<Loading />}>
			<Routes>
				<Route
					path="/"
					element={
						<ProtectedRoute>
							<Main />
						</ProtectedRoute>
					}
				/>
				<Route path="/login" element={<Login />} />
				<Route path="/workspace/:workspaceId" element={<Workspace />} />
				<Route path="/*" element={<Error />} />
			</Routes>
		</Suspense>
	);
}

lazy 함수와 Suspense를 사용해 각 route의 코드를 lazy loading 적용해주었다.

code splitting을 진행하기 전 번들의 사이즈는 940kB, 로드하는데 걸리는 시간은 136ms이었다.

code splitting을 진행한 후 번들의 사이즈는 515kB로 줄었고, 첫 로드 시 97ms가 걸린다. 이전에 비해 줄어든 모습을 볼 수 있다!

code split을 하니까 성능이 한층 더 높아졌다👏

색상 대비율 개선

마지막으로 애매하게 점수가 깎인 접근성 부분을 보니까 background와 텍스트 간의 대비가 충분하지 않아서 접근성이 저하되었다고 나와있다.

위 이미지의 Edited 1 days ago 부분인데, 나름 진한 색이라고 생각했지만 애매했던 모양이다.

시간을 나타내는 색상을 #808080에서 조금 더 진한 #515151색으로 바꾸었다.

짜잔
접근성이 100점이 되었다!

+) 추가 성능 개선?

성능이 97점인게 마음에 안들어서 자세히 살펴보니 react_devtools_backend 번들이 사이즈가 크다고 나왔다. react devtool 확장을 끄고 다시 측정해보았다.

100점🥳


참고 자료
https://web.dev/user-centric-performance-metrics/
https://reactjs.org/docs/code-splitting.html
https://webpack.kr/guides/code-splitting/
https://create-react-app.dev/docs/code-splitting/

0개의 댓글