개요

팀에서 모노레포 도입이 필요했던 배경과 모노레포를 적용하면서 개선된 배포 전략에 대해 정리했습니다. 이미 모노레포 구성 방법은 많이 있으니, 방법보다는 적용하면서 개선된 점들을 위주로 작성하려 합니다.

겪었던 일들을 정리하다 보니 틀린 내용이 있을 수도 있습니다. 피드백은 환영입니다!

도입 배경

기존 멀티레포 구조의 아쉬운 점과 모노레포 도입으로 인해 어떻게 개선되었는지 살펴보겠습니다.

레파지토리 구성

먼저 기존의 레파지토리 구성을 살펴보자면,

  1. 거의 유사하지만 리전별 약간의 차이가 있는 3개의 서비스 ( KR, JP, Global )
  2. 콘텐츠 관리를 위한 백오피스 서비스 ( CMS )
  3. 1, 2 서비스에서 공통으로 package를 install하여 사용하는 모듈 N개 ( Common )

위 구조는 다음 그림과 같이 모두 각각 고유한 저장소를 가진 멀티레포 구조입니다.

문제 1. 공통 모듈 테스트

기존 구조에서 공통 모듈을 수정하고 테스트하기 위해 두 가지 방법이 있습니다.

방법 1: prerelease를 이용한 beta 버전 publish

  1. beta 버전 publish
  2. 각 서비스에서 해당 beta 버전 install
  3. beta 버전 unpublish

방법 2: npm link를 이용한 symlink 생성

  1. 공통 모듈에서 link 생성
  2. 각 서비스에 연결
  3. 테스트 완료 후 unlink

이 두 방법은 의미 없는 patch 버전을 계속해서 증가시키면서 테스트하는 것보다는 낫지만, 굉장히 번거로운 작업입니다. 특히 주요 서비스 레파지토리 N개에 모두 테스트를 한다면 같은 작업을 N번이나 수행해야 했습니다.

모노레포 개선점

모노레포를 사용하면 npm link를 이용해 symlink를 생성하지 않아도 같은 workspace 안에 있다면 자동으로 symlink를 생성해 연결합니다. 즉, symlink 생성, 연결, 해제의 과정을 거치지 않아도 된다는 뜻입니다. 공통 모듈들을 같은 workspace 안에 넣는 것만으로도 코드 수정 시 반영된 결과물을 곧바로 확인할 수 있습니다.

문제 2. 패키지 버전 관리

기능이 수정되면 버전을 올리고 publish 후 각 서비스 레파지토리에 적용해야 합니다. 이 과정을 저희는 GitHub Action workflow를 이용해 자동화하고 있습니다.

문제는 이런 workflow가 각 저장소마다 작성되어 있어야 하고, publish된 버전 관리와 각 서비스 레파지토리에 install해야 하는 불편함이 있습니다.

패키지 버전 관리 flow

  1. npm version patch
  2. npm publish
  3. 각 서비스에서 npm install@latest

모노레포 개선점

문제 1의 개선점과 유사합니다. symlink로 연결되었기 때문에 package.json 안에서 version을 이용해 해당 모듈을 적용할 필요가 없고 바로 사용하면 됩니다.

문제 3. 통일 되지 않은 개발 환경

흔히 devDependency에 설치되어 있는 패키지들을 이용해 팀끼리 개발 환경을 맞추곤 합니다. 대표적으로 eslint, prettier, typescript 등이 있을 것 같네요. 멀티레포 구조에선 이런 패키지들마다 버전이 상이해 개발 환경 차이로 코드 컨벤션 차이가 생길 수 있습니다.

가령 같은 eslint 버전을 사용하더라도 typescript 버전이 다르면 다르게 적용될 수도 있겠죠.

모노레포 개선점

모노레포를 사용하면 root의 package.json에서 devDependency를 공용으로 관리할 수 있습니다. plugin 같은 세부 세팅은 각 레파지토리에서 관리하면 동일한 eslint 버전으로 각각 다르게 개발 환경을 세팅할 수 있습니다.

package.json
1{
2 "devDependencies": {
3 "eslint": "^8.56.0",
4 "prettier": "^3.2.4",
5 "typescript": "^5.4.3",
6 "..."
7 }
8}

최종적으로 개선된 구조를 표현하면 다음과 같습니다.

배포 플로우

모노레포를 도입하면서 배포 플로우도 약간의 변화가 생겼습니다.

기존 멀티레포 구조에선 하나의 레파지토리에서 필요한 모듈들을 install하기 때문에 각 레파지토리에서 배포가 가능했습니다.

하지만 모노레포 구조에선 workspace가 정의되어 있는 root에서 시작해야 합니다. root에서 시작했기 때문에 유사한 A, B, C 서비스는 Dockerfile (혹은 배포 스크립트)도 하나로 관리가 가능해집니다.

개선점 1. 필요한 레파지토리만 COPY 하기

위의 구조는 A 서비스만을 배포하는데 root의 모든 서비스의 패키지를 설치하게 됩니다. B, C, CMS의 패키지는 필요 없죠. 다른 패키지를 설치하는 만큼 배포 시간도 더 길어지게 됩니다. 이를 해결하기 위해 필요한 레파지토리만 COPY 하면서 해결할 수 있습니다.

dockerfile
1# A service 배포
2COPY A ./A
3COPY packages ./packages
4COPY package*.json ./
5
6RUN npm ci
7RUN npm run build -w A

원격 서버에는 A 패키지와 Packages 패키지, 그리고 root의 package.json, package-lock.json만 가지고 있게 됩니다. 최종적으로 A 서비스를 배포한다면 다음과 같습니다.

CMS의 경우 다음과 같습니다.

dockerfile
1# CMS service 배포
2COPY CMS ./CMS
3COPY packages ./packages
4COPY package*.json ./
5
6RUN npm ci
7RUN npm run build -w CMS

개선점 2. 명시적으로 npm ci 하기

npm ci에 대해

필요한 레파지토리만 COPY하여 필요한 패키지만 설치했습니다. 여기서 주목할 점은 npm ci는 package-lock.json 기준으로 패키지를 설치합니다. root의 package-lock.json은 A, B, C, CMS의 패키지 정보를 모두 가지고 있는데, A 레파지토리만 COPY했기 때문에 B, C, CMS의 패키지는 설치되지 않습니다. ( 이 부분은 머리로는 이해가 되지만 내부 동작에 대해선 블랙박스로 남아있습니다. 🥲)

명시적으로 workspace 지정하기

npm ci의 workspace 옵션을 이용해 명시적으로 지정할 수 있습니다. 예를 들어 A 패키지를 배포할 때 필요한 패키지들만 workspace로 ci하고, root의 패키지도 include-workspace-root 옵션을 이용해 설치할 수 있습니다.

dockerfile
1# A Service 배포
2COPY A ./A
3COPY packages ./packages
4COPY package*.json ./
5
6RUN npm ci -w A -w packages -include-workspace-root=true
7RUN npm run build -w A

개선점 3. root의 package.json은 가볍게

root의 package.json을 가볍게 유지하는 것이 중요합니다. 필요한 패키지만 설치하는 것의 중요성을 강조하였는데, root에서 설치되는 것은 공통으로 사용하는 것이 아니라면 각 레파지토리에서 관리하여 효율적으로 배포할 수 있도록 해야 합니다.

아쉬운점

npm workspace의 no hoist 지원

root의 package.json을 가볍게 하면 좋다고 했는데요, 그와 별개로 패키지가 설치되면 root의 node_modules에 호이스팅 되는 것은 막을 수가 없습니다. 어떤 문제가 있었을까요?

예를 들어 A 패키지에 next 버전이 14.2.0 버전이 root에 호이스팅되어 있는 상태에서 버그가 있어 14.1.0으로 낮추려 할 때 하필 버전 간의 문제로 conflict가 난다면 14.1.0 버전은 root엔 적용되지만, A의 node_modules엔 14.2.0 버전이 설치되어 있습니다. 패키지 참조는 가장 가까운 node_modules를 참조하기 때문에 이를 위해 overrides를 이용해 강제로 맞춰주는 작업이 필요합니다.

이러한 문제는 특정 패키지만 호이스팅 되지 않도록 지정하는 no hoist 옵션으로 해소가 가능합니다. next는 root로 호이스팅을 하지 않고 필요한 A 레파지토리 안에서 관리하는 것이죠. 하지만 npm workspace는 이 옵션이 아직 지원하지 않습니다. yarn 같은 다른 도구는 지원하니 이용하면 될 것 같아요.

ref) https://github.com/npm/rfcs/issues/287

root package.json 변경사항. 각 서비스 테스트는?

저희 팀은 PR merge 하기 전 GitHub Action으로 build test를 진행합니다. 이때 root의 package.json이 업데이트되면 어떻게 할까요?

A, B, C, CMS에 공통으로 패키지가 적용되어 있기 때문에, 어떤 패키지가 설치, 업데이트되면 모든 서비스에 영향을 주게 됩니다. 때문에 모든 서비스의 build test를 진행해야 합니다. 이는 GitHub Action의 시간을 4배로 사용하게 됩니다. 따라서 root의 package.json을 최대한 가볍게 유지해 모든 workflow가 실행되지 않게끔 노력하고 있습니다.

참고

  1. npm workspace 공식문서
  2. npm ci 공식문서