Tech
모노레포 적용부터 yarn berry까지
2023. 03. 09
2022년 8월 경 frontend
플랫폼에서는 3~4분기에 진행해야 할 과제를 도출하기 위한 논의가 진행되었습니다. 이때 달성하고자 하는 목표는 크게 두 가지였습니다. 모노레포 적용부터 yarn berry까지
- 빠른 실행력을 갖추면서 높은 퀄리티의 결과물을 낼 수 있는 조직이 된다.
- 한 조직은 한 사람이 개발한 것 같은 제품을 만든다.
그리고 위 목표를 달성하기 위해서 해결해야 할 문제들을 각자 리스트업 한 후 취합했습니다. 해결해야 할 문제들 중 일부를 살펴보자면 다음과 같았습니다.
- 각 프로젝트 간 중복된 기능 구현
- npm에 배포했던 공통 eslint 프로젝트가 전혀 관리되지 않고, 각 프로젝트에서 독자적인 eslint 설정을 가져가고 있어서 스타일이 전부 다름
- 새로운 프로젝트를 진행할 때 다른 곳에서 고민했던 구간을 처음부터 다시 고민한다거나 만들었던 기능 및 컴포넌트를 다시 만든다거나 하는 낭비가 빈번하게 발생
- 프로젝트 생성에 대한 표준이 없어서 처음 환경 마련하는데 너무 많은 비용이 들어감
이를 취합한 결과를 토대로 플랫폼 프로젝트를 모노레포로 구성하는 것이 해결책이 될 수 있겠다는 가설을 세우게 되었습니다.
모노레포를 선택한 이유
화해 제품 그룹은 cross functional team
으로 조직이 구성되어 있습니다. 각 팀은 “밴드”라고 불리는 데, 모든 밴드가 각자의 레파지토리를 가진 채 운영되는 멀티레포
방식을 사용하고 있습니다. 각 밴드들은 독립성을 가지고 운영되어 프로젝트 운영 방식 및 생애 주기가 다릅니다. 기술적인 측면에서도 독립성이 보장되는 것이 중요하기 때문에 별도의 작업 영역을 구성하는 멀티레포
방식은 밴드에서 합리적으로 동작해 왔습니다.
독립적인 워크 스페이스를 구성하는 멀티레포
방식은 각 프로젝트의 완전한 독립성을 보장한다는 장점이 있지만 프론트엔드 플랫폼에서 해결하고자 하는 문제들에 대해서는 몇 가지 한계점으로 다가왔습니다. 멀티레포
방식을 사용하는 밴드 간의 독립성은 프론트엔드 플랫폼 구성원 사이에 만든 작업물이나 이슈 처리 결과 등에 대한 물리적인 공유를 막는 벽이 됩니다. 이러한 문제점은 동일한 이슈를 두 번 세 번 반복하게 만들어 빠른 실행력에 발목을 잡습니다.
물론 이 문제를 해결하기 위해서 meet-up
, 주간 회고
, 업무 공유
와 같은 공유 문화를 만들고 문서화하는 등의 훌륭한 도구들이 존재합니다. 하지만 이러한 간접적인 공유뿐만 아니라 만든 작업물을 직접적으로 공유해 곧바로 사용할 수 있다면 더 빠른 실행력을 가진 조직이 되리라 생각했습니다.
또 다른 문제로는 표준이 정착되지 않는다는 점입니다. 표준은 업무를 예측 가능하게 하고 빠른 커뮤니케이션을 돕습니다. 프론트엔드 플랫폼 역시 배포 전략이나 브랜치 표준 전략 등을 문서로 마련했지만 문서를 통한 권장은 제대로 동작하지 않았습니다. 이 때문에 표준들을 자동화되어 업무 프로세스에 녹아들도록 하는 것이 자연스럽게 표준을 정착시킬 수 있는 방법이라고 생각했습니다.
저희는 두 가지 문제점을 모노레포의 도입으로 해결할 수 있으리라 생각했습니다. 실제로 POC 과정에서도 여러 회사에서 비슷한 문제를 모노레포 도입으로 해결한 사례를 확인할 수 있었습니다. 대표적으로 구글의 사례를 살펴보면 모노레포의 이점이 다음과 같다고 말합니다.
- one of truth source인 통합 버전 관리
- 광범위한 코드 공유 및 재사용
- 단순화된 종속성 관리
- 원자적 변화
- 대규모 리팩토링
- 팀 간 협업
- 유연한 팀 경계 및 코드 소유권
- 코드 가시성과 명확한 트리 구조
이 중 특히 one of truth source인 통합 버전 관리, 광범위한 코드 공유 및 재사용, 팀 간 협업 등은 위에서 해결하려는 문제와 직접적으로 관련 있는 부분이라 생각했습니다.
모노레포 조직 구성 및 도구 선택
모노레포 POC를 진행하기 위해서 3~4분기 동안 책임지고 개발 및 검증할 조직을 fe-ops
라는 이름으로 새로 빌드업했습니다. fe-ops
는 서비스에 관련된 개발보다는 프론트엔드 플랫폼 내부에서 진행하는 업무들에 대한 비용을 최적화하는 조직입니다.
fe-ops
는 POC를 진행하기 앞서 프론트엔드 플랫폼 인원 모두와 1on1 인터뷰를 진행했습니다. 모노레포가 구축된다면 실제 사용 고객은 프론트엔드 플랫폼 구성원 분들이 될 것이기 때문입니다. 모노레포를 사용하는 모든 구성원 분들이 최고의 DX를 경험하실 수 있도록 각자 생각하는 모노레포에 대한 우려 사항 혹은 바람들을 먼저 들어보는 것이 중요했습니다.
그리고 1on1을 통해 다음과 같은 요구사항을 리스트업 했습니다. 이 의견들은 이후 모노레포가 정말 필요한지 다시 확인하고, 구현한다면 다양한 툴 중 어떤 도구를 선택해야 할지에 대한 기준이 되었습니다.
시간이 걸리더라도 완성도 있는 모노레포를 구축하는 것이 목표였기 때문에, 위 요구사항을 가능한 모두 충족하는 결과물을 만들기로 했습니다. 만약 불가능한 경우에는 분명히 합당한 이유가 필요하고, 기대 수준에 미치지 못할 정도라면 모노레포를 진행하지 않을 수도 있다고 계획을 설정했습니다.
위의 요구사항은 다시 몇 개의 그룹으로 나누었고 이를 바탕으로 POC를 진행했습니다.
branch 전략
- 각 패키지를 변경된 부분만 개별적으로 배포할 수 있다.
- 각 패키지 별 CI/CD 분리가 명확해야 한다.
- 프로젝트마다 자유로운 branch 전략을 사용할 수 있다.
- 프로젝트 별로 각자만의 버전을 가져갈 수 있다.
독립적인 환경, 버전, 라이브러리
- 프로젝트 생성과 의존성 추가가 쉽고 자유롭다.
- 의존성 버전 충돌 문제가 없다.
- 각 패키지별 환경변수를 개별적으로 관리할 수 있다.
- 각 패키지들이 각각 독립적 이어야 하고 서로 영향을 주면 안 된다.
IDE와 clone 등에 발생하는 disk space과 performance 이슈
- Monorepo를 로컬에서 clone 하고 IDE로 열었을 때 성능 문제없이 원활히 작업 가능해야 한다.
마이그레이션 충돌 이슈
- 멀티레포를 모노레포로 마이그레이션 하기 쉽다. (Side effect NO)
기타
- Monorepo 사용방식이 단순하고 가이드가 존재한다. (이왕이면 가이드가 최소한으로 존재해도 그냥 쉬움)
- 각 패키지 별 히스토리 파악이 쉬워야 한다.
branch 전략
각 밴드에서는 프로젝트별 독립적인 branch
전략을 사용하길 원했습니다. 프로젝트마다 배포 주기가 다르거나 운영 방식이 다르기 때문에 이에 맞춰 유연하게 동작해야 하기 때문입니다. 이러한 요구사항은 tag
배포로 해결할 수 있었습니다.
저희는 기존에 TBD
를 브랜치 전략 표준으로 사용하고 있었습니다. TBD
는 main
브랜치 하나만 영구적으로 존재하고 나머지 feature
브랜치는 필요시에 임시적으로 생성합니다. (TBD에 대한 더 자세한 설명은 여기를 참고해 주세요.) 다만 CI/CD
의 source artifact를 main
브랜치를 기준으로 이용하게 된다면 여러 프로젝트와 각 환경을 구분할 수 없게 되는 문제가 발생합니다. 이 문제는 태그 기반 배포를 이용하여 해결했습니다. 태그를 활용하면 각 프로젝트 및 환경을 아래와 같이 구분할 수 있습니다.
개발자는 배포가 필요하면 main
브랜치에 코드를 머지하고 해당 버전으로 tag를 만들어 본인이 원하는 버전을 독립적으로 배포할 수 있습니다. 혹은 main
브랜치 내에서 필요한 commit
으로도 tag 생성이 가능하기 때문에 유연한 배포가 가능합니다. 다만 매번 개발자가 이러한 표준을 지키면서 태그를 생성하는 것은 실수를 유발할 가능성이 높아 반드시 대화형 cli
와 같은 도구가 제공되어야 한다고 생각했습니다. 이렇게 branch
전략을 표준화함으로써 CI/CD
의 트리깅 방식 또한 통일할 수 있습니다. 이는 데브옵스팀이 표준화된 하나의 배포 전략을 관리할 수 있다는 장점도 있습니다.
독립적인 환경, 버전, 라이브러리
구성원 분들이 모노레포 도입 시에 가장 걱정하는 것 중 하나는 멀티레포를 사용할 때의 독립성을 상실해 발생하는 문제점이었습니다. 모노레포를 도입하면 제약이 발생하는 것은 피할 수 없는 진실입니다. 다만 행정 편의주의가 되어서는 안 된다는 조언이 많이 있었습니다. 이러한 이유로 표준화해야 할 것과 독립적이어야 할 것을 구분하는 것에 많은 공을 들였습니다. 그리고 이런 구분이 한 번에 결정되는 것이 아니라 지속적인 proposal
을 통해 개선되어야만 한다고 생각했습니다.
eslint 에 대한 표준화 제안
라이브러리의 버전은 가능하면 독립적으로 운영되길 원했습니다. 가령 react
혹은 nextjs
등의 라이브러리들은 버전 별 인터페이스가 다를 수 있습니다. 모노레포 운영이라는 목적으로 강제적으로 버전을 통일시킬 시에는 마이그레이션을 위한 많은 비용을 투자해야 하고 이 때문에 프로젝트들을 운영하는데 걸림돌이 될 수 있습니다. 이는 모노레포를 운영하는 데 있어서는 편리할 수 있어도 서비스를 위한 방식은 아니기에 최대한 독립성을 유지하는 방향을 모색했습니다.
하지만 반드시 버전을 통일할 수밖에 없는 경우도 있었습니다. 대표적으로 IDE와 관련된 라이브러리가 그러합니다. 저희는 모노레포를 구성하기 위해 여러 도구를 실험했고 이 중 패키지 매니저인 yarn berry
와 PnP mode
라는 기능을 검토했었습니다. PnP mode
는 독자적인 모듈 resoultion
방식 때문에 별도의 레이어 위에서 동작하는 것들이 많습니다. IDE에서 사용하는 typescript
혹은 eslint
서버 역시 yarn berry
의 레이어에서 동작해서 sdks
라는 별도의 의존성을 필요로 합니다. 다만 typescript
혹은 eslint
버전이 workspace
별로 동적으로 변하거나 적용되지는 않습니다. 관련 extension과 PnP 진영의 니즈가 있긴 하지만 아직까지는 표준화된 지원은 없었습니다.
이와 같은 경우에는 통일하는 선택지 밖에 찾지 못했습니다. 그 외 마찬가지 이유로 node
버전 정도를 제외하면 나머지 라이브러리들은 모두 프로젝트별 독립적인 버전 설치를 지원하도록 구성할 수 있다고 판단했습니다. typescript
와 eslint
의 설정 파일들은 계층 구조로 이루어진 확장 가능한 설정으로 제공하여 자유도를 제공했습니다. 계층 구조는 환경 별로 나눌 수 있었고, 각 프로젝트는 기본적으로 환경에 따라 이 설정을 사용합니다.
예를 들어 eslint
의 경우는 다음과 같은 설정 파일이 존재합니다.
만약 server side
환경에서만 동작하는 코드일 경우에는 base.js
를 확장해서 사용할 수 있습니다. 그게 아니라 react
혹은 nextjs
환경에서 사용하는 경우에는 각자의 환경에 맞게 확장해서 사용할 수 있습니다. 그 외 프로젝트별 엣지 케이스가 있을 경우에는 rules
설정을 사용자가 설정할 수 있습니다. 이때 공통 설정은 최소한의 표준만을 통해 결정하기로 했습니다. 가장 중요하다고 생각한 것은 이러한 설정들이 한 번에 결정되는 것이 아니라 proposal
을 통해 점진적으로 개선해 나가야 할 것이라는 점입니다.
그 밖에 환경 변수 등은 env-cmd
와 .env
파일을 통해서 프로젝트별로 독립적으로 운영될 수 있었습니다.
IDE와 clone 등에 발생하는 disk space과 performance 이슈
용량 문제는 모노레포 구조를 가져간다면 가장 일반적으로 나타나는 리스크입니다. 멀티레포 구조에서는 필요한 프로젝트만 설치하거나 배포하는 것이 당연한 일입니다. 하지만 모노레포는 모든 프로젝트가 하나의 저장소에서 유기적으로 관리되기 때문에 이것들을 나누기란 쉽지 않습니다. 그렇게 50~100개의 프로젝트가 하나의 저장소에 추가되고 나면 당연하게도 성능 문제로 이어질 수 있습니다.
구글의 경우에는 하나의 저장소에 80TB
에 육박하는 프로젝트를 관리하지만 remote
codespace
를 두어 로컬 머신에 설치 자체를 하지 않는다고 알려져 있습니다. 이러한 이유로 github에서 제공하는 codespace 같은 것이 고려될 수는 있었지만 비용이 발생하므로 당장 사용할 도구는 아니었습니다.
또 다른 방법으로는 git sparse checkout
기능을 이용하는 것입니다. git
2.25.0
버전부터 사용 가능한 이 기능은 모노레포와 같이 커다란 저장소에 퍼포먼스 향상을 위해 제공되는 기능입니다. git sparse
는 저장소 내에 있는 디렉토리 중 자신이 관심 있는 디렉토리만 제한하여 checkout
을 할 수 있습니다. 이 기능은 특히 모노레포 내의 마이크로서비스 구조에서 효과적으로 동작한다고 github
블로그에서 소개하고 있습니다.
출처 : bring-your-monorepo-down-to-size-with-sparse-checkout
가령 위와 같이 workspace
중에 client/android
디렉리만 사용하고 싶은 경우에 다음과 같은 간단한 명령어만으로 문제를 해결할 수 있습니다.
이처럼 git sparse checkout
을 사용하게 되면 어렵지 않게 요구사항을 충족할 수 있으므로 해당 방식을 적용하는 것만으로 프로젝트 용량 및 퍼포먼스 문제를 해결할 수 있으리라 생각합니다.
마이그레이션 충돌 이슈
기존에 사용 중인 yarn classic
으로 모노레포를 구성하는 것이 아니라 pnpm
혹은 yarn berry
방식으로 변경할 시에 걱정되는 부분은 “쉬운 마이그레이션”이었습니다. 특히 yarn berry
로 변경을 때 기존 node
방식과 다른 resolution
방식이 어떤 문제를 야기할지 한 치 앞도 예상이 되지 않았습니다.
이 때문에 모든 프로젝트를 리스트업 하고, 분량을 나눈 후 각자 POC를 진행하였습니다. 가장 빈번하게 발생하는 문제점은 ^(caret)
사용으로 인한 버전 충돌이었습니다. semver
를 표준으로 잘 지킨 라이브러리라면 이슈가 없어야 하겠지만 실제로는 지켜지기 어려웠나 봅니다. 많은 라이브러리들이 patch
버전이 변경되는 것만으로도 인터페이스가 변경되거나 하는 일이 빈번했습니다.
yarn.lock
파일을 제거하면 기존과 완전히 다른 의존성을 설치합니다.
이 문제를 해결하기 앞서 규칙을 하나 세웠는데 기존의 코드베이스를 변경하지 않겠다는 점입니다. 모노레포 도입으로 프로젝트에 사이드 이펙트가 발생해서는 안되고 프로젝트 운영 이외에 발생한 이슈라서 디버깅하기도 훨씬 어려울 것이라 판단했기 때문입니다. 의존성 충돌이 발생하면 기존의 yarn.lock
파일과 새로 생성된 lock
파일들을 비교해 가며 일일이 버전을 맞추어 주었습니다. 그렇게 만든 package.json
파일은 나중에 마이그레이션 할 때 사용할 수 있도록 별도로 관리하고 있습니다.
때로는 라이브러리 자체에 dependencies
의 버전이 맞지 않거나 명시적인 선언이 없어서 문제가 되는 경우도 있었습니다. 이러한 경우에는 직접 의존성 resoultion
을 교정할 수 있는 기능이 패키지 모듈에서 제공되어야만 했습니다. 이 경우에 yarn classic
, pnpm
, yarn berry
모두 지원하고 있어서 큰 어려움은 없었습니다.
마지막으로 yarn berry
의 PnP mode
를 사용하게 됐을 때 resolution
방식이 다르다 보니 의존성 이슈가 발생하지 않을까 걱정이 많았습니다. 하지만 저희 케이스의 경우에는 오래된 레거시 프로젝트를 포함하더라도 아주 드물게 발생했고 해결 가능한 문제들 뿐이었습니다. 그 외에도 모노레포를 더욱 편하게 사용하기 위한 방법을 마련해야 했는데 툴체인을 직접 개발하는 것으로 해소할 수 있을 것이라고 생각했습니다.
Yarn Berry
POC
를 거치면서 어떤 툴을 사용하더라도 모노레포를 구현하는 데는 어려움이 없을 것이라는 판단을 마쳤습니다. 최종적으로는 두 가지 선택지가 남았습니다.
- 패키지 매니저로써
pnpm workspace
과yarn berry
중 어느 것을 사용해야 할지에 대한 선택 - 모노레포 빌드 유틸리티인
turborepo
를 사용할지에 대한 선택
pnpm
은 요약하자면 적은 노력으로도 많은 이점을 누릴 수 있는 장점이 있다고 느꼈습니다. pnpm
은 모노레포가 가져야 할 기본적인 기능에 충실할 뿐만 아니라 symlink
를 활용한 의존성 최적화는 괜찮은 접근 방법이라고 생각했습니다. 여기에 turborepo
와의 궁합도 좋았는데, pnpm
의 기능들을 fully 하게 제공하고 있다는 내용이 공식 문서에 있는 것 또한 안정감을 주었습니다.
하지만 저희가 최종적으로 선택한 것은 yarn berry
였습니다. 당시 판단 근거는 바로 PnP mode
혹은 zero install
이라고 불리는 전략 때문이었습니다. 당시 모든 프로젝트를 모노레포로 이관하는 것이 전사 로드맵 일부였고 장기간 동안 리소스를 투자할 계획이었습니다. 비용이 발생하더라도 수준 높고 완벽한 모노레포를 구현하는 것에 더 초점이 맞춰져 있었습니다. 이런 이유로 혁신적인 퍼포먼스 개선을 위한 가능성에 투자했습니다. 하지만 막상 도입을 결정한 이후에 zero install
을 잘못 이해하고 있었음을 깨달았습니다.
Yarn PnP (zero install) 오해하지 않기
Yarn Berry
를 사용하면 자연스럽게 따라오는 키워드가 PnP
혹은 zero install
이라고 불리는 것입니다. zero install
은 이름 그대로 의존성 설치 과정을 생략해서 빌드 시에 퍼포먼스 향상에 도움을 주게 됩니다. 특히 모노레포는 의존성이 복잡하고 많기 때문에 zero install
의 도입으로 폭발적인 성능 향상을 도울 수 있을 것이라 생각했습니다.
하지만 POC를 거치고 실제 프로젝트를 도입한 결과 처음 기대 한 것과는 전혀 다른 결과를 보였습니다. zero install
그 자체로는 마법처럼 성능 향상에 도움을 주지 않았습니다. 저희는 PnP mode
를 오해하고 있었습니다. zero install
은 기술이라고 생각했지만 사실은 전략에 가깝습니다.
아래 테이블은 프로젝트에 필요한 의존성 설치를 완료하기까지 걸리는 시간을 나타냅니다.
action | cache | lockfile | node_modules | npm | pnpm | Yarn(nm) | Yarn PnP |
install | ✔ | ✔ | ✔ | 2.2s | 1.4s | 2.5s | n/a |
install | ✔ | ✔ | 10.3s | 5.2s | 7.9s | 1.7s | |
install | ✔ | 15.1s | 10.6s | 14.2s | 7.9s | ||
install | ✔ | ✔ | 2.8s | 2.9s | 8.7s | n/a | |
install | 56.3s | 20.5s | 21.2s | 40s | |||
install | ✔ | 17.3s | 17s | 14.3s | 32.9s | ||
install | ✔ | ✔ | 2.2s | 1.4s | 8.7s | n/a | |
install | ✔ | 2.8s | 10.9s | 15.4s | n/a | ||
update | n/a | n/a | n/a | 10.7s | 11.7s | 6.9s | 14.5s |
출처: pnpm 공식 문서에서 제공하는 benchmark 자료
benchmark에 사용된 의존성 pnpm에서 제공하는 예시 저장소에서 확인할 수 있습니다.
cache
가 없을 때는 빨간색으로, 있을 때는 초록 색으로 행을 구분했습니다. cache
가 없는 경우를 살펴보면 pnpm
에 비해 yarn (nm 혹은 nodemodule)
은 미세하게 느린 편입니다. 그에 비해 PnP
는 심한 경우 2배 이상 차이가 날 정도로 느린 편입니다. cache
가 있는 경우는 PnP
가 2배 이상 빠르거나, 설치 자체를 생략하여 n/a
인 경우도 있습니다. 위 벤치마크 예제는 단일 프로젝트이기 때문에 모노레포의 경우에는 훨씬 더 많은 차이를 만들어 낼 수 있습니다.
이 점을 고려했을 때 pnpm
을 쓰지 않는 선택을 해야 했다면 cache
전략을 잘 세워둔 PnP
가 좋은 선택지가 될 수도 있습니다. 그럼에도 불구하고 우리는 반드시 PnP
가 근본적으로 빠르지 않음을 인정해야 합니다. 또한 zero install
이라고 불리는 단어를 맹신하면 안 됩니다. 이는 단지 cache
전략일 뿐이며 cache
를 위해 필요한 장치와 리스크를 제대로 계산해야지만 옳은 방법으로 PnP
를 사용할 수 있게 됩니다.
다시금 말하지만 zero install
의 본질은 캐싱 전략입니다. 이는 yarn berry
의 전유물이 아니며 전혀 새로운 개념 또한 아닙니다. 가령 로컬 머신에서 node_modules
을 한 번 설치하고 나면 매번 node_modules
를 다시 설치하지 않는 것 역시 zero install
입니다.
github action
을 사용해 보신 분들은 다음과 같은 의존성에 대한 캐싱 전략을 사용한 경험이 있으실 것입니다.
위 예시는 npm install
이후에 생기는 package-lock.json
파일의 내용을 해싱한 후에 package-lock.json
파일의 내용을 해싱한 값과 node_modules
를 각각 key와 value로 저장합니다. 이후에 다른 PR의 package-lock.json
파일이 이전과 같다면 의존성이 변하지 않았다는 의미기 때문에 npm install
을 생략합니다. 그게 아니라 package-lock.json
이 변했다면 의존성이 달라졌다는 의미이므로 npm install
을 실행하고 캐시를 갱신합니다.
이처럼 build
가 실행될 머신에 dedicate storage
공간이 있고 캐시 갱신을 위한 key
값만 있으면 pnp
모드가 아니더라도 zero install
빌드가 가능합니다.
만일 모노레포
구조라면 어떨까요?
마찬가지로 package-lock.json
이 변경되지 않는 경우에 설치를 생략할 수 있습니다.
하지만 lockfile
은 프로젝트별로 존재하지 않기 때문에 하나의 의존성만 변경되더라도 모든 프로젝트에 대한 의존성을 새로 설치해야 합니다. 즉 캐시 히트율이 낮은 문제가 발생합니다. 뿐만 아니라 프로젝트가 늘어날수록 node_modules
의 크기가 증가하게 되는데 이는 빌드 머신의 disk space
비용이 계속 증가할 수 있음을 의미합니다.
이러한 문제를 해결하기 위해서는 다음과 같은 해결책을 제시할 수 있을 것입니다.
- 모든 프로젝트 대한 의존성을 새로 설치하지 않고 변경된 프로젝트의 의존성만 갱신하기 위해서
workspace
마다package-lock.json
역할을 대신할 캐시 키를 가지고 있으면 된다. node_modules
에 중복을 최적화하거나 압축하여 의존성 크기를 낮춘다.
이럴 때 yarn berry
의 PnP
를 사용하면 이러한 캐시 전략에 대한 한계를 어느 정도 개선할 수 있습니다.
Yarn PnP Mode
Yarn PnP
모드란 nodejs
의 resolution
방식 대신 yarn berry
만의 독자적인 resolution 방식을 사용함으로써 기존에 node_modules
를 통한 의존성이 가진 한계를 극복하기 위해 만들어졌습니다. 기존에 우리가 사용하고 있는 nodejs
의 resoultion
은 현재 경로부터 계속 상위 디렉토리로 이동, node_modules
경로를 추가하며 필요한 의존성을 찾을 때까지 탐색합니다.
이러한 의존성 탐색 방식은 비 효율적이었습니다. 먼저 원하는 의존성을 탐색하기 위해 매번 수많은 File I/O
를 발생시켜야만 했습니다.
또한 디렉토리는 기본적으로는 Tree
구조입니다. 이는 Linked List
의 탐색 특징을 가지고 있음을 의미하고 있습니다. 보통의 경우라면 O(N)
만큼 탐색해야 원하는 값을 얻을 수 있음을 의미합니다.
물론 OS 측면에서 특별한 자료구조의 사용이나 인덱싱을 함으로써 성능 최적화를 진행하겠지만 어쨌든 직접적인 해결책은 아니었습니다. 즉 두 문제가 복합적으로 작용하여 많은 탐색 비용을 들여 File I/O
연산을 한다는 비효율이 발생하게 됩니다. 이런 이유로 yarn PnP
모드에서는 이러한 방식 대신 의존성을 설치하는 시점에서 Dependencies Mapping Table
같은 것을 만들고 이후에 의존성을 참조해야 할 경우에는 이 Table
을 통해 참조할 위치를 얻어 냅니다.
Table
구조는 O(1)
의 성능을 가졌을 뿐만 아니라 JSON 데이터 타입을 사용하기 때문에 메모리 내의 연산이 이루어지게 되고 File I/O 보다 훨씬 좋은 성능을 보여줍니다. 여기다 PnP
모드는 설치한 의존성을 압축하여 .zip
파일로 관리할 수 있습니다. 이를 통해 disk space
의 효율 또한 가져가게 됩니다.
의존성 크기
node_modules | PnP |
---|---|
495.1MB | 285MB ( – 57.56% ) |
의존성 수
node_modules | PnP |
---|---|
56,328 items | 166,9 items ( – 97.03 %) |
다만 이러한 방식을 사용하기 위해서는 yarn
의 resolution
방식으로 기존 기능을 덮어써야 해서 반드시 yarn
의 context Layer
위에서 코드를 실행시켜야 합니다. 이처럼 설치 시에는 Dependencies Mapping Table
정보를 가지고 있는 .pnp.cjs
파일이나 mjs
모듈을 로드할 수 있는 .pnp.loader.mjs
와 같은 여러 파일들을 생성/수정해야 합니다. 뿐만 아니라 설치한 의존성은 압축하는 과정 또한 필요해서 결론적으로 설치 시간은 오히려 길어질 수밖에 없습니다. 그렇기 때문에 PnP
모드에서 잦은 설치는 독입니다. 매번 설치하는 프로세스를 반복하기보단 한 번 설치한 파일을 바탕으로 최대한 캐시 히트율이 높은 캐싱 전략을 찾아야만 합니다.
이처럼 PnP
모드를 사용하는 프로젝트의 경우에 설치한 의존성들을 npm registry
에서 다운로드하는 것은 굉장히 비효율적이라 github repository
같은 remote
에 의존성을 업로드하는 방식을 선택합니다.
이렇게 만든다 한들 여전히 zero install
이라고 부르기엔 무리가 있습니다. 의존성을 설치하는 영역이 npm registry
에서 github
로 바뀌었을 뿐 여전히 의존성을 설치한다는 사실 자체는 변함이 없습니다. 때문에 빌드 타임 개선 등을 고려해서 zero install
을 도입한다면 반드시 빌드할 머신에도 dedicate storage
를 보유하고 해당 파일들을 캐싱해야 원하는 성능을 끌어낼 수 있습니다. 만약 그렇지 않다면 의존성이 추가된 무거워진 codebase
를 clone
받고 OS 환경에 따라서 달리 설치되어야 할 바이너리 파일 등을 새로 업데이트하느라 Yarn PnP
의 장점을 100% 활용할 수 없습니다.
Game Changer는 따로 있었다.
이러한 사실을 뒤늦게 깨달았을 때 yarn berry
를 선택한 것에 대한 의구심이 생겨나기 시작했습니다. zero install
을 제대로 구현하기 위해서는 인프라의 개선이 필수적인데 빌드 머신을 위한 dedicate server
를 구축한다는 것이 상황상 어려웠기 때문입니다. 뿐만 아니라 git sparse checkout
을 이용하게 되면 필요한 코드만 clone
할 것이라 예상했지만 git objects
내에 필요한 코드들을 찾아 root tree
에 checkout update
만을 진행할 뿐이었습니다. 즉 쉽게 말하면 필요한 파일만 working directory
에 노출할 뿐이지 실제로 다운로드는 모든 objects
를 받는다는 점입니다. 이대로는 monorepo
의 사이즈가 커질수록 성능은 선형적으로 나빠질 것이 분명한 미래였습니다.
이런 이유로 “지금이라도 pnpm
이나 turborepo
를 사용해야 하나…” 몇 번이고 고민했습니다. 특히 turborepo
의 remote cache와 같은 기능은 saas
형태로 제공하고 있어 비용만 청구하면 손쉽게 괜찮은 결과를 만들어 줄 것 같았습니다.
이때 yarn berry
를 통해 여러 도구를 개발하면서 yarn plugins
라는 기능에 점점 익숙해졌는데, 제공하는 core api
를 잘 사용하면 이 문제를 완전히 다른 방식으로 해결할 수도 있겠다는 아이디어가 떠올랐습니다.
Yarn Release
yarn berry
가 제공하는 강력한 기능 중 하나는 yarn
의 api
를 직접 다루어 플러그인을 개발할 수 있다는 점입니다. 저희는 yarn plugins
과 yarn core api
를 통해 yarn release
라고 하는 배포 최적화를 위한 커스텀 플러그인을 개발했습니다.
저희가 yarn release
로 해결하고자 하는 문제는 다음과 같았습니다.
- 모노레포 내에 모든 의존성과 코드를 업로드하는 대신 빌드에 필요한 최소한의 파일만 빌드 머신에 업로드하고 싶다.
- 이때 모노레포 사용자들은 문서를 따라 배포를 진행하는 것이 아니라 모든 것이 자동화되어 버튼 하나로 모든 릴리즈가 끝났으면 좋겠다.
그리고 필요한 기능들을 Pseudo Code
로 정리했고 이는 다음과 같습니다.
그림으로 살펴보면 다음과 같습니다.
Yarn API와 Plugins
이때 배포에 필요한 workspace와 의존성들에 대한 정보들을 가져오는 것이 반드시 필요한데, .pnp.cjs
를 이용한다면 workspace
간의 정보와 필요한 dependencies
값들을 가져올 수 있습니다. 그렇다고 .pnp.cjs
를 직접 읽어서 정규화하는 작업을 할 필요는 없습니다. Yarn API
는 Project
와 Workspace
에 관련된 객체들을 제공하기 때문에 이를 활용하면 별도의 코드 관리 없이도 원하는 바를 이룰 수 있습니다. 이러한 API
는 yarn plugins
내부에서 사용하면 더욱 효과적입니다. yarn plugins
는 확장 가능한 모듈이며 손쉽게 yarn
의 생애주기에 hooks
를 만들거나 yarn core api
를 활용하기 위한 객체들을 제공합니다. 또한 node
혹은 ts-node
로 실행하는 유틸리티 스크립트 같은 경우 plugins
으로 개발했을 때도 다음과 같은 장점이 있습니다.
- 실행 경로가 전역이라서 어떤 패키지에서도 실행 가능함.
Project
정보를 가지고 있어서cwd
혹은 상대 경로를 사용하지 않고도root
경로를 쉽게 참조할 수 있음.- 여러
helper utility
때문에--help
옵션이나command option
그리고 대화형CLI
들을 쉽게 구현할 수 있음.
이러한 장점들을 활용하면 요구 사항에 해당하는 필요한 의존성만 가져오는 것을 비교적 쉽게 해결할 수 있을 뿐만 아니라, 좋은 사용성도 제공할 수 있게 됩니다. 필요한 의존성을 가져오기 위해서는 @yarnpkg/core
내부에 있는 Project
객체의 manifest
프로퍼티를 사용할 수 있습니다. 제공된 속성값을 이용하면 다음과 같이 필요한 의존성만을 가져올 수 있습니다.
아래 그림은 두 가지 상황에서의 예시입니다. 흰색으로 칠해진 파일들이 build 및 실행 시에 필요한 파일입니다.
A Project의 dependecies
B Project의 --proudction
환경의 dependencies (devDependencies 제외)
이렇게 의존성을 분리하고 나면 @yarnpkg/fslib
에서 제공하는 기능들을 통해 임시 공간을 할당하고 필요한 파일만으로 빌드 시 최적화된 의존성을 구성할 수 있게 됩니다. 물론 패키지 별로 core api
가 처리할 수 없는 예외 상황이나 여러 가지 옵션들이 필요할 수 있습니다. 이를 위해 @yarnpkg/cli
내의 기능들을 활용해 커스텀 플러그인을 위한 여러 설정들을 구성해 낼 수 있었습니다.
다만 Yarn API는 현재 문서화가 잘 이루어진 상태는 아닙니다. 때문에 작업하는 내내 라이브러리 내부를 직접 확인하며 기능을 파악해야만 했습니다. 이처럼 파악한 기능은 내부에서 별개로 문서화를 하는 방식으로 진행해 왔습니다.
전자는 Yarn API official documentation 후자는 내부 문서입니다.
Git Blob Diet
의존성을 분리하는 데는 성공했지만 이것만으로 원하는 수준의 개선이 이루어지진 않았습니다. 이유는 .git
내부는 여전히 Tree Shaking
을 하지 않았기 때문입니다. 이 문제를 완전히 해결하기 위해서 .git
내부를 재구성하는 스크립트를 작성했습니다.
Yarn release 결과
테스트한 모노레포에 포함된 프로젝트는 SSR
기반 React
프로젝트가 3개 포함되어 있습니다. yarn install
을 하여 의존성이 모두 설치된 상태이며 그 외에 .next
와 같은 빌드 파일 등 .gitignore
에 포함된 불필요한 파일들은 생략되었습니다. 이때 clone
을 위한 시간과 프로젝트 크기는 다음과 같습니다. (측정은 time
명령어를 이용했습니다.)
프로젝트 명 | clone 시간 (10회 평균) |
clone 시간 (최대값) |
clone 시간 (최소값) |
용량 |
---|---|---|---|---|
fe-monorepo | 61.025s | 62.512s | 60.997s | 1.71GB |
makeup-image-finder + monorepo-plugins | 46.009s | 46.609s | 45.803s | 717.7MB |
weather-check | 29.182s | 31.309s | 26.284s | 426.2MB |
hwahae-blog | 25.309s | 27.101s | 24.718s | 380MB |
다음은 yarn release
로 최적화를 한 후 clone
을 한 시간과 프로젝트 크기입니다. 모노레포를 그냥 clone
했을 때와의 차이를 시간 아래에 표기해 두었습니다.
프로젝트 명 | clone 시간 (10회 평균) |
clone 시간 (최대값) |
clone 시간 (최소값) |
용량 |
---|---|---|---|---|
makeup-image-finder | 16.270s [-73.38 %] |
16.432s [-73.71 %] |
16.107s [-73.59 %] |
326.1MB [-80.93%] |
weather-check | 12.282s [-79.85 %] |
12.650s [-79.76 %] |
11.913s [-80.46 %] |
275.4MB [-83.91%] |
hwahae-blog | 13.990s [-77.07 %] |
15.113s [-75.82 %] |
12.030s [-80.27 %] |
326.7MB [-80.89%] |
위에서 보듯이 yarn release
cli가 생기면서 빌드 머신에서 fe-monorepo
를 통째로 clone
하는 경우는 없어졌습니다. 멀티레포 때와 비교해봐도 각각의 프로젝트 역시 build
에 필요한 commit branch
만 clone
하기 때문에 yarn realase
를 사용함으로써 2배에서 3배 더 빠르게 clone
할 수 있었습니다.
용량도 마찬가지로 yarn release
명령어를 통해 fe-monorepo
를 통째로 clone
했을 때보다 5~6배 줄어들었습니다. 멀티레포와 비교했을 때도 production
에 최적화하여 불필요한 설정 파일이나 devDependencies
생략으로 1.5배에서 2배가량 줄어든 모습을 확인할 수 있었습니다.
추가적으로 이 수치는 일반적인 yarn classic + lerna
조합으로 생성한 모노레포에 비해 6배에서 8배 빠른 빌드 전처리(source download + dependencies install)를 보여주었습니다.
프로젝트 명 | classic + lerna | berry + release |
---|---|---|
makeup-image-finder + monorepo-plugins | 98.239s [-83.48%] |
16.27s |
weather-check | 98.239s [-87.54%] |
12.282s |
hwahae-blog | 98.239s [-85.75%] |
13.99s |
마무리
시간이 지나며 yarn plugins
와 @yarnpkg
을 활용하면 무엇이든 직접 만들어 해결할 수 있다는 가능성을 보게 됐습니다. 이는 장기적으로 개선하며 수준 높은 모노레포를 구성하겠다는 기존의 목표와 잘 맞아떨어졌습니다. 특정 라이브러리가 지원해 주는 기능만으로는 화해 프론트엔드 플랫폼만이 가지는 특수한 상황들을 다 커버하기 어렵습니다. 하지만 플러그인을 확장해 나가는 방식은 자원만 충분하다면 사실상 한계가 없다고 봐도 무방하다는 점에서 yarn berry
가 우리한테 맞는 도구라는 확신을 주었습니다. 실제로 저희는 yarn release
외에도 여러 플러그인을 쉽게 개발할 수 있었습니다.
- yarn docker build 대화형 도커 빌드로 FE 개발자도 쉽게 빌드할 수 있게 도와주는 플러그인
- yarn giff
git diff
결과를 여러 가지 포맷으로 출력해 주는 플러그인 - yarn docgen 각 프로젝트 내에 문서 파일들을 통합하여 스펙에 맞게 빌드해 주는 플러그인
그 외에도 앞으로 모노레포를 사용하면서 발생하는 다양한 개선사항을 피드백받고 로드맵에 추가하고 해결해 나갈 예정입니다.
특히 이런 도구들을 통해 기존에 문서로만 남아있던 표준들을 기능화할 수 있으리란 기대가 있습니다. 항상 권장을 바탕으로 하는 표준은 지켜지기 어렵기 때문입니다.
이처럼 이상적인 모노레포를 구축하기 위한 도구로 여러 방면에서 플러그인은 게임 체인저라는 생각을 하고 있습니다. 이제 yarn berry
를 통해서 모노레포 구현을 마쳤으니 다음번에는 실제 운영 사례를 가지고 돌아오는 날이 있었으면 좋겠네요. 이상으로 마무리하겠습니다. 긴 글 읽어 주셔서 감사합니다.
이 글이 마음에 드셨다면 프론트엔드 플랫폼의 다른 콘텐츠도 확인해보세요!