배달의민족 웹뷰 로딩 성능을 30% 개선한 최적화 전략
세션 소개
배달의민족 커머스 전시 플랫폼팀이 웹뷰 화면인 가게홈의 로딩을 줄인 과정입니다. 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 적용 뒤 각 리소스의 로딩 시간입니다.
| 단계 | 개선 전 | 개선 후 |
|---|---|---|
| HTML | 66ms (여기서 fetch 시작) | |
| JS 번들 다운로드 | 300ms | 393ms (API와 병렬) |
| 번들 실행 | 240ms | |
| React 렌더링 | 190ms | |
| 가게 정보 API | 180ms | 351ms (HTML 직후 시작) |
| 섹션 API | 420ms | 468ms (HTML 직후 시작) |
| 리렌더링 | 320ms | |
| LCP | 1.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 태그 컴포넌트로 감싸 반환합니다.
| 단계 | 담당 | 내용 |
|---|---|---|
| 모듈 해석 | Vite | import 경로를 …/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 요청 외의 작업도 앞당길 수 있습니다.
| 화면 | 목표 | 사례 | 내용 |
|---|---|---|---|
| 가게홈 | LCP | API prefetch | HTML 직후 API 요청 |
| 이미지 preload | API 응답의 상단 로고·첫 배너 이미지 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로 검색
모든 코드를 번들에 담을 필요는 없으니, 커진 번들의 대안으로 인라인 스크립트를 검토해 보라는 제안으로 발표가 끝났습니다.