Vite는 ESM을 어떻게 활용했을까?

10tacion·2025년 4월 16일
15

FE

목록 보기
2/2
post-thumbnail

Vite는 ESM을 어떻게 활용했을까?

요즘 프론트엔드 프로젝트를 할 때면 기본적으로 Vite를 자주 활용한다. 웹팩을 사용하는 CRA와 비교했을 때 개발환경에서 변경된 코드를 반영하는 속도가 매우 빠른데, 이 중심에는 바로 Native ESM (ES Modules) 이 있다


📦 ESM 등장 전까지: 자바스크립트 모듈의 과도기

처음 자바스크립트가 브라우저에 도입됐을 때는 모듈 개념이 없었다. 모든 스크립트는 전역 스코프에서 실행되었고, 충돌과 유지보수 문제가 빈번했다. 이를 해결하기 위해 커뮤니티에서 다음과 같은 "비표준 모듈 시스템"들이 등장했다:

1. IIFE (즉시 실행 함수 표현식)

var MyModule = (function () {
  var privateValue = 42;
  return {
    getValue: function () { return privateValue; }
  };
})();
  • 전역 변수 오염 방지를 위해 쓰였지만, 모듈 간 의존성 관리 불가능

2. AMD (Asynchronous Module Definition)

define(['dep1', 'dep2'], function (dep1, dep2) {
  return function () { /* ... */ };
});
  • 비동기 로딩 지원. 주로 RequireJS에서 사용
  • 문법이 복잡하고 디버깅이 어려움

3. CommonJS (2009년 Node.js 도입, 사실상 표준화)

const fs = require('fs');
module.exports = { readFile: fs.readFile };
  • Node.js 전용. 서버에서는 좋지만 브라우저에서는 동작하지 않음
  • 브라우저에서는 Webpack 등으로 번들링이 필수

➡️ 이들은 모두 정식 ECMAScript 표준이 아니었고, 브라우저와 서버 환경 간의 불일치를 초래했다. 이를 해결하기 위해 등장한 것이 바로 ES Modules (ESM) 이다.


✅ ES Modules (ESM)이란?

ESM은 ECMAScript 2015 (ES6)에서 정식으로 도입된 자바스크립트의 공식 모듈 시스템이다. import와 export 키워드를 사용해서 모듈 간 기능을 주고받을 수 있다.

// math.js
export function sum(a, b) {
  return a + b;
}

// main.js
import { sum } from './math.js';
console.log(sum(2, 3));

주요 특징

  • 모듈은 정적 분석 가능 (import 구문이 파일 최상단에 존재)
  • import 대상이 명확하고, 모듈 간 의존성 추적이 쉬움
  • 브라우저는 각 모듈을 별도 HTTP 요청으로 가져옴
  • 모듈은 비동기적으로 실행됨 (렌더링 차단 없음)
  • 모듈 캐싱은 브라우저가 관리함

왜 브라우저는 모듈을 비동기로 로딩해야 할까?
브라우저는 네트워크를 통해 JS 파일을 가져와야 한다. 이때 동기 방식으로 하나의 모듈을 모두 로딩할 때까지 기다리면, 전체 렌더링이 차단되고 사용자 경험이 나빠진다. ESM은 이를 방지하기 위해 비동기 로딩과 병렬 요청을 기본으로 한다. 덕분에 브라우저는 HTML을 계속 파싱하면서 모듈들을 동시에 요청할 수 있다.

💡 type='module'을 통해 브라우저는 해당 코드를 ESM 방식으로 실행

<!-- index.html -->
<script type="module">
  import('./module-a.js').then(mod => {
    mod.hello();
  });
</script>
// module-a.js
export function hello() {
  console.log('예시');
}

⚡ Vite는 ESM을 어떻게 활용했나?

Vite의 핵심 전략은 "브라우저가 할 수 있는 일은 브라우저에게 맡기자"이다.

Vite는 개발 환경에서 다음과 같은 방식으로 Native ESM을 활용한다.

1. 브라우저가 모듈을 직접 가져감

//index.html
<script type="module" src="/main.js"></script>

// main.js
import { sum } from './math.js';
console.log(sum(2, 3));

// math.js
export function sum(a, b) {
  return a + b;
}
  • 브라우저는 main.js를 로딩하고 import 구문을 분석해 math.js를 별도 요청
  • Vite는 math.js를 요청받으면 즉석에서 변환하고, 그대로 브라우저에 전달

2. HTTP 기반 모듈 처리 (URL 단위)

  • 브라우저는 import './math.js'를 만나면 실제로 GET /math.js 요청을 보냄
  • Vite는 이 요청을 받고, 필요한 경우 .ts, .vue 파일을 JS로 변환한 뒤 ESM 형식으로 응답

3. 사전 번들 없이 import 그대로 유지

  • Webpack은 모든 import를 번들링 시점에 해석하고 하나의 파일로 묶음
  • Vite는 import 구문을 그대로 유지한 채 브라우저에게 전달함

4. HMR도 ESM으로 처리

Vite는 ESM의 구조를 활용해 모듈 단위의 Hot Module Replacement(HMR)를 구현한다. 파일이 변경되었을 때, Vite는 해당 모듈의 URL에 강제로 ?t=<timestamp> 쿼리 문자열을 추가하여 브라우저의 캐시를 무효화하고, 해당 모듈을 다시 불러오도록 유도한다.

예: 변경 전 URL

http://localhost:5173/counter.js

예: 변경 후 URL (HMR 발생 시)

http://localhost:5173/counter.js?t=1713789000000

브라우저는 ?t=<timestamp>가 붙은 모듈 URL을 이전과는 다른 새로운 리소스로 인식하고, 이를 다시 네트워크를 통해 요청하게 된다. 이때 Vite는 변경된 모듈을 다시 불러오고, 해당 모듈에 대해 import.meta.hot.accept()가 등록되어 있는 경우, 그 안에 정의된 콜백 함수를 호출해 새 모듈을 애플리케이션에 반영한다.

이 흐름이 가능하도록, Vite는 개발 중 HMR 대상 모듈에 import.meta.hot 코드를 자동으로 주입한다.
이 객체는 HMR 시스템과의 연결 고리 역할을 하며, 다음과 같은 방식으로 동작한다

  1. Vite는 각 모듈을 분석하고, HMR 대상이라면 해당 모듈 코드에 import.meta.hot 코드를 자동으로 주입한다.
  2. Vite 서버는 파일 변경을 감지하면, 클라이언트에 WebSocket을 통해 변경 알림을 전송한다.
  3. 클라이언트는 해당 모듈을 import()로 다시 불러오되, ?t=... 쿼리로 브라우저 캐시를 우회한다.
  4. import.meta.hot.accept()가 등록되어 있으면, 콜백에 새로운 모듈 객체가 전달된다.
  5. 이 콜백에서 변경된 함수나 컴포넌트를 다시 실행하면, 전체 리로드 없이 상태를 유지한 채 UI 또는 로직이 갱신된다.

🔍 import.meta.hot브라우저의 표준 기능이 아니며, Vite가 개발 중 동적으로 삽입하는 객체다. 이는 브라우저에서 type="module" 스크립트로 실행되더라도 기본적으로 존재하지 않으며, Vite 개발 서버 환경에서만 작동한다.

이 과정에서 기존에 유지되던 상태(state)나 실행 중이던 애플리케이션 전체는 그대로 유지되며, 변경된 모듈만 교체된다. 따라서 UI를 자동으로 갱신하려면, 이 콜백 내부에서 새로 불러온 모듈의 함수를 다시 실행하거나 필요한 로직을 명시적으로 작성해야 한다.

하지만 React, Vue, Svelte 등 주요 프레임워크를 사용하는 경우, Vite와 해당 프레임워크의 플러그인이 협력하여 HMR 처리를 자동화한다. 예를 들어 React 프로젝트에서는 @vitejs/plugin-react가 React Fast Refresh를 통해 다음과 같은 코드를 내부적으로 설정한다:

예제 코드 (Vite 개발 환경에서 내부적으로 어떻게 처리하는지)

// counter.js
export function counter() {
  document.body.innerHTML = `카운트: <span id="num">0</span>`;
  let num = 0;
  setInterval(() => {
    document.getElementById('num').textContent = ++num;
  }, 1000);
}
// main.js
import { counter } from './counter.js';
counter();

// HMR 처리 (Vite에서 자동으로 hot 객체를 주입)
if (import.meta.hot) {
  import.meta.hot.accept('./counter.js', (mod) => {
    // 새로 불러온 모듈의 counter 함수 실행
    mod.counter();
  });
}

위 코드에서 counter.js가 수정되면 Vite는 이 파일의 변경을 감지하고, 새로운 timestamp가 붙은 URL로 해당 모듈을 다시 요청한다. 그리고 accept()의 콜백을 호출하면서 새로 불러온 모듈을 넘겨주므로, 그 안에서 원하는 방식으로 UI를 업데이트할 수 있다.

이렇게 Vite는 ESM의 구조를 그대로 활용하면서도, 개발 환경에서는 import.meta.hot와 URL 캐시 무효화를 통해 빠른 개발 피드백을 제공한다. 전체 페이지 리로드 없이도 특정 모듈만 교체되며, 상태도 유지된다.

직접 관찰해보기

아무 Vite 프로젝트에서 개발환경에서 vite 서버를 실행하자.
그리고 특정 컴포넌트의 코드를 변경한 후 저장을 하면 네트워크탭에는 아래와 같이 ?t=<timestamp> 쿼리 문자열을 추가한 것을 확인 할 수 있다.

🔄 Native ESM vs Webpack 번들링 방식

항목Vite (ESM 기반)Webpack (번들 기반)
모듈 해석브라우저가 ESM import를 직접 해석Webpack이 import/require를 정적으로 분석하여 번들 생성
요청 방식개발 서버가 모듈 단위로 응답, 브라우저가 개별 요청전체 모듈을 하나 이상의 JS 파일로 번들링
초기 로딩서버 시작 즉시 사용 가능, 필요한 모듈만 동적으로 로딩서버 시작 전 전체 애플리케이션을 번들링
HMR변경된 모듈만 빠르게 갱신 (import.meta.hot)변경된 모듈을 기준으로 전체 의존성 그래프를 다시 계산하여 교체 또는 리로드
캐시모듈 단위로 브라우저 캐시 적용번들 단위로 캐시. 일부 모듈 변경 시 전체 번들 재생성

🔍 Webpack의 HMR은 개발 서버가 변경된 파일을 감지하고, 변경된 모듈만 교체 가능한지 판단 후 내부적으로 "accept" 체인을 따라 업데이트를 수행한다. 만약 불가능하다면 전체 페이지를 reload한다. 설정과 구현이 상대적으로 복잡하다.

➡️ Webpack은 hmr이 동작 할 때 모듈 의존성을 추적해서 변경된 모듈과 관련된 전체 번들을 다시 컴파일해야 하는 경우가 많다. 반면 Vite는 개발 환경에서 브라우저가 ESM import를 활용해 필요한 모듈만 그때그때 요청하도록 허용하며, Dev 서버는 이를 빠르게 on-demand 응답하는 방식이다.


정리

  • ESM은 자바스크립트의 공식 모듈 시스템으로, import/export를 사용해 모듈을 정적으로 정의하고 비동기 로딩을 가능하게 한다.
  • Vite는 ESM의 동작 원리를 그대로 활용하여, 개발 시 번들 없이 빠른 서버 시작, 실시간 HMR, 빠른 피드백을 제공한다.
  • 반면 Webpack은 사전 번들링 과정에서 애플리케이션의 모든 의존성을 사전에 정적으로 분석하고 하나 이상의 번들로 미리 묶어두는 방식

ESM를 활용해서 개발자 경험 개선, 마이크로 프론트엔드 설계에도 활용해볼 수 있겠다.

참고 링크

https://vite.dev/guide/api-hmr
https://developer.mozilla.org/ko/docs/Web/JavaScript/Guide/Modules

profile
늦게 자고 일찍 일어나기

0개의 댓글