Tech

모노레포 적용부터 yarn berry까지

2023. 03. 09

 

 

 

2022년 8월 경 frontend 플랫폼에서는 3~4분기에 진행해야 할 과제를 도출하기 위한 논의가 진행되었습니다. 이때 달성하고자 하는 목표는 크게 두 가지였습니다. 모노레포 적용부터 yarn berry까지

  1. 빠른 실행력을 갖추면서 높은 퀄리티의 결과물을 낼 수 있는 조직이 된다.
  2. 한 조직은 한 사람이 개발한 것 같은 제품을 만든다.

 

그리고 위 목표를 달성하기 위해서 해결해야 할 문제들을 각자 리스트업 한 후 취합했습니다. 해결해야 할 문제들 중 일부를 살펴보자면 다음과 같았습니다.

  1. 각 프로젝트 간 중복된 기능 구현
  2. npm에 배포했던 공통 eslint 프로젝트가 전혀 관리되지 않고, 각 프로젝트에서 독자적인 eslint 설정을 가져가고 있어서 스타일이 전부 다름
  3. 새로운 프로젝트를 진행할 때 다른 곳에서 고민했던 구간을 처음부터 다시 고민한다거나 만들었던 기능 및 컴포넌트를 다시 만든다거나 하는 낭비가 빈번하게 발생
  4. 프로젝트 생성에 대한 표준이 없어서 처음 환경 마련하는데 너무 많은 비용이 들어감

 

이를 취합한 결과를 토대로 플랫폼 프로젝트를 모노레포로 구성하는 것이 해결책이 될 수 있겠다는 가설을 세우게 되었습니다.

 

 

 

 

 

모노레포를 선택한 이유

 

화해 제품 그룹은 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을 통해 다음과 같은 요구사항을 리스트업 했습니다. 이 의견들은 이후 모노레포가 정말 필요한지 다시 확인하고, 구현한다면 다양한 툴 중 어떤 도구를 선택해야 할지에 대한 기준이 되었습니다.

 

 

🔘 프로젝트 생성과 의존성 추가가 쉽고 자유롭다.
🔘 의존성 버전 충돌 문제가 없다.
🔘 각 패키지를 변경된 부분만 개별적으로 배포할 수 있다.
🔘 각 패키지별 환경변수를 개별적으로 관리할 수 있다.
🔘 Monorepo는 사용방식이 매우 단순하다.
🔘 Monorepo에서의 작업은 쉽기 때문에 최소한의 가이드만을 제공해도 충분하다.
🔘 멀티레포를 모노레포로 마이그레이션 하기 쉽다. (Side effect NO)
🔘 각 패키지들이 독립적 이어야 하고 서로 영향을 주면 안 된다.
🔘 각 패키지 별 히스토리 파악이 쉬워야 한다.
🔘 각 패키지 별 CI/CD 분리가 명확해야 한다.
🔘 Monorepo를 로컬에서 clone 하고 IDE로 열었을 때 성능 문제없이 원활히 작업 가능해야 한다.
🔘 프로젝트마다 자유로운 branch 전략을 사용할 수 있다.
🔘 프로젝트 별로 각자만의 버전을 가져갈 수 있다.

 

시간이 걸리더라도 완성도 있는 모노레포를 구축하는 것이 목표였기 때문에, 위 요구사항을 가능한 모두 충족하는 결과물을 만들기로 했습니다. 만약 불가능한 경우에는 분명히 합당한 이유가 필요하고, 기대 수준에 미치지 못할 정도라면 모노레포를 진행하지 않을 수도 있다고 계획을 설정했습니다.

 

위의 요구사항은 다시 몇 개의 그룹으로 나누었고 이를 바탕으로 POC를 진행했습니다.

 

 

branch 전략

  • 각 패키지를 변경된 부분만 개별적으로 배포할 수 있다.
  • 각 패키지 별 CI/CD 분리가 명확해야 한다.
  • 프로젝트마다 자유로운 branch 전략을 사용할 수 있다.
  • 프로젝트 별로 각자만의 버전을 가져갈 수 있다.

 

독립적인 환경, 버전, 라이브러리

  • 프로젝트 생성과 의존성 추가가 쉽고 자유롭다.
  • 의존성 버전 충돌 문제가 없다.
  • 각 패키지별 환경변수를 개별적으로 관리할 수 있다.
  • 각 패키지들이 각각 독립적 이어야 하고 서로 영향을 주면 안 된다.

 

IDE와 clone 등에 발생하는 disk space과 performance 이슈

  • Monorepo를 로컬에서 clone 하고 IDE로 열었을 때 성능 문제없이 원활히 작업 가능해야 한다.

 

마이그레이션 충돌 이슈

  • 멀티레포를 모노레포로 마이그레이션 하기 쉽다. (Side effect NO)

 

기타

  • Monorepo 사용방식이 단순하고 가이드가 존재한다. (이왕이면 가이드가 최소한으로 존재해도 그냥 쉬움)
  • 각 패키지 별 히스토리 파악이 쉬워야 한다.

 

 

branch 전략

각 밴드에서는 프로젝트별 독립적인 branch 전략을 사용하길 원했습니다. 프로젝트마다 배포 주기가 다르거나 운영 방식이 다르기 때문에 이에 맞춰 유연하게 동작해야 하기 때문입니다. 이러한 요구사항은 tag 배포로 해결할 수 있었습니다.

 

저희는 기존에 TBD를 브랜치 전략 표준으로 사용하고 있었습니다. TBDmain 브랜치 하나만 영구적으로 존재하고 나머지 feature 브랜치는 필요시에 임시적으로 생성합니다. (TBD에 대한 더 자세한 설명은 여기를 참고해 주세요.) 다만 CI/CD의 source artifact를 main 브랜치를 기준으로 이용하게 된다면 여러 프로젝트와 각 환경을 구분할 수 없게 되는 문제가 발생합니다. 이 문제는 태그 기반 배포를 이용하여 해결했습니다. 태그를 활용하면 각 프로젝트 및 환경을 아래와 같이 구분할 수 있습니다.

 

 

 

 

개발자는 배포가 필요하면 main 브랜치에 코드를 머지하고 해당 버전으로 tag를 만들어 본인이 원하는 버전을 독립적으로 배포할 수 있습니다. 혹은 main 브랜치 내에서 필요한 commit으로도 tag 생성이 가능하기 때문에 유연한 배포가 가능합니다. 다만 매번 개발자가 이러한 표준을 지키면서 태그를 생성하는 것은 실수를 유발할 가능성이 높아 반드시 대화형 cli와 같은 도구가 제공되어야 한다고 생각했습니다. 이렇게 branch 전략을 표준화함으로써 CI/CD의 트리깅 방식 또한 통일할 수 있습니다. 이는 데브옵스팀이 표준화된 하나의 배포 전략을 관리할 수 있다는 장점도 있습니다.

 

 

독립적인 환경, 버전, 라이브러리

구성원 분들이 모노레포 도입 시에 가장 걱정하는 것 중 하나는 멀티레포를 사용할 때의 독립성을 상실해 발생하는 문제점이었습니다. 모노레포를 도입하면 제약이 발생하는 것은 피할 수 없는 진실입니다. 다만 행정 편의주의가 되어서는 안 된다는 조언이 많이 있었습니다. 이러한 이유로 표준화해야 할 것과 독립적이어야 할 것을 구분하는 것에 많은 공을 들였습니다. 그리고 이런 구분이 한 번에 결정되는 것이 아니라 지속적인 proposal을 통해 개선되어야만 한다고 생각했습니다.

 

 

eslint 에 대한 표준화 제안

 

 

라이브러리의 버전은 가능하면 독립적으로 운영되길 원했습니다. 가령 react 혹은 nextjs 등의 라이브러리들은 버전 별 인터페이스가 다를 수 있습니다. 모노레포 운영이라는 목적으로 강제적으로 버전을 통일시킬 시에는 마이그레이션을 위한 많은 비용을 투자해야 하고 이 때문에 프로젝트들을 운영하는데 걸림돌이 될 수 있습니다. 이는 모노레포를 운영하는 데 있어서는 편리할 수 있어도 서비스를 위한 방식은 아니기에 최대한 독립성을 유지하는 방향을 모색했습니다.

 

하지만 반드시 버전을 통일할 수밖에 없는 경우도 있었습니다. 대표적으로 IDE와 관련된 라이브러리가 그러합니다. 저희는 모노레포를 구성하기 위해 여러 도구를 실험했고 이 중 패키지 매니저인 yarn berryPnP mode라는 기능을 검토했었습니다. PnP mode는 독자적인 모듈 resoultion 방식 때문에 별도의 레이어 위에서 동작하는 것들이 많습니다. IDE에서 사용하는 typescript 혹은 eslint 서버 역시 yarn berry의 레이어에서 동작해서 sdks라는 별도의 의존성을 필요로 합니다. 다만 typescript 혹은 eslint 버전이 workspace 별로 동적으로 변하거나 적용되지는 않습니다. 관련 extensionPnP 진영의 니즈가 있긴 하지만 아직까지는 표준화된 지원은 없었습니다.

 

이와 같은 경우에는 통일하는 선택지 밖에 찾지 못했습니다. 그 외 마찬가지 이유로 node 버전 정도를 제외하면 나머지 라이브러리들은 모두 프로젝트별 독립적인 버전 설치를 지원하도록 구성할 수 있다고 판단했습니다. typescripteslint의 설정 파일들은 계층 구조로 이루어진 확장 가능한 설정으로 제공하여 자유도를 제공했습니다. 계층 구조는 환경 별로 나눌 수 있었고, 각 프로젝트는 기본적으로 환경에 따라 이 설정을 사용합니다.

 

예를 들어 eslint의 경우는 다음과 같은 설정 파일이 존재합니다.

 

// eslint-config/base.js
module.exports = {
	extends: [
	    'airbnb/base',
	    'plugin:@typescript-eslint/recommended',
	    'plugin:prettier/recommended',
	],
... 가장 기본이 되는 설정들
}

// eslint-config/react.js
module.exports = {
  extends: [
    require.resolve('./base.js'), // base 확장
    'airbnb/rules/react',
    'airbnb/rules/react-a11y',
    'airbnb/rules/react-hooks',
  ],
... 리액트 설정들
}

// eslint-config/next.js
module.exports = {
  extends: [
		'next/core-web-vitals', 
		require.resolve('./react.js')], // react 확장
};

 

만약 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 set 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 berryPnP mode를 사용하게 됐을 때 resolution 방식이 다르다 보니 의존성 이슈가 발생하지 않을까 걱정이 많았습니다. 하지만 저희 케이스의 경우에는 오래된 레거시 프로젝트를 포함하더라도 아주 드물게 발생했고 해결 가능한 문제들 뿐이었습니다. 그 외에도 모노레포를 더욱 편하게 사용하기 위한 방법을 마련해야 했는데 툴체인을 직접 개발하는 것으로 해소할 수 있을 것이라고 생각했습니다.

 

 

 

 

Yarn Berry

 

POC를 거치면서 어떤 툴을 사용하더라도 모노레포를 구현하는 데는 어려움이 없을 것이라는 판단을 마쳤습니다. 최종적으로는 두 가지 선택지가 남았습니다.

  1. 패키지 매니저로써 pnpm workspaceyarn berry 중 어느 것을 사용해야 할지에 대한 선택
  2. 모노레포 빌드 유틸리티인 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을 사용해 보신 분들은 다음과 같은 의존성에 대한 캐싱 전략을 사용한 경험이 있으실 것입니다.

 

- name: Cache dependencies
        id: cache
        uses: actions/cache@v3
        with:
          path: '**/node_modules' # 캐시할 아티팩트 경로
          key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} # 캐시 키

- name: Install Dependencies
  if: steps.cache.outputs.cache-hit != 'true'
  run: npm install

 

위 예시는 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 빌드가 가능합니다.

 

만일 모노레포 구조라면 어떨까요?

 

root
|- node_modules
|- project1
	|_package.json
|- project2
	|_package.json
|- project3
	|_package.json
|- project4
	|_package.json
|- project5
	|_package.json
|_ package-lock.json

 

마찬가지로 package-lock.json이 변경되지 않는 경우에 설치를 생략할 수 있습니다.

 

하지만 lockfile은 프로젝트별로 존재하지 않기 때문에 하나의 의존성만 변경되더라도 모든 프로젝트에 대한 의존성을 새로 설치해야 합니다. 즉 캐시 히트율이 낮은 문제가 발생합니다. 뿐만 아니라 프로젝트가 늘어날수록 node_modules의 크기가 증가하게 되는데 이는 빌드 머신의 disk space 비용이 계속 증가할 수 있음을 의미합니다.

 

이러한 문제를 해결하기 위해서는 다음과 같은 해결책을 제시할 수 있을 것입니다.

  1. 모든 프로젝트 대한 의존성을 새로 설치하지 않고 변경된 프로젝트의 의존성만 갱신하기 위해서 workspace마다 package-lock.json 역할을 대신할 캐시 키를 가지고 있으면 된다.
  2. node_modules중복을 최적화하거나 압축하여 의존성 크기를 낮춘다.

 

이럴 때 yarn berryPnP를 사용하면 이러한 캐시 전략에 대한 한계를 어느 정도 개선할 수 있습니다.

 

 

 

 

Yarn PnP Mode

 

Yarn PnP 모드란 nodejsresolution 방식 대신 yarn berry만의 독자적인 resolution 방식을 사용함으로써 기존에 node_modules를 통한 의존성이 가진 한계를 극복하기 위해 만들어졌습니다. 기존에 우리가 사용하고 있는 nodejsresoultion은 현재 경로부터 계속 상위 디렉토리로 이동, node_modules 경로를 추가하며 필요한 의존성을 찾을 때까지 탐색합니다.

 

# nodejs resolution (https://nodejs.org/docs/v0.4.2/api/modules.html#all_Together)
NODE_MODULES_PATHS(START)
1. let PARTS = path split(START)
2. let ROOT = index of first instance of "node_modules" in PARTS, or 0
3. let I = count of PARTS - 1
4. let DIRS = []
5. while I > ROOT,
   a. if PARTS[I] = "node_modules" CONTINUE
   c. DIR = path join(PARTS[0 .. I] + "node_modules")
   b. DIRS = DIRS + DIR
6. return DIRS

 

이러한 의존성 탐색 방식은 비 효율적이었습니다. 먼저 원하는 의존성을 탐색하기 위해 매번 수많은 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 %)

 

 

다만 이러한 방식을 사용하기 위해서는 yarnresolution 방식으로 기존 기능을 덮어써야 해서 반드시 yarncontext 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를 보유하고 해당 파일들을 캐싱해야 원하는 성능을 끌어낼 수 있습니다. 만약 그렇지 않다면 의존성이 추가된 무거워진 codebaseclone 받고 OS 환경에 따라서 달리 설치되어야 할 바이너리 파일 등을 새로 업데이트하느라 Yarn PnP의 장점을 100% 활용할 수 없습니다.

 

 

 

 

Game Changer는 따로 있었다.

 

이러한 사실을 뒤늦게 깨달았을 때 yarn berry를 선택한 것에 대한 의구심이 생겨나기 시작했습니다. zero install을 제대로 구현하기 위해서는 인프라의 개선이 필수적인데 빌드 머신을 위한 dedicate server를 구축한다는 것이 상황상 어려웠기 때문입니다. 뿐만 아니라 git sparse checkout을 이용하게 되면 필요한 코드만 clone 할 것이라 예상했지만 git objects 내에 필요한 코드들을 찾아 root treecheckout update만을 진행할 뿐이었습니다. 즉 쉽게 말하면 필요한 파일만 working directory에 노출할 뿐이지 실제로 다운로드는 모든 objects를 받는다는 점입니다. 이대로는 monorepo의 사이즈가 커질수록 성능은 선형적으로 나빠질 것이 분명한 미래였습니다.

 

이런 이유로 “지금이라도 pnpm이나 turborepo를 사용해야 하나…” 몇 번이고 고민했습니다. 특히 turboreporemote cache와 같은 기능은 saas 형태로 제공하고 있어 비용만 청구하면 손쉽게 괜찮은 결과를 만들어 줄 것 같았습니다.

 

이때 yarn berry를 통해 여러 도구를 개발하면서 yarn plugins라는 기능에 점점 익숙해졌는데, 제공하는 core api를 잘 사용하면 이 문제를 완전히 다른 방식으로 해결할 수도 있겠다는 아이디어가 떠올랐습니다.

 

 

 

 

Yarn Release

 

yarn berry가 제공하는 강력한 기능 중 하나는 yarnapi를 직접 다루어 플러그인을 개발할 수 있다는 점입니다. 저희는 yarn pluginsyarn core api를 통해 yarn release라고 하는 배포 최적화를 위한 커스텀 플러그인을 개발했습니다.

 

저희가 yarn release로 해결하고자 하는 문제는 다음과 같았습니다.

  1. 모노레포 내에 모든 의존성과 코드를 업로드하는 대신 빌드에 필요한 최소한의 파일만 빌드 머신에 업로드하고 싶다.
  2. 이때 모노레포 사용자들은 문서를 따라 배포를 진행하는 것이 아니라 모든 것이 자동화되어 버튼 하나로 모든 릴리즈가 끝났으면 좋겠다.

 

그리고 필요한 기능들을 Pseudo Code로 정리했고 이는 다음과 같습니다.

 

# shell command 실행
> yarn release
> select workspace
> select environment

 

// javascript code
> 배포할 workspace 네임으로부터 workspace 정보를 가진 객체를 가져온다.
> workspace 객체가 의존하고 있는 다른 workspace 객체 Set를 가져온다.
> 필요한 workspace을 "워크 스페이스 Set"에 담는다.
	> workspace가 production 환경에 필요한 의존성을 "의존성 Set"에 담는다.
> 그 외 예외적으로 추가가 필요한 파일 및 폴더 경로는 "예외추가 Set"에 담는다.

> 임시폴더를 생성한다.
  > 임시 폴더에 세 개의 "Set"을 모두 복사한다.
  > 필수적인 파일들만 "git objects"로 남기고 "root tree"로 하는 새로운 "git refs"를 생성한다. 
		> 생성한 `refs`를 remote 공간에 push 한다.

 

// BUILD CI
> 생성된 "refs"를 clone 하여 빌드를 실행한다. 

 

그림으로 살펴보면 다음과 같습니다.

 

 

 

 

 

 

 

Yarn API와 Plugins

이때 배포에 필요한 workspace와 의존성들에 대한 정보들을 가져오는 것이 반드시 필요한데, .pnp.cjs를 이용한다면 workspace 간의 정보와 필요한 dependencies 값들을 가져올 수 있습니다. 그렇다고  .pnp.cjs를 직접 읽어서 정규화하는 작업을 할 필요는 없습니다. Yarn APIProjectWorkspace에 관련된 객체들을 제공하기 때문에 이를 활용하면 별도의 코드 관리 없이도 원하는 바를 이룰 수 있습니다. 이러한 APIyarn plugins 내부에서 사용하면 더욱 효과적입니다. yarn plugins는 확장 가능한 모듈이며 손쉽게 yarn의 생애주기에 hooks를 만들거나 yarn core api를 활용하기 위한 객체들을 제공합니다. 또한 node 혹은 ts-node로 실행하는 유틸리티 스크립트 같은 경우 plugins으로 개발했을 때도 다음과 같은 장점이 있습니다.

  1. 실행 경로가 전역이라서 어떤 패키지에서도 실행 가능함.
  2. Project 정보를 가지고 있어서 cwd 혹은 상대 경로를 사용하지 않고도 root 경로를 쉽게 참조할 수 있음.
  3. 여러 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 branchclone 하기 때문에 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를 통해서 모노레포 구현을 마쳤으니 다음번에는 실제 운영 사례를 가지고 돌아오는 날이 있었으면 좋겠네요. 이상으로 마무리하겠습니다. 긴 글 읽어 주셔서 감사합니다.

 

 


 

이 글이 마음에 드셨다면 프론트엔드 플랫폼의 다른 콘텐츠도 확인해보세요!

Higher-Order Components는 여전히 유용하다

Notion API와 함께 정적 페이지로의 여정

  • 모노레포
  • YarnPlugins
  • 프론트엔드
  • YarnBerry
avatar image

문지원 | Front-end Developer

안녕하세요. 프론트엔드 개발자 문지원입니다.

연관 아티클