본문은 사이드 프로젝트를 하면서 왜 Monorepo를 도입했고, Turborepo와 Pnpm을 선택했는지에 대한 이유를 다룹니다. Turborepo 및 Pnpm을 이용해 Monorepo를 구축하는 방법을 다루지 않으므로 참고 부탁드립니다.
도입 계기
모놀로식 아키텍처의 단점: Boilerplate 관리 비용
제가 작업한 사이드 프로젝트에서는 백엔드와 프론트엔드 리포지토리가 분리되어 있으며, 프론트엔드 소스코드는 모노레포 아키텍처로 관리되고 있습니다. (프론트엔드 리포지토리: https://github.com/ask-resume/ask-resume-front)
모노레포 아키텍처를 사용한 이유는 “중복 코드가 생기는 것이 싫었기 때문”입니다.
지금까지 제가 구축해 왔던 프론트엔드 아키텍처는 모놀로식입니다. Boilerplate 리포지토리를 만들어 프로젝트 환경 세팅, 자주 사용되는 컴포넌트, util 함수 등을 관리해왔습니다. 그리고 새로운 프로젝트를 구성할 때마다 이 Boilerplate를 이용해 초기 세팅을 해주었습니다.
처음에는 자주 사용하는 코드를 재사용하기 수월하다고 생각했으나, Boilerplate 또한 결국 하나의 리포지토리이므로 별도로 관리하는 리소스가 든다는 생각을 했습니다. 또한 Boilerplate로 매 프로젝트마다 중복되는 코드들을 생성하게 된다면 추후 프로젝트 스펙이 변경되거나 크리티컬한 버그가 발생했을 때 대처하기 어렵겠다는 판단을 했습니다.
멀티레포 아키텍처의 단점: 버전 관리 비용
위의 단점을 해결하기 위해 생각한 아키텍처는 멀티레포와 모노레포 두 가지였습니다.
처음에는 중복되는 UI와 Util 함수를 라이브러리화하기 위해 멀티레포로 구성하고자 했습니다. 그러기 위해 중복되는 UI와 Util 모듈들을 각각 다른 리포지토리로 분리 후 이를 Npm에 푸시해 라이브러리화하려고 했습니다. 그리고 프로젝트에서 이 라이브러리들을 설치해서 사용하게끔 하고자 했습니다.
하지만 이렇게 관리하다 보면, 현재 Ask Resume 프론트엔드 리포지토리에 프로젝트가 하나라 문제는 문제가 없으나 추후 프로젝트가 여러 개가 되면 “버전 관리 리소스 비용”이 몹시 커지겠다고 판단했습니다. 예시로 v1인 UI 모듈을 업데이트 후 v2로 릴리즈하고, 이 모듈을 이용하는 프로젝트가 100개라고 가정하겠습니다. 그리고 모든 프로젝트가 v2를 이용해야 한다고 합시다. 모듈을 업그레이드하는 작업을 100번 수행해야 할 것입니다. 이처럼 모듈을 사용하는 프로젝트가 여러 개라면 버전 관리를 하는 데 인적 리소스가 많이 소모될 것입니다.
모노레포 아키텍처 장점: 동일한 의존성 공유 + 버전 관리 수월
Ask Resume는 “모놀로식의 단점인 코드 중복”과 “멀티 레포의 단점인 버전관리의 어려움”을 해결하기 위해 모노레포를 선택했습니다.
모노레포는 재사용되는 패키지를 하나의 리포지토리에서 관리합니다. 그래서 모든 프로젝트가 같은 리포지토리 안에서 동일한 패키지 버전을 바라보고 있게 됩니다. 즉 의존성이 바뀌면 동일한 의존성을 누락 없이 공유하게 된다는 장점이 있습니다. 따라서 멀티레포와 달리 각 라이브러리의 모듈을 변경하는 작업이 필요하지 않게 됩니다.
Ask Resume의 모노레포 아키텍처
선택한 모노레포 관리 도구: Pnpm workspace + Turborepo
Why use Pnpm workspace: Yarn berry vs Pnpm
Ask Resume 웹 서비스는 모노레포를 구축하기 위해 여타 패키지 매니저들(Npm, Yarn classic, Yarn Berry)과 비교 후 Pnpm workspace를 선택했습니다.
Pnpm workspace를 선택한 이유는 크게 3가지였습니다.
- 모노레포 지원
- Npm, Yarn classic의 팬텀 디팬던시 문제점
- Yarn Berry와 비교했을 때 러닝커브가 낮음
- 모노레포 지원
- Pnpm workspace는 기본적으로 pnpm-workspace로 모노레포를 지원
- pnpm-workspace.yaml에 사용되는 패키지들을 등록해두고, 특정 프로젝트에서 사용하고자 하는 패키지를 dependency에 등록해 사용 가능
2. Npm, Yarn classic의 팬텀 디팬던시 문제점
a. 의존성이 호이스팅 되는 Npm, Yarn classic
- Npm, Yarn classic 등은 중복 의존성 설치를 방지하기 위해 호이스팅(hosting) 기법을 사용
- workspace 필드를 사용하여 node_modules 디렉토리에 workspace에 대한 심볼릭 링크를 생성
b. Npm, Yarn classic의 팬텀 디팬던시 문제
- Npm과 Yarn classic은 의존성 중복 방지를 위해 호이스팅 기법을 이용하는데, 이로 인해 의도치 않은 side effect를 발생할 수 있다.
- 아래 그림에서 package-1은 B(1.0)을 설치한 적이 없지만 require(‘B’)가 작동한다. require(‘B’)를 사용하는 경우 B(1.0)을 의존하던 패키지를 제거하면 B를 찾지 못하는 오류가 발생한다. 이를 팬텀 디팬던시(phantom dependency)라고 한다.
c. 의존성이 content-addressable 저장소에 저장되는 Pnpm
- Pnpm은 의존성을 node_modules를 직접 설치하지 않고, 전역 저장소에 각 패키지의 고유한 버전을 content-addressable 방식으로 저장합니다. 프로젝트에 패키지를 설치할 때, pnpm은 전역 저장소에서 해당 패키지 버전을 프로젝트 내 node_modules 폴더로 Symbolic Link(symlinks)를 생성해 참조합니다.
3. Yarn Berry와 비교했을 때 러닝커브가 낮음
- Pnpm은 Npm 명령어와 동일하며 install시 node_moules과 lock 파일이 생성됩니다.
- 반면 Yarn Berry의 경우 node_modules를 생성하는 대신, pnp.cjs라는 단일 파일에서 모듈의 의존성을 정의합니다. 그리고 .yarn/cache 폴더에 패키지들이 압축 파일로 저장되어 디스크 파일을 적게 사용합니다.
- 이러한 점에서 Yarn Berry는 Npm, Yarn classic만을 사용해보았던 사용자가 사용하기에 러닝커브가 높습니다.
Why use Turborepo: Turborepo vs Nx
1) 왜 모노레포 관리 툴을 사용했나요? — 의존성 분리
모노레포를 구축하는 과정에서 파이프라인을 구축하는게 복잡하다는 생각을 했습니다.
예시로 Ask Resume 프론트엔드 디렉토리의 apps/docs에는 디자인 시스템을 관리하고 있습니다. 이 패키지를 루트에서 deploy할 때 린팅 → 테스트 → 빌딩 → 배포
파이프라인을 구축하기 위해서는 매우 길고 복잡한 script를 작성해야 할 것입니다.
이러한 문제를 해결하기 위해 모노레포 관리 툴을 이용해 복잡한 스크립트 처리를 위임하고자 했습니다. 아래는 디자인 시스템 deploy시 스크립트 커맨드 예시입니다.
// Before: Only 스크립트 커맨드
"scripts": {
"deploy:storybook": "cd apps/docs && pnpm lint && pnpm apps/docs build && pnpm build && pnpm storybook build && pnpm test && pnpm storybook deploy"
}
// After: Turborepo 사용 예시
"scripts": {
"deploy:storybook": "turbo run deploy --scope='storybook'"
}
After은 복잡하게 이어져있던 스크립트 의존성을 분리하고, 오로지 그 작업에 해당하는 스크립트(lint && build && test && deploy)로만 구성합니다.
의존성은 관리툴인 Turborepo에서 turbo.json
파일을 이용해 작성합니다. 아래 pipeline의 deploy를 보면 lint, build, test의 의존성을 순서대로 처리해주는 것을 확인할 수 있습니다.
// Ask Resume의 turbo.json
{
"$schema": "<https://turbo.build/schema.json>",
"pipeline": {
// 스크립트와 매핑되는 태스크 이름을 작성합니다.
"build": {
// 의존성 빌드 명령이 실행된 후 build 커맨드가 실행됩니다.
"dependsOn": ["^build"],
// 기본 캐시 폴더를 지정합니다.
"outputs": ["dist/**", ".next/**", "!.next/cache/**", "storybook-static/**"],
"env": ["NEXT_PUBLIC_PRODUCTION_API_URL"]
},
"dev": {
"cache": false,
"persistent": true,
"env": ["NEXT_PUBLIC_DEV_API_URL"]
},
"clean": {
"cache": false
},
"deploy": {
// 의존성을 여러 개 지정할 경우 터보가 순서를 맞춰서 진행합니다.
"dependsOn": ["build", "cypress:ci", "snapshots", "lint"]
}
},
"globalEnv": ["GITHUB_TOKEN", "NODE_ENV", "ANALYZE"], // 전역 환경 변수
"globalDependencies": [".env"] // 전역에서 적용할 파일
}
이처럼 관리 툴을 이용하면 복잡한 파이프라인 의존성을 위임할 수 있기에 사용했습니다.
2) Turbporepo vs Nx
Pnpm workspace를 지원하는 모노레포 관리 툴을 찾으면서 Turborepo와 Nx를 많이 쓰고 있다는 것을 확인했습니다. 어떤 툴을 사용할지 고민하다가, Nx 공식 문서에서 둘을 비교하는 글이 있어 각각의 장단점을 확인했습니다.
3) Why use Turborepo?
Ask Resume는 프론트엔드와 백엔드가 다른 리포지토리로 분리되어 있으며, 백엔드는 자바(코틀린으로 마이그레이션할 예정)를 프론트엔드는 TypeScript로 구성되어 있습니다. 두 리포지토리를 인티그레이션할 생각은 없으며 프론트엔드 리포지토리만을 모노레포로 관리하면 되기에, JavaScript 진영만 지원해주면 되었습니다.
또한 Nx가 Tuborepo보다 성능상 이점은 높으나, 그만큼 러닝커브가 높기에 바로 적용하기 힘드리라 판단했습니다. 따라서 Turborepo를 이용하기로 했습니다.
마무리
Ask Resume v1은 프론트엔드 1명과 백엔드 1명이서 개발했습니다. 프론트를 혼자서 개발을 했기에 그동안 관심을 가졌던 다양한 기술 스택을 사용해 보았습니다. 그 중 Monorepo 아키텍처는 가장 관심 있던 트랜드 중 하나였습니다.
Monorepo를 구축하기 위해 어떤 기술 스택을 사용할지 고민하면서 모든 기술들은 저마다의 장단점이 있다는 것을 배우게 되었습니다. 예컨대 Nx는 다양한 기능과 높은 성능을 제공하지만 그만큼 높은 러닝커브를 가지고 있습니다. 또한 Monorepo 아키텍처가 모놀로식과 멀티레포와 비교했을 때 중복 코드가 적고 버전 관리하기 쉽다는 장점이 있지만, 단일 프로젝트 또는 각 프로젝트마다 중복되는 코드가 적다면 다른 아키텍처를 사용하는 것이 더 좋다고 느꼈습니다.
이 경험을 통해 기술의 trade-off를 따지고 참여하는 프로젝트의 특성과 상황에 맞는 기술을 선택하는 것이 중요하다는 것을 배웠습니다.