본문 바로가기

Front-End

모노레포를 통한 개발자 경험 향상하기 ( with Pnpm Workspace )

제가 근무했던 회사의 웹 사이트는 기본적으로 SPA 로 기획된 사이트가 아닙니다. SEO 가 매우 중요하고, 각 주소 별로 별개의 페이지를 가집니다. 따라서 리액트 페이지 안에서 라우팅 되지도 않습니다. 한 페이지당 하나의 리액트 어플리케이션이 실행됩니다.

그래서 처음 제가 입사했을 당시에는 모든 프로젝트가 개별의 레포지토리로 나눠져 있었습니다. 물론 공통적으로 사용되는 로직들도 각각 흩어져 있었습니다.

시간이 갈수록 이슈를 해결하고 기능을 추가 하면서 모든 페이지에서 사용하는 공용 훅 이나 함수들은 페이지에 별로 다른 기능을 가지게 되기 시작했습니다. 나중에는 같은 함수라고 볼수가 없을정도로 많은 수정이 생기게 되었죠. 같은 UI 를 가진 컴포넌트도 안에있는 코드가 완전히 달라져서 도저히 관리를 할수가 없는 지경에 다다르게 되었습니다.

처음부터 잘못된 구조로 설계되고 개발된 프로젝트로 서비스까지 하게된 상황에, 이슈는 계속 쏟아지고 프로덕트에는 추가 기능이 계속 들어서게 되면서, 유지 보수는 거의 불가능한 상태로 그저 당장에 이슈를 해결하는게 급한 채로 몇개월동안 개발을 하게 되었습니다. 정말 힘든 시간이었습니다.. 😢

특히 유지보수 하면서 가장 간절했던 것은 공용 모듈 이었습니다. 한번의 코드수정으로 모든 페이지에 공통으로 적용되는 로직을 사용하고 싶었습니다.

그러던 중, 관심있던 모노레포 구조가 현재 구조에 가장 적합하다는 것을 깨닫게 되었습니다. 각 페이지별로 별개의 프로젝트를 사용하면서 공용 모듈을 사용할수 있게 되는 것이었습니다.

그중에서도 PNPM 을 선택하게 된것은, yarn berry 나 3버전등을 기존에 사용해 본적이 있는데 상당히 난해 하기도 하고, 도입하는데 적지 않은 이슈가 생긴다는점을 느꼈습니다. 특히 사용해 본적이 없는 분과 협업 시에 문제가 됐었다는점이 컸습니다. 반면에 PNPM 은 기존 npm 이나 yarn 1 버전과 사용법이 거의 비슷하면서, 워크스페이스를 통한 모노레포 구축이 쉬워서 손이 덜간다는 장점이 있었습니다. 하드링크를 통한 빌드나 패키지 다운로드 속도도 크게 체감 되었었습니다.

먼저 각각 흩어져 있던 레포지토리는 하나의 레포지토리로 결합하는 과정을 거쳤습니다.

그 다음은, 기존의 yarn-lock.json 파일등을 정리하고 pnpm 워크스페이스 설정을 하기 위한 세팅을 합니다. 레포지토리의 최상위 root 에 packages.json 을 다음과 같이 설정 했습니다.

packages.json

{
  "name": "@yoon0cean/root",
  "private": true,
  "scripts": {
    ...
  },
  "workspaces": [
    "packages/*"
  ],
  "engines": {
    "node": ">=16.14.2",
    "pnpm": ">=7.9.0"
  },
  "packageManager": "pnpm@7.9.0",
  "devDependencies": {
    "eslint-plugin-prettier": "^4.2.1",
    "pnpm": "^7.9.0"
  },
  ...
}

이중 workspaces 필드는, root 아래의 ./packages 폴더가 pnpm workspaces 로 쓰임을 의미합니다. 추가로 pnpm-workspace.yaml 파일을 작성했습니다.

pnpm-workspace.yaml 

packages:
  - "packages/*"

이렇게 설정해두면 이제부터 packages 폴더와 그 하위 폴더는 pnpm workspace 로 쓰입니다.

이 packages 폴더 아래에 모든 프로젝트를 옮겨 넣었습니다.

다음으로 각 프로젝트의 packages.json 에 각 프로젝트를 workspace 에서 관리할수 있도록 name 을 변경해 줬습니다. 또한 공용 모듈로 사용할 프로젝트를 import 해와서 사용할수 있도록 dependencies 에 등록 해줬습니다.

packages.json

{
  "name": "@yoon0cean/m-index",
  ...
  "dependencies": {
    "@yoon0cean/common": "workspace:^0.0.0",
    ...
  }
}

이렇게 각 프로젝트를 설정하고, 공용 모듈과 공용 컴포넌트등이 들어갈 common 폴더를 세팅했습니다.

packages.json

{
  "name": "@yoon0cean/common",
  "private": true,
  "version": "0.0.0",
  "main": "dist/index.js",
  "module": "dist/index.js",
  "typings": "dist/index.d.ts",
  "scripts": {
    "postinstall": "pnpm run build",
    "build": "tsc -p .",
    "build:watch": "tsc -p . --watch"
  },
  ...
}
{
  "compilerOptions": {
    "outDir": "dist",
    "declaration": true,
    "target": "esnext",
    "module": "commonjs",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "jsx": "react-jsx",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "resolveJsonModule": true,
    "isolatedModules": true
  },
  "include": ["src"],
  "exclude": ["./dist"]
}

이후 common 폴더 아래에 다양한 공용 모듈등을 옮겨 넣었습니다. 아래는 production 에서 콘솔로그가 출력 되지 않도록 제가 직접 만든 util 모듈인 localConsole 이라는 모듈입니다.

packages/common/src/utils/localConosle.ts

class LocalConsoleInstance {
  public domain: Array<string> = [];

  public console;

  constructor() {
    if (process.env.NODE_ENV !== "production") {
      this.console = console;
    }

    if (process.env.NODE_ENV === "production") {
      delete this.console;
    }

    return this;
  }
}

const localConsole = new LocalConsoleInstance()?.console;

export default localConsole;

이런식으로 옮겨진 타입스크립트 코드들은, tsconfig.json 의 컴파일 범위가 tsconfig 파일이 위치한 root 의 상위로 넘어가지 못하므로 자바스크립트로 트랜스파일링 해서 export 해야 합니다. 빌드 명령어를 통해 dist 폴더로 타입스크립트 파일들을 트랜스파일링 해줍니다.

위에서 packages.json 에 module 로 선언한 dist/index.js 파일에서 모두 export 해서 써도 되지만, 그렇게 되면 모듈을 불러올때 모든 코드를 import 하게 되기 때문에 저는 트리쉐이킹을 적용하기로 했습니다.

이렇게 하면, 각 프로젝트가 packages.json 에서 @yoon0cean/common 을 모듈로 불러오기 때문에 common 내의 함수등을 불러올수 있게 됩니다.

import localConsole from "@yoon0cean/common/dist/utils/localConsole";

마지막으로, 최상위 root 에서 각 프로젝트를 빌드하거나 실행할수 있도록 packages.json 에 실행 명령어를 추가해준후 pnpm install 을 통해 각 설정과 node_modules 패키지 들을 설치하고 사용할수 있게 해줬습니다.

이렇게 Done 메시지가 뜨면 모노레포 구축이 완료 됩니다.

이런식으로 최소한의 마이그레이션을 통해, 개발 구현 요구사항을 만족하며 개발 편의성과 유지 보수성 을 모두 향상 시킬수 있었습니다 ! 개인적으로는 했던 작업중 가장 의미있는 작업이었습니다. 스스로 개발자 경험을 향상시킬수 있던 작업 이었으니까요 🙂

이 구조에는 한계점이 있는데 바로 공용 컴포넌트 내에서 Root Provider 를 가진 상태관리 라이브러리들 ( recoil, react-query ) 등을 사용하게되면, 이미 빌드되어 넘어온 모듈 이라서 같은 Context 를 사용하지 못하기 때문에 props 를 통해 주입하거나 context API 에 state 를 주입하는 추가적인 작업들이 필요하다는 문제가 있었습니다. 이 문제를 해결하지 못한 채 퇴사하게 되어 아쉽고, 개인적으로 이 문제를 해결해 보는 시간을 가져볼 생각입니다.