Vite 플러그인 알아보기

January 24, 2024 ˑ 24min read

thumbnail

개요

Vite는 개발 서버의 동작이나 빌드 과정을 커스텀하게 구성할 수 있는 플러그인 API를 제공합니다. 플러그인을 통해 원하는 동작을 구성할 수 있다는 점은 Vite의 확장성과 생태계를 더욱 풍성하게 만들어주는 중요한 역할을 하고 있는데요. Awesome Vite에 등록되어 있는 인기있는 플러그인을 살펴보면 Vite에서 제공하는 공식 플러그인 이외에 커뮤니티 플러그인도 많이 사용된다는 것을 알 수 있습니다.

이번 글에서는 Vite 플러그인은 어떤 형태로 구성되는지 알아보려고 합니다.

Rollup 플러그인

Vite 플러그인 문서에 따르면 Vite 플러그인은 Rollup의 플러그인 인터페이스를 기반으로 한다고 설명합니다. 그래서 Vite 플러그인을 알아보기 전에 Rollup 플러그인에 대해 먼저 알아보았습니다.

Rollup 플러그인은 객체로 구성되며 플러그인의 이름과 빌드 시점에 따라 호출되는 훅(Hooks)으로 구성됩니다.

rollup-plugin-example.js
import type { Plugin } from 'rollup'; interface Options { ... } const ExamplePlugin = (options: Options): Plugin => { return { name: 'rollup-plugin-example', resolveId(source) { ... }, load(id) { ... }, transform(code, id) { ... } }; }
Tip

플러그인 모듈은 플러그인 객체을 반환하는 팩토리 함수로 구성한 뒤 인자로 플러그인의 설정을 받을 수 있도록 하여, 사용자로 하여금 플러그인의 동작을 설정할 수 있도록 하는것이 관례입니다.

Tip

플러그인 훅의 인자는 훅 마다 다르기 때문에 인자로 어떤 값이 넘어오는지 확인이 필요합니다. TypeScript로 플러그인을 작성한다면 rollup 모듈에서 Plugin 타입을 불러와 사용하면 훅의 인자를 추론해주기 때문에 편리하게 작성할 수 있습니다.

훅(Hooks)

Rollup 플러그인의 훅은 “동기/비동기”, “순차 실행/병렬 실행”과 같이 빌드 과정에서 훅이 실행되는 특징에 따라 1차로 구분됩니다.

  • sync/asyncPromise를 반환하는 경우 async, 그렇지 않으면 sync 훅으로 처리.
  • first여러 플러그인이 first 훅을 구현하는 경우, 훅이 null이나 undefined가 아닌 값을 반환할 때 까지 순차적으로 실행.
  • sequential여러 플러그인이 sequential 훅을 구현하는 경우, 모든 훅은 등록된 순서대로 실행. 만약, async 훅이 있는 경우 훅이 반환한 Promiseresolve 될 때 까지 기다림.
  • parallel여러 플러그인이 parallel 훅을 구현하는 경우, 모든 훅은 등록된 순서대로 실행. 만약, async 훅이 있는 경우 이후 순서의 훅은 병렬로 실행되며 해당 훅을 기다리지 않음.

일반적으로 훅은 함수 형태이지만, 실행 순서를 조정하거나 건너 뛰는 등 추가적인 옵션을 제공하기 위해 객체 형태로 구성할 수도 있습니다. 객체 형태로 구성되는 경우 handler 함수를 필수적으로 등록해야합니다. 객체 형태일 때 구성할 수 있는 옵션은 아래와 같습니다.

  • order ("pre" | "post" | null)여러 플러그인이 같은 종류의 훅을 등록할 경우 순서를 조정. "pre", "post"도 여러 개인 경우 등록된 순서를 따름.
  • sequential (boolean)parallel 훅에서 사용되며, 훅이 동시에 등록되는 경우 병렬로 실행되지 않도록 설정.
  • filter훅을 조건에 일치하는 경우에만 실행하고자 할때 설정.
rollup-plugin-build-hooks.ts
import type { Plugin } from 'rollup'; const BuildHooksPlugin = (): Plugin => { return { name: 'rollup-plugin-build-hooks', writeBundle: { sequential: true, order: 'post', handler() { ... } } }; }

Rollup은 빌드 과정을 빌드 단계(Build Phase)출력 생성 단계(Output Generation Phase)로 구성하고 있는데요. 각 단계에 따라 실행되는 훅이 나뉘게 됩니다.

빌드 훅(Build Hooks)

빌드 단계는 rollup.rollup 함수를 통해 진행됩니다. rollup.rollup함수는 엔트리 파일로부터 import문을 재귀적으로 탐색하여 전체 코드의 모듈 그래프를 생성하고 트리 쉐이킹을 진행합니다. 하지만 이 단계에서는 빌드 결과물은 출력하지 않습니다.

rollup.rollup 함수의 반환값으로는 출력 생성 단계를 실행할 수 있는 bundle 객체를 반환합니다. 빌드 훅은 Rollup의 빌드 단계에서 실행되는 훅으로, 모듈을 찾고 로드하고 파싱하는 등 모듈 그래프를 구성하는 과정에 개입할 수 있습니다. 실행 순서에 따라 예시를 들면 다음과 같습니다.

  • options빌드 단계의 첫번 째 훅으로 설정을 읽거나 수정.
  • buildStart빌드 프로세스가 시작됨. 최종적으로 해석된 설정을 읽을 때 사용.
  • resolveIdimport문을 만나면 해당 모듈의 위치를 찾음. (이 훅부터는 각 엔트리 파일별로 재귀적으로 반복됨)
  • load모듈 파일의 내용을 불러옴.
  • transform불러온 코드 내용을 필요에 따라 변환.
  • moduleParsed모듈의 JavaScript 코드를 AST로 파싱이 완료됨.
  • buildEnd빌드 프로세스가 종료됨.

빌드 단계는 엔트리 파일별로 모듈을 탐색하여 모듈 그래프를 생성하는 것에 목적이 있습니다. 따라서 코드를 어느 단위의 청크로 나눌지, 어떤 포맷으로 변환할지, 압축(Minify)할지에 대한 결정은 이루어 지지 않습니다. 이와 같은 동작은 다음 단계인 출력 생성(Output Generation)단계에서 이루어 집니다.

출력 생성 훅(Output Generation Hooks)

이 단계는 빌드 단계에서 생성된 모듈 그래프(Module Graph)와 사용자가 지정한 옵션(Output Options)을 이용하여 최종적으로 번들 파일을 생성하는 과정에 해당합니다. 출력 생성 단계에서는 아래 과정이 진행됩니다.

  • 모듈 방식(CommonJS, ESM 등)에 따른 코드 변환
  • 코드 스플리팅 설정에 따라 코드를 분리하여 청크 파일 생성
  • 코드 압축(Minify)
  • CSS나 이미지 파일같은 정적 파일 처리
  • 디버깅을 위한 소스맵 생성

출력 생성 단계는 빌드 단계에서 생성된 bundle 객체의 generate, write 메서드를 통해 진행되며 두 메서드는 다음과 같은 차이가 있습니다.

  • bundle.generate메모리 상에 출력 결과물을 생성합니다. 동일한 인풋을 이용하여 여러 번 호출할 수 있습니다.
  • bundle.write최종적으로 생성될 출력 결과물을 파일로 저장합니다. 한 번만 호출할 수 있습니다.

출력 생성 단계가 실행되면 Rollup은 진행 단계에 따라 출력 생성 훅을 실행합니다.

  • outputOptions출력 생성 옵션을 확정지을 때 실행.
  • renderStartbundle.generate 또는 bundle.write가 호출되어 청크 파일을 렌더링 하기 전에 한번 실행. 빌드 전체에 걸쳐 공유되어야 하는 코드를 초기화할 때 사용.
  • renderDynamicImport코드내 동적 임포트를 제어할 때 사용.
  • resolveFileUrl번들링 과정에서 정적 파일(CSS나 이미지 등)의 최종 URL을 결정하는 방식을 수정할 때 사용. (정적 파일의 경로를 변경하여 CDN으로부터 가져오도록 한다거나..)
  • resolveImportMetaimport.meta의 속성에 접근할 때 동적으로 값을 결정하거나 주입하기 위해 사용. (환경 변수나 피쳐 플래그를 넣어주는 등 여러가지로 응용 가능)
  • banner / footer / intro / outro생성될 청크의 시작과 끝에 문자열을 추가하는데 사용.
    • banner / footer는 출력 결과물의 맨위, 맨아래 삽입되어 주석을 통해 모듈 정보를 추가
    • intro / outro 는 모듈이 실행되는 스코프(IIFE 같은 경우 함수 내부)에서 실제 실행할 코드를 삽입하는데 사용
  • renderChunk개별 청크를 수정할 수 있는 마지막 기회로, 각 청크가 디스크에 쓰이기 직전에 실행되며. (generateBundle 훅보다 먼저 실행됨)
  • augmentChunkHash각 청크의 해시값을 계산할 때 파일 내옹 이외의 추가적인 정보를 부여할때 사용.
  • generateBundle모든 청크와 에셋이 생성된 후 최종 번들 파일들이 디스크에 쓰이기 직전에 한번 호출. 모든 출력 결과물에 접근하여 내용을 수정하거나 새로운 파일을 추가하는 등 많은 작업이 가능함.
  • writeBundlebundle.write 메서드를 실행하여 모든 번들 파일이 디스크에 성공적으로 쓰여진 후 호출.
  • closeBundle번들 생성이 완료되거나 에러가 발생하거나 bundle.close 메서드를 통해 임의로 종료되었을 때 호출. 클린업을 위해 사용.
Note

Rollup 문서에서는 훅이 실행되는 순서를 직관적으로 확인할 수 있도록 그래프를 제공하고 있습니다. 위에서 정리한 설명과 함께 참고하면 좋을 것 같습니다.

플러그인 컨텍스트(Plugin Context)

또한 Rollup은 훅 내부에서 this 키워드를 통해 참조할 수 있는 유틸리티 객체와 함수를 제공합니다. 일반적으로 사용되는 유틸리티 몇가지에 대해 알아보았습니다.

  • this.meta플러그인 인스턴스별로 빌드 과정동안 데이터를 저장하고 공유하기 위해 제공되는 객체. 빌드 과정에서 플러그인의 훅간에 상태를 유지하거나 정보를 공유하는데 사용.
  • this.parse(code)주어진 코드 문자열을 AST(Abstract Syntax Tree)로 파싱. (Rollup은 내부적으로 AST 파서로 Acorn을 사용함.)
  • this.resolve(source, importer, { skipSelf? })Rollup의 모듈 해석(resolution) 로직을 프로그래밍 방식으로 실행. resolveId, load훅에서 import 대상이 실제 어떤 파일로 해석되는지 미리 확인해야할 때 사용.
  • this.load({ id, resolveDependencies? })주어진 모듈 ID에 해당하는 모듈의 내용을 로드하고, 필요한 경우 transform 훅까지 적용된 결과를 가져옴. resolveDependenciestrue로 설정하면 해당 모듈의 의존성까지 함께 해석함.
  • this.getModuleInfo(moduleId)모듈 그래프에 포함된 모듈의 상세 정보를 가져옴. 상세 정보에는 원본 코드, 변환된 코드, 의존성 목록, 이 모듈을 참조하는 모듈 목록, AST, 사이드 이펙트 유무 등이 포함됨.
  • this.moduleIds모듈 그래프에 포함된 모든 모듈 ID들을 순회할 수 있는 Iterable 객체.
  • this.addWatchFile(filePath)Rollup을 watch 모드(-w, —watch)로 실행 중일 떄, 지정된 파일을 감시 대상에 추가함. (환경변수 파일(.env)을 감시하여 변경되는 경우 다시 빌드한다던지 응용 가능)
  • this.emitFile(file)빌드 결과물로 추가적인 파일을 생성할 때 사용. file 객체에는 type(‘asset’ 또는 ‘chunk’), source(내용), fileName 등을 지정할 수 있음.
  • this.setAssetSource(referenceId, source)this.emitFile을 사용하여 type: 'asset'으로 생성된 에셋 파일의 내용을 설정하거나 업데이트함.
  • this.getFileName(referenceId)this.emitFile을 사용하여 생성된 파일의 최종 출력 파일명을 가져옴. (Rollup의 output.chunkFileNames, output.assetFileNames 설정이 적용된 실제 파일명)
  • this.warn(message, position?)빌드 과정 중에 경고 메세지 출력
  • this.error(message, position?)빌드 과정 중에 오류 메세지 출력. 또한 빌드를 중단 시킴.

Rollup 플러그인의 구조와 내부에서 사용할 수 있는 유틸리티까지 알아보았는데요. 두 단계로 나뉘는 빌드 과정과 시점에 따라 실행되는 훅을 통해 내부에서 어떤 동작이 일어나는지 간접적으로 알아볼 수 있었습니다.

Vite는 내부적으로 Rollup을 빌드 타임에 사용하고 있습니다. Vite가 개발 서버를 실행할 때에도 필요한 동작을 수행 할 수 있도록 플러그인이 구성되려면 무언가 추가적인 부분이 있어야 할 것 같네요. 아래에서는 Vite 플러그인에서만 사용되는 전용 기능이 있는지 알아보았습니다.

Vite 플러그인

Vite 플러그인은 Rollup 플러그인의 인터페이스를 사용하며 Rollup 플러그인의 구성을 확장하는 슈퍼셋(Superset)입니다. 인터페이스를 확장한 개념으로, Vite 플러그인에 구성된 Rollup 플러그인의 빌드 훅은 실제 빌드 시점 뿐만 아니라 개발 서버를 실행하는 과정에서 실행될 수 있습니다.

Caution

다만 Vite는 효율적인 동작을 위해 코드에 대한 AST 생성을 진행하지 않기 때문에, moduleParsed 훅은 개발 서버에서 호출되지 않습니다.

개발 서버에서 실행되는 Rollup 훅

Vite의 개발 서버는 Rollup을 직접 사용하지 않지만 몇가지 Rollup 플러그인 훅을 통해 필요한 작업을 실행할 수 있습니다.

개발 서버 실행

브라우저에서 모듈 요청시

모듈 요청시 실행되는 훅은 Vite의 SSR 기능을 판단하기 위해 마지막 인자로 options: { ssr? } 객체를 추가적으로 전달합니다.

개발 서버 종료

Vite 전용 플러그인 훅

아래는 Vite를 위해서 구성되는 플러그인 훅으로 Rollup의 실행 과정에서는 무시됩니다.

  • configVite 설정이 결정되기 전에 변경할 수 있는 기능 제공.
  • configResolvedVite 설정이 확정된 후 호출되어 최종적인 설정을 확인할 때 사용.
  • configureServer개발 서버를 실행하기 전에 Vite가 개발 서버로 사용하는 connect의 인스턴스에 접근하기 위해 사용. (미들웨어 추가 등)
  • configurePreviewServerconfigureServer와 동일하지만 빌드 별과물을 호스팅할 때 사용되는 vite preview에 대해 실행됨.
  • transformIndexHtml진입점이 되는 index.html을 변환하기 위해 사용. 개발 서버가 실행되는 경우 인자로 서버 인스턴스도 전달됨.
  • handleHotUpdate - HMR(Hot Module Replacement)과정에서 사용자 지정 동작을 실행하기 위해 사용. 인자로 받은 ViteDevServer 인스턴스의 웹소켓에 접근하여 커스텀 메세지 전송 가능.

플러그인 실행 순서

Vite 플러그인은 순서를 조정할 수 있는 enforce('pre' | 'post' | undefined) 속성을 갖습니다. 이 속성은 Rollup 플러그인 훅의 order 속성과 비슷하지만 플러그인 단위의 순서를 조정하는데 사용됩니다. enforce 속성에 따라 실행되는 순서는 아래와 같습니다.

  • 'pre' — Vite 코어 플러그인 이전에 실행
  • undefined — Vite 코어 플러그인 이후에 실행
  • 'post' — Vite의 빌드 플러그인 이후에 실행

만약 enforce 속성이 같은 플러그인이 있고 같은 훅을 구성하고 있을 경우, 플러그인 훅의 order 속성을 통해 세부적인 실행 순서가 조정됩니다.

클라이언트-서버 통신

Vite 플러그인의 configureServer훅에서는 서버 인스턴스를 인자로 전달하는데요. 이 서버 인스턴스를 통해 웹소켓을 참조하면 개발 서버와 연결된 클라이언트(브라우저)와 메세지를 송수신할 수 있습니다.

vite.config.ts
export default defineConfig({ plugins: [ { configureServer(server) { server.ws.on('example:message', () => { ... }) server.ws.send('example:message', { msg: 'hello' }) } } ] })

클라이언트 측에서는 import.meta.hot 객체를 통해 제공되는 on, send를 통해 메세지를 송수신할 수 있습니다.

client.ts
// 메세지 수신 import.meta.hot.on('example:message', (data) => { console.log(data.msg) // hello }) // 메세지 송신 import.meta.hot.send('example:message', { msg: 'Hey!' })

클라이언트와 개발 서버에서 소통해야하는 플러그인을 작성한다면 클라이언트 측 메세지 핸들러를 작성을 고민해야 하는데요. 플러그인을 사용하는 개발자로 하여금 직접 작성하도록 문서화를 하는 방법도 있지만 가상 모듈(Virtual Module)을 사용하면 어느정도 캡슐화된 방법을 제공할 수 있습니다.

가상 모듈(Virtual Module)

가상 모듈은 Rollup의 플러그인 시스템에서 사용되는 개념으로 Vite 플러그인에서도 자연스럽게 제공됩니다.

가상 모듈은 말 그대로 파일 시스템에 없는 모듈로 플러그인이 실행되는 과정에서 메모리상에서 동적으로 생성하고 내용을 정의하는 모듈을 말합니다. 어플리케이션 코드에서는 일반 모듈을 불러오는 것과 동일하게 import문을 사용하지만 대상 모듈에 대한 경로를 결정(resolve)하고 로드하는 과정에서 플러그인 훅이 개입하여 모듈의 내용을 작성하게 됩니다.

이러한 특징을 이용하면, 환경 별로 코드를 분기해서 처리하거나 복잡한 로직을 플러그인애서 추상화하여 필요한 부분만 모듈로 구성하는 등 일반 모듈로 처리할 수 없는 이점을 갖을 수 있습니다.

Note

가상 모듈의 이름은 "virtual:"을 접두어로 사용하는 것이 관례입니다. 플러그인 별로 처리하고자하는 가상 모듈을 구분할 수 있도록 플러그인 명을 추가하여 사용합니다.

가상 모듈은 두 개의 플러그인 훅을 통해 구성할 수 있습니다.

  1. resolveId
  • resolveId 훅은 import 문을 통해 특정 모듈을 가져오려고 할 때 호출됩니다.
  • 플러그인은 해당 모듈이 자신이 처리해야할 가상 모듈인지 식별합니다.
  • 만약 자신이 처리해야하는 가상 모듈인 경우 모듈 이름 앞에 "\0"(null 바이트)를 붙혀 다른 플러그인에서 처리되지 않도록 마킹합니다. (관례적으로 사용됨)
  • 일치하지 않으면 null이나 undefined를 반환하여 다른 플러그인이나 기본 로직이 실행될 수 있도록 합니다.
  1. load
  • resolvedId 훅에서 ID를 반환한 경우 load 훅이 실행되어 ID에 해당하는 실제 코드를 반환하여 코드를 불러오게 됩니다.
  • 인자로 전달받은 ID가 자신이 처리해야하는 가상 모듈에 해당하는 경우 필요한 코드를 동적으로 생성하여 반환합니다.
  • 만약 처리해야하는 모듈이 아닌 경우 null이나 undefined를 반환합니다.

코드로는 간단히 살펴보면 아래와 같은 형태로 구성할 수 있습니다.

vite-plugin-example.ts
const ExamplePlugin = (options: Options): Plugin => { const virtualModuleId = "virtual:example"; const resolvedVirtualModuleId = `\0${virtualModuleId}`; return { name: 'vite-plugin-example', resolveId(source) { if (source === virtualModuleId) { return resolvedVirtualModuleId; } }, load(id) { if (id === resolvedVirtualModuleId) { const dynamicData = ... return ` export const data = ${dynamicData}; ` } } }; }
client.ts
import { data } from 'virtual:example' console.log(data) // dynamicData의 내용 출력

마치며

이번 글에서는 Vite 플러그인이 어떻게 구성되고 동작하는지 알아보기 위해 Rollup 플러그인부터 차례대로 알아보았습니다. 조사하는 과정에서 Rollup과 Vite의 내부 동작에 대해 더 자세하게 알게된 것 같아 뿌듯한 기분이 드네요. 직접 필요한 플러그인을 구성해야할 때 도움이 되었으면 좋겠습니다. 감사합니다. :)

Related Articles

Github

Linkedin

Instagram