Micro Frontend 프로젝트 시작하기 (1) - pnpm workspace, Webpack + React + Typescript 프로젝트 생성

마리 Mari·2024년 11월 23일
post-thumbnail

배경

최근 Canvas와 WebGL에 대한 관심이 생겨 이를 공부하고 있다. 공부를 하다 보니 간단한 이미지 편집 기능을 만들게 되었고, 이 기능을 더 발전시켜 하나의 서비스로 만들면 어떨까 하는 생각이 들었다.
이미지 편집 기능을 확장하고, 서비스 개발도 함께 진행하며 두 가지 목표를 동시에 이룰 수 있을 것 같아 갑자기 신이 났다. 그래서 반나절도 안돼서 후다닥 기획서랑 유저플로우, 와이어프레임까지 작성했다..

막상 만들고 나니 '내가 다 만들어낼 수 있을까' 한편으로 불안해졌다. 오히려 두 가지 목표를 다 잃을 수도 있겠다는 생각이 들었다.

그래서 이 프로젝트를 나눠서 작업하기로 했다. 작은 단위로 개발해서 하나로 합치다보면 언젠가 완성할 수 있지 않을까? 마침 최근에 회사에서 MFA 개발 환경을 구축한 경험이 있겠다, 이를 활용해 프로젝트를 쪼개보기로 했다.

그런데 막상 다시 해보려고 하니 몇 달 전의 일임에도 기억이 가물가물했다. 그래서 이번에는 잊지 않기 위해 블로그에 기록을 남겨본다.





Pnpm workspace 환경 세팅

  • pnpm 세팅

    pnpm init
  • pnpm-workspace.yaml 생성

    // pnpm-workspace.yaml
    packages:
      - "packages/*"
  • packages 폴더 생성, packages 하위에 앱 폴더 만들기

    // 폴더 구조
    📦 project
    ├─ packages
    │  ├─ editor-app
    │  └─ service-app
    ├─ package.json
    └─ pnpm-workspace.yaml
    • service-app이 host app이고 editor-app이 remote app이다.




Webpack + React + Typescript 환경 세팅

Vite가 아닌 Webpack을 선택한 이유

처음에는 Vite를 사용해 작업을 시작했다.
Vite에서도 공식은 아니지만 Module Federation 기능을 지원하는 라이브러리가 있어 이를 활용하려고 했다.

그러나 적용 과정에서 문제가 발생했다.
editor-app에서 사용 중인 Zustand와 관련된 오류가 있었고, 이를 검색해보니 vite-federation이 아직 React 18의 useSyncExternalStore를 지원하지 않아 발생하는 에러라는 의견이 있었다. (참고 링크)
비공식 라이브러리인 만큼 안정성과 문제 해결의 한계가 있다고 판단해 Webpack으로 전환했다.

Vite를 유지하면서 Module Federation 대신 iframe과 message 통신을 사용하는 방법도 고려해봤다. 하지만 message 통신은 구조가 복잡하고 불확실성이 있어 Webpack이 더 적합하다고 결론지었다.

참고로 Create React App(CRA)를 선택하지 않은 이유는 명확하다. CRA는 공식 지원이 종료되었기 때문.
간편하긴 하지만 장기적으로 안정성을 보장하기 어렵다고 생각해 사용하지 않았다. 그리고 개인적으로 CRA 자체가 무겁고 불필요한 의존성을 포함하는 경우가 많아 개인적으로 선호하지 않는다.

Webpack 커스터마이징은 처음에는 다소 번거롭게 느껴질 수 있지만, 익숙해지면 충분히 간단하고 체계적으로 설정할 수 있다.


1. 환경 설정 + 패키지 설치

  • app 위치로 이동, pnpm init

    cd packages/service-app
    pnpm init
  • 패키지 설치

    pnpm install react react-dom 
    pnpm install --save-dev typescript @types/react @types/react-dom ts-loader webpack webpack-cli webpack-dev-server clean-webpack-plugin copy-webpack-plugin html-webpack-plugin babel-loader @babel/core @babel/preset-env @babel/preset-react @babel/preset-typescript

    엄청 많이 설치하는 것 같아 복잡해 보이지만, 분류해놓으면 심플하다

    • react 관련 패키지
      • react
      • react-dom
    • typescript 관련 패키지
      • typescript
      • @types/react
      • @types/react-dom
      • ts-loader
    • webpack 관련 패키지
      • webpack
      • webpack-cli
      • webpack-dev-server
      • clean-webpack-plugin
      • copy-webpack-plugin
      • html-webpack-plugin
    • babel 관련 패키지
      • babel-loader
      • @babel/core
      • @babel/preset-env
      • @babel/preset-react
      • @babel/preset-typescript

2. 기본 파일 작성

  • index.html 작성

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <link rel="icon" type="image/svg+xml" href="/icon.svg" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Service-app</title>
      </head>
      <body>
        <div id="root"></div>
      </body>
    </html>
  • src/index.tsx 작성

    import { StrictMode } from "react";
    import { createRoot } from "react-dom/client";
    import App from "./App";
    
    createRoot(document.getElementById("root")!).render(
      <StrictMode>
        <App />
      </StrictMode>
    );
  • App.tsx 작성

    import React from "react";
    
    const App: React.FC = () => {
      return (
        <div>
          <h1>Hello, world!</h1>
        </div>
      );
    };
    
    export default App;

3. Typescript 설정

  • typescript init

    tsc --init

    -> tsconfig.json 생성됨

    • 만약 tsc not found 오류가 발생하면 npm i -g typescript 후 다시 진행할 것
  • 프로젝트에 맞춰 tsconfig.json 수정
    각 옵션에 대한 설명은 tsconfig 주석에서 확인할 수 있고, 자세한 내용은 타입스크립트 문서에서 확인할 수 있다.

    • 수정된 tsconfig.json
    {
      "compilerOptions": {
        /* Environment */
        "target": "es2016",
        "lib": ["ESNext", "DOM"],
        "jsx": "react-jsx",
        "types": ["react"],
    
        /* Modules */
        "module": "commonjs",
        "moduleResolution": "Node",
        "isolatedModules": true,
        "esModuleInterop": true,
        "resolveJsonModule": true,
    
        /* Linting */
        "strict": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "allowSyntheticDefaultImports": true,
        "noFallthroughCasesInSwitch": true
      },
    }

4. babel 설정

  • babel.config.js 작성
    module.exports = {
      presets: [
        "@babel/preset-react",
        "@babel/preset-env",
        "@babel/preset-typescript",
      ],
    };

5. Webpack 설정

  • webpack.config.js 작성

    1. path 관련 설정
      {
        entry: "./src/index",
        output: {
          path: path.join(__dirname, "/dist"),
          filename: "[name].js",
          assetModuleFilename: "images/[hash][ext][query]",
        },
      }
    1. dev 환경 관련 설정

      const isEnvDevelopment = webpackEnv === "development";
      const isEnvProduction = webpackEnv === "production";
      
      {
        mode: isEnvProduction ? "production" : "development,
        devtool: isEnvDevelopment ? "hidden-source-map" : "eval",
        devServer: {
          port: 3300,
          hot: true,
        },
      }
    2. 파일 관련 설정

      {
        resolve: {
          extensions: [".js", ".jsx", ".ts", ".tsx"],
        },
        module: {
          rules: [
            {
              test: /\.tsx?$/,
              use: ["babel-loader", "ts-loader"],
            },
            {
              test: /\.(png|jpg|svg)$/,
              type: "asset/resource",
            },
          ],
        },
      }
      • resolve.extensions: 파일 확장자 작성하지 않아도 import 가능
      • module.rulse: 특정 파일 유형에 대해 어떤 로더를 사용할지 지정
        • 타입스크립트 파일 처리: babel-loader는 최신 자바스크립트 문법 변환, ts-loader는 타입스크립트를 자바스크립트로 변환
        • 이미지 파일 처리: 내장된 asset/resource 모듈 사용. 기존에는 file-loader, url-loader를 사용했으나 webpack5 부터 asset module이 대체
    3. 플러그인 관련 설정

      {
        plugins: [
          new webpack.ProvidePlugin({
            React: "react",
          }),
          new HtmlWebpackPlugin({
            template: "./index.html",
            minify: isEnvProduction
              ? {
                  collapseWhitespace: true,
                  removeComments: true,
                }
              : false,
          }),
          new CopyWebpackPlugin({
            patterns: [{ from: "public" }],
          }),
          new CleanWebpackPlugin(),
        ];
      }
      • ProvidePlugin: 자주 사용하는 모듈이나 변수를 매번 import하지 않아도 자동으로 가져올 수 있도록 설정하는 플러그인. React 자동 import를 위해 추가.
      • HTMLWebpackPlugin: 번들링된 JavaScript 파일을 자동으로 <script\> 태그에 삽입해주는 HTML 파일을 생성. template에 index.html 파일 경로 작성, minify는 production 시에만 되도록 작성
      • CopyWebpackPlugin: 정적 파일(예: 이미지, 폰트 등)을 빌드 디렉토리로 복사해서 번들링 결과에 포함시켜주는 플러그인. public 폴더 내의 정적 파일을 같이 번들링하기 위해 추가
      • CleanWebpackPlugin: 빌드 전에 기존의 번들 파일을 제거해주는 플러그인

전체 webpack.config.js

const HtmlWebpackPlugin = require("html-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");

const path = require("path");
const webpack = require("webpack");

module.exports = (webpackEnv) => {
  const isEnvDevelopment = webpackEnv === "development";
  const isEnvProduction = webpackEnv === "production";
  const mode = isEnvProduction
    ? "production"
    : isEnvDevelopment && "development";

  const pathConfig = {
    entry: "./src/index",
    output: {
      path: path.join(__dirname, "/dist"),
      filename: "[name].js",
      assetModuleFilename: "images/[hash][ext][query]",
    },
  };

  const devConfig = {
    devtool: isEnvDevelopment ? "hidden-source-map" : "eval",
    devServer: {
      port: 3300,
      hot: true,
    },
  };

  const resolve = {
    extensions: [".js", ".jsx", ".ts", ".tsx"],
    alias: {},
  };

  const module = {
    rules: [
      {
        test: /\.tsx?$/,
        use: ["babel-loader", "ts-loader"],
      },
      {
        test: /\.(png|jpg|svg)$/,
        type: "asset/resource",
      },
    ],
  };

  const plugins = [
    new webpack.ProvidePlugin({
      React: "react",
    }),
    new HtmlWebpackPlugin({
      template: "./index.html",
      minify: isEnvProduction
        ? {
            collapseWhitespace: true, // 빈칸 제거
            removeComments: true, // 주석 제거
          }
        : false,
    }),
    new CopyWebpackPlugin({
      patterns: [{ from: "public" }],
    }),
    new CleanWebpackPlugin(),
  ];

  return {
    ...pathConfig,
    ...devConfig,
    mode,
    resolve,
    module,
    plugins,
  };
};

6. 실행 및 빌드 확인하기

  • package.json에 프로젝트 실행 & 빌드 script 작성
"scripts": {
  "dev": "webpack-dev-server --mode=development --open --progress",
  "build": "webpack --mode=production  --progress"
}
  • pnpm run dev 확인하기

  • pnpm run build 확인하기

  • 끝.




추가 설정 및 문제 해결

문제 1) 이미지 파일 import 시 typescript 오류

이미지 파일 import시 아래와 같은 오류가 발생한다.

Cannot find module 'assets/Logo.svg' or its corresponding type declarations.ts(2307)

이는 TypeScript가 이미지 파일(.svg, .png 등)을 모듈로 인식하지 못해 발생하는 문제다.
이를 해결하려면 프로젝트에 이미지 파일 타입을 정의해야 한다.

간단하게 react-app-env.d.ts 파일에 이미지 파일 타입을 정의하여 해결할 수 있다.

declare module "*.png";
declare module "*.svg";
declare module "*.jpeg";
declare module "*.jpg";

만약 다양한 케이스를 고려하거나 구체적인 타입 정의가 필요하다면 아래와 같이 작성해볼 수 있다.
CRA 프로젝트의 react-app-env.d.ts를 참고하여 작성하였다.

참고
/// <reference types="node" />
/// <reference types="react" />
/// <reference types="react-dom" />

declare namespace NodeJS {
  interface ProcessEnv {
    readonly NODE_ENV: "development" | "production" | "test";
    readonly PUBLIC_URL: string;
  }
}

declare module "*.avif" {
  const src: string;
  export default src;
}

declare module "*.bmp" {
  const src: string;
  export default src;
}

declare module "*.gif" {
  const src: string;
  export default src;
}

declare module "*.jpg" {
  const src: string;
  export default src;
}

declare module "*.jpeg" {
  const src: string;
  export default src;
}

declare module "*.png" {
  const src: string;
  export default src;
}

declare module "*.webp" {
  const src: string;
  export default src;
}

declare module "*.svg" {
  import * as React from "react";

  export const ReactComponent: React.FunctionComponent<
    React.SVGProps<SVGSVGElement> & { title?: string }
  >;

  const src: string;
  export default src;
}

declare module "*.module.css" {
  const classes: { readonly [key: string]: string };
  export default classes;
}

declare module "*.module.scs" {
  const classes: { readonly [key: string]: string };
  export default classes;
}

declare module "*.module.sass" {
  const classes: { readonly [key: string]: string };
  export default classes;
}

다양한 파일 형식에 대한 정의뿐만 아니라, svg파일을 React Component로 사용할 수 있는 설정도 포함하고 있다.


문제 2) css, tailwind.css 사용을 위한 설정

css 파일과 관련된 설정이 없어, css 파일을 읽을 수 없다.
css 파일 처리를 위한 loader를 추가하면 간단하게 해결된다.

  • css-loader, style-loader 설치
pnpm install --save-dev css-loader style-loader
  • module.rules에 css 파일에 대한 처리 규칙 추가
// webpack.config.js
  module: {
  	rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader"],
      },
    ],
  },

나의 프로젝트의 경우 tailwind.css를 사용하여 postcss에 대한 처리도 필요하였다.
postcss-loader만 추가하면 해결 가능하다.

pnpm install --save-dev postcss-loader
// webpack.config.js
  module: {
  	rules: [
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader", "postcss-loader"],
      },
    ],
  },

문제 3) react-router-dom 사용을 위한 설정

react-router-dom을 사용할 경우 기본 경로나 중첩 경로에서 제대로 동작하지 않는 문제가 발생할 수 있다. 이를 해결하기 위해 추가적인 설정이 필요하다.

기본 경로 문제 해결

React Router와 Webpack-dev-server의 작동 방식이 다르기 때문에, 기본적인 경로(/somepath)에서 제대로 작동하지 않는 문제가 있다. 이를 해결하려면 Webpack Dev Server 설정에 historyApiFallback을 true로 추가해야 한다.

// webpack.config.js
  devServer: {
    historyApiFallback: true,
  },

React-router는 history API를 이용해 경로를 관리한다. 특정 경로에 방문하면 해당 경로의 리소스를 요청하는 대신 '/' 경로로 되돌아가 필요한 리소스를 받고 React-router가 경로에 대한 처리를 진행한다.
하지만 webpack에서는 기본적으로 historyAPIFallback 기능이 비활성화되어 있으므로, 이 설정을 추가해야 정상적으로 동작한다.

중첩 경로 문제 해결

번들된 JavaScript 파일의 경로가 올바르게 설정되지 않아 중첩 경로(/somepath/morepath)에서 제대로 작동하지 않을 수 있다. 이를 해결하려면 Webpack의 output 설정에 publicPath를 "/"로 지정해야 한다.

// webpack.config.js
  output: {
    publicPath: "/",
  },

webpack이 기본적으로 생성하는 HTML 파일의 <script> 태그는 아래와 같다

 <script type="text/javascript" src="main.js"></script>

여기서 src가 "main.js"로 되어있기 때문에 현재 경로를 기준으로 파일을 찾게 된다.
만약 현재 경로가 "/somepath"이면 "/somepath/main.js" 파일을 찾게 되는데, 실제 파일은 /main.js에 위치하므로 파일을 찾지 못한다.
이 때 publicPath를 루트 경로("/")로 설정하면, 항상 "/main.js"에서 파일을 로드하도록 할 수 있다.


최종 Webpack.config.js

const HtmlWebpackPlugin = require("html-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const CopyWebpackPlugin = require("copy-webpack-plugin");

const path = require("path");
const webpack = require("webpack");

module.exports = (webpackEnv) => {
  const isEnvDevelopment = webpackEnv === "development";
  const isEnvProduction = webpackEnv === "production";
  const mode = isEnvProduction
    ? "production"
    : isEnvDevelopment && "development";

  const pathConfig = {
    entry: "./src/index",
    output: {
      path: path.join(__dirname, "/dist"),
      filename: "[name].js",
      assetModuleFilename: "images/[hash][ext][query]",
      publicPath: "/",
    },
  };

  const devConfig = {
    devtool: isEnvDevelopment ? "hidden-source-map" : "eval",
    devServer: {
      host: "localhost",
      port: 3300,
      historyApiFallback: true,
    },
  };

  const resolve = {
    extensions: [".js", ".jsx", ".ts", ".tsx"],
    alias: {},
  };

  const module = {
    rules: [
      {
        test: /\.tsx?$/,
        use: ["babel-loader", "ts-loader"],
      },
      {
        test: /\.(png|jpg|svg)$/,
        type: "asset/resource",
      },
      {
        test: /\.css$/i,
        use: ["style-loader", "css-loader", "postcss-loader"],
      },
    ],
  };

  const plugins = [
    new webpack.ProvidePlugin({
      React: "react",
    }),
    new HtmlWebpackPlugin({
      template: "./index.html",
      minify: isEnvProduction
        ? {
            collapseWhitespace: true, // 빈칸 제거
            removeComments: true, // 주석 제거
          }
        : false,
    }),
    new CopyWebpackPlugin({
      patterns: [{ from: "public" }],
    }),
    new CleanWebpackPlugin(),
  ];

  return {
    ...pathConfig,
    ...devConfig,
    mode,
    resolve,
    module,
    plugins,
  };
};

내용이 너무 길어져서 Webpack Module Federation에 관한 내용은 다음 글에...





참고 자료

profile
우리 블로그 정상영업합니다.

4개의 댓글

comment-user-thumbnail
2025년 2월 16일

마리님 안녕하세요 구글에 pnpm create mf-app webpack 쳤는데 마리님 블로그 나왔어요 아 저는 연예인 류준열이라고 합니다 반가워서 댓글남깁니다 ㅎㅎ

2개의 답글