궁극의 JavaScript 모노레포 설정

2022년 8월

🖊 들어가며

그동안 대규모 프로젝트를 여럿 거치면서 프론트엔드부터 Node.js 백엔드까지 여러 JavaScript 프로젝트에서 다양한 모노레포 설정을 해왔습니다. 매번 설정할 때마다 개발 경험이 생각보다 만족스럽지 않았고, 도구를 매번 바꿔서 시도해왔습니다. 많은 시행착오와 함께 여러 프로젝트에 흩어져 있는 도구들이 제가 성장했다는 증거로 느껴지기도 하는데요. 😂 모노레포 설정에 힘들어하는 다른 분들께 도움을 드리고자, 이번 기회에 제가 경험했던 내용을 정리하고 얼추 정착한 기술 스택의 스냅샷을 찍어보려고 합니다.

🤔 모노레포는 무엇일까요?

모노레포는 여러 패키지, 프로젝트를 한 레포에 담는 걸 의미합니다. 다양한 프로젝트를 한 레포에서 관리하는 것에는 다양한 이유가 있는데요. 바로 패키지와 프로젝트 간의 의존성 관계를 쉽게 관리할 수 있기 때문입니다.

예를 들면,

의존성 다이어그램

  • 우리 팀에 A, B, C 프로젝트가 있고,
  • A, B, C 프로젝트 간에 공통으로 사용되는 P, Q라는 라이브러리가 있고,
  • P와 Q는 K라는 라이브러리를 사용한다고 가정해보겠습니다.

그러면,

빌드 순서 다이어그램

  • P라는 라이브러리가 수정되면, K, Q를 제외한 P, A, B, C 프로젝트가 순서대로 빌드되어야 합니다.

다음과 같이 패키지 간 의존성이 엮이면 엮일수록 의존성의 변경 사항에 대한 추적이 점점 더 복잡해지고 어려워지게 되는데요. 해당 관계에 대한 정보가 여러 Git 레포에 흩어지게 되면 어떨까요? 각자의 작업물이 온갖 브랜치들이 난립하면서 추적하기 굉장히 어려워질 것 같습니다. 따라서, 한 레포에 해당 패키지들을 모두 포함해 버전 관리를 하는 시도들이 나오게 되었습니다. 이것을 모노레포라고 부릅니다.

(Tony) 저는 현재 당근마켓에서 주로 프로젝트나 팀 단위로 모노레포를 운영하고 있습니다.

🗜 모노레포 도구가 제공해야 하는 기능들

위에 적혀있는 문제들을 조금 더 날카롭게 쪼개서 봐야 하는데요. 모노레포와 모노레포 도구는 다음과 같은 문제 해결을 도와준다고 보시면 됩니다.

1. 의존성 관리

  • 한 레포에 여러 패키지가 포함되면서 기존보다 더 많은 써드파티 의존성 들이 생기게 되는데, 써드파티 의존성 간의 중복을 줄여 의존성 설치를 최적화하고,
  • 써드파티 의존성에서 문제가 발생했을시, 디버깅이나 패치, 버전 통합이 쉬워야 합니다.

2. 스크립트 실행

  • 의존성 관계에 따라 순차대로 스크립트를 실행할 수 있어야 합니다.
  • 변경 사항이 있다면, 빌드 캐시를 적절하게 활용해 다시 필요한 부분만 빌드가 동작해야 합니다.

3. 버저닝

  • 패키지의 수정사항을 추적해, 필요한 패키지를 골라 알맞게 버전을 매길 수 있어야 합니다.

이러한 문제를 모두 해결해주는 올인원 도구를 아마 찾고 계실 텐데요. 제 짧은 경험상 모든 요구사항을 충족시키면서 제대로 돌아가는 도구를 아직 찾지 못했습니다.

Lerna

Lerna is a fast modern build system for managing and publishing multiple JavaScript/TypeScript packages from the same repository.

제 첫 모노레포 설정은 다들 알고 계신 전통적인 Lerna와 함께였습니다. Lerna는 의존성 관리와 스크립트 실행, 버저닝을 모두 도와줍니다.

$ yarn add -D lerna

👎 Lerna의 의존성 관리

Lerna는 각 패키지를 정상적으로 작동하게 하도록 각 패키지 폴더 내에 node_modules 폴더를 복제합니다. 이는 이미 무거운 node_modules 문제를 더 심화시키며, 중복된 의존성 들을 제거해서 최적화 할 수 있는 여지가 없습니다. 따라서 너무 느립니다.

# 모든 패키지 폴더에 node_modules 폴더를 만들고 필요한 의존성을 설치합니다
$ lerna bootstrap

👎 Lerna의 스크립트 실행

Lerna는 JavaScript(Node.js)로 만들어진 패키지입니다. 따라서 스크립트를 병렬로 실행시키거나 관리하는 데 최적화되지 않았습니다. 또한, 실행된 스크립트 결과(빌드 결과물)에 대한 캐싱 등의 기능을 제공하지 않습니다.

최근에 nx에서 Lerna 프로젝트의 관리를 맡게 되면서 nx와의 상호운용을 통해 지원하는 것으로 알고 있습니다. 참고

# 의존성 차례대로 빌드를 수행합니다
$ lerna run build

👍 Lerna의 버저닝

Lerna의 버저닝는 간편합니다. $ lerna publish 한 줄이면 이전 배포 기록과 비교해 수정사항이 있는 패키지를 찾아서, 어떤 버전을 매길지 결정하는 프롬프트가 등장합니다. 여기서 메인테이너는 패키지마다 어떤 버전을 설정할지 직접 결정할 수 있습니다. 개발자가 직접 버전을 결정해야 하므로 비록 GitHub Actions 등 CI 환경에서의 배포는 권장되지 않지만, 여러 사람이 동시에 메인테이닝하는 오픈소스 프로젝트가 아니라, 사람이 패키지 배포를 관리하는 환경이라면 Publish 트리거는 충분합니다. 오히려 개발자에게 배포 주기나 배포된 날짜 등에 대한 인지를 높여 패키지가 잘 관리되고 있다고 느끼게 합니다.

따라서 저는 Semantic Release 등의 자동 배포 도구보다 Lerna를 통한 수동 배포를 조금 더 선호합니다.

# Lerna로 버저닝합니다
# 이전 버전과 비교해 수정사항이 있는 패키지를 찾아, 어떤 버전을 매길지 결정하는 프롬프트가 등장합니다
$ lerna version

# lerna version을 수행하고 난 뒤 뒤따라 자동으로 필요한 패키지를 NPM에 출판합니다
$ lerna publish

Yarn Workspaces

Yarn workspaces aim to make working with monorepos easy, solving one of the main use cases for yarn link in a more declarative way. In short, they allow multiple projects to live together in the same repository AND to cross-reference each other - any modification to one's source code being instantly applied to the others.

Yarn Workspace는 NPM 대체 패키지 매니저인 Yarn에서 제안한 모노레포 관리 방법입니다. Yarn Workspace는 의존성 관리와 플러그인을 통한 스크립트 실행을 지원합니다. 버저닝은 따로 지원하지 않습니다.

// package.json
{
  "workspaces": [
    // ...
  ]
}

👍 Yarn Workspaces의 의존성 관리

Yarn Workspaces는 제대로 된 패키지 매니저답게 의존성 관리를 매우 잘해줍니다. 중복된 의존성 들을 제거해서 용량을 최적화하고 Yarn 2+ 버전의 Plug in play 기능을 통해 수 많은 의존성이 있는 프로젝트더라도, zero-install 기능을 통해 빠르게 프로젝트를 Bootstrap 할 수 있습니다. 따라서 모노레포에서 수많은 써드파티 의존성 때문에 yarn install이 너무 오래 걸리는 문제도 깔끔하게 해결합니다.

또한 resolutions 필드 등을 활용해, 모노레포 내에 서로 다른 써드파티 패키지 버전이 깔리는 걸 방지할 수 있고, packageExtensions 옵션이나, patch 프로토콜을 통한 써드파티 패키지의 패치 지원 등 써드파티 의존성을 우아하게 관리할 수 있습니다.

특정한 코어 패키지는 전 모노레포를 통틀어서 한 버전만 설치되어야 할 수 있습니다. (예: graphql)

👎 Yarn Workspaces의 스크립트 실행

Yarn 2+ 버전에서는 @yarnpkg/plugin-workspace-tools 플러그인을 통해 전체 스크립트 실행을 수행할 수 있습니다. 참고

# 플러그인 설치하기
$ yarn plugin import workspace-tools

# 전체 빌드 실행하기
$ yarn workspace foreach -t build

하지만, 플러그인으로 제공되는 확장 기능인 만큼 특정 패키지에 의존하고 있는 패키지들만 빌드한다든지, 빌드 결과를 캐싱한다든지 하는 더 강력한 기능은 아쉽게도 더 지원되지 않습니다. 또한 마찬가지로 JavaScript(Node.js)로 만들어진 도구이기 때문에 병렬로 다양한 스크립트를 수행하는데 오버헤드가 뒤따릅니다.

Ultra Runner

Ultra fast monorepo script runner and build tool.

Ultra Runner는 모노레포에서 병렬로 스크립트 실행을 도와주는 도구입니다. CLI 내에서 각 스크립트의 진행 상황을 예쁘게 정리해서 보여줍니다. 스크립트 실행기인 만큼 따로 의존성 관리나 버저닝 기능은 존재하지 않습니다.

👍 Ultra Runner의 스크립트 실행

Ultra Runner의 동시 스크립트 수행은 우아하게 잘 작동합니다. 다른 병렬 스크립트 수행기들은 (Lerna, Yarn Plugin, Concurrently 등) 각 스크립트마다의 stdout을 시간 순서대로 보여주는 데 반해, Ultra Runner는 프로세스별로 섹션을 나누어서 보기 쉽게 보여줍니다. 해당 문제를 해결하기 위해 프로세스별로 stdout에 네임스페이스를 넣거나, 특정한 색상을 넣어주는 경우가 있는데, 저는 섹션을 나눠서 보여주는 Ultra Runner의 방식이 가장 보기 좋았습니다.

Ultra Runner가 CLI에서 작동하는 모습

그리고, package.json 내의 스크립트를 따로 수정하지 않고도 스스로 파싱해, && 구문으로 엮인 스크립트를 병렬로 실행시켜주는 등 기타 문법에 얽매이지 않으므로, 기존 package.json 내 스크립트의 상호운용성도 챙길 수 있습니다.

또한, 다른 스크립트 실행기들에는 없는 Caching 기능이 내장되어 있습니다. Ultra Runner는 현재 빌드 결과에서 모노레포 내의 파일시스템을 해싱해 .ultra.cache.json 파일을 만듭니다. 따라서, 이후 패키지 폴더 내 파일이 변경되지 않았다면, 빌드를 건너뜁니다. 이 기능은 모노레포 내에 빌드해야 할 패키지가 많은 경우, 빌드 속도를 절약해줍니다. 또한 캐시 작동 원리가 매우 직관적이고, 어떤 부분이 달라져서 빌드가 재실행되는지도 CLI에 표현되므로 CI에서 캐시와 관련된 디버깅을 할 때 매우 편리했습니다.

만약 특정 패키지에 build 스크립트가 없는 경우, 캐시 기능이 적용 안 되는데, 이럴 땐 의미 없는 build 스크립트를 넣으면 정상 작동합니다

{
  "scripts": {
    "build": "echo \"noop\""
  }
}

Ultra Runner가 빌드 캐시를 활용하는 모습

Turborepo

Turborepo is a high-performance build system for JavaScript and TypeScript codebases.

Turborepo는 Vercel에서 메인테이닝하고 있는 모노레포 스크립트 실행기입니다. 따라서 Turborepo도 따로 의존성 관리나, 버저닝 등의 기능은 없습니다.

👎 Turborepo의 스크립트 실행

Turborepo의 스크립트 실행은 어느 정도 잘 만들어졌습니다. (만족하며 사용하는 유저도 꽤 있는 걸로 알고 있습니다) 하지만 제 생각에는 Ultra Runner보다 부족합니다. package.json 내 적어줘야 하는 설정 항목은 생각보다 복잡합니다. 또한 기존 스크립트 문법과 함께 쓸 수 없어서 상호운용성이 떨어집니다.

두 번째로, 빌드 결과물 캐싱이 모호하고 암묵적입니다. Vercel을 사용할 시에 특별한 설정 없이(Zero-config) 이 캐시를 암묵적으로 보존해주는데, 어떤 캐시가 깨져서 빌드가 다시 수행되는지 모호했고 디버깅을 할 수 없었습니다. Vercel에서 클린 빌드를 계속 눌러줘도 캐시가 계속 보존되는 바람에 결국 Turborepo의 캐시 기능을 끄게 되었습니다.

또, CLI 내에서 색상을 통해 어떤 스크립트에서 나온 stdout인지 구별할 수 있는데, 이것도 역시 Ultra Runner에서 제공되는 CLI보다 시인성이 확실히 떨어집니다.

Turborepo가 CLI에서 작동하는 모습

🖊 결론

여러 모노레포 도구를 모두 사용해본 결과 다음의 통찰을 얻을 수 있었습니다.

  • 👍 Lerna는 수동 버저닝을 알맞게 자동화해준다.
  • 👍 Yarn 그리고 Yarn Workspaces를 사용하면 별도의 모노레포 전용 의존성 관리 도구가 없더라도 충분하다.
  • 👍 Ultra Runner는 병렬 스크립트 실행과 모니터링에 편리하고, 빌드 캐시가 가볍고 직관적이다.

따라서 저는 수많은 시행착오를 통해 다음의 설정을 사용하고 있습니다.

  • Yarn Workspaces를 통해 의존성 관리를 하고,
  • Ultra Runner를 통해 스크립트를 구성하고,
  • (NPM 패키지 배포가 필요하다면) Lerna를 사용한다.

다음 설정은 제 GitHub에 있는 Monorepo App Tony 또는 Stackflow를 통해 실제 동작하는 코드로 살펴보실 수 있습니다.

추천
[번역] SaaS 스타트업을 위한 보안 101
[번역] SaaS 스타트업을 위한 보안 101
2024년 5월
Internal Product Canvas
Internal Product Canvas
2024년 2월
UX시스템 실에서는 어떻게 일해야 하나요?
UX시스템 실에서는 어떻게 일해야 하나요?
2024년 2월
[번역] DORA 지표(DORA Metrics)를 계산하는 방법
[번역] DORA 지표(DORA Metrics)를 계산하는 방법
2023년 11월
목록으로 돌아가기
Loading script...
Designed by Tony