TS + React 개발 환경 직접 만들기

jhj46456·2020년 9월 12일
43
post-thumbnail

React 및 TS 기초에 대해 설명하진 않습니다. 초보자를 위한 내용이 아닙니다.

webpack v4 기준입니다.
📍 v5 준비 중입니다.

📚 모든 내용은 공식 문서를 기반으로 작성되었습니다.

💬 질문은 댓글 남기시면 답변해드립니다.

📌 webpack을 누구나 쉽게 이해할 수 있도록 정리하고 있는데, 후기 및 피드백 남겨주시면 감사하겠습니다.

현재 작업 중 : manifest, ts-loader를 babel-loader로 바꾸기

다음의 개발 환경을 구성합니다.

  • Package Manager : yarn
  • Library : React (TS)
  • JS : ES6+
  • CSS : Styled components

모든 설정을 따라할 필요는 없습니다. 프로젝트 성격에 맞게 활용하세요.


직접 만드는 이유

CRA가 있는데 굳이 힘들게 직접 만드는 이유가 무엇이냐?라고 묻는다면 필자는 장점을 말씀드리진 못합니다.

build time도 ESLint와 Type Checker를 설치하면 CRA에 비해 5~10초 정도 감소하는 정도이며
ESLint를 사용하지 않으면 build timeCRA에 비해 2배 넘게 감소합니다.

node_modules의 파일 개수도 직접 만들었더라도 패키지를 많이 설치하면 CRA를 초과할 수 있습니다.

소규모 프로젝트 정도는 대부분 CRA만으로 개발이 가능한데, 규모가 커지면 eject를 해야하는 상황이 생깁니다.

이럴 때 어차피 eject 해야할 거 그냥 직접 환경 만들자가 되는 것이라고 생각합니다.
webpack.config.js가 670 line인 상태에서 수정하는 것과 백지인 상태에서 수정하는 것은 완전 다르니까요.

필자가 20일 넘게 webpack을 연구한 결과, 172 line만으로 CRA와 동일한 chunk size와 유사한 CUI를 구현할 수 있었습니다.

필자는 직접 개발 환경을 만드는 것을 위와 같이 생각합니다.


yarn을 사용하는 이유

필자가 만든 https://mo-gak-ko.xyz/npmyarn 두 개로 각각 패키지를 설치하고, production build를 진행해보았습니다.

npm

yarn

노란색으로 표시된 부분은 node_modules에 대한 chunk인데,

package manager를 변경했다는 것만으로 bundle 크기가 줄어듭니다.

패키지 종속성 관리를 더 가볍게 해주는 것 같네요.


JS 설정과 다른 점

일단 설치하는 패키지 개수부터 차이가 납니다.

typescript를 설치함으로써 @babel/core, @babel/preset-env, @babel/preset-react 등 수 많은 babel 패키지가 대체됩니다.

JS로 React를 구성할 때는 resolve 옵션을 사용하지 않아도 자동으로 js를 모듈로 인식해서 실행에 지장이 없었는데,
TS로 React를 구성할 때는 resolve 옵션을 tsx, ts, js 순서로 읽히게 설정을 꼭 해야 합니다.

tsconfig.json으로 간편하게 컴파일러 옵션을 조절할 수 있습니다.


초기 설정

CRA의 directory 구조를 최대한 따라해보겠습니다.

프로젝트 디렉터리를 하나 생성합니다.

mkdir [project_name]

하위 디렉터리를 생성합니다.

cd [project_name]
mkdir src public build

public/index.html생성합니다.

📚 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>Typescript React without CRA</title>
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

React 기본 패키지와 @types 패키지도 설치합니다.

yarn init
yarn add react react-dom @types/react @types/react-dom

src/index.tsxsrc/App.tsx생성합니다.

📚 src/App.tsx
        👇
import React from "react";

function App() {
  return (
    <>work</>
  );
}

export default App;
---------------------------------------------------------------
📚 src/index.tsx
        👇
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

ReactDOM.render(<App />, document.getElementById("root"));

tsconfig.json

tsconfig.json을 사용하려면 당연히 typescript를 설치해야 합니다.

yarn add typescript -D

설치가 끝나면 tsc 명령어를 사용할 수 있습니다.

아래 구문으로 tsconfig.json을 간편하게 만들 수 있습니다.

npx tsc --init
yarn tsc --init

여기까지 따라하셨으면 디렉터리 구조가 이렇게 됩니다.

project_directory
  ㄴ build
  ㄴ node_modules
  ㄴ public
  ㄴ src
      ㄴ App.tsx
      ㄴ index.tsx
  ㄴ package-lock.json
  ㄴ package.json
  ㄴ tsconfig.json 👈

하지만 옵션들을 뭔지 모르고 그대로 따라하면 안되겠지요.

Webpack typescript guide를 기반으로 필자의 tsconfig.json 설정을 소개하겠습니다.

📚 tsconfig.json
         👇
{
  "compilerOptions": {
    /* Basic Options */
    "target": "es5",
    "module": "esnext",
    "allowJs": true,
    "jsx": "react",
    "sourceMap": true,
    "outDir": "./build",
    
    /* Strict Type-Checking Options */
    "strict": true
    
    /* Module Resolution Options */
    "moduleResolution": "node",
    "esModuleInterop": true
    
    /* Advanced Options */
    "skipLibCheck": true
    "forceConsistentCasingInFileNames": true
  },
  "include": ["./src/**/*"]
}
  • target : 어느 버전의 ECMAScript로 컴파일 할지 결정합니다. 기본값은 ES5이며 ES5면 대부분의 레거시 환경에서 구동이 가능합니다.
  • module : 컴파일 결과물을 어떤 Module Format으로 내보낼지 결정합니다. Module Format에 대한 부분은 필자가 정리해둔 글이 있습니다.

    일반적으로 Node.js로 백엔드를 구성하는 경우 Node.jsCommonJS가 기본 내장되어 있기 때문에 CommonJS를 옵션으로 사용합니다.
    그래서 Node.js 백엔드의 경우 Module bundler가 필요 없습니다.
    React는 Module bundle 과정을 거쳐 하나의 JS 파일로 만들고, webpackTree Shaking을 위해 es6+을 옵션으로 사용합니다.

  • allowJs : Javascript 파일을 컴파일 할지 결정합니다.
  • jsx : JSX 코드 생성을 preserve, react-native, react 셋 중 어느 것으로 할지 결정합니다.
  • sourceMap : 배포용으로 빌드한 파일과 원본 파일을 서로 연결시켜주는 기능. 배포를 할 때 최적화를 위해 HTML, CSS, JS를 압축하는데, 압축하여 배포한 파일에서 에러가 날 때 디버깅을 하기 위한 옵션입니다.
  • outDir : 컴파일 결과물이 저장될 디렉터리를 지정합니다.
  • strict : 모든 Type-Checking 옵션을 활성화합니다.
  • moduleResolution : typescript가 모듈을 읽는 순서를 설정합니다. node.js를 사용하는 경우 node_modules를 먼저 읽게 만들기 위해 node를 사용합니다.
  • esModuleInterop : 아래 설명 참조

    js로는 모듈을 불러올 때 다음 문법이 가능했습니다.
    import express from "express";

    ts로 모듈을 불러오려면 다음과 같이 작성해야 했습니다.
    import * as express from "express";

    ts로는 js처럼 __importDefault가 안된다는 겁니다.

    그럴 때, js처럼 모듈을 import하기 위해서 esModuleInterop 옵션을 활성화합니다.

  • skipLibCheck : declaration 파일(*.d.ts)의 type-checking을 skip합니다.
  • forceConsistentCasingInFileNames : 같은 파일에 대한 일관성 참조 불허 기본값은 true.
  • include : 컴파일할 directory/file을 지정

    컴파일러는 files, include 둘 다 지정이 안되어있으면 exclude에서 설정된 것을 제외한 모든 .ts, .d.ts, .tsx 파일들을 포함시킵니다.

    exclude 옵션이 비어있으면 node_modules, bower_components, jspm_packages, <outDir>을 기본으로 제외합니다.

    include와 exclude는 *, **/, ? (glob pattern) 사용이 가능합니다.


webpack.config.js

CRA의 build directory 구조를 최대한 따라해봅니다.

프론트의 js같은 경우에는 Module Bundler를 사용해야 하는 이유가 몇 가지 있습니다.

  1. Module Format을 사용하기 위함. (Node같이 CommonJS가 내장되어 있지 않음)
  2. 여러 번의 요청 대신 한번의 요청 만으로 js를 불러옴.
  3. ...

Module Bundler는 webpack, gulp, ... 등 다양하지만, 페이스북이 webpack을 사용하므로 webpack으로 설정하겠습니다.

핵심 패키지 설치

yarn add webpack webpack-cli -D

webpack.config.js생성합니다.

project directory
  ㄴ webpack.config.js

https://webpack.js.org/concepts/ 공식 문서를 기반으로 진행하면서 설정하겠습니다.

Mode

📌 webpack environment-variables docs
📌 webpack environment-variables option docs

webpack 환경 변수에 따라 development, production, none 세 가지로 설정할 수 있습니다.

기본값production입니다.

📚 webpack.config.js 
         👇   
module.exports = (webpackEnv) => {
  const isEnvDevelopment = webpackEnv === "development";
  const isEnvProduction = webpackEnv === "production";
  return {
    mode: webpackEnv,
  };
};

script 작업할 때 --env development/production cli 옵션으로 넣어줄 예정입니다.
env 옵션으로 받은 문자열은 webpackEnv로 넘어갑니다.

Entry

번들 작업을 할 파일을 선택합니다.

entry: string | [string]

index.tsx를 entry에 넣어줍니다.

📚 webpack.config.js 
         👇
const appIndex = path.resolve(__dirname, "src", "index.tsx");

module.exports = (webpackEnv) => {
  ...
  return {
    mode: webpackEnv,
    entry: appIndex,
  };
};

mode: development, production 환경 별로 다른 작업을 할당하기 위해 사용함.

Output

번들 결과물이 저장될 경로를 지정합니다.

자주 쓰이는 옵션으로는 pathfilename이 있습니다.

CRA의 build directory는 다음과 같은 구조입니다.

build
  ㄴ static
       ㄴ js
          ㄴ ...
       ㄴ css
          ㄴ ...
       ㄴ media
          ㄴ ...

위와 같은 구조가 될 수 있도록 변경해봤습니다.

📚 webpack.config.js
         👇
const path = require("path");

const appBuild = path.resolve(__dirname, "build");

module.exports = (webpackEnv) => {
  ...
  return {
    ...
    output: {
      path: appBuild,
      filename: isEnvProduction
        ? "static/js/[name].[contenthash:8].js"
        : isEnvDevelopment && "static/js/bundle.js",
    },
  };
};

여기서 잠깐!

Q. __dirname은 뭐에요?

A. 작업 중인 파일의 위치를 파일명을 제외한 가장 가까운 directory까지 보여주는 예약어입니다.

Loaders

📌 사용 가능한 loaders 목록

webpack은 기본 설정에서 .js와 .json만 이해가 가능합니다. .tsx는 이해하지 못하죠.

이 항목에서는 '어떤 확장자는 이렇게 처리해라~'같은 설정을 해줍니다.

옵션은 다음과 같습니다.

  • test : 이 확장자는 webpack이 해석할 것입니다.

    정규식(regex) 분석
    / ... / : 정규식의 시작과 끝을 나타냄
    \.: .을 나타내는 escaped character
    ( ... ) : 문자열을 판별하는 group
    a | b : a or b
    $ : 해당 정규식으로 끝나는 문자열

  • use : 변환을 위해 사용하는 loader

  • exclude : 제외할 file/directory (절대경로)

  • include : 포함시킬 file/directory (절대경로)

그렇다면 React Component( .tsx )는 typescript로 작성하기 때문에 ts-loader가 필요합니다.

yarn add ts-loader -D

설치가 끝났다면 ts-loader docs를 보고 설정을 추가합니다.

📚 webpack.config.js
         👇
const path = require("path");

module.exports = (webpackEnv) => {
  ...
  return {
    ...
    module: {
      rules: [
        {
          test: /\.(ts|tsx)$/,
          exclude: /node_modules/,
          use: [
            {
              loader: "ts-loader",
              options: {
                transpileOnly: isEnvDevelopment ? true : false,
              },
            },
          ],
        },
      ],
    },
  };
};

여기서 잠깐!

Q. transpileOnly는 뭐에요?

A. ts-loaderloader options 중 하나입니다. 이 옵션을 사용하지 않으면 개발 환경에서 실행함에도 불구하고 변경 사항이 있을 때마다 컴파일을 하기 때문에 프로젝트 사이즈가 커질 수록, 모듈이 많아질 수록 대기 시간이 길어집니다. 그래서 개발 환경(isEnvDevelopment)이 development일 경우에만 활성화하였습니다.

transpileOnlyts-node 패키지의 CLI 옵션 -T or --transpile-only으로도 종종 쓰입니다.

📌 styled-components로 스타일링할 예정이라 ts-loader만 설정하였음.

cache-loader (optional)

매번 build할 때 모든 파일을 확인하다보니 오랜 시간이 걸립니다.

이런 경우 caching을 하여최초 build 시에만 모든 파일을 읽고, 다음 build 부터는 cache로부터 읽어들여 변경사항이 있는 파일만 build하면 빠른 프로덕션 빌드를 얻을 수 있습니다.

babel-loader에는 cache 옵션이 내장되어 있는데 ts-loader는 해당 옵션이 없어 cache-loader를 사용하였습니다.

기본적으로는 cache-loaderRule.use안에 삽입하면 해당 loader가 읽는 파일들을 Caching합니다.

패키지 설치

yarn add cache-loader -D 

webpack cache-loader docs를 보고 꾸며봤습니다.

📚 webpack.config.js
          👇
module.exports = (webpackEnv) => {
  return {
    module: {
      rules: [
        {
          test: /\.(ts|tsx)$/,
          exclude: /node_modules/,
          use: [
            "cache-loader",
            {
              loader: "ts-loader",
              options: {
                transpileOnly: isEnvDevelopment ? true : false,
              }
            }
          ]
        }
      ]
    }
  }
}

초기 프로덕션 빌드에서만 시간이 오래 걸리고, 다음 부터는 빌드 시간이 줄어든 것을 확인할 수 있습니다.

file-loader (optional)

React로 개발할 때 이미지를 import해서 가져와야 할 때가 있습니다.

import ReactIcon from "./assets/React.png";

function Sample() {
  return (
    <img src={ReactIcon} />
    <img src={require("./assets/React.png")} />
  )
}

이것 처럼요.

file-loader가 rules에 없는 상태에서 webpack을 실행하면 이런 에러가 나옵니다.

ERROR in ./src/assets/React.png 1:0
Module parse failed: Unexpected character '�' (1:0)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
(Source code omitted for this binary file)
 @ ./src/App.js 4:0-43 11:9-18
 @ ./src/index.js
 

요약하면 이 파일 형식을 처리하기 위해선 적절한 로더가 필요할 수 있으며, 현재 이 파일을 처리하도록 구성된 로더가 없습니다. 입니다.

필요한 패키지를 설치합니다.

yarn add file-loader -D 

file-loader docs를 보고 설정을 해봅니다.

📚 webpack.config.js
         👇
module.exports = (webpackEnv) => {
  ...
  return {
    ...
    module: {
      rules: [
        ...
        {
          loader: "file-loader",
          exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
          options: {
            outputPath: "static/media",
            name: "[name].[hash:8].[ext]",
            esModule: false, 
          },
        },
      ],
    },
  };
};

옵션은 다음과 같습니다.

  • name : 파일명 지정
  • outputPath : 파일이 저장될 시스템 경로 지정
  • publicPath : express.static 같은 느낌의 정적 파일 제공과 유사하다. 만약 static으로 지정되었다면 /static/~.[ext]로 static 내의 파일에 접근할 수 있다.

    기본값이 __webpack_public_path__ + outputPath라서 특별한 경우가 아니면 outputPath만 설정해줘도 됩니다.

  • esModule : CommonJS를 사용하기 위한 옵션, 모듈 연결/트리 쉐이킹이 필요한 경우 기본값(true) 권장.

    기본값(true) 일 때 console.log(require("./assets/React.png"))의 결과
    Module { default: "static/media/React.090b95be.png", Symbol: "Module", _esModule: true }

    false일 때 console.log(require("./assets/React.png"))의 결과
    static/media/React.090b95be.png

Typescript인 경우 import에서 문제가 발생합니다.

tsconfig.json 수정하기

url-loader (optional)

📌 url-loader docs

기본적으로 file-loader와 같은 기능으로 동작합니다. 하지만 차이점이라면 바이트 제한보다 작은 경우 DataURL을 반환합니다.

DataURL은 base64로 제공됩니다.

이것을 언제 사용할까요?

작은 크기의 이미지, 글꼴 등은 복사하지 않고 base64 문자열 형태로 번들 결과물에 넣습니다.

패키지 설치 및 수정

yarn add url-loader -D 

📚 webpack.config.js
         👇
module.exports = (webpackEnv) => {
  ...
  return {
    ...
    module: {
      rules: [
        { ... },
        {
          test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
          loader: "url-loader",
          options: {
            limit: 10000,
            outputPath: "static/media",
            name: "[name].[hash:8].[ext]",
          },
        },
      ],
    },
  };
};

옵션은 다음과 같습니다.

limit : Boolean | Number | String를 입력으로 받습니다. 기준은 Byte입니다. 10000은 10KB입니다. test하는 확장자가 10KB를 넘으면 file-loader로 작동하고, 10KB 아래면 base64 문자열로 번들 결과물에 들어갑니다.
outputPath : 파일이 저장될 시스템 경로 지정
name : 파일명 지정

outputPath, name은 url-loader 문서에는 없는 설정이지만 작동합니다.

브라우저에서도 base64로 들어가는 것을 볼 수 있습니다.

svg / favicon 등 저용량에 유용합니다.

file-loader가 필요하다는 ERROR 발생 시 file-loader를 설치합니다.

Typescript인 경우 import에서 문제가 발생합니다.

tsconfig.json 수정하기

file-loader & url-loader error

JS에서는 발생하지 않았던 에러가 발생합니다.

모듈 "~/React.png"를 찾을 수 없거나 해당 유형 선언을 찾을 수 없음

require()는 정상 동작합니다.

function Larry() {
  return <img src={require("./assets/React.png")} />
}

Importing Other Assets docs

declaration file을 만들기 위해 /src/@types 디렉터리를 생성합니다.

cd src
mkdir @types

src/@types/import-png.d.ts를 생성합니다.

📚 src/@types/import-png.d.ts
              👇
declare module "*.png" {
  const content: string;
  export default content;
}

tsconfig.json을 수정합니다.

📚 tsconfig.json
        👇
{
  "compilerOptions": {
    ...
    "typeRoots": [
      "./node_modules/@types",
      "./src/@types"
    ]
  },
  "include": ["./src/**/*"]
}

이 부분은 설명하진 않겠습니다. Typescript 기초라서 설명하는 것 자체가 Fun하고 Cool하지 못하네요.

eslint-loader (optional)

Typescript로 개발하는데 ESLint가 왜 나오냐고 하실 수도 있습니다.

일단 eslint는 JS만 지원하는 것이 아니라 추가 패키지 설치를 통해 TS까지 지원이 가능합니다.

거기에 ts-lint는 npm 문서 상에서 deprecated되어 eslint를 권장합니다.

CRA를 최대한 따라한다고 했으니 CRA에서 사용하는 eslint config을 진행하겠습니다.

먼저 webpack eslint-loader docs를 보고 설치 및 설정이 필요합니다.

yarn add eslint eslint-loader -D 

📚 webpack.config.js
          👇
const appSrc = path.resolve(__dirname, "src");

module.exports = (webpackEnv) => {
  return {
    ...
    module: {
      rules: [
        {
          test: /\.(ts|tsx)$/,
          enforce: "pre",
          exclude: /node_modules/,
          loader: "eslint-loader",
          options: {
            cache: true,
            formatter: isEnvDevelopment
              ? "codeframe"
              : isEnvProduction && "stylish",
          },
          include: appSrc,
        },          
      ]
    }
  }
}

formatter options docs

loader를 추가했으니 eslint 설정이 필요합니다.

CRAeslint-config-react-app을 사용합니다. 문서를 보면 알 수 있듯이 airbnb처럼 preset같은 느낌이라 간단하게 설정이 가능합니다.

yarn add eslint-config-react-app @typescript-eslint/eslint-plugin@2.x @typescript-eslint/parser@2.x babel-eslint@10.x eslint@6.x eslint-plugin-flowtype@4.x eslint-plugin-import@2.x eslint-plugin-jsx-a11y@6.x eslint-plugin-react@7.x eslint-plugin-react-hooks@2.x -D
📌 20년 9월 23일 기준이므로 문서 참조 필요

이후 .eslintrc.json 혹은 package.json에 세 줄만 추가하면 됩니다.

📚 .eslintrc.json
         👇
{
  "extends": "react-app"
}

📚 package.json
        👇
"eslintConfig": {
  "extends": "react-app"
}

이제부터 Quick FixCompile warning을 볼 수 있습니다.

resolve ⭐

webpack은 기본값으로 .js와 .json만 설정이 되어있다고 위에서 말씀드렸는데요.

모듈을 해석하는 방식도 .js가 1순위로 잡혀있어 Typescript + React인 경우 이 순서를 바꿔주는 작업이 필요합니다.

📚 webpack.config.js
         👇
module.exports = (webpackEnv) => {
  ...
  return {
    ...
    resolve: {
      extensions: [".tsx", ".ts", ".js"],
    },
  };
};

Plugins

React는 아시다시피 index.html에 번들 작업을 마친 js 파일이 script 태그로 추가되어 작동하는 방식입니다.

그러기 위해서는 public/index.htmlbuild/ 로 옮겨주면서 번들 결과물이 script 태그로 들어가야 합니다.

Plugins 목록

이런 역할을 하는 플러그인이 하나 있습니다.

yarn add html-webpack-plugin -D

설치가 끝나면 html-webpack-plugin docs를 보고 설정을 해줍니다.

📚 webpack.config.js
         👇
...
const HtmlWebpackPlugin = require("html-webpack-plugin");
const appHtml = path.resolve(__dirname, "public", "index.html");

module.exports = (webpackEnv) => {
  ...
  return {
    ...
    plugins: [new HtmlWebpackPlugin({ template: appHtml })],
  };
};

📌 template 옵션을 사용하지 않으면 webpack이 자체적으로 html을 만듦.

Environment variables (optional)

CRA에서 .env로 환경 변수를 설정할 때 지켜야 하는 규칙이 하나 있습니다.

환경 변수 이름은 REACT_APP으로 시작해야 한다는 것이죠. 지키지 않으면 사용이 불가능합니다.

설정한 환경 변수는 보통 개발/배포 환경에 따라 다르게 설정해야 하는 경우 유용합니다.

const httpLink = new HttpLink({
  uri:
    process.env.NODE_ENV === "production"
      ? process.env.REACT_APP_PROD_API_URL
      : process.env.REACT_APP_DEV_API_URL,
});

개발 환경에서는 로컬 API 서버에 연결하고, 배포 환경에서는 실제 API 서버에 연결하는 코드입니다.

아래 이미지는 CRA로 만들어진 프로젝트에서 process.env를 콘솔에 찍어본 이미지입니다.


NODE_ENVPUBLIC_URL 그리고 REACT_APP 환경 변수만을 가져오죠.

이제 구현해보겠습니다. 먼저, 패키지를 설치합니다.

yarn add dotenv

다음으로 .env를 생성하여 테스트 값을 채워넣습니다.

📚 .env
    👇
REACT_APP_NAME="Larry Jung"
REACT_AP="ddd"
REACT_="dawd"

webpack에는 DefinePlugin이라는 플러그인이 있습니다.

컴파일 시 전역 상수를 구성하는 플러그인입니다.

📚 webpack.config.js
          👇
require("dotenv").config();
const webpack = require("webpack");

function getClientEnv(nodeEnv) {
  return {
    "process.env": JSON.stringify(
      Object.keys(process.env)
        .filter((key) => /^REACT_APP/i.test(key))
        .reduce(
          (env, key) => {
            env[key] = process.env[key];
            return env;
          },
          { NODE_ENV: nodeEnv }
        )
    ),
  };
} 👆 JS 기본기가 가득한 코드니 따로 설명은 안하겠다.

module.exports = (webpackEnv) => {
  ...
  const clientEnv = getClientEnv(webpackEnv); 👈 webpackEnv 값에 따라 NODE_ENV가 달라짐
  return {
    plugins: [
      ...,
      new webpack.DefinePlugin(clientEnv),
    ],
  };
};

주의사항
webpack.DefinePlugin({...})은 반드시 JSON 문자열 포맷으로 작성해야 합니다.

이 후 React 컴포넌트에서 실행해봅니다.

📚 src/App.js
       👇
console.log(process.env);

NODE_ENVwebpack mode에 따라 webpack이 임의적으로 process.env.NODE_ENV를 추가해줍니다.
그래서 process.env에는 나타나지 않지만 process.env.NODE_ENV로 접근하면 값이 나옵니다.
반드시 NODE_ENV를 넣어줘야 하는 것은 아닙니다.

환경 변수 세팅을 마치셨습니다.

만약, 필터링 없이 .env에 적힌 모든 것을 React로 보내고 싶다면 아래와 같이 하면 됩니다.

📚 webpack.config.js
          👇
const dotenv = require("dotenv");          
module.exports = (webpackEnv) => {
  ...
  return {
    plugins: [
      ...
      new webpack.DefinePlugin({
        "process.env": JSON.stringify(
          Object.assign({}, dotenv.config().parsed, {
            NODE_ENV: webpackEnv,
          })
        )
      }),
    ],
  }
}

webpack-manifest-plugin (optional)

먼저 배경 지식을 짚고 넘어가겠습니다.

runtime은 기본적으로 모듈화된 앱을 연결하는데 필요합니다. 모듈을 연결하는데 필요한 로직을 포함합니다.

html이 브라우저에서 실행되면 필요한 bundle과 asset을 불러와서 연결해야 합니다.
하지만, webpack의 최적화로 src는 합쳐지고, 최소화되고, chunk로 나눠집니다.
그렇다면 webpack은 모듈들을 어떻게 관리할까요? 여기에서 manifest가 사용됩니다.

manifest가 무엇이냐면, 컴파일러가 입력, 확인, 맵핑 과정을 거치면서 모듈에 대한 메모를 기록합니다.
data collectionmanifest라고 부릅니다.

data collection을 번들로 합쳐 브라우저에서 실행되면 모듈을 해석하고 불러오는데 runtime이 사용됩니다.

Module Format이 무엇이든 번들된 결과물은 __webpack_require__로 모듈을 식별합니다.

결론은 manifest data를 이용하여 식별자 뒤에 있는 모듈을 찾아야 하는 위치를 알아냅니다.

📚 build/bundle.js
         👇
/******/ 	// expose the modules object (__webpack_modules__)
/******/ 	__webpack_require__.m = modules;
/******/
/******/ 	// expose the module cache
/******/ 	__webpack_require__.c = installedModules;
/******/
/******/ 	// define getter function for harmony exports
/******/ 	__webpack_require__.d = function(exports, name, getter) {
/******/ 		if(!__webpack_require__.o(exports, name)) {
/******/ 			Object.defineProperty(exports, name, { enumerable: true, get: getter });
/******/ 		}
/******/ 	};

JS 난독화 작업 전에 볼 수 있는 모습. 난독화된 후에는 찾아볼 수 없다.

지금까지는 webpack이 보이지 않는 곳에서 어떻게 동작하는지 알아봤습니다.

물론, manifest가 없어도 홈페이지는 완벽하게 동작하는 것처럼 보입니다.

하지만 브라우저 캐싱을 사용한다면 문제가 발생합니다.

필자는 webpack.output 설정에 [contenthash:8]을 사용해서 매번 build마다 파일명이 변경됩니다.

이 경우 캐싱이 무효화 됩니다.

manifest를 추출하는 방법webpack-manifest-plugin을 사용하는 것입니다.

패키지 설치

yarn add webpack-manifest-plugin -D 

webpack.config.js 수정

📚 webpack.config.js
          👇
const ManifestPlugin = require("webpack-manifest-plugin");
module.exports = (webpackEnv) => {
  ...
  return {
    plugins: [
      ...,
      new ManifestPlugin({
        generate: (seed, files, entrypoints) => {
          const manifestFiles = files.reduce(
            (manifest, { name, path }) => ({ ...manifest, [name]: path }),
            seed
          );
          const entryFiles = entrypoints.main.filter(
            (filename) => !/\.map/.test(filename)
          );
          return { files: manifestFiles, entrypoints: entryFiles };
      }), // 📌 추가적인 옵션은 문서 참조
    ],
  };
};

public/index.html 수정

<head>
  <link rel="manifest" href="./manifest.json" />
</head>

이후 build에서 manifest.json이 생깁니다.

📚 build/manifest.json
{
  "files": {
    "main.js": "/static/js/main.9a18f1f4.chunk.js",
    "main.js.map": "/static/js/main.9a18f1f4.chunk.js.map",
    "runtime-main.js": "/static/js/runtime-main.9b2e54b7.js",
    "runtime-main.js.map": "/static/js/runtime-main.9b2e54b7.js.map",
    "static/css/2.0d11afa3.chunk.css": "/static/css/2.0d11afa3.chunk.css",
    "static/js/2.52bd26e2.chunk.js": "/static/js/2.52bd26e2.chunk.js",
    "static/css/2.0d11afa3.chunk.css.map": "/static/css/2.0d11afa3.chunk.css.map",
    "static/js/2.52bd26e2.chunk.js.map": "/static/js/2.52bd26e2.chunk.js.map",
    "index.html": "/index.html",
    "static/js/2.52bd26e2.chunk.js.LICENSE.txt": "/static/js/2.52bd26e2.chunk.js.LICENSE.txt",
    "static/media/banner-min.3c026bd5.jpg": "/static/media/banner-min.3c026bd5.jpg"
  },
  "entrypoints": [
    "static/js/runtime-main.9b2e54b7.js",
    "static/css/2.0d11afa3.chunk.css",
    "static/js/2.52bd26e2.chunk.js",
    "static/js/main.9a18f1f4.chunk.js"
  ]
}

브라우저 캐싱에 관심 있다면 webpack caching docs를 확인하세요.

fork-ts-checker-webpack-plugin (optional)

ESLint는 Syntax를 확인하고 fork-ts-checker-...는 Type을 확인합니다.

Issues checking in progress... 후에 syntax, type checking 모두 하는 모습

이 플러그인을 사용하기 위해 설치합니다.

yarn add fork-ts-checker-webpack-plugin -D 

type을 확인하기 위한 패키지를 설치합니다.

yarn add eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin -D 

ESLint 설정은 eslint-loader 항목을 참조하거나 본인이 직접 구성합니다.

설치 및 설정을 한 뒤, fork-ts-checker-webpack-plugin docs를 보고 꾸며봤습니다.

📚 webpack.config.js
          👇
const ForkTsCheckerWebpackPlugin = require("fork-ts-checker-webpack-plugin");        
module.exports = (webpackEnv) => {
  ...
  return {
    module: {
      rules: [
        📌 eslint-loader 제거
      ]
    }
    plugins: [
      ...
      new ForkTsCheckerWebpackPlugin({
        eslint: {
          files: "./src/**/*.{ts,tsx,js,jsx}",
        },
      }),
    ],
  }
}

문서에서 소개하는 ESLint와 연결하는 방법입니다.

ESLint를 사용하지 않을 것이고 ts-loader와 플러그인을 연결할 것이라면 문서를 참조합니다.

벤치마크

  • 조건 : eslint-loader, ts-loader = cache 비활성화 | Other options = cache 비활성화 | 동일한 소스 | 이 외 설정 동일

  • 실행 환경 : 필자 PC

  • A : eslint-loader + fork-ts-checker-webpack-plugin

    1 : 39.42s (no cache)
    2 : 24.63s (cached compressor toolkit)
    3 : 25.35s
    4 : 27.54s

  • B : fort-ts-checker-webpack-plugin({ eslint })

    1 : 34.95s (no cache)
    2 : 23.87s (cached compressor toolkit)
    3 : 22.27s
    4 : 24.09s

  • 결과

    최초 build는 B4.47s만큼 빠름. (기준 : 표본 1개)

    compressor toolkit이 캐싱된 후에는 B가 평균 2.43s 만큼 빠름. (기준 : 표본 3개)

Cache (optional)

cache-loader와는 다른 용도로 쓰이는 옵션입니다. 그래서 분류도 Other Options에 속해있습니다.

이것이 어떤 역할을 하냐면, Plugins에 대한 Caching을 해줍니다.

예시를 들면 JS compressor toolkit 혹은 css minimizer plugin 등이 Caching됩니다.

Webpack Other Options - cache docs를 보고 꾸며봤습니다.

📚 webpack.config.js
          👇
const dotenv = require("dotenv");          
module.exports = (webpackEnv) => {
  ...
  return {
    cache: {
      type: isEnvDevelopment ? "memory" : isEnvProduction && "filesystem",
    }
  }
}

생성된 webpack module과 chunk를 캐싱하여 빌드 시간을 단축시킵니다.

development 환경에서는 { type : "memory" }로 자동 적용됩니다.
production 환경에서는 { type : false }로 자동 적용됩니다.

DevServer

📌 https://webpack.js.org/configuration/dev-server/
📌 https://webpack.js.org/guides/development/#using-webpack-dev-server

CRA로 yarn start할 때 자동으로 localhost:3000이 열리는 것을 볼 수가 있는데요.

이와 같은 기능입니다. RAM에 개발 서버를 올려서 구동하는 것이죠.

필요한 패키지 설치

yarn add webpack-dev-server -D 

설정을 추가합니다.

📚 webpack.config.js
         👇
const appPublic = path.resolve(__dirname, "public");

module.exports = (env) => {
  ...
  return {
    ...
    devServer: {
      port: 3000,
      contentBase: appPublic,
      open: true,
      historyApiFallback: true,
      overlay: true,
      stats: "errors-warnings",
    },
  };
};

port : 웹 서버가 실행될 PORT 지정
contentBase : 개발 환경에서 정적 파일을 제공하려는 경우 필요합니다.
open : 번들 작업이 끝나면 자동으로 브라우저를 열어주는지
historyApiFallback : 개발 환경에서 localhost:3000/subpage 등 URL로 직접 접근하였을 경우, cannot get /subpage 대신에 index.html로 보내줍니다.
overlay : 컴파일러 오류 또는 경고가있을 때 브라우저에 전체 화면 오버레이를 표시
stats : 컴파일(트랜스파일) 시 보여주는 항목 설정

webpack-dev-server로 개발 서버를 실행하면 브라우저 콘솔에 이런 Warning이 나올 것입니다.

DevTools failed to load SourceMap: Could not load content for webpack:///node_modules/sockjs-
client/dist/sockjs.js.map: HTTP error: status code 404, net::ERR_UNKNOWN_URL_SCHEME

이 warning을 해결하는 방법은 devtool 옵션을 추가하는 것입니다.

📚 webpack.config.js
         👇
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== "false";

module.exports = (env) => {
  ...
  return {
    ...
    devtool: isEnvProduction
      ? shouldUseSourceMap
        ? "source-map"
        : false
     : isEnvDevelopment && "cheap-module-source-map",
  };
};

source-map : 일반적으로 production에서 사용 가능
cheap-module-source-map : 디버깅을 위한 최상의 옵션

scripts (optional)

CRA에는 scripts/ 디렉터리 안에 start, build, test 파일이 있습니다.

파일을 생성하기 전에 어떤 코드를 짜야할 지 생각을 해봅시다.

webpack을 production mode로 실행할 때, webpack compiler 동작 전에 build/ 디렉터리를 비워준 뒤,
index.html을 제외한 모든 public/ 파일을 build/로 복사해야 합니다.

이 동작을 2~3 줄 만으로 끝내는 코드가 있습니다.

📚 fs-extra 패키지를 이용한 방법
               👇
fs.emptyDirSync(paths.appBuild);
fs.copySync(paths.appPublic, paths.appBuild, {
  dereference: true,
  filter: file => file !== paths.appHtml,
});

하지만 필자는 모듈 의존성을 고려하고 코딩하기 때문에 fs-extra를 사용하지 않을 것입니다.

기본 제공하는 fs 만으로 기능 구현이 가능한데, 굳이 패키지를 추가로 설치해야 하냐는 것이죠.

scripts/prebuild.js 생성

project directory
  ㄴ scripts/
      ㄴ prebuild.js
      

내용 작성

📚 scripts/prebuild.js
           👇
const path = require("path");
const fs = require("fs");

const APP_DIR = process.cwd();
const BUILD_DIR = path.resolve(APP_DIR, "build");
const PUBLIC_DIR = path.resolve(APP_DIR, "public");

function getBuildPath(name) {
  return path.resolve(BUILD_DIR, name);
}
function getPublicPath(name) {
  return path.resolve(PUBLIC_DIR, name);
}

// build 내 파일이 존재하면 비우고 build directory가 없으면 만듬
function emptyDir(dir) {
  if (fs.existsSync(dir)) {
    fs.readdir(dir, (_, files) => {
      files.forEach((item) => {
        if (/_|\.[\w]{1,}/.test(item)) {
          fs.unlinkSync(getBuildPath(item));
        } else {
          fs.rmdirSync(getBuildPath(item), { recursive: true });
        }
      });
    });
  } else {
    fs.mkdirSync(dir);
  }
}
// `passList: Array<string>`를 제외한 모든 public 파일을 build/로 복사
function copyPublic(passList) {
  fs.readdir(PUBLIC_DIR, (_, files) => {
    files.forEach((item) => {
      if (!passList.includes(item)) {
        fs.copyFileSync(getPublicPath(item), getBuildPath(item));
      }
    });
  });
}

emptyDir(BUILD_DIR);
copyPublic(["index.html"]);

확실히 fs-extra를 사용하는 것 보다는 복잡해보입니다. 하지만 따지고 보면 직접 구현한 코드가 더 짧습니다.

무슨 🐶소리냐구요?

node_modules/fs-extra/lib/copy-sync.js의 LOC는 130입니다. (copySync 기능만 포함)
scripts/prebuild.js의 LOC는 38입니다. (모든 기능 포함)

더 이상의 설명은 생략합니다.

npm script

webpack command-line docs에 모든 cli 옵션이 적혀있습니다.

이제 Module Bulder를 설정했으니 CRA처럼 yarn start를 입력하면 개발 서버가 실행되는 기능을 구현해야 합니다.

📚 package.json
       👇
"scripts": {
  "prebuild": "node scripts/prebuild",
  "build": "webpack --mode production --env production",
  "start": "webpack-dev-server --env development"
},

📌 options
      👇
--progress : 진행 상황 표시
--watch, -w : 파일 변화 감지
--env larry : webpack.config.js에 환경변수 전달 >> "larry"
--env.name=larry : webpack.config.js에 환경변수 전달 >> { name: "larry" }

📌 shortcuts
      👇
-d : --debug --devtool cheap-module-eval-source-map --output-pathinfo
-p : --mode production

yarn start를 입력한 뒤 localhost:3000으로 접속해보세요.


stats (optional)

yarn startyarn build를 실행해보면 아시겠지만 필자만 이렇게 생각하는진 모르겠는데
build result를 지저분하게 보여줍니다.

이럴 때 사용하는 것이 stats 옵션입니다.

stats이란 webpack compiler가 동작하였을 때 출력하는 내용물을 결정하는 옵션입니다.

CRA필자가 설정한 옵션을 한번 보시죠

둘 다 동일하게 assets, time, publicPath을 동일하게 보여줍니다.

차이점이라면 CRAgzip size를 보여주고 Customparse size를 보여준다는 것이죠.
chunkCode Splitting과 관련된 내용입니다.

webpack stats docs를 보고 필자 입맛에 맞게 바꿔봤습니다.

📚 webpack.config.js
          👇
module.exports = (webpackEnv) => {
  return {
    ...
    stats: {
      builtAt: false,
      children: false,
      entrypoints: false,
      hash: false,
      modules: false,
      version: false,
      publicPath: true,
      excludeAssets: [/\.(map|txt|html|jpg|png)$/, /\.json$/],
      warningsFilter: [/exceed/, /performance/],
    },
  };
};

webpack을 경험한 기간이 짧다면 위의 설정은 비추천합니다.
모든 항목을 익힌 다음, 필요한 항목만 가져와야 합니다.


결과물

필자가 Router와 scss(styled-components)를 이용해서 간단하게 하나 만들어봤습니다.

Web : https://react-without-cra.netlify.app/
Github : https://github.com/Kunune/ts-react-without-cra

이 상태로도 build 후 정적 사이트 배포가 가능하다는 것을 보여주고 싶습니다.

React, Webpack 이미지는 file-loader를 통해서 가져온 것이고
검은 배경 이미지는 url-loader를 통한 base64 문자열로 가져온 이미지입니다.

F12로 확인을 해봅시다.

📌 Web 링크는 JS로 시작했던 것을 TS로 리팩터링하였기 때문에 JS + React 개발 환경 직접 만들기 결과물을 그대로 가져왔다.


배포

정적 배포 플랫폼 말고 직접 VPS 호스팅으로 VM 인스턴스를 얻어서 웹 서버로 사용한다면

두 가지 방법이 있습니다.

  1. 프록시 서버 설정을 SSL 여부에 따라 serve의 PORT를 80 혹은 443 PORT에 proxy_pass로 연결.
  2. build directory만 VM으로 옮기고 root, index 수정 (프록시 서버 메인 페이지를 build/index.html로 변경)
npm i serve -g

serve -s build

혹은

npx serve -s build

Code Splitting

바로가기


Tree Shaking

바로가기


SCSS to CSS

바로가기


Javascript + React

바로가기

profile
리액뚜

0개의 댓글