김맥스 블로그

모노레포를 지탱하는 툴링

모노레포에는 반드시 걸맞는 툴링이 필요합니다

최근 모노레포로 코드베이스를 관리하는 프랙티스가 각광받는다는 느낌을 많이 받습니다.

모노레포를 구성함으로써 얻을 수 있는 장점들은 널리 알려져 있습니다. 하지만 상대적으로 부각되지 않는 것이라면 모노레포의 장점을 십분 활용하기 위해서는 모노레포 구조가 가져오는 단점이 최소화되어야 한다는 사실입니다. 이러한 단점이 제대로 관리되지 못한다면 모노레포는 기존 레포지토리 관리 방식에 거대한 복잡도를 더하게 되고, 모노레포 도입으로 달성하고자 하는 것들에도 좋지 않은 영향을 줄 확률이 높습니다.

모노레포의 단점을 최소화하려면 떨어져있는 멀티레포들을 한 곳으로 모으는 일 이상의 무언가가 필요합니다. 그것이 바로 코드베이스 위에서 일어나는 여러 일들을 관리할 수 있게 하는, 모노레포에 알맞는 툴링입니다. 이 포스팅에서는 모노레포의 장점을 최대한 달성할 수 있게 코드베이스 툴링이 필요한 몇 개 지점들을 소개해보려고 합니다. 이를 위해 모노레포의 정의와 장점들을 먼저 간단히 소개해보려고도 하고요.

굳이 이야기하자면 이 포스팅에서 말하는 모노레포의 기준은 nodejs 패키지 기준입니다. 소개할 문제들이 특정 개발 생태계에서는 100% 들어맞지 않는 이야기일 수도 있을 것 같고, 빌드 툴 등이 자연스럽게 해결하는 문제도 있을 것 같아요.

어떤 것이 모노레포인가?

A monorepo is a single repository containing multiple distinct projects, with well-defined relationships. - Nrwl

모노레포 빌드 툴 중 하나인 Nx를 만드는 Nrwl 팀에서 정의한 모노레포의 의미가 간결하고 정확한 설명이라고 생각합니다.

모노레포를 이루는 개별 프로젝트(애플리케이션, 패키지)들은 독립적(distinct)이어야 합니다. 즉, 정상적인 구조의 모노레포라면 당장 모노레포를 폴리레포로 바꾸어도 개별 프로젝트들이 의존성 문제 없이 도로 다 분리될 수 있어야 합니다.

모노레포 위에서 각 패키지-애플리케이션, 혹은 패키지-패키지 관계에서의 의존성이 복잡하고 관리될 수 없는 형태라서 각 프로젝트들이 독립적인 배포가 힘들다면, 이는 단순히 프로젝트 여러개가 한 레포지토리에 모여있는 모놀리식과 다를게 없습니다. 이런 경우는 폴리레포로 관리하는 경우보다 못하게 특정 지점의 코드 변경이 독립적으로 배포돼야 하는 애플리케이션에 영향을 줄 위험이 큽니다. 또한 뒤에 설명할 모노레포 관리 도구를 실질적으로 적용하는데도 어려움이 생깁니다.

모노레포는 단순히 code-colocation이 아니기 때문에 각 프로젝트의 관계가 엄밀히, 잘 정의되어야 합니다(well-defined relationships). 모노레포의 프로젝트들이 패키지-패키지, 패키지-애플리케이션 관계를 이루고 있다면 의존 관계가 필연적으로 발생합니다. 이런 경우에도 각 프로젝트들은 의존 관계가 쉽게 추적될 수 있도록 패키지에는 버저닝이 필요하며, 특정 패키지 의존을 강제하거나 혹은 의존할 수 없도록 통제할 수 있는 도구가 필요합니다.

모노레포의 장점은 무엇인가?

모노레포 적용으로 얻으려고 하는 주된 장점은 크게 3가지 정도로 나눠볼 수 있습니다.

1. 거대한 프로젝트의 코드 관리 비용을 줄인다.

모노레포는 여러 프로젝트간 일관된 개발자 경험(DX)을 제공할 수 있는 기반이 됩니다. 폴리레포였다면 다 따로따로 세팅해줘야할 린트, 테스트, 빌드 도구들을 모노레포에서 관리하면 단일한 config로 단순하게 적용하고 관리할 수 있습니다.

원자적 커밋으로 여러 프로젝트의 핵심적인 변경을 쉽게 커밋할 수 있습니다. 코어 라이브러리에 대한 버전업이 필요할때 폴리레포였다면 각 레포지토리들을 순회하며 일일히 버전업 작업을 진행해야 했겠지만, 모노레포에서는 하나의 커밋으로 여러 프로젝트의 버전을 올릴 수 있습니다.

2. 팀과 작업간 경계를 허물어 유연하고 투명한 협업구조를 만든다.

모노레포에서는 팀이나 프로젝트의 인위적인 벽이 없고, 점진적인 리팩토링이나 코드베이스 변화가 비교적 자유롭습니다.

이는 엔지니어들의 일하는 방식에도 큰 영향을 미칩니다. 프로젝트 전체의 코드 변경을 한 레포지토리에서 PR로 확인할 수 있어 어떤 변경이 일어났는지 쉽게 확인이 가능하고, 코드리뷰도 여러 팀원들에게 받을 수 있습니다. 다른 엔지니어들이 하고 있는 일을 파악하기 쉬워지고 오너십과 무관하게 코드베이스의 개선을 쉽게 시작할 수 있게 됩니다.

3. 특정 패키지의 코드를 쉽게 확인하고 변경을 쉽게 적용할 수 있다.

내부 패키지 코드와 애플리케이션 코드가 한 곳에 있다면, 패키지단에 코드 변경을 쉽게 적용할 수 있고 이어서 애플리케이션에 해당 패키지를 적용했을 때 잘 작동하는지까지 단일한 코드베이스 위에서 검증할 수 있습니다.

폴리레포에서는 패키지를 수정했을 때 이를 배포하고, 애플리케이션 레포지토리로 옮겨 코드를 수정하고, 검증하고 빌드하는 과정이 순차적으로 이루어지기 때문에 모노레포 구조보다 더 번거롭습니다.

또한 패키지와 애플리케이션이 모두 한 레포지토리에 존재하기 때문에 특정 애플리케이션이 의존하는 패키지의 코드를 쉽게 탐색할 수 있습니다.

어떤 툴링이 필요할까?

이러한 장점들이 있음에도 모노레포에서는 하나의 큰 레포지토리가 필연적으로 가질 수 밖에 없는 복잡성, 코드 탐색의 난이도 상승, 분리되어 고립된 레포지토리에서는 신경쓸 필요가 없었던 새로운 단점들을 고려해야 합니다.

이러한 단점들을 적절한 툴링으로 해결해야 합니다.

단점 1) 레포지토리의 용량이 너무 크다

거대한 프로젝트들을 모노레포로 관리한다면 코드가 너무 많기 때문에 CI/CD 단에서 이루어지는 레포지토리 checkout 성능에 큰 부하가 발생합니다. 모노레포가 커지면 커질수록 checkout에 걸리는 시간은 선형적으로 증가할 수 밖에 없습니다.

따라서 CI, CD 머신에서 코드를 다운받는 시간을 최적화할 수 있는 방법이 필요합니다.

기본적으로 레포지토리를 full clone하게 되면 해당 레포지토리의 태그, 커밋, 파일, 히스토리를 전부 다 받게 됩니다. checkout을 수행하는 git 명령어를 이용해 checkout을 최적화할 수 있습니다. shallow clone을 사용해 특정 커밋 기준의 파일 형상만을 가져올수도 있고, 이전 변경 이력과의 변경이 필요하다면 partial clone을 이용해 파일은 최신 커밋 기준으로 가져오되 변경 이력을 함께 checkout할 수 있습니다.

# partial clone $ git clone [레포지토리_주소] --filter=blob:none # shallow clone $ git clone [레포지토리_주소] --depth 1

만약 특정 애플리케이션을 빌드해야할 때 필요한 폴더들을 쉽게 특정할 수 있다면, sparse checkout도 고려할 수 있습니다. sparse checkout은 앞서 설명했던 checkout 방법과는 달리 최신 커밋의 모든 디렉토리 형상을 가져오지 않고, 명령어에 명시된 디렉토리만 가져오니 다운로드 받을 파일의 용량을 크게 줄일 수 있습니다.

$ git sparse-checkout init --cone $ git sparse-checkout set [특정_디렉토리]

아주 기초적인 개념만 설명했는데, 경량 checkout에 대해 더 자세히 알고 싶으시다면 아래 아티클을 참고하세요!

단점 2) 모노레포는 의존성을 추가, 수정하기가 너무 쉬운 환경이다.

자율성은 고립에 의해 제공되고, 고립은 협업을 방해한다. - monorepo.tools

모노레포는 폴리레포 운영 환경보다 같은 레포지토리의 패키지와 의존 관계를 맺기가 더 쉬워집니다. 각 프로젝트가 고립된 폴리레포 환경을 벗어나면서 오는 효과이기도 합니다.

고립된 개별 레포지토리에서는 개별 팀이 코드의 의존 관계 등을 임의로 설정해도 물리적으로 떨어져있는 다른 프로젝트에 큰 영향을 미칠수 없었지만, 모노레포 환경에서는 쉽게 그러한 상황이 발생할 수 있습니다.

그래서 모노레포에서는 패키지 의존성과 참조를 감시할 수 있는 수단과 컨벤션이 필요합니다. yarn workspace를 사용한다면 Constraints을 사용해 패키지간 의존성 규칙을 제공할 수 있습니다.

제 생각에 가장 좋은 것은 패키지를 카테고리별로 구분하여 레이어를 구성하고, 상위 레이어의 패키지가 하위 레이어의 패키지와 의존하는 역방향 의존을 방지하는 것입니다. nodejs 기반 모노레포에서는 어떻게 해야하는지는 아직 잘 모르겠습니다만, 다른 개발 생태계에서는 멀티 모듈 프로젝트를 구성할 때 이러한 참조 규칙들을 정하곤 합니다.

잘못된 참조, 순환참조를 개발 과정, 혹은 CI 단에서 잡아야합니다. 이러한 장치가 없으면 의존성의 흐름을 제대로 파악하지 못하게 되고, 프로젝트의 복잡성이 잘못된 의존으로 인해 선형적으로 증가하는 것을 막을 수 없습니다. 모노레포 도구인 turborepo는 정의된 명령을 수행할때 순환참조가 발생한 패키지 의존 관계를 잡아줍니다.

turborepo 에러

패키지 의존성간의 관계를 시각적으로 보여줄 수 있는 툴이 있다면 유용합니다. turborepo에도 관련 기능이 존재하고 dependency cruiser와 같은 써드파티를 사용해서도 의존성 그림을 그릴 수 있습니다. turborepo는 아직 사용성이 좋지 못하고, 개인적으로 제일 만족감이 높았던 시각화는 Nx에서 제공하는 것이었는데요, 현재는 Nx로 프로젝트를 빌딩하지 않아도 Nx 커맨드를 사용하면 모노레포의 의존성을 시각화된 그래프로 표현해줍니다.

nx

단점 3) 특정 애플리케이션과 관련된 태스크를 수행할 때, 어떤 패키지의 태스크를 수행해야할지 판단하기 힘들다

패키지와 애플리케이션 코드가 모두 한 곳에 있는 모노레포에서는 특정 하위 패키지의 코드가 바뀌었을때 최종적으로 어떤 애플리케이션이 배포되어야 하는지 파악하기 힘들 수 있습니다. 따라서 해당 패키지에 의존하고 있는 애플리케이션을 모두 찾아 빌드와 배포를 진행할 수 있는 도구가 필요합니다.

turboreponx위상적 의존성(Topological Dependency)을 추적하여 특정 태스크들을 수행할 수 있습니다. turborepo를 예로 들면, turbo.json에 다음과 같이 설정해 놓은 후에

{ "pipeline": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] // 빌드 결과물 } } }

특정 애플리케이션을 빌드하게 되면 turborepo는 빌드할 애플리케이션에 의존하는, 코드 변경이 이루어진 하위 패키지부터 다시 빌드합니다.

$ turbo run build --filter=@apps/some-app

turborepo는 위상적 의존성을 바탕으로 의존하는 패키지를 모두 찾아 빌드를 다시 하는 시스템, Nx는 실제로 import된 것들을 기준으로 변경을 추적해나간다는 차이점이 있다고 알고 있습니다.

비단 빌드와 배포뿐 아니라, 테스트, CI 등의 동작들도 특정 애플리케이션이 배포되기 직전에 수행되어야 할 것입니다. 이때 해당 애플리케이션과 관련이 있는 패키지들만 해당 태스크를 수행할 수 있어야 합니다.

폴리레포에서는 패키지들이 다른 레포지토리에 격리되어있기 때문에 특정 패키지에 맞는 CI 파이프라인을 개별적으로 세팅하고 특정 레포지토리 위에서 작업이 수행되면 되니 세팅과 실행까지 별로 고려할 것이 없습니다. 하지만 모노레포에서는 패키지들이 격리되지 않고 한 곳에 모여있기 때문에 공용으로 사용하는 태스크 config를 세팅해놓고, 의도된 태스크만 수행할 수 있도록 하는 툴링이 필요해진 것입니다.

단점 4) 여러 패키지와 앱 간에 같은 태스크를 여러번 수행해야 할 수 있다

모노레포에서는 여러 프로젝트들에 대한 빌드, 린트, 테스트 수행들이 수시로 일어납니다. 각 프로젝트들은 모노레포 위에서 관계를 맺고 있으니, 중복되어 수행되는 태스크가 발생합니다.

turborepo의 경우 앞 단락에서 말씀드렸던 빌드 태스크들은 모두 캐시되어, 코드의 형상이 바뀌지 않았을 때 해당 패키지를 다시 빌드할 일이 생기는 경우, 캐시를 사용하여 아주 빠른 시간안에 태스크가 끝납니다. 비단 빌드뿐 아니라 turborepo위에서는 테스트 수행, 린팅과 같은 일련의 태스크들도 캐시됩니다.

리모트 캐싱을 사용하면 태스크 수행 후 발생한 캐시 파일들을 리모트 저장소에 두고 사용할 수 있는데요. 이를 활용하면 PR을 올리고 코드리뷰를 받는 사이에, CI단에서 빌드를 먼저 해두고 배포 파이프라인에서는 리모트 캐시를 그대로 가져와 빌드를 다시 수행하지 않는 등의 개발 사이클 개선도 가능해집니다.

맺는 말

Google은 2000년대 초반부터 전사 코드베이스를 모노레포로 관리하고 있는 것으로 유명합니다. Google의 모노레포 규모를 들어보면 저걸 어떻게 하나의 레포로 유지하지 싶을 정도로 규모가 크고 변경이 전 세계에서 아주 자주 발생하는데요. 모노레포가 주는 여러 장점들을 그대로 유지하기 위해 구글에서는 자체 개발한 여러 툴과 인프라를 적용하고 있습니다.

  • Piper: 대용량 코드 저장소
  • Critique: 코드 리뷰 툴
  • CodeSearch: 코드 검색 도구
  • Tricorder: 컨벤션 강제를 위한 adivse 툴
  • Rosie: 여러 오너십 영역에 걸친 Large Sclae Change를 관리하기 위한 툴

이러한 툴링 공수를 감수하면서도 구글이 모노레포를 오랜 기간동안 유지해왔던 것은 모노레포 구조에서 얻을 수 있는 장점이 꽤 크기 때문이겠죠. 반대로 말하면 이런 툴링이 없다면 모노레포가 제대로 동작하지 않을 것입니다.

모노레포 도입을 준비하시거나 운영하시는 분들께서도 현재 프로젝트에 알맞는 툴링을 구성하셔서, 성공적인 모노레포 운영 하시길 바라겠습니다!!

reference


Written by 김맥스