어느 날 구글 애널리틱스에서 회사 웹사이트의 페이지 속도를 보는데 충격을 받았던 기억이 있다. 당시 회사에서 밀고 있던 A상품과 메인 페이지가 페이지뷰가 가장 많았는데 정말 슬프게도 이 두 개의 평균 페이지 로드 시간이 웹 페이지 통틀어서 가장 안 좋은 수준이었다. 우리 웹사이트 기준으로도 안 좋았지만 전 세계 모든 웹사이트 기준 이상적인 페이지 로드 시간에서도 크게 벗어났다. 마치 음식점에서 갖은 홍보를 통해 많은 손님을 식당에 받았는데 음식 하나 나오는데 1시간 넘게 걸리는 느낌이었다. 프론트엔드 개발자로서 이 부분에 대해 미처 신경 쓰지 못했다는 것이 부끄러웠고, 높은 우선순위를 두어 웹 성능을 최적화하는 프로젝트를 진행하게 되었다.


측정 환경

  • 65%에 해당하는 모바일 유저가 페이지 뷰가 가장 많은 A상품 혹은 메인 페이지를 첫 방문했을 때를 대상으로 한다.
  • 첫 방문 사용자를 기준으로  하기 위해 크롬 개발자 도구의 네트워크 탭에서 Disable cache를 체크한다.
  • 평균 성능의 모바일 기기를 기준으로 하기 위해 크롬 개발자 도구의 퍼포먼스 탭에서 Network: Fast 3G, CPU: 4x slow down으로 한다.
  • 모든 측정은 배포(production) 모드에서 한다. 일반적으로 개발(development) 모드는 배포 모드보다 앱을 더 느리게 로딩하고 실행한다. 버그로 이어질 수 있는 부분을 검증하는 코드가 추가되기 때문에 번들이 더 크기 때문이다.

측정 도구

  • Chrome DevTools Lighthouse tab - 로딩 성능
  • Chorome DevTools Performance tab - 렌더링 성능
  • Page Speed Insight - 필드 데이터 측정

개선 목표

Next.js 이커머스 사이트 예시

https://www.starbucksreserve.com/en-us/coffee
https://deliveroo.co.uk/

개선 방법

로딩 성능

  • 불필요한 요청 제거

눈에 보이지는 않지만 페이지 로드할 때마다 마운트 되는 컴포넌트들은 조건부 렌더링을 적극적으로 사용했다.

원래는 _document에서 모든 스크립트 태그를 관리하고 있었기 때문에 페이지 생성할 때마다 불필요한 스크립트 실행 시간이 상당했다. 그래서 next/script를 사용하여 페이지별로 필요한 스크립트를 실행시켰다.

  • 이미지 최적화

이미지는 JPG, PNG 등의 여러 포맷을 섞어 사용하고 있었는데, WebP 포맷으로 모두 변경했다.  여기서 최신 이미지 포맷인 AVIF, WebP 중에 무엇을 선택할지 고민했다. AVIF 가 WebP에 비해 더 긴 인코딩 시간, 더 작은 압축 크기를 특징으로 한다. 그렇기 때문에 처음 요청에서는 더 느리지만, 캐싱 이후의 요청에서는 더 빠를 것이다. 캐싱이 되어있지 않을 때의 로드 시간을 줄이고 싶었기에 WebP를 선택했다.

무손실 압축을 도와주는 ImageOptim도 모든 이미지에 적용해서 최적화시켰다.

앞으로 디자이너가 만들어준 이미지를 추출할 때도 과도하게 높은 해상도 보다는 2배수 레티나 디스플레이까지 커버할 수 있도록 2배의 해상도로 추출하기로 했다.

  • 동영상 최적화

웹사이트 곳곳에 자동 재생 동영상이 몇 개 있었는데, 이 중에 크기가 20mb가 넘는 것도 있었다. 방문이 아주 드문 페이지에 쓰이는 것이어서 미처 신경 쓰지 못했던 것이 부끄러웠다. 이번 기회를 통해 불필요한 재생 구간을 삭제하고, 렌더링 크기에 맞춰서 비율을 줄이고, 최적의 해상도로 업데이트했다.

이 프로젝트 후에 WebM 포맷을 알게 되었는데 다음부터 이 포맷을 사용해보는 것도 좋을 것 같다.

  • 자바스크립트 최적화

트리 셰이킹(Tree Shaking)과 코드 분할(Code Splitting)을 사용했다. 코드 분할의 경우 Dynamic Import 기능으로 구현했고, 모달 컴포넌트에 유용하게 사용할 수 있었다.

  • 폰트 최적화

원래 사용하는 구글 웹 폰트를 이용하여 폰트 서브셋팅을 한 결과물을 적용했다.

  • 레이지 로딩 이미지

next/image 를 사용했다. 만약에 이 기능을 사용할 수 없었다면 Intersection Observer API로 레이지 로딩을 구현했을 것이다.

  • 이미지 우선순위 조정

next/image의 priority 속성으로 우선순위를 조정했다. 레이지 로딩하기도 까다롭고 SEO도 좋지 않은 background-image 속성도 모두 next/image로 변경해서 레이지 로딩을 적용했다.

  • 스크립트 우선순위 조정

next/script의 strategy 속성으로 우선순위를 조정했다. 미리 로드될 필요가 없는 써드파티 스크립트를 지연 로드했더니 실행 시간이 상당히 감소했다.

렌더링 성능

  • 시각적 변화에 rAF(requestAnimationFrame) 사용

rAF는 브라우저의 렌더링 프레임에 맞춰 콜백을 실행한다. 프레임의 초기에 콜백을 실행하도록 브라우저에게 권한을 넘기는 것이다. 프레임의 중간에 콜백이 실행되는 것을 막기 때문에 버벅거림을 줄일 수 있다.

  • 스타일 복잡도 줄이기

스타일 시간을 계산해보았을 때 50%가 셀렉터를 찾는 것, 나머지 50%가 렌더 스타일을 구성하는 것이라고 한다.

.box:nth-last-child(2) .title {
    /* styles */
}

.final-box-title {
    /* styles */
}

위의 첫 번째 예는 컴퓨에게 "box라는 클래스를 가진 부모 요소의 마지막 요소로부터 2번 째 요소가 title이라는 클래스"를 묻는 것과 같다. 어렵다. 이 대신 두 번째 처럼 셀렉터로만 요소를 찾는 것이 브라우저의 계산 시간을 줄여 준다.

  • Web Workers 사용

메인 스레드의 무거운 작업을 줄여주기 위하여 웹워커를 이용할 수 있는데 Partytown 라이브러리를 발견했다! 써드 파티 스크립트를 메인 스레드가 아닌 웹 워커에서 실행시키는 아이디어로 탄생했는데 정말 사용하기 편하게 되어있다.

성능 측정 자동화

  • Lighthouse CI 도입

앞으로 작업을 할 때 성능 측정을 자동화하기 위하여 Github action과 Lighthouse CI를 연동하기로 결정했다.


도움이 된 자료

Web Fundamentals, (2021,09,24), https://developers.google.com/web/fundamentals/performance/get-started

트리쉐이킹으로 자바스크립트 사이즈 줄이기, (2021,09,24), https://yceffort.kr/2021/08/javascript-tree-shaking

웹폰트 경량화, (2021,09,24), https://www.44bits.io/ko/post/optimization_webfont_with_pyftsubnet