Webpack이 필요한 이유
JavaScript에서 ES2015(ES6)부터 문법 수준에서 모듈을 지원하기 시작했다. import와 export 키워드가 도입되었지만 모든 브라우저에서 이를 사용할 수 있게 된 것은 Webpack과 같은 번들러가 등장하면서부터다.
Webpack이 등장하기 이전에는 어떤 방식으로 모듈을 관리했는지 살펴보자.
모듈 시스템의 역사
모듈 시스템 이전의 JavaScript
과거에는 여러 JavaScript 파일을 HTML에 순서대로 로드하는 방식으로 코드를 구성했다. 간단한 예제를 통해 살펴보자.
// calculator.js
function multiply(a, b) {
return a * b;
}
function divide(a, b) {
return a / b;
}js// app.js
console.log(multiply(5, 3)); // 15
console.log(divide(10, 2)); // 5jsHTML 파일에서 이 스크립트들을 로드할 때는 순서가 매우 중요하다. calculator.js를 먼저 로드해야 app.js에서 함수를 사용할 수 있다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Module Example</title>
</head>
<body>
<script src="src/calculator.js"></script>
<script src="src/app.js"></script>
</body>
</html>htmlopen index.html 명령어로 브라우저에서 실행하면 콘솔에 다음과 같이 출력된다.
콘솔 출력:
15
5전역 스코프 오염 문제
과거에는 index.html에 여러 JavaScript 파일을 순서대로 로드하여 다른 파일의 함수를 사용했다. 하지만 이 방식에는 전역 스코프 오염이라는 심각한 문제가 있다.
calculator.js에서 정의한 multiply 함수는 해당 파일 안에서만 유효한 것이 아니라, 애플리케이션 전역에서 접근할 수 있다.
브라우저 개발자 도구 콘솔에서 확인해보자.
> multiply
ƒ multiply(a, b) { return a * b; }
> window.multiply
ƒ multiply(a, b) { return a * b; }전역 스코프 window 객체에 함수가 등록된 것을 확인할 수 있다.
> multiply(10, 4)
40함수를 바로 호출할 수도 있다. 하지만 JavaScript는 동적 타입 언어이기 때문에 함수 변수에 다른 값을 할당할 수 있다.
> multiply = 2
2
> multiply(10, 4)
Uncaught TypeError: multiply is not a function이렇게 전역 스코프가 오염되면 애플리케이션이 예측 불가능해지고, 결국 런타임 에러가 발생하게 된다.
IIFE 방식의 모듈
IIFE(Immediately Invoked Function Expression)는 즉시 실행 함수 표현식이다. 함수를 정의하자마자 실행하는 패턴으로, 함수 내부에 독립적인 스코프를 생성한다. 이를 통해 내부에서 정의한 변수나 함수가 외부에서 접근할 수 없도록 하여 전역 스코프 오염을 예방할 수 있다.
앞서 만들었던 calculator 모듈을 IIFE 방식으로 변경해보자.
// calculator.js
// 전역 네임스페이스 calculator 생성
// 이미 존재하면 기존 것을 사용하고, 없으면 빈 객체 할당
var calculator = calculator || {};
// 즉시 실행 함수
(function () {
// 이 안에서 정의된 함수는 외부에서 접근 불가
function multiply(a, b) {
return a * b;
}
function divide(a, b) {
return a / b;
}
// 전역 네임스페이스에 필요한 함수만 노출
calculator.multiply = multiply;
calculator.divide = divide;
})();js이 패턴의 동작 원리를 단계별로 살펴보자.
1단계: 전역 네임스페이스 생성
var calculator = calculator || {};jscalculator라는 전역 네임스페이스를 만들고, 이미 존재하면 기존 것을 사용하고 없으면 빈 객체를 할당한다.
2단계: IIFE로 독립적인 스코프 생성
(function () {
// 외부로부터 독립적인 스코프
})();js함수를 즉시 정의하고 실행하면, 이 함수 내부의 스코프는 외부로부터 완전히 독립된다.
3단계: 필요한 함수만 네임스페이스에 노출
함수 내부에서 multiply와 divide를 정의하고, 이 함수들을 외부에서 사용할 수 있도록 전역 네임스페이스 calculator에 할당한다.
이제 app.js에서는 다음과 같이 사용해야 한다.
// 잘못된 사용 - multiply는 전역 스코프에 없음
// console.log(multiply(5, 3)); // ReferenceError
// 올바른 사용 - calculator 네임스페이스를 통해 접근
console.log(calculator.multiply(5, 3)); // 15
console.log(calculator.divide(10, 2)); // 5js브라우저 콘솔에서 확인하면 multiply 함수는 전역에 직접 노출되지 않고, calculator 네임스페이스를 통해서만 접근할 수 있다.
> multiply
Uncaught ReferenceError: multiply is not defined
> calculator.multiply
ƒ multiply(a, b) { return a * b; }
> calculator.multiply(10, 4)
40다양한 모듈 스펙
JavaScript 모듈을 구현하는 대표적인 명세가 AMD와 CommonJS이다.
CommonJS
CommonJS는 JavaScript를 사용하는 모든 환경에서 모듈을 사용하는 것을 목표로 한다. exports 키워드로 모듈을 만들고, require() 함수로 모듈을 불러온다.
// calculator.js
exports.multiply = function (a, b) {
return a * b;
};
exports.divide = function (a, b) {
return a / b;
};
// app.js
const calculator = require('./calculator.js');
console.log(calculator.multiply(5, 3)); // 15
console.log(calculator.divide(10, 2)); // 5js대표적으로 서버 사이드 플랫폼인 Node.js에서 CommonJS를 사용한다. calculator라는 모듈을 만들 때 exports 키워드로 함수를 내보내고, require() 함수로 모듈의 경로를 전달하면 해당 모듈을 가져올 수 있다.
AMD와 UMD
AMD(Asynchronous Module Definition)는 비동기로 로딩되는 환경에서 모듈을 사용하는 것을 목표로 한다. 주로 브라우저 환경에서 사용된다. 브라우저처럼 외부에서 JavaScript를 로딩해야 하는 비동기 환경에서는 AMD 스펙을 사용한다.
CommonJS와 AMD 두 가지를 모두 지원하는 것이 UMD 스펙이다.
ES2015 표준 모듈
이후 ES2015에서 표준 모듈 시스템이 만들어졌다. 현재는 Babel이나 Webpack을 이용해서 표준 모듈 시스템을 사용하는 것이 일반적이다.
// calculator.js
export function multiply(a, b) {
return a * b;
}
export function divide(a, b) {
return a / b;
}
// app.js
// 모든 export를 가져오기
import * as calculator from './calculator.js';
// 필요한 것만 가져오기
// import { multiply, divide } from './calculator.js';
console.log(calculator.multiply(5, 3)); // 15
console.log(calculator.divide(10, 2)); // 5jsexport 구문으로 모듈을 만들고, import 구문으로 가져올 수 있다.
브라우저의 모듈 지원
모든 브라우저가 ES 모듈을 지원하는 것은 아니다. Internet Explorer를 포함한 몇몇 모바일 브라우저는 모듈 시스템을 지원하지 않는다. 최신 브라우저(Chrome, Firefox, Safari 등)에서는 모듈 시스템을 어떻게 사용하는지 살펴보자.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ES Module Example</title>
</head>
<body>
<!-- type="module"을 지정하면 ES 모듈 사용 가능 -->
<script type="module" src="src/app.js"></script>
</body>
</html>html스크립트 태그를 로드할 때 type="text/javascript" 대신 type="module"을 사용하면 app.js에서 모듈을 사용할 수 있다.
그러나 브라우저에 무관하게 모듈을 사용하고 싶다면 바로 여기서 Webpack이 필요한다.
로컬 파일 실행 시 CORS 문제
위 코드를 그대로 실행하면 CORS 에러가 발생할 수 있다. 브라우저가 파일을 직접 읽지 않고 HTTP 서버를 통해 로드해야 하기 때문이다.
이를 해결하기 위해 간단한 개발 서버를 실행해보자.
npx light-serverbash서버가 실행되면 다음과 같이 출력된다.
Light server is listening at http://localhost:4000
serving static dir: ./
when file changes, reload the page이제 http://localhost:4000에 접속하여 모듈이 제대로 동작하는지 확인할 수 있다.
Webpack 설치 및 기본 사용
설치 및 CLI 사용법
모듈로 개발하게 되면 모듈 간의 의존 관계가 생긴다. 하나의 JavaScript 모듈에서 다른 모듈을 import하는 방식이다.
예를 들어 다음과 같은 의존 관계를 생각해볼 수 있다.
app.js (엔트리 포인트)
├─ calculator.js
├─ formatter.js
└─ utils.js
└─ helpers.js이렇게 만든 구조가 우리가 만든 app.js와 calculator.js의 관계와 유사하다.
Webpack은 모듈로 연결된 여러 개의 JavaScript 파일을 하나로 합쳐주는 역할을 한다.
이렇게 하나로 합쳐진 파일을 번들(Bundle)이라고 하고, 이 작업을 하는 도구를 번들러(Bundler)라고 한다. Webpack이 바로 이런 번들링 작업을 수행하는 대표적인 번들러이다.
번들러 작업을 하는 webpack 패키지와, Webpack을 터미널 명령어로 사용할 수 있게 해주는 webpack-cli를 설치해보자.
npm install -D webpack webpack-clibash이 글에서는 다음 버전을 기준으로 진행한다.
{
"devDependencies": {
"webpack": "^5.102.0",
"webpack-cli": "^6.0.1"
}
}json먼저 --help 옵션으로 Webpack을 어떻게 사용하는지 알아보자.
node_modules/.bin/webpack --helpbashWebpack을 실행할 때 필수적인 옵션은 3가지이다.
1. mode 옵션
개발 환경(development) 또는 운영 환경(production)을 지정한다.
2. entry 옵션
모듈의 시작점(엔트리 포인트)을 지정한다. Webpack은 이 파일부터 시작해서 연결된 모든 모듈을 찾아간다.
3. output 옵션
엔트리 포인트를 통해 웹팩이 모든 모듈을 합친 결과물을 저장하는 경로를 설정한다.
Webpack 4 vs 5 차이점
Webpack 4에서는 --output 뒤에 경로와 파일명을 함께 지정할 수 있었다.
# Webpack 4
node_modules/.bin/webpack --mode development --entry ./src/app.js --output dist/main.jsbashWebpack 5부터는 --output이 디렉토리 경로만 받을 수 있고, 파일명은 별도 옵션으로 지정해야 한다.
--output-path: 빌드 산출물이 저장될 폴더 경로--output-filename: 출력될 파일 이름
# Webpack 5
node_modules/.bin/webpack --mode development --entry ./src/app.js --output-path dist --output-filename main.jsbash빌드가 성공하면 다음과 같은 메시지가 출력된다.
asset main.js 4.5 KiB [emitted] (name: main)
./src/app.js 150 bytes [built] [code generated]
./src/calculator.js 120 bytes [built] [code generated]
webpack 5.102.0 compiled successfully in 150 ms빌드 결과 사용하기
이제 index.html에서 빌드된 파일을 로드해보자.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Webpack Example</title>
</head>
<body>
<!-- 빌드된 파일을 로드 -->
<script src="dist/main.js"></script>
</body>
</html>html이제 모든 브라우저에서 모듈 시스템을 사용할 수 있다. 콘솔에 다음과 같이 정상적으로 출력되는 것을 확인할 수 있다.
콘솔 출력:
15
5Webpack은 이렇게 여러 개의 모듈을 하나의 파일로 만들어주는 역할을 한다.
설정 파일 만들기
Webpack 설정을 할 때 매번 터미널에 긴 옵션을 입력하는 것은 비효율적이다. 설정 파일을 만들어서 관리하는 것이 좋다.
--help 문서를 보면 --config 옵션이 있는데, 이를 통해 Webpack 설정 파일을 지정할 수 있다. 기본 파일명은 webpack.config.js이다.
프로젝트 루트에 webpack.config.js 파일을 생성하자.
// webpack.config.js
const path = require('path');
// Node의 모듈 시스템 사용
module.exports = {
mode: 'development',
entry: {
main: './src/app.js',
// 엔트리가 여러 개일 경우
// sub: './src/sub.js',
},
output: {
// path는 절대 경로로 지정
path: path.resolve('./dist'),
// 엔트리 키 값으로 동적 파일명 생성
// main.js, sub.js 형태로 생성됨
filename: '[name].js',
},
};js설정을 자세히 살펴보자.
mode: development로 설정
entry: 시작점을 지정하는데, 객체 형태로 지정한다. entry 포인트의 경로뿐만 아니라 main이라는 키도 설정했다. 이 키는 다음에 나올 output 설정에서 사용된다.
output: path와 filename을 설정한다.
path: 절대 경로를 지정한다.path.resolve()를 사용하여 현재 디렉토리 기준으로dist폴더의 절대 경로를 생성한다.filename:[name].js로 지정했다.[name]부분이 엔트리에서 설정한 키 값(main)으로 치환된다.
이렇게 설정한 이유는 엔트리가 여러 개일 수 있기 때문이다. 엔트리가 여러 개면 그에 맞는 output도 여러 개여야 하는데, 파일명을 동적으로 생성할 수 있다.
예를 들어 엔트리가 다음과 같다면:
entry: {
main: './src/app.js',
sub: './src/sub.js',
},jsmain.js와 sub.js가 각각 output으로 생성된다.
npm 스크립트 등록
npm은 프로젝트를 관리하는 도구로, 스크립트를 자동화하는 기능이 있다. Webpack으로 코드를 번들링하는 과정을 npm 스크립트에 등록해보자.
package.json에서 scripts 부분을 다음과 같이 수정한다.
{
"scripts": {
"build": "webpack"
}
}json이제 다음 명령어로 빌드할 수 있다.
npm run buildbash로더(Loader)
Webpack은 모든 파일을 모듈로 바라본다.
JavaScript로 만든 모듈뿐만 아니라 CSS, 이미지, 폰트까지 전부 모듈로 처리한다. 따라서 ES6의 import 키워드를 사용하면 이런 파일들을 JavaScript 코드 안으로 가져와서 사용할 수 있다.
이것이 가능한 이유는 Webpack의 로더(Loader) 때문이다. Webpack의 로더는 모든 파일을 JavaScript 모듈처럼 만들어준다. 예를 들어 CSS 파일을 JavaScript에서 직접 로딩해서 사용할 수 있게 해준다.
또한 이미지 파일을 Data URL 형식의 문자열로 변환한 다음, JavaScript에서 이미지 파일을 사용할 수 있게 해주는 것이 로더의 역할이다.
로더를 사용하기 전에 직접 로더를 만들어보면서 동작 원리를 살펴보자.
커스텀 로더 만들기
프로젝트 루트에 console-to-alert-loader.js 파일을 생성한다.
// console-to-alert-loader.js
module.exports = function consoleToAlertLoader(content) {
console.log('console-to-alert 로더가 동작한다');
// console.log를 alert로 변경
return content.replace('console.log(', 'alert(');
};js로더는 함수 형태로 작성한다. 로더가 파일을 읽으면 파일의 내용이 content 인자로 전달된다. 여기서는 로더가 동작했는지 확인하기 위해 console.log만 출력하고, console.log를 alert로 변경하는 간단한 작업을 수행한다.
이제 만든 로더를 Webpack 설정에 연결해야 한다.
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'development',
entry: {
main: './src/app.js',
},
output: {
filename: '[name].js',
path: path.resolve('./dist'),
},
module: {
rules: [
{
// 로더가 처리해야 하는 파일들의 패턴 (정규표현식)
// .js로 끝나는 모든 파일 처리
test: /\.js$/,
use: [path.resolve('./console-to-alert-loader.js')],
},
],
},
};jsmodule.rules 배열에 로더 규칙을 추가했다.
test: 로더가 처리할 파일의 패턴을 정규표현식으로 지정한다./\.js$/는.js로 끝나는 모든 파일을 의미한다.use: 사용할 로더의 경로를 지정한다.
JavaScript 파일이 아닌 파일들은 이 로더가 처리하지 않는다. 빌드하면 console.log 대신 alert가 실행되는 것을 확인할 수 있다.
터미널 출력:
console-to-alert 로더가 동작한다
asset main.js 4.8 KiB [emitted] (name: main)브라우저에서 실행하면 alert 창이 뜬다.
주요 로더 사용법
이제 실무에서 자주 사용되는 대표적인 로더 몇 가지를 살펴보자.
css-loader와 style-loader
CSS 파일에 로더를 설정하면 JavaScript 파일에서 CSS 파일을 모듈로 불러올 수 있다.
// app.js
import './app.css';js일반적으로는 이 코드가 동작하지 않는다. Webpack의 로더를 사용하면 ES6의 import 구문으로 CSS 코드를 JavaScript 코드로 가져와서 사용할 수 있다.
CSS 파일이 모듈이 되려면 Webpack의 로더가 CSS 파일을 JavaScript 모듈로 변환해주어야 한다.
스타일 코드를 작성해보자.
/* app.css */
body {
background-color: #f0f0f0;
font-family: Arial, sans-serif;
}
h1 {
color: #333;
text-align: center;
}cssapp.js에서 이 CSS 파일을 import한다.
// app.js
import './app.css';
import * as calculator from './calculator.js';
console.log(calculator.multiply(5, 3));
console.log(calculator.divide(10, 2));js빌드를 하면 오류가 발생한다.
ERROR in ./src/app.css 1:4
Module parse failed: Unexpected token (1:4)
You may need an appropriate loader to handle this file type,
currently no loaders are configured to process this file.Webpack이 CSS 파일을 읽다가 문법을 이해하지 못해서 에러를 발생시킨 것이다.
Webpack이 CSS 파일을 JavaScript에서 모듈로 가져올 수 있도록 CSS 파일을 처리해주는 것이 css-loader이다.
로더를 사용하려면 먼저 설치해야 한다.
npm install -D css-loaderbashWebpack 설정에 로더를 추가한다.
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'development',
entry: {
main: './src/app.js',
},
output: {
filename: '[name].js',
path: path.resolve('./dist'),
},
module: {
rules: [
{
// CSS 파일을 처리하는 로더
test: /\.css$/,
use: ['css-loader'],
},
],
},
};jsWebpack은 엔트리 포인트부터 시작해서 연결된 모든 모듈을 검색한다. 그러다가 CSS 파일을 만나면 이 패턴(/\.css$/)에 의해 css-loader가 동작한다.
빌드가 성공한다.
asset main.js 7.2 KiB [emitted] (name: main)
./src/app.js 180 bytes [built] [code generated]
./src/calculator.js 140 bytes [built] [code generated]
./src/app.css 250 bytes [built] [code generated]
webpack 5.102.0 compiled successfully in 280 msdist 폴더를 보면 CSS 파일이 별도로 있는 것이 아니라 main.js에 포함되어 있다. JavaScript 문자열로 CSS 코드가 변환된 것을 확인할 수 있다.
하지만 브라우저에서 확인해보면 스타일이 적용되지 않는다.
HTML 코드가 DOM으로 변환되어야 브라우저에 문서가 보이듯이, CSS 코드도 CSSOM(CSS Object Model)으로 변환되어야 브라우저에서 스타일이 적용된다.
그러려면 HTML 파일에서 CSS 코드를 직접 불러오거나, 인라인 스타일로 넣어줘야 한다. 아직 그런 처리를 하지 않고 JavaScript 파일에만 CSS 코드가 있어서 브라우저에 적용되지 않는 것이다.
style-loader는 JavaScript로 변경된 스타일 코드를 HTML에 삽입해주는 로더이다.
CSS 코드를 모듈로 사용하고 Webpack으로 번들링하려면 css-loader와 style-loader 두 가지를 함께 사용해야 한다.
먼저 style-loader를 설치한다.
npm install -D style-loaderbashWebpack 설정을 수정한다.
// webpack.config.js
const path = require('path');
module.exports = {
mode: 'development',
entry: {
main: './src/app.js',
},
output: {
filename: '[name].js',
path: path.resolve('./dist'),
},
module: {
rules: [
{
// CSS 파일을 처리하는 로더
test: /\.css$/,
// 로더는 오른쪽에서 왼쪽으로 실행됨
// css-loader로 CSS를 JavaScript로 변환 → style-loader로 HTML에 삽입
use: ['style-loader', 'css-loader'],
},
],
},
};jsuse 배열에 여러 로더를 지정할 때는 오른쪽에서 왼쪽으로 실행된다. 먼저 css-loader가 CSS 파일을 JavaScript 모듈로 변환하고, 그 다음 style-loader가 변환된 스타일을 HTML에 삽입한다.
다시 빌드하고 브라우저에서 확인하면 스타일이 제대로 적용된 것을 볼 수 있다.
개발자 도구에서 확인하면 <head> 태그 안에 <style> 태그가 자동으로 삽입된 것을 확인할 수 있다.
<head>
...
<style>
body {
background-color: #f0f0f0;
font-family: Arial, sans-serif;
}
h1 {
color: #333;
text-align: center;
}
</style>
</head>htmlfile-loader와 Asset Modules
로더는 CSS뿐만 아니라 이미지 파일도 처리할 수 있다.
import 구문으로 이미지 파일을 JavaScript로 가져와서 사용할 수 있다. CSS 파일도 모듈로 처리하기 때문에 CSS 파일에서 이미지 파일을 가져올 수 있다.
실습을 위해 이미지 파일을 프로젝트에 추가하자. src 폴더에 background.jpg 이미지를 추가한다.
Webpack 4에서는 file-loader를 사용했다.
npm install -D file-loaderbash// webpack.config.js (Webpack 4)
const path = require('path');
module.exports = {
mode: 'development',
entry: {
main: './src/app.js',
},
output: {
filename: '[name].js',
path: path.resolve('./dist'),
},
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
// 이미지 파일을 처리하는 로더
test: /\.(png|jpe?g|gif)$/,
loader: 'file-loader',
options: {
// 파일을 모듈로 사용했을 때 경로 앞에 추가되는 문자열
// output 경로가 dist이므로 같은 값 지정
publicPath: './dist/',
// 파일이 output에 복사될 때 사용하는 파일 이름
// 원본 파일명.확장자?해시값 형태
name: '[name].[ext]?[hash]',
},
},
],
},
};jsCSS 파일에서 이미지를 배경으로 사용해보자.
/* app.css */
body {
background-image: url('./background.jpg');
background-size: cover;
background-position: center;
}css빌드하면 dist 폴더에 이미지 파일이 복사되고, CSS에서 올바른 경로로 참조된다.
Webpack 5부터는 file-loader 대신 내장 Asset Modules를 사용한다.
// webpack.config.js (Webpack 5)
const path = require('path');
module.exports = {
mode: 'development',
entry: {
main: './src/app.js',
},
output: {
filename: '[name].js',
path: path.resolve('./dist'),
},
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
// 이미지 파일을 처리하는 Asset Module
test: /\.(png|jpe?g|gif)$/,
type: 'asset/resource',
},
],
},
};jstype: 'asset/resource'로 지정하면 파일을 별도로 복사하고 URL을 반환한다.
빌드하면 dist 폴더에 해시값이 포함된 파일명으로 이미지가 저장된다.
📦 dist/
┣ 📜 main.js
┗ 📜 a1b2c3d4e5f6g7h8i9j0.jpg파일명이 해시값으로 변경되는 이유는 캐시 갱신을 위해서이다. Webpack은 빌드할 때마다 유니크한 해시값을 생성한다.
정적 파일의 경우 브라우저에서 캐싱하는 경우가 많다. JavaScript, CSS, 이미지, 폰트 등을 성능을 위해 캐싱한다. 파일 내용이 변경되어도 이름이 같으면 브라우저가 이전 캐시를 사용할 수 있다. 이를 방지하는 방법 중 하나가 파일 이름을 변경하는 것이다.
실제로 실행시켜보면 이미지가 배경으로 잘 표시된다.
Webpack 5에서도 파일명과 경로를 커스터마이즈할 수 있다.
// webpack.config.js (Webpack 5 - 상세 설정)
const path = require('path');
module.exports = {
mode: 'development',
entry: {
main: './src/app.js',
},
output: {
filename: '[name].js',
path: path.resolve('./dist'),
},
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpe?g|gif|svg|webp)$/i,
type: 'asset/resource',
generator: {
// 브라우저에서 접근할 때 사용할 경로
publicPath: './dist/',
// 파일 이름 형식
// [name]: 원본 파일명
// [contenthash]: 파일 내용 기반 해시
// [ext]: 확장자
filename: '[name].[contenthash][ext]',
},
},
],
},
};jsurl-loader와 Asset Modules (inline)
사용하는 이미지 개수가 많아지면 네트워크 요청 수가 늘어나 사이트 성능에 영향을 줄 수 있다.
만약 한 페이지 안에서 작은 이미지를 여러 개 사용한다면 Data URL 스키마를 이용하는 방법이 더 효율적이다.
Data URL은 파일 경로 대신 파일 데이터를 직접 Base64로 인코딩한 문자열을 사용하는 방식이다.
<img
src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
alt="1x1 red pixel"
/>html이렇게 하면 별도의 HTTP 요청 없이 HTML에 이미지를 직접 삽입할 수 있다. 작은 파일들은 이렇게 인라인으로 포함하는 것이 더 효율적이다.
실습을 위해 작은 이미지를 추가하자. src 폴더에 icon.png (5KB 미만)를 추가한다.
// app.js
import './app.css';
import * as calculator from './calculator.js';
import icon from './icon.webp';
document.addEventListener('DOMContentLoaded', () => {
document.body.innerHTML = `
<h1>Webpack Example</h1>
<img src="${icon}" alt="Icon" />
`;
});
console.log(calculator.multiply(5, 3));
console.log(calculator.divide(10, 2));jsWebpack 4에서는 url-loader를 사용했다.
npm install -D url-loaderbash// webpack.config.js (Webpack 4)
module.exports = {
// ...
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpe?g|gif)$/,
loader: 'url-loader',
options: {
publicPath: './dist/',
name: '[name].[ext]?[hash]',
// 20KB 이하는 Data URL로 변환
// 그 이상은 파일로 복사
limit: 20000,
},
},
],
},
};jsWebpack 5에서는 type: 'asset'을 사용하면 파일 크기에 따라 자동으로 인라인 또는 파일로 처리한다.
// webpack.config.js (Webpack 5)
const path = require('path');
module.exports = {
mode: 'development',
entry: {
main: './src/app.js',
},
output: {
filename: '[name].js',
path: path.resolve('./dist'),
},
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpe?g|gif|svg|webp)$/i,
type: 'asset', // 자동으로 inline/resource 선택
parser: {
dataUrlCondition: {
maxSize: 20000, // 20KB 이하는 inline
},
},
generator: {
filename: '[name].[contenthash][ext]',
},
},
],
},
};js빌드하면 다음과 같은 결과를 얻을 수 있다.
📦 dist/
┣ 📜 main.js (icon.png가 Base64로 포함됨)
┗ 📜 background.a1b2c3d4.jpg (20KB 이상이므로 파일로 복사)main.js 내부를 확인하면 작은 이미지는 Data URL로 변환되어 있다.
// main.js 일부
const icon = 'data:image/png;base64,iVBORw0KGgoAAAANS...';js브라우저에서 Network 탭을 확인하면 작은 이미지는 별도의 HTTP 요청이 발생하지 않는 것을 볼 수 있다.
플러그인(Plugin)
로더가 각 파일 단위로 처리했던 것에 반해, 플러그인은 번들된 결과물 전체를 처리한다. JavaScript 코드를 난독화한다거나 특정 텍스트를 추출하는 용도로 플러그인이 사용된다.
로더의 원리를 이해하기 위해 커스텀 로더를 만들어봤듯이, 플러그인도 직접 만들어보면서 동작 원리를 이해해보겠다.
커스텀 플러그인 만들기
Webpack 문서의 “Writing a Plugin”을 참고하여 플러그인을 만들어보겠다. 플러그인은 로더가 함수로 정의된 것과 다르게 클래스로 정의한다.
banner-add-plugin.js 파일을 생성한다.
// banner-add-plugin.js (Webpack 5)
class BannerAddPlugin {
// apply 메서드를 구현하면 Webpack이 compiler 객체를 전달함
apply(compiler) {
// emit 훅은 파일이 생성되기 직전에 실행됨
compiler.hooks.emit.tapAsync('BannerAddPlugin', (compilation, callback) => {
// compilation 객체를 통해 번들된 결과물에 접근 가능
if (compilation.assets['main.js']) {
// 기존 소스 코드 가져오기
const source = compilation.assets['main.js'].source();
// 파일 상단에 추가할 배너
const banner = [
'/**',
' * 이것은 BannerAddPlugin이 처리한 결과입니다.',
' * Build Date: ' + new Date().toLocaleDateString(),
' */',
].join('\n');
const newContent = banner + '\n\n' + source;
// 기존 파일을 새 내용으로 교체
compilation.assets['main.js'] = {
source: () => newContent,
size: () => newContent.length,
};
}
// 비동기 작업 완료를 Webpack에 알림
callback();
});
}
}
module.exports = BannerAddPlugin;jsWebpack 4 이하에서는 compiler.plugin() 메서드를 사용했지만, Webpack 4 이상에서는 Tapable Hooks API로 변경되었다.
// Webpack 3 이하 (더 이상 사용하지 않음)
compiler.plugin('emit', (compilation, callback) => {
// ...
});
// Webpack 4 이상 (권장)
compiler.hooks.emit.tapAsync('PluginName', (compilation, callback) => {
// ...
});js만든 플러그인을 Webpack 설정에 추가한다.
// webpack.config.js
const path = require('path');
const BannerAddPlugin = require('./banner-add-plugin');
module.exports = {
mode: 'development',
entry: {
main: './src/app.js',
},
output: {
filename: '[name].js',
path: path.resolve('./dist'),
},
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpe?g|gif|svg|webp)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 20000,
},
},
generator: {
filename: '[name].[contenthash][ext]',
},
},
],
},
// 플러그인 배열에 인스턴스 추가
plugins: [new BannerAddPlugin()],
};js빌드하고 dist/main.js 파일을 열어보면 상단에 배너가 추가된 것을 확인할 수 있다.
/**
* 이것은 BannerAddPlugin이 처리한 결과입니다.
* Build Date: 2025/10/11
*/
/******/ (() => { // webpackBootstrap
...js로더가 여러 파일 각각에 대해 실행되는 것과 달리, 플러그인은 번들된 결과물에 대해 한 번만 실행되는 것을 확인할 수 있다.
주요 플러그인 사용법
커스텀으로 만들었던 플러그인과 유사한 것이 실제로 존재한다. 대표적인 플러그인들을 살펴보겠다.
BannerPlugin
빌드한 결과물에 빌드 정보나 커밋 버전 같은 것을 추가할 수 있는 플러그인입니다. BannerPlugin은 Webpack이 기본으로 제공하는 내장 플러그인입니다.
// webpack.config.js
const path = require('path');
const webpack = require('webpack');
module.exports = {
mode: 'development',
entry: {
main: './src/app.js',
},
output: {
filename: '[name].js',
path: path.resolve('./dist'),
},
module: {
rules: [
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
{
test: /\.(png|jpe?g|gif|svg|webp)$/i,
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: 20000,
},
},
generator: {
filename: '[name].[contenthash][ext]',
},
},
],
},
plugins: [
new webpack.BannerPlugin({
banner: '이것은 BannerPlugin이 처리한 결과입니다.',
}),
],
};js빌드하고 dist/main.js를 확인하면 상단에 배너가 추가된 것을 볼 수 있다.
/*! 이것은 BannerPlugin이 처리한 결과입니다. */
/******/ (() => { // webpackBootstrap
...js배너에 더 자세한 정보를 추가해보겠다.
// webpack.config.js
const childProcess = require('child_process');
plugins: [
new webpack.BannerPlugin({
banner: `
Build Date: ${new Date().toLocaleDateString()}
Commit Version: ${childProcess.execSync('git rev-parse --short HEAD').toString().trim()}
Author: ${childProcess.execSync('git config user.name').toString().trim()}
`,
}),
],jsNode의 child_process 모듈을 사용하여 터미널 명령어를 실행할 수 있다.
git rev-parse --short HEAD: 현재 커밋의 짧은 해시값git config user.name: Git 사용자 이름
빌드하면 다음과 같은 배너가 생성된다.
/*!
Build Date: 2025/10/11
Commit Version: a1b2c3d
Author: John Doe
*/js빌드된 결과물에 이런 배너 정보를 추가하는 이유는, 배포 후 실제 정적 파일이 제대로 배포되었는지, 또는 캐시로 인해 갱신되지 않았는지 확인할 때 유용하기 때문입니다.
DefinePlugin
프론트엔드 소스 코드는 개발 환경과 운영 환경으로 나눠서 운영한다.
환경에 따라 API 주소가 다를 수 있다. 배포할 때마다 코드를 수정하면 휴먼 에러가 발생하기 쉽고 장애가 날 위험이 있다.
API 주소처럼 환경 의존적인 정보를 소스가 아닌 다른 곳에서 관리하는 것이 좋다. 이런 환경 변수를 애플리케이션에 제공하기 위해 DefinePlugin을 사용한다.
DefinePlugin도 Webpack의 기본 내장 플러그인입니다.
// webpack.config.js
plugins: [new webpack.DefinePlugin({})];js이렇게 빈 객체로 설정해도 DefinePlugin이 기본적으로 주입하는 환경 변수가 있다. 바로 Node의 환경 변수인 process.env.NODE_ENV입니다.
Webpack이 Node 환경에서 실행되기 때문에, 빌드될 때 이 NODE_ENV 환경 변수를 번들에 포함시킵니다.
// app.js
import './app.css';
import * as calculator from './calculator.js';
console.log(calculator.multiply(5, 3));
console.log(calculator.divide(10, 2));
console.log('Environment:', process.env.NODE_ENV);js빌드하고 실행하면 다음과 같이 출력된다.
콘솔 출력:
15
5
Environment: developmentdevelopment가 출력되는 이유는 webpack.config.js에서 mode: 'development'로 설정했기 때문입니다.
직접 환경 변수를 추가할 수도 있다.
// webpack.config.js
plugins: [
new webpack.DefinePlugin({
TWO: '1+1',
}),
],js애플리케이션에서는 TWO라는 전역 변수로 접근할 수 있다.
console.log(TWO); // 2js'1+1'은 문자열이 아니라 JavaScript 코드로 평가되어 2가 된다.
만약 문자열 값을 넣고 싶다면 JSON.stringify()를 사용한다.
plugins: [
new webpack.DefinePlugin({
TWO: JSON.stringify('1+1'),
'api.domain': JSON.stringify('http://dev.api.domain.com'),
}),
],jsconsole.log(TWO); // "1+1" (문자열)
console.log(api.domain); // "http://dev.api.domain.com"js객체 형식으로 정의하면 api.domain처럼 점 표기법으로 접근할 수 있다.
HtmlWebpackPlugin
HtmlWebpackPlugin은 Webpack의 기본 플러그인은 아니지만, 널리 사용되는 서드파티 플러그인입니다. HTML 파일을 빌드 과정에 포함시켜 처리한다.
지금까지는 HTML 파일을 프로젝트 루트에 직접 만들고, 빌드한 결과물의 경로를 수동으로 입력했다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<!-- 수동으로 입력 -->
<script src="dist/main.js"></script>
</body>
</html>htmlHTML도 빌드 과정에 포함시키고 싶다면 이 플러그인을 사용한다.
먼저 패키지를 설치한다.
npm install -D html-webpack-pluginbashindex.html을 src/index.html로 이동하고, JavaScript 로딩 코드는 제거한다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<!-- 스크립트 태그 제거 - 플러그인이 자동으로 추가함 -->
</body>
</html>htmlWebpack 설정에 플러그인을 추가한다.
// webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin');
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
}),
],js빌드하면 dist 폴더에 index.html이 생성된다.
📦 dist/
┣ 📜 main.js
┗ 📜 index.html생성된 index.html을 확인하면 스크립트 태그가 자동으로 추가되어 있다.
Webpack 4에서는 <body> 태그 하단에 <script> 태그가 추가되었지만, Webpack 5에서는 <head> 태그에 <script defer> 태그가 추가된다.
<!-- Webpack 5 결과 -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script defer src="main.js"></script>
</head>
<body></body>
</html>html이제 http://127.0.0.1:5500/dist/index.html로 접속하면 된다.
HtmlWebpackPlugin을 사용하면 동적으로 HTML을 생성할 수 있다. EJS 템플릿 문법을 사용하여 환경에 따라 다른 값을 넣을 수 있다.
<!-- src/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- EJS 템플릿 문법 사용 -->
<title>Document <%= env %></title>
</head>
<body></body>
</html>htmlWebpack 설정에서 templateParameters로 값을 전달한다.
// webpack.config.js
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
templateParameters: {
env: process.env.NODE_ENV === 'development' ? '(개발)' : '(운영)',
},
}),
],js환경 변수를 설정하여 빌드한다.
NODE_ENV=development npm run build # 개발 환경
NODE_ENV=production npm run build # 운영 환경bash개발 환경에서는 <title>Document (개발)</title>, 운영 환경에서는 <title>Document (운영)</title>로 생성된다.
개발 환경에서 타이틀에 “(개발)” 표시를 하면 휴먼 에러를 줄일 수 있다.
운영 환경에서는 HTML을 압축하고 주석을 제거하는 것이 좋다.
// webpack.config.js
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
templateParameters: {
env: process.env.NODE_ENV === 'development' ? '(개발)' : '(운영)',
},
minify:
process.env.NODE_ENV === 'production'
? {
collapseWhitespace: true, // 빈칸 제거
removeComments: true, // 주석 제거
}
: false,
}),
],js운영 환경에서 빌드하면 HTML이 한 줄로 압축된다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Document (운영)</title>
<script defer src="main.js"></script>
</head>
<body></body>
</html>htmlCleanWebpackPlugin
CleanWebpackPlugin은 output 폴더를 자동으로 삭제해주는 플러그인입니다.
지금까지는 필요할 때마다 dist 폴더를 수동으로 삭제했는데, 이 플러그인을 추가하면 빌드할 때마다 dist 폴더를 자동으로 정리하고 새로운 파일을 생성한다.
npm install -D clean-webpack-pluginbash// webpack.config.js
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
plugins: [
new CleanWebpackPlugin(),
// ... 다른 플러그인들
],js주의할 점은 default export가 아니라 named export이므로 중괄호로 import해야 한다.
빌드하면 기존 dist 폴더가 삭제되고 새로운 빌드 결과물이 생성된다.
MiniCssExtractPlugin
스타일시트가 많아지면 JavaScript 파일 하나에 모두 포함시키는 것이 부담스러울 수 있다. 브라우저에서 큰 파일 하나를 로드하는 것보다 여러 작은 파일을 동시에 로드하는 것이 성능에 유리한다.
번들 결과에서 스타일시트 코드만 따로 뽑아서 CSS 파일을 별도로 만들어 역할에 따라 파일을 분리하는 것이 좋다.
최종 결과물이 JavaScript 하나, CSS 파일 하나로 분리되면 각각의 용량이 줄어들고, 브라우저가 병렬로 다운로드할 수 있어 페이지 로딩 성능이 향상된다.
이 플러그인도 서드파티 패키지이므로 설치가 필요한다.
npm install -D mini-css-extract-pluginbash// webpack.config.js
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
module.exports = {
// ...
module: {
rules: [
{
test: /\.css$/,
use: [
// 운영 환경에서는 MiniCssExtractPlugin.loader 사용
// 개발 환경에서는 style-loader 사용
process.env.NODE_ENV === 'production'
? MiniCssExtractPlugin.loader
: 'style-loader',
'css-loader',
],
},
// ... 다른 rules
],
},
plugins: [
// 운영 환경에서만 플러그인 활성화
...(process.env.NODE_ENV === 'production'
? [
new MiniCssExtractPlugin({
filename: '[name].[contenthash].css',
}),
]
: []),
// ... 다른 플러그인들
],
};js이 플러그인은 로더와 플러그인 설정을 모두 추가해야 한다.
- 로더 설정:
style-loader대신MiniCssExtractPlugin.loader사용 - 플러그인 설정: CSS 파일명 지정
개발 환경에서는 빠른 빌드를 위해 JavaScript 파일 하나로 처리하고, 운영 환경에서만 CSS를 분리하는 것이 일반적입니다.
운영 환경으로 빌드하면:
📦 dist/
┣ 📜 main.js
┣ 📜 main.a1b2c3d4.css
┗ 📜 index.htmlindex.html을 확인하면 CSS도 자동으로 로드된다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Document (운영)</title>
<link href="main.a1b2c3d4.css" rel="stylesheet" />
<script defer src="main.js"></script>
</head>
<body></body>
</html>html개발 환경 구성
지금까지 실습한 환경에서는 HTML, JavaScript, CSS, 이미지 파일들을 직접 브라우저에 올려서 결과를 확인했다.
실제 웹 서비스가 제공되려면 인터넷 어딘가에 게시되어야 한다. 인터넷에 연결된 컴퓨터에서 서버 프로그램이 정적 파일을 제공하는 형식이어야 한다.
개발 환경에서도 이와 유사한 환경을 갖추는 것이 좋다. 운영 환경과 동일하게 맞춤으로써 운영 환경에 배포했을 때 발생할 수 있는 문제를 개발 단계에서 미리 확인할 수 있다.
또한 AJAX 방식의 API 연동을 하게 되면 CORS(Cross-Origin Resource Sharing) 브라우저 보안 정책 때문에 개발 환경에서 API를 사용하는 것이 불가능할 수 있다. 이런 이유로 개발 환경에서도 서버가 반드시 필요한다.
Webpack은 이런 개발 서버를 제공하는 webpack-dev-server를 제공한다.
webpack-dev-server 설정
npm install -D webpack-dev-serverbashpackage.json에 스크립트를 추가한다.
{
"scripts": {
"build": "webpack",
"start": "webpack-dev-server"
}
}json개발 서버를 실행한다.
npm startbash서버가 실행되면 다음과 같은 메시지가 출력된다.
<i> [webpack-dev-server] Project is running at:
<i> [webpack-dev-server] Loopback: http://localhost:8080/
<i> [webpack-dev-server] On Your Network (IPv4): http://192.168.0.10:8080/
<i> [webpack-dev-server] Content not from webpack is served from 'public' directory이제 http://localhost:8080에 접속하여 결과물을 확인할 수 있다.
Webpack 개발 서버는 파일 변화를 감지하여 자동으로 브라우저를 새로고침해주는 기능도 제공한다.
예를 들어 app.css를 수정하면:
/* app.css */
body {
background-color: #e0f7fa; /* 색상 변경 */
}css저장하면 Webpack 개발 서버가 변경을 감지하고 자동으로 브라우저에 반영한다. 브라우저를 수동으로 새로고침할 필요가 없다.
이렇게 Webpack 개발 서버를 사용하면 개발 서버를 제공할 뿐만 아니라, 코드 변화를 감지해서 결과물을 실시간으로 브라우저에 보여주는 매우 편리한 기능을 제공한다.
devServer 상세 설정
Webpack 개발 서버의 설정을 살펴보겠다.
// webpack.config.js (Webpack 4)
const path = require('path');
module.exports = {
// ...
devServer: {
// 정적 파일을 제공할 경로 (기본값: output 경로)
contentBase: path.join(__dirname, 'dist'),
// 브라우저를 통해 접근하는 경로
publicPath: '/',
// 개발 환경 도메인 설정
// 쿠키 기반 인증은 인증 서버와 동일한 도메인이어야 함
host: 'dev.domain.com',
// 빌드 시 에러나 경고를 브라우저 화면에 표시
overlay: true,
// 개발 서버 포트
port: 8081,
// 메시지 수준 설정
stats: 'errors-only', // 'none', 'errors-only', 'minimal', 'normal', 'verbose'
// History API를 사용하는 SPA 개발 시 설정
// 404 발생 시 index.html로 리다이렉트
historyApiFallback: true,
},
};jsWebpack 5에서는 일부 옵션이 변경되었다.
// webpack.config.js (Webpack 5)
const path = require('path');
module.exports = {
// ...
devServer: {
// 정적 파일 제공 경로
static: {
directory: path.join(__dirname, 'dist'),
publicPath: '/',
},
// 개발 도메인 설정
host: 'dev.domain.com',
// 개발 포트
port: 8081,
// SPA의 History API 지원
historyApiFallback: true,
// 브라우저 오버레이 설정
client: {
overlay: {
errors: true,
warnings: false,
},
logging: 'error',
},
// HMR 활성화 (기본값 true)
hot: true,
// 터미널 메시지 레벨 조정
infrastructureLogging: {
level: 'error',
},
// 서버 구동 시 자동으로 브라우저 열기
open: true,
},
};jsAPI 서버 연동 및 CORS 해결
API 없이 개발하게 되면 프론트엔드 개발할 때 난감할 때가 있다. 목업(Mock) 데이터를 제공하는 목업 API를 만들어서 화면을 개발하면 더 수월한다.
Webpack 개발 서버는 목업 API를 제공하는 기능도 있다.
// webpack.config.js (Webpack 4)
module.exports = {
devServer: {
overlay: true,
stats: 'errors-only',
// before 함수로 Express 앱에 접근
before: (app) => {
// app은 Express 서버 인스턴스
app.get('/api/users', (req, res) => {
res.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
]);
});
},
},
};jsWebpack 5부터는 setupMiddlewares를 사용한다.
// webpack.config.js (Webpack 5)
module.exports = {
devServer: {
client: {
overlay: true,
logging: 'error',
},
setupMiddlewares: (middlewares, devServer) => {
if (!devServer) {
throw new Error('webpack-dev-server is not defined');
}
// Express 인스턴스 접근
const app = devServer.app;
// 목업 API 엔드포인트 등록
app.get('/api/users', (req, res) => {
res.json([
{ id: 1, name: 'Alice' },
{ id: 2, name: 'Bob' },
{ id: 3, name: 'Charlie' },
]);
});
// middlewares 반환 필수
return middlewares;
},
},
};jsapp.get, app.post, app.use 등 Express의 모든 메서드를 사용할 수 있다.
이제 Webpack 개발 서버로 API 요청을 날려보겠다.
npm install axiosbash// app.js
import axios from 'axios';
import './app.css';
import * as calculator from './calculator.js';
document.addEventListener('DOMContentLoaded', async () => {
try {
const res = await axios.get('/api/users');
console.log('API 응답:', res.data);
} catch (error) {
console.error('API 오류:', error);
}
console.log(calculator.multiply(5, 3));
console.log(calculator.divide(10, 2));
});js개발 서버를 실행하고 브라우저 콘솔을 확인하면:
콘솔 출력:
API 응답: [{id: 1, name: "Alice"}, {id: 2, name: "Bob"}, {id: 3, name: "Charlie"}]
15
5데이터를 성공적으로 받아온 것을 확인할 수 있다.
실제 프로젝트에서는 connect-api-mocker 같은 전용 패키지를 사용하는 것이 더 편리한다. API 응답값을 JSON 파일로 만들어 놓고 개발 서버에 연동시킬 수 있다.
npm install -D connect-api-mockerbash프로젝트에 mocks/api/users/GET.json 파일을 생성한다.
mocks/
└─ api/
└─ users/
└─ GET.json[
{ "id": 1, "name": "Alice" },
{ "id": 2, "name": "Bob" },
{ "id": 3, "name": "Charlie" }
]jsonWebpack 5 설정:
// webpack.config.js (Webpack 5)
const ConnectApiMocker = require('connect-api-mocker');
module.exports = {
devServer: {
setupMiddlewares: (middlewares, devServer) => {
if (!devServer) {
throw new Error('webpack-dev-server is not defined');
}
// /api 요청을 mocks/api 폴더의 파일로 매핑
devServer.app.use(ConnectApiMocker('/api', 'mocks/api'));
return middlewares;
},
},
};js이제 /api/users 요청은 자동으로 mocks/api/users/GET.json 파일을 반환한다.
CORS 문제 해결하기
실제 API 서버가 만들어졌다고 가정해보겠다.
현재 Webpack 개발 서버는 http://localhost:8080에서 실행되고 있고, API 서버가 http://localhost:8081에 있다면 어떻게 될까요?
이 경우 Ajax 요청을 하면 CORS 에러가 발생한다. 브라우저의 동일 출처 정책(Same-Origin Policy) 때문입니다.
서버에서 Access-Control-Allow-Origin 헤더를 추가하는 방법이 있다.
// server/index.js
app.get('/api/keywords', (req, res) => {
res.header('Access-Control-Allow-Origin', '*'); // 모든 도메인 허용
res.json(keywords);
});js서버 응답 헤더를 수정할 필요 없이, Webpack 개발 서버에서 API 서버로 프록시하는 방법도 있다. 이 방법이 개발 환경에서 더 편리한다.
// webpack.config.js
module.exports = {
devServer: {
proxy: {
'/api': 'http://localhost:8081',
},
},
};js이 설정은 개발 서버에 들어온 모든 HTTP 요청 중 /api로 시작하는 요청을 http://localhost:8081로 프록시한다.
클라이언트 코드에서는 상대 경로만 사용하면 된다.
// API 호출 코드
import axios from 'axios';
// 전체 URL 대신 상대 경로 사용
const { data } = await axios.get('/api/keywords');js이렇게 설정하면 CORS 에러 없이 정상적으로 API를 호출할 수 있다.
참고: Next.js에서는
rewrites속성으로, Webpack에서는proxy속성으로 동일한 기능을 제공한다.
Hot Module Replacement (HMR)
Hot Module Replacement(HMR)는 애플리케이션이 실행되는 동안 변경된 모듈만 교체하는 기능입니다.
예를 들어 폼 데이터에 무언가 입력한 상태에서 관련 없는 다른 모듈을 수정했을 때, 폼에 입력한 데이터가 날아가지 않도록 해줍니다.
Webpack 5에서는 hot 옵션이 기본적으로 true로 설정되어 있다.
// webpack.config.js (Webpack 5)
module.exports = {
devServer: {
hot: true, // 기본값이 true이므로 생략 가능
},
};jsHMR을 제대로 사용하려면 모듈이 HMR 인터페이스를 구현해야 한다.
// app.js
if (module.hot) {
console.log('HMR이 활성화되었다');
// calculator 모듈 변경 감지
module.hot.accept('./calculator', () => {
console.log('calculator 모듈이 변경되었다');
// 모듈 다시 로드
});
}js이렇게 HMR을 사용하면 코드가 변경될 때 전체 화면을 새로고침하지 않고 변경된 모듈만 교체된다. 개발 속도를 크게 향상시킬 수 있다.
많이 사용되는 로더들은 HMR을 기본적으로 지원한다.
대표적인 예로 style-loader가 있다. CSS 파일을 수정하면 페이지 새로고침 없이 스타일만 즉시 반영된다.
style-loader 내부적으로는 다음과 같은 HMR 코드가 구현되어 있다.
// style-loader 내부 구현 예시
if (module.hot) {
module.hot.accept();
module.hot.dispose(() => {
// 스타일 업데이트 로직
});
}js최적화
Webpack에서 번들 결과물을 최적화하는 방법을 알아보겠다.
코드가 많아지면 번들된 결과물인 JavaScript 파일이 메가바이트 단위까지 커질 수 있다. 브라우저에서 큰 파일을 다운로드하는 것은 성능에 큰 영향을 줍니다.
Webpack으로 번들 결과를 압축하고, 상황에 따라 작은 파일 여러 개로 분리하는 방법을 살펴보겠다.
번들 크기 최적화
mode 설정
가장 간단한 방법은 mode 값을 설정하는 것입니다.
// webpack.config.js
module.exports = {
mode: 'production', // 운영 환경에서는 production 사용
};js개발 환경에서는 디버깅 편의를 위해 다음 플러그인들을 사용한다.
NamedChunksPluginNamedModulesPlugin
또한 DefinePlugin을 통해 process.env.NODE_ENV 값이 "development"로 설정되어 애플리케이션 전역 변수로 주입된다.
mode를 "production"으로 설정하면 JavaScript 결과물을 최소화하기 위해 다음 플러그인들을 자동으로 사용한다.
FlagDependencyUsagePluginFlagIncludedChunksPluginModuleConcatenationPluginNoEmitOnErrorsPluginOccurrenceOrderPluginSideEffectsFlagPluginTerserPlugin
process.env.NODE_ENV 값이 "production"으로 설정된다.
환경 변수 NODE_ENV 값에 따라 자동으로 모드를 설정하도록 구성할 수 있다.
// webpack.config.js
const mode = process.env.NODE_ENV || 'development';
module.exports = {
mode,
};jspackage.json의 스크립트를 다음과 같이 설정한다.
{
"scripts": {
"build": "NODE_ENV=production webpack --progress",
"start": "webpack-dev-server --progress"
}
}json이제 npm run build를 실행하면 production 모드로 빌드된다. 빌드된 파일을 확인하면 코드가 난독화되고 압축된 것을 볼 수 있다.
optimization 속성
optimization 속성을 사용하면 빌드 과정을 더욱 세밀하게 커스터마이징할 수 있다.
HtmlWebpackPlugin이 HTML 파일을 압축하는 것처럼, CSS 파일도 공백을 제거하여 압축할 수 있다.
먼저 플러그인을 설치한다.
npm install -D css-minimizer-webpack-pluginbashWebpack 설정에 플러그인을 추가한다.
// webpack.config.js
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const mode = process.env.NODE_ENV || 'development';
module.exports = {
mode,
// ...
optimization: {
minimizer: mode === 'production' ? [new CssMinimizerPlugin()] : [],
},
};jsoptimization.minimizer는 Webpack이 결과물을 압축할 때 사용할 플러그인 배열입니다. 위 설정을 통해 빌드 결과물 중 CSS 파일이 압축된다.
mode=production일 때 자동으로 사용되는 TerserWebpackPlugin은 JavaScript 코드를 난독화하고 debugger 구문을 제거한다.
기본 설정 외에도 콘솔 로그를 제거하는 옵션을 추가할 수 있다. 배포 버전에서는 보안을 위해 콘솔 로그를 제거하는 것이 좋다.
먼저 플러그인을 설치한다.
npm install -D terser-webpack-pluginbashoptimization.minimizer 배열에 플러그인을 추가한다.
// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');
module.exports = {
optimization: {
minimizer:
mode === 'production'
? [
new TerserPlugin({
terserOptions: {
compress: {
drop_console: true, // 콘솔 로그 제거
},
},
}),
new CssMinimizerPlugin(),
]
: [],
},
};js참고: 개발 환경에서는 디버깅을 위해 콘솔 로그가 필요하지만, 운영 환경에서는 민감한 정보 노출을 방지하기 위해 제거하는 것이 좋다.
코드 스플리팅 (Code Splitting)
코드를 압축하는 것 외에도, 결과물을 여러 개로 분리하면 브라우저 다운로드 속도를 높일 수 있다. 큰 파일 하나를 다운로드하는 것보다 작은 파일 여러 개를 동시에 다운로드하는 것이 더 빠르기 때문입니다.
가장 단순한 방법은 엔트리를 여러 개로 분리하는 것입니다.
// webpack.config.js
module.exports = {
entry: {
main: './src/app.js',
sub: './src/sub.js',
},
};js빌드하면 dist 폴더에 main.js와 sub.js 두 개의 파일이 생성된다. HtmlWebpackPlugin이 HTML 파일에 두 스크립트를 자동으로 포함시킵니다.
그런데 main.js와 sub.js 모두 axios 라이브러리를 사용한다면, 같은 코드가 두 파일에 중복으로 포함된다.
이럴 때 splitChunks를 사용하면 중복 코드를 별도 파일로 분리할 수 있다.
// webpack.config.js
module.exports = {
optimization: {
splitChunks: {
chunks: 'all',
},
},
};js빌드하면 다음과 같은 파일들이 생성된다.
📦 dist/
┣ 📜 main.js # main 엔트리 고유 코드
┣ 📜 sub.js # sub 엔트리 고유 코드
┗ 📜 vendors-main-sub.js # 공통으로 사용하는 코드 (axios 등)전체 용량이 줄어든 것을 확인할 수 있다.
ls -lh distbash엔트리 포인트를 수동으로 분리하는 것은 관리가 어렵다. Dynamic Import를 사용하면 Webpack이 자동으로 코드를 분리해줍니다.
기존의 정적 import 대신 동적 import를 사용한다.
변경 전 (정적 import):
import calculator from './calculator';js변경 후 (동적 import):
document.addEventListener('DOMContentLoaded', async () => {
// 동적으로 모듈 불러오기
const module = await import(
/* webpackChunkName: "calculator" */ './calculator.js'
);
const calculator = module.default || module;
console.log(calculator.multiply(5, 3));
console.log(calculator.divide(10, 2));
});js/* webpackChunkName: "calculator" */ 주석은 Webpack이 생성할 청크 파일의 이름을 지정하는 매직 코멘트입니다. 이를 통해 생성되는 파일명을 제어할 수 있다.
Dynamic Import를 사용하면 멀티 엔트리 포인트 설정을 제거할 수 있다.
// webpack.config.js
module.exports = {
entry: {
main: './src/app.js',
// calculator 엔트리는 더 이상 필요하지 않음
},
// splitChunks 설정도 제거 가능
};js빌드하면 다음과 같은 파일들이 생성된다.
📦 dist/
┣ 📜 main.js # 메인 번들
┣ 📜 calculator.js # Dynamic Import로 분리된 청크
┗ 📜 vendors-calculator.js # calculator에서 사용하는 외부 라이브러리Dynamic Import를 사용하면 엔트리 포인트를 수동으로 관리하지 않아도 Webpack이 자동으로 코드를 분리해줍니다.
externals로 번들 최적화
번들 크기를 줄이는 마지막 방법은 애초에 번들에 포함하지 말아야 할 라이브러리를 빌드 범위에서 제외하는 것입니다.
axios와 같은 외부 라이브러리는 npm에서 다운로드한 이미 빌드된 파일입니다. 이런 라이브러리를 Webpack으로 다시 빌드할 필요가 없다.
externals 설정을 사용하면 특정 라이브러리를 번들에서 제외하고 전역 변수로 사용하도록 설정할 수 있다.
// webpack.config.js
module.exports = {
externals: {
axios: 'axios', // axios 모듈을 전역 변수 axios로 대체
},
};js이제 Webpack은 코드에서 axios를 발견해도 번들에 포함시키지 않고, 전역 변수 axios를 사용하도록 빌드한다.
externals로 제외한 라이브러리는 직접 HTML에서 로드해야 하므로, 빌드된 파일을 dist 폴더로 복사해야 한다.
copy-webpack-plugin을 설치한다.
npm install -D copy-webpack-pluginbash// webpack.config.js (Webpack 5)
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
externals: {
axios: 'axios',
},
plugins: [
new CopyPlugin({
patterns: [
{
from: './node_modules/axios/dist/axios.min.js',
to: './axios.min.js',
},
],
}),
],
};jssrc/index.html에 axios를 로드하는 스크립트 태그를 추가한다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<!-- externals로 제외한 라이브러리는 직접 로드 -->
<script src="axios.min.js"></script>
</body>
</html>html빌드하면 dist 폴더에 axios.min.js가 복사되고, index.html에서 이를 로드하는 것을 확인할 수 있다.
npm run build
ls dist # axios.min.js 파일 확인bashexternals의 장점은 다음과 같다.
- 번들 크기 감소: 이미 빌드된 라이브러리를 제외하여 번들 크기가 줄어듭니다.
- 빌드 속도 향상: 외부 라이브러리를 빌드하지 않아 빌드 시간이 단축된다.
- 개발/운영 환경 모두 개선: 개발 서버 시작 시간과 운영 빌드 시간 모두 단축된다.
이처럼 이미 빌드된 외부 라이브러리는 externals로 빼서 관리하는 것이 효율적입니다.
핵심 정리
Webpack 학습 핵심 포인트
-
모듈 시스템의 필요성: 전역 스코프 오염을 방지하고 코드를 체계적으로 관리하기 위해 모듈 시스템이 필요한다.
-
Webpack의 역할: 여러 모듈을 하나의 번들 파일로 합쳐주는 번들러다. 모든 파일(JS, CSS, 이미지)을 모듈로 처리한다. -
로더: 각 파일을 JavaScript 모듈로 변환한다. (css-loader,style-loader, Asset Modules 등) -
플러그인: 번들된 결과물 전체를 후처리한다. (BannerPlugin, DefinePlugin, HtmlWebpackPlugin 등) -
개발 서버:webpack-dev-server로 개발 환경을 운영 환경과 유사하게 구성하고 HMR로 개발 속도를 향상시킬 수 있다. -
최적화:mode설정으로 자동 최적화- CSS/JS 압축
- 코드 스플리팅으로 파일 분리
externals로 외부 라이브러리 제외
-
Webpack 4 vs 5: Output 옵션, Asset Modules, devServer 설정 등에서 차이가 있으므로 버전에 맞는 설정을 사용해야 한다.
마치며: 현대 번들러 생태계
이제 Webpack의 핵심 개념과 활용 방법을 모두 살펴보았다. 하지만 말씀드리고 싶은 부분이 있다. 요즘은 Webpack뿐만 아니라 다양한 번들러가 등장했고 프로젝트마다 사용하는 도구가 달라지고 있다.
Vite가 최근에는 거의 표준처럼 자리잡았다고 볼 수 있다. 대부분의 신규 프로젝트에서 Vite를 사용하고 있는데 개발 서버 속도가 매우 빠르고 개발 환경에서는 esbuild를, 프로덕션 빌드에서는 Rollup을 사용해 안정성도 유지하는 구조 덕분이라고 생각한다.
Rollup은 UI 컴포넌트 라이브러리를 제작할 때 가장 많이 사용된다. 디자인 시스템이나 내부 공용 컴포넌트를 개발할 때 Rollup의 깔끔한 번들링과 Tree-shaking이 큰 장점으로 작용한다.
Webpack은 오래된 프로젝트에서 여전히 많이 사용되고 있다. 다양한 플러그인 생태계의 이점도 있고 수년간 유지해온 설정을 쉽게 바꿀 수 없다는 현실적인 이유도 있다.
이 글의 내용이 최신 트렌드와 다소 차이가 있는 것은 사실이다. 하지만 "번들러의 동작 원리"를 이해하는 것이 가장 중요하다. Webpack을 통해 로더와 플러그인의 개념, 모듈 시스템의 필요성, 최적화 방법을 익히면 이 지식을 Vite, Rollup, esbuild 등 최신 도구에도 그대로 적용할 수 있다.
도구는 계속 변하지만 번들러가 해결하려는 근본적인 문제는 동일하다. 이러한 원리를 이해하고 직접 최신 도구에 적용하는 과정에서 크게 성장할 수 있다고 생각한다. 이것이 바로 실무 감각을 키우는 방법이기도 하다.
실제 프로젝트에서 번들러를 선택하고 설정할 때 이 글에서 배운 개념들을 토대로 각 도구의 특성을 비교하고 활용해보시기 바란다.