Lighthouse로 Next.js 프로젝트 성능 개선하기

조예진·2021년 12월 4일
19
post-thumbnail

라이트하우스는 웹앱 품질 측정도구이다. 웹앱의 성능, 접근성, SEO 등을 검사해주고, PWA 조건을 만족하는지도 검사해준다. 크롬 확장 프로그램으로 다운받아서 사용할 수 있고, 혹은 크롬 개발자 도구에서 Lighthouse 탭으로 들어가 사용할 수도 있다.

라이트하우스로 개발하고 있는 웹 프로젝트의 성능을 개선해 보기로 했다. 배포 환경에서 성능을 측정한 결과,

노란 불이 세 개나 켜졌다. 라이트하우스는 점수와 함께 어떤 부분이 미흡하고 어떻게 개선시키면 되는지를 참고할 문서 링크와 함께 알려준다.

Performance

퍼포먼스 점수가 가장 낮게 나왔다. 첫 콘텐트풀 페인트(First Contentful Paint)는 첫 번째 텍스트와 이미지 요소가 화면에 렌더링되는 시간인데, 그 시간이 2초나 걸리고 있다. 상호작용이 가능해지는 시간은 7.9초로, 전체적으로 매우 느리게 나타났다.

이미지

Image elements do not have explicit width and height

img 요소에는 명시적으로 width와 height 속성값을 넘겨줘야 한다. 명시적으로 넘겨줘야 한다는 의미는 100%, auto처럼 다른 요소에 종속적인 값이 아니라 200px처럼 값 자체를 넘겨줘야 한다는 것이다.

이미지는 보통 용량이 크기 때문에 로드되는 데 오래 걸린다. 이미지가 로드되기 전까지 브라우저는 불러오는 이미지의 너비와 높이를 알 수 없기 때문에 img 태그의 자리를 할당해 주기 어려워 빈 공간으로 남겨 둔다. 그리고 이미지가 로드되면 그 너비와 높이만큼 자리를 만들어 준다.

이렇게 되면 레이아웃이 갑자기 변경되는 CLS(Cumulative Layout Shift, 누적 레이아웃 이동)가 발생한다. CLS는 페이지 레이아웃이 예상치 못하게 갑자기 바뀌는 현상이다. 사용자 경험 면에서 매우 나쁘다.

따라서 img 태그에 명시적인 속성값을 넘겨주어야 한다. widthheight 값을 주면 이미지가 로드되기 전에도 그 크기만큼 공간을 할당해 줄 수 있다.

그런데 사용자가 업로드하는 이미지는 width 값과 height 값을 예상하기 어렵다. 그래서 width 값과 height 값을 줘버리면 이미지가 찌그러질까봐 속성값을 주지 않았었는데, CSS를 통해 다시 조정해 주니 이미지가 찌그러지지 않았다.

이미지 로딩 & 캐시

  • Serve static assets with an efficient cache policy
    • 이미지를 AWS S3 버킷에 저장하고 있는데, S3에서 가져온 이미지에 캐시 TTL이 설정되어 있지 않았다. 이걸 해결하려면 일일이 s3 이미지 리소스에 캐시 설정을 달아주거나, cloudfront를 CDN으로 연결해서 구성해 주어야 한다고 한다.
  • Serve images in next-gen formats
    • 이미지 형식을 업로드된 그대로 사용하고 있었는데, WebP 등 새로운 이미지 파일 형식을 사용하는 것이 좋다고 한다. WebP는 구글이 제공하는 웹을 위한 새로운 이미지 파일 형식으로, JPEG의 손실 있는 압축과 PNG의 투명도 기능을 제공하면서도 더 나은 압축을 제공한다.
  • Defer offscreen images
    • Lazy loading을 사용하라는 것이다. Lazy loading은 이미지 영역이 화면에 보이지 않을 때는 이미지를 불러오지 않고, 화면에 보이게 되었을 때 이미지를 불러오는 방식이다.

Next.js에서는 내장된 next/image 라이브러리를 사용하면 위의 내용을 해결할 수 있다. 프로젝트 내에서 사용하고 있던 공통 ImageComponent에서는 이미지 표시를 위해 기본 HTML img 요소를 사용했는데, 이를 next/image의 Image 컴포넌트로 대체했다.

속성으로 loading="lazy" 값을 주면 Lazy loading이 적용된다. next.config.js 파일에서 이미지의 캐시 TTL을 지정해 줄 수 있다. 외부 도메인에서 이미지를 가져올 경우, next.config.js 파일에 도메인 정보를 추가해 주어야 한다.

// next.config.js
module.exports = {
  images: {
    domains: [
      "fork-fork-cake.s3.ap-northeast-2.amazonaws.com",
      "cdn.pixabay.com",
    ],
    minimumCacheTTL: 60,
  }
};

next/image의 Image 컴포넌트를 사용하면

    <Image
      src={imgSrc || fallback}
      alt={alt}
      width={width}
      height={height}
      loading="lazy"
      placeholder="empty"
      onError={onError}
    />

폰트

Ensure text remains visible during webfont load

CSS의 @font-face 부분에 font-display: swap 를 넣어주면 폰트가 로딩되지 않았을 때나 폰트를 로드하지 못했을 때 시스템 폰트를 보여준다. 이렇게 넣어주지 않으면 폰트가 로드되지 않았을 때 글씨가 화면에 나타나지 않는다.

link 태그로 불러오는 웹 폰트일 경우, <link rel="preload" as="font"> 처럼 preload를 넣어 주면 폰트를 먼저 불러오기 때문에 렌더링된 처음부터 폰트가 적용되어 있다.

만약 구글 폰트라면, url 뒤에 &display=swap 을 넣어주면 swap이 적용된다. 예시: <link href="https://fonts.googleapis.com/css?family=Roboto:400,700&display=swap" rel="stylesheet">

Serve static assets with an efficient cache policy

CSS Import로 가져오던 웹 폰트가 있었는데, 폰트를 다운받아 프로젝트 내부에서 정적 리소스로 가져다 쓰도록 바꾸어 주었다.
(정적 리소스로 바꾼 것이 캐시나 네트워킹 속도에 영향을 주었는지 정확하지 않다. 뒤에 나올 서비스워커 때문에 캐싱 되었을 수 있다.)

Avoid enormous network payloads

GmarketSans 폰트를 사용했는데, 다운로드할 수 있는 폰트는 otf와 ttf만 제공하길래 ttf를 다운로드해서 static resource에 넣어 두고 사용했다. 그런데 네트워크 페이로드 총 4,856 KiB 중에 GmarketSans만 2,289 KiB를 차지하고 있었다.

찾아 보니 폰트를 woff 형식으로 바꿔주는 사이트가 있었다. WOFF(Web Open Font Format)는 웹 글꼴 형식으로, 압축된 형식이라 TTF나 OTF보다 빠르게 로드된다. 메타 데이터로 라이센스 정보를 포함할 수도 있다고 한다.

GmarketSans는 누구나 제약 없이 자유롭게 수정하고 재배포할 수 있다고 되어 있어서, WOFF로 변환해서 정적 리소스에 다시 넣어 주었다.

종류가 문서로 되어 있는 파일이 WOFF로 변환된 폰트이다. TTF는 2MB 이상인 반면에(ㅎㅎ) WOFF 파일은 600KB 내외인 것을 확인할 수 있다.

카카오 API..

Avoid document.write()
도대체 쓴 적이 없는데 자꾸 쓰지 말라 해서 뭐가 잘못된 건가 했는데, 카카오 주소 API에서 document.write()를 써서 스크립트를 넣어준다고 한다. 2020년 6월 답변에 따르면 사용자 경험을 크게 해치지 않는다고 판단해 브라우저에서 사용 불가 수준의 선고가 내려지지 않는 이상 변경할 예정이 없다고 한다. 다른 걸 고쳐 두고 보니 빨간 경고가 뜨긴 해도 성능 점수에는 영향이 없길래 신경쓰지 않기로 했다. (ㅎㅎ)

자바스크립트

  • Reduce unused JavaScript
  • Minify JavaScript
  • Minimize main-thread work
  • Reduce JavaScript execution time

Next.js 번들 사이즈를 줄이려면

  1. code splitting을 해 주기
    • dynamic import를 적용해서 컴포넌트를 동적으로 import해 js 코드를 분리해 줄 수 있다. 참고
    • 컴포넌트를 무슨 기준으로 분리해 줘야 할 지 모르겠어서 우선은 보류했다...
  2. 코드 minify, compress
  3. 사용하지 않는 코드 삭제
  4. PRPL 패턴으로 네트워크 속도 개선하고 코드 캐싱하기
    • Push (or preload) the most important resources.
      • 중요한 리소스는 <link rel="preload" ...> 처럼 preload를 붙여 미리 로드해올 수 있다.
    • Render the initial route as soon as possible.
    • Pre-cache remaining assets.
      • 서비스워커를 활용해서 HTML, CSS 등을 캐싱할 수 있다.
    • Lazy load other routes and non-critical assets.

서비스 워커 캐싱

html, js, 이미지 등의 리소스를 캐싱하기 위해 서비스 워커를 도입했다. 그 결과 웹 로딩 속도가 5배 정도 빨라졌다. (점수가 낮은 이유는 로컬 개발 환경에서 성능을 측정했기 때문이다)

서비스 워커는 브라우저가 백그라운드에서 실행하는 스크립트로, 웹 응용 프로그램, 브라우저, 네트워크 사이에 프록시 서버 역할을 한다. 서비스 워커로 푸시 알림이나 오프라인 상태에서의 백그라운드 동기화와 같은 기능을 구현할 수 있다. 이를 통해 효과적인 오프라인 경험을 제공하고 네트워크 요청을 가로채 네트워크 사용 여부에 따라 적절한 행동을 취하게 할 수 있다. 리소스 요청을 가로채서 수정하거나 캐싱할 수도 있다. (참고 1: MDN 문서, 참고 2: 구글 Web Fundamentals 문서)


(이미지 출처: https://web.dev/apply-instant-loading-with-prpl/)
그림에서 첫 HTML, CSS 요청에서는 캐시에 해당하는 파일이 저장되어 있지 않았기 때문에 서버로 요청을 보내주었다. 그 응답으로 받은 HTML과 CSS를 서비스 워커가 가로채 캐시에 저장한 후에 클라이언트에 파일을 넘겨준다. 이후에 같은 요청이 들어올 때는 서버에 요청을 보내지 않고 캐싱해 두었던 HTML과 CSS를 넘겨준다.

서비스 워커는 보안 상의 이유로 HTTPS 혹은 localhost에서만 작동한다. 서비스 워커를 통해 네트워크 요청을 수정할 수 있기 때문에 공격에 매우 취약하기 때문이다.

이 프로젝트에서는 리소스 캐싱을 위해 서비스 워커를 사용했다. 서비스 워커와 관련된 코드는 아래와 같다.

// /public/sw.js
const cacheName = "::myServiceWorker";
const version = "v0.0.1";
const cacheList = [];

// 네트워크 fetch 시
self.addEventListener("fetch", (e) => {
  // 응답을 수정한다
  e.respondWith(
    // 요청에 대한 응답을 캐싱한 적이 있는지 확인한다
    caches.match(e.request).then((r) => {
      // 캐싱된 데이터가 있으면 그것을 반환한다
      if (r) {
        return r;
      }

      const fetchRequest = e.request.clone();
		
      // 캐싱된 데이터가 없으면 원래의 요청을 보낸다
      return fetch(fetchRequest).then((response) => {
        if (!response) {
          return response;
        }

        const requestUrl = e.request.url || "";
	
        const responseToCache = response.clone();
        // POST 요청에 대한 응답이나 chrome extension에 대한 응답은 캐싱 불가능하다.
        if (
          !requestUrl.startsWith("chrome-extension") &&
          e.request.method !== "POST"
        )
          // 캐싱 가능한 응답이면 캐시에 요청에 대한 응답을 저장한다.
          caches.open(version + cacheName).then((cache) => {
            cache.put(e.request, responseToCache);
          });
        
		// 요청을 반환한다.
        return response;
      });
    }),
  );
});
// _app.js
function MyApp({ Component, pageProps }) {
  useEffect(() => {
  	// 서비스 워커를 사용 가능하면
    if ("serviceWorker" in navigator) {
      // 서비스 워커를 설치한다.
      window.addEventListener("load", () => {
        navigator.serviceWorker.register("/sw.js");
      });
    }
  }, []);
  return <Component {...pageProps} />;
}

export default MyApp;

서비스 워커가 돌고 있는지 확인하려면 개발자 도구의 Application 탭의 Service Workers를 확인하면 된다. 실행되고 있으면 Status에 초록불이 들어온다.

사용하고 있는 캐시의 용량은 아래의 Storage 메뉴에서 확인할 수 있다. Clear site data를 누르면 해당 사이트의 모든 캐시 데이터가 비워진다.

일단 GET 요청 보내는 것들을 다 캐싱했더니 캐시 용량이 31MB를 차지하고 있다... 자바스크립트 크기를 줄이거나 꼭 필요한 것만 캐싱하게 수정이 필요할 것 같다. (아래 스크린샷)

....라고 생각했는데 구글 개발자 문서는 500MB가 넘어가서 그냥 이대로도 괜찮겠다고 생각했다. (아래 스크린샷)

Accessibility

Background and foreground colors do not have a sufficient contrast ratio.

color 값과 background-color 값의 대비가 충분하지 않아 발생한 경고이다. 배경 색이 흐리한 연핑크라 대비가 충분하지 않았던 것 같다. 좀 더 강한 핫핑크를 주었더니 쓸데없이 강조가 되긴 하지만 확실히 글씨는 잘 보인다.

cmd + Shift + C 를 눌러 요소를 선택하면 styles 탭이 열리면서 해당 요소의 스타일 값을 보여준다. color의 색상 미리보기 사각형을 클릭해 보면 Contrast ratio를 확인할 수 있다. (아래 사진 참고)

  • AA: 최소 조건 대비. 텍스트의 배경과 텍스트의 대비는 4.5:1이어야 한다. 단, 텍스트의 크기가 큰 경우에는 3:1의 대비만 충족되어도 된다. 텍스트가 단순히 페이지를 꾸미기 위한 용도이거나 로고나 브랜드 이름의 일부일 경우에는 대비 조건을 만족하지 않아도 된다.
  • AAA: 최적 조건 대비. 배경과 텍스트가 7:1의 대비를 가져야 좋다. 단, 텍스트의 크기가 클 경우에는 4.5:1의 대비만 가져도 된다. AA와 마찬가지로, 단순 꾸미기 위한 텍스트나 로고나 브랜드 이름의 일부가 되는 텍스트는 대비 조건을 만족하지 않아도 된다.

AAA까지 만족하면 Contrast ratio 옆에 붙는 체크가 쌍체크가 된다. 색에 검은색을 좀 많이 섞어 줘야 쌍체크가 떠서.. 슬프지만 체크 하나로 일단은 만족..

Buttons do not have an accessible name

내용이 없고 아이콘만 있는 버튼에 이름이 붙어 있지 않아서 발생한 경고이다. 버튼에 컨텐츠가 있으면 자동으로 그 컨텐츠가 이름이 되는데, 플로팅 버튼에 아이콘만 넣어 두었더니 기본 설정된 이름이 없어서 오류가 발생했다. button 요소에 title 속성에 플로팅 버튼의 이름을 넣어주었다.

요소의 이름을 보려면, 개발자 도구의 Elements 탭의 패널에서 Accessibility 탭을 확인하면 된다.

[user-scalable="no"] is used in the <meta name="viewport"> element or the [maximum-scale] attribute is less than 5.

viewport 옵션 때문에 발생했다. 네이버 모바일 화면과 구글 모바일 화면을 참고해서 넣었는데, user-scalable을 금지하거나 maximum-scale이 5보다 작으면 접근성 이슈가 있다고 한다. 시력이 낮은 분들이 화면을 확대할 수 없기 때문인데.. 그런데 이걸 허용하면 텍스트를 입력할 때 화면이 확대되고 입력 완료 후에도 확대된 채로 있기 때문에 좀 불편한 옵션이다. 일단은... 그대로 두기로 했다. 웹뷰를 염두에 두고 만든 프로젝트라 확대는 막아줘야 할 것 같다.

<meta
  name="viewport"
  content="width=device-width,initial-scale=1,minimum-scale=1,maximum-scale=1,user-scalable=no,minimal-ui"
/>

SEO

Document does not have a meta description

분명 최근에 메타 태그를 열심히 넣어 준 기억이 있는데 description을 포함해서 og graph 메타 태그까지 아무 것도 없어서 당황했다... 대체 어느 프로젝트였던건지... _document.tsx 파일에 메타 태그를 넣어주었다.

<meta
  name="description"
  content="페이지 설명"
/>

PWA

서비스 워커

PWA의 조건 중 하나는 manifest.json 파일에 있는 start_url에서 서비스 워커를 작동시키는 것이다. 서비스 워커 구성은 위에서 알아봤다.

manifest

  • https://maskable.app/editor 에서 maskable 아이콘을 만들어 주었다. 플랫폼에 따라서 앱 아이콘을 보여주는 마스크 모양이 다르다. (둥근 사각형, 사각형, 원, 말풍선 모양 등...) 그래서 어떤 마스크를 씌우더라도 예쁘게 나올 수 있는 마스커블 아이콘을 따로 만들어 주는 것이 좋다. 만든 아이콘은 manifest.json의 icons 배열에 추가한다.
icons: [{
   "src": "\/maskable_icon_x384.png",
   "sizes": "384x384",
   "type": "image\/png",
   "density": "4.0",
   "purpose": "maskable"
  }]
  • Custom Splash Screen
    • manifest.json 파일에 앱 이름, 배경 색, 512x512 사이즈 아이콘을 추가하면 스플래시 스크린을 자동으로 만들어준다.
// manifest.json
{
  name: 앱 이름,
  background_color: "#fff",
    icons: [
      {
        "src": "\/icon-512x512.png",
        "sizes": "512x512",
        "type": "image\/png",
        "density": "4.0"
      }
    ]
  // 그 외 여러가지 설정...
}
  • html 메타 태그에 theme-color 태그를 넣어 주면 주소창의 색깔이 테마 컬러로 설정된다.
<meta name="theme-color" content="#bbb" />

installable, PWA optimized 뱃지가 달렸다! 이제 크롬 주소창에서 '크롬 앱으로 다운받기' 버튼이나 '크롬 앱으로 열기' 버튼을 확인할 수 있다.

라이트하우스가 돌지 않을 때

가끔식 라이트하우스가 98% 까지 가서는 안 될 때가 있다..... 심증으로는 서비스워커가 원인인 것 같다.

그럴 때 해결 방법

  1. 라이트하우스 탭 안에 톱니바퀴를 누르고 Clear Storage의 체크를 풀고 다시 실행해 본다.

  1. 그래도 99%에서 멈춰 있다면 캔슬 버튼을 눌렀다가 다시 Generate 버튼을 누르면 보고서를 보여준다.

전부 백 점 만들면 폭죽 터뜨려 준대서 몇 줄 주석 치고 백 점으로 만들어 봤는데 폭죽은 안 터뜨려 줬다. 쩝

최종 개선 결과

성능에서는 서비스 워커의 캐싱이 큰 역할을 했다.. js, 폰트, 이미지 등의 리소스를 캐싱해줘서 페이지 로딩 시간이 정말 빨라졌다. 모든 수치를 1초 미만으로 낮출 수 있었다.

접근성 점수를 잠깐 좀 포기했다... 그 이유는

  • 배경-텍스트 대비를 충분히 주지 않아서 접근성이 깎였다. 바꿨던 강렬한 핫핑크가 너무 안 예뻐서 일단 원래의 분홍색으로 바꿔뒀다. 좀 더 예쁘고 흰색과 대비를 이루는 핑크를 찾으면 변경하려고 한다.
  • viewport에서 확대를 막아서 접근성 점수가 또 깎였다.

새로고침을 마구 눌러도 깜빡임이 거의 보이지 않을 정도로 빠르게 로드된다. 🙌

라이트하우스 덕분에 접근성이나 이미지, 폰트 등 정적 리소스 로딩 성능 등 놓치고 있던 것들을 개선할 수 있었다. 그런데 자바스크립트 코드 splitting, minify, compress에 대해서는 아직 따로 해 준 것이 없어서 js 청크 하나하나의 크기가 큰 상태다. 서비스 워커로 캐싱을 해줘서 한 번 로드한 후에는 빠르게 페이지를 그려내지만, 근본적인 문제는 해결되지 않았기 때문에 캐시가 없는 상태이거나 서비스 워커가 동작하지 않는 상태에서 사이트에 접근하면 여전히 로딩이 매우 오래 걸린다. 어떻게 해야 할 지 추가 조사가 필요하다.

profile
https://oooooroblog.com 으로 이사갔어요

0개의 댓글