Critical JavaScript. 언제, 어떻게 써야 할까

notion image

Critical JavaScript, 왜(그리고 언제, 어떻게) 써야 할까

초기 화면을 그리기 위해 브라우저는 index.html을 받은 뒤 JS·CSS를 내려받고, JS가 실행되면서 필수 API를 호출해 데이터를 가져옵니다. 문제는, 필수 데이터가 늦게 도착하면 사용자 입장에서 “완성된 화면”을 보기까지의 지연이 길어질 수 있다는 점이에요.

LCP/FCP, 정말 API가 끝나야 LCP가 끝날까?

반드시 그런 건 아닙니다.
  • LCP(Largest Contentful Paint)는 “뷰포트 내 가장 큰 콘텐츠그려지는 시점”이에요. 이 요소가 API 데이터에 의존하지 않는다면, API보다 먼저 LCP가 끝날 수 있어요.
  • FCP(First Contentful Paint)는 “페이지가 로드 중임을 처음으로 보여주는 시점”이에요. 스켈레톤/플레이스홀더 같은 초깃값이 찍히면 FCP는 빠르게 끝날 수 있죠.
핵심은 LCP 요소가 데이터 의존적인가예요. 의존적이라면 데이터 도착이 늦을수록 LCP도 늦어집니다.

JS 실행과 데이터 대기를 겹치기

필수 API 응답을 JS 실행 직후에야 시작하면, 네트워크 대기 + 파싱 시간이 직렬로 이어져 총 대기 시간이 길어집니다. 반대로 JS 실행과 독립적으로 데이터를 미리 받아놓을 수 있다면, 렌더 직전에 곧바로 주입해 화면을 훨씬 빨리 완성할 수 있어요.
여기서 쓰는 기법이 크리티컬 자바스크립트(Critical JavaScript)입니다. 간단히 말해, 번들 평가와 별개로 “정말 필요한 데이터”를 가능한 일찍 요청해 두고, 런타임에 즉시 소비할 수 있게 준비하는 전략이에요. 이렇게 하면 사용자에게 보이는 시간(특히 데이터 의존 LCP)을 크게 줄일 수 있습니다.

예상 시나리오

Vite 프로젝트에서 핵심 데이터 요청을 번들 평가와 병렬로 앞당기는 “크리티컬 자바스크립트”를 어떻게 구현할지, 최소 설정부터 동작 확인까지 시나리오로 알아보겠습니다.
이번 시나리오의 목표는 사용자가 지정한 크리티컬 스크립트(src/page.critical.ts)를 HTML에 가장 이른 시점에 주입해, 번들 평가와 병렬로 필수 데이터를 당겨오게 하는 것입니다.
이는 Vite 기본 기능이 아니므로, vite.config.ts에서 플러그인으로 직접 주입 로직을 정의합니다.

vite.config.ts

1. name

참고 : 전체 코드
  • 플러그인 식별자이자 디버깅용 라벨. 로그·오류 추적 시 어떤 훅이 동작했는지 바로 파악할 수 있습니다.
 

2. transformIndexHtml — 인라인 크리티컬 스크립트 주입

참고 : 전체 코드
이때 post 단계에서 실행하는 이유는 뭘까?
Vite는 index.html을 읽은 뒤, 내부 <script src="/src/main.tsx">를 분석하며 에셋 태그를 자동 삽입합니다. order: "prev" 단계에서 인라인을 넣으면 이후 Vite의 재구성 과정(에셋 태깅/변환)에서 태그 위치가 바뀌거나 덮여 쓰일 수 있습니다.
그래서 모든 처리가 끝난 뒤(post) 최종 HTML에 안정적으로 삽입하는 게 안전하기 때문에 사용했습니다.
참고(간단 파이프라인)
  1. index.html 파싱 → 2) transformIndexHtml(prev) → 3) 스크립트/에셋 분석·주입 → 4) transformIndexHtml(post) → 5) 번들 로드
무엇을 주입하는가?
  • 예시는 ./src/page.critical.ts 소스 자체를 인라인으로 넣는다.
  • 목적은 번들 평가와 독립적으로 “가장 이른 시점”에 필수 데이터 요청을 시작하는 것.
 

3. configureServer(server) — 개발용 API 목

참고 : 전체 코드
  • Vite dev 서버에 미니 목 엔드포인트를 붙여, 네트워크 왕복(지연)을 흉내 냅니다.
  • 크리티컬 스크립트가 얼마나 빨리 요청을 시작하고, 그 결과가 초기 렌더에 어떻게 반영되는지 체감 테스트하기 위해서 사용했습니다.
  • 배포 환경에선 실제 API로 대체하거나, 별도 프록시/서버에서 제공하면 됩니다.

page.critical.ts

1. fetch가장 먼저 시작

참고 : 전체 코드
  • 네트워크 요청을 즉시 실행. HTML 파싱 중에도 네트워크 파이프를 채워 대기 시간을 숨깁니다.
 

2. 전역 네임스페이스 window.__boot

참고 : 전체 코드
  • promises: 이미 출발한 요청을 공유해 네트워크/CPU 낭비를 막고
  • data: 프리페치가 끝났다면 컴포넌트에서 즉시 읽기 가능합니다.
 

3. has(url)중복 차단

참고 : 전체 코드
  • 동일 URL 프리페치가 진행 중이면 새로 시작하지 않게 필터링 하는 역할입니다.
  • 앱 코드의 후속 cachedJson(url) 호출도 같은 Promise를 재사용합니다.
 

4. req.then에서 clone() 사용

참고 : 전체 코드
Response는 본질적으로 스트림 1회성이라,
  • 한 번은 JSON 파싱에,
  • 한 번은 Cache Storage put에 사용하려면 clone()이 사용합니다.
 

5. Cache Storage

참고 : 전체 코드
  • 재사용을 위해서 요청받은 API의 재사용을 위해서 Cache Storage를 사용합니다.

cachedJson

1. 진행 중 프리페치 재사용 (promises)

참고 : 전체 코드
  • 동일 URL에 대한 중복 네트워크 요청을 막고, 이미 떠 있는 요청의 결과를 공유하고
  • 초기 크리티컬 자바스크립트가 던진 필수 API가 아직 완료되지 않았다면, 대기 후 동일 결과를 받습니다.
 

2. 동기 데이터 (data)

참고 : 전체 코드
  • 미리 주입해 둔 즉시 접근 가능한 동기 캐시를 최우선 활용합니다.
 

3. Cache Storage 조회

참고 : 전체 코드
  • 필수 API 요청이 실패했다면, Cache Storage에 해당 데이터가 있는지 확인 후 있다면 응답해줍니다.
 

4. 네트워크 폴백 (fetch)

참고 : 전체 코드
  • 필수 API 요청도 실패하고, Cache Storage에 데이터도 없다면 서버로부터 API 요청을 실행합니다.

성과

notion image
스크린샷처럼 크리티컬 자바스크립트 적용 시점부터(DOMContentLoaded) 데이터 페치가 시작됩니다. 동일한 코드에서 크리티컬 스크립트를 제거했을 때 load 이후에야 호출되던 것과 비교하면, 평균 약 50ms 앞당김을 확인했습니다.
숫자만 보면 작아 보이지만, 이는
  • 초기 네트워크 슬롯을 선점해 경합을 줄이고
  • 하이드레이션 직전 데이터가 준비되어 초기 상호작용 지연(입력 지연) 완화
  • 라우트 전환 시 boot.data/promises 재사용으로 중복 요청 제거
로 이어져 페이지 수·요청 수가 많을수록 누적 체감 개선이 커집니다. 결과적으로 같은 기능 대비 초기 체감 응답성이 더 또렷해졌고, 규모가 커질수록 이 차이는 사용자 경험에서 분명한 속도 이점으로 확장됩니다.

막 사용해도 될까?

좋은 기법도 “아무 데나” 쓰면 성능이 오히려 나빠질 수 있습니다. 상황에 맞춰 쓰는 게 핵심입니다.

인라인 스크립트

네트워크 왕복이 없어서 즉시 실행된다는 장점이 있지만, 비용도 분명합니다.
  • HTML 바이트 증가: 인라인이 커질수록 HTML이 비대해져 TTFB·다운로드·파싱이 모두 늘어납니다.
  • 전 페이지 오버헤드: 일부 라우트에서만 필요한 코드라도, 인라인이면 모든 페이지가 같은 비용을 집니다.
  • 캐시 부재: HTML과 함께 매 요청마다 다시 내려가므로 재방문 이득이 적습니다.

대안 : 외부 스크립트 + modulepreload

인라인만큼 빠르게 “실행”하진 않지만, 전체 경험과 유지보수에 유리할 수 있습니다.
  • 네트워크 요청 추가: 별도 요청이 필요합니다.
    • 다만 한 번 받은 파일은 캐시 재사용되어 재방문이 빨라집니다.
  • 병목 완화: 파서가 보자마자 미리 당겨와 실행 시점 대기를 줄입니다.
  • HTML 슬림화: 큰 코드를 HTML에서 분리해 문서 다운로드 부담을 낮춥니다.
  • 범용성: 다중 라우트에서 공유 코드를 한 번만 받게 만들 수 있습니다.

언제 무엇을 쓸까?

  • 인라인이 유리한 경우
    • 크기가 작다
    • 모든 페이지에서 반드시 필요하고, 초기 상호작용/LCP에 직접 기여한다.
    • 릴리즈마다 내용이 바뀌어 캐시 이득이 거의 없다.
  • 외부 + modulepreload가 유리한 경우
    • 스크립트가 크거나 복잡하다.
    • 일부 라우트에서만 필요하거나, 여러 페이지에서 재사용된다.
    • 캐시/무효화 전략(파일 해시)을 통해 재방문 이득을 보고 싶다.
    • HTML TTFB·파싱 시간을 줄이고 싶다.
API를 최대한 빨리 호출하려면 인라인이 유리할 때가 있습니다. 다만 한 가지만 보고 선택하는 것이 아닌 페이지 전체 성능·캐시 전략·코드 규모·라우트 분포까지 고려해야 합니다.
예상 시나리오도 크리티컬 스크립트가 어떤 것인지 알아보기 위해서 작성한 것이지 이 방법이 정답은 아닙니다. 팀에서 실측(LCP/FID/TTFB) 기반으로 검증·결정하는 단계를 거치는 것은 필수입니다.