배달의민족 웹뷰 로딩 성능을 30% 개선한 최적화 전략

일시
2026.10.28 16:50-17:30
트랙
트랙 C
연사
추용혁

#프런트엔드

세션 소개

배달의민족 커머스 전시 플랫폼팀이 웹뷰 화면인 가게홈의 로딩을 줄인 과정입니다. HTML에 넣은 인라인 스크립트에서 API를 먼저 요청하는 방식을 검증하고, 이를 critical-script 번들러 플러그인으로 만들어 여러 화면에 적용한 뒤 오픈소스로 공개했습니다.

핵심 내용

문제: 가게홈 로딩은 순차 워터폴

배달의민족 앱은 하이브리드 앱이라 0.5~1초 안에 뜨는 네이티브 화면에서 웹뷰 화면으로 넘어가면 느린 차이가 바로 체감됩니다. 측정해 보니 가게홈 로딩의 모든 단계가 앞 단계가 끝나야 시작하는 순차 구조였습니다.

구분네이티브웹뷰
렌더링UI 코드가 앱 바이너리에 있어 빠름HTML, CSS, JS를 매번 네트워크로 받아 느림
개발·배포iOS·Android 따로 개발, 스토어 심사 필요한 번 개발, 심사 없이 배포
  • 가게홈: 메인 홈이나 장보기·쇼핑 홈(네이티브)에서 들어가는, 커머스 웹뷰 중 사용자가 가장 많고 가장 앞단에 있는 화면
  • 지표: LCP(Largest Contentful Paint, 배너·상품 이미지가 표시되는 시점), Sentry로 집계
  • 실사용자 LCP: p50 1.6초, p75 2.2초(Core Web Vitals 좋음), p90 2.9초(개선 필요)
  • 구조: HTML은 정적 파일 서빙(SSR 없음), 번들에 React·axios·TanStack Query와 비즈니스 코드가 모두 포함
  • 병목: API 호출이 query hook 안에 있어 번들 실행과 React 렌더링(스켈레톤, FCP 시점)이 끝난 뒤에야 요청 시작

단계별 소요 시간입니다. 개선 전은 발표자 폰에서 Chrome DevTools Performance 탭으로 잰 값이고, 개선 후는 critical-script 적용 뒤 각 리소스의 로딩 시간입니다.

단계개선 전개선 후
HTML66ms (여기서 fetch 시작)
JS 번들 다운로드300ms393ms (API와 병렬)
번들 실행240ms
React 렌더링190ms
가게 정보 API180ms351ms (HTML 직후 시작)
섹션 API420ms468ms (HTML 직후 시작)
리렌더링320ms
LCP1.34초0.85초

아이디어: HTML을 받자마자 API 요청

번들 크기 축소, 청크 분할, 렌더링 최적화, preconnect, API 응답 단축 같은 일반적인 전략은 가게홈에 더 적용할 여지가 거의 없었습니다. 더 다듬어도 체감 차이를 만들기 어렵고, 비즈니스 로직이 늘면 줄인 시간이 금방 사라질 수 있다고 판단했습니다. 그래서 HTML에 인라인 스크립트를 넣어 HTML 직후 API를 요청하는 방식을 최소 구현부터 검증했습니다.

  • 검증: Next.js 빌드 결과물(.next 아래 HTML)의 <head>에 직접 스크립트를 넣어 로컬에서 확인, API 요청이 HTML 직후 시작
  • 한계: 다시 빌드하면 수정이 사라짐. 인라인 스크립트의 API 호출 코드는 계속 바뀌는 비즈니스 로직이라 소스에서 TypeScript로 작성하고 타입 검사를 거쳐야 함
  • 요구사항: 최종 HTML에 삽입, 인라인 스크립트에서 API 호출, HTML 크기를 지키는 최소한의 코드

첫 구현: 별도 패키지로 빌드해 ?raw로 삽입

인라인 스크립트를 packages/prefetch에서 TypeScript로 작성해 Vite로 따로 빌드·압축하고, 앱에서 raw-loader(?raw)로 문자열을 가져와 Script 컴포넌트로 렌더링했습니다. 프리렌더링 때 이 코드가 최종 HTML에 인라인 스크립트로 들어갑니다.

// apps/bmstore-app/src/pages/seller/[id].tsx
import prefetchScript from '@lib/prefetch/bmstore-app/sellerhome?raw'
import Script from 'next/script'

<Script id="prefetch" strategy="beforeInteractive">{prefetchScript}</Script>
  • 인라인 스크립트는 HTML 파싱 중 실행되고 메인 번들과 라이브러리·모듈 스코프를 공유하지 않음
  • React 139KB, TanStack Query 46KB, axios 35KB를 쓸 수 없고, axios를 넣으면 HTML이 너무 커져 fetch 사용
  • 응답 전달: fetch가 반환한 Promise<Response>를 URL을 키로 window.__prefetch__에 저장하고, 메인 앱의 axios 어댑터가 꺼내 axios 형식으로 변환

운영 배포 뒤 LCP는 1.34초에서 0.85초로 37% 줄었습니다(Galaxy S22+, Wi-Fi). 실사용자 집계에서도 개선 폭이 거의 같았습니다.

다른 화면으로 넓히기 어려웠던 이유

가게홈의 결과를 다른 화면에 옮기려 하자 구조상의 걸림돌 세 가지가 드러났습니다.

포인트내용
낮은 응집도1:1로 함께 바뀌어야 하는 인라인 스크립트(packages/prefetch)와 페이지(apps/bmstore-app)가 다른 패키지에 있음
메인 앱 코드 사용 불가API 경로 상수, OpenAPI 스펙에서 생성한 요청·응답 타입을 import하지 못해 두 벌로 복사
번거로운 적용페이지마다 스크립트 추가, 패키지 빌드, ?raw 삽입의 세 단계 필요

설계: 페이지 옆에 *.critical.ts 두기

인라인 스크립트를 페이지 바로 옆에 .critical.ts로 두고, 빌드와 삽입은 번들러 플러그인이 맡게 했습니다. 스타일 파일을 모듈처럼 import하고 번들러 플러그인이 CSS로 출력하는 vanilla-extract, CSS Modules에서 가져온 아이디어입니다.

  • .critical.ts: 일반 TypeScript 모듈처럼 작성하고 메인 앱의 타입·유틸리티 같은 작은 모듈을 import 가능
  • 페이지에서는 모듈 지정자 뒤에 ?as-critical-script를 붙여 import하고 컴포넌트처럼 렌더링
  • 이름: 로딩 성능을 결정하는 임계 경로(critical path) 최적화가 목적이라 inline-script 대신 critical-script
// StoreHomePage.tsx
import CriticalScript from './StoreHomePage.critical?as-critical-script'

export default function Page() {
  return <><CriticalScript /><StoreHomePage /></>
}
// StoreHomePage.critical.ts
import { getStoreHomeUrl } from '@/apis/storeHome'

const q = new URLSearchParams(location.search)
const url = getStoreHomeUrl(q.get('storeId'))
window.__prefetch__ ??= { cache: new Map() }
window.__prefetch__.cache.set(url, fetch(url))

구현: Vite 플러그인

Vite 플러그인이 모듈 로딩 단계에서 ?as-critical-script 모듈을 가로채, 원래 파일을 번들·압축한 코드를 script 태그 컴포넌트로 감싸 반환합니다.

단계담당내용
모듈 해석Viteimport 경로를 …/StoreHomePage.critical.ts?as-critical-script 절대 경로로 해석
모듈 로딩플러그인쿼리를 뗀 파일 경로의 내용을 읽음
번들·압축플러그인esbuild로 import한 모듈까지 번들·압축
컴포넌트 변환플러그인압축 코드를 script 컴포넌트로 감싸 반환, 프리렌더링 때 HTML에 삽입
// StoreHomePage.critical?as-critical-script
export default (props) => (
  <script {...props} dangerouslySetInnerHTML={{
    __html: '(()=>{const e=new URLSearchParams(location…})();'
  }} />
)
  • 안전장치: 실수로 라이브러리를 import해 HTML이 커지지 않도록 인라인 스크립트 크기 상한을 넘으면 빌드 실패
  • 개발자는 .critical.ts를 만들고 import만 하면 되어 여러 화면에 적용

활용: LCP·FCP 최적화 사례

번들에 의존하던 일을 인라인 스크립트로 옮기면 API 요청 외의 작업도 앞당길 수 있습니다.

화면목표사례내용
가게홈LCPAPI prefetchHTML 직후 API 요청
이미지 preloadAPI 응답의 상단 로고·첫 배너 이미지 URL을 <link rel="preload">로 추가, 번들과 병렬 로드
상품상세FCP디바이스 인셋앱 브리지로 받은 인셋을 CSS 변수로 설정, 준비 전에는 스켈레톤 숨김
스켈레톤 상품 이미지상품 카드가 쿼리 파라미터로 넘긴 이미지 URL을 스켈레톤에 바로 적용
  • 디바이스 인셋: 상태 표시줄·홈 인디케이터처럼 콘텐츠가 피해야 하는 영역. 스켈레톤에도 필요한데 앱 브리지 호출에 JS 실행이 필요해 번들을 기다려야 했음
  • 결과: 1초 걸리던 스켈레톤 렌더링이 169ms로 단축
  • 상품 이미지: 이전 화면의 상품 카드가 이미 보여 준 이미지라 URL을 넘기면 API 응답 없이 표시 가능
// 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', '')
})

성과와 오픈소스 공개

Sentry 실사용자 데이터 기준으로 LCP가 30% 안팎 줄었고, 플러그인으로 도입 허들을 낮춘 덕분에 여러 팀이 도입했습니다. 비슷한 오픈소스를 찾지 못해 직접 만든 만큼 노하우를 나누려고 공개했습니다.

기준결과
화면가게홈(?) 최대 32%(?), 상품상세 32~34%
백분위전 구간에서 거의 균등한 비율로 개선
디바이스 사양사양별로도 거의 균등, 대부분 구간에서 30%에 근접
도입발표 시점 85개 화면
  • 사내 오픈소스 프로세스가 없어 유동식 님이 이 공개를 계기로 만듦(공개 적합성 사전 확인, 법무 리스크 검토, 품질 체크리스트), 같은 시간 트랙 B 「AI 네이티브 워크플로: 개인의 역량을 넘어 조직의 역량으로」에서 발표
  • 문서에 로딩 워터폴 최적화를 이해하는 자료와 워터폴을 직접 실행해 보는 기능 추가
  • GitHub 저장소는 Google에서 critical-script로 검색

모든 코드를 번들에 담을 필요는 없으니, 커진 번들의 대안으로 인라인 스크립트를 검토해 보라는 제안으로 발표가 끝났습니다.