Lighthouse of FE beginner

Vite의 사전 번들링, HMR 본문

[WEB] 프론트엔드

Vite의 사전 번들링, HMR

[FE] Lighthouse 2025. 5. 23. 18:44

Vite

Vite는 프로젝트 전체를 번들링하지 않고도 빠른 개발 서버를 제공합니다.

Vite가 등장하기 전 많이 사용된 번들러인 Webpack은 개발 서버를 실행할 때 프로젝트 전체의 의존 그래프를 해석하고 이를 번들링한 결과물을 브라우저에 전달합니다. 반면, Vite는 Native ESM(ECMAScript Modules)을 활용하여 브라우저가 직접 모듈을 해석하도록 위임하는 방식으로 동작합니다.
 
예를 들어, Webpack은 진입점부터 모든 의존성을 분석해 번들링을 수행한 뒤 이를 메모리에 저장하고 개발 서버를 통해 서빙합니다. 반면 Vite는 콜드 스타트 시점에만 esbuild로 라이브러리를 사전 번들링하고, 이후에는 소스 코드를 ESM 형태로 그대로 브라우저에 전달합니다.
 
이로 인해 Vite는 번들링 시간이 거의 없이 즉시 개발 서버를 실행할 수 있고, 변경된 모듈만 빠르게 반영되어 좋은 개발 경험(DX)을 제공합니다.

Vite 공식 홈페이지 참고

 

Vite의 개발 서버는 어떻게 작동하나?

Vite는 개발 서버 구동 시 다음과 같은 과정을 거칩니다.
 

콜드 스타트 시 사전 번들링 (Pre-Bundling)

  • node_modules에 존재하는 외부 의존성(CJS 또는 ESM)을 esbuild를 이용해 하나의 ESM으로 번들링합니다.
  • 주된 목적은 CJS → ESM 변환, 모듈 수 최소화, 캐싱 최적화입니다.
  • 번들링 결과는 .vite/deps 디렉토리에 저장됩니다.
사전 번들링은 일반적으로 한 번만 수행되며, 이후 개발 서버를 다시 실행해도 그대로 캐시를 사용합니다. --force 플래그를 사용하면 캐싱된 사전 번들링을 무시하고 다시 콜드 스타트를 할 수 있습니다.

 

Vite 프로젝트를 생성한 후 의존성을 설치(pnpm install)한 모습
개발 서버를 구동한 후 (pnpm dev) .vite/deps에 의존성(라이브러리)들이 사전 번들링이 된 모습

 

왜 esbuild를 사용할까?

  • esbuild는 Go로 작성된 초고속 번들러로, Webpack이나 Rollup보다 10~100배 빠릅니다.
  • Vite는 개발 서버의 응답 속도와 콜드 스타트 시간을 극대화하기 위해 esbuild를 사용해 의존성 번들링을 처리합니다.

 

Native ESM 기반 개발 서버

브라우저는 Vite 개발 서버(localhost:5173)에 최초 요청 시 다음과 같은 순서로 작동합니다:

  1. index.html을 요청하여 HTML 문서와 <script type="module">로 명시된 진입 모듈을 받아옵니다.
  2. 진입 모듈에서 import한 ESM 모듈들을 차례대로 동적으로 요청합니다.

 

개발 서버 구동

최초로 개발 서버를 구동하면 캐싱된 모듈이 없기에 모든 모듈에 대한 요청이 200OK로 응답 받습니다.

네트워크 탭을 살펴보면 t라는 쿼리 파라미터도 존재하지 않습니다.

 
개발 서버 구동 시 Vite 개발 서버와 브라우저간에 WebSocket 커넥션이 열리게 되는데, 해당 커넥션은 HMR을 핸들링 하기 위한 커넥션으로 사용됩니다.

 
작동 여부를 확인하기 위해 코드를 수정하겠습니다.

import { useState } from "react";

function App() {
  const [count, setCount] = useState(0);

  return (
    <>
      <h1>Vite 개발 서버</h1>
      <div className="card">
        <button onClick={() => setCount((count) => count + 1)}>
          count: {count}
        </button>
      </div>
    </>
  );
}

export default App;

 
원래의 소스 코드를 수정한 후 파일을 저장합니다.
 
Vite 개발 서버가 소스 코드의 변경을 감지하여 브라우저에 소스 코드가 변경됐다는 메세지를 보내며 해당 메세지에는 변경된 모듈의 경로를 함께 알려줍니다.
 
커넥션에서 전달 받은 메세지를 통해 코드 변경을 감지하고 변경된 모듈을 요청합니다.

 
이후 동일한 모듈을 요청할 경우 해당 timestamp를 사용해 요청을 보냅니다. Vite 개발 서버는 timestamp를 기반으로 동일한 모듈인지 체크하여 변함이 없다면 304 Not Modified를 응답합니다.
 

?t=timestamp

요청 URL을 살펴보면 t라는 key를 가진 쿼리 파라미터를 확인할 수 있는데, 해당 쿼리 파라미터는 브라우저의 캐시 정책을 우회하기 위해 사용하는 쿼리 파라미터입니다.
브라우저는 기본적으로 다음의 캐시 특성을 지니고 있습니다.

  • 브라우저 Native ESM은 URL을 기준으로 캐시합니다.
  • 같은 URL이면 이전에 불러온 모듈은 절대 다시 요청하지 않습니다. (Cache-Control: no-cache 헤더가 존재해도 마찬가지입니다.)
  • 특히 <script type=”module”>로 import 된 모듈은 ESM 캐시의 영향을 강하게 받습니다.

위 특성을 우회하기 위해 Vite 개발 서버는 브라우저에서 개발 서버에 요청을 보낼 시 timestamp를 사용해 캐시 정책을 우회(무효화) 합니다.

브라우저는 Vite 개발 서버에 요청을 보낼 때 기본적으로 Cache-Control: no-cache 헤더를 포함합니다. 이는 브라우저가 해당 자원을 캐시하고 있더라도, 반드시 서버에 유효성 검사를 요청하라는 의미입니다.

 

?t=timestamp는 어디서 왔을까?

소스 코드가 변경된 후 클라이언트는 Web Socket을 통해 소스 코드의 변경을 알림 받습니다. 그리고 t=timestamp를 쿼리 파라미터로 붙여 변경된 모듈을 다시 요청합니다.
 
저는 문득 t=timestamp는 어디서 생긴 값인지 궁금했습니다.

해당 내용은 아래 블로그를 참고한 내용입니다. Vite에서 HMR이 어떻게 동작하는지 소스 코드 레벨에서 잘 분석을 해준 포스팅 입니다.

Vite에서 import.meta는 왜 사용하는 걸까? (feat. HMR)

 
 
먼저 Vite 개발 서버는 소스 코드가 변경되면 변경된 모듈에 timestamp를 붙여서 다시 생성합니다.
예를 들면 App.tsx 코드를 수정했다면, Vite 개발 서버는 해당 소스 코드를 App.tsx?t=1747990597069 이런 식으로 다시 생성합니다.
 
궁금증을 해결하기 위해 @vite/client의 코드를 간략하게 살펴보겠습니다. 이전에 어떤 과정이 있는지 (import.meta.hot에 HMR Context가 주입되는 과정)는 생략하도록 하겠습니다.
 
Web Socket 커넥션이 연결된 후 @vite/client는 커넥션에서 전달 받는 메세지를 handleMessage 함수로 처리합니다. 아래는 handleMessage 함수의 일부입니다.
 

async function handleMessage(payload) {
  switch (payload.type) {
    // 생략 //
    case "update":
      await hmrClient.notifyListeners("vite:beforeUpdate", payload);
      await Promise.all(
        payload.updates.map(async (update) => {
          if (update.type === "js-update") {
            return hmrClient.queueUpdate(update);
          }
          const { path, timestamp } = update;
          const searchUrl = cleanUrl(path);
          const el = Array.from(
            document.querySelectorAll("link")
          ).find(
            (e) => !outdatedLinkTags.has(e) && cleanUrl(e.href).includes(searchUrl)
          );
          if (!el) {
            return;
          }
          const newPath = `${base}${searchUrl.slice(1)}${searchUrl.includes("?") ? "&" : "?"}t=${timestamp}`;
          return new Promise((resolve) => {
            const newLinkTag = el.cloneNode();
            newLinkTag.href = new URL(newPath, el.href).href;
            const removeOldEl = () => {
              el.remove();
              console.debug(`[vite] css hot updated: ${searchUrl}`);
              resolve();
            };
            newLinkTag.addEventListener("load", removeOldEl);
            newLinkTag.addEventListener("error", removeOldEl);
            outdatedLinkTags.add(el);
            el.after(newLinkTag);
          });
        })
      );
      break;
// 생략 //

 

payload로 전달받은 데이터 중 updates 배열을 순회합니다. 이때 update 객체에는 path, timestamp라는 프로퍼티가 존재하는데, 이 프로퍼티는 Vite 개발 서버로부터 전달받은 변경된 파일명과 timestamp 값 입니다. 해당 값들을 활용해 newPath를 만들고 해당 path로 새로운 모듈을 요청합니다.

 

조금 더 자세하게 살펴보겠습니다. JavaScript 코드를 수정할 시 update.type 은 'js-update' 입니다. 이 경우 hmrClient.queueUpdate 메서드에 update 객체를 넘겨주며 핸들링 함수가 종료됩니다. queueUpdate 메서드를 살펴보겠습니다.

  async queueUpdate(payload) {
    this.updateQueue.push(this.fetchUpdate(payload));
    if (!this.pendingUpdateQueue) {
      this.pendingUpdateQueue = true;
      await Promise.resolve();
      this.pendingUpdateQueue = false;
      const loading = [...this.updateQueue];
      this.updateQueue = [];
      (await Promise.all(loading)).forEach((fn) => fn && fn());
    }
  }

 

hmrClient 객체의 updateQueue 프로퍼티에 fetchUpdate 메서드를 실행시켜 값을 밀어넣고 있습니다. fetchUpdate 메서드를 확인합니다.

  async fetchUpdate(update) {
    const { path, acceptedPath, firstInvalidatedBy } = update;
    
    // 생략
    if (isSelfUpdate || qualifiedCallbacks.length > 0) {
      const disposer = this.disposeMap.get(acceptedPath);
      if (disposer) await disposer(this.dataMap.get(acceptedPath));
      try {
        // importUpdateModule을 확인
        fetchedModule = await this.importUpdatedModule(update);
      } catch (e) {
        this.warnFailedUpdate(e, acceptedPath);
      }
    }
    
    // 생략
  }

 

파라미터로 전달되는 update는 커넥션에서 전달받은 메세지 객체 입니다. 여기서 fetchedModule이라는 값에 집중할 필요가 있고 해당 변수에 값을 할당하는 메서드를 한번 더 살펴보겠습니다.

 

importUpdatedModule 메서드는 hmrClient 객체가 생성될 때 파라미터로 전달받는 함수 입니다. 해당 함수는 파라미터로 전달받아 객체의 메서드로 등록됩니다.

  async function importUpdatedModule({
    acceptedPath,
    timestamp,
    explicitImportRequired,
    isWithinCircularImport
  }) {
    const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`);
    const importPromise = import(
      /* @vite-ignore */
      base + acceptedPathWithoutQuery.slice(1) + `?${explicitImportRequired ? "import&" : ""}t=${timestamp}${query ? `&${query}` : ""}`
    );
    if (isWithinCircularImport) {
      importPromise.catch(() => {
        console.info(
          `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`
        );
        pageReload();
      });
    }
    return await importPromise;
  }

 

메서드를 살펴보면 importPromise에 url로 t=timestamp 값이 들어가는 것을 확인할 수 있습니다.

이렇게 만들어진 importPromise는 fetchedModule로, fetchedModule 값은 qualifiedCallbacks을 순회하며 각 객체의 deps로 변형이 되어 해당 객체 속 fn의 인자로 전달됩니다.

 

지금은 t=timestamp의 값이 어디서 왔는지에 포커스를 두고 있어 더 자세하게 살펴보지는 않겠습니다. 만약 그 이후의 Vite의 HMR이 어떻게 동작하는지 궁금하시다면 아래 블로그 글을 참고해주세요.

Vite 프로젝트에서 리액트 컴포넌트는 어떻게 HMR될까? (소스코드 뜯어보기)

 

 

Vite 프로젝트에서 리액트 컴포넌트는 어떻게 HMR될까? (소스코드 뜯어보기)

vite + react 프로젝트에서 HMR이 수행되는 과정을 쫓아가 봐요

velog.io

 

Vite 개발 서버가 코드가 수정되면 script 파일을 timestamp를 붙여서 다시 생성한다는 점과 Web Socket을 통해 클라이언트에 timestamp와 변경된 스크립트 파일의 경로를 보내준다는 점, 브라우저의 캐싱 정책을 우회하기 위해 t 쿼리 파라미터를 활용한다는 점을 고려하면 Vite의 HMR이 어떻게 동작하는지 감이 잡힙니다.