마이리얼트립 웹사이트 성능 측정 및 최적화 Part 1. 리소스 로딩

여행 경험을 돕는 웹사이트 가꾸기

마이리얼트립은 다양한 방식으로 여행 경험을 제공합니다.

현지 가이드와 생생한 로컬 경험을 함께 할 수도 있고, 즉시 이용 가능한 티켓들을 사서 간편하게 명소나 관광지에 입장할 수도 있습니다. 민박부터 호텔까지 취향에 맞는 숙소를 예약할 수도 있고, 항공권도 저렴하게 구입할 수 있습니다.

하지만 여행에 대한 부푼 마음을 갖고 방문한 페이지의 로딩이 답답할 정도로 느리다면 어떤 기분이 들까요? 관광 명소에 입장하기 위해 예약 내역 페이지를 확인하는데 몇십초가 걸린다면 어떨까요? 상품 상세 정보를 보려고 페이지를 내릴 때 스크롤이 버벅인다면 어떨까요?

여행자들의 소중한 시간이 낭비될 것입니다. 느린 서비스라는 인식이 생기고, 신뢰도도 떨어질 것입니다. 나아가 회사의 매출에도 악영향을 미치고 기업의 규모가 클수록 더 심각한 결과를 가져올 것입니다.

Help individuals experience the world

마이리얼트립의 미션입니다. 여행자의 경험을 돕기 위한 미션의 진입점인 웹사이트에서 불편함을 겪지 않도록 프론트엔드 팀은 지난 한 달간 성능을 측정하고, 개선 방안을 찾아 적용했습니다.

이번 글에서는 리소스 로딩 관련 개선 사례를 다루고자 합니다. 이후 아래와 같은 주제로 성능 개선 과정을 공유드릴 예정입니다.

  • 렌더링 최적화: 리소스 로딩 이후 유의미한 콘텐츠가 화면에 그려지기까지의 과정을 주요 렌더링 경로 분석과 React Profiler를 통해 개선합니다.
  • 메모리 관리: 사용자의 텍스트 입력, 마우스 클릭과 같은 상호 작용 간에 발생하는 메모리 누수를 찾아 개선합니다.

개선 작업에 아래 링크의 교육, 기술 문서, 블로그가 큰 도움이 됐습니다. NHN 엔터테인먼트의 유동식 책임님, 크롬 브라우저 팀의 Addy Osmani님을 포함한 개발자 분들 감사합니다! 👍


속도의 기준

1. 네트워크 환경

웹사이트의 속도는 상대적입니다.

일반적인 사용자의 로딩시간 분포도

Wi-Fi, 5G, LTE, 3G 등 네트워크 환경에 따라 체감되는 속도는 천차만별이기 때문에 “우리 웹사이트는 3초 안에 로딩됩니다” 와 같은 말은 이뤄지기 어렵습니다. 그보다는 서비스의 성격에 따라 초점을 맞출 환경을 정하는게 더 합리적입니다.

마이리얼트립 이용 시 속도 면에서 가장 답답함을 느낄 여행자 환경은 어떤 것일까요? 아래와 같은 항목들이 떠올랐습니다.

  • 모바일, 해외 로밍 (제한된 네트워크 대역폭)
  • 최초 방문자 (브라우저나 서버의 캐시를 제공받을 수 없는 환경)

즉, 여행지에서 해외 로밍을 이용 중이고, 모바일로 웹페이지에 처음 접속하는 여행자가 됩니다.

2. 상호 작용 가능 시점 (TTI)

콘텐츠가 눈에 보이지만 클릭이 되지 않거나 스크롤이 더디게 움직이는 경험이 한번쯤은 있으실텐데요. 이는 브라우저의 상호 작용이 원활하지 않음을 뜻하고, 아직 TTI(Time to Interactive)에 이르지 못한 상태를 말합니다. 이 상태가 길어진다면 여행자의 체감 속도도 비례하여 떨어질 것입니다.

따라서 TTI에 도달하는 시간을 앞당겨야 합니다.

TTI (Time to Interactive): 레이아웃이 안정되고, 주요 웹 글꼴이 보이고, 메인 스레드가 사용자 입력을 처리하기에 충분한 시점

3. 콘텐츠의 우선 순위 (Hero element)

모든 웹페이지에는 방문한 목적을 달성하기 위한 영역이 존재합니다. 예를 들어 마이리얼트립 메인 페이지의 경우 아래와 같이 검색창과 인기 지역일 가능성이 높습니다.

반면 하단의 추천 영역은 중요할 순 있지만 스크롤을 내려야 도달하기 때문에 가장 먼저 로딩되어야 할 영역은 아닙니다.

방문 목적을 달성하기 위한 영역이 보여지는 시점을 측정해야 합니다.


측정 방법

크롬 브라우저 개발자 도구에는 성능 측정을 위한 기능이 잘 갖춰져 있습니다. 이번 편에서는 주로 Network, Performance 탭을 활용했습니다.

앞서 정한 속도의 기준에 맞춰 아래와 같이 환경을 설정한 뒤, 로딩 과정을 분석합니다.

1. 네트워크 대역폭 제한

네트워크 탭에서 옵션을 선택해 원하는 속도로 시뮬레이션할 수 있습니다. 성능 측정 시 Fast 3G를 기준으로 진행했습니다.

2. 캐시 비활성화

Disable cache 옵션으로 리소스 캐시를 비활성화해 웹사이트 첫 방문과 동일한 환경을 유지할 수 있습니다.

3. 타임라인 측정

네트워크 탭에서는 시간의 흐름에 따른 리소스 로딩 정보를 분석할 수 있습니다.

1, 2번 항목(네트워크 대역폭 제한, 캐시 비활성화)이 적용된 상태에서 페이지 새로고침을 하면 HTML, 자바스크립트, CSS, 이미지 파일, API 호출 등의 리소스 로딩에 걸리는 시간, 파일 크기를확인할 수 있습니다.

4. 프로파일링

네트워크 탭은 리소스 기준인 반면 퍼포먼스 탭에서는 브라우저가 시간의 흐름에 따라 수행한 작업에 초점이 맞춰집니다. 따라서 특정 시점에 브라우저가 어떤 일을 했는지 자세히 들여다 볼 수 있습니다.

위 스크린샷의 주요 측정 기준 중 FMP(First Meaningful Paint)는 유의미한 콘텐츠가 화면에 그려지는 시점으로 TTI와 함께 사용자 관점에서 중점적으로 측정해야 할 지표입니다.


개선 방안

속도의 기준을 정하고 측정 방법에 따라 분석해보니 개선해야 할 항목들이 눈에 들어왔습니다.

1. 번들 파일 용량 줄이기

일반적으로 운영 환경 배포 전에 서비스에 쓰이는 모든 소스코드를 합치고 압축하는 과정을 거칩니다. 이렇게 만들어진 파일을 번들이라 합니다.

번들 파일은 주로 자바스크립트와 CSS로 구성됩니다. 이 중 자바스크립트는 HTML의 구조나 스타일을 직접 변경할 수 있습니다.

브라우저는 HTML의 구조(DOM)와 CSS의 구조(CSSOM)의 분석이 끝나야 웹페이지를 그릴 수 있는데 자바스크립트가 어떻게 관여할지 모르기 때문에 로딩이 끝나길 기다리게 됩니다. 그 말은 곧 자바스크립트의 용량을 줄여 빠르게 불러올수록 웹페이지를 그리는 시점이 빨라진다는 것을 뜻합니다.

httparchive.org에 따르면 웹사이트에서 로딩되는 자바스크립트 용량의 중앙값(median)은 데스크탑 401.7 KB, 모바일 355.3 KB입니다. 그에 비해 마이리얼트립 웹사이트의 번들 크기는 1 MB를 넘어서고 있었습니다.

application: 서비스 구현 코드, vender: 외부 라이브러리

3G 환경인걸 감안하더라도 16초는 상당한 인내심이 필요한 시간이고, 이 부분의 개선이 시급했습니다.

- 빌드 시스템(Webpack) 설정 점검

서비스의 빌드 시스템 설정에 잘못된 부분은 없는지 점검했습니다.

webpack-bundle-analyzer 라는 도구를 활용하면 번들의 세부 구성을 한 눈에 확인할 수 있습니다.

번들 구성 중 이미지의 비중이 너무 높았습니다. 이미지 관련 옵션을 분석한 결과, 네트워크 요청 수를 줄이기 위해 파일을 base64 문자열로 인코딩해주는 url-loader 의 용량 limit 옵션의 문제를 발견했고, 이 옵션을 10000 (10 KB)로 변경하니 번들 크기가 60% 감소했습니다.

{
test: /\.(png|jpg|gif|svg|eot|ttf|woff|woff2)$/,
use: [{
loader: 'url-loader',
options: {
limit: 1000000 // 1 MB 이하의 이미지와 폰트가 번들에 포함됨 😱
}
}]
}

빌드 도구의 목적을 명확히 이해하지 못한 채 사용하는 건 오히려 독이 될 수 있다는 것을 실감했습니다.

- Code splitting

각 웹페이지들은 페이지 단위의 React component 구분되어 있습니다. 예를 들어 상품 목록은 OfferList라는 컴포넌트를 시작으로 UI 영역을 구성합니다. 상품 목록 페이지에 진입하기 전에 해당 소스코드를 로딩하는 과정은 불필요할 것입니다.

Code splitting은 번들을 잘게 쪼개기 위한 기법 중 하나입니다. 아래 스크린샷과 같이 빌드를 통해 chunk라는 별도 파일로 나누고 필요한 시점에 불러오게 됩니다.

React에서는 React.lazy라는 기능으로 code splitting을 간편하게 구현할 수 있도록 돕고 있습니다.

const OfferList = React.lazy(() => import('/path/to/OfferList'));
return (
<React.Suspense fallback={<SpinnerOrSkeleton />}>
<OfferList />
</React.Suspense>
);

8개 정도의 주요 React component에 적용한 결과, 번들 크기가 22% 감소했습니다.

- 외부 라이브러리 최적화

vendor라는 번들 파일은 프로젝트에 사용 중인 외부 라이브러리로 구성됩니다. 이 파일의 용량 역시 속도에 영향을 주기 때문에 아래와 같은 최적화 작업을 진행했습니다.

  • 모듈 import 최적화: lodash, react-dates
  • 불필요한 라이브러리 제거: moment-timezone, redux, redux-*
  • 용량이 작은 라이브러리로 교체: moment -> dayjs, swiper 자체 구현

빨간 박스로 표시된 부분이 최적화 대상이 된 라이브러리 목록입니다.

lodash의 경우 함수 단위로 사용되기 때문에 통째로 import하는 코드를 제거해야 합니다. 또한 모듈 import 방식에 최적화된 lodash-es라는 라이브러리로 교체 가능합니다.

import isEqual from 'lodash-es/isEqual';

Redux는 당시 특정 코드에만 적용되어 있었고, React Hooks가 출시되면서 자체 제공되는 useReducer, useContext만으로 어플리케이션 상태 관리가 가능해 Redux 관련 라이브러리를 제거했습니다.

Moment.js는 date 관련 유틸리티로 가장 많이 사용되는 라이브러리입니다. 한가지 아쉬운 점은 용량이 너무 크다는 것입니다. 마이리얼트립 프로젝트에서 Moment.js의 일부 기능만 사용하고 있어 dayjs라는 경량 라이브러리로 교체하는 작업을 진행 중입니다. Datepicker UI에 사용되는 react-dates에 의존성이 있어 이 부분 호환성 이슈를 제보해 다음 릴리즈에 반영되길 기다리는 상태입니다. 이 작업이 완료되면 65.9 KB 에서 2.8 KB로 줄일 수 있어 큰 개선이 기대됩니다.

Swiper.js 역시 용량이 큰 라이브러리 중 하나입니다. 주로 상품 카드를 좌우로 탐색하는 영역에 사용되고 있어 꼭 필요한 기능만 정의해 자체 구현하는 방안을 검토 중입니다.

Swiper.js로 구현된 상품 카드 영역

위의 작업들을 통해 vendor 번들의 크기를 20% 줄일 수 있었습니다.

- 이미지 스프라이트

아이콘은 대부분 1 KB 이하의 작은 이미지 파일이라 url-loader의 limit 옵션을 10 KB로 줄인 뒤에도 번들에 약 200개 정도의 아이콘 파일이 존재했습니다. 페이지 별로 사용되는 아이콘이 다른데 한꺼번에 로딩된다는 점과 아이콘 1개당 1번의 네트워크 요청이 필요하다는 점이 비효율적이라고 판단했습니다.

이미지 스프라이트는 여러 이미지를 하나의 2D 비트맵으로 합치는 기법인데 아래와 같이 작은 이미지가 여러 개 존재하는 영역에 적용할 수 있습니다.

각 스프라이트 파일의 용량을 60~80 KB 이하로 유지하기 위한 3가지 기준을 정했습니다.

  • sprite-layout: 기본 레이아웃인 header, footer 내의 아이콘 모음
  • sprite-common: arrow, 닫기 버튼과 같이 모든 페이지 공통으로 사용되는 아이콘 모음
  • sprite-{menu}: 투어&티켓, 숙소 등과 같이 특정 메뉴에서만 사용되는 아이콘 모음

기존의 img 태그 기반 아이콘들을 교체하려니 예상보다 시간이 많이 소요됐습니다. 현재는 sprite-layout에 대해서만 반영된 상태고, 나머지 작업이 진행 중입니다.

sprite-layout

2. 지연 로딩(Lazy Loading)

지연 로딩은 필요한 시점에 실행되도록 동작을 뒤로 미루는 방법입니다. 번들 크기를 줄이는 작업이 절대적인 수치를 개선하는 작업인 반면 지연 로딩은 체감 속도를 향상시킵니다. 웹페이지 상에서 여행자의 상호 작용 시점을 앞당기기 위해 중요한 역할을 하는 개선 방법입니다.

- 이미지 지연 로딩

마이리얼트립은 다양한 상품 정보를 제공합니다. 각 상품 정보에는 여러 개의 이미지가 존재합니다. 따라서 한 페이지에 많게는 50개 이상의 이미지가 제공되기도 합니다.

문제는 그 이미지들을 페이지 로딩 시점에 모두 불러온다는 것이었습니다. 이렇게 되면 페이지를 그려낸 뒤에도 연속적으로 네트워크 호출이 발생하고, 로딩된 이미지가 배치되면서 UI에 layout(또는 reflow)이라 불리는 부하가 발생할 수 있습니다. 이는 상호 작용을 지연시키거나 Pagespeed insight과 같은 웹사이트 성능 측정 도구의 스코어를 낮추기도 합니다.

여행자가 스크롤 이동 또는 상품을 좌우 스와이프로 탐색하는 행동을 하는 시점, 즉 이미지가 필요한 순간에 불러오도록 개선해야 합니다.
Web API 중 Intersection Observer라는 API가 있습니다. 대상으로 정한 UI가 특정 영역에 진입하는 시점에 이벤트를 발생하는 역할을 합니다. 이 API와 React Hooks를 조합해 지연 로딩 처리를 위한 custom hook을 구현 및 적용했습니다.

useLazyImageObserver Hook 예제 코드

그리고 각 이미지마다 서로 다른 크기(tiny, medium)로 2개씩 로딩하는 문제가 있었습니다.

크기가 다른 동일한 이미지 호출

tiny 크기의 이미지는 blur 처리에 사용됐고, 상품의 이해를 돕는 역할을 하지 못하기 때문에 불필요한 요청으로 판단됐습니다.

Blur 처리된 tiny 크기의 이미지

tiny 크기의 이미지를 제거하고 지연 로딩을 적용한 결과, 다낭 도시 페이지의 이미지 로딩 기준으로 아래와 같은 효과가 있었습니다.

  • 네트워크 요청 수: 122개 -> 51개
  • 전송된 네트워크 데이터 용량: 3.5 MB -> 532 KB

- 데이터 지연 로딩

이 작업은 “방문 목적을 달성하기 위한 영역이 보여지는 시점을 측정”하는 속도의 기준과 연관되어 있습니다.

웹페이지를 그리기 전, 서버를 거치는 과정에서 유저, 상품, 예약 정보 등의 데이터를 DB 또는 외부 API 호출을 통해 불러옵니다. 웹브라우저는 데이터 로딩이 완료될 때까지 대기합니다. 따라서 데이터 로딩 시간이 길어질수록 빈 화면이 노출되거나 현재 페이지에 멈춰있게 됩니다.

측정 당시 로딩 속도가 느렸던 페이지 목록은 아래와 같습니다.

  • 메인(투어&티켓)
  • 도시 페이지
  • 상품 상세 페이지

이 페이지들은 하단에 추천 상품 스와이프 영역이 있다는 공통점이 있습니다. 해당 영역이 페이지에 진입한 목적을 달성하기 위한 최우선 순위의 UI (Hero Element)는 아닐 것입니다. 따라서 이 영역의 로딩은 주요 UI를 제공한 뒤 불러와도 무방합니다.

지연이 필요한 영역의 데이터 호출 로직을 서버 controller에서 제거하고, 클라이언트 단에서 필요한 시점에 API를 호출하는 방식으로 전환했습니다. 그리고 자연스러운 UX를 위해 지연되는 시간 동안 스켈레톤으로 그 영역을 채웠습니다.

다른 페이지에도 이와 비슷한 기법으로 향상시킬 요소가 있을거라고 판단됩니다. 신규 페이지 추가 시에도 방문 목적을 달성하기 위해 가장 중요한 UI 영역을 먼저 제공하고, 중요도가 떨어지는 영역을 이후 제공해 체감 속도를 향상시켜야 합니다.

3. 웹 성능 예산(Performance Budget) 도입

리소스 로딩과 관련한 성능 품질을 지속하기 위한 방안이 필요하던 중, 웹 성능 예산이라는 개념이 눈에 들어왔습니다.

웹 성능 예산이란 초과하지 않도록 팀 내에서 약속된 성능의 기준입니다. 그것은 단순히 번들의 크기일 수도 있고, 특정 네트워크 환경 상에서 TTI 시점에 도달하는 시간이 될 수도 있습니다. 중요한 건 서비스의 품질을 유지하기 위한 합의점이자 약속이라는 것입니다.

아직 마이리얼트립 웹사이트는 개선할 점이 많고, 최적화할 항목이 남아 있어 웹 성능 예산을 엄격하게 책정할 단계는 아니라고 판단됩니다. 적어도 현재의 수준을 유지하기 위해 번들 크기 기준으로 상시 확인할 수 있는 도구인 bundlesize를 적용했습니다. 이 라이브러리는 package.json에 크기 제한을 설정해 비교 결과를 보여주는 역할을 합니다.

"bundlesize": [
{
"path": "path/to/application.*.js",
"maxSize": "170 kB"
},
{
"path": "path/to/vendor.*.js",
"maxSize": "310 kB"
}
]

정해진 번들 크기를 초과하면 에러를 반환하기 때문에 CI에 연동해 코드 반영 전 자동화도 가능합니다.


결론

리소스 로딩 편이 마무리된 시점에 속도의 기준(Fast 3G, 캐시 비활성화)으로 측정된 웹사이트 성능은 아래와 같습니다.

  • 번들 파일 크기: application 167 KB, vender 310 KB
  • DOMContentLoaded: 8초
  • FMP (First Meaningful Paint): 14초

아직 갈 길이 멉니다. 단기간 내 해결이 어려운 몇가지 제약 사항(기존 jQuery 코드 유지를 위한 별도의 번들 파일, 클라이언트와 서버 코드의 높은 결합도)을 감안하더라도 여전히 개선 가능한 부분이 많다고 생각합니다. 번들 크기를 모두 100 KB 대로, FMP를 10초 이내로 줄이는 것이 다음 목표입니다. 아울러 시리즈 다음 편인 렌더링 최적화, 메모리 관리 편도 많은 관심 부탁드립니다.

이번 작업으로 웹사이트 성능은 시간과 관심을 들이는만큼 개선 가능성이 늘어난다는 확신을 얻었습니다. 이 가능성을 더욱 높여줄 프론트엔드 팀 동료 분을 애타게 모시고 있습니다! 아래의 채용 페이지를 방문해주세요.

https://career.myrealtrip.com/


Portions of this page are reproduced from work created and shared by Google and used according to terms described in the Creative Commons 3.0 Attribution License.