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

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

Google I/O 2019에서 마리코, 제이크, 저는 웹용 최신 Minesweeper 클론인 PROXX를 출시했습니다. PROXX의 차별화된 점은 접근성(스크린 리더로 플레이할 수 있음)에 중점을 두고 있으며, 고급 데스크톱 기기에서와 마찬가지로 피처폰에서도 실행할 수 있다는 점입니다. 피처폰은 여러 가지로 제약됩니다.

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

그러나 최신 브라우저를 실행하고 매우 저렴합니다. 이러한 이유로 신흥 시장에서 피처폰이 다시 인기를 얻고 있습니다. 이 가격대 덕분에 이전에는 감당할 수 없었던 완전히 새로운 독자층이 온라인으로 접속하여 최신 웹을 활용할 수 있게 되었습니다. 2019년 인도에서만 약 4억 대의 피처폰이 판매될 것으로 예상되므로 피처폰 사용자는 상당한 잠재고객이 될 수 있습니다. 또한 신흥 시장에서는 2G와 유사한 연결 속도가 일반적입니다. 피처폰 환경에서 PROXX를 잘 작동하도록 하려면 어떻게 해야 하나요?

PROXX 게임플레이.

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

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

현재 상황 파악

실제 기기에서 로드 성능을 테스트하는 것이 중요합니다. 실제 기기가 없다면 WebPageTest, 특히 '간단한' 설정을 사용하는 것이 좋습니다. WPT는 에뮬레이션된 3G 연결을 사용하는 실제 기기에서 일련의 로드 테스트를 실행합니다.

3G는 측정에 적합한 속도입니다. 4G, LTE 또는 곧 5G에 익숙해져 있을 수 있지만 모바일 인터넷의 현실은 이와는 상당히 다릅니다. 기차, 회의, 콘서트, 비행기 등에서 이 경우 3G에 가까운 속도를 경험하게 되며, 경우에 따라 속도가 더 느려질 수도 있습니다.

하지만 PROXX는 피처폰과 신흥 시장을 타겟팅하므로 이 도움말에서는 2G에 중점을 둘 것입니다. 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 애널리틱스를 로드하는 것이 이상적입니다. 이렇게 하면 초기 로드 중에 대역폭이나 처리 성능이 차지하지 않습니다. 매니페스트는 사용자 인증 정보가 없는 연결을 통해 로드되어야 하므로 웹 앱 매니페스트의 새 연결은 가져오기 사양에 의해 지정됩니다. 다시 말씀드리지만 웹 앱 매니페스트는 앱이 렌더링되거나 상호작용이 되도록 차단하지 않으므로 그다지 신경 쓸 필요가 없습니다.

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

로드 병렬 처리

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

결과

변경사항이 어떤 효과를 가져왔는지 살펴보겠습니다. 결과를 왜곡할 수 있는 다른 변수를 테스트 설정에서 변경하지 않는 것이 중요합니다. 따라서 이 도움말의 나머지 부분에서는 WebPageTest의 간단한 설정을 사용하고 필름 스트립을 살펴봅니다.

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를 실행하는 것입니다.

필름 스트립에서 FMP 측정항목이 확실히 개선된 것을 확인할 수 있습니다. TTI는 대부분 영향을 받지 않습니다.

유의미한 첫 페인트가 8.5초에서 4.9초로 개선되었습니다. TTI는 여전히 약 8.5초에 발생하므로 이번 변경의 영향을 거의 받지 않았습니다. 여기서는 지각을 변경했습니다. 손재주라고 부르는 사람도 있을 겁니다. 게임의 중간 시각화를 렌더링하여 인식되는 로드 성능을 개선합니다.

인라이닝

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

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

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

이 문제를 해결하기 위해 HTTP/2 푸시가 처음 고안되었습니다. 앱 개발자는 특정 리소스가 필요하다는 것을 알고 이를 전송할 수 있습니다. 클라이언트가 추가 리소스를 가져와야 한다는 것을 깨닫는 시점에 이미 리소스가 브라우저의 캐시에 있습니다. HTTP/2 푸시는 올바르게 구현하기가 너무 어려워 권장되지 않습니다. 이 문제 영역은 HTTP/3 표준화 과정에서 다시 검토될 예정입니다. 현재로서는 가장 쉬운 해결 방법은 캐싱 효율성을 희생하여 모든 중요한 리소스를 인라인화하는 것입니다.

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

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() 호출이 실행된 후에만 네트워크에서 가져옵니다. 물론 네트워크에 액세스하는 데는 비용이 들며 여유 시간이 있는 경우에만 실행해야 합니다. 여기서 중요한 것은 로드 시 중요하게 필요한 모듈을 정적으로 가져오고 나머지는 모두 동적으로 로드하는 것입니다. 하지만 마지막 순간에도 확실히 사용될 모듈을 지연 로드해서는 안 됩니다. 필 월튼긴급할 때까지 유휴 상태 유지는 지연 로드와 조기 로드 간의 적절한 중간 지점을 찾는 데 적합한 패턴입니다.

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() 함수에서 구성요소의 Promise를 사용할 수 있습니다. 예를 들어 애니메이션 배경 이미지를 렌더링하는 <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에 비해 크게 개선되었습니다.

Google의 FMP와 TTI는 100ms 차이에 불과하며, 이는 인라인 JavaScript를 파싱하고 실행하기만 하면 되기 때문입니다. 2G에서 5.4초만 지나면 앱이 완전히 대화형이 됩니다. 그 밖의 덜 중요한 모듈은 모두 백그라운드에서 로드됩니다.

More Sleight of Hand

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

결론

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

필름 스트립을 통해 사용자에게 앱을 로드할 때 어떤 느낌이 주어지는지 파악할 수 있습니다. 폭포식 차트를 통해 로드 시간이 길어질 수 있는 리소스를 파악할 수 있습니다. 다음은 로드 성능을 개선하기 위해 할 수 있는 체크리스트입니다.

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

제약이 많은 기기에서 런타임 성능을 최적화하는 방법을 다루는 2부도 기대해 주세요.