XionWCFM의 로고 이미지

모노레포에서 Internal Packages를 관리하는 3가지 방법

frontend
XionWCFM의 로고 이미지
유길종(XionWCFM)
2024.06.20. 11:34
모노레포는 이미 현대 프론트엔드에서 떼어놓고 이야기할 수 없는 존재가 된 것 같습니다. 이제 대부분의 메이저한 도구들의 Docs에서 모노레포에 대한 가이드라인이 작성되어 있는 것을 심심찮게 찾아볼 수 있으며 다들 모노레포가 가져다주는 장점에 대한 공감대는 형성되어있는 것 같다고할까요?
그런데 그럼에도 불구하고 모노레포의 지저분한 부분들을 다루는 아티클들은 찾아보기 힘들었습니다. 제가 능력이 부족해 못찾은 것일수도 있지만 저같은 경우엔 찾기가 힘들더라구요
그러던 중 터보레포가 메이저버전 2를 출시하면서 Docs도 새롭게 개편한 것을 알게되었는데 이 터보레포의 Docs에서 평소 고민하던 부분들에 대한 답을 시원하게 얻어가는 경험을 하게되어서 소개드립니다.

일반적인 형태의 모노레포 프로젝트 구성

-packages
-apps
가장 일반적으로 이루어지는 구성인 것 같습니다. 주로 여러개의 프로젝트에서 공통된 유틸리티 코드나 디자인 시스템들을 가져다 쓰기 위해 구조를 잡다보면 이러한 형태가 되는데요 여기에서 모노레포안에 위치하게되는 packages 폴더 내부의 프로젝트들을 Internal Packages라고 부릅니다.
흔히 npm과 같은 곳에 퍼블리시하는 패키지가 아니라 말그대로 모노레포 프로젝트 내부에서만 사용하는 패키지라는 의미에서 Internal Packages라고 부르는 것 같아요 저는 모노레포를 터보레포와 같은 추상화된 도구로 시작하지 않았다보니 Yarn Workspace 위에서 직접 설정들을 구성했던 경험이 있는데요
이렇게 구성을 하면서 가장 많이 고민을 했던 부분은 모노레포 내부에서 사용하는 패키지들의 빌드를 어떻게 처리할것인가 였습니다. 저같은 경우엔 당연히(?) NPM에 배포할때와 같이 내부 패키지도 빌드를 하고 외부에는 빌드된 결과물을 내보내야하지않을까? 라고 생각을 하면서 내부패키지에 대한 빌드를 열심히한 뒤 사용했던 기억이 있는데 하면서도 이게.. 최선인가? 했던 기억이 많이 납니다.
이제 모노레포 프로젝트에서 인터널패키지를 관리하는 3가지 방법을 알아봅시다.

Just-In Time Packages

Just-In Time 이란 용어는 꽤 친숙합니다. Just In Time 패키지는 패키지에서 빌드를 수행하는게 아니라 이 패키지를 사용하는 쪽에 빌드의 책임이 있습니다. 즉 제가 예전에 했던 방식과 같이 각 패키지를 빌드해서 결과물을 내보내는게 아니라 빌드되지 않은 파일을 그대로 내보낸다는 뜻입니다. 이렇게 JIT 방식으로 패키지를 구성하면 어떤 장점이 있을까요?

JIT 패키지의 장점

  1. 최소한의 Configuration으로 패키지 분할이 가능하다

JIT 패키지의 단점

  1. 해당 패키지를 사용하는 측이 트랜스파일링, 빌드를 수행해줘야한다.
  2. 빌드 결과물을 캐싱하는 게 불가능하다 ( 애초에 빌드를 안하니까요 )
  3. 다른 방식에 비해 빌드시간이 길어질 수 있다
장점은 하나지만 단점은 많아보이죠? 하지만 구성설정이 단순하고 간편하다는 장점은 때로는 모든 단점을 이길만큼의 가치를 지니기도합니다. 구성이 간편한덕에 프로젝트 초기에 오버엔지니어링을 피하면서 빠르게 기능 구현을 해나가야하는 경우 매우 좋은 전략이라고 생각해요 JIT 패키지를 구성설정하는 것은 너무나도 간단합니다. turborepo의 Internal Packages에 관한 문서에서는 이런 예제를 제공하고있어요
./packages/ui/package.json
{
  "name": "@repo/ui",
  "exports": {
    "./button": "./src/button.tsx", 
    "./card": "./src/card.tsx"
  },
  "scripts": {
    "lint": "eslint . --max-warnings 0", 
    "check-types": "tsc --noEmit"
  }
}
놀랍게도 이렇게 package.json에 간단한 설정을 추가해주는 것만으로도 Just-In Time Packages를 사용할 준비가 끝나게됩니다! 여기에서 주목할만한 부분은 exports 필드에 대한 정의입니다. npm 라이브러리를 배포해본 경험이 있으신 분들이라면 어느정도 익숙한 필드일것이라고 생각이 되는데요 이 exports 필드는 외부에 공개하는 진입점의 역할을 수행합니다.
즉 저렇게 @repo/ui 프로젝트의 src/button.tsx에 접근하기 위해서는 경로를 이렇게 명시하게 된다는 것입니다.
@repo/ui/button
import { Button } from "@repo/ui/button";
 
export default function Home() {
  return (
    <div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
      <Button appName="hello">hello</Button>
    </div>
  );
}
그래서 실제로 사용할 때에는 이러한 형태가 됩니다. 이렇게 exports 필드를 통하여 패키지의 진입점을 명시하는 것인데 여기서 또 눈에 띄는 점은 src 폴더 내부의 .tsx 파일을 직접 참조할 수 있게 진입점을 열어주었다는 점이에요
이것이 Just-In Time Packages에서 상당히 특이한 점 중 하나인데요 잘 알려진 프레임워크 중 하나인 next의 package.json을 확인해보면 다음과 같은 부분을 찾아볼 수 있습니다.
{
  "name": "next",
  "version": "14.2.4",
  "description": "The React Framework",
  "main": "./dist/server/next.js",
  "license": "MIT",
  "repository": "vercel/next.js",
  "bugs": "https://github.com/vercel/next.js/issues",
  "homepage": "https://nextjs.org",
  "types": "index.d.ts",
  ...중략
}
기본적인 진입점을 의미하는 "main" 필드에 주목하여 보면 ./dist 폴더안의 next.js를 진입점으로 하고 있는 것을 확인할 수 있어요 dist는 distributable(배포 가능한)의 약자로 일반적으로는 바로 사용할 수 있는 형태의 빌드 결과물들이 들어있는 폴더를 의미합니다.
이렇듯 배포되어 외부에 공개되고 있는 패키지들을 참고해보면 일반적으로 소스코드 자체를 내보내는게 아니라 소스코드를 빌드한 결과물을 내보냅니다. 하지만 우리가 보고있던 Just-In Time Packages 들은 이런 빌드결과물을 내보내는 대신 .tsx 파일들을 내보내고 있습니다.
따라서 이런 .tsx 파일들을 적절하게 사용하기 위해서는 사용하는 측에게 가져다 쓴 코드를 트랜스파일링하고, 번들링할 책임이 주어지게됩니다.

Compiled Packages

위에서 JIT Pacakges는 빌드를 하지 않고 바로 .tsx 파일들을 내보내는것을 확인했습니다. 반대로 Compiled Packages 는 일반적으로 떠올리는 빌드과정을 거친 결과물들을 내보내는 방식입니다. 제가 위에서 이야기했듯 맨 처음 모노레포를 알게된 뒤 사용한 방법이기도하고요!
이렇게 컴파일 패키지 방식을 사용하기 위해서는 JIT 방식에 비해 약간의 오버헤드가 발생합니다. 코드를 빌드할 책임이 외부 사용자에게서 패키지 내부로 넘어오기 때문인데요 즉 외부사용자에게 빌드된 결과물을 서빙해주어야한다는 책임이 생긴다는 뜻입니다. Compiled Packages를 구성하는 package.json도 한번 참고해볼까요?
{
  "name": "@repo/ui",
  "exports": {
    "./button": {
      "types": "./src/button.tsx", 
      "default": "./dist/button.js",
    },
    "./card": {
      "types": "./src/card.tsx", 
      "default": "./dist/card.js",
    }
  },
  "scripts": {
    "build": "tsc"
  }
}
위의 JIT 패키지 방식과 비교했을 때에 달라진점을 눈치채셨나요?
1. exports 필드에서 내보내는 파일이 ./dist 내부의 js 파일이 되었다.
2. scripts에 build 프로세스가 추가되었다.
즉 Compiled Packages를 이루는 가장 큰 핵심은 패키지에서 빌드를 수행하고 외부에는 빌드결과물을 공개한다는 점입니다. 이제 이런 방식의 장점과 단점을 정리해봅시다.

Compiled Packages의 장점

  1. 빌드 캐싱이 가능합니다.

Compiled Packages의 단점

  1. 더 많은 추가 Configuration이 필요합니다.
  2. 적절하게 패키지를 빌드하기 위해 필요한 지식들을 학습해야합니다.
  3. 빌드 순서에 대한 의존성이 생깁니다.
Compiled Packages의 가장 큰 장점은 캐싱이 가능해진다는 점입니다. 빌드를 한번 해두었다면 해당 패키지의 내용이 바뀌지 않는 한 빌드 결과물을 재사용해도 된다는 관점에서 접근하면 쉽습니다 따라서 프로젝트 규모가 커지면 커질수록 또 Packages의 성숙도가 크면 클수록(변경될 일이 없을수록) 장점이 커지는 방식입니다.
다만 적절하게 빌드를 하기 위해서는 추가적으로 필요한 지식들이 있으며 빌드 순서에 대한 의존성이 생기고 또 터보레포와 같이 추상화된 도구를 사용하지않는다면 빌드 캐싱을 위한 지식도 필요합니다.
저같은 경우엔 처음에 각 패키지간의 의존관계로 인해 빌드 순서에 의존성이 생기는 문제가 까다로웠던 경험이 있는데 일반적으로 마주하게되는 문제이다보니 터보레포같은 도구들은 이런 부분들을 잘 해결해서 제공하고 있는 것 같아요


또 Compiled Packages와 관련하여서는 약간 특이한점을 turborepo의 best practice 문서에서 찾아볼 수 있는데요 Internal Packages에 대한 컴파일 도구로 tsc 사용을 권장하며 번들러(webpack, rollup 등) 사용에 대해서는 그리 권장하지 않는다는 듯한 뉘앙스를 찾아볼 수 있습니다.
라이브러리를 통해 번들링을 하게되면 코드가 애플리케이션의 번들러에 포함되기 이전에 이미 minify, uglify 되어 디버깅이 어려워질 수 있고 해당 도구들을 학습해야하는 비용이 생긴다는 점을 문제로 드는 것 같아요 이런 측면에서 보면 vercel팀의 경우에는 간단한 구성과 설정이라는 가치를 굉장히 높이 평가하고 있다라는 생각도 듭니다.

Publishable Packages

일반적으로 npm , yarn과 같은 패키지매니저를 통해 다운로드받을 수 있게 publish된 패키지들을 의미합니다. Internal Packages라고해서 외부에 배포하면 안된다는 법도 없으니까요 다만 이러한 경우 외부에서 패키지를 다운로드하는 사용자들의 다양한 케이스를 고려하여 많은 구성설정을 해줘야할 수 있습니다.
또 패키지의 버저닝, 변경로그 등을 관리하기 위해 changesets과 같은 도구들의 사용법도 익히는게 좋죠 ( 여담으로 저는 처음 changesets을 접했을때 꽤나 헤맸던 기억이 있습니다.)

Publishable Packages의 장점

  1. 버저닝이 용이하다.

Publishable Packages의 단점

  1. 수많은 구성과 도구가 필요할 가능성이 높다.
Publishable Packages의 경우에는 조금 특수한 상황이 아니라면 추천드리고 싶진 않을 것 같습니다. 그럼에도 불구하고 고려해볼만한 상황이 어떤게 있을까 고민해보았을 때에는 이런 경우가 있을 것 같습니다.
1. 멀티레포에서 모노레포로 통합해나가는 과정에서 하나의 진실공급원을 준수하고 싶은 경우
2. 여러 모노레포에서 하나의 진실공급원으로 공유 패키지를 사용하고싶은 경우
3. 애플리케이션 모노레포와 분리하여 레포지토리의 코드크기를 줄이고싶은 경우
그 외에 더 유용한 상황이 있을 시 댓글로 알려주시면 반영하도록 하겠습니다

그럼 어떻게 모노레포에서 Internal Packages를 관리하면 좋을까요?

상황에 따라 다르다라는 무적답변을 제외하고 생각해본다면 저는 이렇게 관리할 것 같습니다.
- 프로젝트 초기에는 모든 패키지를 JIT 방식으로 관리한다. 
- 빌드시간에 대한 불편함이 생기면 그때 Compiled Packages로의 전환을 수행한다.
실제로 JIT 방식을 사용해보았을 때 Configuration의 간단함이 큰 장점으로 다가왔습니다. 빌드시간 자체도 코드베이스가 매우 커지기전까진 크게 체감이 안되는 수준이니 우선은 JIT를 적극활용할 것 같아요

참고문헌

마치며

이번에는 Turbo Repo의 Docs를 참고하며 모노레포에서 Internal Packages들을 다루는 3가지 방법을 알아보았습니다. 처음 모노레포를 접했을 때엔 이렇게 깔끔하게 정리된 개념과 문서를 찾아보기 힘들어 머릿속으로 열심히 분류하며 장단점을 고려했던 기억이 나는데요 세상 참 좋아진 것 같습니다(?)
그럼 이만 글 마치겠습니다. 읽어주셔서 감사합니다.
XionWCFM을 나타내는 캐릭터 이미지
유길종(XionWCFM)
무언가를 쉽게 설명 해낼 때 쾌감을 느끼는 사람입니다. 현재는 프론트엔드 개발자로 일하고 있습니다.