[모던 프론트엔드 구성] 1. npm workspace로 모노레포 구현하기
TLDR;
🦄 npm workspace를 사용하여 모노레포를 구성해봅시다🦄
✨Sample Code✨
https://github.com/SpookyJelly/workspace
Before we start
npm workspace는 npm 7 버전 이상부터 사용할 수 있습니다. node.js LTS를 설치하셨다면 기본적으로 npm 8 버전이 설치되어있어 바로 시작하시는데 문제가 없으시겠지만 (22/06/06), 혹시 모르니, 사용 중인 npm의 버전을 확인해봅시다.
npm -v
그럼 이제 하단의 공식 문서를 같이 띄워놓고 시작해봅시다.
https://docs.npmjs.com/cli/v8/using-npm/workspaces
프로젝트 시작
workspace를 직접 다뤄보는 것이 이번 시리즈의 목적이므로, 복잡한 기능이 없는 간단한 프로젝트를 만들어보겠습니다.
- View를 보여줄 React 패키지인 app
- app의 컴포넌트에 사용될 스타일만으로 구성된 패키지 styles
- 모든 패키지에서 공유할 수 있는 유틸리티 패키지 shared
이 세 가지 패키지를 하나의 프로젝트로 관리해보겠습니다.
npm workspace를 통하여 위 세 프로젝트는 하나의 리모트 저장소에 관리되고, 상호 참조가 가능하며, 의존성 관리를 루트에서 일괄적으로 처리할 수 있는 모노레포 프로젝트가 될 것입니다.
이제 각설하고 시작해봅시다. 빈 폴더를 하나 만드시고, 선호하는 IDE로 열어주세요. 이후 npm init 커멘드를 실행시켜 package.json을 만들어주는 것부터 시작합시다.
npm init을 실행시키면 패키지 이름, 버전, 작성자, 스크립트를 입력하는 프롬프트 화면으로 넘어가게 됩니다.
크게 중요한 부분이 아니므로, 대충 넘어가줍시다.
{
"name": "workspace",
"version": "1.0.0",
"description": "study in workspace",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "spookyjelly",
"license": "ISC"
}
그럼 대충 이런 모습이 나오는데, 여기서 workspace 라는 키로 직접 workspace로 관리할 패키지 이름을 입력하고, 그에 맞게 디렉토리 구조를 짜도 무관하지만, npm은 간단한 CLI command로 워크스페이스 추가를 간단하게 할 수 있습니다.
npm init -w {% 패키지 위치%}
npm에서 -w는 workspace의 shorthand입니다. 위와 같이 입력하게 되면 npm은 입력받은 위치에 새로운 워크스페이스를 추가하면서, package.json을 생성해줍니다.
경로는 본인 기호에 맞춰 작성해도 무방하나, 프로젝트가 모노레포 구조를 가져가게 되면, `packages` 라는 이름으로 생성하는 것이 관례입니다.
따라서 저는 npm init -w ./packages/app 으로 진행했습니다.
모노레포 구조의 또 한 가지 관례가, 어떤 패키지를 추가할때 위와 같이 @{% 프로젝트 이름%}/ {% 패키지 이름 %}
을 사용합니다. 가독성을 높이고, 관심사를 분류하는데 큰 도움이 되므로, 참고 바랍니다.
app package에 React 프로젝트 만들기
CRA나 Vite template을 이용해서 간단하게 보일러플레이트를 만들어줍시다. 저는 웹팩 공부도 할 목적으로 탬플릿을 사용하지 않았지만, 시리즈 진행에는 아무런 차이가 없습니다.
⚠ 대신 typescript 탬플릿으로 진행해주세요. 하단에 typescript를 사용한 예제가 나옵니다.
⚠ app 패키지 안에서 React를 사용할 것이므로, CRA등의 npx 기반 명령어를 사용할 경우, app 폴더로 이동 후, package.json을 삭제한 다음 실행해줘야한다.
여기까지 잘 따라왔으면, 이제 프로젝트 루트에서 app 패키지를 실행시켜보자.
아마 지금 app 패키지를 실행시키기 위해서 ./packages/app 으로 이동한 다음 run start 등의 스크립트로 실행했을텐데, 여러 패키지를 가지고 있는 모노레포 구조에서는 이렇게 패키지를 실행하려고 할때마다 폴더를 이동하는 것은 너무 불편할 것이다.
루트 폴더의 package.json으로 이동해서 아래와 같이 스크립트를 수정해보자.
{
"name": "workspace",
"version": "1.0.0",
"description": "study in workspace",
"main": "index.js",
"scripts": {
"start:app": "npm run start -workspace=@workspace/app "
},
"author": "spookyjelly",
"license": "ISC",
"workspaces": [
"packages\\app"
]
}
보면 start:app이 추가된것을 확인할 수 있는데, -workspace 플래그를 주면 특정 워크 스페이스의 package.json에 적힌 script를 실행한다는 뜻이다.
--workspaces 플래그로 워크스페이스 전체를 순회하면서 명령어를 실행 할 수 도 있고, --if-present
플래그로 워크스페이스에 해당 스크립트가 존재할 경우만 실행하게 하는 등, 입맛대로 튠업 할 수 있다.
⚠ 이하를 진행하기 전에, app 패키지 안에 node_modules가 있다면 삭제해주세요.
아무튼, 위와 같이 스크립트를 수정한 뒤, 루트에서 npm install을 실행 한다음, npm run start:app을 실행시켜보자.
app 폴더에서 실행한 것과 같이 동일하게 앱이 실행 될 것이다.
눈썰미가 좋다면, node_module가 app 내부가 아닌, 루트 폴더로 끌어올려진 것을 확인할 수 있는데, 이것을 dependency hoisting 이라고 한다. 이렇게 hoisting이 되기 때문에, 패키지간 의존성 문제가 해결 되는 것인데, 자세한 내용은 조금 뒤에서 다루겠습니다.
styles package 만들기
다음은 컴포넌트의 스타일을 담당할 styles 패키지를 만들어보자.
npm init -w ./packages/styles
styles 패키지의 package.json
{
"name": "@workspace/styles",
"version": "1.0.0",
"description": "",
"main": "index.js",
"devDependencies": {},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "spookyJelly",
"license": "ISC"
}
디폴트 값만을 사용했으면 위와 같이 main (프로젝트의 진입점)이 index.js로 되어있을텐데, 스타일 패키지라고 굳이 css loader까지 깔아서 export 할 필요는 없으니, 색상명과 HEX 코드를 Object로 export 해서 app 패키지에서 레퍼런스로 참조할 수 있을 정도의 규모로 만들자.
아래와 같이 index.js를 만들자
// ./packages/styles/index.js
export const color = {
primary: "#33B5E5",
success: "#00C851",
warning: "#FFBB33",
danger: "#ff4444",
BADA55: "#BADA55",
};
이후, 다시 루트에서 npm install 을 실행시켜주자.
스크립트를 실행시키면 이상하게도, 아무것도 설치한 것이 없는데도 뭔갈 설치했다는 메시지가 뜰 것이다.
그 정체는 바로 루트의 node_modules 폴더를 확인하면 알 수 있다.
node_modules를 확인해보면, 아까는 없었던 styles 패키지가 새로운 패키지로 추가되어있음을 확인할 수 있습니다.
신기하게도 @workspace 라는 폴더 아래에 가지런하게 놓여있는데요. 처음 우리가 워크스페이스의 이름을 만들때, @workspace/ 라는 prefix를 붙였던 것 때문에, node_modules에서도 위와 같이 같은 디렉터리로 묶이게 된 것입니다.
그렇다면 이렇게 각 패키지가 루트의 node_modules로 hoist 되었으므로, 혹시 app에서 node_modules에 있는 styles을 그냥 import 할 수 있을까요?
맞습니다. 우리가 외부 패키지를 import 할 때, 현재 디렉토리에 node_modules가 없다면 상위 폴더로 거슬러올라가며 찾기 때문에, 루트에 있는 node_modules/styles 에 접근할 수 있게 됩니다.
그러면 바로 실험해봅시다. app 패키지의 index.tsx를 아래와 같이 변경해주세요.
import React from "react";
import ReactDOM from "react-dom";
import { color } from "@workspace/styles"; // 아까 보았던 @workspace의 styles에서 export 된 color를 가져옵니다.
function App() {
return (
<div>
<h1 style={{ color: color.primary }}>React with npm workspace!</h1> // import 한 color를 사용합니다
</div>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
그리고 다시 app을 켜면...
아 생각해보니 저희 app은 ts로 만들었죠.... styles에서 import 하는 color는 js로 만들어져서.... 타입 없다고 에러를 뿜내요.
빠르게 color에 대한 .d.ts 파일을 만들어줍시다.
./packages/app/tsconfig.json
{
"exclude": ["node_modules"],
"compilerOptions": {
"esModuleInterop": true,
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"lib": ["DOM", "ESNext"],
"jsx": "react",
"strict": true,
"sourceMap": true,
"typeRoots": [
"../../node_modules/@types",
"./types"
] , // 여기 추가
"skipLibCheck": true
"forceConsistentCasingInFileNames": true
}
}
먼저 app의 tsconfig.json에서 typeRoots라는 속성을 설정해줍시다. 타입 파일을 불러올 기본 경로를 지정해주는 것인데,
생략시 node_modules/@types 가 디폴트로 지정됩니다.
그러나 우리는 커스텀 .d.ts를 사용할 예정이므로, ./types에서도 불러와주세요~~ 라는 의견을 전달해줘야합니다.'
이후, types 폴더를 만들고, index.d.ts 파일을 만들어줍시다.
./packages/app/types/index.d.ts
declare module "@workspace/styles" {
type Color = {
primary: string;
success: string;
warning: string;
danger: string;
BADA55: string;
};
const color: Color;
export { color };
}
모듈명으로 저희가 사용할,( 실체가 있지만 타입은 없는 ) @workspace/styles 의 이름을 넣어주고, 해당 모듈이 가지고 있는 객체들의 타입을 지정해 준 뒤 export 해줍시다.
이렇게 타입 선언까지 끝내주면 비로소 styles 패키지에 선언된 color를 불러올 수 있게 됩니다.
shared package 만들기
다음은 shared package를 만들어보겠습니다. shared는 모든 패키지에서 공통적으로 사용할 수 있는 유틸리티 패키지입니다.
npm init -w ./packages/shared
shared는 typescript 를 사용할 것이므로, 디펜던시로 ts를 깔아줍시다.
npm install -D typescipt -w @workspace/shared
그리고 package.json과 tsconfig.json을 아래와 같이 만들어줍시다.
// packages/shared/package.json
{
"name": "@workspace/shared",
"version": "1.0.0",
"description": "",
"main": "./dist/index.js",
"devDependencies": {
"typescript": "^4.7.3"
},
"scripts": {
"start" : "tsc -d"
},
"author": "spookyjelly",
"license": "ISC"
}
// ./packages/shared/tsconfig.json
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"rootDir":"./",
"outDir": "dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}
완성되었으면, shared에 index.ts를 아래와 같이 만들어줍시다.
// ./packages/shared/index.ts
export function sum(x: number, y: number) {
return x + y;
}
마지막으로, 루트의 package.json의 스크립트를 수정해야하는데, 그 전에 지금 package.json과 tsconfing.json에 무슨 짓을 한건지 알아봅시다.
먼저 package.json의 main을 ./index.js가 아니라 ./dist/index.js로 수정했습니다.
아시다시피 타입스크립트는 브라우저가 해석할 수 없기 때문에, 자바스크립트로 컴파일 한 다음 사용해야하는데, 저희는 tsconfig와 workspace script를 교묘하게 이용하여, 프로젝트가 시작되는 시점에 컴파일이 완료된 shared 패키지를 dist/ index.js 에 몰아넣을 것입니다. tsconfig를 보시면 rootDir과 outDir의 조합으로 tsc 커맨드를 실행시켰을때, 컴파일 결과물이 ./dist 에 위치하게 됨을 알 수 있습니다. 그리고, shared 패키지의 start 스크립트로 tsc -d 를 주어, shared 패키지가 start 되었을 때, 컴파일이 수행되도록 하였습니다.
지금 잘 이해가 안되신다면, shared 로 이동하시어 npm run start를 입력해보시면 좋을 것 같습니다.
그럼 이제 루트의 package.json을 아래와 같이 수정해봅시다.
{
"name": "workspace",
"version": "1.0.0",
"description": "study in workspace",
"main": "index.js",
"scripts": {
"start:app": "npm run start -workspace=@workspace/app ",
"start:all": "npm run start -workspaces --if-present"
},
"author": "spookyjelly",
"license": "ISC",
"workspaces": [
"packages\\shared",
"packages\\app",
"packages\\styles"
]
}
start:all 이라는 스크립트가 추가 되었고, workspaces 배열에서 shared가 최상위 인덱스가 되었습니다.
start:all은 모든 워크스페이스에 start라는 스크립트를 실행하는 명령어이고 ( --if-present 플래그로 인해 start가 존재하는 패키지에 한해서), workspaces 플래그로 커맨드를 실행시켰을시, workspaces 배열에 있는 패키지의 앞쪽부터 순차적으로 실행하기 때문에, shared를 먼저 실행시켜, app 패키지에서 shared를 참조하기 전에 타입 파일을 생성하기 위함입니다.
다소 복잡해보이지만, 모노레포 구조로 프로젝트를 생성할 때는 스크립트의 실행 순서 또한 개발자가 고려해야할 요소 중 하나입니다. 물론, 스크립트를 더 정교하게 짠다면 이런 동기적 요소를 고려할 필요가 없겠지요.
아무튼, 다시 한번 npm install을 실행시킨 뒤, app 패키지의 index.tsx를 아래와 같이 수정합시다.
import React from "react";
import ReactDOM from "react-dom";
import { color } from "@workspace/styles";
import { sum } from "@workspace/shared";
function App() {
console.log("color", color);
return (
<div>
<h1 style={{ color: color.BADA55 }}>React with npm workspace!</h1>
<p>{sum(5, 7)}</p>
</div>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
그리고 루트에서 npm run start:all 을 실행시키면
shared에서 선언한 sum 함수도 그대로 app에서 사용할 수 있게 됩니다.
styles 패키지에서도 마찬가지로 사용할 수 있습니다.
// ./packages/styles/index.js
import { sum } from "@workspace/shared";
export const color = {
primary: "#33B5E5",
success: "#00C851",
warning: "#FFBB33",
danger: "#ff4444",
BADA55: "#BADA55",
};
export const fontSize = sum(16, 16);
styles에서 사용해 만든 fontSize라는 친구도 app 에서 사용할 수 있습니다.
// ./packages/app/types/index.d.ts
declare module "@workspace/styles" {
type Color = {
primary: string;
success: string;
warning: string;
danger: string;
BADA55: string;
};
const color: Color;
const fontSize: number;
export { color, fontSize };
}
import React from "react";
import ReactDOM from "react-dom";
import { color, fontSize } from "@workspace/styles";
import { sum } from "@workspace/shared";
function App() {
console.log("color", color);
return (
<div>
<h1 style={{ color: color.BADA55 }}>React with npm workspace!</h1>
<p>{sum(5, 7)}</p>
<p style={{ fontSize: fontSize }}>font Size : {fontSize}</p>
</div>
);
}
ReactDOM.render(<App />, document.getElementById("root"));
workspace는 여러 개발자가 하나의 프로젝트에 투입되었을 시 빛을 발합니다. 코드의 충돌을 신경쓰지 않은 채 오롯히 자신이 맡은 영역만 집중해서 개발할 수 있기 때문입니다. A라는 사람은 app 이라는 패키지에서 view만을 만들고, B라는 사람은 styles라는 패키지에서 스타일만을 만들고, C라는 사람은 shared라는 패키지에서 유틸리티를 만든 뒤, 서로가 필요한 곳에서 import 만 잘 해서 쓸 수 있게 만들어주기 때문입니다.
그러면 여기서 의문이 드는 것이, 만약 A라는 사람은 typescript 4.x 를 peer Dependency로 가지는 라이브러리를 쓰고, B라는 사람은 typescript 3.x 을 peer Dependency로 쓰는 경우는 어떻게 되는 걸까요? workspace를 이용하는 이유가 의존성의 충돌을 원천적으로 차단하기 위함인데, 이렇게 패키지별로 불가피하게 다른 버전을 사용하게 될 경우는 답이 없는 걸까요?
그 해결책은 다음 포스트에서 다루도록 하겠습니다.