critical-script, 배달의민족 웹뷰 로딩 성능을 30% 개선한 인라인 스크립트 로딩 전략 이야기

2026. 10. 21. 추용혁

Frontend

들어가며

배달의민족 앱은 일부 지면을 웹뷰로 구성하는 하이브리드 앱입니다. 대표적인 커머스 서비스인 B마트의 가게홈 지면도 웹뷰로 구성됩니다. 웹뷰는 한 번의 개발로 iOS와 Android에 동시에 대응할 수 있어 개발 공수가 적고, 스토어 심사를 거치지 않아 필요할 때 배포할 수 있는 장점이 있습니다. 다만 화면을 그리기 위한 에셋(HTML, CSS, JavaScript)을 매번 다운로드해야 해서, 코드가 이미 앱 번들에 포함되어 있는 네이티브에 비해 로딩 속도가 느린 단점이 있기도 합니다.

로딩 속도가 느리면 어떤 문제가 있을까요? 사용자는 답답함을 느끼고 페이지를 이탈할 수 있습니다. 이탈률이 증가하면 주문 전환율의 감소로 이어집니다. 반대로 로딩 속도를 개선하면 이탈률을 줄이고 주문 전환율을 높일 수 있습니다. 실제로 영국의 통신 기업 Vodafone은 2021년에 LCP를 개선한 페이지와 기존 페이지를 A/B 테스트했습니다. 그 결과 LCP가 31% 개선된 페이지에서 매출이 약 8%, 장바구니 진입 비율이 11% 증가했습니다. (web.dev에서 소개된 사례)

LCP(Largest Contentful Paint)는 페이지 로딩을 시작한 뒤 화면에 보이는 요소 중 가장 큰 이미지나 텍스트가 표시되는 데 소요된 시간입니다. 로딩 성능을 평가하는 대표적인 지표로 쓰입니다.

이상적인 목표는 웹뷰의 로딩 시간을 네이티브 앱 수준까지 단축하는 것입니다. 그래서 이 글에서는 사용자가 웹뷰도 쾌적하게 사용할 수 있도록 API 요청 시점을 앞당겨 웹뷰 로딩 시간을 개선한 과정을 소개합니다.

문제 분석하기

커머스 서비스(B마트 같은 장보기 서비스)의 웹뷰 중 사용자가 가장 많은 웹뷰는 첫 진입점에 있는 가게홈(가게에 들어가면 처음 보이는 화면) 웹뷰입니다. 사용자가 가장 많은 웹뷰의 로딩 속도는 어땠을까요? 좋은 네트워크 환경과 좋은 성능의 스마트폰에서 열어 봤는데도 네이티브로 구현된 페이지에 비하면 느리다고 체감되었습니다. 네트워크 상태가 나쁘거나 저사양 기기를 쓰는 사용자라면 로딩이 훨씬 더 느릴 것입니다.

로딩 속도가 느린 이유를 알기 위해 먼저 Chrome DevTools의 Performance 탭으로 로딩 과정을 측정했습니다.

화면이 그려지기까지 다음 일이 차례대로 일어났습니다.

  1. 브라우저가 index.html 을 다운로드하고 파싱합니다.
  2. <script> 태그에 적힌 JavaScript 번들을 다운로드합니다.
  3. 번들을 실행해서 React 컴포넌트 트리를 만듭니다.
  4. 클라이언트에서 서버로 API를 요청합니다.
  5. 서버가 요청을 처리해서 응답을 돌려줍니다.
  6. 응답을 받은 React가 리렌더링하여 화면을 그립니다.

커머스 웹뷰는 빌드된 에셋이 정적 파일로 배포되기 때문에 SSR(Server-Side Rendering)처럼 클라이언트에 전송하기 전에 서버에서 HTML을 렌더링하는 과정은 없습니다.

모든 일이 순차적으로 일어나고 있었습니다. 각 단계는 앞 단계가 끝나야 시작되기 때문에 LCP는 각 단계에서 소요된 시간을 모두 더한 값이 됩니다.

접근법

로딩 성능을 개선하는 방법으로는 다음 세 가지가 있습니다.

  • API 응답에 소요되는 시간을 개선합니다.
  • 번들 크기를 줄입니다.
  • <link rel="preconnect"> 로 API 서버와 미리 연결해 둡니다.

다만 이미 충분히 최적화되어 있어 개선할 수 있는 부분이 많지 않았습니다. 물론 최적화를 더 진행하면 지표상으로는 조금 나아질 수 있지만, 사용자 입장에서 체감할 수 있을 정도의 유의미한 차이를 만들긴 어렵습니다. 당장 개선하더라도 향후 비즈니스 로직을 추가하다 보면 절감했던 로딩 시간이 금세 사라질 수도 있습니다.

로딩 과정을 크게 바꿀 수 있는 부분이 없을지 곰곰이 생각해 보다가, 한 가지 아이디어를 떠올렸습니다.

"HTML에서 인라인 스크립트를 사용하면, HTML을 다운로드하자마자 API를 요청할 수 있지 않을까?"

이 경우 HTML 다운로드가 완료된 시점부터 번들 다운로드 및 실행과 API 요청이 동시에 진행됩니다. 즉, 직렬 구조의 워터폴에서 API 요청을 분리하여 병렬로 처리하는 것입니다. 각 단계를 부분적으로 최적화하는 것이 아니라, 로딩 워터폴의 구조 자체를 개선하는 방법입니다.

첫 번째 시도: 인라인 스크립트에서 API 호출

먼저 아이디어가 실제로 동작하는지 확인하기 위해 index.html 에 <script> 태그를 추가하고, API를 호출하는 인라인 스크립트를 직접 작성했습니다.

<html>
  <head>
    <script>
      window.__storeHomeResponse = fetch('https://api.example.com/store/home?storeId=123')
    </script>
    <!-- 번들은 window.__storeHomeResponse에 담긴 응답을 꺼내 사용합니다. -->
    <script src="/static/js/main.3f9a1c2e.js" defer></script>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

기대한 대로 API 요청이 번들보다 먼저 시작되었습니다.

다만 아직 운영 환경에 적용하기에는 부족했습니다. 인라인 스크립트는 컴파일 타임의 타입 검사가 없기 때문에, API 스펙 변경에 취약합니다. API 호출 코드도 비즈니스 로직이므로 최소한 TypeScript로 작성할 수 있어야 했습니다.

구현 요구사항

인라인 스크립트를 TypeScript로 작성하고 빌드하기 위해 다음 세 가지 요구사항을 정했습니다.

  • API를 호출할 수 있어야 합니다. 인증 정보처럼 API 호출에 필요한 공통 정보도 인라인 스크립트에서 직접 가져와야 합니다.
  • 빌드 결과물의 크기를 최소한으로 유지해야 합니다. 인라인 스크립트의 크기가 증가하면 HTML의 크기 또한 증가하기 때문입니다.
  • 최종적으로 출력된 HTML에 인라인 스크립트 형태로 포함되어야 합니다.

당시 가게홈 웹뷰는 Next.js로 개발되어 있었습니다. Next.js는 인라인 스크립트를 TypeScript로 작성하는 수단을 제공하지 않고, 오직 문자열로만 전달할 수 있었습니다. 그래서 모노레포에 인라인 스크립트 전용 패키지를 추가하고, 이 패키지에서 TypeScript 코드를 Vite로 미리 빌드했습니다. 페이지에서는 webpack의 raw-loader로 빌드 결과물을 인라인 스크립트로 삽입했습니다.

webpack 설정에는 ?raw 로 import한 파일을 문자열로 읽는 규칙을 추가했습니다.

// next.config.js
config.module.rules.push({
  test: /\.m?js/,
  resourceQuery: /raw/,
  use: 'raw-loader',
})

페이지에서는 빌드 결과물을 ?raw로 import하고, Next.js의 Script 컴포넌트로 래핑하였습니다. 이렇게 하면 스크립트가 HTML에 인라인되어 번들보다 먼저 실행됩니다.

// 가게홈 페이지
import prefetchScript from 'prefetch/dist/store-home.js?raw'
import Script from 'next/script'

export default function Page() {
  return (
    <>
      <Script id="prefetch" strategy="beforeInteractive">
        {prefetchScript}
      </Script>
      <StoreHomePage />
    </>
  )
}

API 응답 전달

인라인 스크립트가 받은 응답을 메인 앱에서 사용하려면, 두 환경 사이에서 응답을 전달하는 장치가 필요합니다. 인라인 스크립트와 메인 번들은 서로 다른 런타임이므로 모듈 스코프를 공유할 수 없습니다. 그래서 window 전역 객체에 캐시를 두고, 요청 URL을 키로 응답을 전달하는 방식을 선택했습니다.

인라인 스크립트는 API를 요청하고, 응답 Promise를 URL과 함께 전역 캐시에 저장합니다.

// 인라인 스크립트: HTML 파싱 중에 실행됩니다.
const url = 'https://api.example.com/store/home?storeId=123'

window.__prefetch__ ??= { cache: new Map() }
window.__prefetch__.cache.set(url, fetch(url))

메인 앱은 axios와 TanStack Query를 사용하여 API를 호출합니다. 그러나 인라인 스크립트에서는 코드 크기를 최소화하기 위해 axios 대신 fetch로 요청합니다. 메인 앱에서는 이 fetch 응답을 axios 응답으로 변환하여 사용합니다.

axios의 request interceptor는 요청을 보내기 전에 전역 캐시를 확인합니다. 캐시가 존재하면 꺼내어 네트워크 요청 대신 캐시된 응답을 반환합니다.

// 메인 앱: axios가 요청하기 전에 전역 캐시를 확인합니다.
api.interceptors.request.use((config) => {
  const url = api.getUri(config)
  const cached = window.__prefetch__?.cache.get(url)
  if (!cached) return config

  window.__prefetch__.cache.delete(url)
  config.adapter = async () => {
    const response = await cached
    const data = await response.json()
    return { data, status: response.status, statusText: response.statusText, headers: {}, config }
  }
  return config
})

지금까지의 흐름을 정리하면 다음과 같습니다.

측정 결과

운영 환경에 배포하기 전/후 갤럭시 S22+ 및 Wi-Fi 환경에서 LCP를 측정하였습니다. 1,340ms에서 847ms로 LCP가 37% 개선되었습니다.

적용 전후의 로딩 워터폴을 비교하면 다음과 같습니다. API 요청이 HTML 다운로드 직후에 시작되어 번들 다운로드 및 실행과 동시에 진행됩니다.

아쉬운 점

앞선 구현의 결과로 LCP를 크게 단축할 수 있었지만, 유지보수 측면에서 몇 가지 아쉬운 점이 있었습니다.

낮은 응집도

인라인 스크립트 코드와 그 인라인 스크립트를 사용하는 페이지 코드가 패키지 단위로 분리되어 있어 파일이 서로 너무 멀리 떨어진 상태가 되었습니다.

인라인 스크립트는 페이지와 1:1 관계입니다. 가게홈 페이지에서 사용하는 인라인 스크립트는 가게홈의 API만 호출하고, 가게홈의 API 호출 규칙이 변경되면 인라인 스크립트에서의 API 호출 코드가 같이 변경되어야 합니다. 따라서 인라인 스크립트 코드는 페이지 코드와 같은 위치에 있는 것이 이상적입니다.

그러나 첫 번째 시도에서는 인라인 스크립트를 별도 패키지에서 빌드했기 때문에, 인라인 스크립트가 해당 패키지 안에만 위치할 수 있었습니다.

packages/
  prefetch/
    src/
      store-home.ts              # 가게홈 인라인 스크립트 원본
    dist/
      store-home.js              # 빌드된 인라인 스크립트 (페이지가 ?raw로 가져감)
apps/
  store/
    src/pages/StoreHomePage.tsx  # 가게홈 페이지

페이지는 앞에서 본 것처럼 빌드 결과물을 ?raw로 가져올 뿐이므로, 인라인 스크립트의 원본 코드는 페이지와 전혀 다른 위치에 있습니다. 강하게 연관된 두 코드가 패키지 단위로 멀리 떨어져 있으니, 둘을 함께 관리하기 어려웠습니다.

메인 앱의 코드 사용 불가

인라인 스크립트 코드도 메인 앱과 같은 API를 다루는 비즈니스 로직입니다. 따라서 작은 TypeScript 모듈이나 타입을 메인 앱과 공유할 필요가 있었습니다. 예를 들어 openapi-typescript 같은 도구로 생성한 API 타입을 메인 앱에서 사용하는데, 인라인 스크립트도 같은 API를 호출하므로 동일한 타입을 그대로 사용할 수 있어야 합니다. API URL을 조합하는 작은 유틸리티 함수나 상수도 마찬가지입니다.

하지만 인라인 스크립트는 독립된 별도 패키지에서 빌드되므로, 메인 앱의 모듈이나 타입을 참조할 수 없었습니다. 결국 같은 타입과 상수를 인라인 스크립트 쪽에 따로 작성해야 합니다. 두 벌로 관리되는 코드는 어긋나기 쉽고 어떤 쪽이 기준인지 알기 어려워 혼란을 줍니다.

번거로운 적용

인라인 스크립트는 페이지마다 하나씩 필요한데, 다른 페이지에 적용하려면 패키지에 해당 페이지의 인라인 스크립트와 빌드 엔트리를 하나씩 추가해야 했습니다. 이러한 번거로움 때문에 가게홈 이외의 페이지에 추가로 적용하기가 어려웠습니다.

이 한계들을 해결하기 위해 번들러 플러그인을 개발하게 되었습니다.

두 번째 시도: 번들러 플러그인 구현

아이디어

핵심 문제는 인라인 스크립트 코드를 페이지 코드 옆에 둘 수 없다는 점입니다.

기존의 결과를 유지하면서 인라인 스크립트 코드를 페이지 코드 옆에 둘 방법을 고민하던 중 CSS Modules와 vanilla-extract에서 아이디어를 얻었습니다.

// CSS Modules
import styles from './StoreHomePage.module.css'

// vanilla-extract
import * as styles from './StoreHomePage.css'

이 도구들을 사용하면 스타일 파일을 TypeScript 모듈처럼 import할 수 있으면서도, 빌드 과정에서는 번들러 플러그인을 통해 별도의 빌드 로직이 적용됩니다. 인라인 스크립트도 같은 방식으로 다룰 수 있을 것으로 생각했습니다. 같은 TypeScript 모듈이지만 번들러 플러그인이 빌드 과정을 대신 처리하여 최종적으로 인라인 스크립트 형태로 HTML에 삽입하도록 만들면 됩니다.

설계

인라인 스크립트 코드는 페이지 코드 바로 옆에 *.critical.ts 라는 이름으로 둡니다.

src/pages/StoreHomePage/
  StoreHomePage.tsx           # 가게홈 페이지
  StoreHomePage.critical.ts   # 가게홈 인라인 스크립트

*.critical.ts는 일반 TypeScript 모듈처럼 작성합니다. 메인 앱과 같은 프로젝트 안에 있으므로, 메인 앱에서 사용하는 URL 유틸리티 같은 작은 모듈도 import할 수 있습니다.

// StoreHomePage.critical.ts
import { getStoreHomeUrl } from '@/apis/storeHome'

const storeId = new URLSearchParams(location.search).get('storeId')
const url = getStoreHomeUrl(storeId)

window.__prefetch__ ??= { cache: new Map() }
window.__prefetch__.cache.set(url, fetch(url))

페이지에서는 import 문 모듈 지정자(module specifier) 뒤에 인라인 스크립트임을 식별할 수 있는 ?as-critical-script 구문을 붙여 import하고 React 컴포넌트처럼 렌더링하도록 합니다.

import CriticalScript from './StoreHomePage.critical?as-critical-script'

export default function Page() {
  return (
    <>
      <CriticalScript />
      <StoreHomePage />
    </>
  )
}

구현

위 설계를 바탕으로 하여 Vite 플러그인으로 구현했습니다. 플러그인의 핵심 로직을 간략히 나타내면 다음과 같습니다.

// 번들러 플러그인 (Vite)
const criticalScriptPlugin = () => ({
  name: 'critical-script',
  async load(id) {
    if (!id.endsWith('?as-critical-script')) return

    const result = await esbuild.build({
      entryPoints: [id.replace(/\?.+$/, '')],
      bundle: true,
      minify: true,
      format: 'iife',
      write: false,
    })
    const script = result.outputFiles[0].text

    // 인라인 스크립트를 렌더링하는 React 컴포넌트 코드로 반환합니다.
    return `
      import { jsx } from 'react/jsx-runtime'
      export default (props) => jsx('script', {
        ...props,
        dangerouslySetInnerHTML: { __html: ${JSON.stringify(script)} },
      })
    `
  },
})

번들러는 import 문을 만나면 먼저 모듈 해석(module resolution) 단계에서 import 경로를 실제 파일 경로로 해석하고, 이어서 모듈 로딩(module loading) 단계에서 그 파일의 코드를 읽습니다. 번들러 플러그인은 모듈 로딩 단계에서 모듈 지정자(module specifier)의 ?as-critical-script 구문을 확인하고, 파일의 원본 코드 대신 인라인 스크립트를 렌더링하는 React 컴포넌트 코드를 반환합니다. 빌드 타임에 페이지를 프리렌더링하면 이 컴포넌트도 함께 렌더링되고, 빌드 결과물의 HTML의 <head> 에 인라인 스크립트로 삽입됩니다.

플러그인은 ?as-critical-script가 붙은 모듈을 만나면 다음 과정을 거칩니다.

  1. 해당 모듈을 엔트리로 삼아 esbuild로 번들하고 압축합니다.
  2. 압축된 코드의 크기를 검사하고, 8KB를 넘으면 에러를 발생시켜 빌드가 실패하도록 합니다.
  3. 압축된 코드를 Script 컴포넌트로 감싼 React 컴포넌트로 변환하여 export합니다.

outputSizeLimit

앞서 인라인 스크립트의 코드가 8KB를 넘으면 빌드가 실패한다고 설명했습니다. 이는 인라인 스크립트에서 의도치 않게 라이브러리를 import하는 실수를 방지하기 위한 동작입니다. 그리고 이 상한은 outputSizeLimit 옵션으로 정의했고, 기본값은 8KB입니다.

처음에는 4KB로 설정하여 사용했지만 가끔 4KB를 초과하는 일이 있어 한도를 8KB로 올렸습니다. 의도한 로직만 포함한 코드는 8KB 안에 충분히 들어가고, 라이브러리를 import한 코드는 8KB를 확실하게 초과합니다. 그래서 8KB는 최소한의 크기를 유지하면서도 실수를 확실하게 잡아낼 수 있는 기준이 되었습니다.

결과

번들러 플러그인을 개발하여 문제를 다음과 같이 해결했습니다.

  • 인라인 스크립트 코드가 페이지 코드 바로 옆에 위치하므로, 페이지를 수정할 때 인라인 스크립트도 함께 수정하기 쉬워졌습니다.
  • 인라인 스크립트가 페이지와 같은 디렉터리에서 빌드되므로, 인라인 스크립트에서도 메인 앱의 타입이나 모듈을 사용할 수 있게 되었습니다.
  • 페이지마다 *.critical.ts 파일을 하나 추가하고 import하기만 하면 되므로, 빌드 설정을 수정하지 않고도 다른 페이지에 적용할 수 있게 되었습니다.

여담으로 critical-script라는 이름은 번들러 플러그인을 만들면서 지었습니다. inline-script가 아닌 critical-script로 이름을 지은 이유는, 이 플러그인의 목적이 코드를 인라인 스크립트에 담는 것 자체가 아니기 때문입니다. 목적은 로딩 성능을 결정하는 임계 경로(Critical Path)를 최적화하는 것이고, 이 의도를 이름에서부터 드러내고자 했습니다.

활용 사례 – LCP 최적화

번들러 플러그인의 구현으로 적용이 훨씬 쉬워지면서 critical-script를 사용하는 페이지가 많아졌습니다. 처음에는 가게홈 한 군데에서만 사용했으나, 지금은 약 80개의 페이지에서 사용하고 있습니다. 현재 critical-script는 API를 미리 호출하는 용도뿐만 아니라, 로딩 성능을 최적화하기 위한 다양한 용도로 활용되고 있습니다. 이 활용 사례를 LCP 최적화와 FCP(First Contentful Paint, 화면에 첫 콘텐츠가 그려지는 시점) 최적화 두 가지로 나누어 소개합니다.

API prefetch

대표적인 사례는 앞에서 소개한 API prefetch입니다. 인라인 스크립트가 페이지에 필요한 API를 메인 번들보다 먼저 요청하고, 메인 앱은 전역 캐시를 통해 응답을 넘겨받습니다.

LCP 이미지 preload

많은 페이지에서 LCP 요소는 이미지입니다. 페이지 상단의 배너 이미지나 상품 목록의 앞쪽 상품 이미지 6개처럼, 상단에 나타나는 이미지가 LCP 요소가 될 가능성이 높습니다. 그리고 이러한 이미지의 URL은 대개 API 응답 안에 있습니다. 따라서 이미지 요청은 API 응답을 받은 React가 <img> 태그를 렌더링한 뒤에야 시작됩니다. API 요청을 앞당기더라도, 이미지 요청은 여전히 메인 번들의 실행과 렌더링을 기다려야 합니다.

인라인 스크립트에서 API 응답을 읽으면 이미지 로딩을 메인 번들 실행 및 React 렌더링과 동시에 진행할 수 있습니다. 응답에서 이미지 URL을 꺼내 <link rel="preload"> 를 추가하면, 메인 번들이 실행되기 전에 이미지 다운로드가 시작됩니다.

// StoreHomePage.critical.ts
const response = fetch(url)
window.__prefetch__.cache.set(url, response)

response
  .then((res) => res.clone().json())
  .then((data) => {
    const link = document.createElement('link')
    link.rel = 'preload'
    link.as = 'image'
    link.href = data.bannerImageUrl
    document.head.appendChild(link)
  })

커머스 가게홈에서는 API의 응답에서 얻은 상단 로고 이미지, 첫 번째 배너 이미지의 URL을 preload하도록 했습니다.

활용 사례 – FCP 최적화

인라인 스크립트는 HTML 파싱 중에 실행되므로 추가 네트워크 홉(Hop) 없이 JavaScript를 실행할 수 있습니다. 이를 활용하면 스켈레톤처럼 첫 화면에 보이는 요소를 더 일찍 그릴 수 있고, FCP를 개선할 수 있습니다.

스켈레톤에 디바이스 인셋 적용하기

배달의민족 앱의 웹뷰는 네이티브 상단 바와 상태 표시줄, 홈 인디케이터 같은 영역을 피해서 콘텐츠를 그려야 합니다. 이 영역의 크기를 디바이스 인셋이라고 합니다.

배달의민족 웹뷰에서 디바이스 인셋 값은 JavaScript 앱 브리지 호출을 통해 가져오도록 되어 있습니다. 네이티브 앱은 웹뷰의 JavaScript 환경에 앱 브리지 객체를 주입하고, 웹뷰는 앱 브리지의 함수를 호출하여 값을 요청합니다. 앱 브리지 호출은 네이티브와 통신하는 과정이므로 결과를 비동기로 전달받습니다.

스켈레톤도 디바이스 인셋 값이 있어야 올바른 위치에 그릴 수 있습니다. critical-script가 없다면 메인 번들이 실행된 뒤에야 앱 브리지를 호출할 수 있으므로 스켈레톤도 메인 번들 실행 이후에야 그릴 수 있습니다.

인라인 스크립트에서는 메인 번들보다 먼저 앱 브리지를 호출하여 디바이스 인셋 값을 CSS 변수로 설정합니다. HTML에 포함된 스켈레톤은 값을 받기 전까지 숨겨 두고, 값이 설정되면 표시합니다.

// SkeletonRoot.critical.ts
AppBridge.getNativeFrame().then((frame) => {
  const root = document.documentElement
  root.style.setProperty('--top-bar-height', frame.topBarHeight + 'px')
  root.style.setProperty('--inset-bottom', frame.insets.bottom + 'px')
  root.setAttribute('data-inset-ready', '')
})
[data-skeleton-root] { visibility: hidden; }
[data-inset-ready] [data-skeleton-root] { visibility: visible; }

스켈레톤에 상품 이미지 먼저 표시하기

상품 목록에서 상품을 선택하면 상품상세 페이지로 이동합니다. 상품상세 페이지의 첫 화면에서 가장 크게 보이는 요소는 상품 이미지인데, 이 이미지의 URL은 상세 API의 응답을 받아야 알 수 있습니다. 하지만 이동하기 직전의 상품 목록에서는 상품 카드가 이미 같은 이미지를 보여 주고 있습니다.

상품 카드에서 사용한 상품 이미지 URL을 상품상세 페이지로 이동할 때 쿼리 파라미터로 넘겨주면, 첫 페인트 시점에 상품 이미지 URL을 사용할 수 있습니다. 인라인 스크립트는 쿼리 파라미터에서 이미지 URL을 읽고, 스켈레톤의 이미지 영역에 <img> 태그를 추가합니다.

// ProductDetailPage.critical.ts
const imageUrl = new URLSearchParams(location.search).get('mainImageUrl')

if (imageUrl) {
  const image = document.createElement('img')
  image.setAttribute('fetchpriority', 'high')
  image.src = imageUrl
  document.querySelector('[data-skeleton-product-image]')?.appendChild(image)
}

이미지 URL이 이미 페이지 URL의 쿼리 파라미터에 있으므로 앱 브리지 호출이나 API 응답을 기다리지 않고 HTML 파싱 중에 이미지를 요청할 수 있습니다. 따라서 사용자가 상품상세 페이지에 진입했을 때 상품 이미지가 바로 표시될 수 있습니다.

배경색 초기화

기본 배경색이 아닌 다른 색의 배경을 사용하는 화면에서는 메인 번들에서 화면을 렌더링하기 전까지 기본 배경색인 흰색이 잠시 보였다가 바뀌는 깜빡임이 발생합니다. 인라인 스크립트에서 배경색을 먼저 적용하면 이 깜빡임을 최소화할 수 있습니다.

// IntroPage.critical.ts
document.documentElement.style.background = '#00F6D9'

운영 환경 측정 결과

LCP

critical-script를 적용한 페이지 중 가게홈과 상품상세 페이지의 LCP를 실사용자 데이터로 비교했습니다. Sentry로 수집한 적용 전/후 각각 1주일 데이터로 확인했고, 기기 성능에 따른 편차를 최소화하기 위해 사용자가 가장 많은 기기 하나를 선정하여 집계하였습니다.

p50, p75, p90 모두 LCP가 25~35% 균등하게 개선되었습니다.

FCP

스켈레톤을 HTML에 프리렌더링하더라도 디바이스 인셋에 따라 위치가 조정되어야 하는 경우, 디바이스 인셋을 적용하는 코드 실행을 위해 번들 다운로드 및 실행 시점까지 기다려야 했습니다.

디바이스 인셋을 적용하는 코드를 critical-script로 사용하면 번들을 기다리지 않고 즉시 스켈레톤을 보여줄 수 있어 FCP를 크게 개선할 수 있습니다. 아래는 커머스홈 페이지의 FCP를 개선하고 측정한 데이터입니다.

한계

critical-script는 언뜻 보면 로딩 성능을 크게 최적화해주는 실버 불릿처럼 보입니다. 하지만 불편한 점과 제약도 있습니다.

CPU 시간이나 네트워크 시간 자체를 개선하는 것은 아닙니다

critical-script는 로딩 파이프라인을 병렬화할 뿐이며 CPU 시간이나 네트워크 시간 자체를 줄이지는 않습니다. API 응답 시간이나 번들 다운로드 같은 네트워크 시간, 번들 실행이나 하이드레이션 같은 CPU 시간은 그대로이므로, 경우에 따라 로딩 파이프라인을 병렬화하더라도 개선 효과가 미미할 수 있습니다.

메인 앱과 독립적인 런타임입니다

인라인 스크립트는 메인 앱과 별도로 번들되고 실행되므로 메인 앱의 런타임을 공유하지 않습니다. React나 TanStack Query 같은 프레임워크를 사용할 수 없고, 메인 앱에 적용되는 polyfill도 적용되지 않습니다. 따라서 인라인 스크립트를 작성할 때는 서비스가 지원하는 최소 브라우저 버전을 확인하고, 해당 버전에서 지원하는 ECMAScript 기능과 Web API만 사용해야 합니다.

모듈 싱글톤도 공유되지 않으므로, 인라인 스크립트와 데이터 공유가 필요하다면 window나 localStorage, sessionStorage와 같은 전역 객체를 거쳐야 합니다. 또한 라이브러리를 import할 수도 없습니다. 인라인 스크립트를 작성할 때는 이러한 차이를 인지하고 있어야 합니다.

라이브러리 대체나 변환(어댑터) 로직이 필요할 수 있습니다

인라인 스크립트에서는 axios, TanStack Query와 같은 서드 파티 라이브러리를 포함할 수 없어 Web API를 사용해야 합니다. 따라서 메인 앱의 서드 파티 라이브러리를 Web API로 대체하거나, 인라인 스크립트의 결과를 메인 앱에 전달하기 위해 Web API의 인터페이스를 서드 파티 라이브러리의 인터페이스에 맞게 변환하는 어댑터 로직이 필요할 수 있습니다.

커머스 웹뷰 사례에선 메인 앱이 사용하던 crypto 라이브러리를 인라인 스크립트에 포함할 수 없어서 Web Crypto API로 대체하여 사용하기도 했습니다. 메인 앱은 API 요청을 위해 axios 라이브러리를 사용하고 있었으나 axios를 fetch Web API로 대체하는 비용이 너무 커서 fetch 응답을 axios 응답으로 변환하는 어댑터 로직을 붙였습니다.

번외. API가 충분히 빠르더라도, SSR 환경이더라도 유용할 수 있습니다

API가 충분히 빠르더라도

API 응답이 빠르다면 critical-script의 효과가 작다고 생각할 수 있습니다. 하지만 critical-script가 최적화하는 것은 API의 처리 시간이 아니라, 메인 번들이 실행된 뒤에 API를 요청하고 응답을 받기까지의 네트워크 왕복입니다. 네트워크 왕복 자체를 줄이는 것으로 충분히 효과가 있습니다. 특히 지연 시간이 불안정한 모바일 네트워크 환경에서는 효과가 더 큽니다.

SSR 환경이더라도

SSR(Server-Side Rendering)을 사용하면 서버가 HTML에 데이터를 채운 상태로 렌더링하여 클라이언트에 전송하므로 critical-script가 필요 없다고 생각할 수 있습니다. 하지만 디바이스 인셋이나 로컬 스토리지 등 클라이언트에서만 얻을 수 있는 데이터는 서버에서 처리할 수 없습니다. 이러한 데이터를 First Paint 전에 처리할 땐 SSR 환경이더라도 critical-script가 유용합니다.

오픈소스

“HTML에 API를 호출하는 인라인 스크립트를 넣어 LCP를 최적화해 보자”라는 아이디어에서 출발하여, 가게홈에서 큰 효과를 얻었습니다. 이후 critical-script라는 번들러 플러그인으로 고도화했고, 지금은 사내에 널리 전파되어 약 80개의 페이지에서 사용하고 있습니다.

사실 처음부터 번들러 플러그인을 직접 만들 생각은 없었습니다. 비슷한 고민을 누군가 이미 했을 것이라 생각하여 잘 만들어진 오픈소스를 찾아서 사용하려고 했으나, 찾아볼 수 없었습니다. 그래서 백지 상태에서 개발했습니다.

실제로 효과가 좋았던 만큼, 이 아이디어를 커뮤니티에도 공유하고 싶어 오픈소스화하기로 결심했습니다. 다만 사내에서 개발한 소스 코드를 공개하는 것이므로, 보안상 문제나 법무적 리스크가 없는지 검토가 필요했습니다. 그 외에도 라이선스, 코드 품질을 검토하고 차질 없는 운영을 위해 오픈소스 운영 정책을 정리하기도 했습니다.

또 어떤 오픈소스인지, 어떤 효과가 있는지 잘 이해할 수 있도록 문서와 시각화 자료도 꼼꼼히 준비했습니다. 아래 GitHub 저장소 주소를 통해 관심 있으신 분들의 많은 방문 부탁드립니다.

https://github.com/woowabros/critical-script

마치며

UI 컴포넌트, 데이터 페칭, 폼 검증, 스타일링 등 여러 분야에서 수많은 라이브러리가 존재하며 오늘날의 웹 애플리케이션을 구성하고 있습니다. 웹 애플리케이션이 의존하는 라이브러리들을 브라우저에서 실행 가능한 형태로 만들기 위해 번들러가 하나의 JavaScript 번들로 합칩니다. 전송 최적화를 위해 압축하고 청크로 분할하기도 합니다.

비록 압축이나 청크 분할을 거치더라도 출력되는 JavaScript 코드의 양은 여전히 거대합니다. 네트워크나 기기의 성능이 많이 좋아졌지만 브라우저가 JavaScript 파일을 다운로드하고 실행하는 것은 오늘날에도 여전히 느립니다. 배달의민족 커머스 서비스의 웹뷰가 그러했듯이요.

그래서 번들을 거치지 않고 이른 시점에 코드를 실행할 수 있는 인라인 스크립트에도 한 번 관심을 가져 보시길 권합니다. 모든 걸 번들에 넣을 필요는 없으니까요.

  • 우아한형제들에서 커머스 웹 프런트엔드 개발자로 일하고 있습니다.