클린 아키텍쳐 이슈 트래커 (3) 웹팩

2024. 3. 18. 22:02카테고리 없음

dev mode 에선 어떤 것이 중요할까?

웹팩의 dev mode에 대해 먼저 말해보고자 한다. 어떤 점을 가장 중요시 해야할까? 바로 빌드 시간일것이다. production mode는 번들 사이즈의 최소화가 가장 중요한 점이지만, dev mode에선 번들 사이즈를 최소화하기 위해 여러 로더를 넣어 빌드 시간을 증가시키는 일을 줄여야 한다.

 

다음과 같이 dev mode를 설정했다.

 

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const Dotenv = require('dotenv-webpack');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');

module.exports = {
  mode: 'development',
  entry: path.resolve(__dirname, 'src/index.tsx'),
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
  },
  devServer: {
    port: 3000,
    historyApiFallback: true,
  },
  devtool: 'eval-cheap-module-source-map',
  module: {
    rules: [
      {
        test: /\.(png|jpg|svg)$/,
        type: 'asset',
      },
      {
        test: /\.css$/,
        use: ['style-loader', 'css-loader', 'postcss-loader'],
      },
      {
        test: /\.(jsx?|tsx?)$/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: [
                [
                  '@babel/preset-env',
                  {
                    targets: 'defaults',
                  },
                ],
                [
                  '@babel/preset-react',
                  {
                    runtime: 'automatic',
                  },
                ],
                '@babel/preset-typescript',
              ],
              plugins: [
                'react-refresh/babel',
                'babel-plugin-transform-typescript-metadata',
                ['@babel/plugin-proposal-decorators', { legacy: true }],
              ],
            },
          },
        ],
        include: path.resolve(__dirname, 'src'),
        exclude: path.resolve(__dirname, 'node_modules'),
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'index.html'),
    }),
    new Dotenv({
      path: path.resolve(__dirname, '.env'),
      prefix: 'import.meta.env.',
    }),
    new ReactRefreshWebpackPlugin(),
  ],
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
};

 

 

소스맵

소스맵 옵션부터 살펴보자. 소스맵은 번들된 파일과 내가 작성한 코드를 연결해주는 역할을 한다. 소스맵을 따로 설정하지 않으면 번들된(트랜스파일된) 최종 결과물을 기준으로 코드를 보여주기 때문에 내가 작성한 어떤 코드의 결과물인지 알기 어렵다. 

 

웹팩에선 소스맵에 대한 다양한 옵션을 제공한다. 웹팩 devtool 링크

해당 링크에서 dev mode에 추천하는 옵션은 다음 네가지이다.

 

1) eval

2) eval-source-map

3) eval-cheap-source-map

4) eval-cheap-module-source-map

 

여기서 1) eval과 3) eval-cheap-source-map은 각각 generated(번들 결과물)와 transformed(바벨 등으로 트랜스파일) 형태로 코드를 연결해준다. 빌드시간은 2) eval-source-map, 4) eval-cheap-module-source-map 보다 빠른 것으로 문서에 나와있지만, 내가 작성한 original 코드와 직접적으로 연결해주지 않아 사용하지 않았다.

 

그럼 2번과 4번 옵션이 남아있는데, 2번 옵션은 4번 옵션보다 느리다. 가장 고품질의 소스맵을 제공하지만, 빌드 시간이 가장 느린 옵션이다. 나는 내가 작성한 코드의 행 정보만 있어도 디버깅에 충분하다고 판단을 했고 그 중 더 성능이 좋은 4) eval-cheap-module-source-map 옵션을 사용했다. 

 

바벨

babel을 설정하면서 이 프로젝트를 통해 어떻게 CRA같은 툴에서만 jsx를 사용할 수 있는지 알 수 있었다.

 

@babel/preset-env는 es6이상의 최신 자바스크립트 syntax를 지정한 브라우저에 맞게 트랜스 파일해주는 역할을 한다.

 

@babel/preset-react는 리액트 환경을 위한 다양한 플러그인을 모아놓았다. 링크

여기서 주목할 점은 runtime: automatic 옵션을 주면 과거 모든 파일에서 사용하던 import React from 'react'를 사용하지 않아도 된다.

 

@babel/preset-typescript는 babel과 타입스크립트의 조합을 위해 사용한다. 많은 블로그 글에서 ts-loader와 @babel/preset-typescript를 동시에 설정해놓는걸 많이 봤는데, 공식 문서를 확인 해보니 둘을 함께 사용할 필요는 없었다. ts-loader와 바벨 중 어떤 걸로 타입스크립트 환경을 할까 고민하다 일단은 빌드 속도에서 이점이 있는 바벨을 택했다. 

 

중간에 Inversify를 도입하면서 문제가 발생했는데, 도저히 Inversify가 동작하지 않았다. gpt도 프론트엔드에서 IoC를 사용하는 것에 대한 데이터는 부족한지 문제 해결의 실마리를 제공해주지 못하던 중, 명불허전 stackoverflow에서 문제에 대한 해답을 얻을 수 있었다.

 

문제의 원인은 @babel/preset-typescript 였는데, 내부 동작 원리가 타입스크립트를 모두 제거하고 메타데이터를 번들에 포함시키지 않았기 때문에 inversify가 동작할 수 없었다.

 

또, 데코레이터가 아직 표준이 아니다보니 스펙이 변했는지 @babel/plugin-proposal-decorators를 삽입해도 동작하지 않았는데, 다행히 stackoverflow의 답변처럼 legacy 옵션을 true로 설정하니 잘 작동했다. 

HMR, Hot Reloading

맨 처음엔 매번 다시 빌드할 필요 없이 코드를 수정하여 저장하기만 하면 dev server에 반영해주는 HMR을 사용해 개발했었다. 하지만 context menu (드롭다운 메뉴)를 개발하던 중 굉장히 불편함을 느끼게 됐다. 바로 HMR은 변경된 모듈만 전체 페이지 새로고침 없이 반영해주는 역할을 하는데, 중요한 점은 리액트 컴포넌트의 상태는 유지해주지 못한다는 점이었다. 

 

이러한 불편함을 해소해주는 react-hot-loader를 사용하기 위해 react-hot-loader의 README를 읽으니 React Fast Refresh를 사용할 것을 권장하고 있었다. 따라서 React Fast Refresh를 적용하고 이제 React 컴포넌트의 상태를 유지한 채로 코드를 수정할 수 있었다.

 

 

그럼 Production mode 에선 어떤 것이 중요할까?

production mode에서는 빌드 타임보다는 번들 크기의 최소화가 좀 더 주된 관심사다.

 

다음과 같이 설정했다.

 

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyPlugin = require('copy-webpack-plugin');
const Dotenv = require('dotenv-webpack');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');

module.exports = {
  mode: 'production',
  entry: path.resolve(__dirname, 'src/index.tsx'),
  output: {
    filename: '[name].bundle.js',
    path: path.resolve(__dirname, 'dist'),
    clean: true,
  },
  module: {
    rules: [
      {
        test: /\.(png|jpg|svg)$/,
        type: 'asset',
      },
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
      },
      {
        test: /\.(jsx?|tsx?)$/,
        use: [
          {
            loader: 'babel-loader',
            options: {
              presets: [
                [
                  '@babel/preset-env',
                  {
                    targets: 'defaults',
                  },
                ],
                [
                  '@babel/preset-react',
                  {
                    runtime: 'automatic',
                  },
                ],
                '@babel/preset-typescript',
              ],
              plugins: [
                'babel-plugin-transform-typescript-metadata',
                ['@babel/plugin-proposal-decorators', { legacy: true }],
              ],
            },
          },
        ],
        include: path.resolve(__dirname, 'src'),
        exclude: path.resolve(__dirname, 'node_modules'),
      },
    ],
  },
  plugins: [
    new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'index.html'),
    }),
    new MiniCssExtractPlugin({
      filename: '[name].css',
    }),
    new Dotenv({
      path: path.resolve(__dirname, '.env'),
      prefix: 'import.meta.env.',
    }),
    new BundleAnalyzerPlugin(),
  ],
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
  optimization: {
    splitChunks: {
      chunks: 'all',
    },
    minimize: true,
    minimizer: [
      `...`,
      new CssMinimizerPlugin({
        minimizerOptions: {
          preset: [
            'default',
            {
              discardComments: { removeAll: true },
            },
          ],
        },
      }),
    ],
  },
};

 

Asset 

 

png, jpg, svg 등의 정적 자원은 asset 옵션을 사용했다. asset을 관리하는 옵션은 다음과 같이 네가지가 있다.

 

1) asset/resource

2) asset/inline

3) asset

4) asset/source

asset/resource 옵션은 output 디렉터리에 정적 자원 파일을 저장하는 옵션이다. 큰 이미지나 큰 글꼴 파일에 적합하다.

asset/inline 옵션은 직접 asset을 번들에 포함시킨다 (base 64 형태로 바꾸어 직접 js 파일에 inject 한다). svg 파일과 같은 작은 asset을 사용할 때 적합하다.

asset 옵션은 asset/resource와 asset/inline을 적절히 조합한 옵션이다. 8kb보다 파일이 작으면 inline, 크면 resource 옵션을 적용하게 된다. 물론 꼭 8kb는 아니어도 되고, 개발자가 수정가능하다.

asset/resource 옵션은 자바스크립트 문자열 형태로 plaintext 데이터를 import할 경우에 사용할 수 있다곤 하는데, 구체적인 예시는 사실 잘 모른다. 

 

내 asset은 거의 모두 svg 였기 때문에 inline을 사용할까 고민하다, 몇몇 svg 파일이 크기가 굉장히 큰 것을 발견하여 asset 옵션을 줘서 큰 파일들은 번들에 포함시키지 않았다. 

 

 

MiniCssExtractPlugin

dev mode에서 사용한 style-loader는 CSS를 <style> 태그로 동적으로 추가한다. 즉 웹팩 번들에 포함된 CSS가 런타임에 DOM 내에 스타일 태그로 삽입된다. 

 

MiniCssExtractPlugin.loader는 CSS를 별도의 파일로 추출하여 <link> 태그로 참조하는 데 사용된다. 이렇게 하면 CSS를 별도의 파일로 추출하기 때문에, 병렬 로딩이 가능하다. 따라서 로딩 시간이 감소하고, Layout Shift를 방지할 수 있다. 

 

CssMinimizerPlugin

웹팩의 production mode에서는 terser가 기본적으로 내장되어있기 때문에 js에 대해서는 따로 최적화를 설정해주지 않아도 된다. 하지만 MiniCssExtractPlugin를 사용하게 되면, CSS 파일이 따로 추출되고 CSS에 대해서는 기본 CSS 최적화 설정이 없어서 CssMinimizerPlugin을 사용해 CSS 파일에 대한 최적화를 해주었다.

 

Code splitting

웹팩에서 디폴트로 설정해놓은 번들 파일보다 크기가 크다는 경고가 발생했다. 초기 로딩 속도를 향상 시키기 위해, 리액트의 lazy를 사용하여 라우트별 코드 스플리팅을 해주었다.

 

import { lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

import AppLayout from './common-ui/app-layout';
import ProtectedRoute from './common-ui/protected-route';

const Issues = lazy(
  () => import(/* webpackChunkName: "issues" */ './pages/Issues')
);
const NewIssue = lazy(
  () => import(/* webpackChunkName: "new-issue" */ './pages/new-issues')
);
const Issue = lazy(
  () => import(/* webpackChunkName: "issue" */ './pages/issue')
);
const Login = lazy(
  () => import(/* webpackChunkName: "login" */ './pages/login')
);
const Signup = lazy(
  () => import(/* webpackChunkName: "signup" */ './pages/signup')
);
const Labels = lazy(
  () => import(/* webpackChunkName: "labels" */ './pages/labels')
);

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <BrowserRouter>
        <Routes>
          <Route
            element={
              <ProtectedRoute>
                <AppLayout />
              </ProtectedRoute>
            }
          >
            <Route
              index
              element={<Navigate replace to="/issues?isOpen=open" />}
            />
            <Route path="/issues" element={<Issues />} />
            <Route path="/issues/:id" element={<Issue />} />
            <Route path="/new-issue" element={<NewIssue />} />
            <Route path="/labels" element={<Labels />} />
          </Route>

          <Route path="/login" element={<Login />} />
          <Route path="/new-user" element={<Signup />} />
        </Routes>
      </BrowserRouter>
    </QueryClientProvider>
  );
}
export default App;

 

참고로 lazy를 사용하게 되면 반드시 suspense를 사용해야 한다. 다만 나같은 경우 전체 페이지에 로딩이 표시되는게 싫어 AppLayout에 suspense를 두었다.또한 webpackChunkname 주석은 코드 스플링된 번들이 각각 어떤 번들인지 파악하기 어려워 사용했다.

 

code splitting을 하게 되면, 각 번들마다 공통된 종속성을 가질 수 있다. 이를 추출해내기 위해 splitChunks 옵션을 주었다.