피처폰에서도 웹 앱을 빠르게 로드하는 기술

PROXX에서 코드 분할, 코드 인라인 처리, 서버 측 렌더링을 사용한 방법

Google I/O 2019에서 마리코, 제이크, 나는 최신 웹용 지뢰 찾기 클론인 PROXX를 출시했습니다. PROXX의 차별화 요소는 접근성 (스크린 리더로 재생 가능)과 고급형 데스크톱 기기처럼 피처폰에서도 실행할 수 있다는 점입니다. 피처폰은 다음과 같은 여러 가지 방법으로 제한됩니다.

  • 취약한 CPU
  • GPU가 약하거나 존재하지 않음
  • 터치 입력이 없는 작은 화면
  • 매우 제한된 양의 메모리

하지만 최신 브라우저를 실행하며 가격도 매우 저렴합니다. 이 때문에 신흥 시장에서 피처폰이 부활하고 있습니다. 이러한 가격대는 이전에는 비용을 감당할 수 없던 완전히 새로운 잠재고객이 온라인에 접속하여 최신 웹을 활용할 수 있게 해줍니다. 2019년에는 인도에서만 약 4억 대의 피처폰이 판매될 것으로 예상되므로 피처폰 사용자가 잠재고객의 상당 부분이 될 수 있습니다. 또한 2G 연결 속도는 신흥 시장의 표준이 되었습니다. 피처폰 환경에서 PROXX가 원활하게 작동하도록 하기 위해 Google에서 어떤 노력을 기울였나요?

PROXX 게임플레이.

성능이 중요합니다. 여기에는 로드 성능과 런타임 성능이 모두 포함됩니다. 우수한 실적은 사용자 유지율 증가, 전환수 향상, 그리고 무엇보다도 포용성 향상과 상관관계가 있는 것으로 나타났습니다. 제레미 와그너실적이 중요한 이유에 관해 훨씬 더 많은 데이터와 통계를 제공합니다.

이 내용은 총 2부로 구성된 시리즈의 1부입니다. 1부에서는 로드 성능에 초점을 맞추고 2부에서는 런타임 성능에 초점을 맞춥니다.

현재 상황 포착

실제 기기에서 로드 성능을 테스트하는 것이 매우 중요합니다. 지금 사용할 수 있는 기기가 없다면 WebPageTest를 사용해 특히 '간단한' 설정을 사용해 보세요. WPT는 에뮬레이션된 3G 연결을 사용하여 실제 기기에서 배터리 로드 테스트를 실행합니다.

3G는 측정에 좋은 속도입니다. 4G, LTE 또는 5G에 익숙하시겠지만 모바일 인터넷의 현실은 상당히 다릅니다. 기차, 회의, 콘서트 또는 비행기와 같이 이 문제는 3G에 가까울 가능성이 높으며 때로는 더 심해질 수 있습니다.

그렇지만 이 글에서는 2G에 초점을 맞출 것입니다. PROXX가 타겟층의 피처폰과 신흥 시장을 명시적으로 표적으로 삼고 있기 때문입니다. WebPageTest가 테스트를 실행하면 상단에 슬라이드와 함께 폭포식 구조 (DevTools에 표시되는 것과 유사함)가 표시됩니다. 필름 스트립은 앱이 로드되는 동안 사용자에게 표시되는 내용을 보여줍니다. 2G에서는 최적화되지 않은 PROXX 버전의 로드 환경이 매우 나빠집니다.

슬라이드 동영상은 에뮬레이션된 2G 연결을 통해 PROXX가 실제 저사양 기기에 로드될 때 사용자에게 표시되는 화면을 보여줍니다.

3G를 통해 로드되면 4초간 흰색 화면이 표시됩니다. 2G에서는 8초 동안 사용자에게 아무것도 표시되지 않습니다. 실적이 중요한 이유를 읽어보면 조바심으로 인해 잠재 사용자의 상당수를 잃었다는 사실을 알 수 있습니다. 화면에 무언가를 표시하려면 사용자는 62KB의 JavaScript를 모두 다운로드해야 합니다. 다행인 점은 화면에 표시되는 모든 항목이 상호작용적이라는 점입니다. 정말 불가능할까요?

최적화되지 않은 PROXX 버전의 [첫 번째 의미 있는 페인트][FMP] 는 _기술적으로_ [대화형][TTI] 이지만 사용자에게는 유용하지 않습니다.

약 62KB의 gzip으로 압축된 JS가 다운로드되고 DOM이 생성되면 사용자는 앱을 보게 됩니다. 앱은 기술적으로 대화형입니다. 하지만 이 그림을 보면 다른 현실을 알 수 있습니다. 웹 글꼴은 백그라운드에서 계속 로드되며 준비가 될 때까지 사용자에게 텍스트가 표시되지 않습니다. 이 상태는 첫 번째 의미 있는 페인트 (FMP)에 해당하지만, 사용자가 어떤 입력에 관한 것인지 알 수 없으므로 적절한 양방향이라고 할 수는 없습니다. 앱을 사용할 준비가 될 때까지 3G에서는 1초, 2G에서는 3초가 소요됩니다. 앱과 상호작용하려면 3G에서는 6초, 2G에서는 11초가 걸립니다.

폭포 분석

이제 사용자에게 표시되는 내용을 알았으므로 이유를 파악해야 합니다. 이를 위해 폭포식 구조를 살펴보고 리소스가 너무 늦게 로드되는 이유를 분석할 수 있습니다. PROXX의 2G 트레이스에서 두 가지 주요 문제를 확인할 수 있습니다.

  1. 여러 색상의 가는 선이 여러 개 있습니다.
  2. JavaScript 파일은 체인을 형성합니다. 예를 들어 두 번째 리소스는 첫 번째 리소스가 완료된 후에만 로드를 시작하고, 세 번째 리소스는 두 번째 리소스가 완료될 때만 로드를 시작합니다.
폭포식 구조는 로드되는 시기와 시간에 대한 유용한 정보를 제공합니다.

연결 수 줄이기

각 가는 줄 (dns, connect, ssl)은 새 HTTP 연결 생성을 나타냅니다. 3G에서는 약 1초, 2G에서는 약 2.5초가 걸리기 때문에 새 연결을 설정하는 데 비용이 많이 듭니다. 폭포식 구조에서 다음에 대한 새 연결을 확인할 수 있습니다.

  • 요청 #1: index.html
  • 요청 #5: fonts.googleapis.com의 글꼴 스타일
  • 요청 #8: Google 애널리틱스
  • 요청 #9: fonts.gstatic.com의 글꼴 파일
  • 요청 #14: 웹 앱 매니페스트

index.html의 새 연결은 불가피합니다. 브라우저가 콘텐츠를 가져오기 위해 Google 서버에 연결해야 합니다. 최소 분석 등을 인라인 처리하면 Google 애널리틱스의 새 연결을 피할 수 있지만, Google 애널리틱스는 앱의 렌더링이나 상호작용을 차단하지 않으므로 로드 속도에는 크게 관심이 없습니다. Google 애널리틱스는 다른 모든 항목이 이미 로드된 유휴 시간에 로드되는 것이 가장 좋습니다. 이렇게 하면 초기 로드 시 대역폭이나 처리 성능을 차지하지 않습니다. 웹 앱 매니페스트의 새 연결은 가져오기 사양에 의해 규정됩니다. 매니페스트는 사용자 인증 정보가 아닌 연결을 통해 로드되어야 하기 때문입니다. 다시 말씀드리지만, 웹 앱 매니페스트는 앱이 렌더링되거나 대화형이 되는 것을 차단하지 않으므로 크게 신경 쓸 필요가 없습니다.

그러나 이 두 글꼴과 스타일은 렌더링과 상호작용을 차단하므로 문제가 됩니다. fonts.googleapis.com에서 제공하는 CSS를 살펴보면 각 글꼴마다 하나씩 두 개의 @font-face 규칙만 있습니다. 글꼴 스타일이 사실 너무 작아서 HTML에 인라인으로 만들기로 하여 불필요한 연결 하나를 제거하기로 했습니다. 글꼴 파일의 연결 설정 비용을 방지하기 위해 글꼴을 자체 서버에 복사할 수 있습니다.

로드 동시 로드

폭포식 구조를 보면 첫 번째 JavaScript 파일의 로드가 완료되면 새 파일이 즉시 로드되기 시작합니다. 이는 모듈 종속 항목에서 일반적입니다. 기본 모듈에는 정적 가져오기가 있을 수 있으므로 이러한 가져오기가 로드될 때까지 JavaScript를 실행할 수 없습니다. 여기서 알아야 할 중요한 점은 이러한 종류의 종속 항목이 빌드 시간에 알려져 있다는 것입니다. <link rel="preload"> 태그를 사용하면 HTML을 수신하는 즉시 모든 종속 항목이 로드되기 시작합니다.

결과

변경사항을 통해 달성한 결과를 살펴보겠습니다. 테스트 설정에서 결과를 왜곡할 수 있는 다른 변수를 변경하지 않는 것이 중요하므로 이 도움말의 나머지 부분에서는 WebPageTest의 간단한 설정을 사용하고 슬라이드를 살펴봅니다.

WebPageTest의 슬라이드를 사용하여 변경사항의 결과를 확인합니다.

이러한 변경사항으로 TTI가 11초에서 8.5초로 단축되었으며, 이는 Google에서 삭제하려고 했던 연결 설정 시간의 약 2.5초에 해당합니다. 잘하셨습니다.

사전 렌더링

TTI를 줄였지만 사용자가 8.5초 동안 계속 길어야 하는 흰색 화면에는 영향을 주지 않았습니다. 당연히 FMP의 가장 큰 개선은 index.html에서 스타일이 지정된 마크업을 전송함으로써 얻을 수 있습니다. 이를 위한 일반적인 기법으로는 사전 렌더링과 서버 측 렌더링이 있습니다. 이러한 기술은 밀접하게 관련되어 있으며 웹에서 렌더링하기에 설명되어 있습니다. 두 기법 모두 노드에서 웹 앱을 실행하고 결과 DOM을 HTML로 직렬화합니다. 서버 측 렌더링은 서버 측에서 요청별로 이 작업을 실행하는 반면, 사전 렌더링은 빌드 시간에 이를 실행하고 출력을 새 index.html로 저장합니다. PROXX는 JAMStack 앱이며 서버 측이 없기 때문에 사전 렌더링을 구현하기로 했습니다.

프리렌더러를 구현하는 방법에는 여러 가지가 있습니다. PROXX에서는 Puppeteer를 사용하기로 했습니다. Puppeteer는 UI 없이 Chrome을 시작하고 Node API를 통해 해당 인스턴스를 원격으로 제어할 수 있게 해 줍니다. 이를 사용하여 마크업과 JavaScript를 삽입한 다음 DOM을 HTML 문자열로 다시 읽습니다. CSS 모듈을 사용하기 때문에 필요한 스타일의 CSS 인라인 처리를 무료로 받을 수 있습니다.

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  await page.setContent(rawIndexHTML);
  await page.evaluate(codeToRun);
  const renderedHTML = await page.content();
  browser.close();
  await writeFile("index.html", renderedHTML);

이를 통해 FMP가 개선될 수 있습니다. 이전과 동일한 양의 JavaScript를 로드하고 실행해야 하므로 TTI가 크게 변경되지는 않을 것입니다. 오히려 index.html가 커져 TTI가 약간 지연될 수 있습니다. 확인할 수 있는 한 가지 방법은 WebPageTest를 실행하는 것입니다.

필름 스트립에서 FMP 측정항목의 개선이 명확하게 나타나고 있습니다. TTI는 거의 영향을 받지 않습니다.

첫 번째 의미 있는 페인트가 8.5초에서 4.9초로 대폭 개선되었습니다. TTI는 약 8.5초에서 계속 발생하므로 이 변경 사항이 크게 영향을 받지 않았습니다. 여기서 한 일은 지각적 변화입니다. 일종의 실수라고 부르기도 합니다. 우리는 게임의 중간 시각적 요소를 렌더링함으로써 인지되는 로드 성능을 개선하려고 합니다.

인라인

DevTools와 WebPageTest에서 모두 제공하는 또 다른 측정항목은 TTFB (Time To First Byte)입니다. 전송되는 요청의 첫 번째 바이트부터 수신 응답의 첫 바이트까지 걸리는 시간입니다. 이 시간은 왕복 시간 (RTT)이라고도 하지만 실질적으로 이 두 숫자 사이에는 차이가 있습니다. 즉, RTT에는 서버 측의 요청 처리 시간이 포함되지 않습니다. DevTools 및 WebPageTest는 요청/응답 블록 내에서 밝은 색상으로 TTFB를 시각화합니다.

요청의 라이트 섹션은 요청이 응답의 첫 번째 바이트를 수신하기 위해 대기 중임을 나타냅니다.

폭포식 구조를 보면 모든 요청이 응답의 첫 번째 바이트가 도착할 때까지 기다리는 시간 중 대부분을 소비한다는 것을 알 수 있습니다.

이 문제는 HTTP/2 푸시의 원래 의도였습니다. 앱 개발자는 특정 리소스의 필요성을 인식하고 리소스를 밀어낼 수 있습니다. 클라이언트가 추가 리소스를 가져와야 한다는 것을 깨달을 무렵에는 이미 브라우저의 캐시에 저장되어 있습니다. HTTP/2 Push는 제대로 하기가 너무 어렵기 때문에 권장되지 않는 것으로 여겨집니다. 이 문제 공간은 HTTP/3의 표준화 과정에서 재검토될 것입니다. 현재 가장 쉬운 해결책은 캐싱 효율성이 떨어지더라도 모든 중요한 리소스를 인라인으로 추가하는 것입니다.

CSS 모듈과 Puppeteer 기반 사전 렌더러 덕분에 중요한 CSS가 이미 인라인으로 처리되고 있습니다. JavaScript의 경우 중요한 모듈 및 종속 항목을 인라인해야 합니다. 이 작업은 사용 중인 번들러에 따라 난이도가 다릅니다.

자바스크립트 인라인 처리를 통해 TTI를 8.5초에서 7.2초로 줄였습니다.

덕분에 TTI가 1초 단축되었습니다. 이제 index.html에 초기 렌더링과 대화형이 되는 데 필요한 모든 항목이 포함된 지점에 도달했습니다. HTML은 다운로드 중에 렌더링되어 FMP가 생성됩니다. HTML의 파싱과 실행이 완료되는 순간 앱은 상호작용합니다.

적극적인 코드 분할

예. index.html에는 상호작용을 위해 필요한 모든 것이 포함되어 있습니다. 하지만 자세히 확인해 보니 다른 모든 것도 포함되어 있습니다. index.html는 약 43KB입니다. 이를 시작할 때 사용자가 상호작용할 수 있는 요소와 관련하여 생각해 봅시다. 몇 가지 구성요소, 시작 버튼, 사용자 설정을 유지하고 로드할 코드 등을 포함하는 게임을 구성하는 양식이 있습니다. 여기까지입니다. 43KB는 너무 큰 것 같습니다.

PROXX의 방문 페이지입니다. 여기에서는 중요한 구성요소만 사용됩니다.

번들 크기의 출처를 파악하려면 소스 맵 탐색기 또는 유사한 도구를 사용하여 번들이 무엇으로 구성되어 있는지 분석할 수 있습니다. 예상대로 번들에는 게임 로직, 렌더링 엔진, 승리 화면, 실패 화면 및 여러 유틸리티가 포함되어 있습니다. 이러한 모듈 중 일부분만 방문 페이지에 있으면 됩니다. 상호작용에 반드시 필요하지 않은 모든 것을 지연 로드 모듈로 이동하면 TTI가 상당히 감소합니다.

PROXX의 `index.html` 콘텐츠를 분석하면 불필요한 리소스가 많이 표시됩니다. 중요한 리소스는 강조 표시됩니다.

해야 할 작업은 코드 분할입니다. 코드 분할은 모놀리식 번들을 주문형으로 지연 로드할 수 있는 작은 부분으로 나눕니다. Webpack, Rollup, Parcel 같은 인기 번들러는 동적 import()를 사용하여 코드 분할을 지원합니다. 번들러는 코드를 분석하고 정적으로 가져온 모든 모듈을 인라인합니다. 동적으로 가져오는 모든 항목은 자체 파일에 저장되며 import() 호출이 실행된 후에만 네트워크에서 가져옵니다. 물론 네트워크에 접속하는 것은 비용이 발생하므로 여유 시간이 있는 경우에만 수행해야 합니다. 여기서 핵심은 로드 시간에 중요한 모듈을 정적으로 가져오고 나머지는 모두 동적으로 로드하는 것입니다. 그러나 확실히 사용될 모듈을 지연 로드하려면 최후의 시간까지 미루어서는 안 됩니다. 필 월튼Idle Until Urgent는 지연 로드와 Eager 로드 사이의 건강한 중간 지점을 위한 훌륭한 패턴입니다.

PROXX에서는 필요하지 않은 모든 것을 정적으로 가져오는 lazy.js 파일을 만들었습니다. 그런 다음 기본 파일에서 lazy.js동적으로 가져올 수 있습니다. 하지만 일부 Preact 구성요소는 lazy.js로 끝났는데, 이는 Preact는 지연 로드된 구성요소를 즉시 처리할 수 없기 때문에 다소 복잡한 것으로 나타났습니다. 이러한 이유로 실제 구성요소가 로드될 때까지 자리표시자를 렌더링할 수 있는 작은 deferred 구성요소 래퍼를 작성했습니다.

export default function deferred(componentPromise) {
  return class Deferred extends Component {
    constructor(props) {
      super(props);
      this.state = {
        LoadedComponent: undefined
      };
      componentPromise.then(component => {
        this.setState({ LoadedComponent: component });
      });
    }

    render({ loaded, loading }, { LoadedComponent }) {
      if (LoadedComponent) {
        return loaded(LoadedComponent);
      }
      return loading();
    }
  };
}

이렇게 하면 render() 함수에서 구성요소의 프로미스를 사용할 수 있습니다. 예를 들어 애니메이션 배경 이미지를 렌더링하는 <Nebula> 구성요소는 구성요소가 로드되는 동안 빈 <div>로 대체됩니다. 구성요소가 로드되어 사용할 준비가 되면 <div>가 실제 구성요소로 대체됩니다.

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

이 모든 것을 적용하여 index.html를 원래 크기의 절반 미만인 20KB로 줄였습니다. 이는 FMP와 TTI에 어떤 영향을 미치나요? WebPageTest에서 확인할 수 있습니다.

필름스트립 확인: TTI가 5.4초로 변경되었습니다. 기존 11s에 비해 크게 개선되었습니다.

FMP와 TTI는 인라인 JavaScript를 파싱하고 실행하는 것의 문제이기 때문에 차이는 100ms에 불과합니다. 2G에서 5.4초만 가면 앱이 완전히 상호작용할 수 있습니다. 나머지 모든 모듈은 덜 필수적인 모듈이 백그라운드에서 로드됩니다.

더 손쉬운 손잡이

위의 주요 모듈 목록을 살펴보면 렌더링 엔진이 주요 모듈에 포함되지 않는다는 것을 알 수 있습니다. 물론, 게임을 렌더링할 렌더링 엔진이 있어야 게임을 시작할 수 있습니다. 렌더링 엔진이 게임을 시작할 준비가 될 때까지 '시작' 버튼을 사용 중지할 수 있지만, Google의 환경에서는 사용자가 일반적으로 게임 설정을 구성하는 데 시간이 오래 걸리므로 그렇게 하지 않아도 됩니다. 대부분 렌더링 엔진과 나머지 모듈은 사용자가 '시작'을 누를 때부터 로드가 완료됩니다. 드물지만 사용자가 네트워크 연결보다 빠른 경우 나머지 모듈이 완료될 때까지 기다리는 간단한 로딩 화면이 표시됩니다.

결론

측정은 중요합니다. 실제가 아닌 문제에 시간을 소비하지 않으려면 항상 최적화를 구현하기 전에 먼저 측정하는 것이 좋습니다. 또한 측정은 3G 연결을 사용하는 실제 기기에서 수행하거나, 현재 사용 중인 기기가 없는 경우 WebPageTest에서 측정해야 합니다.

슬라이드를 통해 앱 로드 시 사용자의 느낌을 파악할 수 있습니다. 폭포식 구조를 통해 로드 시간이 길어질 수 있는 원인이 되는 리소스를 확인할 수 있습니다. 다음은 로드 성능을 개선하기 위해 할 수 있는 체크리스트입니다.

  • 하나의 연결을 통해 최대한 많은 애셋을 전달합니다.
  • 첫 번째 렌더링과 상호작용에 필요한 리소스를 미리 로드하거나 인라인으로 할 수도 있습니다.
  • 앱을 사전 렌더링하여 인지되는 로드 성능을 개선합니다.
  • 적극적인 코드 분할을 활용하여 상호작용에 필요한 코드의 양을 줄입니다.

2부에서는 극도로 제한된 기기에서 런타임 성능을 최적화하는 방법에 대해 다룰 예정이니 기대해 주세요.