관리 메뉴

Dev Blog

1장 블로그 서비스 최적화 본문

Tech Books/프론트엔드 성능 최적화 가이드

1장 블로그 서비스 최적화

Nomad Kim 2023. 2. 2. 23:31

Repository

최적화 기법

  • 로딩 성능 최적화
    • 이미지 사이즈 최적화
    • 코드 분할
    • 텍스트 압축
  • 렌더링 성능 최적화
    • 병목 코드 최적화

 

Lighthouse 툴을 이용한 페이지 검사

 

Lighthouse 검색 결과

 

First Contentful Paint(FCP)

FCP는 페이지가 로드될 때 브라우저가 DOM 콘텐츠의 첫 번째 부분을 렌더링하는 데 걸리는 시간에 관한 지표입니다.

10%의 가중치를 갖습니다.

 

Speed Index(SI)
SI는 페이지 로드 중에 콘텐츠가 시각적으로 표시되는 속도를 나타내는 지표입니다.

A 페이지는 일부 콘텐츠가 B 페이지보다 먼저 떴음을 알 수 있습니다. 이런 경우 A 페이지가 B 페이지보다 전체적으로 더 빨리 로드된 것으로 계산되며, 더 높은 점수를 받습니다. SI는 총점을 계산할 때, 10%의 가중치를 갖습니다.

Largest Contentful Paint(LCP)

LCP 는 페이지가 로드될 때 화면 내에 있는 가장 큰 이미지나 텍스트 요소가 렌더링되기까지 걸리는 시간을 나타내는 지표 입니다.

LCP는 총점을 계산할 때, 25%의 가중치를 갖습니다.

Time to Interactive(TTI)

TTI는 사용자가 페이지와 상호 작용이 가능한 시점까지 걸리는 시간을 측정한 지표입니다. 여기서 상호 작용이란 클릭 또는 키보드 누름 같은 사용자 입력을 의미합니다. 10%의 가중치를 갖습니다.

 

Total Blocking Time(TBT)

TBT 는 페이지가 클릭, 키보드 입력 등의 사용자 입력에 응답하지 않도록 차단된 시간을 총합한 지표 입니다.

30%의 가중치를 갖습니다.

 

Cumulative Layout Shift(CLS)

CLS 는 페이지 로드 과정에서 발생하는 예기치 못한 레이아웃 이동을 측정한 지표 입니다.

15%의 가중치를 갖습니다.

 


이미지 사이즈 최적화

 너무 큰 사이즈의 이미지를 무분별하게 사용하면 네트워크 트래픽이 증가해 서비스 로딩이 오래 걸립니다.

 

실제 이미지 사이즈(Intrinsic size)는 1200  x 1200px인데,

화면에 그려지는 이미지의 사이즈(Rendered size)는 120 x 120px이라고 합니다.

처음부터 120  120px에 맞는 이미지를 사용하라는 뜻이죠.

요즘 사용되는 레티나 디스플레이는 같은 공간(픽셀)에 더 많은 픽셀을 그릴 수 있기 때문에, 너비 기준으로 두 배 정도 큰 이미지를 사용하는 것이 적절합니다.

 

자체적으로 가지고 있는 정적(static) 이미지라면 사진 편집 툴을 이용하여 직접 이미지 사이즈를 조절하면 되는데, 이렇게 API를 통해 받아오는 경우에는 어떻게 이미지 사이즈를 조절할까요? 이때 생각해 볼 수 있는 한 가지 방법은 Cloudinary나 Imgix 같은 이미지 CDN을 사용하는 방법입니다.

 

이미지 CDN

이미지 CDN은 이미지에 특화된 CDN이라고 볼 수 있습니다. 기본적인 CDN 기능과 더불어 이미지를 사용자에게 보내기 전에 특정 형태로 가공하여 전해 주는 기능까지 있습니다. 예를 들어 이미지 사이즈를 줄이거나, 특정 포맷으로 변경하는 등의 작업이 가능합니다.

이미지 CDN 서버의 주소에 쿼리스트링(query string)으로 가져올 이미지의 주소(src) 또는 이름을 입력해 줍니다.

그리고 필요에 따라 변경하고자 하는 형태(width, height)를 명시해 주는 식입니다.

즉, 위 주소를 통해 내가 명시한 이미지([img src])를 가로 240px, 세로 240px의 사이즈로 변환된 상태로 받아 올 수 있습니다.

 

결과

최적화 전에는 47점이었는데, 이미지 사이즈 최적화 후 점수가 56점이 되었습니다.

무엇보다 Opportunities 섹션에 더 이상 Properly size images 항목이 보이지 않습니다!

 


병목 코드 최적화

페이지 로드 과정 살펴보기

0.chunk.js의 로드 시간이 매우 깁니다.

실제로 해당 막대를 클릭해 Summary 탭을 보면 파일 크기가 4.2MB로 굉장히 크다는 것을 알 수 있습니다.

 

1. HTML 파일이 다운로드된 시점을 보면 메인 스레드에서는 Parse HTML이라는 작업을 하고 있습니다.

2. 0.chunk.js의 다운로드가 끝난 시점을 보면, 이어서 자바스크립트 작업이 실행되고 있습니다.

3. Timings 섹션에서도 메인 스레드의 자바스크립트 작업이 끝나는 시점에 컴포넌트에 대한 렌더링(App [mount]) 작업이 기록되어 있음을 확인할 수 있습니다.

4. 컴포넌트가 마운트되면 ArticleList 컴포넌트에서는 블로그 글 데이터를 네트워크를 통해 요청합니다.

5. articles 데이터가 모두 다운로드되니 메인 스레드에서는 해당 컴포넌트를 렌더링하기 위해 자바스크립트를 실행합니다.

 

여기 이상한 점이 있습니다. 

Timings 섹션의 ArticleList 항목에 커서를 올려 두면 간단한 정보가 뜨는데, 실행 시간이 무려 1.4초라는 점입니다.

 

메인 스레드의 해당 구간을 따라 내려가다 보면 Article이라는 작업이 있습니다. 이름으로 추측해 보면 이 작업이 Article 컴포넌트를 렌더링하는 작업으로 보입니다. 그리고 그 아래로 하나 더 내려가 보면 removeSpecialCharacter라는 작업도 보입니다

이 removeSpecialCharacter라는 작업이 Article 컴포넌트의 렌더링 시간을 길어지게 했다는 이야기인데 바로 src/Article/index.js에 있는 함수의 이름입니다.

 

병목 코드 개선

 

자바스크립트에는 일치하는 문자를 찾아 제거해 주는 replace라는 함수가 이미 있습니다. 

replace 함수로 쉽고 빠르게 할 수 있는 작업을 반복문을 중첩하여 비효율적으로 처리하고 있으니 성능이 저하될 수밖에 없습니다.

 

또한 사실 서비스에 사용되는 건 대략 200자 정도입니다. 그러면 굳이 9만 자나 되는 문자열을 모두 탐색 및 변경할 필요 없이 앞의 200자 정도만 잘라서 탐색하고 변경한다면 어떨까 생각해 볼 수 있습니다. 제거되는 문자까지 고려하여 300자로 자릅니다.

 

최적화 전후 비교

최적화 전에는 1.4초 걸렸던 작업이, 최적화 후에는 무려 36밀리초로 줄어든 것을 볼 수 있습니다.

플레임 차트의 removeSpecialCharacter 함수를 찾아봐도 굉장히 작아졌음을 확인할 수 있습니다.
Lighthouse로 검사해 보겠습니다.점수가 89점으로 많이 올랐습니다. 

Metrics가 전반적으로 좋아졌는데, 특히 Time to Interaction과 Total Blocking Time이 많이 줄어든 것을 확인할 수 있습니다.

 


코드 분할 & 지연 로딩

번들 파일 분석

화면을 그리는 데 필요한 리소스(리액트 코드)의 다운로드가 늦어지면, 다운로드가 완료된 후에나 화면을 그릴 수 있기 때문에 다운로드가 오래 걸린 만큼 화면도 늦게 뜬다는 문제가 있습니다.

 

 

청크 파일의 구성을 상세히 보기 위해 Webpack Bundle Analyzer라는 툴을 이용해 볼 것입니다.

하나씩 살펴보면, 가장 많은 부분을 차지하고 있는 2.6140cefb.chunk.js(이하 2.chunk.js) 파일이 보입니다. 비중과 이름을 봤을 때, 앞서 Performance 패널에서 분석할 때 굉장히 크고 느렸던 0.chunk.js 파일과 동일한 번들 파일이라고 유추할 수 있습니다.

분석 결과의 오른쪽 상단을 보면 파란색 블록이 보이는데요. 그 안의 이름들로 유추했을 때, 서비스에서 작성된 코드임을 알 수 있습니다.

정리해 보면,

외부 모듈은 2.chunk.js라는 이름으로,

직접 작성한 서비스 코드는 main.chunk.js라는 이름으로 번들링되었습니다.

어떤 패키지 때문에 2.chunk.js 파일이 큰 것인지 확인해 봐야겠습니다. 2.chunk.js의 내부를 살펴보면, 크게 refractor와 react-dom이 매우 큰 비중을 차지하고 있습니다. react-dom은 리액트를 위한 코드이므로 생략하고, refractor 패키지의 출처를 확인해 봅시다.

패키지 출처는 package-lock.json(또는 yarn.lock) 파일에 명시되어 있습니다.

npm install을 하면 이 package-lock.json을 참조해서 설치하고자 하는 패키지가 어떤 버전의 패키지에 의존성이 있는지 확인해서 함께 설치해 줍니다.

react-syntax-highlighter라는 패키지에서 refractor를 참조하고 있는 것이 보입니다.

 

 

크기가 너무 큰 react-syntax-highlighter 모듈은 블로그 글 상세 페이지(Code Block 컴포넌트)에서만 사용되니 사용자가 처음 진입하는 목록 페이지에서는 react-syntax-highlighter 패키지를 굳이 다운로드할 필요가 없습니다. 

그래서 하나로 합쳐져 있는 이 번들 파일을 페이지별로 필요한 내용만 분리하여 필요할 때만 따로따로 로드하면 좋을 것 같습니다.

 

코드 분할이란

페이지에서 필요한 코드만 따로 로드하면 불필요한 코드를 로드하지 않아 더욱 빨라집니다.

코드 분할(Code Splitting) 기법을 이용해서 페이지별로 코드를 분리하는 겁니다. 

코드 분할 기법은 말 그대로 코드를 분할하는 기법으로 하나의 번들 파일을 여러 개의 파일로 쪼개는 방법입니다.

분할된 코드는 사용자가 서비스를 이용하는 중 해당 코드가 필요해지는 시점에 로드되어 실행됩니다. 이를 지연 로딩이라고 합니다.

 

 

추가로 코드 분할 기법에는 여러 가지 패턴이 있습니다.

페이지별로 코드를 분할할 수도 있는 반면, 각 페이지가 공통으로 사용하는 모듈이 많고 그 사이즈가 큰 경우에는 페이지별로 분할하지 않고 그림 1-53의 오른쪽처럼 모듈별로 분할할 수도 있습니다.

 

 

코드 분할 적용하기

우선 코드 분할을 하는 가장 좋은 방법은 동적 import를 사용하는 방법입니다. 

동적 import 란 아래와 같이 import 문을 사용하면 빌드할 때가 아닌 런타임에 해당 모듈을 로드하는 방식을 말합니다.

 

 

webpack은 이 동적 import 구문을 만나면 코드를 분할하여 번들링합니다. 하지만 이 방식에는 문제가 하나 있습니다. 

바로 동적 import 구문은 Promise 형태로 모듈을 반환해 준다는 것입니다.

import하려는 모듈은 컴포넌트이기 때문에 Promise 내부에서 로드된 컴포넌트를 Promise 밖으로 빼내야 합니다. 

 

다행히 리액트는 이런 문제를 해결하기 위해 아주 유용한 함수 lazy와 Sus-pense를 제공합니다. 

이 함수를 이용하면 비동기 문제를 신경 쓰지 않고 간편하게 동적 import를 할 수 있습니다.

 

동적 import를 하는 동안 ListPage, ViewPage Component가 아직 값을 갖지 못할 때는 Suspense의 fallback prop에 정의된 내용으로 렌더링되고, 이후 Component 들이 온전히 로드됐을 때 fallback 값으로 렌더링된 Suspense가 정상적으로 ListPage, ViewPage Component 를 렌더링합니다.

각 페이지 컴포넌트는 코드가 분할되고, 사용자가 목록 페이지에 접근했을 때 전체 코드가 아닌 해당하는 컴포넌트의 코드만 동적으로 import하여 화면을 띄웁니다.

 

변화된 번들 파일 구조

 

 

0.chunk.js: ListPage에서 사용하는 외부 패키지를 모아 둔 번들 파일(axios)

3.chunk.js: ViewPage에서 사용하는 외부 패키지를 모아 둔 번들 파일(react-syntax-highlighter)

4.chunk.js: 리액트 공통 패키지를 모아 둔 번들 파일(react-dom 등)

5.chunk.js: ListPage 컴포넌트 번들 파일

6.chunk.js: ViewPage 컴포넌트 번들 파일

 

Performance 패널도 확인해 봅시다. 목록 페이지에서 전에는 대략 4.2MB에 6.3초 정도 걸렸던 chunk 파일이 코드 분할 후에는 대략 1.9MB에 3초 정도로 줄어든 것을 볼 수 있습니다.

 


텍스트 압축

production 환경과 development 환경

production 환경일 때는 webpack에서 경량화라든지 난독화(uglify) 같은 추가적인 최적화 작업을 합니다. 

development 환경에서는 그런 최적화 작업 없이 서비스를 실행합니다. 즉, 각 환경에서 성능을 측정할 때 차이가 있으므로

최종 서비스의 성능을 측정할 때는 실제 사용자에게 제공되는 production 환경으로 빌드된 서비스의 성능을 측정해야 합니다.

 

 

서버로부터 리소스를 받을 때, 텍스트 압축을 해서 받아라 라는 의미 입니다.

 

텍스트 압축이란

텍스트 압축이란 말 그대로 텍스트를 압축하는 것입니다. 기본적으로 HTML, CSS, 자바스크립트는 텍스트 기반의 파일이기 때문에 텍스트 압축 기법을 적용할 수 있습니다. 이런 파일을 압축하여 더 작은 크기로 빠르게 전송한 뒤, 사용하는 시점에 압축을 해제합니다. 이때 압축한 만큼 파일 사이즈가 작아질 테니 리소스를 전송하는 시간도 단축되는 것이죠.

 

 

Network 패널 articles API 항목을 확인해 보면, 응답 헤더(Response Headers)에 Content-Encoding: gzip이라고 되어 있는 것을 볼 수 있습니다. 이 리소스가 gzip이라는 방식으로 압축되어 전송되었다는 의미입니다.

그에 반해 main 번들 파일을 확인해 보면, 응답 헤더에 Content-Encoding이라는 항목이 없습니다. 즉, 텍스트 압축이 적용되어 있지 않다는 의미이고, 바로 이런 파일에 텍스트 압축을 적용할 예정입니다.

 

텍스트 압축 적용

텍스트 압축은 이 리소스를 제공하는 서버에서 설정해야 합니다.
s 옵션은 SPA 서비스를 위해 매칭되지 않는 주소는 모두 index.html로 보내겠다는 옵션이고, u 옵션은 텍스트 압축을 하지 않겠다는 옵션입니다. 즉, 텍스트 압축을 적용하기 위해서는 이 u 옵션만 제거하면 됨을 알 수 있습니다.

 

 

다시 실행한 후 살펴보면, 번들 파일의 사이즈가 줄어든 것과 응답 헤더에 Content-Encoding 값이 gzip으로 설정된 것을 볼 수 있습니다.

 

 

 

-알라딘 eBook <프론트엔드 성능 최적화 가이드> (유동균 지음) 중에서

Comments