1. 배경
문법 수준에서 모듈을 지원하기 시작한 것은 ES2015(ES6)부터 입니다.
import / export 구문이 없었던 모듈 이전 상황에서는, 자바스크립트 파일을 HTML의 <script> 태그를 통해 순서대로 로딩하는 방식으로 코드를 분리했습니다.
//math.js
function sum(a, b) {
return a + b
} // 전역 공간에 sum이 노출
// app.js
sum(1, 2) // 3
이 두 파일을 모두 하나의 HTML 파일 안에서 로딩하여 실행하게 되면, math.js가 먼저 로딩된 이후 app.js는 전역 스코프(Global Scope)에서 sum 식별자를 찾은 뒤 해당 함수를 실행합니다.
이때 문제는 sum이라는 변수(정확히는 함수 값을 참조하는 식별자)가 전역 공간에 노출된다는 것입니다.
만약 다른 파일에서도 sum이라는 동일한 이름을 사용한다면 전역스코프에서 이름 충돌(Name Collision)하게 됩니다.
1-1. IIFE 방식의 모듈
이러한 전역 오염 문제를 해결하기 위해 스코프(Scope)를 활용한 패턴이 등장했습니다. 이때 대표적인 방식이 IIFE (Immediately Invoked Function Expression)이라는 즉시실행 함수입니다.
IIFE는 즉시 실행되는 함수 표현식을 통해 함수 스코프(Function Scope) 를 만들고, 그 내부에 변수와 로직을 감추는 방식입니다.
// math.js
var math = math || {} // math 네임스페이스
;(function () {
function sum(a, b) {
return a + b
}
math.sum = sum // 네임스페이스에 추가
})()
즉시 실행 함수로 작성된 위 코드는 다음과 같은 특징을 가집니다.
- IIFE 내부에 독립적인 지역 스코프(Local Scope) 가 생성됨
- sum 식별자는 해당 스코프 내부에만 존재
- 외부에서는 sum에 직접 접근할 수 없음
- 오직 math 객체를 통해서만 기능을 사용할 수 있음 (
math.sum(1, 2))
IIFE 방식은 전역 오염 문제를 상당 부분 해결했지만, 몇 가지 한계를 가지고 있습니다.
-
의존성 관계가 명확하지 않음
- 어떤 파일이 math에 의존하는지 코드만 보고 알기 어렵습니다.
// math.js var math = math || {} ;(function () { function sum(a, b) { return a + b } math.sum = sum })()// calculator.js var calculator = calculator || {} ;(function () { function addAndDouble(a, b) { // math가 먼저 로딩되어 있어야 함 return math.sum(a, b) * 2 } calculator.addAndDouble = addAndDouble })()위 코드에서 calculator.js는 암묵적으로 math.js에 의존하고있지만,
- 코드 상에 의존성 선언이 없고,
- 어떤 파일을 먼저 로딩해야 하는지 명시되어 있지 않습니다.
- 이는 개발자가 직접 순서를 기억해야는
⇒ 의존성 관계가 숨겨진 상태(hidden dependency)가 됩니다.
-
로딩 순서에 강하게 의존
HTML에서
<script>태그의 로딩 순서가 곧 실행 순서가 됩니다.<!-- ❌ 잘못된 순서 --> <script src="calculator.js"></script> <script src="math.js"></script>위와 같이 로딩시에 calculator.js 실행시점에 math.js가 로딩되지 않아 정의되지 않았기 때문에 런타임 에러가 발생하게 됩니다. (Uncaught ReferenceError: math is not defined)
이러한 순서 관리는 파일이 많아질수록 더 어려워집니다.
-
정적 분석이 어려움
IIFE 방식은 전역 객체에 런타임으로 속성을 추가하는 구조이기 때문에, 도구가 코드를 실행하지 않고 다음 사항을 판단하기 어렵습니다.
- 어떤 모듈이 어떤 모듈을 사용하는지
- 어떤 함수나 로직이 실제로 사용되는지
;(function () { function sum(a, b) { return a + b } function multiply(a, b) { return a * b } math.sum = sum math.multiply = multiply })()// app.js math.sum(1, 2)사람은 multiply가 사용되지 않는다는 것을 쉽게 알 수 있지만, 도구 입장에서는 math.multiply가 다른 파일이나 다른 시점에서 사용될 가능성을 배제할 수 없습니다.
이로 인해
- 의존성 그 래프를 만들기 어렵고,
- 사용되지 않는 코드(dead code)를 제거할 수 없으며
- 트리 셰이킹과 같은 최적화가 불가능합니다.
이처럼 IIFE 방식의 모듈은 전역 오염 문제를 해결했지만, 모듈 간 의존성과 로딩 순서를 사람이 직접 관리해야 했다는 한계를 가집니다. 이러한 한계로 인해 더 체계적인 모듈 시스템에 대한 요구가 커지게됩니다.
1-2. 다양한 모듈 스펙
자바스크립트는 오랫동안 언어 차원의 공식 모듈 시스템을 제공하지 않았기 때문에, 각 실행 환경에 맞춰 커뮤니키 주도의 모듈 스펙이 등장하게 됩니다.
대표적인 것이 CommonJS, AMD, UMD입니다.
CommonJS
CommonJS는 자바스크립트를 사용하는 모든 환경에서 모듈을 사용할 수 있도록 하는 것을 목표로 한 명세입니다.
- 동기적 로딩 방식
require()함수로 모듈을 불러옵니다.exports또는module.exports로 모듈을 내보냅니다.
// math.js
exports.sum = function (a, b) {
return a + b
}
// app.js
const math = require("./math.js")
math.sum(1, 2) // 3
CommonJS는 서버 환경에서는 파일 시스템 접근이 빠르고, 초기 로딩 시 모든 모듈을 한 번에 불러와도 문제가 없기 때문에 Node.js의 기본 모듈 시스템으로 채택되었습니다.
다만 브라우저 환경에서는 동기 로딩으로 인한 성능 문제 때문에 적합하지 않다는 한계를 가집니다.
-
💡 브라우저 환경에서는 왜 동기 로딩이 적합하지 않을까?
CommonJS의
require()는 동기(synchronous) 입니다.const math = require("./math")위 코드의 의미는, 1) 이 파일의 실행을 멈추고, 2) math 모듈을 즉시 로드하고 평가한 뒤, 3) 그 결과를 반환해라 입니다.
즉,
require()가 끝나기 전까지는 다음 코드로 절대 진행하지 않습니다.서버(Node.js)에서는 왜 이런 방식이 괜찮을까요?
Node.js 환경에서는
- 모듈 파일이 실행 중인 머신의 로컬 파일 시스템에 존재하며
- 네트워크 요청 없이 동기적으로 로딩할 수 있고
- 서버는 일반적으로 시작 시점에 모든 모듈을 한 번에 로딩한 뒤, 초기화가 완료된 이후에 요청을 처리
다만 브라우저 환경에서는 모듈 로딩이 네트워크 요청을 수반하며, 동기 로딩은 메인 스레드를 차단해 렌더링과 사용자 입력을 멈추게 되므로 CommonJS 방식은 브라우저 환경에 적합하지 않다는 한계를 가집니다.
AMD (Asynchronous Module Definition)
AMD는 비동기 로딩이 필요한 브라우저 환경을 주요 대상으로 설계된 모듈 스펙입니다.
- 비동기 로딩을 전제로 합니다.
define()함수를 통해 모듈을 정의합니다.- 의존성을 배열 형태로 명시적으로 선언합니다.
// math.js
define([], function () {
return {
sum(a, b) {
return a + b
},
}
})
// app.js
define(["./math"], function (math) {
math.sum(1, 2)
})
AMD는 아래와 같은 장점이 있습니다.
- 네트워크 지연에 안전하고
- 필요한 모듈만 비동기로 로딩할 수 있으며
- 브라우저 환경에서 동기 로딩 문제를 피할 수 있습니다.
대표적인 구현체로는 RequireJS가 있습니다.
하지만 문법이 장황하고, 코드 가독성과 유지보수 측면에서 불리하다는 단점이 있었습니다.
UMD (Universal Module Definition)
UMD는 하나의 코드로 CommonJS와 AMD를 모두 지원하기 위한 패턴입니다.
실행환경을 런타임에 판별하여, 각 환경에 맞는 방식으로 모듈을 노출합니다.
;(function (root, factory) {
if (typeof define === "function" && define.amd) {
// AMD
define([], factory)
} else if (typeof module === "object" && module.exports) {
// CommonJS
module.exports = factory()
} else {
// Browser Global
root.myLib = factory()
}
})(this, function () {
return {
sum(a, b) {
return a + b
},
}
})
UMD는 브라우저와 서버 어디에서든 동작해야하는 라이브러리 배포에 유용했지만,
- 코드가 복잡해지고
- 의존성 관계가 여전히 런타임에 결정되며
- 정적 분석이 어렵다는 한계를 가집니다.
CommonJS, AMD, UMD는 각각의 실행 환경에서 IIFE 방식의 한계를 보완하려는 시도였습니다.
- CommonJS는 서버 환경에 적합한 동기 모듈 로딩을 제공했고
- AMD는 브라우저 환경을 고려한 비동기 모듈 로딩을 제안했으 며
- UMD는 여러 환경을 동시에 지원하기 위한 절충안을 제시했습니다.
하지만 이들 모듈 스펙은 모두 언어 차원의 표준이 아니었고, 환경마다 문법과 로딩 방식이 달랐으며, 의존성 관계를 도구가 정적으로 분석하기 어렵다는 공통적인 한계를 가지고 있었습니다.
이러한 문제를 해결하기 위해 ES2015(ES6) 에서 자바스크립트 언어 차원의 표준 모듈 시스템인 ES Module(import / export) 이 도입됩니다.
1-3. 브라우저의 모듈 지원
ES Module이 표준으로 도입되면서, 브라우저 역시 import / export 문법을 직접 실행할 수 있게 되었습니다.
브라우저에서는 <script> 태그에 type=”module” 속성을 사용해 모듈을 로딩할 수 있습니다.
<script type="module" src="app.js"></script>
type=”module” 로 로딩된 스크립트는 ES Module 문법을 사용할 수 있으며, 각 파일은 독립적인 모듈 스코프를 가지게 됩니다.
다만, 모든 이전에는 브라우저가 동일한 수준 으로 모듈 시스템을 지원하지는 않았습니다. 하지만 현재는 IE제외 거의 모든 브라우저에서 ES Module을 지원합니다.
하지만, 브라우저가 ES Module을 지원한다고 해서 모듈 시스템과 관련된 모든 문제가 해결된 것은 아닙니다. 브라우저 네이티브의 모듈 지원에는 다음과 같은 현실적인 제약이 존재합니다.
- 모듈 파일 하나당 네트워크 요청이 발생하여, 모듈 수가 많아질수록 요청 수가 급격히 증가합니다.
- ES Module 지원과 최신 자바스크립트 문법 지원은 별개의 문제로, 브라우저별 문법 지원 격차는 여전히 존재합니다.
- CSS , 이미지, 폰트와 같은 비 JavaScript 자원은 모듈 의존성으로 직접 관리할 수 없습니다.
- 코드 분할, 트리셰이킹, 압축과 같은 빌드 단계 최적화를 브라우저가 직접 수행하지 않습니다.
즉, 브라우저의 ES Module 지원은 모듈 문법을 실행할 수 있는 최소한의 환경을 제공할 뿐, 실제 서비스에 필요한 의존성 관리와 최적화까지는 책임지지 않습니다.
이러한 이유로 개발자는 ES Module 문법을 사용하면서도 브라우저 환경에 의존하지 않고, 안정적이고 최적화된 결과물을 만들 수 있는 도구를 필요로 하게 됩니다.
이 문제를 해결하기 위해 등장한 것이 **웹팩(Webpack)**과 같은 번들러입니다.
웹팩은 ES Module을 기반으로 애플리케이션의 모든 의존성을 분석하여 의존성 그래프를 구성하고, 브라우저가 효율적으로 실행할 수 있는 형태로 코드를 번들링합니다.
2. Webpack
웹팩(Webpack)은 모듈 기반으로 작성된 애플리케이션을 분석하여, 브라우저에서 실행할 수 있는 결과물로 만들어주는 번들러(Bundler) 입니다.
개발 단계에서는 애플리케이션이 수많은 파일과 모듈로 나뉘어 있지만, 브라우저는 이 모든 모듈을 효율적으로 로딩하고 실행하는 데 한계가 있습니다.
웹팩은 이러한 간극을 빌드 단계에서 미리 해결합니다.
2-1. 번들러(Bundler)와 번들링(Bundling)
번들러(Bundler)?
번들러는 모듈 간 의존성을 분석하여, 여러 파일로 흩어진 코드를 하나 또는 몇 개의 파일로 묶어 브라우저에서 실행 가능한 형태로 만들어주는 도구입니다.
여기서 “묶는다”는 의미는 단순히 파일을 이어붙이는(concatenate) 수준이 아닙니다. 번들러는 각 파일이 어떤파일을 필요로 하는지(import / require)를 따라가며 전체 관계를 정리하고, 브라우저가 실행 가능한 구조로 다시 구성합니다.
이 과정에서 번들러는 다음과 같은 일을 수행합니다.
- 모듈 간 의존 관계를 파악하고 (누가 누구를 가져오는지)
- 실행 순서를 보장하며 (필요한 모듈이 먼저 준비되도록)
- 파일 단위 스코프를 분리해 충돌을 방지하고 (전역 오염 / 이름 충돌 방지)
- 배포에 적합한 형태로 최적화된 결과물을 생성합니다. (압축, 분할 등)
웹팩은 이러한 역할을 수행하는 대표적인 번들러입니다.
번들링(Bundling)
번들링은 번들러 가 위 작업을 수행해 최종 결과물을 만들어내는 빌드 과정 전체를 의미합니다. 웹팩에서 번들링 과정은 보통 다음 흐름으로 진행됩니다.
1) 엔트리(Entry)에서 전체 모듈 추적
웹팩은 하나의 시작 파일(entry)부터 분석을 시작합니다. 그리고 그 파일이 import하는 모듈, 그 모듈이 다시 import하는 모듈을 연쇄적으로 따라가며 전체 애플리케이션을 수집합니다.
이때 웹팩이 머릿속에 만드는 “관계 지도”를 **의존성 그래프(Dependency Graph)**라고 합니다. 의존성 그래프는 별도의 추가 작업이라기 보다, 번들링을 하려면 필연적으로 필요한 분석 결과물 입니다.
2) 의존성 그래프 기준으로 번들 구조 구성
의존성 그래프가 만들어지면, 웹팩은 이 관계를 기준으로 “어떤 코드들이 한 덩어리로 묶여야 하는지”를 결정합니다. 기본 설정에서는 엔트리를 기준으로 하나의 번들로 만들지만, 상황에 따라 여러 개의 번들(청크)로 나눌 수도 있습니다.
3) 브라우저에서 실행 가능한 형태로 모듈 변환
브라우저는 파일 단위 모듈 시스템을 그대로 실행하지 못하거나(구형 환경), 또는 모듈 수가 많아질수록 네트워크 요청이 증가해 비효율적일 수 있습니다. 웹팩은 이 문제를 해결하기 위해 모듈들을 번들 내부에서 관리할 수 있도록 형태를 바꿉니다.
예를 들어, 각 모듈은 번들 내부에서 독립적으로 실행되도록 감싸지고, 모듈 간 참조는 번들 내부의 “로딩 함수”를 통해 연결됩니다. 덕분에 브라우저는 최종 번들 파일만 로딩하면 애플리케이션을 실행할 수 있게 됩니다.
4) 사용하지 않는 코드 제거 (트리 셰이킹)
웹팩이 의존성 그래프를 분석하는 이유는 단지 “묶기 위해서”만은 아닙니다. 이 분석 덕분에, 어떤 코드가 실제로 쓰이는지/안 쓰이는지까지 판단할 수 있고, 그 결과 불필요한 코드를 제거하는 최적화가 가능해집니다.
이때 대표적인 최적화가 **트리 셰이킹(Tree Shaking)**입니다.
트리 셰이킹은 ES Module의 import / export 구조를 기반으로 “실제로 참조되지 않는 export”를 번들 결과물에서 제거하는 방식입니다.
즉, 번들링 과정에서 의존성 관계를 정리하기 때문에 가능한 최적화입니다.
정리하면, 트리 셰이킹은 ‘별도의 기능’이라기 보다, 번들링 과정에서 의존성 관계를 정적으로 분석할 수 있을때 자연스럽게 따라오는 최적화입니다.
2-2. 엔트리(Entry)와 아웃풋(Output)
이제 웹팩이 번들링을 어떤 흐름으로 수행하는지 보았으니, 다음으로는 그 과정의 출발점인 엔트리(Entry)와 번들 결과물이 생성되는 아웃풋(Output)을 살펴보겠습니다.
웹팩의 설치는 번들 작업을 하는 webpack 패키지와 웹팩 터미널 도구인 webpack-cli를 설치합니다.
$ npm install -D webpack webpack-cli
설치를 완료하면 node_modules/.bin 폴더에 실행 가능한 명령어가 몇 개 생기며, webpack과 webpack-cli가 있는데 둘 중 하나를 실행하면 됩니다. —help 옵션을 통해 가능한 사용 방법을 확인해볼 수 있습니다.
터미널에 npx webpack --help 를 입력시에 간단하게 확인할 수 있습니다.
webpack의 옵션은 매우 많지만, 가장 기본적인 번들링을 이해하는데 필요한 옵션은 아래 세 가지 입니다. 이 세가지 옵션만으로도 간단한 번들링을 수행할 수 있습니다.
Options:
--mode <value> Enable production optimizations or development hints.
-o, --output-path <value> The output directory as **absolute path** (required).
--entry <value...> A module that is loaded upon startup. Only the last one is exported.
-
—mode
웹팩의 실행 모드를 저장합니다. development는 개발 환경에 맞춘 설정(디버깅 친화적)을 적용하고, production은 배포를 위한 최적화(압축, 트리셰이킹 등)을 활성화 합니다.
-
—entry
웹팩이 번들링을 시작할 엔트리 파일의 경로를 지정합니다. 이 파일을 기준으로 웹팩은 모든 의존성을 분석합니다.
-
—output-path
번들링 결과물이 생성될 디렉토리 경로를 지정합니다. 이때 출력 경로는 반드시 절대 경로여야합니다.
아래 명령어는 개발모드로 웹팩을 실행하고, ./src/app.js 를 엔트리로 사용해 번들링을 수행합니다. 이 경우 번들 결과물은 기본 설정에 따라 dist/main.js 로 생성됩니다.
npx webpack --mode development --entry ./src/app.js --output-path dist
만약 파일명까지 설정하고 싶을 경우는 아래처럼 파일명을 명시해주면 됩니다.
npx webpack
--mode development
--entry ./src/app.js
--output-path dist
--output-filename main.js
CLI 옵션을 통해 번들된 결과를 index.html에 로딩하면, 번들링 이전과 동일한 결과를 얻을 수 있습니다.
<script src="dist/main.js"></script>
Webpack Config
지금까지는 모든 설정을 CLI 옵션으로 전달했지만, 웹팩은 설정 파일을 통해 동일한 내용을 코드로 관리할 수 있도록 지원합니다.
다양한 옵션들 중 —-config 항목을 보면 아래와 같습니다.
-c, --config <pathToConfigFile...> Provide path to one or more webpack configuration files to process, e.g.
"./webpack.config.js".
—config 옵션은 웹팩 설정 파일의 경로를 지정하는 옵션입니다.
별도로 지정하지 않으면 웹팩은 기본적으로 프로젝트 루트에서 webpack.config.js 또는 webpack.js 파일을 자동으로 찾습니다.
앞서 CLI에 사용했던 옵션들을 설정파일(webpack.config.js)로 옮겨 보겠습니다. 아래 설정은 앞서 CLI에서 사용한 옵션들과 동일한 의미를 가집니다.
// webpack.config.js
const path = require("path")
module.exports = {
mode: "development",
entry: {
main: "./src/app.js",
},
output: {
filename: "[name].js",
path: path.resolve(__dirname, "dist"),
},
}
-
mode : 웹팩 실행 모드를 지정합니다. "development"는 개발 환경에 적합한 설정을 적용한다.
-
entry : 애플리케이션의 진입점을 지정합니다. main은 엔트리의 이름이며, 이후 번들 이름에 사용된다.
-
output.filename : 번들 결과물의 파일 이름을 지정합니다. [name]에는 entry에 정의한 키(main)가 문자열로 치환되고, 기본설정에 따라 결과적으로 main.js 파일이 생성됩니다.
-
output.path : 번들 파일이 생성될 디렉토리의 경로를 지정합니다. 웹팩은 초기부터 출력 경로로 절대 경로를 요구해 왔기 때문에, Node.js의 path.resolve()를 사용해 절대 경로를 계산합니다.
path는 Node.js의 코어 모듈로, 파일 경로를 안전하게 다루기 위한 유틸리티를 제공합니다.
이제 모든 옵션을 설정 파일로 옮겼기 때문에 웹팩 실행 명령어도 npm 스크립트로 관리할 수 있습니다.
// package.json
{
"scripts": {
"build": "webpack"
}
}
이 설정은 내부적으로 다음과 같은 명령을 실행합니다. (설정파일 이름이 기본값이기 때문에 —config 옵션은 생략됩니다.)
webpack --config webpack.config.js
이제 터미널 내부에서 다음 명령어로 웹팩을 실행할 수 있습니다.
npm run build
pnpm run build
다음 편에서는 웹팩을 확장하는 두 가지 핵심 메커니즘인 **로더(Loader)**와 **플러그인(Plugin)**을 다룹니다.