훕치치 모노레포 도입기


최근 프로젝트 훕치치가 세 번째 대회를 성공적으로 끝마쳤습니다. V2 출시 후, 사용자들에게 받은 설문을 바탕으로 대대적인 UI/UX 수정을 진행한 덕에 누적 사용자 수 역시 큰 폭으로 상승했습니다. 이 과정에서 앞으로 더욱 효율적이고 편리하게 작업을 할 수 있도록 개발자 경험을 개선하는 데에도 심혈을 기울였는데요, 그러한 노력 중 하나가 바로 모노레포의 도입이었습니다.

오늘은 훕치치가 모노레포를 도입한 이유와, 그 과정에서 겪은 다양한 상황들에 대해 이야기해보겠습니다.

기존 훕치치의 문제점

훕치치의 서비스에는 매니저 사이드와 관객 사이드가 있습니다. 이는 각각 대회를 관리하는 학생회, 경기를 관람하는 일반 학생을 대상으로 하고 있습니다. 기존에는 서비스 검증을 위해 빠르게 MVP를 만들 필요가 있었고, 동시에 주어진 개발 시간이 부족했기 때문에 개발 환경에 대해 깊게 고민할 여유가 부족했습니다. 그래서 기존 아키텍처는 하나의 어플리케이션에 두 개의 분리되어야 할 페이지가 혼재되어 있는 형태였습니다. 하지만 프로젝트가 점점 고도화 되면서 각 페이지에 종속된 다양한 기능들이 생겨나기 시작했고, 이와 함께 수면 밑에 가라앉아 있던 문제가 드러나기 시작했습니다.

사용자 입장에서의 불편함

매니저 페이지와 관객 페이지는 URL 경로를 기준으로 구분되어 있었습니다.

https://훕치치.com # 관객용
https://훕치치.com/manager # 매니저용

이러한 구조로 인해 관객들이 매니저 페이지에 접근할 수 있는 가능성이 비교적 높다고 판단했습니다. 물론 매니저 페이지에서는 로그인 정보를 필요로 하기 때문에 실제로 경기 관리 기능을 이용할 수는 없습니다만, 아무런 기능을 이용할 수 없는 페이지에 진입하는 것 자체만으로 유저들에게 혼란을 줄 수 있었습니다.

프로젝트를 배포할 때에도 두 개의 사이드가 하나의 도메인으로 배포되고 있기 때문에, 빌드 파일의 크기가 불필요하게 커진다는 문제가 있었습니다. 이는 곧 사용자가 페이지에 진입 후 JS 파일을 다운로드 받는 시간에 영향을 미치는 것이고, 빈 화면을 마주하는 시간이 늘어날수록 사용자 경험은 저하될 수밖에 없었습니다.

개발자 입장에서의 불편함

두 개의 페이지에서 사용하는 여러 모듈이 하나의 프로젝트에 포함되어 있어 디렉토리 구조가 너무나도 복잡해지고 있었습니다. 예를 들어 ~/components 디렉토리 안에는 관객 페이지에서 사용하는 컴포넌트와 매니저 페이지에서 사용하는 컴포넌트가 동시에 포함되어 있었습니다. 결국 특정 모듈을 탐색할 때 굳이 확인하지 않아도 될 정보를 포함하여 탐색해야 한다는 불편함이 발생했습니다.

나아가, 두 페이지에서 사용하는 모듈 중에는 비슷한 기능을 담당하는 것들도 있었습니다. 이를 명확하게 구분하기 위해 모듈이 사용되는 위치를 정확하게 명시하자는 불필요한 컨벤션을 정하기도 했습니다.

// api/league.ts
getAllLeagues() { /* ... */ }
 
// api/manager/league.ts
getAllLeaguesOnManager() { /* ... */ }

모노레포를 도입한 이유

앞선 이유로 우리는 관객 도메인과 매니저 도메인을 구분하기로 결정합니다. 그리고 이 과정에서 멀티레포 구조와 모노레포 구조 사이에서 많은 고민을 하기도 했는데요, 결론적으로 우리 팀이 도입한 방식은 모노레포 방식입니다. 이러한 결정을 한 데에는 공용 파일 분리에 대한 필요성과 프론트엔드 팀의 작업 방식이 크게 작용했습니다.

공용 파일 분리

훕치치의 관객 페이지와 매니저 페이지는 각자 타겟으로 삼는 대상은 달랐지만, 결국 하나의 서비스라는 점에서 다양한 요소를 공유하고 있었습니다. 디자인 테마부터 시작해서 로고와 같은 아이콘, 그리고 작은 단위의 컴포넌트 등이 그 예시입니다. 그리고 기존에 문제 삼았던 최종 빌드 파일의 크기 증가를 해결하기 위해서는 이런 공유 파일들을 별도 패키지로 분리할 필요가 있었습니다. 만약 멀티레포 구조를 선택한다면 이들을 npm 라이브러리로 배포하여 관리하게 될텐데, 프론트엔드 팀의 작업 방식을 고려한다면 이는 적절한 방식이 아니라고 판단했습니다.

프론트엔드 작업 방식

훕치치의 프론트엔드 팀은 총 3명으로 구성되어 있습니다. 각 팀원들은 사전에 정해진 역할에 따라 작업하는 것이 아니라, 상황에 맞게 유동적으로 인력을 배치하는 방식으로 작업하고 있었습니다. 기존에는 모든 기능이 하나의 레포지토리에 포함되어 있었기 때문에 큰 문제가 없었습니다. 그러나 매니저 사이드, 관객 사이드, 공용 패키지 등을 분리하게 되면 작업 환경 전환이 빈번해질 것으로 예상되었고, 이에 따라 레포지토리 간의 이동이 비교적 자유로워야 한다고 생각했습니다. 이런 상황에서 만약 npm으로 패키지를 배포한다면 작업 환경에 따라 일일이 레포지토리를 전환해주어야 할 것이고, 의존 패키지의 버전이 업그레이드될 때마다 직접 설치하는 번거로움이 있을 것으로 예상했습니다.

모노레포 구조를 선택하면 이러한 문제들을 모두 해결할 수 있다고 판단했습니다. 하나의 레포지토리 내에서 독립된 프로젝트를 관리하기 때문에 작업 환경 전환이 용이합니다. 또한, 의존 패키지의 버전을 업그레이드할 때도 레포지토리를 이동할 필요 없이 한 번만 설치하면 되기 때문에 버전 관리가 훨씬 편리해질 것입니다.

새로운 공용 패키지를 추가하는 것이 아주 간편하고, 패키지를 생성할 때마다 ESLintTS Config 등의 설정을 새롭게 할 필요가 없다는 것도 큰 장점입니다. 이러한 사소한 개선들이 모여 개발자의 경험을 크게 향상시킬 것이라고 생각했습니다.

모노레포 도입

이런 의사 결정 과정을 거쳐, 우리는 모노레포를 프로젝트에 도입하기 위한 준비를 시작합니다. 이 과정에서 이용할 수 있는 다양한 도구가 있었는데요. 우리는 두 가지 선택지를 두고 고민했습니다.

1) Yarn Workspace

처음에는 Yarn Workspace를 이용하기로 결정했습니다. 기존 패키지 매니저로 Yarn Classic을 이용하고 있었기 때문에 마이그레이션에 드는 비용이 비교적 저렴할 것이라는 기대가 가장 큰 이유입니다. 모든 팀원이 모노레포에 대해 익숙하지 않은 상황에서 조금이라도 익숙한 기술을 사용하자는 의견은 그 무엇보다 강력하고 합리적이었습니다.

동시에 유령 의존성과 비효율적인 의존성 관리 등의 문제를 해결하기 위해 Yarn Berry를 도입하기로 결정했습니다. 팀원들 다수가 이미 해당 버전을 사용해본 경험이 있었기 때문에 큰 비용이 발생하지 않을 것이라 판단했고, node_modules를 하나의 .pnp.cjs 파일로 대체한 뒤 JavaScript Map 자료구조로 관리하기 때문에 Zero-Install 전략을 간편하게 이용할 수 있다는 점도 결정에 큰 부분을 차지했습니다.

그러나 의존성의 크기는 우리가 생각한 것 이상으로 비대했습니다. 하나당 약 100MB를 넘는 것도 있었습니다. 이를 그대로 원격 저장소에 올린다면 분명 Git에 지속적인 부하를 줄 것이라 생각했습니다. 그래서 enableGlobalCache 옵션을 활성화하여 의존성 캐시를 개인의 로컬에 저장하는 방법을 시도했습니다. 더이상 의존성 zip 파일을 Git에 올리지 않게 되어 부하를 줄일 수 있었지만, 새로운 문제에 봉착했습니다.

Yarn Global Cache

위 사진은 enableGlobalCache 옵션을 활성화한 뒤 저장한 의존성의 정보입니다. packageLocation의 값으로는 의존성이 저장된 위치가 표시되어 있는데, 로컬 저장소의 경로를 상대 경로로 탐색하고 있습니다. 의존성을 로컬 캐시에 저장하도록 설정해두었기 때문에 당연한 결과라고 볼 수 있습니다. 하지만 이것이 문제라고 판단한 이유는, 각 팀원이 프로젝트를 생성한 위치가 서로 다를 경우에 해당 경로는 틀린 경로가 될 것이고 결국 다시 패키지를 설치해줘야 하기 때문입니다. 이와 같은 흐름은 Zero-Install으로 볼 수 없다고 판단했습니다.

Zero-Install 없이 Yarn Berry + Yarn Workspace를 그대로 이용할 수도 있었지만, 그렇게 하지 않았습니다. 이와 관련해서는 아래 Turborepo 챕터에서 이어 설명하겠습니다.

2) Turborepo

다음을 고려한 방법은 Pnpm + Turborepo 조합입니다. 앞서 언급한 것처럼 Zero-Install 없이 Yarn Berry를 사용할 수도 있었지만, 어차피 매번 의존성을 설치해야 한다면 설치해야 하는 의존성의 수를 줄이는 것이 도움이 될 것이라 판단했습니다. 그런 점에서 Pnpm은 심볼링 링크를 이용하여 패키지를 저장하고 가상의 중복된 패키지 설치를 지양하기 때문에 설치 속도 뿐만 아니라 저장 공간을 절약하는 측면에서도 효율적이었습니다. 실제로 아래 이미지와 같이 프로젝트 내에서 사용하는 특정 의존성의 용량이 0인 것을 확인할 수 있었습니다.

Pnpm Hardlink Size

이에 더해 Turborepo를 도입하여 복잡한 스크립트 의존성을 단순화 할 수 있었습니다. 예를 들어 관객 페이지를 deploy 할 때 lint, test, build 등의 전처리 작업을 해야 한다면 다음과 같이 실제 실행할 스크립트만 작성할 수 있게 되었습니다.

// Before
"scripts": {
  "deploy": "pnpm lint --filter spectator && pnpm test --filter spectator && pnpm build --filter spectator && pnpm deploy --filter spectator"
}
 
// After
"scripts": {
  "deploy": "turbo deploy --filter spectator"
}

deploy 스크립트를 실행하기 전, 전처리 되어야 하는 스크립트는 아래와 같이 turbo.json에 작성하여 쉽게 의존성 파이프라인을 구성할 수 있습니다.

// turbo.json
"pipeline": {
  "build": {
    "dependsOn": ["^build"],
  },
  "deploy": {
    "dependsOn": ["lint", "test", "build"]
  },
  "build": {},
  "lint": {},
  "test": {},
}

맺음

어떤 기술을 선택하는 과정에서 더 많이 고민하고, 이를 바탕으로 더 적절한 선택을 하고자 노력하는 편입니다. 모든 코드는 시간이 지남에 따라 자연스럽게 레거시가 되어 간다지만, 기술의 발전이나 기획의 변경이 발생하지 않는다면 근거의 합당성은 변하지 않기 때문이죠. 그리고 이러한 선택은 대부분 제품의 완성도를 높이고 사용자들의 경험을 향상하기 위한 것들이라는 점에서, 신중하게 고민하는 시간은 곧 제품의 성장을 위한 투자이기도 합니다.

앞으로도 신중한 고민의 가치를 마음에 새기며 사용자들에게 좋은 경험을 제공하기 위해 노력하는 개발자가 되겠습니다. 긴 글 읽어주셔서 감사합니다.