웹 서비스 성능 최적화하기 - Part 1. 로드 및 렌더링 성능

gyomni·2023년 3월 20일
2

Performance Optimization

목록 보기
1/2
post-thumbnail

최근에 웹 앱 서비스 프로젝트를 배포하게 되었고,
유저가 느낄 수 있는 성능에 관련한 불편함을 최소화하고 싶었다.
적어도 사용성이 좋지 않아서 🏃‍♂️서비스를 탈출하는 일🏃‍♂️은 없게끔...

그리하여... 로딩, 렌더링, 반응성 성능을 측정한 뒤 개선해 볼 것이다.

( 참고로 현 프로젝트는 Next.js로 개발했다. )


성능 측정 도구

Chrome devtools의 Lighthouse로 성능 측정을 할 것이다.
Lighthouse 패널은 chrome 103부터 user flow 측정을 위해 3가지 모드를 지원하고 있다.

Navigation
가장 보편적인 보고 형태로, 단일 페이지 로딩을 측정한다.
chrome 103 이전 버전의 모든 Lighthouse 리포트는 navigation 리포트라고 한다.

Timespans
일반적으로 유저 인터렉션을 포함한 임의의 기간을 분석한다.

Snapshots
일반적으로 유저가 상호작용 한 후 특정 상태의 페이지를 분석한다.

🔊 이번 게시물은 Navigation 성능 측정 부분을 기술한다.

Navigation

Lighthouse의 Navigation mode로 단일 페이지의 로드 성능을 Desktop, Mobile 버전 별로 각각 측정해봤다.
Performance, Accessibility, Best Practices, SEO, PWA 각 탭을 살펴보자.

  • Desktop ver.

  • Mobile ver.

1. Performance

점수를 보면 Desktop 85점, Mobile 44점 으로 꽤 많은 점수차가 났다.
두 버전의 점수가 왜 다른지 궁금해서 찾아보았다.

  • 성능 차이
    : 모바일 장치는 데스크톱에 비해 기기의 프로세서 속도가 느려서 일반적으로 데스크톱에 비해 처리 능력이 낮다.
    혹은, 이런 툴 들은 일반적으로 slow 3G 연결을 통해 로드되는 모바일 장치의 페이지 성능을 측정하는데, 빠른 Wi-Fi를 사용하는 경우 모바일 페이지 속도 점수가 데스크톱 페이지 속도 점수와 비교하여 큰 차이가 없을 수도 있다고 한다.

  • 다양한 화면 크기
    : 웹 사이트는 화면 크기에 따라 다르게 나타날 수 있으며, 이는 시각적 디자인과 레이아웃에 영향을 미칠 수 있다. (반응형 설계를 염두에 두지 않은 경우 점수에 영향을 줄 수 있다.)

  • 다양한 브라우저 기능
    : 모바일 버전과 데스크톱 버전의 크롬은 기능과 한계가 다를 수 있으며, 이는 lighthouse점수에 영향을 미칠 수 있다.
    예를 들어, 모바일 장치는 특정 CSS 또는 자바스크립트 기능을 제한적으로 지원할 수 있다.

  • 모바일 장치별 CSS 규칙
    : 모바일 장치는 이미지와 같은 다양한 웹 페이지 요소의 크기를 조정하는 고유한 방법을 가지고 있다.

위와 같은 여러가지 이유로 인해 차이가 있을 수 있다고 한다.
현재 서비스는 주로 모바일 웹 환경에서 사용되므로 Mobile 버전에 초점을 맞춘다.

Performance탭에는 세부적으로 Metrics, Opportunities, Diagnostics가 있다.
각각의 영역에서 제시하는 가이드를 토대로 개선해보겠다.


1) Metrics

Lighthouse 보고서의 성능 섹션에서 추적하는 6가지 Metrics가 있다.
*각 Metric은 페이지 로드 속도의 일부 측면을 포착한다.
3가지 영역에서 개선이 필요해보인다.

🟧 Time to Interactive (TTI) / 4.8 s
Time to interactive is the amount of time it takes for the page to become fully intercative.

페이지가 완전히 상호 작용 가능하게 되는 데 걸리는 시간 측정한다.

TTI 부분에 특히 영향을 주는 것이 불필요한 JavaScript작업이라고 한다. 그래서 JavaScript를 최적화 하는 방법 (메인 스레드 작업 최소화, JavaScript 실행 시간 단축)을 사용해서 개선하면 된다.

현재 4.8 s로 1.0s 정도만 개선하면 좋은 평가를 받을 수있다.

🔺 Total Blocking Time (TBT) / 2,350 ms
Sum of all time periods between FCP and Time to Interactive, when task length exceeded 50ms, expressed in milliseconds.

TBT는 interaction동안 발생한 모든 blocking time의 총합이다.
여기서 blobking time은 메인 스레드를 50ms이상 점유하는 long task에서 50ms를 초과한 시간을 의미한다.

마우스 클릭, 화면 탭과 같은 사용자 입력으로부터 페이지가 응답하지 못하도록 차단된 총 시간 측정한다.

성능 패널에서 코드를 분석하는 동안 기본 스레드가 페이지를 로드하는 데 실제로 필요하지 않은 JavaScript 작업을 수행할 때 영향을 받는다고 한다.
코드 분할, 사용하지 않는 코드 제거 등으로 점수를 향상시킬 수 있다.

현재 2,350 ms로 범위에 많이 초과된 시간이다.

🔺 Largest Contentful Paint (LCP) / 11.0 s
Largest Contentful Paint marks the time at which the largest test or image is painted.

viewport에서 가장 큰 콘텐츠 요소(메인)가 화면에 렌더링되는 시간 측정한다. 이는 페이지의 기본 콘텐츠가 사용자에게 표시되는 시기와 비슷하다.

LCP는 주로 알와 같은 요인에 의해 영향을 받는다고 한다.

  • 느린 서버 응답 시간
  • 렌더링 차단 JavaScript 및 CSS
  • 리소스 로드 시간
  • 클라이언트 측 렌더링

현재 11 s로 역시 범위에 많이 초과된 시간이다.

어느 부분에서 성능을 갉아먹고 있는지 파악 완료했다.

이번에는 Opportunities, Diagnostic 에서 안내하는 가이드에 따라 코드를 수정해서 3가지 사항들을 개선 할 차례이다.


2) Opportunities

리소스의 관점에서 가이드를 제시해준다. 즉, 로딩 성능 최적화와 연관있다.

Reduce unused Javascript
"사용하지 않는 JavaScript를 감소시키기" 항목이다.
안내되어 있는 데로 사용되지 않는 JavaScript코드를 탐색하기 위해 @next/bundle-analyzer 사용했다.
(*@next/bundle-analyzerwebpack을 통해 번들링된 파일들이 어떤 코드들로 구성되어 있는지 보여준다.)

번들의 내용을 정확하게 시각화해서 볼 수 있는 source-map-explorer도 같이 설치해주었다.
설치 후에는 아래와 같이 next.config.js에 설정을 추가했다.
(next-pwa 설정은 3. PWA에서 수정한 코드를 참고!)

next.config.js

/** @type {import('next').NextConfig} */


const withPlugins = require('next-compose-plugins'); // 여러 plugin 사용 위함
const withPWA = require('next-pwa');

// @next/bundle-analyzer 관련 설정
const withBundleAnalyzer = require('@next/bundle-analyzer')({
  enabled: process.env.ANALYZE === 'true',
  compress: true,
  webpack(config) {
    const prod = process.env.NODE_ENV === 'production';
    const plugins = [...config.plugins];
    return {
      ...config,
      mode: prod ? 'producton' : 'development',
      devtool: prod ? 'hidden-source-map' : 'eval',
      plugins,
    };
  },
});

const nextConfig = {
  reactStrictMode: true,
  swcMinify: true,
  presets: ['next/babel'],
  productionBrowserSourceMaps: true, // source-map-explorer 관련 설정
  webpack: (config) => {
    config.module.rules.push({
      test: /\.svg$/i,
      issuer: /\.[jt]sx?$/,
      use: ['@svgr/webpack'],
    });
    return config;
  },
};

module.exports = withPlugins(
  [[withBundleAnalyzer], [withPWA({ pwa: { dest: 'public' } }), ]],
  nextConfig,
);

그리고 build시 수행되도록 package.jsonscripts에 아래와 같이 작성했다.

 "build": "cross-env ANALYZE=true NODE_ENV=production next build&&source-map-explorer .next/static/**/*.js"

build를 하면 (e.g. yarn build)
아래와 같이 .next 하위에 analyze폴더, 그리고 그 안에 html파일이 생성된다.

번들을 확인하기 전에, 먼저 터미널을 보면현재는 Fisrt Load JS 번들 크기가 크다고 빨간색으로 보여주고 있다.
빨간색으로 표시된 경우에는 모든 페이지의 크기 및 상태에도 영향을 미치므로 최대한 줄여주는 것이 좋다.


이제 분석 툴로 실행된 결과물을 보자.

  • @next/bundle-analyzer - client.html

  • source-map-explorer

분석

static/chunks/pages/_app-.....js (207.9KB) 번들이 가장 큰 공간을 자리 잡고 있다.

✔ 전체 번들 사이즈는 약 680KB이다.

✔ 아이콘 라이브러리인 iconify가 있는 동시에 public/assets/cry.svg (9.96KB) 아이콘 대비 공간을 차지하고 있다.

global.ts 파일이 큰 사이즈를 차지하고 있다.


개선 방안

Dynamic Imports 사용

Menu, Modal과 같이 특정한 이벤트(e.g. 클릭)가 발생했을 때 불러와도 되는 component들이 존재했다.
이런 성질을 가진 component들은 동적으로 import해 와서 필요시에 불러 올 수 있도록 수정하였다.

import dynamic from 'next/dynamic';

const Modal = dynamic(() => import('components/modal'));

✅ 아이콘 사용 방법 변경

현재 svg 파일을 불러와서 사용하는 방법과 아이콘 라이브러리 iconify를 병행해서 사용하고 있었다.
svg 파일 아이콘 중 하나인 cry.svg 아이콘의 크기가 다른 아이콘들 보다 컸기 때문에 일차적으로 svg 파일 크기를 줄여보았다.
1%가 줄어들었는데, 전체 번들크기에는 효과가 거의 없었다.

이어서 iconify 라이브러리를 제거하였다.
사실 iconify로 사용하는 아이콘은 2개로, 거의 사용되지 않았기 때문에 제거하고 svg로 저장해서 사용해도 큰 상관은 없겠다고 생각이 들었다.


✅ 배경 이미지 가져오기 방법 변경

전역 style을 관리하는 global.ts에서 배경 이미지를 불러오는 것 외에는 크게 크기를 차지할 요소는 없었다.
배경 이미지의 크기가 해당 파일의 크기에 크게 관여하고 있겠다고 예상 했다.

단순하게 배경 이미지를 svg로 저장하여 불러와봤다.
결과적으로 크기와 초기 로드 속도가 유의미하게 줄어들정도로 차이가 났다.
그런데 전자의 이미지코드와 후자의 svg 이미지코드를 살펴봤을 때 비슷했다.

파일 크기 자체보다는 다른 이유일 가능성이 커졌다.
파일을 불러오는 방법에 대해 찾아보니 전자와 후자의 방법이 각각 다른 특성을 가지고 있었다.

전) data URI encoding

global.ts

export const global = css`
...

body {
    ...
    margin: 0;
    background-image: url('data:image/png;base64,iVBORw0............');
  }
...

`

코드를 보면 data URI encoding을 사용하여 이미지를 불러온다.
이 방법은 이미지를 로드하기 위한 별도의 HTTP 요청이 필요하지 않기 때문에 작은 이미지는 유용할 수 있다고 한다.

그러나 아래와 같이 성능에 영향을 미칠 수 있는 단점들이 존재했다.

  • CSS 파일 크기 증가
    : 이미지 데이터가 CSS 파일에 포함되어 있기 때문에 파일 크기를 크게 늘릴 수 있다. 이렇게 하면 특히 연결 속도가 느린 경우 페이지의 로드 속도가 느려질 수 있다.
  • 캐싱 감소
    : 이미지 데이터가 CSS 파일에 포함되면 별도로 캐시할 수 없다.
    이것은 CSS 파일이 요청될 때마다 이미지 데이터도 전송되어야 한다는 것을 의미한다. 이로 인해 후속 요청이 느려지고 서버 부하가 증가할 수 있다.

후) 별도의 파일로 로드

global.ts

export const global = css`
...

body {
    ...
    margin: 0;
     background-image: url('../../assets/backgroundImg.svg');
  }
...

`

해당 코드는 이미지를 svg로 저장했기 때문에 상대 경로를 가진 별도의 파일로 로드하고 있다.
이 방법은 CSS 파일의 크기를 줄이고 캐싱을 향상시킬 수 있지만 페이지를 로드하는 데 필요한 HTTP 요청의 수를 증가시킬 수도 있다.

성능에 미치는 영향

  • 증가된 HTTP 요청
    : 이미지가 별도의 파일로 로드될 때마다 별도의 HTTP 요청이 필요하다.
    특히 로드할 이미지가 많은 경우 페이지 로드 속도가 느려질 수 있다.
  • 향상된 캐싱: 이미지가 별도의 파일로 로드되면 브라우저에서 별도로 캐시할 수 있어서 전송해야 하는 데이터 양을 줄임으로써 후속 요청의 성능을 향상시킬 수 있다.

배경 이미지를 파일로 로드했을 떼 성능이 좋아지게 되었으므로 후자의 방법을 유지했다.

1차 개선

  • @next/bundle-analyzer - client.html

  • @next/bundle-analyzer - client.html

  • terminal

  • 정리

    📍 static/chunks/pages/_app-.....js 번들 사이즈: 207.9KB -> 117.4KB

    📍 번들 총 사이즈: 680KB -> 579KB

    📍 First Load shared by all: 194KB -> 126KB


Preconnet to requied origins
사전 연결로 페이지 로드 속도 향상에 관련있는 "필수 원본에 사전 연결" 항목이다.

<Head>
 <link rel="preload" as="font" href="https://cdn.jsdelivr.net" />
   ...
</Head>

폰트 관련 CDN을 rel="preload" 속성을 사용해 미리 연결하여 연결에 필요한 시간을 절약시킬 수 있게 되었다. rel="preload"를 사용할 때 주의해야할 점이 있다.
as 속성을 사용해서 리소스 유형을 알려줘야 하고, 무조건 리소스를 가져오므로 중복 참조하지 않아야 한다. 그리고 반드시 사용되는 리소스에만 사용해야 한다.
( 관련 내용이 더 궁금하다면 게시물 하단의 참고 자료 링크를... )


3) Diagnostics

페이지의 실행 관점에서 가이드를 제시해준다. 즉, 렌더링 성능 최적화와 연관 있는 항목이다.

Ensure text remains visible during webfont load
폰트 표시 CSS 기능을 활용하여 웹 폰트가 로드되는 동안 텍스트를 사용자가 볼 수 있도록 하는 것이 좋다.
Opportunities/Preconnet to requied origins부분에서 font CDN을 사전 연결한 것과 더불어 시스템 글꼴을 일시적으로 표시하는 css 속성도 추가해주었다.

@font-face{
	...
   font-display: swap;

}

Minimize main-thread work
JS가 구문 분석, 컴파일 및 실행하는 데 소요되는 시간을 줄이는 것이 좋다.
더 작은 JS 페이로드를 제공하는 것이 도움이 될 수 있다고 한다.
TBT에 영향을 준다.

Reduce JavaScript execution time
JS 실행 시간을 줄이는 것 또한 TBT개선에 도움을 준다.

두 항목을 개선하기 위해 performace tab을 살펴봤다.
TBT시간을 빠르게 하기 위해서는 long task 시간과 실제 필요하지 않는 JS들을 최대한 줄여줘야 한다.

현재 googlemanager을 제외하면 _app.tsx 파일에서 사용되지 않는 bytes 비율이 높다고 나와있다.
또한 main thread를 봤을 때 long task가 발생하고 있기 때문에 이 부분에서 작업을 해줘야 할 필요를 느꼈다.
_app.tsx에서 굳이 사용해도 되지 않는 코드를 다른 파일로 옮겨 줬고 total bytes, unused bytes 모두 감소되었다.


4) 결과


전반적으로 First Load JS를 1차개선보다 더욱 더 감소시켰다.

📍 First Load shared by all: 194KB (개선 전) -> 126KB (1차) -> 109 KB (2차)

점수가 상승하긴 했지만, GA 같은 third party code가 스레드를 차단하면서 성능을 떨어뜨리고 있었다.
third party를 로드하는 부분에는 preconnect 속성을 추가해 주긴 했지만 이게 최선인가 싶어서 아쉬웠기에... 이 부분은 조금 더 찾아봐야겠다


2. Accessibility

접근성에 관련해서도 3가지의 개선사항들이 존재했다.

1) Best Practices

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

_app.tsx

<Head>
  ...
	<meta
          name="viewport"
          content="width=device-width, initial-scale=1.0, maximum-scale=1, user-scalable=no"
        />
   ...
</Head>
        

현재 위와 같이 meta태그의 user-scalable=no 속성으로 막아두었다.
이 부분은 모바일에서 앱 같은 사용성을 주기 위해 확대를 하지 못하도록 의도한 것이므로 일단 이 부분은 유지한 채 넘어가겠다.

2) Internationalization and Localization

<html> element does not have a attribute
lang 속성을 지정하지 않았다고 안내를 하고 있다.

next.config.js

i18n: {
    locales: ['ko'],
    defaultLocale: 'ko',
  },

그렇기에 lang 속성을 추가해 주었다.

3) Navigation

Heading elements are not in a sequentially-descending order
제목 요소가 순차적으로 내림차순이 아니라고 가이드에서 안내를 하고 있다.
현재 h1 태그 밑에 바로 h3 태그가 배치되어 있었고, h3 태그를 h2 태그로 수정해주었다.


4) 결과


user-scalable=no 속성으로 100점까지 도달하지는 못했지만,
그 부분을 제외하고는 웹 접근성에 대한 요소들을 모두 갖추게 되었다.

3. PWA


서비스 특성상 앱 같은 사용성을 제공한다면 지속적인 서비스 이용에 도움을 줄 것이라 생각했다.
예시로, 게스트가 보낸 녹음된 파일을 들을 수 있는 주된 기능이 있는데,
화면에 아이콘을 추가해두면 앱처럼 한번 받은 파일들은 계속 온라인에 연결되어 있지 않아도 두고 두고 들을 수가 있다.

사실 대부분의 유저들은 오프라인 상태가 아닐테지만
매번 도메인 링크로 들어가서 게스트가 보낸 파일들을 확인하는 것 보다는
훨씬 번거로움이 줄 것이다.
이와 같은 이유로 PWA관련 설정도 해두었다.

그런데, 현재 lighthouse검사에서 PWA조건이 충족되지 못해서 당황스러웠다...
PWA가 되지 못한 부분들을 보완해서 통과시켜 보자!

1) Installable


Web app manifest or service worker do not meet the installability requirements
현재 설치 요구 조건에 충족하지 못하고 있음을 알 수 있다.
다음의 PWA Optimized 조건에 맞춰서 개선해보겠다.

2) PWA Optimized

Does not register a service worker that controls page and start_url
next-pwa로 PWA관련 작업을 해 놓았는데 해당 안내되고 있었다.
build시에 public하위에 sw.js, workbox.js와 같은 파일들도 생성되지 않고 있었다.

찾아보니 next-pwa 버전 ^5.6.0next.config 설정이 기존 방식과 살짝 달랐고, 게시물 위의 next.config.js에서 withPWA 부분을 아래와 수정해주었다.

next.config.js

const withPWA = require('next-pwa')({
    dest: 'public',
    register: true,
  });

  ...

  const nextConfig = withPWA({
    reactStrictMode: true,
    swcMinify: true,
    presets: ['next/babel'],
    productionBrowserSourceMaps: true,
    webpack: (config) => {
      config.module.rules.push({
        test: /\.svg$/i,
        issuer: /\.[jt]sx?$/,
        use: ['@svgr/webpack'],
      });
      return config;
    },
  });

  module.exports = withPlugins([[nextConfig], [withBundleAnalyzer]]);

다시 build를 한 결과 ,
public하위에 sw.js, workbox.js 파일 생성이 잘 되었다.

Does not set a theme color for the address bar
현재 주소 표시줄의 테마 색상을 설정하지 않고 있었다.

Lighthouse는 페이지의 HTML에서 meta name="theme-color"manifest.json에서 theme_color 속성을 찾지 못하면 심사를 실패한다고 한다.

manifest.json에는 테마 컬러 속성 작성해주었기에 meta 태그만 추가해주었다.


_app.tsx

<meta name="theme-color" content="#242729" />

주소 표시줄의 테마 색상 관련 브라우저 호환성은 아래를 참고하면 된다.


Manifest doesn't have a maskable icon

마스크 가능한 아이콘이 없어서 안내된 가이드이다.

manifest.json

...
 "icons": [
    {
      "src": "icons/icon-192x192.png",
      "sizes": "192x192",
      "type": "image/png",
      "purpose": "any maskable"
    },
    {
      "src": "icons/icon-256x256.png",
      "sizes": "256x256",
      "type": "image/png"
    },
    ...

"purpose": "any maskable"속성을 추가해 주었다.

3) 결과

PWA 검사가 깔끔하게 통과된다.

이제 우리 서비스를 chrome으로 들어가면 상단에 생긴 아이콘으로 다운로드를 할 수 있다.


참고 자료

profile
Front-end developer 👩‍💻✍

0개의 댓글