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

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

Google I/O 2019에서 제이크와 저는 최신 웹용 지뢰찾기 클론인 PROXX를 출시했습니다. PROXX가 차별되는 점은 접근성 (스크린 리더로 재생할 수 있음) 및 피처폰에서처럼 고급 데스크톱 기기에서 잘 실행될 수 있다는 것입니다. 피처폰은 다음과 같은 여러 가지 방식으로 제한됩니다.

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

하지만 최신 브라우저를 실행하고 매우 저렴합니다. 이러한 이유로 신흥 시장에서 피처폰이 다시 인기를 얻고 있습니다. 이 가격대를 통해 이전에는 감당할 수 없었던 완전히 새로운 독자층이 온라인에 접속하여 최신 웹을 활용할 수 있습니다. 2019년에는 인도에서만 약 4억 대의 피처폰이 판매될 것으로 예상되므로 피처폰 사용자는 잠재고객의 상당 부분이 될 수 있습니다. 그뿐만 아니라 2G와 비슷한 연결 속도는 신흥 시장에서 표준입니다. 피처폰이라는 조건에서 PROXX이(가) 잘 작동하도록 할 수 있었던 비결은 무엇일까요?

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> PROXX 게임플레이

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

이 시리즈는 2부로 구성된 시리즈의 1부입니다. 1부에서는 로드 성능을 중점적으로 살펴보고, 2부에서는 런타임 성능에 중점을 둡니다.

현상 유지

실제 기기에서 로드 성능을 테스트하는 것이 중요합니다. 사용 가능한 실제 기기가 없다면 WebPageTest, 특히 'simple' 설정을 참조하세요. WPT는 에뮬레이션된 3G 연결을 사용하여 실제 기기에서 로드 테스트의 배터리를 실행합니다.

3G는 측정하기에 좋은 속도입니다. 4G, LTE 또는 조만간 5G에 익숙하실 수도 있지만 모바일 인터넷의 현실은 상당히 다릅니다. 기차, 콘퍼런스, 콘서트, 비행기를 타고 있을 수도 있습니다. 3G에 더 가깝고 심지어 더 나쁜 환경에서 겪게 되실 겁니다.

그렇더라도 PROXX는 대상 고객의 피처폰과 신흥 시장을 명시적으로 타겟팅하고 있기 때문에 이 문서에서는 2G에 초점을 맞추겠습니다. WebPageTest가 테스트를 실행하면 워터폴 (DevTools에 표시되는 것과 유사)과 상단에 슬라이드가 표시됩니다. 필름 스트립은 앱이 로드되는 동안 사용자에게 표시되는 내용을 보여줍니다. 2G에서는 최적화되지 않은 PROXX 버전의 로드 환경이 매우 좋지 않습니다.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 이 필름 스트립 동영상은 PROXX가 에뮬레이션된 2G 연결을 통해 실제 저사양 기기에서 로드 중일 때 사용자에게 표시되는 내용을 보여줍니다.

3G를 통해 로드되면 사용자에게 4초 동안 아무 것도 보이지 않습니다. 2G를 통해 사용자는 8초 동안 아무 것도 보지 못합니다. 성능이 중요한 이유를 읽으면 조바심으로 인해 잠재적 사용자의 상당 부분이 이탈했다는 것을 알 수 있습니다. 화면에 내용을 표시하려면 62KB의 JavaScript를 모두 다운로드해야 합니다. 이 시나리오에서 한 가지 유념은 두 번째로 화면에 표시되는 모든 것이 상호작용한다는 점입니다. 정말 불가능할까요?

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 최적화되지 않은 PROXX 버전의 [첫 번째 의미 있는 페인트][FMP] 는 _기술적으로_ [대화형][TTI] 이지만 사용자에게는 쓸모가 없습니다.

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

폭포 분석

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

  1. 여러 색상의 가는 선이 여러 개 있습니다.
  2. JavaScript 파일은 체인을 형성합니다. 예를 들어 두 번째 리소스는 첫 번째 리소스가 완료된 후에만 로드되기 시작하고, 세 번째 리소스는 두 번째 리소스가 완료된 경우에만 시작됩니다.
를 통해 개인정보처리방침을 정의할 수 있습니다. <ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder"></ph> <ph type="x-smartling-placeholder">
</ph> 폭포식 구조를 통해 어떤 리소스가 언제, 얼마나 걸리는지에 대한 통계를 확인할 수 있습니다.

연결 수 줄이기

각 가는 줄 (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 애널리틱스를 로드하는 것이 이상적입니다. 이렇게 하면 초기 로드 중에 대역폭이나 처리 성능을 차지하지 않습니다. 매니페스트는 사용자 인증 정보가 없는 연결을 통해 로드되어야 하므로 웹 앱 매니페스트의 새 연결은 가져오기 사양에 의해 지정됩니다. 다시 말하지만, 웹 앱 매니페스트는 앱이 렌더링되거나 대화형이 되는 것을 차단하지 않으므로 그다지 신경 쓸 필요는 없습니다.

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

로드 병렬화

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

결과

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

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> WebPageTest의 필름스트립을 사용하여 변경 사항이 무엇을 달성했는지 확인합니다.

이러한 변경으로 TTI가 11초에서 8.5초로 단축되었으며, 이는 삭제 목표인 연결 설정 시간의 약 2.5초에 해당합니다. 잘했어.

사전 렌더링

TTI를 줄였을 뿐 아니라 사용자가 8.5초 동안 감당해야 하는 영원한 긴 흰색 화면에는 실제로 영향을 미치지 않았습니다. 분명 index.html에서 스타일이 지정된 마크업을 보내 FMP를 가장 크게 개선할 수 있습니다. 이를 달성하기 위한 일반적인 기술은 사전 렌더링과 서버 측 렌더링으로, 밀접하게 관련되어 있으며 웹에서 렌더링에 설명되어 있습니다. 두 기법 모두 Node에서 웹 앱을 실행하고 결과 DOM을 HTML로 직렬화합니다. 서버 측 렌더링은 서버 측에서 요청에 따라 이 작업을 실행하는 반면, 사전 렌더링은 빌드 시간에 이를 실행하고 출력을 새 index.html로 저장합니다. PROXX는 JAMStack 앱이고 서버 측이 없기 때문에 사전 렌더링을 구현하기로 했습니다.

사전 렌더링기를 구현하는 방법에는 여러 가지가 있습니다. PROXX에서는 UI 없이 Chrome을 시작하고 Node API로 인스턴스를 원격으로 제어할 수 있게 해주는 Puppeteer를 사용하기로 했습니다. 이를 사용하여 마크업과 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를 실행하는 것입니다.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 슬라이드에서 FMP 측정항목이 명확하게 개선되었음을 확인할 수 있습니다. TTI는 거의 영향을 받지 않습니다.

최초의 의미 있는 페인트가 8.5초에서 4.9초로 단축되어 대폭 개선되었습니다. TTI는 여전히 약 8.5초에서 실행되므로 이 변경사항의 영향을 거의 받지 않았습니다. 여기서 우리가 한 것은 인지적 변화입니다. 어떤 사람들은 그것을 손바닥이라고 부르기도 합니다. 게임의 중간 이미지를 렌더링하여 인지되는 로드 성능을 개선합니다.

인라이닝

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

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 요청의 라이트 섹션은 요청이 응답의 첫 번째 바이트를 수신하기 위해 대기 중임을 나타냅니다.

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

이 문제는 원래 HTTP/2 푸시의 목표였습니다. 앱 개발자는 특정 리소스가 필요하다는 것을 알고 이를 푸시할 수 있습니다. 추가 리소스를 가져와야 한다는 것을 클라이언트가 깨닫게 되면 리소스가 이미 브라우저의 캐시에 있습니다. HTTP/2 푸시는 제대로 하기가 너무 어려운 것으로 확인되었고 이는 낙후된 것으로 여겨집니다. 이 문제 공간은 HTTP/3의 표준화 과정에서 재검토될 것입니다. 현재 가장 쉬운 솔루션은 캐싱 효율성이 떨어지지만 모든 중요한 리소스를 인라인하는 것입니다.

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

를 통해 개인정보처리방침을 정의할 수 있습니다. <ph type="x-smartling-placeholder">
</ph>
JavaScript를 인라인 처리하여 TTI를 8.5초에서 7.2초로 줄였습니다.

그 결과 TTI가 1초 단축되었습니다. 이제 index.html에 초기 렌더링 및 대화형 기능이 되는 데 필요한 모든 항목이 포함됩니다. 다운로드 중에 HTML이 렌더링될 수 있으므로 FMP가 생성됩니다. HTML의 파싱과 실행이 완료되는 순간 앱은 상호작용합니다.

적극적인 코드 분할

예, index.html에는 상호작용을 하는 데 필요한 모든 것이 포함되어 있습니다. 그러나 자세히 확인해 보니 다른 모든 것들도 포함되어 있습니다. index.html는 약 43KB입니다. 사용자가 시작 시 상호작용할 수 있는 항목과 관련하여 생각해 보겠습니다. 몇 가지 구성요소, 시작 버튼, 사용자 설정을 유지하고 로드하는 몇 가지 코드를 포함하는 게임을 구성하는 양식이 있습니다. 거의 다 되었습니다. 43KB는 많은 것 같습니다.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> PROXX의 방문 페이지입니다. 여기에는 중요한 구성요소만 사용됩니다.

번들 크기의 출처를 파악하려면 소스 맵 탐색기 또는 유사한 도구를 사용하여 번들의 구성 요소를 분석하면 됩니다. 예측한 대로, 번들에는 게임 로직, 렌더링 엔진, 승리 화면, 잃어버린 화면 및 여러 가지 유틸리티가 포함되어 있습니다. 이러한 모듈의 일부 하위 집합만 방문 페이지에 필요합니다. 상호작용에 반드시 필요하지 않은 모든 항목을 지연 로드되는 모듈로 이동하면 TTI가 크게 줄어듭니다.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> PROXX의 `index.html` 콘텐츠를 분석하면 불필요한 리소스를 많이 볼 수 있습니다. 중요한 리소스가 강조표시되어 있습니다.

코드 분할을 수행하면 됩니다. 코드 분할은 모놀리식 번들을 주문형으로 지연 로드할 수 있는 작은 부분으로 분할합니다. Webpack, Rollup, Parcel과 같은 인기 번들러는 동적 import()를 사용하여 코드 분할을 지원합니다. 번들러는 코드를 분석하고 정적으로 가져온 모든 모듈을 인라인합니다. 동적으로 가져오는 모든 항목은 자체 파일에 배치되며 import() 호출이 실행된 후에만 네트워크에서 가져옵니다. 물론 네트워크에 접속하는 데는 비용이 듭니다. 따라서 시간적 여유가 있는 경우에만 수행해야 합니다. 여기서 핵심은 로드 시간에 반드시 필요한 모듈은 정적으로 가져오고 다른 모든 모듈은 동적으로 로드하는 것입니다. 하지만 마지막 순간에도 확실히 사용될 모듈을 지연 로드해서는 안 됩니다. 필 월튼Idle Until Urgent는 지연 로드와 즉시 로드 사이의 건강한 중간 지점을 보여주는 좋은 패턴입니다.

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로 확인할 수 있습니다.

<ph type="x-smartling-placeholder">
</ph> <ph type="x-smartling-placeholder">
</ph> 필름에 따르면 이제 우리의 TTI는 5.4초입니다. 기존의 11초를 크게 바꾸어 놓았습니다.

Google의 FMP와 TTI는 100ms의 차이가 있으며 이는 인라인 JavaScript를 파싱하고 실행하기만 하면 되기 때문입니다. 2G에서 불과 5.4초가 지나면 앱이 완전한 양방향성을 하게 됩니다. 덜 필수적이지 않은 다른 모든 모듈은 백그라운드에서 로드됩니다.

간편한 사용

위의 주요 모듈 목록을 살펴보면 렌더링 엔진이 주요 모듈에 포함되지 않음을 알 수 있습니다. 물론, 게임을 렌더링할 렌더링 엔진이 있어야만 게임을 시작할 수 있습니다. '시작' 옵션을 버튼을 클릭하기만 하면 됩니다. 그러나 저희 경험상 사용자는 게임 설정을 구성하는 데 시간이 많이 걸리기 때문에 그럴 필요가 없습니다. 대부분의 경우 렌더링 엔진과 기타 나머지 모듈은 사용자가 '시작'을 누를 때 로드가 완료됩니다. 드물지만 사용자의 네트워크 연결 속도가 빠른 경우에는 나머지 모듈이 완료될 때까지 기다리는 간단한 로드 화면이 표시됩니다.

결론

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

이 슬라이드를 통해 앱을 로드할 때 사용자가 느끼는 느낌을 알 수 있습니다. 폭포식 구조를 통해 잠재적으로 긴 로드 시간의 원인이 될 수 있는 리소스를 확인할 수 있습니다. 다음은 로드 성능을 개선하기 위해 할 수 있는 체크리스트입니다.

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

고도로 제한된 기기에서 런타임 성능을 최적화하는 방법을 다루는 2부를 기대해 주세요.