템플릿, CSS import 같은 비표준 JavaScript 확장은 많은 프로젝트에서 필수적이지만, 오늘날에는 각 도구가 이를 위해 별도의 플러그인을 요구한다. 공통 처리 파이프라인(가상 파일 시스템)을 통해 린터·포매터·타입체커·테스트 러너·런타임이 동일한 플러그인 파이프라인을 공유하자는 제안이다.
📖 tl;dr: 템플릿, CSS import, 그리고 JavaScript에 대한 기타 비표준 확장들은 많은 프로젝트에서 매우 중요하다. 그럼에도 불구하고, 각각의 도구는 이를 이해시키기 위해 자체 플러그인을 요구한다. 만약 우리가 공통의 처리 파이프라인을 가질 수 있다면 어떨까?
최근 나는 도구를 더 간단히 사용할 수 있는 방법을 찾는 데 마음이 쏠려 있었다. 오늘날의 도구들은 꽤나 파편화된 아키텍처를 보인다. 프런트엔드 레이어는 커스텀 템플릿 언어, CSS import, JSX, 순수 JavaScript 등 여러 언어를 한꺼번에 다뤄야 한다. 린터, 포매터, 타입 체커, 테스트 러너, 개발 서버 등 각 도구는 이런 워크플로를 이해시키기 위해 보통 자신만의 고유한 플러그인 시스템과 전처리 로직을 요구한다.
이는 프레임워크를 혁신하고 현존하는 툴체인과 통합하는 일을 어렵게 만든다. 예를 들어 .svelte 파일을 보자. 린터가 이를 이해하게 하려면 어떻게 해야 할까? 타입 체크는 또 어떨까? 모든 것이 각각의 플러그인을 필요로 한다. 하지만 왜 그래야 할까?
이 모든 플러그인의 공통점은 입력 문자열을 받아 유효한 JavaScript로 다시 돌려준다는 것이다. Svelte, Vue 같은 커스텀 언어에서 타입 체크를 작동시키려면, 코드를 TypeScript가 이해할 수 있도록 트랜스파일한다. 템플릿 부분은 JSX로, 나머지는 표준 JS로 변환된다. JS와 JSX는 둘 다 TypeScript로 타입 체크할 수 있다. 타입 체크 오류가 발생하면 소스맵을 확인해 사용자 코드로 매핑되는지 본다. 매핑되면 보고하고, 아니면 무시한다.
`<script lang="ts"> export let name: string; let count: number = 0; function handleClick() { count += 1; } </script>
<div> <h1>Hello, {name}!</h1> <p>You have clicked {count} times.</p> <button on:click="{handleClick}">Click me</button> </div> <style> div { border: 1px solid blue; padding: 10px; } </style>` LSP로 전송되는 내용의 의사 예시(실제와는 다르지만 같은 아이디어):import { SvelteComponent, init, safe_not_equal } from "svelte/internal"; const __svelte_script = (() => { let name: string; let count: number = 0; function handleClick() { count += 1; } return { props: { name }, data: { count }, methods: { handleClick }, }; })(); function render() { const { name, count, handleClick } = __svelte_script; return ( <div> <h1>Hello, {name}!</h1> <p>You have clicked {count} times.</p> <button onClick={handleClick}>Click me</button> </div> ); }
TypeScript는 이를 완벽하게 타입 체크할 수 있다.
타입 체커나 린터에 넘기기 전에 파일을 수정할 수 있는 세상을 상상해보자. 모두가 접속할 수 있는 일종의 가상 파일 시스템 같은 것 말이다. 런타임도 이것을 이용해 코드를 직접 실행할 수 있다. 다른 어떤 스크립트를 실행하듯 코드를 실행할 수 있을 것이다:
이 가상 파일 시스템의 핵심은 여러 도구가 공유하는 처리 파이프라인이며, 전형적인 번들러 API와 많은 공통점이 있다. 곰곰이 생각해보면, resolveId()와 load() + transform()은 파일 시스템 연산에 깔끔하게 대응된다.
| 번들러 API | 파일 시스템 API |
|---|---|
resolveId | 심볼릭 링크 따라가기 |
load (+ transform) | readFile |
resolveId 함수는 이 가상 파일 시스템 안에서 심볼릭 링크를 해석하는 것으로 재해석될 수 있다. 이를 통해 파일의 동적 매핑과 별칭(aliased) 처리가 가능해진다. 마찬가지로 코드가 읽히고 수정되는 load와 transform 단계는 readFile 호출로 생각할 수 있다.

많은 도구들이 일부를 Rust나 Go 같은 다른 언어로 옮겨가고 있다. 이제 우리는 여러 소비 측 언어를 고려해야 하는 상황에 놓였다. 모두가 IPC를 통해 동일한 처리 파이프라인과 대화하도록 하면 이 문제를 해결할 수 있다.
이런 것이 언제, 혹은 과연 현실화될지조차 확신할 수 없다. 그럼에도 나는 이를 환영한다. 새로운 템플릿 언어나 다른 DSL을 JS 생태계에서 실험하는 일이 훨씬 쉬워질 것이기 때문이다. 어떤 테스트 러너, 린터, 포매터를 쓰든 상관없이, 내 전처리 파이프라인을 함께 가져갈 수 있기를 바란다.