모노레포의 기술적 요구사항 (1) - Workspaces & Dependencies

2022. 8. 7. 15:14Frontend

 

Overview

모노레포의 문화적 의의를 다룬 이전 포스팅에 이어, 이번 포스팅 시리즈에서는 실제로 모노레포를 팀에 도입하기 위해 거쳐왔던 여러 기술적인 고려 사항들을 간단하게 이야기해보려고 합니다. "모노레포는 이렇게 운영하는 것이 좋다"라는 가이드라기보다는 프론트엔드 팀이 모노레포로 전환하는 과정에서 겪은 여러 문제들과, 이를 해결해 나가는 과정에 대한 기록에 가까울 것 같습니다. 글은 다음과 같은 순서로 작성되었습니다.

 

  1. Workspaces & Dependencies
  2. Global Lint & Prettier
  3. Deploy & Branch Management
  4. Plugin
  5. (Optional) Sparse Checkout

 

 

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

 

 

콴다 프론트엔드 팀에서는 모노레포를 사용하기 이전부터 npm 대신 yarn(yet another resource navigator)을 사용해서 패키지를 관리하고 있었습니다. yarn은 classic version인 1버전부터 모노레포에 대한 지원들을 조금씩 해왔었는데, 2버전(Berry) 이후부터는 이 지원이 대폭 확대되어 yarn에서 제공하는 기능만으로도 모노레포를 운영하는 데에 큰 문제가 없었습니다. 따라서 yarn의 공식문서를 참고해서 모노레포로의 이전을 진행하였고, 모노레포를 사용하고 있는 지금 현재, yarn workspaces를 사용해서 큰 문제없이 모노레포를 잘 운영하고 있습니다.

 

 

yarn workspace를 사용해서 모노레포를 구축하면 다음과 같은 디렉터리 구조가 생성되며, services는 실제 유저에게 오픈되는 프로덕트들, libraries는 design-system, latex-renderer와 같은 라이브러리 성 모듈들을 포함합니다. 자세한 내용들은 yarn의 공식문서를 참고해주세요.

 

...
├─ services
│    ....
│  ├─ service-a
│  └─ services-b
...
├─ libraries
│    ....
│  ├─ libraries-a
│  └─ libraries-b
└─ package.json

 

focus install #

모노레포 안에서 사용하는 서비스의 개수가 많아지면, 이에 비례해서 모노레포의 dependency가 늘어나게 됩니다. yarn berry에서 제공하는 Plug and Play 기능을 사용하지 않았다면 최초 install 시에, 혹은 CI / CD 환경에서 빌드 시에 많은 시간을 dependency를 fetch 하고 link 하는 데에 사용하게 되는데 하나의 서비스를 배포하기 위해 다른 서비스의 dependency까지 fetch 하는 리소스 낭비 현상을 방지하기 위해 yarn에서는 "focus install"이라는 기능을 제공합니다. 

 

 

This command will run an install as if the specified workspaces (and all other workspaces they depend on) were the only ones in the project. If no workspaces are explicitly listed, the active one will be assumed. - yarn

 

 

focus install을 사용하면 모노레포 환경에서도, 마치 레포에 해당 서비스 하나만 존재하는 것처럼 dependency install을 할 수 있게 됩니다. 따라서 모노레포의 전체 package.json의 dependency를 install 하는 yarn install 커맨드보다 시간을 아낄 수 있게 되며, 프로젝트를 테스트하거나, 프로덕션에 배포하기 위해 CI / CD환경에서 빌드할 때는, 이 focus install 커맨드를 사용해서 dependency resolution을 수행합니다.

 

// DOCKERFILE의 일부
# Install prod dependencies
RUN yarn workspaces focus @mathpresso/qanda-ai-user-web --production

nmHoistingLimits & defaultSemverRange #

기존의 프로젝트들을 모노레포로 이전할 때 가장 중요하게 생각한 요소 중의 하나는 "dependency resolution"이었습니다. 모노레포로 옮겨지는 서비스들이 모노레포에서 새롭게 생성되는 서비스들이 아닌 "기존에 배포되어 잘 사용하던" 서비스들이기 때문에 모노레포 이전 후에도 서비스가 정상적으로 작동하도록 만드는 것이 중요했습니다. 대부분의 경우에는 문제가 없었지만, 개발 서버 배포 후 테스트하는 과정에서 몇몇 애플리케이션들이 제대로 동작하지 않는 경우가 발생했고 확인한 결과, 이는 dependency resolution과 관련이 있었습니다.

 

 

npm package 들은 기본적으로 패키지들이 semantic versioning을 따를 것이라고 전제합니다. 게다가 모노레포 여부와 상관없이 기본적으로 yarn에서 제공하는 package install 방식(defaultSemverRange)은 caret(^)입니다. [caret(^) & tilde(~)에 대한 설명은 여기를 참고하세요] dependency가 caret으로 설치되어 있는 경우, lockfile이 없다면 기존 레포에서 설치되어 사용하고 있는 node_modules 속 패키지와 모노레포로 이전했을 때 사용하게 되는 node_modules 속 패키지의 버전이 다를 수 있다는 이야기이며, 사용하는 package가 semver의 조건들을 정확히 지키지 않았을 경우(e.g patch version을 올리면서 실제 코드상으로는 breaking changes를 넣었거나 하는 경우) 문제를 일으킬 수 있습니다. 

 

 

이해를 돕기 위해 간단한 예시를 하나 살펴보겠습니다. 아래는 테스트 레포를 하나 만들고 거기에 react만 ^18.0.0으로 설치했을 때의 lockfile입니다. 설치는 18.0.0으로 했지만, 이를 caret으로 설치했기 때문에 실제로는 최신 버전인 18.2.0이 설치된 것을 확인할 수 있습니다. react는 semver를 잘 지키는 라이브러리이기 때문에 18.0.0을 사용하던 서비스가 18.2.0을 사용하더라도 기존에 사용하던 기능들이 제대로 동작하지만, 이를 잘 지키지 않은 라이브러리를 caret으로 설치하는 경우 문제가 생길 수 있습니다.

 

 

react 18.0.0을 caret으로 설치하면 실제로는 최신 버전인 18.2.0을 사용한다.

 

 

가장 쉬운 방법은 기존 레포를 모노레포로 옮길 때 lockfile을 마이그레이션 하는 것입니다. 하지만 모노레포로 이전할 당시(그리고 글을 쓰는 지금 현재까지도) 모노레포 이전을 위해 lockfile을 마이그레이션 하는 옵션은 지원해주지 않고 있었습니다. 따라서 이를 해결하기 위해 아래의 2가지 방법을 사용하였습니다.

https://github.com/yarnpkg/yarn/issues/6563

 

Migrate lock file to workspace root · Issue #6563 · yarnpkg/yarn

I've migrated our monorepo from separate packages to Yarn workspaces with a single central yarn.lock file. During the migration, I did this for each package: Added it as a workspace in the root...

github.com

 

기존 레포의 caret을 제거하고 테스트한 후 마이그레이션 한다.

앞선 react 예제에서 나타난 문제를 해결하기 위해서 기존 레포의 package.json에 명시된 패키지들에서 caret을 떼는 작업을 진행했습니다. 무작정 caret만 제거하는 것이 아닌, lockfile을 확인하면서 package.json에 명시된 버전과 실제 설치된 버전이 다르다면 실제 설치된 버전으로 caret을 뗀 상태로 package.json을 업데이트해주었습니다. 가령, 위의 예시에서 ^18.0.0으로 설치되어 있었지만, 실제로 lockfile에 설치된 버전이 18.2.0이라면 다음과 같이 명시해주는 식입니다.

 

{
	"react": "18.2.0"
}

 

이렇게 모든 dependency를 업데이트 하고, 해당 레포에서 테스트를 진행한 후에 모노레포로 이전하였습니다. 그리고 모노레포에서 새로운 패키지를 추가할 때도, 명확한 버전을 사용하도록 가이드하기 위해서 당분간은 새로운 패키지를 추가할 때 caret / tilde를 모두 제거한 상태로 install이 되도록 yarnrc.yml에 defaultSemverRangePrefix를 ""으로 변경하였습니다.

 

모노레포의 nmHoistingLimit을 workspace로 변경한다.

또한 서로 다른 서비스들이 사용하고 있는 디펜던시들이 서로 꼬이는 것을 방지하기 위해 nmHoistingLimit을 workspace로 변경하였습니다. 이는, 모노레포 안의 서비스 A가 react@^18.0.0을 사용하고 서비스 B가 react@18.1.1을 사용한다고 하면, react가 각각의 workspace 안에서 설치되는 것을 의미합니다.

 

 

노드 모듈의 특성상 하나의 패키지는 또 다른 패키지에 의존하며, 직접 설치하는 서비스가 아닌 설치하는 서비스가 꼬리에 꼬리를 물고 설치하는 서비스들에서 caret과 tilde를 제거할 수는 없기 때문에(즉, react 자체를 caret과 tilde를 제거하고 명시적인 버전으로 설치할 수는 있지만, react가 의존하고 있는 loose-envify와 같은 package들은 react에서 설치하는 것이기 때문에 명시적인 버전으로 설치할 수 없다) node_modules이 hoisting 되는 scope를 기존 레포지토리에서 사용하는 것처럼 분리해서 서비스 별로 명확하게 의존성을 분리하는 것이 낫다는 결론이었습니다.

 

 

물론 처음부터 모든 프로젝트가 모노레포에서 개발을 시작할 수 있는 상황이라면 nmHoistingLimit을 처음부터 "none"으로 설정하여 가능한 한 Root로 패키지들이 hoisting되는 것도 고려해볼 수 있는 옵션이겠지만 기존에 사용하고 있던 많은 프로젝트들을 마이그레이션하는 상황에서 서비스의 안정성이 hoisting 보다 중요했기 때문에 이와 같이 설정했습니다.

 

 

Defines the highest point where packages can be hoisted. One of workspaces (don't hoist packages past the workspace that depends on them), dependencies (packages aren't hoisted past the direct dependencies for each workspace), or none (the default, packages are hoisted as much as possible). This setting can be overriden per workspace through the installConfig.hoistingLimits field. - yarn

 

 

// .yarnrc.yml의 일부
yarnPath: .yarn/releases/yarn-3.1.1.cjs
nmHoistingLimits: workspaces

nodeLinker: node-modules

 

이렇게 함으로써, 기존의 서비스들이 최대한 기존 환경을 유지하면서 모노레포 안에서 정상적으로 서비스될 수 있도록 하였고, 이와 동시에  모노레포 안에서 공통적으로 사용하는 react와 같은 패키지는 다른 서비스들이 사용하는 버전으로 하나씩 통일해가면서 추후 PnP Migration을 준비하고 있습니다.

 

Conclusion

처음부터 모노레포에서 프로젝트를 시작하는 경우에는 dependency resolution에 대한 부담이 덜할 수 있지만, 기존 프로젝트를 모노레포로 이전하는 경우, 특히나 dependency resolution에 많은 신경을 써야 했던 것 같습니다. 다음 포스팅에서는 모노레포를 사용해서 모든 프로젝트들의 lint / prettier 설정을 일괄적으로 관리하는 방법에 대해 살펴보도록 하겠습니다.

반응형