리액트 앱을 CRA없이 처음부터 만들려면 웹팩, 바벨 등 설정해줘야하는 사항들이 정말 많다. 이번에 웹팩으로 리액트 앱을 만들면서 많이 헤맸는데 프로젝트를 시작하기도 전에 힘이 빠지는 느낌이었다.
사실 그동안 CRA가 얼마나 편리하고 편하게 개발했는지 느끼게 된 계기가 되었다. CRA 공홈에 가면
You don't need to learn and configure many build tools. Instant reloads help you focus on development. When it's time to deploy, your bundles are optimized automatically.
다양한 툴에 대해서 배우고 환경설정할 필요없이 개발에 집중할 수 있게 해준다고 써있다. 커맨드라인 하나로 정말 간단하게 리액트 앱을 만들어주고 바벨, 웹팩 config가 다 이미 만들어져있다.
하지만 이미 만들어진 설정은 변경하기가 굉장히 힘들다. 설정을 변경하기 위해서 필요한 모듈을 또 설치하고 config.js
파일을 override하기 위해서 다시 작성해줘야하는 번거로움이 있다. 또한 정말 다양한 모듈들이 깔려 있기 때문에 성능문제가 발생하기 쉽다.
그래서 시도해본 CRA 도움을 받지않고 개발자가 원하는 방식대로 webpack
, babel
등을 사용해서 리액트 앱을 만들어 보았다.
$ cd Desktop
$ mkdir webpack-react
$ cd webpack-react
$ code .
$ npm init -y
$ npm i react react-dom
$ npm i -D typescript @types/react @types/react-dom ts-loader
$ tsc --init
ts-loader
: typescript로 작성된 코드가 javascript로 변환하게 해주는 loadertsc --init
: tsconfig.json 파일 생성$ npm i -D webpack webpack-cli
webpack
: 웹팩 라이브러리webpack-cli
: 웹팩을 명령어로 조작하기 위한 라이브러리$ npm i -D html-webpack-plugin webpack-dev-server
html-webpack-plugin
: 번들링 된 js 파일을 html 파일에 삽입해준다. webpack-dev-server
: 로컬에서 개발하기 위한 테스트 서버$ npm i -D babel-loader @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript
babel-loader
: 바벨 config 파일을 읽어 설정에 맞게 변환한다.@babel/core
: 바벨의 코어기능을 포함하고 있어 반드시 설치해야 한다.@babel/preset-env
: ECMAScript2015+를 변환할 때 사용한다. 바벨7이전에는 babel-reset-es2015와 같이 버전별로 제공되었지만, 현재는 통합되어 사용하기 편리해졌다.@babel/preset-react
: react를 변환한다. (jsx -> js)@babel/preset-typescript
: 타입스크립트를 변환한다. (ts,tsx -> js)module.exports = {
presets: [
["@babel/preset-react", { runtime: "automatic" }],
"@babel/preset-env",
"@babel/preset-typescript",
],
};
@babel/preset-react
@babel/preset-env
@babel/preset-typescript
@babel/preset-flow
@babel/preset-jest
npm i -D css-loader style-loader
css-loader
: CSS를 JS파일 내에서 불러올 수 있게 하기 (나중에 styled-components로 변경해서 적용할 예정)style-loader
: CSS를 DOM(style 태그) 안에 담기const HtmlWebpackPlugin = require('html-webpack-plugin');
const path = require('path');
const webpack = require('webpack');
module.exports = {
mode: "development",
entry: "./src/index.tsx", // src/index.js or src/index.ts가 기본값
output: {
path: path.resolve(__dirname, "build"),
filename: "bundle.js", // main.js가 기본값
},
module: {
rules: [
{
test: /\.(ts|tsx)$/,
use: ['babel-loader', 'ts-loader'],
exclude: ['/node_modules'],
},
{
test: /\.css$/i,
use: [
{
loader: 'style-loader',
},
{ loader: 'css-loader' },
],
},
],
},
plugins: [
new HtmlWebPackPlugin({
template: './public/index.html'
})
],
devServer: {
historyApiFallback: true,
port: 8080,
hot: true,
}, // test dev 서버 설정하기
};
// ./src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import Home from './pages/Home';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<Home /> // ./src/pages/Home.tsx 생성하고 index.tsx에 import 해주기
);
<!-- ./public/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-React</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
new HtmlWebPackPlugin({ template: './public/index.html' })
webpack.config.ts
에서 설정한 HtmlWebPackPlugin 에 의해서 번들링 후 생성된./build/index.html
은 템플릿에 설정된 파일./public/index.html
의 동일한 내용을 자동으로 주입한다.
"scripts": {
"dev": "webpack serve --open --mode development",
"start": "webpack --mode development",
"build": "webpack --mode production"
},
$ npm dev
or
$ npm start
or
$ npm run build
$ npm i -D file-loader
웹팩 사용시 이미지, 폰트 등을 로더없이 번들을 시도하면 실패한다. 에셋을 읽지 못하고 변환하지 못한 것이다. 그래서 file-loader
or url-loader
가 필요하다.
file-loader
: 파일을 모듈로 사용할 수 있게 만들어준다.url-lodaer
: 파일을 base64 URL로 변환한다. // webpack.config.ts에 추가하기
module.exports = {
mode: "development",
entry: "./src/index.tsx", // src/index.js or src/index.ts가 기본값
output: {
path: path.resolve(__dirname, "build"),
filename: "bundle.js", // main.js가 기본값
},
module: {
rules: [
{
test: /\.(ts|tsx)$/,
use: ['babel-loader', 'ts-loader'],
exclude: ['/node_modules'],
},
{
test: /\.css$/i,
use: [
{
loader: 'style-loader',
},
{ loader: 'css-loader' },
],
},
// 이미지 로더
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
use: [
{
loader: 'file-loader',
options: {
outputPath: 'static/media',
},
},
],
},
// 폰트 로더
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
use: [
{
loader: 'file-loader',
},
],
},
],
},
plugins: [
new HtmlWebPackPlugin({
template: './public/index.html'
})
],
devServer: {
historyApiFallback: true,
port: 8080,
hot: true,
}, // test dev 서버 설정하기
};
outputPath
: public 폴더에 있는 파일이 아니면 번들 후 ./build/static/media에 저장되도록 설정리액트에서 public 디렉토리는 정적 파일을 넣어서 사용한다. (index.html, images, fonts 등) 번들링할 때도 webpack으로 처리되지 않고, 원본이 build 폴더에 복사된다.
public 폴더 내에 있는 이미지를 절대경로로 불러와 사용하면 dev 서버에서는 보이지만, build시 build 폴더에 불러온 이미지가 포함되지 않는다. 이럴 때 정적파일을 원본 그대로 불러오는 플러그인을 사용해야한다.
$ npm i -D copy-webpack-plugin
// webpack.config.ts 플러그인에 추가하기
plugins: [
new HtmlWebPackPlugin({
template: './public/index.html'
})
new CopyWebpackPlugin({
patterns: [
{
from: 'public/',
globOptions: {
ignore: ['**/index.html'],
},
},
],
}),
],
globOptions
- ignore
에 html 파일을 제외하고 복사해야 한다 (그냥 복사하면 build/index.html와 충돌나서 에러가 발생한다.)빌드에 성공하면 public에 있는 fonts 와 images가 그대로 복사되었고 src 디렉토리에 저장된 이미지는 static/media에 랜덤해쉬값과 함께 빌드되었다.
📌 빌드할 때마다 바뀌는 해쉬값 고정하기
static/media에 저장된 이미지 파일명이 랜덤값으로 build할 때마다 바뀌는데 이걸 원본 파일명을 그대로 쓰고 싶어서 알아본 방법
아주 간단하다. webpack.config.ts
에서 설정한 file-loader
옵션에 이름을 고정하는 것이다.
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
use: [
{
loader: 'file-loader',
options: {
outputPath: 'static/media',
name: '[name].[ext]',
},
},
],
},
원본 파일명 유지
css 파일을 만들어서 import 해준 후 dev 서버를 확인해보면 아래와 같이 <style>
태그에 스타일이 적용이 되었다.
css 파일이 여러개일 때 <style>
이 수없이 늘어나서 찾기가 힘들 것이다. 이러한 css 파일을 하나로 묶어서 추출해주는 플러그인이 바로 mini-css-extract-plugin
이다.
$ npm i -D mini-css-extract-plugin
// webpack.config.ts 플러그인에 추가하기
plugins: [
new HtmlWebPackPlugin({
template: './public/index.html'
})
new CopyWebpackPlugin({
patterns: [
{
from: 'public/',
globOptions: {
ignore: ['**/index.html'],
},
},
],
}),
new MiniCssExtractPlugin({ filename: 'static/css/app.css' }), // 파일경로 & 파일명 지정하기
],
개발 모드에서는 CSS를 여러 번 수정하고 DOM에 <style>
요소의 코드로 주입하는 것이 훨씬 빨리 작동하므로 "style-loader"를 사용하고, 배포 모드에서는 MiniCssExtractPlugin.loader를 사용하는 것이다 좋다고 한다. 개발, 배포모드에 따라서 다르게 적용하기 위해서는 환경변수를 사용해서 분리해야한다.
{
test: /\.css$/i,
use: [
{
loader: isProd ? MiniCssExtractPlugin.loader : 'style-loader',
},
{ loader: 'css-loader' },
],
},
❗️
MiniCssExtractPlugin.loader
와style-loader
는 함께 사용할 수 없다. 둘 중 하나만 사용하거나 모드를 나눠서 사용하면 된다.
<link />
에 파일이 추출된 것을 확인할 수 있다.
pulic 디렉토리에 저장된 이미지를 절대 경로로 불러와서 이미지를 사용할 경우 문제없이 이미지가 렌더링된다. 그럼 src 디렉토리에 있는 이미지를 사용할 경우는 어떨까? 바로 아래와 같은 에러가 발생한다.
import React from 'react';
import Button from '../components/Button';
import JavaScript from '../assets/5968292.png'; // img from assets
const Home = () => {
console.log('webpack test');
return (
<div>
<h2>Home</h2>
<Button>
<div>
HTML
<img src="./images/1126012.png" />
// image from public directory
</div>
</Button>
<Button>
<div>
JavaScript
<img src={JavaScript} />
// image from src directory
</div>
</Button>
</div>
);
};
export default Home;
TS2307: Cannot find module '../assets/5968292.png' or its corresponding type declarations.
타입스크립트에서 발생한 에러이다. Typescript에서 .d.ts
파일을 추가해줘서 image에 대한 타입을 지정해줘야한다.
// src/types/images.d.ts
declare module '*.png';
declare module '*.jpg';
declare module '*.jpeg';
모듈 이미지 타입을 지정해주고 tsconfig.json
include에 작성한 src/types/images.d.ts
를 추가해주면 된다.
{
(...)
"exclude": ["node_modules"],
"include": ["**/*.ts", "**/*.tsx", "src/index.tsx", "src/types/images.d.ts"]
}
개발을 하다보면 ../../../assets/123.png
와 같은 엄청난 depth의 상대경로를 보면 지저분하고 위치를 정확하게 파악하기가 어렵다. config 파일에서 경로를 설정해주면 깔끔하게 정리할 수 있다.
tsconfig.json
에서 baseUrl
과 "path"
부분에 사용하고자하는 경로를 설정해주면 된다.
{
"compilerOptions": {
"baseUrl": "./src",
"paths": {
"@components/*": ["./components/*"],
"@assets/*": ["./assets/*"],
"@styles/*": ["./styles/*"],
"@contexts/*": ["./contexts/*"],
"@hooks/*": ["./hooks/*"],
"@utils/*": ["./utils/*"]
}
}
}
tsconfig.json
을 아래와 같이 수정한 후
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@src/*": ["src/*"]
}
}
}
웹팩에서도 경로를 처리할 수 있도록 수정해줘야 한다. (webpack.config.ts
)
// webpack.config.ts
(...)
entry: './src/index.tsx',
resolve: {
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
alias: {
'@src': path.resolve(__dirname, 'src'),
},
}, // 확장자나 경로를 알아서 처리할 수 있도록 설정
output: {
path: path.resolve(__dirname, "build"),
filename: "bundle.js", // main.js가 기본값
},
(...)
// 절대경로 사용
import React from 'react';
import Button from '@src/components/Button';
import JavaScript from '@src/assets/5968292.png'; // img from assets
const Home = () => {
console.log('webpack test');
return (
<div>
<h2>Home</h2>
<Button>
<div>
HTML
<img src="./images/1126012.png" />
// image from public directory
</div>
</Button>
<Button>
<div>
JavaScript
<img src={JavaScript} />
// image from src directory
</div>
</Button>
</div>
);
};
export default Home;
styled-components 설치하기
$ npm i styled-components
styled-components 사용하기
import React from 'react';
import styled from 'styled-components';
interface IButton {
children: React.ReactNode;
}
const Button = ({ children }: IButton) => {
return <SButton>{children}</SButton>;
};
export default Button;
const SButton = styled.button`
width: auto;
height: 50px;
color: black;
background-color: #eee;
padding: 10px 20px;
border-radius: 10px;
border: none;
cursor: pointer;
div {
display: flex;
align-items: center;
gap: 10px;
}
img {
width: 18px;
}
`;
styled-components를 설치하고 사용하고 webpack.config.ts
에서 따로 추가하거나 수정해줄 필요없이 바로 사용이 가능하다.
궁금해서 테스트해보기 위해 import한 css 파일을 주석처리하고 styled-components만 남겨두고 webpack.config.ts
에서 설정한 css-loader
, style-loader
를 다 지워보고 번들링을 해봤다. 결과는 오류없이 성공했다.
여기서 웹팩이 styled-components에서 준 스타일 속성들을 어떻게 처리하는지에 대해서 찾지 못했다.. (더 구글링해봐야겠다..✍🏻)
결론은 styled-components는
css-loader
,style-loader
없이 사용이 가능하다!
배포는 vercel를 사용했다. vecel은 깃허브랑 연동해서 repo를 바로 불러올 수 있다.
배포를 진행하면서 실패의 연속을 경험했다. vercel에서 배포 시 아래와 같이 몇 개의 설정을 할 수 있다.
환경변수만 설정해두고 배포를 했는데 계속해서 빈화면만 반복해서 나왔다. 개발자도구로 확인해도 <script>
에서 번들된 js 파일을 불러와야하는데 <script>
조차도 없는 상태.
빌드 실패 + 에러만 나는 상황에서 혹시나하고 setting 보드에 들어가서 Output directory를 수정해봤다.
문제는 번들링하고 생성된 build 폴더를 찾지 못해서
index.html
을 열지 못한 것이었다. 디렉토리를 설정해주니./build/index.html
를 성공적으로 불러왔다.
우여곡절이 많았던 CRA없이 웹팩으로 리액트 환경 구축하기. 아직 더 추가로 설정해야할 부분들이 있기 때문에 앞으로 더 추가할 예정입니다.
실수의 연속이었던 Repo 링크와 함께...🙇♀️