CRA 없이 webpack으로 React 시작하기 101 (with TypeScript)

2023. 1. 5. 20:04React

1. 왜 CRA 안쓰나요?


한 귀로 흘려들었던 수많은 정보 중에 production 대상용 프로젝트는 CRA를 쓰지 않는다는 내용이 있었다.

이번 포스팅을 준비하면서 열심히 구글링해본 결과 그 이유는 아래 이유로 요약할 수 있다.

 

1. SSR 설정을 위해 eject를 하고 나면 결국엔 CRA를 쓰는 이유가 없기 때문에
2. build 명령어에서 development와 production에서 사용할 플러그인 선택이 불가능해서
(js 코드량을 최대한 줄이는 기적의 운영을 위해서)
3. react-scripts로 명령어가 추상화되어있어서 설정 flow 이해가 힘들어서

 

약간 다른 이야기를 해보자면, React를 처음 입문할 때 직접 설치하는 과정을 튜토리얼로 본 적 있었는데 강사님 스크립트를 따라치다가 뭔가 꼬여서 그냥 사-악 밀고 CRA로 다시 깔았던 적이 있었다.

 

"어차피 기능만 개발하면 되는거지. 이거는 나중에 배워야지 뭐"

 

이런 마인드였는데 내가 볼때 미루고 미루던 나중이 지금이 되었다. 빌드도구를 공부할 시간이다.

그냥 설정 파일 속성들만 좌르륵 나열하는 목적이 아니라 왜 해당 속성이 필요하고 어떤 역할을 하는지도 최대한 자세하게 설명하는 문서를 목표로 한다.

 

2. 빌드도구 (webpack)은 왜 쓰나요?


 

먼저 웹팩을 사용한다는 의미를 먼저 알아야할 것 같다.

 

1. 파일 단위의 자바스크립트 모듈 관리의 필요성.

2. 웹 개발 작업 자동화 도구.

3. 웹 애플리케이션의 빠른 로딩 속도와 높은 성능의 중요성.

4. HTTP 요청 숫자의 제약.

 

모듈의 필요성


// app1.js

var num = 10;
console.log(num); // expect = 10

// app2.js

var num = 20;
console.log(num) // expect = 20
<body>
    <script src="./app1.js" defer></script>
    <script src="./app2.js" defer></script>
    <script>
        console.log(num) // tobe = 20
    </script>
</body>

 

JavaScript의 전역 변수는 가비지 콜렉팅 되지 않는다는 특성 덕분에 편하게 사용할 때도 많지만, 다른 파일에서 선언된 전역 변수까지 오염되는 현상이 일어난다.

 

이런 문제점을 해결하기 위해 똑똑하신 선대 개발자분들이 module이라는 개념을 도입했다.

파일 단위로 변수를 관리를 도와주는 툴이 빌드도구인 webpack의 목적 중 하나이다.

 

핫 리로딩과 lazy loading


텍스트 에디터에서 뭔가를 수정했을 때 바로바로 화면에 반영되는 것을 핫 리로딩이라고 한다.

webpack에서는 해당 기능을 webpack-devserver를 사용해서 개발환경에서 다뤄볼 수 있다.

예전에 프론트엔드 개발을 할 때 가장 많이 하던 작업은 코드를 편집하고 브라우저를 새로 고침하는 것이었다.

 

웹 상에서 다양한 콘텐츠를 다루기 시작하고 점점 스크립트파일이 많은 모듈로 나뉘게 되면서 웹사이트의 로딩 속도라는 새로운 문제를 맞이하게 되었다. 이 문제를 해결하기 위해서는 세 가지 노하우가 적용되었다.

  • html, css, js와 같은 정적 자원 압축.
  • 이미지, 비디오와 같은 자원을 압축하고 병합.
  • 필요한 자원만 로딩(lazy loading)

 

기본적으로 이런 모든 처리를 자동화해주는 도구로써 웹팩과 같은 빌드도구를 사용하게 된다.

CRA 명령어를 실행했을 때 기본적으로 webpack기반의 프로젝트로 설치되며, 최근엔 Vite와 같은 빠르고 성능좋은 빌드도구도 많이 나오고 있다.

 

HTTP 요청 숫자의 제약


TCP 스펙에 따라 한번에 보낼 수 있는 HTTP 요청 숫자가 제약되어 있기 때문에 각각의 js 파일을 받아오는게 성능측면에서 매우 불리하게 작용하게 되었다.

결론적으로 HTTP 요청을 줄이는 것이 성능을 높이는데 매우 유리해졌고 빌드도구도 이 점에서 가장 큰 의의를 지닌다.

 

브라우저 최대 연결 횟수
익스플로러 8,9 6
익스플로러 10,11 8, 13
크롬, 파이어폭스 6
사파리 6
안드로이드, IOS 6

 

3. CRA 없이 react 환경설치해보기


1. 패키지 설치


// package.json을 만들어 준다.
npm init

// react와 react-dom을 설치한다. 이 두 개는 dependency로 나머지 라이브러리는 devDependency로 설치한다,

npm i react
npm i react-dom

// TypeScirpt를 사용한다면 아래 명령어도 함께 설치한다.

npm i -D typescript ts-node @types/node @types/react @types/reeact-dom

 

TypeScript로 추가되는 라이브러리 중에 ts-node@types/node가 무슨 역할을 하게 될까?

 

아래에서 더 자세하게 설명하겠지만, webpack은 기본적으로 js를 읽는다.

설정 파일로 webpack.config.js가 아니라 webpack.config.ts로 작성하기 위해서 ts-node를 설치한다.

@types/node는 후술할 webpack.config.js의 경로 문제 해결을 위해 필요한 Node.js에서 type을 명시해주는 라이브러리이다.


// 바벨 관련 라이브러리를 설치한다. JavaScript만 설치한다면 아래 명령어만 설치한다.

npm i -D @babel/core @babel/preset-env babel-loader css-loader style-loader

// TypeScript를 사용한다면 아래도 함께 설치한다.

npm i -D @babel/preset-typescript

 

babel은 자바스크립트 컴파일러이다.

다양한 브라우저에서 다양한 버전으로 웹사이트가 동작하기 때문에 TypeScript나 JSX, ESNext와 같이 최신 문법으로 작성된 스크립트를 모든 브라우저에서 동작할 수 있도록 호환성을 보장해준다.(크로스 브라우징)

 


// webpack 설정을 위해 필요한 라이브러리를 설치한다.

// 웹팩 설정 및 구동을 위해 꼭 필요한 기본 라이브러리.
npm i -D webpack webpack-cli webpack-dev-server

// TypeScript를 쓴다면 타입도 따로 설치해주자.
npm i -D @types/webpack


// 리렌더링 시 페이지 전체가 아니라 변경 일어난 부분만 변경
npm i -D @pmmmwh/react-refresh-webpack-plugin

// 타입스크립트 검사를 별도로 실행하는 플러그인. 일단 컴파일과 번들링을 먼저 실행하고 타입 체크는 좀 미룰 수 있음.
npm i -D fork-ts-checker-webpack-plugin

// index.html을 public같은 폴더에 넣어두고 싶다면 설치.
npm i -D html-webpack-plugin

// 어떤 요소가 얼마나용량을 차지하는지 알 수 있다.
npm i -D webpack-bundle-analyzer

// TypeScript를 쓴다면 타입도 따로 설치해주자.
npm i -D @types/webpack-bundle-analyzer

 

plugin이 붙어있는 라이브러리는 선택사항으로 설정파일에서 해당 플러그인을 생성하는 코드를 빼면 문제없이 동작할 수 있다.

webpack-bundle-analyzer는 빌드 시 아래와 같이 지금 프로젝트가 어떤 구조인지 시각적으로 확인할 수 있는데 큰 도움을 준다.

 


// Windows를 사용한다면 설치
npm i -D cross-env

// 아래처럼 사용
// cross-env NODE_ENV=production webpack

 

CRA를 쓰지 않고 굳~이 이렇게 따로 설치하는 가장 큰 이유는 production 최적화를 위해서인데, Windows에서는 NODE_ENV를 따로 넣어줄 수가 없다, cross-env를 사용하면 NODE_ENV에 상태를 넣어줄 수 있다.

 

1-1. tsconfig.json (TypeScript를 사용한다면)


TypeScript를 사용한다면, 컴파일러 속성을 지정해주는 tsconfing.json을 작성해줘야한다.

 

{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true,
    "outDir": "./dist",
    "sourceMap": true,
    "composite": true,
    "noImplicitAny": true,
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "module": "ESNext",
    "moduleResolution": "Node",
    "strict": true,
    "resolveJsonModule": true,
    "target": "es5",
    "jsx": "react",
    "isolatedModules": true,
    "esModuleInterop": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["./src/**/*", "webpack.config.ts"],
  "ts-node": {
    "compilerOptions": {
      "module": "commonjs",
      "moduleResolution": "Node",
      "target": "ES5",
      "esModuleInterop": true
    }
  }
}

 

중요한 속성들은 어떤 의미인지 알아야할 필요가 있다.

주요 속성에 대해 주석을 달아놨다. esModuleInterop 이런거 false로 바꿔보고 진짜 import React안되는지 봐보면 확 와닿을 것 같다.

 

{
  "compilerOptions": {
    "allowSyntheticDefaultImports": true, // 함수가 아니어도 import 사용 가능.
    "outDir": "./dist" // JS로 컴파일된 결과물이 위치하는 디렉토리 명시,
    "sourceMap": true, // 빌드할 때 .map. 을 넣을 지 여부
    "composite": true, // 빌드할 때, 바뀐 부분만 빌드되게끔 해줌.
    "noImplicitAny": true, // 명시적으로 any를 쓰지 않겠다는 뜻.
    "lib": ["dom", "dom.iterable", "esnext"], // 사용할 라이브러리
    "allowJs": true, // JS 파일을 사용하는지 여부
    "skipLibCheck": true,
    "module": "ESNext", // 어떤 문법을 사용할지 선택 es6 es5 등..
    "moduleResolution": "Node",
    "strict": true,
    "resolveJsonModule": true,
    "target": "es5",
    "jsx": "react", // 어떤 환경에서 jsx가 실행되는지, react-jsx, react로 사용한다.
    "isolatedModules": true,
    "esModuleInterop": true, // import * as React from "react"를 import React from "react"로 쓸 수 있게 해줌.
    "baseUrl": ".", // baseUrl이 루트라는 뜻
    "paths": {
      "@/*": ["src/*"] // src에서 가져올 때는 "../../" 이거 없이 "@files"로 import 가능.
    }
  },
  "include": ["./src/**/*", "webpack.config.ts"], // 컴파일할 대상.
  // ts를 못읽는 webpack을 위해 webpack.config.ts를 컴파일 해줌.
  // nodejs에서 사용하는 path라던가 process.env같은거 쓸 수 있게 해줌.
  "ts-node": {
    "compilerOptions": {
      "module": "commonjs",
      "moduleResolution": "Node",
      "target": "ES5",
      "esModuleInterop": true
    }
  }
}

 

2. webpack.config.ts를 설정해주자!


꽤 길어서 주요한 부분을 부분부분 보는게 이해가 더 쉬울 것 같다.

문서에 흐름이 있어서 의미를 잘 생각하면서 읽으면 생각했던 것처럼 큰 장벽은 없는 것 같다.

 

const isDev = process.env.NODE_ENV !== 'production';

 

지금 컴파일이 development 모드인지 production인지 확인한다.

isDev를 보고 어떤 플러그인을 넣을 지 정한다.

 

const config: Configuration = {
  name: 'MusicQL',
  mode: isDev ? 'development' : 'production',
  devtool: isDev ? 'hidden-source-map' : 'inline-source-map',
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
    alias: {
      '@/*': path.resolve(__dirname, 'src'),
    },
  },
  entry: {
    app: './src/Index',
  },
  //...
}

 

resolve에서 tsconfig.json에서 설정해줬던 allowJS라던가, pathname 설정을 다시 하는걸 볼 수 있다.

  • tsconfig.json | 코드를 작성할 때 문법에 맞는건지 체크.
  • webpack.config.ts | 해당 코드를 빌드하면서 JavaScript로 바꿀 때 문법 체크.

 

entry 속성의 app에서 처음 실행할 파일을 고를 수 있다. ReactDom.createRoot()를 실행하는 가장 최상단의 컴포넌트를 넣어주면 되겠다.


module: {
    rules: [
      {
        test: /\.tsx?$/,
        loader: 'babel-loader',
        options: {
          presets: [
            [
              '@babel/preset-env',
              {
                targets: { browsers: ['IE 10'] },
                debug: isDev,
              },
            ],
            '@babel/preset-react',
            '@babel/preset-typescript',
          ],
          exclude: path.join(__dirname, 'node_modules'),
        },
      },
      {
        test: /\.css?$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },

 

babel을 사용하여 tsx, jsx파일을 html, css, js로 만든다.

  • test 옵션의 정규표현식에 해당하는 파일을 받는다.
  • 거기에 맞는 loader를 불러온다.
  • babel의 preset 대로 파싱하여 해당 문서를 만든다.

결국 브라우저가 읽을 수 있는 정적파일 형태로 tsx를 찢어줘야하기 때문에 중요하다.

tsx나 css가 아니어도 이미지 파일같은 에셋도 babel에 넣을 수 있다.


plugins: [
    new ForkTsCheckerWebpackPlugin({
      async: false,
    }),
    new HtmlWebpackPlugin({
      template: path.join(__dirname, 'public/index.html'),
      minify: true,
    }),
    new webpack.EnvironmentPlugin({ NODE_ENV: isDev ? 'devlopment' : 'production' }),
  ],
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js',
    publicPath: '/dist/',
  },
  devServer: {
    historyApiFallback: true,
    port: 3000,
    devMiddleware: { publicPath: '/dist/' },
    static: { directory: path.resolve(__dirname, 'dist') },
    hot: true,
    compress: true,
  },
};

 

아까 설치했던 플러그인은 plugin 배열로 관리한다.

devServer 속성에서 실행시킬 포트번호, hot reloading 여부, 압축 여부 등 많은 설정을 할 수 있다.

devServer 설정 가짓수는 너무 많아서 공식문서를 보면서 이런 것도 있구나 하고 알아두면 좋을 것 같다.

 

https://webpack.kr/configuration/dev-server/

 

DevServer | 웹팩

웹팩은 모듈 번들러입니다. 주요 목적은 브라우저에서 사용할 수 있도록 JavaScript 파일을 번들로 묶는 것이지만, 리소스나 애셋을 변환하고 번들링 또는 패키징할 수도 있습니다.

webpack.kr


if (isDev && config.plugins) {
  config.plugins.push(new webpack.HotModuleReplacementPlugin());
  config.plugins.push(new ReactRefreshWebpackPlugin());
  config.plugins.push(new BundleAnalyzerPlugin({ analyzerMode: 'server', openAnalyzer: false }));
}

if (!isDev && config.plugins) {
  config.plugins.push(new webpack.LoaderOptionsPlugin({ minimize: true }));
  config.plugins.push(new BundleAnalyzerPlugin({ analyzerMode: 'static' }));
}

 

아까 넣어준 plugins 배열에 NODE_ENV에 따라 다른 플러그인을 넣어준다.

development 환경에서는 HMR, refresh를 넣고, production 환경에서는 loader들을 최소화시켜준다.

 

전체코드


import path from 'path';
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
import webpack, { Configuration as WebpackConfiguration } from 'webpack';
import { Configuration as WebpackDevServerConfiguration } from 'webpack-dev-server';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import ForkTsCheckerWebpackPlugin from 'fork-ts-checker-webpack-plugin';
import HtmlWebpackPlugin from 'html-webpack-plugin';

interface Configuration extends WebpackConfiguration {
  devServer?: WebpackDevServerConfiguration;
}

const isDev = process.env.NODE_ENV !== 'production';

const config: Configuration = {
  name: 'MusicQL',
  mode: isDev ? 'development' : 'production',
  devtool: isDev ? 'hidden-source-map' : 'inline-source-map',
  resolve: {
    extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
    alias: {
      '@/*': path.resolve(__dirname, 'src'),
    },
  },
  entry: {
    app: './src/Index',
  },
  module: {
    rules: [
      {
        test: /\.tsx?$/,
        loader: 'babel-loader',
        options: {
          presets: [
            [
              '@babel/preset-env',
              {
                targets: { browsers: ['IE 10'] },
                debug: isDev,
              },
            ],
            '@babel/preset-react',
            '@babel/preset-typescript',
          ],
          exclude: path.join(__dirname, 'node_modules'),
        },
      },
      {
        test: /\.css?$/,
        use: ['style-loader', 'css-loader'],
      },
    ],
  },
  plugins: [
    new ForkTsCheckerWebpackPlugin({
      async: false,
    }),
    new HtmlWebpackPlugin({
      template: path.join(__dirname, 'public/index.html'),
      minify: true,
    }),
    new webpack.EnvironmentPlugin({ NODE_ENV: isDev ? 'devlopment' : 'production' }),
  ],
  output: {
    path: path.join(__dirname, 'dist'),
    filename: '[name].js',
    publicPath: '/dist/',
  },
  devServer: {
    historyApiFallback: true,
    port: 3000,
    devMiddleware: { publicPath: '/dist/' },
    static: { directory: path.resolve(__dirname) },
    hot: true,
    compress: true,
  },
};

if (isDev && config.plugins) {
  config.plugins.push(new webpack.HotModuleReplacementPlugin());
  config.plugins.push(new ReactRefreshWebpackPlugin());
  config.plugins.push(new BundleAnalyzerPlugin({ analyzerMode: 'server', openAnalyzer: false }));
}

if (!isDev && config.plugins) {
  config.plugins.push(new webpack.LoaderOptionsPlugin({ minimize: true }));
  config.plugins.push(new BundleAnalyzerPlugin({ analyzerMode: 'static' }));
}

export default config;

 

3. 이제 진짜 빌드해보기


이제 제대로 컴파일이 되는지 빌드해볼 시간이다.

package.json의 scripts 명령어에 build를 추가해주자.

// package.json
{
  "name": "frontend",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "build": "cross-env NODE_ENV=production webpack",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  //..
}

 

실행시키면 dist 폴더 안에 app.js로 압축된 폴더를 확인할 수 있다.

+ 여담인데 main의 entry point는 전혀 실행에 관계가 없는 듯하다. 오직 webpack.config.ts의 entry만 실제 연관이 있는 듯 하다.

 

4. Hot Reloading 설정.


 

webpack이 설정되긴 했지만, 매번 빌드하고 켜볼 순 없기 때문에 핫 리로딩 설정을 따로 넣어줘야한다.

먼저, module의 options로 react-refresh를 넣어준다.

//..
    options: {
          presets: [
            [
              '@babel/preset-env',
              {
                targets: 'last 2 versions',
                debug: isDev,
              },
            ],
            '@babel/preset-react',
            '@babel/preset-typescript',
          ],
          env: {
            development: {
              plugins: [require.resolve('react-refresh/babel')],
            },
          },
          exclude: path.join(__dirname, 'node_modules'),
        },
        //..

 

이후에 스크립트를 package.json에 추가해주자.

 

"scripts": {
    "start": "npm run dev -- --open",
    "dev": "cross-env NODE_ENV=development webpack serve",
    "build": "cross-env NODE_ENV=production webpack",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

 

보통 CRA로 실행시킬 때 npm start를 사용하고 기본 브라우저로 자동 실행시키는 기능이 있으니,

dev로 hot-update.js를 만들기 전에 새 창을 열어주는 기능을 넣었다.

 

결과는?

 

 

핫 리로딩까지 성공했다.

다 끝나고 나니까 드는 생각인데 CRA 정말 좋은 설정인 것 같다.

 

출처


웹팩 핸드북 - @joshua1988

webpack server & HMR - @yoiyoy

webpack - 공식문서

webpack-bundle-analyzer를 사용한 번들 사이즈 최적화

webpack 모듈 번들러 - @yamoo9