웹과 크롬 확장 프로그램을 통해서 할일을 쉽게 관리하는 생산성 툴을 개발하려 합니다.
두 환경 모두 비슷한 UI를 띄게 될 프로덕트인데, 이를 멀티레포로 개발하려니 이거야 말로 모노레포로 관리해야 하지 않을까? 라는 생각이 들어, 개발기를 정리합니다.
모노레포에서 기대하는 개발 경험
- 공용 컴포넌트 기준으로, 크롬 익스텐션과 웹 사이트 모두 활용 ✅
- 기본적인 개발 및 빌드, 배포까지 파이프라인 구성
- CI / CD 과정에서 크롬 및 웹의 각 화면의 주요 테스트 케이스를 통과할 수 있게
- Chrome 익스텐션과 Web의 상호작용 케이스 또한 E2E 테스트로 검사
- Eslint, Prettier, Husky 를 하나의 설정으로 관리 ✅
결론적으로, 중복된 설정은 줄이고, 개발, 테스팅 배포까지 하나의 레포지토리에서 수행하자!
라는 목표를 갖고 프로젝트를 세팅하기 시작했습니다. 이번 글에서 다룰 내용은, 1번과 5번입니다.
패키지 매니저 정하기
가장 먼저 떠오른 패키지 매니저는
pnpm
이었습니다. lerna
같은 모노레포 관리도구는, 또 다른 공부를 시작하기엔 부담스럽다는 생각이 들어, pnpm
workspace를 적극적으로 활용하면 좋지 않을까? 라는 생각이 들었습니다. 다른 패키지 매니저인 yarn
또한 사용 사례를 많이 찾아보았고, 도입 사례가 많이 갈리는 것도 확인했습니다. 따라서, 이런 케이스들을 분석해서, 저희 팀에 맞는 패키지 매니저를 선별하려고 했습니다. 
가장 먼저, 사용률부터 참고해봤습니다.
pnpm
, npm
, yarn
순으로 사용되고 있음을 확인할 수 있었습니다.실제 도입에 많은 레퍼런스를 찾을 수 있고, Ghost Dependency 현상을 겪지 않을 패키지 매니저는
pnpm
과 yarn berry
였기 때문에 이를 중점으로 비교하였습니다.yarn berry
의 경우, 다음과 대표적으로 강점을 갖고 있습니다.- Ghost Dependency : 패키지 크기 최적화를 위해
Hoisting
할 때, 사용자가 모르는 의존성의 패키지가 마치 설치된 것 처럼 작동 →yarn berry
나pnpm
에서 활용하는 패키지 Link 방식으로 해결가능합니다.
- Zero Install : 로컬 프로젝트의 패키지 캐싱 크기와 속도가 빠른 yarn의 경우, 이 캐싱을 버전 관리에 포함시킬 수 있습니다. 이렇게 된다면 버전관리 툴에 따른 모든 환경은 패키지를 설치할 필요가 없어집니다.
빠르게 템플릿을 만들어 보기 위해서 크롬 익스텐션 패키지를 간단하게 설치 한 뒤, 개발 서버를 돌려보려 했지만, 다음과 같은 에러가 발생했습니다.

의존성을 찾을 수 없다는 에러를 검색해보니,
yarn berry
사용자 들이 흔하게 겪는 에러임을 알게되었습니다. 패키지 사용 시 의존성이 명확하게 설치되어 있지 않다면, 사용자가 직접 의존성 문제를 해결해야 합니다.nodeLinker: pnp enableGlobalCache: false packageExtensions: "@crxjs/vite-plugin@*": dependencies: "vite": "^6.3.5"
해결방안은, 위와 같이 yarn 설정 파일에서 수동으로 의존성을 설정해주면 됩니다. 이는
PnP
모드를 사용하게 되면 자주 겪는 문제입니다. yarn berry
가 경량화 된 패키지를 설치할 때, 패키지에 명시된 의존성에 의해서만 설치하기 때문에 해당 문제가 발생합니다.반면,
pnpm
의 경우, 비슷한 문제는 나타나지 않았습니다. 원초적으로 글로벌 node_modules
에 설치하고, 이에 대해 하드링킹하는 방식이기 때문에, 이러한 이슈는 생겨날 수 없습니다. 따라서, 모노레포 내 패키지를 매끄럽게 설치할 수 있었습니다.패키지 설치 벤치마크 상 결과로는 Yarn PnP 모드가 평균적으로 37% 정도 빨랐습니다. 하지만, 배포 환경이라든지, 실제 use case 상 패키지 설치가 빈번하지 않기 때문에, 해당 지표를 통해 패키지 매니저를 결정하기는 좋지 않은 선택인 것 같다고 느꼈습니다. 따라서, 개발 경험 과 기능 에 따른 결정을 내리는 것이 가장 좋다고 생각했습니다.
yarn berry
에서만 제공하는 기능은 Zero Install 이라는 기능입니다. PnP 모드를 사용하기 때문에 Zip 파일로 의존성을 설치하여, 낮은 용량을 가진다는 특징을 이용하여, 패키지 설치 Cache를 유용하게 사용할 수 있습니다. 단적으로, .gitignore
에 node_modules
를 제외하는 관행과 다르게, 패키지 설치를 원격 레포지토리에 올려, 팀원들이 패키지를 설치하는 번거로움을 줄일 수 있습니다. ( 배포 환경이라면 배포 시간을 줄일 수 있을 겁니다. )하지만, 아직까지 필요한 기능은 아니라고 생각하고, PnP 기능의 Tradeoff는 위에서 다루었던 호환성 문제가 존재한다고 생각합니다. 따라서, 기존의 npm, yarn v1 처럼 익숙한
pnpm
을 통해 모노레포를 구성하기로 결정했습니다.공통 패키지 정의
모노레포의 특성 상, 굳이 중복하여 각 패키지에 의존성을 설치할 필요가 없기 때문에, Global 하게 관리할 의존성과 설정을 나열해 보았습니다.
- React : 의존성 설치
- TailwindCSS : 의존성 설치
- Typescript : 의존성 설치 및
tsconfig.base.json
으로 글로벌 설정 관리
- eslint : 의존성 설치 및 글로벌 설정 관리
- prettier : 의존성 설치 및 글로벌 설정 관리
다음과 같은 패키지는 공용으로 사용하도록 정의하였습니다.
설치는 단순하니 세부적인 내용은 제외하겠지만,
typescript
와 eslint
, prettier
는 Global 설정을 각 패키지가 어떻게 override하여 사용하는지 살펴보겠습니다.tsconfig.base.json
{ "compilerOptions": { "strict": true, "strictNullChecks": true, "esModuleInterop": true, "emitDecoratorMetadata": true, "experimentalDecorators": true, "noUnusedLocals": true, "skipLibCheck": true, "sourceMap": true, "jsx": "react-jsx", "allowJs": true, "target": "ES2017" } }
각 라이브러리는 다음과 같이 extends 하여 사용하고,
만일 base와 중복되는 옵션이 있다면 제거하여 사용합니다.
{ "compilerOptions": { "target": "esnext", "types": ["vite/client", "node", "chrome"], "lib": ["dom", "dom.iterable", "esnext"], ... "paths": { "@src/*": ["src/*"], "@assets/*": ["src/assets/*"], "@locales/*": ["src/locales/*"], "@pages/*": ["src/pages/*"] } }, "include": ["src", "utils", "vite.config.base.ts", "vite.config.chrome.ts", "vite.config.firefox.ts"], "extends": "../../tsconfig.base.json" }
eslint.config.js
exports = { root: true, env: { browser: true, es2021: true, }, extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended', ], overrides: [ { env: { node: true, }, files: ['.eslintrc.{js,cjs}'], parserOptions: { sourceType: 'script', }, }, ], parser: '@typescript-eslint/parser', parserOptions: { ecmaVersion: 'latest', sourceType: 'module', }, plugins: ['@typescript-eslint', 'plugin:prettier/recommended'], rules: {}, };
다음과 같은 전역 설정을,
tsconfig.json
과 마찬가지로 내부 패키지에서 extends 하여 사용하였습니다..prettierrc
{ "trailingComma": "none", "tabWidth": 2, "semi": true, "singleQuote": true, "printWidth": 120, "useTabs": false, "bracketSpacing": true, "endOfLine": "auto" }
다음과 같은 루트 설정 하나로 모든 패키지에 적용할 수 있습니다.
패키지 정의하기
이제, 루트 설정을 완료했으니, 실제 개발할 프로젝트들을 정의하였습니다.
우선적으로 빠르게 개발할 패키지를 설계하고, 필요에 따라 구성을 바꾸어주려 합니다.
common-components
패키지
pnpm create vite
다음 명령어를 통해서 간단한 vite 패키지를 만들었습니다.
next.js
패키지
pnpm create create-next-app@latest
다음 명령어를 통해서 간단한 next.js 패키지를 만들었습니다.
- Vite + React 기반 Chrome Extension 패키지
해당 템플릿을 Clone 하였습니다.
패키지 상호작용 테스트
모노레포를 구성하면서 가장 단순하게 하고 싶은 작업은
Next.js 앱 ↔ 공용 컴포넌트 ↔ Chrome Extension 끼리 참조하여,
공용 컴포넌트를 서로 다른 배포 환경에서 재정의 없이 사용하는 경험이었습니다.
따라서, 공용 컴포넌트에서 다음과 같은 예제 버튼 코드를 작성하였습니다.
interface ButtonProps { children: React.ReactNode; onClick?: () => void; className?: string; } const Button = ({ children, onClick, className = '' }: ButtonProps) => { return ( <button className={`bg-blue-500 text-white p-2 rounded-md ${className}`} onClick={onClick}> {children} </button> ); }; export default Button;
해당 버튼을
index.tsx
에 export 후, package.json에 "main": "./src/index.tsx"
라는 속성으로 추가해 주었습니다. 이제 다른 패키지에서 사용하기 위해서 각 패키지의
package.json
에 의존성을 추가했습니다."common-components": "workspace:*"
Chrome Extension과 Next.js 에서 컴포넌트를 사용하는 코드는 다음과 같습니다.
import React from 'react'; import { Button } from 'common-components'; import logo from '@assets/img/logo.svg'; export default function Popup() { return ( <div className="absolute top-0 left-0 right-0 bottom-0 text-center h-full p-3 bg-gray-800"> <header className="flex flex-col items-center justify-center text-white"> <img src={logo} className="h-36 pointer-events-none animate-spin-slow" alt="logo" /> <p> Edit <code>src/pages/popup/Popup.jsx</code> and save to reload. </p> <a className="text-blue-400" href="https://reactjs.org" target="_blank" rel="noopener noreferrer"> Learn React!!!asdasdasdasdas </a> <Button>Click me!!</Button> <p>Popup styled with TailwindCSS!</p> </header> </div> ); }
import { Button } from 'common-components'; export default function Home() { return ( <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]"> <main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start"> <div className="flex gap-4 items-center flex-col sm:flex-row"> <Button>Click me!!!</Button> </div> </main> </div> ); }

이후, Chrome Extension 패키지와 Next.js 패키지에서 동일한 렌더링을 보이는지 확인하였습니다.
정리
기본적인 모노레포 환경을 정의하고, 실제 간단한 예제 개발을 해보니, 다음과 같은 특징을 체감했습니다.
eslint
나prettier
,tsconfig
와 같은 설정을 공통적으로 적용할 수 있었습니다. 다만, 패키지 마다 속성이 조금씩 차이나기 때문에 제일 간편하다고 느껴진 건prettier
가 공통적으로 적용되는 점입니다.
- 이와 비슷하게
husky
나lint-stage
적용도 일괄적으로 할 수 있어 깔끔하게, 또 안정적으로 다양한 패키지를 관리할 수 있었습니다.
- 공용 컴포넌트를 크롬 확장프로그램과
Next.js
에서 모두 사용하며 중복 코드를 줄이고, 관리 포인트를 하나(common-components
) 로 만드는 과정은 가장 큰 장점이라고 느꼈습니다.
이러한 모든 장점을 아울러, 제 느낌은 “개발 경험이 좋다” 라는 생각이 듭니다.
개발 말고, 테스트나 배포 과정에서도 기존의 개발 경험을 향상 시키는 경험에 대한 정리로 돌아오겠습니다.
감사합니다!