회사에서 vue-cli로 만들어진 프로젝트를 Vite로 마이그레이션을 진행 중이다. vue-cli는 2년 전부터 maintenance mode(deprecated)에 들어갔고, webpack을 기반으로 만들어졌기 때문에 번들링 및 빌드 할 때 무척 느리다. vue-cli 패키지 내부에는 eslint, prettier가 vue-cli에 맞게 묶여있었는데 최신 버전의 eslint, prettier 버전을 사용하고 싶어도 호환이 되지 않았다. 그래서 vue-cli 대신 번들러를 바꿔 개발 환경을 최신화하기로 했다.

예전부터 사용해 보고 싶었던 번들러는 Vite다. Vite의 가장 큰 특징은 devServer에서 소스코드와 디펜던시를 두 가지 카테고리로 나누어 빌드하고, ESM 환경인 브라우저에 빠르게 제공한다는 점이다. devServer가 cold-start 될 때 현재 페이지에서 필요한 정보만 아주 빠르게 실행시킬 수 있다. 이 말은, 코드 베이스 또는 라이브러리(사용하고 있는 node_modules 디펜던시)의 모든 파일을 번들링 하여 제공하는 것이 아니라, 현재 필요한 모듈만 골라내어 빠르게 제공한다는 뜻이다. Vite 가이드에서 가장 먼저 이야기하고 있는 것이 ESM을 통한 devServer start 속도이다.

사전 번들링은, esBuild를 통해 디펜던시를 빌드하고 변경이 잦은 소스코드는 ESM으로 제공한다. 기존에 Webpack과 같은 번들러는 모든 파일을 빌드 및 번들링 해야만 브라우저에서 구동이 가능했다. Vite는 소스코드를 ESM으로 제공하기 때문에 브라우저에서 동적 import를 통해 원하는 JavaScript 파일만 실행시킬 수 있다. 이 매커니즘에 따라 Webpack의 Node.js에서만 가능했던 기능이 브라우저에서 동작하기 위해 어떻게 Vite가 해결했는지를 알아볼 것이다.


Node.js 환경 변수

process.env

대부분의 bundler는 Node.js 환경 위에서 동작한다. Node.js에서는 process 라는 특별한 이름의 예약어를 사용해 환경 변수에 접근할 수 있다. process 예약어는 현재 실행되고 있는 Node.js 프로세스에 대한 정보를 가지고 있고 이를 제어할 수 있는 객체다.

대부분의 JavaScript 프레임워크에서는 process.env 변수를 통해 환경 변수에 접근한다. process.env는 런타임에 동적으로 변수를 추가 및 수정할 수 있다.

console.log(process.env.TEST); // undefined
process.env.TEST = "test!";
console.log(process.env.TEST); // test!

위 코드를 Node.js의 node 명령어로 실행하면 주석과 같은 결과를 얻을 수 있다. 프레임워크에서는 .env 파일을 통해 process.env 변수에 개발자가 설정한 변수를 process.env에 주입시켜주는데, 주로 dotenv 라이브러리를 통해 주입한다.

dotenv

모던 JavaScript 프레임워크에서 dotenv 라는 라이브러리를 내부적으로 래핑해서 사용하고 있다. dotenv 라이브러리는 node.js 환경 및 ESM 환경에서 .env 파일에 있는 환경 변수를 런타임 환경의 JavaScript로 들고 올 수 있다.

라이브러리를 가볍게 분석해 보자.

$ npm init -y
$ npm i -D dotenv

명령어로 npm 환경을 생성한다. package.json 파일이 생성된 후, dotenv 라이브러리를 다운로드 받았다.

프로젝트 root에 .env 파일을 생성하고 테스트 값을 넣는다.

# .env file
TEST=ENV_TEST!

main.js 파일을 생성하고, dotenv 라이브러리를 통해 .env 파일의 환경 변수 값을 가져와보자.

// src/main.js

require("dotenv").config();
console.log(process.env.TEST);  // ENV_TEST!

이제 node 명령어로 Node.js 환경에서 이를 실행시켜보자.

$ node src/main.js

=> ENV_TEST!

어떻게 이런 결과가 나올까? dotenv 라이브러리를 살짝 까보자. 시작은 require("dotenv").config(); 줄이다. config 메서드를 실행한다.

function config (options) {
  if (_dotenvKey(options).length === 0) {
    return DotenvModule.configDotenv(options);
  }
  // ...
}

configDotenv() 함수를 실행한다. lib/main.js의 configDotenv 함수. .env 파일의 경로를 읽고 파싱 해 process 환경을 파싱 된 값으로 채우고 반환한다.

function configDotenv (options) {
  let dotenvPath = path.resolve(process.cwd(), '.env');
  // ...
  const parsed = DotenvModule.parse(fs.readFileSync(dotenvPath, { encoding }));

  let processEnv = process.env;
  if (options && options.processEnv != null) {
    processEnv = options.processEnv;
  }

  DotenvModule.populate(processEnv, parsed, options);
  return { parsed };
}
  1. dotenvPath 변수에 .env 파일의 경로를 저장한다.

    • process.cwd() : node 명령을 호출한 디렉토리의 절대 경로다. /Users/pozafly/Documents/dev/play-ground/dotenv-exam
    • path.resolve() : 경로를 묶어 새로운 경로를 반환한다. /Users/pozafly/Documents/dev/play-ground/dotenv-exam/.env
  2. parsed 변수에 .env 파일을 파싱 해 담는다.

    • fs.readFileSync() : 인자로 들어오는 절대 경로로 파일 text 값을 읽어온다. 따라서, dotenvPath의 값을 읽어온다.

    • DotenvModule.parse() : 파싱 한 text 값을 JavaScript object로 만들어서 return 한다.

    • parsed: { TEST: 'ENV_TEST!' } 값이 담겨있다.

  3. populate(processEnv, parsed, options) 실행한다. populate는 ‘덧붙이다’라는 뜻을 가지고 있다.

function populate (processEnv, parsed, options = {}) {
  // Set process.env
  for (const key of Object.keys(parsed)) {
    // ...
    processEnv[key] = parsed[key];
  }
}

위에서 processEnv = process.env 로 레퍼런스 주소를 묶어주었기 때문에 key로 즉시 할당한다. 다시 우리가 작성했던 main.js 구문을 보자.

require('dotenv').config();
console.log(process.env.TEST);  // ENV_TEST!

정리하면, dotenv 라이브러리는 Node.js 환경에서 .env 를 파싱 해 text 값을 통해 객체를 생성하고, process.env에 값을 추가해 Node.js 환경의 전역에 노출된다. JavaScript 프레임워크에서는 보통 Node.js를 한 번만 실행하기 때문에 .env 파일의 환경 변수 값이 변경되면 devServer를 껐다 켜주어야 값을 변경된 값으로 반영할 수 있는 이유이다.

Vite는 바로 반영이 되는데 devServer를 순간적으로 다시 시작시켜준다. .env 파일에서 변수를 변경하면 터미널 log에 server restarted.라고 알려준다. Vite는 Webpack처럼 모든 파일을 번들링 하지 않기 때문에 무척 빠르기에 가능하다.

dotenv에서 ESM 환경 또한 지원한다.

// package.json
{
  "type": "module",
}

위와 같이 package.json 파일에 type을 module로 넣어주면 Node.js에서 ESM 코드를 사용할 수 있다.

※ Vite로 마이그레이션을 진행할 때도, package.json에 위와 같이 "type": "module" 을 넣어주어야 하는데 Vite는 기본적으로 ESM 방식으로 동작한다.

참고로, main.js 파일을, main.mjs 라는 확장자를 붙여주면 Node.js에서 알아서 ESM으로 동작하게 되기 때문에 package.json에 따로 붙여줄 필요는 없다.

vue-cli는 @vue/cli-services 패키지 내에서 사용하고 있고, CRA 같은 경우 react-scripts 라이브러리 내부 package.json에 dotenv 을 사용하고 있으며 Next.js도 내부적으로 dotenv 라이브러리를 이용해 .env 파일을 가져오고 있다.

—env-file

node.js 20 버전부터는 --env-file=config 를 통해 .env 파일을 dotenv 라이브러리 없이 사용할 수 있도록 개발 중이다. 실제로 터미널에서 node 명령어로 .env 파일을 지정 해 줄 수 있다. 아직은 실험용.

$ node --env-file=.env --env-file=.development.env index.js
// .env
PORT=3000

따라서, 이제는 점차 dotenv 라이브러리가 필요없어지게 될 것 같다.


모드 별 환경 변수 접근

하지만, .env 파일만 있는 것이 아니라 배포 환경에 따라 환경 변수의 값이 달라져야 환경 변수를 사용하는 의미가 있다. 즉, .env.development, .env.production 등의 환경 변수 파일이 추가로 필요하고 mode에 따라 지정된 값이 process.env 값에 할당되어 build 되어야 한다.

dotenv-webpack 플러그인은 아주 간단하게 직접 path를 지정하도록 되어 있다.

먼저 사용법을 보자.

// webpack.config.js

const Dotenv = require('dotenv-webpack');

module.exports = {
  plugins: [
    new Dotenv({ path: '.env.production' }),
  ],
};

webpack 설정 파일에 new Dotenv() 를 통해 인스턴스를 만든다. 인자로 path key를 가진 객체를 넣어주는데, 빌드 시 적용할 .env.production 파일의 경로를 적어준다. 그럼, dotenv-webpack 플러그인을 보자. dotenv-webpack 소스

import dotenv from 'dotenv-defaults';

class Dotenv {
  // 1.
  constructor (config = {}) {
    this.config = Object.assign({}, {
      path: './.env',
      prefix: 'process.env.'
    }, config);
  }
  
  // 3.
  getEnvs () {
    const { path } = this.config;
    const env = dotenv.parse(this.loadFile({
      file: path,
    }));
    return {
      env
    };
  }
  
  gatherVariables () {
    const { env } = this.getEnvs(); // 2.
    const vars = Object.assign({}, process.env); // 4.

    Object.keys(env).forEach(key => {
      const value = Object.prototype.hasOwnProperty.call(vars, key) ? vars[key] : env[key];
      vars[key] = value; // 5.
    })

    return vars;
  }
}

간추린 코드만 들고 왔다.

  1. 인스턴스를 생성할 때, 넣어준 path를 생성자에서 설정한다.
  2. getEnvs() 메서드 실행.
  3. config에 담긴 path를 가져와 .env* 파일을 가져오고, dotenv 라이브러리에 parse 메서드를 사용해 env 객체로 만들어 return.
  4. vars 에 새로운 객체에다가 process.env 값을 복사한다.
  5. vars[key] = value 를 통해 파싱 한 값을 집어넣어 return.

즉, dotenv-webpack은 단순히 .env 값을 path로 받아 파싱 한다.


Vite의 환경 변수 접근

Vite는 환경 변수가 정의된 파일(.env)에서 여타 번들러와 마찬가지로 어플리케이션에 필요한 변수를 가져와 사용할 수 있다. Vite의 경우 공식 문서에서 환경 변수 접근법에 대해 import.meta라는 JavaScript 문법으로 접근할 것을 알려주고 있다. .env 파일에 환경과 관련된 변수를 VITE_ prefix를 주고, 코드 상에서 import.meta.env 로 선언한 값을 들고 올 수 있다.

import.meta

import.meta라는 JavaScript 문법을 알아보며 MDN 문서에 아직 아무도 번역하지 않아 이번 기회에 함께 번역해 보았다. import.meta 는 ES Module이 탄생하면서 모듈에 대한 메타 데이터(부수 데이터)를 저장하기 위한 객체다.

여기서 말하는 메타 데이터란, 이미지 파일을 생각하면 조금 더 이해하기 쉽다. 휴대폰으로 사진을 찍으면 사진의 메타 데이터에 ‘날짜’, ‘사진 용량’, ‘디바이스 정보’, ‘위치 데이터’ 등의 메타 데이터가 담기게 된다. import.meta 는 ‘모듈’ 자체에 대한 메타 데이터를 담고 있다.

import.meta는 대표적으로 모듈 메타 데이터인 경로(URL)를 담고 있는데 이는 호스트 환경(브라우저 혹은 Node.js)에 따라 저장되는 내용이 달라진다. 먼저 Node.js를 통해 import.meta가 가지고 있는 정보를 열어보자.

// src/main.js

console.log(import.meta);

node 명령어로 main.js를 실행시키면 SyntaxError가 발생한다. 이유는 node.js는 기본적으로 CommonJS 환경에서 동작하기 때문이다. import.meta는 ESM 환경에서 동작하기 때문에 이런 에러가 발생하는 것이다.

Node.js에서 import.meta를 사용하는 방법은 2가지인데, 하나는 main.js 파일을 main.mjs 로 ESM에서 동작하는 JavaScript 확장자를 붙여주어 node 명령어로 다시 실행시키는 방법이고, 다른 하나는 dotenv 라이브러리에서 ESM 환경으로 동작시킬 때 알아본 package.json 파일에 "type": "module" 을 명시해 주는 방법이다.

// src/main.js

console.log(import.meta);
/**
 * [Object: null prototype] {
 *   dirname: '/Users/pozafly/Documents/dev/play-ground/esm-exam/src',
 *   filename: '/Users/pozafly/Documents/dev/play-ground/esm-exam/src/main.js',
 *   resolve: [Function: resolve],
 *   url: 'file:///Users/pozafly/Documents/dev/play-ground/esm-exam/src/main.js'
 * }
*/

위와 같은 결과를 얻었다. dirname, filename, resolve, url 등의 정보가 들어있다. 절대 경로로 내 파일(모듈)의 위치를 가리키고 있다. 그렇다면 브라우저에서는 어떻게 동작하는가?

간단하게 lite-server를 설치하고 index.html 파일을 브라우저로 서빙해보자.

$ npm i -D lite-server
// package.json
{
  // ...
  "type": "module",
  "scripts": {
    "dev": "lite-server"
  },
  "devDependencies": {
    "lite-server": "^2.6.1"
  }
}

dev 스크립트를 입력해 주고, index.html 파일을 만들어 npm run dev 명령어를 실행한다.

<!-- index.html -->
<!DOCTYPE html>
<html>
  <head>
    <script type="module" src="./src/main.js"></script>  </head>
  <body></body>
</html>

browser-import.meta

resolve와, url 정보가 들어있다. Node.js 환경과는 조금 다르다. dirname, filename이 없다. 그리고 Node.js 환경에서의 url 은 모듈(파일)의 절대 경로가 들어가 있고, 브라우저 환경에서의 url 은 http server가 서빙하고 있는 주소 자체를 담고 있다.

여기서 재미있는 것을 하나 더 해볼 수 있는데, 쿼리 파라미터로 원하는 값을 넣어줄 수 있다.

<script type="module" src="./src/main.js?info=5"></script>

이렇게, 쿼리 파라미터 info라는 key에 value를 5를 넣었다. 다시 실행해 보면, main.js가 모듈로서 여전히 호출되고

import.meta.info

이렇게 url의 모습이 변경된 것을 볼 수 있다. 그러면 이 url 변수를 가공해 info 값을 얻을 수가 있는데,

// src/main.js

const info = new URL(import.meta.url).searchParams.get('info');
console.log(info); // 5

이런 식으로 모듈끼리의 정보도 주고받을 수 있다. 이는 Node.js 환경에서도 마찬가지이다.

// some.js

export function callImportMeta() {
  return new URL(import.meta.url).searchParams.get('id');
}
// main.js

import { callImportMeta } from './some.js?id=pozafly';
console.log(callImportMeta()); // pozafly

메타 정보를 쿼리 파라미터로 주고받았다.

node.js와 browser 모두 meta 정보에 접근할 수 있다. 근데 Vite에서는 왜 dotenv를 쓰는 걸까? env를 단순히 읽어들이기 위해서일까?

Vite로 프로젝트를 실행하고 Chrome devTools의 source 탭에 App.tsx가 트랜스파일링 된 파일을 보면

source-tab

이렇게 되어 있다. env 파일에 정의한 VITE_xxx 변수 및 다른 변수가 브라우저 외부로 노출되어 있다. Vite는 우선 devServer(Node.js 환경)에서 환경 변수를 설정할 뿐 아니라, 위의 쿼리 파라미터를 이용해 다른 기능에도 응용하고 있다. 이 쿼리 파라미터를 어떻게 사용하고 있을까?


HMR

HMR(Hot Module Replacement)는 번들러 환경에서 개발할 때 주로 사용하는데, 소스코드를 변경하면 devServer를 새로 껐다 키지 않고 브라우저를 새로고침 하지 않아도 모듈을 교체해 변경된 부분을 즉시 볼 수 있게 만들어주는 기능이다.

Vite에서는 HMR 기능에 import.meta 를 사용하고 있다. 브라우저에 로드 된 Sources 탭의 소스 맵에 breakpoint를 찍어 보면서 따라가보자.

※ Vite는 컴퓨터에서 파일(소스코드) 변경 감지는 chokidar이라는 라이브러리를 사용하고 있다.

dev-tools-break-point

HTML head 태그에 /@vite/client 를 불러오도록 명시하고 있다. 따라서 진입점은 client.ts 파일이다.

// @vite/client.ts (간추린 버전)

const importMetaUrl = new URL(import.meta.url);const socketProtocol = importMetaUrl.protocol === 'https:' ? 'wss' : 'ws';
const socketHost = `${importMetaUrl.hostname}:${importMetaUrl.port}`;

let socket = setupWebSocket(socketProtocol, socketHost);

function setupWebSocket(protocol, hostAndPath) {
  const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr');
  let isOpened = false;

  // Listen for messages
  socket.addEventListener('message', async ({ data }) => {
    handleMessage(JSON.parse(data));
  });

  return socket;
}

importMetaUrl 은 import.meta.url을 URL 객체화 한 정보를 가지고 있다.

importMetaUrl: URL
  hash: ""
  host: "localhost:5173"
  hostname: "localhost"
  href: "http://localhost:5173/@vite/client"
  origin: "http://localhost:5173"
  password: ""
  pathname: "/@vite/client"
  port: "5173"
  protocol: "http:"
  search: ""
  searchParams: URLSearchParams
    size: 0
  username: ""

위에서 살펴봤던 브라우저에 로드 된 @vite/client 모듈의 정보가 담겨있다. 쿼리 스트링에 담겨있을 searchParams 는 아직은 존재하지 않는다. 다시 소스를 보면, socket 연결을 위한 작업들을 하고 있다. 이 정보를 봤을 때 Vite의 devServer와 브라우저가 소켓 통신을 통해 어떤 정보를 주고받을 준비를 하는 것을 볼 수 있다.

그리고 socket으로 message 이벤트를 감지하고 있는데, 브라우저의 소켓에서 메세지 이벤트를 devServer로부터 전달받으면 handleMessage 함수를 실행하도록 되어있다. 이를 기억하자.

이제 HTML의 body 하단의 <script type="module" src="/src/main.tsx"></script> 코드가 실행되고, 어플리케이션이 실행된다. 렌더링이 종료되어 브라우저에 코드가 실행되고 난 후, 소스코드를 수정하면 재미있는 현상을 볼 수 있다.

t-query-params

바로, 소스코드를 수정한 모듈에 쿼리 파라미터 t 가 새롭게 붙은 파일이 브라우저로 로드되어 새롭게 대체되는 현상이다.

// @vite/client.ts (간추린 버전)

async function handleMessage(payload) {
  switch (payload.type) {
    // ...
    case 'update':
      if (isFirstUpdate) {
        window.location.reload();
        return;
      }
      await Promise.all(
        payload.updates.map(async (update) => {
          if (update.type === 'js-update') {
            return hmrClient.fetchUpdate(update);          }
          // CSS 관련 HMR 소스코드 더 있음.
        })
      );
  }
}

socket을 브라우저에 등록하는 과정에서 message 이벤트가 일어나면 실행할 handleMessage 함수를 등록했다. 바로 그 함수가 실행되는데 JavaScript 함수이므로 hmrClient.fetchUpdate() 함수가 실행된다.

hmrClient는 마찬가지로 Vite가 처음 브라우저로 넘어왔을 때 만들어진 인스턴스인데, 이는 아래와 같다.

// @vite/client.ts (간추린 버전)

const hmrClient = new HMRClient(async function importUpdatedModule({
  acceptedPath,
  timestamp,
  explicitImportRequired,
}) {
  const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`);
  return await import(    base +      acceptedPathWithoutQuery.slice(1) +      `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${query ? `&${query}` : ''}`  );
});

이곳이 핵심이다. await import() 를 사용해 어떤 다른 모듈을 동적으로 불러오는 것을 볼 수 있다. t= 로 시작하는 쿼리 파라미터가 있으며, t에는 timestamp가 존재한다. dev-tools에서 봤던 App.tsx?t=xxx 는 이것이다.

HMRClient class를 보자.

// @vite/hmr.ts (간추린 버전)

export class HMRClient {
  constructor(importUpdatedModule) {
    this.importUpdatedModule = importUpdatedModule;
  }

  public async fetchUpdate(update) {
    const { path, acceptedPath } = update;
    const isSelfUpdate = path === acceptedPath;

    if (isSelfUpdate) {
      await this.importUpdatedModule(update);
    }

    return () => {
      for (const { deps, fn } of qualifiedCallbacks) {
        fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)));
      }
    };
  }
}

importUpdatedModule 을 받는다. 이는 HMRClinet class를 생성할 때 받는 함수 자체를 말하는데, 결국 이게 실행되는 것은, await imort() 동적 모듈을 가져오는 과정이다.

결국 @vite/client.ts 에 있던 socket의 addEventListener에 등록된 handleMessage 에서 넘겨받은 payload 안에 update 객체가 들어있고, update 객체에서는 이것이 js인지 css인지 구분하는 값과, timestamp 값, query 등의 값이 들어있다. 이 정보는 모두 devServer에서 만들어주어 socket으로 브라우저로 전송되어 입력되며 동적 import를 통해 다시 수정된 파일을 devServer에서 다시 브라우저로 전송하는 것이다.

이를 도식화 해보면 아래와 같다.

hmr-process

Vite는 import.meta를 통해 모듈의 정보를 이용해 HMR를 구현했다. url을 통해 모듈의 경로와 메타 데이터를 활용해 socket를 세팅하고, 소스코드 변경이 일어나면 모든 모듈을 교체하는 것이 아니라 변경된 코드가 담긴 모듈만 동적 import를 통해 교체하는 것이다.

App.tsx에서 import.meta.hot 을 콘솔에 찍어보면

import.meta.hot

이런 정보를 가지고 있고, 그중 hotModulesMap 도 들어있다.

hotModulesMap

hotModulesMap은, 현재 소스코드를 Vite가 code splitting을 통해 모듈로 나눠놓은 것을 담고 있다. 이 모듈에서 socket으로부터 받은 payload의 정보를 통해 모듈을 교체하기 때문에 훨씬 효율적으로 모듈을 교체할 수 있는 것이다.

참고로 .env 파일의 환경 변수를 담는 import.meta.env 와 HMR의 모듈 교체를 위한 map을 담고 있는 import.meta.hot 는 브라우저로 전송되는 .tsx 모든 모듈의 상단에서 주입하고 있다. 위에서 봤던 사진을 다시 한번 보자.

source-tab

그래서 소스코드를 수정할 때마다 새로운 timestamp가 쿼리 파라미터가 브라우저에 넘어오고, 쿼리 파라미터 정보를 import.meta로 읽어 새로운 모듈을 가져와야 한다는 정보를 얻어 devServer에 요청 후 갈아끼울 수 있는 매커니즘을 가질 수 있었다.

그렇다면 await import() 동적 가져오기를 사용해 모듈 자체(예시: Some.tsx) 가져왔다면 react에서 적용은 어떻게 하는 것일까? HMRClient class의 fetchUpdate() 함수를 다시 가져왔다.

// @vite/hmr.ts (간추린 버전)

public async fetchUpdate(update) {
  const { path, acceptedPath } = update;
  const isSelfUpdate = path === acceptedPath;

  if (isSelfUpdate) {
    await this.importUpdatedModule(update);
  }

  return () => {
    for (const { deps, fn } of qualifiedCallbacks) {      fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)));
    }
  };
}

qualifiedCallbacks 은 허용된 콜백이라는 뜻인데, 허용된 목록 안에 있는 acceptedPath가 모듈 path(dep) 와 동일하다면 fn을 실행하는 구조로 되어있다. 외부로 노출된 import.meta.hot 을 통해 콜백이 허용되었는지를 검사 후, 모듈을 교체하는 작업이다.

이후의 동작은 Vite 공식 문서의 Hot Module Replacement를 통해 사용하고 있는 프레임워크의 plugin으로 이어져 동작하게 된다.


마이그레이션 결과

vue-cli를 Vite로 마이그레이션 한 결과 devServer 실행 속도뿐 아니라 build 속도도 개선되었다. 아래는 GitHub Actions 빌드 결과이다.

vue-cli 빌드 결과

vue-cli-build

Vite 빌드 결과

vite-build

14s → 5s로 약 62% build 속도가 증가되었다. Vite는 내부적으로 빌드 시 rollup을 사용하기 때문에 Webpack에 비해 더욱 빠른 속도로 빌드 할 수 있다. (devServer는 esbuild로 번들링)


마치며

import.meta는 모듈의 메타 데이터를 저장하고 활용할 수 있는 도구다. 이전에 import.meta가 없었을 때는 Node.js 환경에서 아래 코드와 같이 파일의 경로를 알아내고, 파일을 읽어올 수 있었다.

// Node.js 환경

const fs = require('fs');
const path = require('path');
const bytes = fs.readFileSync(path.resolve(__dirname, 'data.bin'));

또한, 브라우저 환경에서 모듈에 대한 메타 데이터를 얻을 수 있는 방법은 아래와 같았다.

<!-- 브라우저 환경 -->

<script data-option="value" src="library.js"></script>

<script>
  const theOption = document.currentScript.dataset.option;
</script>

하지만, Node.js 환경에서는 fs와 path 라이브러리를 항상 가지고 있어야 하며 브라우저 환경에서는 현재 실행되고 있는 스크립트가 반드시 library.js 모듈일지는 불명확했기 때문에 문제가 되었다. 따라서 TC39에 import.meta에 대한 제안이 올라왔고 구현되었다.

import.meta는 비교적 최신 문법이며 따라서 구형 브라우저에서는 동작하지 않는다. Vite는 import.meta를 사용하기 때문에 자연스럽게 구형 브라우저에서는 사용할 수 없다. 하지만, @vitejs/plugin-legacy를 사용한다면 가능하다.

또한 Vite의 discussions를 보면 process.env 대신 import.meta를 사용하는 이유에 대한 답변으로 ECMAScript 모듈의 ‘새로운’ 표준이기 때문에 사용한다라는 답변이 있다. 물론 import.meta는 ECMAScript의 새로운 표준이기도 하지만 Vite가 개발 모드에서 브라우저에 모듈을 서빙하는 과정을 보면 브라우저 자체가 모듈을 다룰 수 있도록 하고, 빠른 번들링 및 모듈 교체를 위함임을 알 수 있다. 이는 Vite의 철학과도 연관되어 있다.

Vite로 마이그레이션 하면서 process.env대신 import.meta를 사용하는 이유가 궁금했다. 결과, import.meta MDN 문서도 함께 번역해 보면서 번들러가 어떻게 이를 사용하고 있는지 살펴볼 수 있는 시간이었다.

참고