Parcel로 설정 없이 번들링하기: 내부 파이프라인, TypeScript·React·CSS, 환경 변수, 코드 스플리팅
이 글의 핵심
Parcel은 엔트리 HTML부터 의존성 그래프를 수집해 변환·번들링하는 도구입니다. 내부 파이프라인·캐시·프로덕션 빌드에서 자주 막히는 지점을 정리합니다.
이 글의 핵심
Parcel로 Zero Config 번들링을 구현하는 글입니다. 자동 변환, HMR, Code Splitting, Tree Shaking까지 실전 예제로 정리했습니다.
실무에서 마주치는 문제들
Webpack 설정이 어려워요
수백 줄의 설정이 필요합니다. Parcel은 Zero Config입니다.
빠른 프로토타이핑이 필요해요
설정에 시간을 쓰고 싶지 않습니다. Parcel은 즉시 시작합니다.
자동 최적화가 필요해요
수동 설정이 번거롭습니다. Parcel은 자동으로 최적화합니다.
Parcel이란?
핵심 특징
Parcel은 Zero Config 웹 애플리케이션 번들러입니다. 주요 장점:
- Zero Config: 설정 파일 불필요
- 빠름: JS 변환(SWC)·CSS 처리(Lightning CSS)에 Rust 구현 사용
- 자동 변환: Babel, PostCSS 자동
- HMR: 즉시 반영
- Code Splitting: 자동
“Rust 기반”이라는 표현은 절반만 맞습니다. Parcel 2의 코어와 플러그인 시스템은 JavaScript로 작성되어 있고, 가장 무거운 작업인 JavaScript 파싱·변환은 Rust로 만든 SWC가, CSS 파싱·압축·벤더 프리픽스는 역시 Rust로 만든 Lightning CSS가 맡습니다. 여기에 여러 워커 프로세스로 파일을 병렬 처리하고, 한 번 처리한 결과를 .parcel-cache에 저장해 두었다가 바뀌지 않은 파일은 다시 처리하지 않는 캐시가 더해져 빠른 속도가 나옵니다. 그래서 Parcel은 첫 빌드보다 두 번째 빌드부터 차이가 크게 느껴지는 도구입니다.
Parcel 2 파이프라인 개요(내부 관점)
Parcel 2는 엔트리 자산(HTML/JS/…)에서 출발해 의존성 그래프를 만들고, 각 파일에 대해 Transformer가 AST/바이너리를 다음 단계로 넘깁니다. Resolver가 node_modules·별칭·package.json exports를 해석하며, Bundler가 그래프를 청크로 묶습니다. “설정이 없다”는 것은 합리적인 기본 파이프라인이 내장돼 있다는 뜻이며, 커스터마이징은 .parcelrc로 파이프라인에 노드를 끼워 넣는 방식입니다.
캐시: 대규모 프로젝트에서 빌드 속도는 디스크 캐시 히트율에 크게 좌우됩니다. CI에서는 캐시 디렉터리(.parcel-cache)를 키(lockfile+Parcel 버전)와 함께 CI 캐시로 저장했다가 복원합니다. 이 디렉터리는 로컬 개발에서도 수백 MB까지 커질 수 있으므로 반드시 .gitignore에 넣어야 합니다. 캐시가 원인으로 의심되는 이상한 빌드 결과(고친 코드가 반영되지 않거나, 플러그인 설정을 바꿨는데 동작이 그대로인 경우)가 나오면 .parcel-cache를 지우고 다시 빌드하는 것이 가장 먼저 해 볼 일입니다.
설치 및 기본 사용
설치
npm install -D parcel
HTML 파일
<!-- src/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>My App</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
</body>
</html>
package.json
{
"scripts": {
"dev": "parcel src/index.html",
"build": "parcel build src/index.html"
}
}
실행
npm run dev
Parcel의 가장 큰 특징은 HTML 파일을 엔트리로 받는다는 점입니다. Webpack은 JavaScript 엔트리에서 시작하고 HTML은 플러그인으로 따로 만들어야 하지만, Parcel은 index.html을 읽어 <script>, <link>, <img>가 가리키는 파일을 모두 의존성으로 따라가고, 빌드 결과의 해시가 붙은 파일 이름으로 HTML을 다시 써 줍니다. npm run dev는 기본적으로 http://localhost:1234에서 개발 서버와 HMR을 띄우고, parcel build는 dist/에 최적화된 결과를 만듭니다.
<script> 태그에 type="module"을 붙인 것이 중요합니다. import/export를 쓰는 파일을 일반 <script src>로 연결하면 Parcel이 Browser scripts cannot have imports or exports 에러를 냅니다. 브라우저에서 일반 스크립트는 모듈 문법을 쓸 수 없기 때문이며, Parcel은 이 차이를 존중해서 type="module"일 때만 ES 모듈로 취급합니다. package.json에 "main" 필드가 남아 있으면 Parcel이 라이브러리 빌드로 오해해 Unexpected output file type .html in target "main" 같은 에러를 내는 경우도 있는데, 애플리케이션 프로젝트라면 "main"을 지우거나 "source"/"targets"를 명시하면 됩니다.
TypeScript
// src/index.ts
const message: string = 'Hello Parcel!';
console.log(message);
자동으로 TypeScript를 변환합니다. tsconfig.json이 없어도 변환은 되지만, 에디터의 타입 검사와 paths 같은 설정을 위해 두는 것이 일반적입니다.
Parcel은 SWC로 타입 표기를 제거하기만 할 뿐 타입 검사는 하지 않습니다. const message: string = 42;처럼 명백한 타입 에러가 있어도 빌드는 성공하고 브라우저에서 그대로 실행됩니다. 타입 검사는 에디터에 맡기거나 package.json에 "typecheck": "tsc --noEmit" 스크립트를 따로 두고 CI에서 빌드 전에 실행하는 것이 보통입니다. esbuild와 마찬가지로 파일을 하나씩 변환하므로 tsconfig.json에 "isolatedModules": true를 켜 두면 타입만 다시 내보내는 코드처럼 파일 단위 변환에서 문제가 되는 패턴을 tsc가 미리 알려 줍니다.
React
npm install react react-dom
npm install -D @types/react @types/react-dom
// src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
// src/App.tsx
export default function App() {
return <h1>Hello Parcel + React!</h1>;
}
Parcel은 package.json에 설치된 React 버전을 보고 JSX 변환 방식을 자동으로 고릅니다. React 17 이상이면 새 JSX 런타임을 쓰므로 App.tsx처럼 import React가 없어도 동작하고, index.tsx의 import React는 사실 없어도 됩니다. 개발 모드에서는 React Fast Refresh가 자동으로 켜져서, 컴포넌트를 수정하면 상태를 유지한 채 그 부분만 바뀝니다. 다만 Fast Refresh는 컴포넌트만 export하는 파일에서만 제대로 동작하므로, 한 파일에서 컴포넌트와 일반 상수·함수를 함께 export하면 수정할 때마다 페이지 전체가 새로고침될 수 있습니다. document.getElementById('root')!의 !는 TypeScript에게 “이 값은 null이 아니다”라고 알려 주는 것으로, HTML의 id와 철자가 다르면 런타임에 Target container is not a DOM element 에러가 납니다.
CSS
기본 CSS
// src/index.tsx
import './styles.css';
CSS Modules
// src/App.tsx
import styles from './App.module.css';
export default function App() {
return <h1 className={styles.title}>Hello</h1>;
}
CSS Modules는 파일 이름을 .module.css로 끝내기만 하면 켜집니다. styles.title은 빌드 시 _title_abc123처럼 고유한 클래스 이름으로 바뀌어 다른 컴포넌트의 같은 이름과 충돌하지 않습니다. TypeScript에서 import styles from './App.module.css'가 Cannot find module './App.module.css' or its corresponding type declarations 에러를 낸다면 declare module '*.module.css' { const classes: { [key: string]: string }; export default classes; } 같은 선언 파일(.d.ts)을 추가해야 합니다. 이 에러는 빌드가 아니라 에디터와 tsc에서만 나므로, Parcel 빌드는 되는데 타입 검사만 실패하는 흔한 원인입니다.
PostCSS
npm install -D postcss autoprefixer
// .postcssrc
{
"plugins": {
"autoprefixer": {}
}
}
위 설정은 흔히 볼 수 있는 예지만, Parcel 2에서는 autoprefixer가 필요 없습니다. Parcel은 Lightning CSS로 browserslist에 맞춰 벤더 프리픽스를 자동으로 붙이고 최신 CSS 문법을 낮춰 주기 때문에, autoprefixer만 설정된 .postcssrc가 있으면 오히려 “이 설정은 불필요하며 빌드를 느리게 한다”는 경고를 출력합니다. PostCSS 설정이 있으면 Parcel은 더 느린 PostCSS 경로로 CSS를 처리하므로, Tailwind CSS처럼 PostCSS 플러그인이 꼭 필요한 경우에만 .postcssrc를 두는 것이 좋습니다. 지원 브라우저는 package.json의 "browserslist": "> 0.5%, last 2 versions, not dead"처럼 지정합니다.
이미지 & Assets
// JS에서 에셋의 URL을 얻으려면 url: 접두사 또는 new URL()을 사용
import logo from 'url:./logo.png';
<img src={logo} alt="Logo" />
// 또는 표준 문법
const logoUrl = new URL('./logo.png', import.meta.url);
Parcel 1에서는 import logo from './logo.png'만으로 이미지 URL을 받을 수 있었지만, Parcel 2에서는 JavaScript에서 이미지 같은 에셋을 가리킬 때 url: 접두사를 붙이거나 표준 new URL(..., import.meta.url) 문법을 써야 합니다. 이 규칙을 모르고 Parcel 1 시절 예제를 그대로 옮기면 이미지가 URL 문자열이 아니라 엉뚱한 값으로 들어오거나 변환 에러가 나는데, 이 글의 예전 버전도 그런 형태였기에 고쳐 두었습니다. new URL() 방식은 브라우저 표준이라 다른 번들러로 옮겨도 그대로 동작한다는 장점이 있습니다. TypeScript에서는 declare module 'url:*' { const value: string; export default value; } 선언을 추가해야 타입 에러가 나지 않습니다.
HTML이나 CSS에서 참조하는 이미지(<img src="./logo.png">, background: url(./bg.jpg))는 따로 할 일 없이 Parcel이 찾아서 복사하고 해시가 붙은 이름으로 바꿔 줍니다. 쿼리 파라미터로 url:./photo.jpg?width=800&as=webp처럼 쓰면 빌드 시 크기 조정과 포맷 변환까지 해 주는데, 이 기능이 Parcel의 대표적인 편의 기능 중 하나입니다.
환경 변수
.env
API_URL=https://api.example.com
APP_TITLE=My App
사용
const apiUrl = process.env.API_URL;
const appTitle = process.env.APP_TITLE;
Parcel은 빌드할 때 코드에 나온 process.env.API_URL 같은 표현식을 실제 값으로 치환합니다. 브라우저에는 process 객체가 없으므로 실행 시점에 환경 변수를 읽는 것이 아니라, 빌드 시점의 값이 번들에 문자열로 박혀 들어갑니다. 그래서 배포 후 서버의 환경 변수를 바꿔도 이미 빌드된 결과에는 반영되지 않고, 값을 바꾸려면 다시 빌드해야 합니다. process.env[name]처럼 동적으로 접근하면 치환되지 않고 undefined가 됩니다.
.env 외에 .env.local, .env.development, .env.production 파일도 읽으며, NODE_ENV에 따라 해당 파일이 우선 적용됩니다. 여기서 가장 주의할 점은 코드에서 참조한 환경 변수는 모두 브라우저로 내려간다는 것입니다. Vite의 VITE_ 같은 접두사 제한이 없으므로 실수로 process.env.DB_PASSWORD를 참조하면 그 값이 번들에 그대로 들어갑니다. 비밀 값은 프런트엔드 코드에서 아예 참조하지 않고, .env 파일은 .gitignore에 넣어 두는 것이 기본입니다.
Code Splitting
Dynamic Import
// 자동으로 Code Splitting됨
button.addEventListener('click', async () => {
const module = await import('./heavy-module');
module.doSomething();
});
import()를 만나면 Parcel은 그 모듈과 의존성을 별도의 번들(청크)로 분리하고, 버튼을 클릭한 순간에만 네트워크로 받아 옵니다. 차트 라이브러리나 에디터처럼 무겁지만 첫 화면에 필요 없는 코드를 이렇게 나누면 초기 로딩이 빨라집니다. 여러 청크가 같은 라이브러리를 쓰면 Parcel이 공통 부분을 공유 번들로 따로 빼서 중복 다운로드를 막습니다. 경로는 import('./heavy-module')처럼 문자열 리터럴이어야 하며, 변수로 조합한 경로는 빌드 시점에 어떤 파일인지 알 수 없어 분리되지 않습니다. 네트워크가 끊겨 청크를 받지 못하면 import()가 reject되므로, 실제 서비스에서는 try/catch로 실패를 처리하거나 재시도 UI를 보여 주는 것이 좋습니다. 새 버전 배포 직후에 이전 버전 HTML을 띄워 둔 사용자가 이미 삭제된 옛 청크를 요청해 실패하는 경우가 대표적인 예입니다.
최적화
Production Build
parcel build src/index.html --no-source-maps
.parcelrc
{
"extends": "@parcel/config-default",
"optimizers": {
"*.js": ["@parcel/optimizer-terser"]
}
}
parcel build는 기본으로 SWC 기반 압축기로 JavaScript를 최소화하므로, 위처럼 Terser로 바꾸는 것은 특정 압축 옵션이 필요하거나 SWC 압축 결과에 문제가 있을 때의 선택지입니다. 설치하지 않은 플러그인을 .parcelrc에 적으면 빌드가 실패하므로 npm install -D @parcel/optimizer-terser를 먼저 해야 합니다. .parcelrc는 JSON이라 플러그인 이름을 반드시 따옴표로 감싸야 하고(따옴표 없이 쓰면 JSON 파싱 에러), 배열에 적은 이름은 기본 파이프라인의 해당 단계를 대체합니다. 기본 단계를 유지한 채 앞이나 뒤에 추가하려면 ["@parcel/my-plugin", "..."]처럼 "..." 항목으로 기본 플러그인 자리를 표시합니다. --no-source-maps는 소스맵을 만들지 않아 빌드 결과에서 원본 코드가 노출되지 않게 하지만, 운영 환경의 에러 스택을 읽을 수 없게 되는 트레이드오프가 있습니다.
Plugins
npm install -D @parcel/transformer-sass
// .parcelrc
{
"extends": "@parcel/config-default",
"transformers": {
"*.scss": ["@parcel/transformer-sass"]
}
}
사실 Sass는 Parcel의 기본 설정(@parcel/config-default)에 이미 연결되어 있어서, 위 .parcelrc 없이 .scss 파일을 import하기만 해도 됩니다. 개발 모드에서는 필요한 변환 플러그인을 자동으로 설치해 주기도 합니다. .parcelrc가 필요한 경우는 기본 파이프라인에 없는 형식(예: 사내 전용 템플릿)을 처리하거나 기본 플러그인을 다른 것으로 교체할 때입니다. 커스텀 설정을 추가할수록 “설정 없는 번들러”라는 Parcel의 장점이 줄어들기 때문에, .parcelrc가 길어지기 시작한다면 Vite나 Webpack처럼 설정을 전제로 한 도구가 더 맞는지 다시 검토해 볼 시점이기도 합니다.
프로덕션 빌드와 소스맵
parcel build는 미니파이·트리 쉐이킹·에셋 해시 파일명을 한 흐름에서 처리합니다. 장애 분석을 위해 소스맵을 남길지는 배포 정책과 보안(내부 경로 노출)을 함께 봅니다. 정적 호스팅(S3·Cloudflare Pages)에서는 캐시 헤더와 파일명 해시 조합으로 롱 캐시를 쓰는 경우가 많습니다.
트러블슈팅
| 증상 | 점검 |
|---|---|
| HMR은 되는데 빌드만 실패 | 프로덕션에서만 쓰는 process.env.NODE_ENV 분기·동적 import 경로 |
| 동일 저장소인데 CI만 느림 | 캐시 미스·콜드 스타트·node_modules 재설치 |
| CSS 순서가 프로덕션에서만 깨짐 | import 순서·코드 스플리팅 청크 경계 — 크리티컬 CSS 분리 검토 |
| WASM/네이티브 에셋 누락 | .parcelrc에 해당 Transformer 등록 여부 |
| 브라우저 목록과 맞지 않는 폴리필 | browserslist·package.json targets |
정리 및 체크리스트
핵심 요약
- Parcel: Zero Config 번들러
- 빠름: Rust 기반
- 자동 변환: Babel, PostCSS
- HMR: 즉시 반영
- Code Splitting: 자동
- TypeScript: 네이티브 지원
언제 Parcel을 고르는가
Parcel이 가장 빛나는 것은 설정에 시간을 쓰고 싶지 않은 프로토타입, 정적 사이트, 교육용 프로젝트, 그리고 HTML 여러 장으로 이루어진 다중 페이지 사이트입니다. HTML을 엔트리로 받는 구조 덕분에 parcel src/*.html처럼 여러 페이지를 한 번에 빌드할 수 있습니다. 반대로 프레임워크 공식 도구가 Vite나 Webpack을 전제로 하는 경우(Next.js, SvelteKit 등), 빌드 파이프라인을 세밀하게 제어해야 하는 대규모 애플리케이션이라면 생태계가 큰 도구 쪽이 문제를 만났을 때 참고할 자료가 더 많습니다.
같이 보면 좋은 글
자주 묻는 질문 (FAQ)
Q. Webpack과 비교하면 어떤가요?
A. Parcel이 훨씬 간단하고 빠릅니다. Webpack은 더 많은 제어를 제공합니다.
Q. Vite와 비교하면 어떤가요?
A. 둘 다 설정이 적고 빠르지만 방식이 다릅니다. Vite는 개발 중에 번들링하지 않고 브라우저의 ES 모듈 기능으로 파일을 그대로 제공해 서버 시작이 매우 빠르고, Parcel은 개발 모드에서도 번들을 만들되 캐시로 속도를 냅니다. 프레임워크 지원과 플러그인 생태계는 Vite 쪽이 훨씬 크고, 이미지 변환이나 다중 HTML 엔트리처럼 “설정 없이 기본 제공되는 기능”은 Parcel이 더 많습니다.
Q. 학습 곡선은 어떤가요?
A. 낮습니다. 거의 설정이 필요 없습니다.