모노리포 마이그레이션

멀티리포에서 모노리포로 전환한 이야기

Table of Contents

TL;DR

  • 모노리포는 코드 재사용, 개발 생산성, 협업 등 멀티리포 대비 여러 이점을 제공한다.
  • 여기서는 여러 프로젝트를 리포지토리 하나로 관리하는 모노리포로 마이그레이션 하는 과정을 다룬다.
  • 멀티리포에서 발생했던 문제 대부분이 해결되었으나, 새로이 고려해야 할 부분이 생겼다.

서론

모노리포란 여러 프로젝트를 한 리포지토리로 관리하는 방식을 말한다. 이는 코드 중복을 줄이고, 재사용하기 쉽게 만들어준다. 또한 프로젝트를 한 번에 빌드하고 배포할 수 있어 개발 생산성을 높여준다.

지금 다니는 회사는 멀티리포로 관리하고 있었다. 멀티리포는 모노리포와 반대되는 개념으로, 각 프로젝트를 서로 다른 리포지토리로 관리하는 방식이다. 이로 인해 공통으로 사용하는 코드를 재사용하기 어려웠다. 반복되는 구성도 번거로웠다.

그래서 나는 모노리포로 마이그레이션 하고자 했다. 다들 피로감을 느끼고 있었기 때문에 공감을 얻어내기는 쉬웠다. 계획도 어려움은 없었다. 그러나 모든 일이 그렇듯 생각만큼 잘되지는 않았다.

하지만 포기하고 싶지 않았다. 무엇보다 지금 코드베이스에서 일하기 정말 싫었다. 그러니 어쩌겠는가. 힘닿는 데까지 부딪혔다.

여기서는 모노리포를 도입한 배경과 과정을 이야기한다. 다만 이 내용이 모노리포를 도입하고자 하는 사람에게 도움이 될지는 미지수다. 상황이 각기 다르기 때문이다. 그러니 이 글은 단순한 회고로만 읽어주기 바란다.

왜 일을 벌였을까

chimp

특정 애플리케이션에서만 사용할 패키지가 있다고 하자. 상식적으로, 해당 패키지는 애플리케이션과 같은 리포지토리에서 관리해야 맞다. 그러나 우리는 이를 분리했다. 이유는 재사용과 생산성이었다. 특히 메인 애플리케이션은 HMR에 수 초 이상 소요되었기에, 이러한 제약에서 벗어나고자 했다. 당시에는 적절한 판단이라 생각했다.

이러다 보니 서로 다른 기술 스택을 가져버렸다. 가령 영상 편집을 위한 패키지는 Preact 기반 TypeScript + Vite 프로젝트였다. 디자인 시스템 패키지는 JavaScript와 Webpack 5를 사용했다. 메인 애플리케이션은 Vue 2 기반 JavaScript + Webpack 4 프로젝트였다. 따라서 "어떤 올바른 순서"로 빌드를 수행해야 한다. 이는 로컬 개발 서버 실행부터 프로덕션 배포까지 모든 과정을 복잡하고 번거롭게 만든다. 의존성이 복잡해져 충돌이 발생하기도 했다.

도커는 완벽한 해결책이 되지 못했다. 빌드만이 문제가 아니었다. 협업 시에도 정말 패키지가 최신 버전인지 항상 확인이 필요했다. 일일이 사람이 직접 확인해 주는 수밖에 없었다.

패키지 전달 방식도 부적절했다. 우리는 보통 Private NPM Package를 이용했다. 레거시한 경우 Git Submodule로 다른 패키지를 가져와 Link 하거나, 빌드된 파일을 직접 가져와 사용했다.

세 가지 방식 다 문제가 있다. NPM은 패키지를 업데이트하고 배포한 다음 이를 사용하는 모든 프로덕트에서 패키지를 업데이트해야 하므로 번거롭다. Git Submodule은 일일이 커밋 버전을 확인해야 하기에 실수할 가능성이 높다. 빌드 파일은 항상 최신 버전을 사용하면 된다지만, 빌드 결과물이 Git으로 관리되기에 협업이 어렵다. 무엇보다 모두 변경 사항 추적이 어렵다.

수년째 이러고 있었다. 무언가 잘못되었다는 생각이 들었다. 수준은 땅을 기지만 키보드로 밥벌하는 입장으로서 이러고 앉아 있을 수만은 없었다. 그래서 방법을 찾아보았고, 모노리포가 가장 이상적인 해결책이라는 결론을 얻었다.

모노리포 마이그레이션

과정 0 - 준비하기

리포지토리 구조를 변경하는 작업은 언제나 위험하다. 병렬적으로 수행되는 다른 작업에 미치는 영향을 차치하더라도, 얼마나 시간이 필요할지조차 모른다. 나는 리스크를 최소화하기 위해 이를 두 단계로 나누었다. 그리고 단계마다 팀원들에게 공유했다.

  1. 패키지 매니저 교체 & 모노리포 구성
  2. Turborepo 도입

과정을 진행하다 문제가 발생하면 Todo-List 형태로 기록해 두었다. 기록으로 남은 아이템만 40개가 넘는다. 이는 마이그레이션 과정이 얼마나 다사다난했는지를 보여준다. 대부분 첫 단계에서 발생했다.

과정 1 - 패키지 매니저 교체 & 모노리포 구성

Yarn Classic(v1)도 모노리포를 위한 패키지 매니저로서 사용할 수 있다. 그러나 Hoisting으로 인해 유령 디펜던시가 존재하거나 모듈 설치 시 퍼포먼스가 좋지 않다. 따라서 모노리포에서 사용하기에 적합하지 않다. 대신 PNPM을 사용했는데, 이유는 다음과 같다:

  • Yarn 최신 버전은 node_modules 대신 pnp를 사용하고 있다. 그런데 이는 node_modules를 사용하는 일부 툴에서 호환성 문제가 발생할 수 있다. 그래서 node_modules를 유지하는 PNPM이 더 적합하다고 판단했다.
  • PNPM은 Workspace를 통해 모노리포를 지원한다.
  • 함께 도입하고자 하는 Turborepo가 PNPM을 권장하고 있다.
  • PNPM은 원본 디펜던시가 존재하고 이를 심볼릭 링크로 연결하는 방식을 이용한다. 따라서 많은 디펜던시를 사용하는 모노리포에 적합하다.

교체 과정에서는 크게 두 가지 문제가 발생했다. Lock 파일과 Link 패키지.

Lock 파일 이전은 실패했다. pnpm import 명령으로 yarn.lock 파일을 pnpm-lock.yaml 파일로 변환할 수는 있다. 그러나 Lock 파일 구조가 달라서였을까? 유령 디펜던시와 Link해서 사용하던 디펜던시가 발목을 잡았다. 아예 yarn.lock 파일을 지우고 pnpm install 명령으로 pnpm-lock.yaml 파일을 생성해 보기도 했는데, 결과는 마찬가지. 이번에는 시맨틱 버전을 지키지 않는 디펜던시가 문제였다. 둘 다 빌드부터 실패했다.

다른 방법이 떠오르지 않았다. 그래서 처음부터 시작하기로 했다. 새로운 프로젝트를 만들고, 원본 애플리케이션 코드를 하나씩 옮겼다. 성공하면 전진하고, 실패하면 해결했다. 이를 모두 옮길 때까지 반복했다. 나쁘지 않은 방법이었다. 빌드도 잘 수행되고, 시간은 생각만큼 오래 걸리지 않았다. 하지만 애플리케이션 동작이 이상했다. Link해서 사용하던 패키지를 PNPM Workspace 패키지로 변경했는데, 이게 원인이었다.

link vs workspace link vs workspace

Link된 패키지는 node_modules에서 심볼릭 링크로 특정 디렉터리와 연결된다. 그리고 Yarn Classic은 디펜던시들이 Hoisting 되어, non-모노리포 환경에서 전이 디펜던시(Sub-dependency)를 포함한 모든 디펜던시가 루트에 설치된다. 따라서 기존에는 모두 Link된 패키지를 가리켜 정상 동작했다.

이와 비슷하게, Workspace도 원하는 디렉터리를 패키지처럼 사용할 수 있다. 다만 동작이 조금 다르다. Link는 디펜던시 버전을 신경 쓰지 않고 항상 해당 패키지를 사용한다. 하지만 Workspace는 패키지 버전이 다른 경우, NPM에서 알맞은 버전을 찾아 설치한다. 전이 디펜던시 버전은 내가 관리할 수 없기에, 이는 문제가 될 수 있다.

예를 하나 들어보자. Workspace 패키지로 foo@1.0.0을 만들었다. 다른 패키지에서 이를 사용하고자 한다면, foo: "1.0.0"을 디펜던시로 package.json 파일에 추가하면 된다. 그러나 만약 foo: "2.0.0"을 디펜던시로 추가한다면, PNPM은 Workspace 패키지가 아닌 NPM에서 foo@2.0.0을 찾아 사용하게 된다. 이 정도면 이름은 같지만 서로 다른 디펜던시라 봐도 무방하다. 동작이 이상해질 수밖에 없다.

그렇다고 Link로 회귀하고 싶지는 않았다. 나와 같은 상황을 마주한 사람은 없었을까? 검색해 보니 버전을 덮어쓰는 방법이 있다고 한다. PNPM에서 제공하는 package.json pnpm.overrides 필드다.

// package.json

{
  "pnpm": {
    "overrides": {
      "foo": "workspace:*"
    }
  }
}

pnpm.overrides 필드는 디펜던시 그래프 내 모든 곳에 적용된다. 따라서 모든 foo 패키지가 Workspace foo 패키지를 가리킨다. 이는 Lock 파일에서도 확인할 수 있다. 참고로 workspace: 프로토콜은 명시적으로 Workspace 패키지를 사용함을 의미한다. 즉, 버전을 상관하지 않고(*) foo Workspace 패키지를 사용하겠다는 말이다.

패키지 문제를 해결하고 나니 애플리케이션이 잘 동작한다. 솔직히 힘들었고 시간도 가장 많이 소요되었지만, 이제는 모두 해결되었다. 야호! 하지만 아직 끝나지는 않았다.

과정 2 - Turborepo 도입

우리는 곧 여러 애플리케이션과 패키지를 리포지토리에서 한 곳에서 관리할 계획이다. 캐시나 병렬 실행과 같은 최적화에 대한 필요성이 느껴졌다. 이왕이면 성능과 확장성 모두 잡을 수 있으면 좋겠다. 물론 구성이 어려우면 안 된다. 이 조건에 부합하는 툴이 하나 있다. 바로 Turborepo.

turbo vercel turbo

Turborepo는 Vercel에서 개발한 모노리포용 오픈소스 빌드 시스템이다. 빠르고 확장할 수 있는 모노리포를 지향하며, 빌드 캐싱, 병렬 실행, 증분 빌드 등 다양한 기능을 제공한다. 이를 이용해 빌드와 테스트 속도를 향상할 수 있다. 자세한 도입 방법은 Turborepo 문서를 참고.

Lerna나 Nx 같은 다른 선택지도 있었다. 하지만 Lerna는 기능이 아쉬웠고, Nx는 복잡했다. 다른 툴 역시 마음에 들어차지 않았다. 물론 케이스 바이 케이스. 다양한 요구사항들이 있을 테니, 만약 모노리포 빌드 시스템 도입을 고려한다면 https://monorepo.tools/ 를 참고하자. 모노리포 관리를 위한 다양한 툴을 소개하고 각각을 비교해 보여주고 있다.

아무튼. 모노리포 구성은 앞서 모두 끝냈으니, Turborepo 도입 자체는 어렵지 않았다. 팀원을 위한 가이드 문서도 작성해 줘야 했는데, 여기서는 Vite 번역 경험이 도움이 되었다. 다만 제공하는 기능 중 캐싱을 활용할 수 있으려면 CI 설정을 조금 손봐야 하는데, 변명이지만 여유가 없어 준비만 해 둔 상태다. 이와 함께 Remote Caching도 사용해보고 싶다. 둘 다 생산성에 도움이 될 것이다.

마이그레이션 이후, 마치며

next

문제를 해결하고자 시작한 마이그레이션이지만 처음에는 확신이 없었다. 그러나 지금은 아무도 이전 방식으로 돌아가고 싶어 하지 않는다. Git Submodules, 파편화된 PR, 도커, 패키지 전달, 변경 사항 추적 등 여럿 개선되었다. 필요하면 분리된 애플리케이션 개발 환경을 구축할 수 있기에, 개발 속도를 잃지도 않았다. 더 이상 불필요한 시간을 낭비하지 않아도 된다. 협업이 자연스레 더 잘 이루어졌다.

물론 긍정적인 면만 있지 않다. 미비한 패키지 분리 기준과 높아진 복잡도로 인한 가이드가 필요하다. Alias 설정이 잘못되었는지 다른 파일을 가리킬 때도 있다. 굳이 패키지로 만들지 않아도 될 부분까지 분리하기도 했다. 모두 배움이 부족한 탓이다.

그래서 아쉬움이 남는다. 경험 부족으로 시행착오가 많았다. 마이그레이션 이전에는 고려 대상이 아니었지만, 실제로는 고려해야 했던 부분도 있다. 특히 패키지 매니저 교체가 예상 밖이었다. 그렇게나 많은 문제가 발생할 줄이야. 패키지 매니저 교체와 모노리포 작업을 동시에 진행하지 말았어야 했다. Turborepo에서 제공하는 기능도 제대로 활용하지 못하고 있다.

그래도 더 나은 방향으로 나아가고 있다는 느낌이 든다. 다른 서비스에서 우리와 같은 스택을 사용하는 모습을 볼 때마다 더욱 그렇다. 애플리케이션 전반에 영향을 끼치는 변경 사항을 어떻게 해야 잘 관리할 수 있을지에 대한 감도 조금 잡았다. 앞으로도 이런 개선을 다양한 부분에서 진행해 볼 생각이다.