CRA를 쓰다가 vite가 생긴 이후로 더더욱 빌드 환경을 직접 건드리는 경우가 없어졌는데, webpack을 사용해야하는 상황이 되었다. 사실 webpack이 근본(?)있는 모듈 번들러이기 때문에 공부할 필요성을 느꼈는데 동기부여가 좀 부족했기도 했고, webpack 쓸 줄 아냐고 물어보면 머뭇거리게 된다. 내 생각에 webpack을 제대로 이해해야 번들러를 이해했다고 할 수 있다. 이번 기회에 webpack 쓸 줄 아는 사람이 되어보자. 시간이 없다면 마지막 설정 부분만 추가하여도 된다.
[ webpack 핵심 5가지 ]
entry
: webpack이 내부의 디펜던시 그래프 를 생성하기 위해 사용해야 하는 모듈 (진입점)
- 엔트리 포인트가 (직간접적으로) 의존하는 다른 모듈과 라이브러리를 찾아냄
- 기본값은 ./src/index.js
output
: 번들을 내보낼 위치와 파일 이름을 지정하는 방법을 webpack에 알려주는 역할
- 기본 출력 파일의 경우에는 ./dist/main.js로 , 생성된 기타 파일의 경우에는 ./dist 폴더로 설정
loader
: webpack이 JS와 JSON 파일 외에 다른 유형의 파일을 처리할 때 사용
- 다른 유형의 파일들을 유효한 모듈로 변환하여 사용하거나 디펜던시 그래프에 추가
- 변환이 필요한 파일(들)을 식별하는
test
속성- 변환을 수행하는데 사용되는 로더를 가리키는
use
속성plugin
: 번들 최적화, 애셋 관리, 환경 변수 주입 등 광범위한 작업 수행
- require ()를 통해 플러그인을 요청하고 plugins 배열에 추가
mode
: development, production, none 중 설정하여 webpack에 내장된 환경별 최적화 활성화
- 기본값은 production
webpack으로 파일을 빌드할 수 있는 환경을 만들어보자.
webpack.config.js
파일을 루트 디렉토리에 생성하여 webpack 설정을 커스텀할 수 있다. 파일이 없어도 동작하지만 모두 기본값으로만 동작하기 때문에 추가적인 설정이 필요한 경우 파일을 생성한다. 또한 webpack을 cli 환경에서 실행하기 위해 webpack
과 webpack-cli
를 설치한다. webpack 4.0 이상부터 cli를 추가로 설치해야 하며, npm 스크립트를 통해 node_modules에서 webpack을 찾아 실행한다.
$ npm i -D webpack webpack-cli
// webpack.config.js
const path = require("path");
module.exports = {
entry: "./src/test.js",
output: {
filename: "bundle.js",
path: path.resolve(__dirname + "/dist"),
},
mode: "none"
};
package.json은 기본 설정값으로 생성하고, build 스크립트를 추가한 후 build를 실행하면 bundle.js 파일이 생긴 것을 확인할 수 있다.
$ npm init -y
// package.json
{
"scripts": {
"build": "webpack"
},
}
$ npm run build
asset bundle.js 113 bytes [emitted] (name: main)
./src/test.js 29 bytes [built] [code generated]
webpack 5.92.1 compiled successfully in 88 ms
위에서 빌드 환경을 구축하였다. 우리의 최종 목표는 React 개발 환경 구축이다. React 개발 환경을 생각해보면 아래와 같은 index.html을 불러오고, script 태그로 컴포넌트들을 불러왔다. 따라서, HTML을 불러올 수 있어야 한다.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
하지만 webpack은 기본적으로 JavaScript와 JSON 파일만 이해할 수 있기 때문에, HTML을 문자열로 내보내는 html-loader
를 통해 HTML 파일을 webpack이 이해할 수 있다. options 으로는 minimize
라는 코드 최적화 옵션을 추가했는데, 추가하고 빌드 후 HTML 파일을 확인하면 줄바꿈이 없어져 한줄로 표현된 것을 볼 수 있다.
html-webpack-plugin
은 생성된 모든 번들을 자동으로 삽입하여 HTML 파일을 생성한다. plugin 만 적용하면 HTML 파일이 그대로 output으로 나오는데, 그 파일에서 오류가 발생해도 빌드 오류가 나지 않는다. 반대로 loader만 적용하면 해석할 수 있도록 처리했는데 해석할 파일을 주지 않는 셈이므로 빌드 결과로 HTML 파일이 나오지 않는다.
따라서 html-loader라는 loader와 html-webpack-plugin 이라는 plugin을 적용하여 HTML 파일을 읽을 수 있게 되었다.
npm i -D html-webpack-plugin html-loader
// webpack.config.js
const path = require("path");
const HTMLWebpackPlugin = require("html-webpack-plugin");
module.exports = {
...,
module: {
rules: [
{
test: /\.html$/,
use: [
{
loader: "html-loader",
options: { minimize: true }
}
]
}
]
},
plugins: [
new HTMLWebpackPlugin({
template: "./index.html", // 읽을 파일명
filename: "./index.html", // output으로 출력할 파일명
}),
],
};
위에서 작업한 내용으로 HTML 파일을 읽을 수 있으며, script 태그로 불러오는 JS 파일을 불러올 수 있다. 하지만 React 컴포넌트를 사용하기 위해선 js 문법 그대로가 아닌 React의 규칙대로 코드가 변경되어야 한다. React 에서 사용하는 JSX 문법을 JS로 트랜스파일하기 위해 babel
을 사용해야 한다. @babel/preset-react
를 사용하지 않고 render()를 실행하려고 하면 오류가 발생하는데, JSX 문법
을 해석할 수 없어서 발생하는 오류임을 알 수 있다.
$ npm i -D @types/react @types/react-dom @babel/preset-env @babel/preset-react babel-loader
$ npm i react react-dom
// src/index.js
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")).render(<App />);
// .babelrc
{
"presets": [
"@babel/preset-env",
["@babel/preset-react", { "runtime": "automatic" }]
]
}
// webpack.config.js
const path = require("path");
const HTMLWebpackPlugin = require("html-webpack-plugin");
module.exports = {
...,
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /node_modules/,
use: ["babel-loader"],
}
],
},
};
babel로 jsx 문법을 js로 변환하는 것과는 별개로, jsx 확장자를 사용한다면 resolve 할 수 없다는 오류가 발생한다. webpack은 앞에서 말했듯이 JavaScript와 JSON 파일만 이해할 수 있기 때문이다. 그래서 설정을 통해 resolve할 때 확인하는 확장자를 추가로 설정할 수 있다.
module.exports = {
resolve: {
extensions: [".js", ".jsx"],
},
...
};
위에 설정만으로도 React 개발을 진행할 수 있다. 하지만 reset.css 같은 css 파일을 추가할 경우도 존재하는데, 현재는 webpack이 css 파일을 이해하지 못한다. css-loader
를 활용하여 css 파일을 webpack이 읽을 수 있도록 한다.
$ npm i -D css-loader
// webpack.config.js
module.exports = {
module: {
rules: [
...
{
test: /\.css$/,
use: ["css-loader"],
},
],
},
};
하지만, build가 완료된 index.html 파일을 열어보면 css 적용 ❌
css 파일을 읽은 후 어딘가에 저장해야 적용할 수 있음
style-loader
: development 환경에서 사용 권장
injects CSS into the DOM using multiple
하기에 css파일을 추출하는 것보다 더 빠르다.MiniCssExtractPlugin
: production 환경에서 사용 권장
- css파일을 추출하게 되면 css파일과 js파일을
parallel load
할 수 있어 사용자가 페이지를 빠르게 load할 수 있다.
use에 있는 loader 순서는 오른쪽에서 왼쪽 순서로 실행
된다.
css-loader로 css 파일을 읽고, style-loader로 DOM에 css를 주입한다.
// webpack.config.js
module.exports = {
module: {
rules: [
...
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
],
},
};
마지막으로 타입스크립트까지 적용해보려고 한다. 앞에서 설명한 것처럼 .ts
, .tsx
확장자를 resolve 하기 위해 설정에 추가해줘야 한다. 그리고 타입스크립트를 webpack이 이해하기 위해 ts-loader
를 사용해야 하며, ts-loader가 typescript를 load하기 위해 typescript를 설치해야 한다.
$ npm i -D ts-loader typescript
// webpack.config.js
module.exports = {
module: {
resolve: {
extensions: [".js", ".jsx", ".ts", ".tsx"],
},
rules: [
...
{
test: /\.(ts|tsx)$/,
use: "ts-loader",
exclude: /node_modules/,
},
],
},
};
typescript를 트랜스파일하기 위해 tsconfig.json 파일이 필요하다. 새로운 JSX 변환 방식(React 17+)으로 jsx를 사용하기 위해"jsx": "react-jsx"
를 추가하였고, default export하는 라이브러리를 가져오기 위해 "allowSyntheticDefaultImports": true
를 추가하였다.
{
"compilerOptions": {
"target": "ESNext", // 코드의 변환 대상 ECMAScript 버전을 최신 버전으로 지정
"module": "ESNext", // 최신 ECMAScript 버전 모듈 시스템을 타겟으로 컴파일
"moduleResolution": "node", // TypeScript가 모듈을 해석하고 처리하는 방식 지정
"outDir": "./dist", // 컴파일된 파일이 저장될 출력 디렉토리 지정
"noImplicitAny": true, // 암시적 any를 허용하지 않음
"strict": true, // 엄격한 타입 검사
"jsx": "react-jsx", // 새로운 JSX 변환 방식(React 17+)을 사용하기 위해 필요
"jsxImportSource": "@emotion/react", // React의 jsx()함수가 아니라 Emotion의 jsx()함수를 대신 사용
"allowSyntheticDefaultImports": true, // default export 가 없어도 import 허용
"sourceMap": true, // 디버깅 시 어떤 파일에서 에러났는지 추적 용이
},
}
tsconfig.json 에서 "jsx": "react-jsx"
를 추가하면 babel을 설정하지 않아도 jsx를 해석할 수 있다. 처음에는 ts-loader 의 부가 기능인줄 알았는데, 생각해보니 타입스크립트는 원래 자바스크립트로 트랜스파일하여 실행한다.
그럼 언제 트랜스파일이 될까?
ts-loader는 tsc를 사용해 트랜스파일을 진행하고, tsconfig.json 설정을 따른다고 한다.
webpack으로 TypeSciprt를 다루는 방법은 2가지가 있다.
ts-loader
: TS → JS 트랜스파일 + 타입 체크. But, 타입 체크로 인해 빌드 속도 느림babel-loader + @babel/preset-typescript
: TS → JS 트랜스파일 + 타입 체크 ❌. But, 빌드 속도 빠름
개발 환경에서는 빌드 단계에서 오류를 미리 방지하기 위해 타입 체크가 필요하기 때문에 ts-loader를 사용하고, 프로덕션 환경에서는 이미 타입 체크가 되어 있으며, ci 환경에서 빠르게 빌드하고 babel-loader로 polyfill을 제공할 수 있기 때문에 프로덕션 환경에서 사용하는 것이 적절하다고 판단할 수 있다.
근데, 타입 체크를 한다고 그렇게 빌드 속도가 차이날까?
그래서 파일은 거의 없지만 속도를 테스트해보기로 했다. 크게 차이 안나면 타입 체크를 해주는 ts-loader를 사용할 생각으로 테스트를 진행하였다.
babel-loader + @babel/preset-typescript + @babel/preset-react 로 빌드했을 때 1초 내외로 빌드되는 것을 확인할 수 있다.
코드 | 빌드 결과 |
---|---|
ts-loader로만 빌드할 경우 약 2초정도 걸리는 것을 확인할 수 있다.
코드 | 빌드 결과 |
---|---|
ForkTsCheckerWebpackPlugin
를 사용하면 ts-loader options에 transpile:true가 자동으로 들어가 컴파일과 번들링만 빨리 실행하고, 타입 체크는 따로 실행할 수 있다. 플러그인을 적용하면 타입체크도 하고, 1.6초가 걸리는 것을 확인할 수 있다.
코드 | 빌드 결과 |
---|---|
속도를 비교해본 결과, 플러그인을 사용해도 1.6배 정도 차이나는 것을 확인할 수 있다. 따라서, ts-loader + ForkTsCheckerWebpackPlugin 는 개발 환경, babel-loader는 프로덕션 환경에 분리해서 적용하는 게 효율적이라고 판단하였다.
일단 image를 빌드하기 전에 tsx로 설정하였다면 모듈 또는 해당 형식 선언을 찾을 수 없습니다.
라는 오류가 발생한다. 이것은 타입스크립트가 이미지 확장자를 읽지 못해서인데, custom.d.ts 파일을 만들고 타입을 declare해주면 된다. 만들어도 인식이 안된다면 tsconfig.json의 include 범위를 확인해보자.
// custom.d.ts
declare module "*.jpg";
declare module "*.png";
declare module "*.jpeg";
declare module "*.gif";
declare module "*.svg";
// src/App.tsx
import TEST from "./assets/images/test.jpg";
const App = () => {
return (
...
<img src={TEST} width={100} />
);
};
타입 에러는 사라졌지만 build 시 오류가 발생하는데, 이미지를 빌드하기 위해선 file-loader
를 사용해야 webpack이 이해할 수 있다.
module.exports = {
...
module: {
rules: [
...,
{
test: /\.(png|jpe?g|gif)$/i,
use: [
{
loader: "file-loader",
options: {
name: "[path][name].[ext]",
},
},
],
},
],
},
};
개발환경은 모두 구축했는데 소스코드를 수정할 때마다 웹팩으로 직접 빌드해야 확인할 수 있는 게 너무 불편하다.
webpack-dev-server
: 소스코드를 수정할때마다 알아서 webpack이 빌드해주는 도구
소스코드 수정 후 저장하면 webpack이 자동으로 빌드하고, 브라우저 화면도 수정된 코드가 적용된다. (HMR)
static을 설정하지 않으면 output 을 기준으로 경로가 설정된다.
소스코드를 수정 후 새로고침을 하지 않더라도 webpack-dev-server 에서 HMR을 기본으로 지원해주기 때문에 브라우저에 바로 반영된다.
webpack-dev-server v4.0.0부터 Hot Module Replacement가 기본적으로 활성화되어 있습니다.
https://webpack.kr/configuration/dev-server/#devserverhot
$ npm i -D webpack-dev-server
// webpack.config.js
module.exports = {
devServer: {
port: 3000
},
...
}
// package.json
{
"scripts": {
"start": "webpack serve --config webpack.config.js"
},
polyfill
: 브라우저에서 지원하지 않는 코드를 사용 가능한 코드 조각이나 플러그인으로 변환한 코드
최신 문법은 구형 브라우저에서 동작하지 않을 수 있다. 이때는 최신 문법을 구형 브라우저에서도 동작하는 코드로 변환한 polyfill 을 제공해야 한다.
여러가지 방법으로 제공할 수 있는데, 해당 블로그에 따라 전역 스페이스 오염 문제와, 바벨 런타임 플러그인의 인스턴스 메소드 문제를 모두 해결한 core-js
를 사용하였다.
target을 ie 8로 하고 빌드하면 빌드 파일의 라인 수가 증가하는 것으로 적용된지를 확인할 수 있다.
$ npm i -D core-js
{
"presets": [
[
"@babel/preset-env",
{
"targets": "> 2%, not dead",
"useBuiltIns": "usage",
"corejs": "3.37",
"shippedProposals": true
}
],
"@babel/preset-typescript",
["@babel/preset-react", { "runtime": "automatic" }]
]
}
husky 는 .git
디렉토리와 같은 디렉토리에 있어야 한다. .git 에서 git 관련 데이터를 모두 관리하고 있고, 커밋이나 푸시 모두 .git 디렉토리 정보를 바탕으로 처리되기 때문이다. 하지만 현재 디렉토리 구조상 루트 디렉토리에서 .git 이 관리되며, 하위 디렉토리인 frontend 에 package.json이 존재하므로 추가 처리가 필요하다. husky가 최근에 9버전으로 버전업되면서 래퍼런스가 별로 없었지만 릴리즈 노트를 통해 바뀐 부분만 수정하여 처리하였다.
팀에서 정한 git convention 에 따라 허용하는 prefix를 지정 → ALLOWED_PREFIXES
브랜치명을 {prefix}/#{issue_number} 로 설정하므로 브랜치명에서 이슈 번호 추출 → ISSUE_NUMBER
prefix와 postfix 를 체크하여 팀에서 정한 컨벤션을 지켰는지 확인하고, 지켜지지 않을 경우 커밋을 취소한다.
COMMIT_MSG_FILE=$1
COMMIT_MSG=$(cat $1)
# 허용하는 commit prefix
ALLOWED_PREFIXES="^(feat|fix|refactor|build|docs|chore|test|style|design|init): "
# 현재 브랜치명
CURRENT_BRANCH=$(git branch --show-current)
# 브랜치명에서 이슈 번호 추출. 브랜치명 : {prefix}/#{issue_number}
ISSUE_NUMBER=$(echo $CURRENT_BRANCH | sed -n 's/.*#\([0-9]*\).*/\1/p')
if ! echo "$COMMIT_MSG" | grep -Eq "$ALLOWED_PREFIXES"; then
echo "Error: Commit message does not follow the convention."
echo "Allowed prefixes: feat:, fix:, refactor:, build:, docs:, chore:, test:, style:, design:, init:"
exit 1
fi
if ! echo "$COMMIT_MSG" | grep -q "#$ISSUE_NUMBER"; then
echo "Error: Commit message does not contain the issue number #$ISSUE_NUMBER."
exit 1
fi
lint-staged
: staged된 파일에 대해서만 lint를 실행하는 도구
현재 변경된 파일(staged)만 lint를 실행하기 위해 해당 도구를 사용한다.
cd frontend && npx lint-staged
eslint와 prettier를 commit할 때 잡더라도 타입 에러나 빌드 에러는 잡아주지 않는다. 따라서 원격 저장소에 push하기 전에 빌드하여 에러를 미리 확인할 수 있도록 처리한다.
cd frontend && npm run build-dev
prepare 는 npm 에서 자체적으로 제공하는 스크립트 명령어 중 하나다.
처음 프로젝트를 로컬에 클론받고 npm install
을 실행할 때 자동으로 함께 호출된다.
해당 명령어로 husky 가 동작하기 위해 필요한 초기 설정 파일들을 설치한다.
{
...
"scripts": {
"prepare": "cd .. && husky frontend/.husky",
},
"devDependencies": {
"husky": "^9.0.11",
"lint-staged": "^15.2.7",
},
"lint-staged": {
"**/*.{ts,tsx}": [
"eslint --fix",
"prettier --write"
]
},
}
webpack으로 React 개발환경을 구축해보았다. 확실히 직접 구축해보니 webpack 핵심 기능들이 각각 어떤 기능들을 하는지 이해할 수 있었고, 이해를 바탕으로 추후에 커스텀할 수 있을 것 같다. 해당 개발환경으로 js, jsx, ts, tsx, css, html, 이미지 파일까지 빌드가 가능하다.
npm i react react-dom
npm i -D @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript @types/react babel-loader css-loader html-loader html-webpack-plugin style-loader ts-loader typescript webpack webpack-cli webpack-dev-server core-js husky lint-staged
📦Project
┣ 📂dist
┣ 📂src
┃ ┣ 📜App.tsx
┃ ┗ 📜index.js
┣ 📜.babelrc
┣ 📜.gitignore
┣ 📜index.html
┣ 📜package-lock.json
┣ 📜package.json
┣ 📜style.css
┣ 📜tsconfig.json
┗ 📜webpack.config.js
const path = require("path");
const HTMLWebpackPlugin = require("html-webpack-plugin");
module.exports = {
entry: "./src/index.tsx",
output: {
filename: "bundle.js",
path: path.resolve(__dirname + "/dist"),
},
devServer: {
port: 3000,
},
mode: "development",
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx'],
alias: {
'@': path.resolve(__dirname, './src'),
},
},
module: {
rules: [
{
test: /\.html$/,
use: [
{
loader: "html-loader",
options: { minimize: true },
},
],
},
{
test: /\.css$/,
use: ["style-loader", "css-loader"],
},
{
test: /\.(ts|tsx)$/,
use: "ts-loader",
exclude: /node_modules/,
},
{
test: /\.(png|jpe?g|gif|svg)$/i,
use: [
{
loader: "file-loader",
options: {
name: "[path][name].[ext]",
},
},
],
},
],
},
plugins: [
new HTMLWebpackPlugin({
template: "./index.html", // 읽을 파일명
filename: "./index.html", // output으로 출력할 파일명
}),
],
};
{
"compilerOptions": {
"target": "ESNext", // 코드의 변환 대상 ECMAScript 버전을 최신 버전으로 지정
"module": "ESNext", // 최신 ECMAScript 버전 모듈 시스템을 타겟으로 컴파일
"moduleResolution": "node", // TypeScript가 모듈을 해석하고 처리하는 방식 지정
"outDir": "./dist", // 컴파일된 파일이 저장될 출력 디렉토리 지정
"noImplicitAny": true, // 암시적 any를 허용하지 않음
"strict": true, // 엄격한 타입 검사
"jsx": "react-jsx", // 새로운 JSX 변환 방식(React 17+)을 사용하기 위해 필요
"jsxImportSource": "@emotion/react", // React의 jsx()함수가 아니라 Emotion의 jsx()함수를 대신 사용
"allowSyntheticDefaultImports": true, // default export 가 없어도 import 허용
"sourceMap": true, // 디버깅 시 어떤 파일에서 에러났는지 추적 용이
"baseUrl": ".", // 모듈 해석 기본 경로
"paths": {
// 경로 별칭 설정
"@/*": ["src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules"]
}
{
"presets": [
[
"@babel/preset-env",
{
"targets": "> 2%, not dead",
"useBuiltIns": "usage",
"corejs": "3.37",
"shippedProposals": true
}
],
"@babel/preset-typescript",
["@babel/preset-react", { "runtime": "automatic" }]
]
}
{
"scripts": {
"build": "webpack",
"start": "webpack serve --config webpack.config.js"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@babel/cli": "^7.24.8",
"@babel/core": "^7.24.8",
"@babel/preset-env": "^7.24.8",
"@babel/preset-react": "^7.24.7",
"@babel/preset-typescript": "^7.24.7",
"@types/react": "^18.3.3",
"babel-loader": "^9.1.3",
"css-loader": "^7.1.2",
"file-loader": "^6.2.0",
"html-loader": "^5.0.0",
"html-webpack-plugin": "^5.6.0",
"style-loader": "^4.0.0",
"ts-loader": "^9.5.1",
"typescript": "^5.5.3",
"webpack": "^5.92.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^5.0.4"
}
}
https://webpack.kr/
https://webpack.kr/guides/typescript/#loader
https://joshua1988.github.io/webpack-guide/webpack/what-is-webpack.html
https://velog.io/@pop8682/번역-왜-babel-preset이-필요하고-왜-필요한가-yhk03drm7q
https://ui.toast.com/fe-guide/ko_BUNDLER
https://techblog.woowahan.com/6465/
https://onlydev.tistory.com/163#:~:text=%40babel%2Fcore 패키지에는 바벨,코드를 생성해 나타낸다.