hyunsu blog

홈동향

웹뷰 LCP 를 줄인 배민 critical-script, 번들보다 먼저 API 를 부르는 구조

목차 · 9
  1. 01정적 배포 웹뷰는 번들이 돌기 전까지 API 를 부르지 못했다
  2. 02HTML 이 API 를 먼저 부르고, 번들은 결과만 꺼내 간다
  3. 03별도 패키지를 버리고 페이지 옆 *.critical.ts 로 옮겼다
  4. 04API 말고도 첫 화면 전에 끝낼 일이 있었다
  5. 0530%·37%·25~35% 는 서로 다른 잣대에서 나왔다
  6. 06줄어드는 것은 기다림이지 일의 양이 아니다
  7. 07한국 개발자에게: 정적 배포 웹뷰라면 첫 화면 API 하나로 시험해 볼 만하다
  8. 08할 일: 재고, 하나만 옮기고, 막힐 곳을 미리 본다
  9. 09자주 묻는 질문

배달의민족 웹뷰는 그동안 JS 번들을 내려받아 실행한 다음에야 첫 화면 API 를 불렀다. critical-script 는 이 요청을 HTML <head> 의 작은 인라인 스크립트로 앞당겨, 번들을 받는 시간과 API 응답을 기다리는 시간이 겹치게 만든다. 운영 실사용자 데이터에서 LCP 가 p50·p75·p90 모두 약 25~35% 줄었다. 서버 렌더링 없이 정적으로 배포하는 웹뷰이면서 HTML 을 빌드 때 프리렌더할 수 있다면 같은 효과를 노려 볼 수 있다.

01 정적 배포 웹뷰는 번들이 돌기 전까지 API 를 부르지 못했다

배달의민족 앱에는 웹뷰로 그리는 화면이 섞여 있고, B마트 가게홈이 그런 지면이다. 웹뷰는 코드 한 벌로 iOS·Android 를 함께 맡고, 스토어 심사 없이 내보낼 수 있다. 대신 화면에 필요한 HTML·JS·CSS 를 열 때마다 받아야 하니 네이티브 화면보다 늦게 뜬다. 원문은 이 지연이 이탈과 주문 전환에 닿는다는 근거로 Vodafone 사례를 든다. LCP 를 31% 줄인 페이지에서 매출이 약 8% 늘었다는 내용이다.

팀은 Chrome DevTools 의 Performance 탭으로 첫 로딩을 뜯어봤다. 커머스 웹뷰는 정적 배포라 서버가 HTML 을 그려 주지 않는다. 그래서 모든 단계가 앞 단계를 기다리며 한 줄로 늘어서 있었고, LCP(Largest Contentful Paint, 화면에서 가장 큰 요소가 그려진 시점)는 각 단계 시간을 모두 더한 값이 됐다.

text
HTML 다운로드
→ JS 번들 다운로드·실행
→ React 트리 생성
→ API 요청 → 서버 응답
→ 다시 렌더링 (여기서 LCP)

API 응답 속도, 번들 크기, preconnect 같은 흔한 처방은 이미 손본 상태였다. 그래서 단계마다 조금씩 더 깎는 대신 줄 서는 방식 자체를 바꾸는 쪽을 택했다. web.dev 의 LCP 가이드는 LCP 를 TTFB·리소스 로드 지연·리소스 로드 시간·요소 렌더링 지연 넷으로 나눈다. 이 틀에 대어 보면, 이번 작업이 줄이는 것은 LCP 요소가 API 응답을 기다리느라 출발하지 못하던 시간이다. 같은 가이드에 따르면 JS 가 나중에 끼워 넣는 리소스는 브라우저의 preload scanner 가 HTML 에서 미리 찾아내지 못한다. LCP 이미지 주소가 API 응답 안에 있는 CSR 웹뷰라면 API 대기 뒤에 이미지 대기가 한 번 더 붙는다.

02 HTML 이 API 를 먼저 부르고, 번들은 결과만 꺼내 간다

첫 시도는 단순했다. index.html 에 인라인 스크립트를 넣어 번들보다 먼저 fetch 를 시작했다. 까다로운 쪽은 응답을 메인 앱에 넘기는 일이었는데, 인라인 스크립트와 메인 번들은 모듈 스코프를 공유하지 못한다. 그래서 window 에 전역 캐시(Map)를 두고 URL 을 키로 요청 Promise 를 넣었다. 메인 앱의 axios 요청 인터셉터는 요청을 보내기 전에 이 캐시부터 본다. 항목이 있으면 지운 뒤 네트워크 대신 그 응답을 돌려주고, 없으면 평소처럼 요청을 내보낸다.

구조만 줄이면 아래와 같다. 원문 코드를 옮긴 것이 아니라 같은 구조를 다시 쓴 스케치다. 캐시 키를 getUri 로 만들고 응답 상태를 그대로 넘기는 방식은 원문 코드와 같다. 어댑터가 돌려주는 필드는 axios 문서를 따랐고, window.__prefetch__ 의 전역 타입 선언은 뺐다.

ts
// home.critical.ts — HTML <head> 에서 번들보다 먼저 실행된다
const cache = (window.__prefetch__ ??= new Map<string, Promise<Response>>())
const url = '/api/home' // 메인 앱의 axios.getUri(config) 결과와 글자까지 같아야 한다
cache.set(url, fetch(url)) // 인증 헤더가 필요하면 여기서 직접 붙인다
ts
// 메인 앱 — 요청 전에 전역 캐시를 먼저 본다
axios.interceptors.request.use((config) => {
  const key = axios.getUri(config) // baseURL·params 까지 합친 최종 URL
  const hit = window.__prefetch__?.get(key)
  if (!hit) return config // 캐시에 없으면 평소처럼 네트워크로
  window.__prefetch__!.delete(key) // 한 번 쓰고 지운다
  config.adapter = async () => {
    const res = await hit
    // 직접 만든 어댑터는 상태 코드 검사를 거치지 않으므로 실패를 스스로 거른다
    if (!res.ok) throw new Error(`prefetch failed: ${res.status}`)
    return {
      data: await res.json(), status: res.status, statusText: res.statusText, headers: {}, config,
    }
  }
  return config
})

인라인 쪽은 크기를 아끼려고 axios 대신 fetch 를 쓴다. 그래서 실제 서비스에서도 fetch 응답을 axios 응답 모양으로 바꿔 주는 어댑터를 메인 앱에 따로 두었다. 이 구조를 직접 짜면 챙길 것이 세 가지 생긴다. 첫째, 캐시 키는 메인 앱이 실제로 부르는 최종 URL 과 정확히 같아야 한다. 둘째, 원문이 요구 사항으로 꼽았듯 인증 정보처럼 API 호출에 필요한 공통 값도 인라인 스크립트가 직접 구해야 한다.

셋째는 원문이 다루지 않은 실패 처리다. axios 어댑터 README 를 보면 상태 코드에 따라 성공·실패를 가르는 일(settle)은 어댑터 안에서 한다. 직접 만든 어댑터가 응답 객체를 돌려주기만 하면 500 응답도 성공으로 넘어간다는 뜻이고, 원문 코드도 response.ok 를 보지 않는다. 스케치는 res.ok 검사로 이를 막았지만, 이대로면 인라인 요청이 실패할 때 메인 앱 요청도 같이 실패한다. 실패하면 네트워크로 다시 가는 길을 두는 편이 안전하다.

효과는 바로 보였다. 갤럭시 S22+ 를 Wi-Fi 에 붙여 잰 LCP 가 1,340ms 에서 847ms 로 약 37% 줄었다.

text
HTML 다운로드 ─┬─ 인라인 스크립트: API 요청 ──────────── 응답 도착
               └─ 번들 다운로드·실행 → React 트리 → 캐시에서 응답 꺼냄 → 렌더링(LCP)

03 별도 패키지를 버리고 페이지 옆 *.critical.ts 로 옮겼다

손으로 쓴 인라인 스크립트에는 타입 검사가 없다. API 스펙이 바뀌면 소리 없이 깨진다는 뜻이라, TypeScript 로 쓰고 빌드해서 넣기로 했다. 당시 가게홈은 Next.js 로 만들어져 있었고, Next.js 에는 인라인 스크립트를 TypeScript 로 쓰는 길이 없었다. 팀은 모노레포 안 별도 패키지에서 Vite 로 미리 빌드하고, 결과를 webpack raw-loader 로 문자열처럼 읽어 Next.js Script 컴포넌트(strategy="beforeInteractive")에 넣었다.

돌아가긴 했지만 손이 많이 갔다. 인라인 코드가 별도 패키지에 있어 그 코드를 쓰는 페이지와 멀리 떨어졌고, 메인 앱의 타입·유틸을 가져다 쓰지 못해 같은 로직을 두 벌 관리했다. 페이지를 하나 늘릴 때마다 패키지에 엔트리도 추가해야 했다.

두 번째 시도는 CSS Modules 나 vanilla-extract 의 방식을 빌렸다. 특정 import 를 번들러 플러그인이 가로채서 따로 빌드한다. 이렇게 만든 Vite 플러그인이 critical-script 다. 인라인 자체보다 임계 경로(Critical Path, 첫 화면까지 반드시 거치는 단계들)를 줄이는 데 목적이 있어서 붙은 이름이다. 빌드는 이렇게 흐른다.

  1. 페이지 옆에 *.critical.ts 를 두고, 페이지에서 ?as-critical-script 를 붙여 import 한다.
  2. 플러그인의 load 훅이 이 모듈을 받아 esbuild 로 번들·압축해 IIFE(즉시 실행 함수) 하나로 만든다.
  3. 결과가 8KB 를 넘으면 빌드를 실패시킨다.
  4. 압축된 코드를 dangerouslySetInnerHTML 로 <script> 에 넣는 React 컴포넌트를 돌려준다.
  5. 빌드 때 페이지를 프리렌더하면 이 컴포넌트가 HTML <head> 안 인라인 스크립트로 남는다.

같은 프로젝트 안에 있으니 메인 앱의 타입과 모듈을 그대로 import 할 수 있고, 페이지를 늘려도 설정은 건드리지 않는다. 8KB 한도는 처음엔 4KB 였다가 가끔 넘쳐서 올렸다. 원문 설명으로는 의도한 로직만 담으면 8KB 안에 넉넉히 들어가고, 라이브러리를 끌어오면 확실히 넘는다. 실수로 붙은 의존성을 빌드에서 걸러 내는 장치인 셈이다.

공개 패키지는 2026년 10월 공식 문서 기준으로 이렇게 붙인다.

bash
npm install -D @woowabros/vite-plugin-critical-script
ts
// vite.config.ts
import { defineConfig } from 'vite'
import { criticalScriptPlugin } from '@woowabros/vite-plugin-critical-script'

export default defineConfig({
  plugins: [criticalScriptPlugin({ outputSizeLimit: 8192 })],
})
json
{ "compilerOptions": { "types": ["@woowabros/vite-plugin-critical-script"] } }
tsx
// home.tsx
import CriticalScript from './home.critical?as-critical-script'

export default function Home() {
  return (
    <>
      <CriticalScript />
      <Page />
    </>
  )
}
옵션기본값하는 일
outputSizeLimit8192 (바이트)압축 뒤 크기 한도, 넘으면 빌드 실패
define{}esbuild define 으로 넘겨 process.env.X 같은 값을 빌드 때 치환
targetesnext문법을 낮출 대상 환경, esbuild 형식 그대로

<CriticalScript /> 는 nonce 를 포함한 표준 <script> 속성을 모두 받는다. suppressHydrationWarning 과 압축 뒤 바이트 수를 담은 data-size 속성은 알아서 붙는다.

04 API 말고도 첫 화면 전에 끝낼 일이 있었다

틀이 생기자 쓰임이 넓어졌다. 원문이 든 사례는 다섯 가지다.

쓰임겨냥한 지표인라인 스크립트가 하는 일
API prefetchLCP페이지에 필요한 API 를 번들보다 먼저 요청
LCP 이미지 preloadLCPAPI 응답에서 이미지 주소를 꺼내 <link rel="preload"> 추가(가게홈 상단 로고·첫 배너)
디바이스 인셋 적용FCP앱 브리지를 먼저 불러 CSS 변수를 정하고, 준비 전까지 스켈레톤을 숨김(data-inset-ready)
상품 이미지 먼저 보이기FCP목록이 쿼리 파라미터로 넘긴 이미지 주소를 상세 API 응답 전에 스켈레톤에 표시(fetchpriority="high")
배경색 초기화깜빡임흰 배경이 잠깐 비치지 않게 배경색을 먼저 적용

인셋 값은 앱 브리지로 받는데, 네이티브와 통신하는 호출이라 결과가 비동기로 온다. 그래서 전에는 번들이 돈 뒤에야 스켈레톤 위치를 잡을 수 있었다. 커머스홈은 이 값을 인라인 스크립트로 먼저 받아 번들을 기다리지 않고 스켈레톤을 띄웠고, 원문은 FCP(First Contentful Paint, 첫 콘텐츠가 그려진 시점)가 크게 줄었다고 적었다. FCP 수치는 그래프로만 실려 있어 여기 숫자로 옮기지 않는다.

05 30%·37%·25~35% 는 서로 다른 잣대에서 나왔다

출처마다 비율이 조금씩 다른 것은 측정 조건이 달라서다.

수치출처조건
약 37% (1,340ms → 847ms)원문, 1차 구현갤럭시 S22+, Wi-Fi
약 25~35%원문, 운영 측정가게홈·상품상세, Sentry 실사용자 데이터, 적용 전후 1주일씩, p50·p75·p90
30~40%GitHub READMEAPI prefetch 패턴, 이전 구현 대비
30%원문 제목제목에 내건 대표값

운영 측정은 사용자가 가장 많은 기기 하나로 모았다. 기기 성능에 따른 편차를 줄이려는 선택이었고, 그만큼 저사양 기기나 느린 회선에서 같은 비율이 나온다는 보장은 없다. 공식 문서 사이트에는 네트워크(Slow 4G 등)·API 지연·번들 시작 시간을 바꿔 가며 적용 전후를 비교하는 브라우저 벤치마크가 있다. 자기 서비스와 비슷한 값을 넣어 보면 감을 잡기 쉽다. 적용 범위는 가게홈 한 곳에서 시작해 지금 약 80개 페이지까지 늘었다.

06 줄어드는 것은 기다림이지 일의 양이 아니다

critical-script 는 CPU 시간도 네트워크 시간도 줄이지 않는다. 한 줄로 서 있던 일을 나란히 세울 뿐이라, 번들이 작고 금방 실행된다면 겹쳐 아낄 시간도 짧다. 그런데도 원문은 API 가 빠른 서비스에도 쓸모가 있다고 본다. 아끼는 것이 서버 처리 시간이 아니라 번들 실행 뒤 요청이 출발해 돌아오기까지의 네트워크 왕복이어서다. 회선이 불안정한 모바일 환경일수록 이 왕복이 길어진다.

인라인 스크립트는 메인 앱과 따로 도는 런타임이다

인라인 스크립트 안에서는 React 도 TanStack Query 도 못 쓴다. 메인 앱의 polyfill 이 닿지 않으니 지원 브라우저가 기본으로 가진 ECMAScript 문법과 Web API 만 써야 한다. 모듈 싱글톤도 공유되지 않아 데이터는 window·localStorage·sessionStorage 같은 전역 저장소로 건넨다. 배달의민족 팀은 crypto 라이브러리를 Web Crypto API 로 바꿨고, 앞서 말한 fetch → axios 응답 어댑터도 이 제약 때문에 생겼다.

인라인 코드는 HTML 캐시를 따라간다

공식 문서의 Caveats 절은 인라인 스크립트가 HTML 의 일부라는 점을 짚는다. 외부 JS 파일처럼 따로 캐시되거나 CDN 에서 따로 받지 않고, HTML 의 캐시 정책을 그대로 따른다. HTML 요청마다 함께 실려 가므로 한도를 올려도 가능한 한 작게 두라고 권한다. 반대 방향도 생각해 둘 만하다. HTML 이 엣지에 오래 머물면 그 안의 스크립트도 함께 머문다. 엣지 캐시가 옛 HTML 을 들고 있던 사례는 배포는 성공했는데 옛 페이지가 보였다 — 엣지 캐시 퍼지 실패와 재시도에 정리해 뒀다. API 응답 모양을 바꿀 때는 옛 HTML 의 인라인 스크립트가 캐시 수명만큼 더 돈다는 점을 감안해 배포 순서를 짠다.

CSP 와 target 을 먼저 맞춘다

MDN 에 따르면 CSP(Content-Security-Policy)에 script-src 가 있으면 인라인 스크립트도 막힌다. 허용하는 길은 'unsafe-inline'·nonce·hash 셋이고, MDN 은 앞의 것을 피하라고 권한다. nonce 는 요청마다 새로 만들어야 하므로 빌드 때 굳는 정적 HTML 과는 잘 맞지 않는다. 내용이 고정된 인라인 스크립트라면 MDN 설명대로 hash 를 정책에 넣는 쪽이 자연스럽다. 다만 hash 는 공백까지 포함한 스크립트 내용 그대로 계산하므로, 인라인 코드가 바뀌는 빌드마다 정책의 hash 도 새로 넣어야 한다. 요청마다 HTML 을 그리는 서버가 있다면 컴포넌트에 nonce 를 넘기면 된다.

target 기본값 esnext 도 웹뷰에서는 따져 볼 값이다. esbuild 문서에 따르면 target 은 문법만 낮추고 polyfill 은 넣지 않으며, 낮출 수 없는 문법을 만나면 빌드 에러를 낸다. 오래된 Android WebView 를 지원 범위에 둔다면 target: ['chrome80', 'ios14'] 처럼 지원 하한을 적어 두고, 그 버전에 없는 Web API 는 쓰지 않는다. 버전 값은 각자 지원 정책에 맞춘다.

07 한국 개발자에게: 정적 배포 웹뷰라면 첫 화면 API 하나로 시험해 볼 만하다

앱 안 지면을 웹뷰로 만들고 정적 파일로 배포하는 커머스·핀테크·배달 프론트엔드라면 이 글의 구조가 그대로 닿는다. 서버 렌더링 없이 번들이 API 를 부르는 구조라면 원문이 본 직렬 대기가 그대로 있을 가능성이 크다. 라이선스는 MIT 라 사내 도입에 걸리는 조건도 적다. 다만 공개 플러그인이 붙는 자리는 정해져 있어서, 스택부터 확인해야 한다.

지금 스택붙이는 길
Vite 기반 React + 빌드 때 프리렌더(react-router framework mode, TanStack Start)공개 플러그인을 그대로 쓴다
Next.jsREADME 는 Vite 만 다룬다. 원문 1차 구현처럼 따로 빌드해 Script beforeInteractive 로 넣는다
프리렌더 없는 SPA클라이언트에서만 그리는 페이지에는 플러그인이 스크립트를 넣지 못한다. index.html 에 직접 넣는 1차 방식부터 시작한다
이미 SSR서버가 모르는 값(디바이스 인셋·localStorage)을 첫 페인트 전에 다룰 때 쓴다

Next.js App Router 에서는 제약이 하나 더 있다. 공식 문서상 beforeInteractive 스크립트는 루트 레이아웃에 두어야 하고, 문서를 처음 읽을 때 한 번만 돈다. 클라이언트 내비게이션에서는 다시 실행되지 않는다. 그래서 페이지마다 다른 API 를 미리 부르려면 스크립트 안에서 경로를 보고 나눠야 한다. 렌더링 방식별로 HTML 이 언제 만들어지는지는 CSR·SSR·SSG·ISR·RSC·PPR 차이에서 따로 다뤘다.

하지 않아도 되는 경우도 있다. 번들이 작아 실행이 금방 끝나거나, LCP 요소가 API 와 무관하게 초기 HTML 에 들어 있는 이미지라면 겹쳐 아낄 대기가 거의 없다. 이미 LCP 가 목표 안에 들어온 지면도 서두를 이유가 없다.

08 할 일: 재고, 하나만 옮기고, 막힐 곳을 미리 본다

  1. DevTools Performance 탭으로 첫 로딩을 떠서 첫 화면 API 가 번들 실행 뒤에 출발하는지 확인한다.
  2. 실사용자 LCP 를 기기별 p50·p75·p90 으로 모아 둔다. 원문처럼 적용 전후 같은 기간을 비교해야 판단이 선다.
  3. 첫 화면 API 하나만 인라인 스크립트로 옮기고, 캐시 키 일치와 실패 시 네트워크 폴백을 함께 넣는다.
  4. CSP 의 script-src 에 hash 나 nonce 를 넣을 수 있는지, target 이 지원 웹뷰 하한과 맞는지 본다.
  5. LCP 이미지 preload 나 인셋 처리는 API 쪽 효과를 확인한 뒤에 넓힌다. 크기 한도는 기본 8KB 그대로 둔다.

09 자주 묻는 질문

SSR 을 이미 쓰면 필요 없나?

아니다. 원문은 디바이스 인셋이나 localStorage 처럼 클라이언트에서만 얻는 값은 서버가 다룰 수 없어서, 첫 페인트 전에 처리하는 용도로 여전히 쓸모가 있다고 본다.

API 응답이 이미 빠른데 효과가 있을지 미리 알 수 있나?

공식 문서의 벤치마크로 먼저 재 보면 된다. 추가 API 지연을 0ms 까지 낮출 수 있으니, 서버 지연을 0 으로 두고 네트워크 조건과 번들 시작 시간만 바꿔 가며 적용 전후를 비교해 볼 수 있다.

8KB 를 넘기면 어떻게 되나?

빌드가 실패한다. import 를 줄이거나 바닐라 JS 로 다시 쓰는 것이 먼저고, outputSizeLimit 로 한도를 올려도 HTML 마다 실린다는 점은 그대로다.