본문 바로가기

FrontEnd+

순수(?) 리액트 앱에 webpack 설정하기 (without CRA)

<우아한테크코스> 레벨 3 팀프로젝트를 시작하면서 가장 먼저 맞닥뜨린 과제는 CRA 없이 개발환경을 setting 하는 것이었다.

레벨 2 기간 내내 편하게 써온 CRA를 사용하면 안 된다는 과제가 던져졌다.

우아한테크코스 레벨3 팀프로젝트 기술스택 요구사항

 

 

🤔 CRA, 편한데 왜?

CRA는 Create React App의 약자로, 초보자들도 쉽고 빠르게 개발환경을 세팅할 수 있도록 리액트 팀에서 제공하는 보일러 플레이트이다. 터미널에 아래와 같이 명령어 한 줄만 치면 기본적으로 필요한 패키지를 모두 포함한 프로젝트의 기본 뼈대를 만들어준다.

npx create-react-app --use-npm

CRA가 구성해주는 디렉토리 구조

 

코치님들께서 CRA 사용을 제한하신 이유는 무엇일까?

CRA를 사용하면 편하긴 하다. CRA 편한 것을 즐기려면 적어도 CRA가 무엇을 얼마나 해주는지는 알아야 할텐데 실상 그렇지 못했다. 그래서 웹팩 설정 하나를 변경하려고 하면, 어디서 어떻게 변경해야할지 막막했다.

현업에서 과연 CRA를 쓸까를 생각해보면 그렇지도 않을 것 같다. CRA를 하고 나서 항상 하는 일은 자동으로 생성된 파일 중 사용하지 않는 것들을 정리하는 일이었다. CRA에서 '기본적으로' 만들어주는 것 중에 실상 필요하지 않은 부분도 많다는 말이다.

CRA 없이 개발환경을 설정해보는 경험은 앞으로 CRA를 사용하더라도 우리에게 조금 더 능동적이고 자유롭게 개발환경을 주물럭거릴 수 있는 학습과정이 되지 않을까.

 

+ 프로젝트 시작 3주차 (7. 24)
1주차 때 잘 모르고 설정했던 것들이 개발을 진행하면서 문제가 되어, 계속 찾아보고 고치면서 설정하고 있다. 역시 CRA 품에서 벗어나서 얻게 되는 것들이 많다.

 

 

🛠 기본 패키지 설정

가장 처음으로는 패키지를 init 하고 react 등 필요한 패키지를 설치한다.

npm init
npm i react react-dom prop-types
npm i styled-component // (선택) CSS in JS 라이브러리
npm i react-query // (선택) 비동기 상태관리 라이브러리

 

package.json 설정

package.json 파일은 npm init 명령어로 프로젝트를 만들면 자동으로 생성된다. 이 파일은 해당 프로젝트에 대한 중요한 데이터를 정의하는 데 사용한다.

- 'name' 과 'version' 는 해당 패키지를 식별하는 데에 가장 중요한 속성이다.
- 'description' 에 적힌 문자열은 npm search 명령어를 실행했을 때 검색 대상이 된다.
- 'main' 에는 프로젝트의 진입점을 명시한다. (명시하지 않아도 기본값이 index.js이긴 하다.)
- 'scripts' 에는 npm 명령어를 정의한다.
- 'contributors' 는 author와 다르게 배열로 작성할 수 있다. 우리는 팀프로젝트여서 contributors를 작성했다.
- 'license' 에 명시된 ISC는 npm 의 기본 라이센스이다. ISC는 베른 컨벤션을 적용하지 않는다는 점 외에 MIT 라이센스와 동일하다.
- 'repository' 에는 코드가 어디에 위치해 있는지 적어 repo를 찾아올 수 있도록 돕는다.
- 'engines' 는 어떤 노드 버전에서 작업을 하고 있는지 명시할 수 있는 속성이다. 버전이 다르면 경고를 띄워주기만 한다.
- 'dependencies' 에는 해당 프로젝트에 필요한 패키지와 그의 버전을 명시한다.

{
  "name": "see-you-there-frontend",
  "version": "0.1.0",
  "description": "여기서만나(프론트엔드)",
  "main": "index.js",
  "scripts": {
    ...,
  },
  "contributors": [
    {
      "name": "Seonbin Im",
      "email": "0imbean0@gmail.com",
      "url": "https://github.com/0imbean0"
    },
    {
      "name": "Haru Kim",
      "email": "365listener@gmail.com",
      "url": "https://365kim.tistory.com/"
    }
  ],
  "repository": {
    "type": "git",
    "url": "https://github.com/woowacourse-teams/2021-see-you-there".
    "directory": "frontend"
  },
  "engines": {
    "node": "v14.17.3",
    "npm": "6.14.13"
  },
  "license": "ISC",
  "dependencies": {
    "prop-types": "15.7.2",
    "react": "17.0.2",
    "react-dom": "17.0.2",
    "react-query": "3.18.1",
    "react-router-dom": "5.2.0",
    "styled-components": "5.3.0"
  },
}

 

 

 

🛠 웹팩 & 바벨 설정

웹팩과 로더, 플러그인을 설치한다.

npm i -D webpack webpack-cli
npm i -D file-loader url-loader
npm i -D html-webpack-plugin clean-webpack-plugin
npm i -D babel-loader @babel/core @babel/preset-env @babel/preset-react

 

package.json 변동사항

{
  ....,
  "devDependencies": {
    "@babel/core": "7.14.6",
    "@babel/preset-env": "7.14.7",
    "@babel/preset-react": "7.14.5",
    "babel-loader": "8.2.2",
    "clean-webpack-plugin": "4.0.0-alpha.0",
    "file-loader": "6.2.0",
    "html-webpack-plugin": "5.3.2",
    "url-loader": "4.1.1",
    "webpack": "5.43.0",
    "webpack-cli": "4.7.2",
  }
}

 

webpack.config.js 설정

const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');

const config = ({ isDev }) => ({
  mode: isDev ? 'development' : 'production',
  resolve: {
    extensions: ['.js', '.jsx'],
  },
  entry: {
    main: './src/index',
  },
  output: {
    path: path.join(__dirname, 'dist'),
    publicPath: '/',
    filename: '[name].js',
  },
  module: {
    rules: [
      {
        test: /\.(png|jpg|svg|gif)$/,
        loader: 'url-loader',
        options: {
          name: '[name].[ext]?[hash]',
          limit: 5000,
        },
      },
      {
        test: /\.(js|jsx)$/,
        exclude: '/node_modules',
        loader: 'babel-loader',
        options: {
          presets: [
            ['@babel/preset-env', { targets: { esmodules: true, browsers: ['last 2 versions'] } }],
            '@babel/preset-react',
          ],
          plugins: [isDev && 'react-refresh/babel'].filter(Boolean),
        },
      },
    ],
  },
  plugins: [
    new webpack.DefinePlugin({
      VERSION: JSON.stringify('v0.1.0'),
    }),
    new CleanWebpackPlugin(),
    new HtmlWebpackPlugin({
      template: './src/index.html',
    }),
    new ReactRefreshWebpackPlugin(),
  ],
});

module.exports = (env, argv) => config({ isDev: argv.mode === 'development' });

 

🙋🏻‍♀️ babel/preset-env 더 알아보기

babel/preset-env 사용자가 원하는 수준에 맞추되 번거로운 설정 과정 없이, 최신 JavaScript를 사용할 수 있게 해주는 편리한 preset이다. (preset의 말뜻 그대로 '미리 설정된 것'을 말한다.) 사용자가 목표로 하는 환경을 명시만 하면 그에 맞게 번들링을 해주기 때문에, 불필요하게 번들링 사이즈가 커지는 것도 방지할 수 있다.

presets: [
            ['@babel/preset-env', { 
              targets: { 
                browsers: ['last 2 versions'],
              } 
            }],
            '@babel/preset-react',
          ],

 

만약 target을 별도로 명시하지 않으면, preset-env는 기본적으로 모든 ES2015-ES2020 코드를 ES5에 맞춰 변환한다. 하지만 어떤 목표하는 수준을 명시하지 않고 몽땅 ES5로 변환하는 것은, babel/preset-env의 기능을 활용하지 않는 거라 별로 권장하지는 않는다고 한다. 참고로, babel/preset-env로 설정하고자 하는 브라우저를 선택할 수 있는 이유는 browserslist와 같은 오픈소스 프로젝트 덕분이다. 사용자가 특정 브라우저 target을 지정하면, 바벨은 browserslist 의 데이터를 참고해서 어떤 JavaScript 구문으로 트랜스파일할지 결정한다. 

우리 프로젝트에는 원래는 'browsers' 타겟만 적어두었었다. 그런데 async await 구문을 사용하려고 할 때 '바벨 regenerateRuntime 에러'가 발생했다. 구글링 해서 발견한 해결책을 참고해서 'esmodules'도 추가해서 적었다.

regeneratorRuntime 에러

 

targets에 'esmodules'와 'browsers' 를 동시에 명시해두니, 이번에는 웹팩 번들링 시 다음과 같은 경고 문구가 새로 표시되었다. 

@babel/preset-env: esmodules and browsers targets have been specified together.
`browsers` target, `last 2 versions` will be ignored.

바벨 공식 홈페이지에 따르면 'esmodules'를 true는 "ES 모듈을 지원하는 모든 브라우저"를 지원하도록 설정하는 것이라고 한다. 그리고 esmodules 설정이 browsers를 명시하는 것보다 우선순위가 높아, esmodules를 true로 설정한다면 브라우저 target을 명시하더라도 무시된다.

우리가 설정한 두 경우에 각각 어떤 버전이 지원되는 것인지 알아보기 위해, 다음과 같은 명령어를 터미널에서 실행해보았다. 

'last 2 versions'의 경우 총 30개의 버전을, 'es6-module'의 경우 총 143개 버전을 지원하고 있었다. (browserslist 4.16.6 기준) 

후자의 경우 브라우저 지원 범위가 5배 가까이 넓었지만, 그렇다고 해서 빌드 결과물이 5배 무거워지는 것을 아닐 것이다. 실제로 웹팩으로 빌드를 해도 번들링 결과 사이즈나 번들링 속도에 큰 차이가 없었다.

npx browserslist 'latest 2 versions' 
// 총 30개
// and_chr 91, and_ff 89, and_qq 10.4, and_uc 12.12, android 91, baidu 7.12, bb 10, bb 7, chrome 92, chrome 91, edge 91, edge 90, firefox 90, firefox 89, ie 11, ie 10, ie_mob 11, ie_mob 10, ios_saf 14.5-14.7, ios_saf 14.0-14.4, kaios 2.5, op_mini all, op_mob 62, op_mob 12.1, opera 77, opera 76, safari 14.1, safari 14, samsung 14.0, samsung 13.0

npx browserslist 'supports es6-module' 
// 총 143개
// (너무 길어서 생략)

 

 

 

🛠 개발서버 설정

개발용 서버를 설정하기 위해 웹팩 데브 서버 패키지를 추가로 설치한다. 이때 react-refresh도 함께 설치해주었다. react-refresh는 코드를 수정하더라도 새로고침 없이 수정된 사항만 쏘옥 반영해주는 개발용 라이브러리이다. 폼을 작성하다가 CSS 한 줄을 수정해도 작성하던 것이 날아가지 않아 편-안하다.

npm i -D webpack-dev-server
npm i -D react-refresh @pmmmwh/react-refresh-webpack-plugin

 

package.json 설정

scripts에 개발용 서버를 실행시키는 명령어 npm start와 빌드 결과물을 생성하는 npm build 두 개의 명령어를 추가한다.

'--mode' 는 옵션을 넣으면 DefinePlugin 상에 process.env.NODE_ENV 값을 해당 값으로 설정해준다. 'development', 'production', 'none' 중 하나의 값을 갖는다. 빌드 최적화에 사용할 수 있다.
'--progress' 는 옵션을 넣으면 빌드 현황을 보여준다.

{
  "scripts": {
    "start": "webpack serve --mode=development --progress",
    "build": "webpack --mode=production"
  },
  ...,
  "devDependencies": {
    ...,
    "@pmmmwh/react-refresh-webpack-plugin": "0.4.3",
    "react-refresh": "0.10.0",
    "webpack-dev-server": "3.11.2"
  }
}

 

webpack.config.js 설정

const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');

const config = ({ isDev }) => ({
 module: {
    rules: [
      ...,
      {
        test: /\.(js|jsx)$/,
        exclude: '/node_modules',
        loader: 'babel-loader',
        options: {
          ...,
          plugins: [isDev && 'react-refresh/babel'].filter(Boolean),
        },
      },
    ],
  },
  plugins: [
    ...,
    new ReactRefreshWebpackPlugin(),
  ],
  devServer: {
    contentBase: path.join(__dirname, 'dist'),	// 빌드 결과물의 path
    publicPath: '/',				// 브라우저에서 접근하는 path. (기본값: '/')
    port: 9000, 				// 개발서버 포트 (기본값: 8080)
    historyApiFallback: true,			// 404 응답 시 index.html로 리다이렉트
    open: true,
    hot: true,
    overlay: true,				// 웹팩 빌드 에러를 브라우저 상에 출력
    stats: 'errors-only'			// 메세지 표시 수준 조절 (none, minimal, normal, verbose)
    proxy: {
      '/api': 'http://localhost:8080',		// 프론트 단에서 CORS 에러 해결하는 방법
    },
  },
});

module.exports = (env, argv) => config({ isDev: argv.mode === 'development' });

 

 

 

🛠 린터 & 포맷터 설정

eslint 역할은 코드 포맷팅과 코드 품질 향상이다. prettier 역할은 코드 포맷팅이다. 역할이 중첩되는 듯 하지만 prettier의 더 강력한 코드 포맷팅 기능을 활용하기 위에 둘 다 사용한다.

npm i -D eslint prettier
npm i -D eslint-plugin-prettier eslint-config-prettier 
npm i -D eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-jsx-a11y
npm i -D @babel/eslint-parser

이름이 매우 비슷한 eslint-plugin-prettier 와 eslint-config-prettier는 다음과 같이 다른 역할을 한다.

  • eslint-plugin-prettier:  prettier 상의 포맷팅 문제를 eslint 에러로서 표출해준다.
  • eslint-config-prettier:  eslint의 포맷팅과 prettier의 포맷팅 룰이 겹칠 때, eslint 포맷팅 무시한다.

 

package.json 설정

{
  "scripts": {
    "start": "eslint . && webpack serve --mode=development",
    ...
  },
  ...,
  "eslintConfig": {
    "env": {
      "browser": true,
      "node": true,
      "es6": true
    },
    "extends": [
      "eslint:recommended",
      "plugin:prettier/recommended",
      "plugin:react/recommended",
      "plugin:react-hooks/recommended",
      "plugin:jsx-a11y/recommended"
    ],
    "parser": "@babel/eslint-parser",
    "parserOptions": {
      "ecmaFeatures": {
        "modules": true,
        "jsx": true
      },
      "sourceType": "module",
      "requireConfigFile": false,
      "babelOptions": {
        "presets": [
          "@babel/preset-react"
        ]
      }
    },
    "settings": {
      "react": {
        "version": "detect"
      }
    },
    "ignorePatterns": "node_modules",
    "rules": {
      "react-hooks/exhaustive-deps": "off",
      "jsx-a11y/no-autofocus": "off",
      "no-unused-vars": [
        "error",
        {
          "varsIgnorePattern": "_"
        }
      ]
    }
  },
  "prettier": {
    "printWidth": 120,
    "singleQuote": true,
    "quoteProps": "consistent",
    "endOfLine": "auto"
  },
  "devDependencies": {
    "@babel/eslint-parser": "7.14.7",
    "eslint": "7.31.0",
    "eslint-config-prettier": "8.3.0",
    "eslint-plugin-jsx-a11y": "6.4.1",
    "eslint-plugin-prettier": "3.4.0",
    "eslint-plugin-react": "7.24.0",
    "eslint-plugin-react-hooks": "4.2.0",
    "prettier": "2.3.2",
  }
}

 

 

참고자료

- 웹팩 공식 홈페이지 - mode 
- npm 공식 홈페이지 - package.json 
- 바벨 공식 홈페이지 - @babel/preset-env