Vite 플러그인 직접 만들어 보기

Vite 플러그인 직접 만들어 보기

Vite로 프로젝트를 만들다 보면 @vitejs/plugin-reactvite-plugin-svgr 같은 플러그인을 한두 개쯤은 꼭 설치하게 되는데요. vite.config.jsplugins 배열에 함수 하나 넣었을 뿐인데 JSX가 변환되고, SVG가 컴포넌트로 바뀌고, 환경 변수가 주입됩니다. 도대체 이 플러그인이라는 게 안에서 무슨 일을 하길래 빌드 과정에 이렇게 자연스럽게 끼어들 수 있는 걸까요? 🤔

사실 Vite 플러그인은 생각보다 훨씬 단순한 구조로 되어 있습니다. 객체 하나에 약속된 이름의 함수 몇 개를 넣어주면 그게 곧 플러그인이거든요. 이번 글에서는 Vite 플러그인이 정확히 무엇인지, 왜 필요한지부터 짚어 보고, 코드를 변환하고 가상 모듈을 만들고 개발 서버를 확장하는 플러그인을 직접 만들어 보겠습니다.

Vite 플러그인이란 무엇일까요?

Vite 플러그인은 한마디로 빌드 파이프라인의 특정 시점에 끼어들어 동작을 추가하거나 바꾸는 객체입니다. 번들러가 모듈을 찾고, 읽고, 변환하고, 묶는 일련의 과정에는 정해진 단계들이 있는데요. 플러그인은 이 단계마다 마련된 “훅(hook)“이라는 진입점에 자신의 코드를 등록해 둡니다. 그러면 Vite가 해당 단계에 도달할 때마다 등록된 함수를 대신 호출해 줍니다.

여기서 한 가지 알아두면 좋은 사실이 있습니다. Vite의 플러그인 시스템은 Rollup의 플러그인 인터페이스를 그대로 확장한 것입니다. 그래서 resolveId, load, transform 같은 핵심 훅은 Rollup과 이름도 동작도 똑같습니다. 덕분에 잘 만든 Rollup 플러그인은 대부분 Vite에서도 그대로 동작하고, 우리가 배운 지식도 두 도구에서 함께 써먹을 수 있습니다. 여기에 Vite는 개발 서버를 다루는 configureServer 같은 자기만의 훅을 몇 개 더 얹은 것뿐입니다.

참고로 Vite는 프로덕션 빌드 번들러를 Rust로 작성된 차세대 번들러 Rolldown으로 옮겨가는 작업을 진행하고 있습니다. 그래도 걱정할 필요는 없는데요. Rolldown은 처음부터 Rollup 플러그인 API와 호환되도록 설계되었기 때문에, 이 글에서 다루는 훅들은 그대로 통합니다. 즉 우리는 여전히 “Rollup 스타일”의 플러그인을 작성하는 셈입니다.

플러그인의 가장 기본적인 형태는 다음과 같습니다.

vite.config.js
function myPlugin() {
  return {
    name: "my-plugin", // 필수: 디버깅과 에러 메시지에 사용됩니다
    // 여기에 훅 함수들을 추가합니다
  };
}

name 속성을 가진 객체를 반환하는 함수, 이게 전부입니다. 보통 함수로 감싸는 이유는 나중에 인자를 받아 동작을 바꾸는 옵션을 넘기기 위해서인데요. 이 패턴은 뒤에서 실제로 활용해보겠습니다.

왜 직접 만들어야 할까요?

이미 수많은 플러그인이 npm에 공개되어 있는데 굳이 직접 만들 필요가 있을까 싶을 수 있습니다. 하지만 프로젝트를 하다 보면 기성 플러그인으로는 딱 들어맞지 않는 자잘한 요구사항이 생기기 마련입니다.

예를 들어 빌드 시각이나 Git 커밋 해시를 코드에 박아 넣고 싶을 때, 특정 확장자의 파일을 우리 회사만의 규칙으로 변환하고 싶을 때, 개발 서버에 간단한 목(mock) API 엔드포인트를 붙이고 싶을 때가 그렇죠. 이런 작업은 플러그인 하나면 깔끔하게 해결되는데, 핵심 원리만 알면 10~20줄로 충분합니다.

플러그인의 원리를 이해해 두면 남이 만든 플러그인이 안 돌아갈 때 원인을 추적하기도 훨씬 수월해집니다. 그럼 이제 실제로 동작하는 플러그인을 하나씩 만들어 보겠습니다.

먼저 실습할 프로젝트를 준비합니다.

mkdir vite-plugin-demo && cd vite-plugin-demo
bun add -d vite

transform 훅으로 코드 변환하기

가장 자주 쓰이는 훅은 단연 transform입니다. 이름 그대로 모듈의 소스 코드가 메모리에 올라온 직후, 번들에 묶이기 전에 코드를 가로채서 바꿀 수 있는 훅인데요. JSX를 일반 JavaScript로 바꾸거나, 특정 문자열을 치환하는 작업이 모두 이 훅에서 일어납니다.

빌드할 때 소스 코드의 __APP_VERSION__이라는 표시를 실제 버전 문자열로 바꿔주는 플러그인을 만들어 보겠습니다.

main.js
console.log("현재 버전은 __APP_VERSION__ 입니다");
vite.config.js
import { defineConfig } from "vite";

function versionPlugin(version) {
  return {
    name: "inject-version",
    transform(code, id) {
      // id는 변환 중인 파일의 절대 경로입니다
      if (!id.endsWith(".js")) return; // JS 파일만 처리
      if (!code.includes("__APP_VERSION__")) return; // 대상이 없으면 건너뜀
      // 변환한 코드를 반환하면 Vite가 이 결과로 교체합니다
      return code.replaceAll("__APP_VERSION__", JSON.stringify(version));
    },
  };
}

export default defineConfig({
  plugins: [versionPlugin("1.2.3")],
});

transform 훅은 모듈의 소스 코드 code와 파일 경로 id를 인자로 받습니다. 여기서 중요한 점은 바꿀 필요가 없으면 아무것도 반환하지 말아야 한다는 것입니다. undefined를 반환하면 Vite는 “이 플러그인은 이 모듈을 건드리지 않았구나”라고 판단하고 원본을 그대로 둡니다. 그래서 위 코드처럼 대상 파일이 아니거나 치환할 문자열이 없으면 일찍 return으로 빠져나오는 패턴을 자주 씁니다.

이제 빌드해서 결과를 확인해보겠습니다.

bunx vite build

빌드된 JavaScript 파일을 열어 보면 __APP_VERSION__이 실제 값으로 바뀌어 있습니다.

dist/assets/index-*.js (일부)
console.log('현재 버전은 "1.2.3" 입니다');

소스 코드는 그대로인데 번들 결과물에만 버전이 박혀 들어갔습니다. 이렇게 빌드 타임에 값을 주입하는 방식은 Webpack의 DefinePlugin이 하던 일과 정확히 같은 역할인데, Vite에서는 이렇게 직접 만들 수도 있는 것이죠.

가상 모듈로 데이터 주입하기

transform이 기존 파일의 내용을 바꾸는 훅이라면, 가상 모듈(virtual module)은 디스크에 존재하지 않는 파일을 마치 있는 것처럼 만들어 내는 기법입니다. 빌드 시각, 설정값, 환경 정보처럼 코드로 생성해야 하는 데이터를 모듈처럼 import해서 쓰고 싶을 때 안성맞춤인데요.

가상 모듈을 만들려면 두 개의 훅이 짝을 이룹니다. 우선 resolveId 훅에서 우리가 정한 가상 모듈 이름의 요청을 가로채고, 그다음 load 훅에서 그 모듈의 실제 내용을 만들어 반환합니다.

vite.config.js
import { defineConfig } from "vite";

function buildInfoPlugin() {
  const virtualId = "virtual:build-info";
  // 관례상 \0 접두사를 붙여 다른 플러그인이 건드리지 않도록 표시합니다
  const resolvedId = "\0" + virtualId;

  return {
    name: "build-info",
    resolveId(id) {
      // 우리 가상 모듈을 import하면 내부 식별자로 해석해 줍니다
      if (id === virtualId) return resolvedId;
    },
    load(id) {
      // 그 식별자의 실제 내용을 코드 문자열로 만들어 반환합니다
      if (id === resolvedId) {
        return `export const buildTime = ${JSON.stringify(new Date().toISOString())}`;
      }
    },
  };
}

export default defineConfig({
  plugins: [buildInfoPlugin()],
});

resolveId는 어떤 모듈을 import했을 때 “그 모듈이 실제로 어디에 있는지” 알려주는 훅입니다. 보통은 Vite가 알아서 파일 경로를 찾아주지만, virtual:build-info처럼 디스크에 없는 이름은 우리가 직접 가로채서 처리하는 것이죠. 여기서 반환하는 \0(널 문자) 접두사는 Rollup 생태계의 관례입니다. 이 표시가 붙은 식별자는 다른 플러그인이나 도구가 “실제 파일이 아니다”라고 인식해서 건드리지 않습니다.

이제 가상 모듈을 일반 모듈처럼 가져다 쓸 수 있습니다.

main.js
import { buildTime } from "virtual:build-info";

console.log("빌드 시각:", buildTime);

빌드해보면 4개 모듈이 변환되며 정상적으로 묶입니다.

결과
vite v6.3.5 building for production...
 4 modules transformed.
dist/index.html                0.16 kB gzip: 0.14 kB
dist/assets/index-C4ksieqe.js  0.78 kB gzip: 0.47 kB
 built in 29ms

디스크에 virtual:build-info라는 파일이 없는데도 import가 동작하고, 빌드된 번들 안에는 빌드 당시의 시각이 그대로 박혀 들어갑니다.

개발 서버 확장하기

여기까지는 Rollup과 공유하는 훅이었습니다. 이번에는 Vite만의 훅인 configureServer를 살펴보겠습니다. (앞서 다룬 resolveId, load, transform은 모두 Rollup과 공유하는 범용 훅입니다.) 이 훅은 개발 서버(vite dev)가 시작될 때 호출되며, 서버에 미들웨어를 추가하거나 동작을 바꿀 수 있게 해줍니다.

Vite 개발 서버는 내부적으로 Connect 스타일의 미들웨어 체인을 사용하는데요. 들어오는 모든 요청을 로그로 남기는 간단한 플러그인을 만들어 보겠습니다.

vite.config.js
import { defineConfig } from "vite";

function logRequestPlugin() {
  return {
    name: "log-request",
    apply: "serve", // 개발 서버에서만 적용 (뒤에서 설명합니다)
    configureServer(server) {
      // server.middlewares에 미들웨어를 등록합니다
      server.middlewares.use((req, _res, next) => {
        console.log(`[요청] ${req.method} ${req.url}`);
        next(); // 다음 미들웨어로 넘겨야 요청이 정상 처리됩니다
      });
    },
  };
}

export default defineConfig({ plugins: [logRequestPlugin()] });

개발 서버를 띄우고 브라우저로 접속하면 터미널에 요청 로그가 찍힙니다.

결과
  VITE v6.3.5  ready in 86 ms
  Local:   http://localhost:5173/
[요청] GET /
[요청] GET /main.js

미들웨어에서 next()를 호출하지 않으면 요청이 거기서 멈춰 버리니 잊지 말아야 합니다. 이 패턴을 응용하면 개발 중에만 동작하는 목 API를 붙이거나, 특정 경로의 요청을 가로채 직접 응답하는 일도 가능합니다.

apply와 enforce로 실행 시점 제어하기

방금 예제에서 apply: "serve"를 슬쩍 넣었는데요. 요청 로그는 개발할 때나 필요하지 프로덕션 빌드에는 의미가 없으니, 이 플러그인이 개발 서버에서만 동작하도록 제한한 것입니다. apply 옵션에는 "serve"(개발 서버)나 "build"(프로덕션 빌드)를 지정할 수 있고, 더 세밀한 조건이 필요하면 함수를 넘길 수도 있습니다.

플러그인이 여러 개일 때는 실행 순서도 신경 써야 합니다. Vite는 내부적으로 자기 플러그인들을 돌리는데, 우리 플러그인을 그보다 먼저 또는 나중에 실행시키고 싶을 때 enforce 옵션을 씁니다.

  • enforce: "pre" — Vite 코어 플러그인보다 먼저 실행됩니다. 원본에 가까운 코드를 다뤄야 할 때 사용합니다.
  • (지정 안 함) — Vite 코어 플러그인과 함께 일반 순서로 실행됩니다.
  • enforce: "post" — 모든 변환이 끝난 뒤에 실행됩니다. 최종 결과물을 다뤄야 할 때 사용합니다.

예를 들어 JSX를 다루는 플러그인이라면 다른 변환이 일어나기 전 원본 JSX를 봐야 하므로 enforce: "pre"를 쓰는 식입니다.

function myPlugin() {
  return {
    name: "my-plugin",
    enforce: "pre", // 다른 플러그인보다 먼저
    apply: "build", // 프로덕션 빌드에서만
    transform(code, id) {
      // ...
    },
  };
}

자주 쓰는 훅 정리

지금까지 다룬 것 외에도 Vite 플러그인에는 다양한 훅이 있습니다. 크게 보면 Rollup과 공유하는 범용 훅과 Vite 전용 훅으로 나뉘는데, 자주 쓰는 것들만 정리하면 다음과 같습니다.

  • resolveIdimport 요청을 받아 모듈의 실제 위치를 결정합니다. 가상 모듈이나 경로 별칭에 사용합니다.
  • load — 식별자에 해당하는 모듈의 내용을 불러옵니다. 가상 모듈의 본문을 만들 때 사용합니다.
  • transform — 불러온 모듈의 소스 코드를 변환합니다. 가장 자주 쓰는 훅입니다.
  • config — Vite 설정이 확정되기 전에 설정 객체를 수정합니다.
  • configResolved — 최종 확정된 설정을 읽습니다. 개발/빌드 모드를 구분할 때 유용합니다.
  • configureServer — 개발 서버에 미들웨어 등을 추가합니다. Vite 전용 훅입니다.
  • transformIndexHtmlindex.html을 변환합니다. 메타 태그나 스크립트 주입에 사용합니다.

훅의 반환값으로 undefined를 주면 “처리하지 않음”을 의미한다는 원칙은 대부분의 훅에 공통으로 적용되니 기억해 두면 좋습니다.

마치며

지금까지 Vite 플러그인이 무엇이고 왜 필요한지부터, 코드를 변환하는 transform, 가상 모듈을 만드는 resolveIdload, 개발 서버를 확장하는 configureServer까지 직접 만들어 보았습니다. 핵심은 플러그인이 결국 약속된 이름의 훅 함수를 담은 객체일 뿐이라는 점, 그리고 그 인터페이스의 뿌리가 Rollup에 있다는 점입니다. 이 두 가지만 이해하면 처음 보는 플러그인의 코드도 어렵지 않게 읽어 낼 수 있습니다.

여기서 한 걸음 더 나아가고 싶다면, 만든 플러그인을 별도 패키지로 분리해 npm에 공개하거나, 여러 플러그인을 묶어 하나의 프리셋으로 제공하는 방법을 살펴보면 좋습니다. Vite를 비롯한 프론트엔드 빌드 도구 전반이 궁금하다면 Vite 관련 글을 참고하세요.

플러그인 API의 전체 훅 목록과 각 훅의 상세한 동작은 Vite 공식 플러그인 API 문서에 잘 정리되어 있으니 함께 보시길 권합니다.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord