<Demopeu/>

모노레포 환경에서의 내부 패키지 컴파일 전략

모노레포 환경에서의 내부 패키지 컴파일 전략

devmonorepoturborepobuild-strategy

이번에 교회 홈페이지와 관리자 페이지를 만드는 프로젝트를 부탁 받아서 작업을 하던 중, 모노레포 환경에서의 내부 패키지 컴파일 전략에 대해 고민하게 되었다.

📌 외부 패키지 vs 내부 패키지, 무엇이 다른가?

일반적으로 사용하는 라이브러리(외부 패키지)와, 모노레포 안에서 apps가 가져다 쓰는 라이브러리(내부 패키지)는 소비 주체빌드 목적이 완전히 다르다.

비교 항목 내부 패키지 (Internal Package) 외부 패키지 (External Package)
소비 주체 워크스페이스 내의 특정 앱 (예: apps/web) 불특정 다수
실행 환경 소비하는 앱의 환경이 명확함 (Next.js 등) 사용자의 환경을 예측할 수 없음 (CRA, Vite, Node 등)
빌드 목표 Transpile Only (단순 변환)소스 코드 형태 유지, 디버깅 용이성 중시 Bundling (번들링)호환성 확보, 파일 하나로 압축, 코드 난독화
최적화 주체 **최종 앱(App)**이 번들링 할 때 처리함 라이브러리가 자체적으로 최적화해서 배포함
주요 도구 tsc (TypeScript Compiler) Rollup, Vite, Webpack

📌 왜 내부 패키지는 번들링(Bundling)을 하지 않는가?

위 표에서 볼 수 있듯이, 우리 프로젝트 내부에서만 사용하는 UI 패키지는 굳이 무거운 번들러(Webpack, Rollup 등)를 사용하여 코드를 하나로 뭉칠 필요가 없다.

  1. 이중 번들링(Double Bundling) 방지 어차피 라이브러리를 가져다 쓰는 Next.js(apps/web)가 강력한 번들러(Webpack/Turbopack)를 내장하고 있다. 라이브러리 단계에서 한 번 묶고, 앱 단계에서 또 묶는 것은 빌드 시간만 늘리는 비효율적인 과정임.

  2. 디버깅 용이성 tsc를 통해 트랜스파일링만 수행하면 폴더 구조가 그대로 유지(src/* -> dist/*) 됨. 덕분에 앱에서 에러가 발생했을 때, node_modules 내부의 난독화된 코드가 아닌 원본에 가까운 코드를 보며 디버깅하기 편함.

  3. 트리쉐이킹(Tree-shaking) 위임 최적화는 최종적으로 배포되는 **앱(App)**이 담당하는 것이 가장 효율적. 라이브러리는 가공되지 않은 상태(Raw Code)로 넘겨주고, 앱이 필요한 부분만 잘라내서 쓰도록 위임하는 것이 모던한 모노레포의 전략(Internal Package Pattern).

이에, Turborepo는 공식문서에서 3가지 컴파일 전략을 제시한다.

📌 Turborepo의 3가지 컴파일 전략

1. JIT 전략(Just-in-Time Packages)

package.json에서 exports 필드에 원본 경로를 지정

Source 전략이라고도 부르며, 소스파일을 그대로 export해서 이를 사용하는 apps의 빌드 과정에서 컴파일 및 번들링을 수행하는 방식이다. 말 그대로 필요한 순간에 번들러가 컴파일을 수행한다.별도의 빌드 출력물이 없어서 빌드 스크립트도 없고, 설정도 간소한 편.

현대적인 프레임워크(Next.js)들은 node_modules 안의 TS 코드도 트랜스파일할 수 있도록 지원하기 때문에 패키지 매니저의 workspace: 로 소스들을 연결. next.js의 경우 transpilePackages 옵션을 통해 워크스페이스 패키지를 트랜스파일 대상에 추가할 수 있다.

장점

  1. 설정 간소화: TS 컴파일러나 번들러를 설정할 필요 없음. 보통 Turborepo 공식 예제들도 단순함을 위해 기본적으로 이 방법 채택
  2. 실시간 개발 편의: 실시간으로 코드 변경 확인 가능
  3. IDE 및 타입 연동: IDE에서 해당 패키지의 함수나 타입 정의로 바로 이동 가능. 디버깅 하기 용이함.

단점

  1. 빌드 효율 저하: 해당 코드를 사용하는 모든 애플리케이션마다 중복으로 TS 컴파일 수행. 특히 Turborepo 캐싱이 안됨.
  2. 번들러 의존: app의 번들러를 의존하기 때문에 Node.js 환경 등에서 해석 못함.
  3. TypeScript 제약: TypeScript 컴파일러의 path alias 기능을 사용할 수 없음. TS는 자기 패키지 내부에서 컴파일된다고 가정해서 paths를 해석하는데 여기서는 소비자 쪽에서 컴파일해서 안됨. 다행히 TypeScript 5.4부터는 Subpath Imports 같은 대안을 사용할 수는 있지만, 번들 시점에서 오류가 보고 되므로 힘들다.
  4. 내부 종속성 오류가 보고 이슈: 타입 검사가 실패하는 이슈가 있는데 생각보다 잡기 어렵고 이는 Turborepo의 고질적 문제다.

2. 컴파일 전략(Compiled Packages)

package.json에서 exports 필드에 소스(.ts)와 산출물(.js)을 매핑

스스로의 소스 코드를 컴파일하여 JS 출력물을 생성(transpile)한 후 다른 패키지들이 그 결과물을 import하도록 하는 방식이다. Turborepo에서는 tsc를 사용하여 소스 코드를 컴파일하고, dist 폴더에 출력물을 생성한다.(tsc를 사용하라고 권장)

Turborepo는 workspace의 의존성 그래프를 파악해, 어떤 애플리케이션이 이 패키지를 의존하는지 알고 있으므로, 패키지가 변경되면 관련 애플리케이션을 다시 빌드하도록 하고, 변경되지 않으면 캐시된 결과로 빌드 생략도 가능.

장점

  1. 효율적인 빌드 및 캐싱: 공통 라이브러리를 한 번 빌드해 두면, 여러 애플리케이션에서 그 결과물을 공유하여 중복 컴파일을 줄이고 전체 빌드 시간을 단축시킬 수 있음.
  2. 광범위한 호환성: 브라우저 번들러가 아닌 환경에서도 사용 가능한 모듈이라 JS 실행 환경에서도 바로 import하여 사용 가능.
  3. 명확한 경계 및 안정성: 패키지별로 자체 컴파일 시에 오류를 발견할 수 있으므로, 문제 원인을 분리하기 쉽다.

단점

  1. 초기 설정과 구성의 복잡성: exports 필드에 소스/타입/출력 경로를 세심하게 명시해야만 함. 또한 번들러에 Tree Shaking 최적화를 돕기 위한 "sideEffects": false 설정 같은 것도 고민해야 함. Turborepo 문서에서도 TypeScript 컴파일러의 다양한 옵션과 패키지 최적화를 위한 추가 설정들이 관리 어려움 포인트라고 지적.
  2. 빌드 파이프라인 증가: 패키지당 별도 빌드/배포 단계가 생기므로, 모노레포 내 관리해야 할 스크립트와 워크플로우가 늘어남. Turborepo의 dev 파이프라인에서 패키지 build를 watch 모드로 걸어두는 식의 추가 작업이 필요.

3. 패키지 전략(Publishable packages)

컴파일 전략에 배포에 필요한 모든 것이 포함

컴파일된 파일에 .d.ts 선언 파일과 .map 소스맵, ESM용 .js 및 CJS용 .cjs 파일 등이 모두 구비.

장점

  1. 외부 배포 및 재사용: npm 패키지로서의 재사용성
  2. 최적화된 산출물: 출판용 패키지는 불특정 다수의 환경에서 동작해야 하므로, 필요에 따라 여러 포맷으로 출력하거나 폴리필을 포함하는 등 최적화
  3. 명확한 버전 관리: 내부 패키지라도 출판 형태를 취하면, 버전 넘버를 통해 변화 추적

단점

  1. 매우 높은 복잡도: 라이브러리 출판과 동일한 모든 고려사항을 충족해야 하므로, 설정과 관리의 부담이 가장 큼.
  2. 배포 오버헤드: npm에 실제로 배포하는 경우 지속적으로 버전을 올리고 퍼블리시하는 오버헤드
  3. 실용성 문제: 출판 가능한 패키지로 만든다고 해서 반드시 성능이 향상되는 것은 아님. 디버깅이 어렵고, 피드백 받기 힘듬.

📌 나의 선택: 컴파일 전략

Turborepo는 3가지 전략(JIT, Compiled, Publishable)을 제시하는데, 나는 컴파일 전략을 선택했다.

구체적으로는 tsc로 JS를 변환하고 tailwindcss CLI로 CSS를 별도 추출하는 방식이다. JIT가 더 간편해 보였지만, 다음 이유로 컴파일을 선택했다.

① 격리된 환경과 안정성

JIT 전략은 라이브러리 코드가 앱(apps/web)의 tsconfig나 번들러 설정에 종속된다. 라이브러리 자체는 문제가 없어도 앱 설정과 충돌하여 "로컬에선 되는데 배포하면 안 되는" 상황이 발생할 수 있다.

컴파일 전략은 라이브러리 단계에서 tsc로 오류를 먼저 걸러내고, 검증된 결과물(dist)만 앱으로 전달한다. 문제 발생 시 원인이 라이브러리인지 앱인지 명확하게 분리된다.

② shadcn/ui 스타일 캡슐화

이번 프로젝트는 shadcn/ui를 사용한다. shadcn은 컴포넌트뿐만 아니라 globals.css에 정의된 CSS 변수들이 반드시 필요하다.

소스 코드만 넘기면 라이브러리를 사용하는 모든 앱이 각자 CSS 파일에 shadcn 변수들을 복사해야 한다. 대신 패키지 빌드 시점에 CSS를 완성해서 내보내기로 했다.

// packages/ui/package.json
"scripts": {
  "build:styles": "tailwindcss -i ./src/styles.css -o ./dist/index.css"
}

소비하는 앱은 한 줄의 import로 스타일을 완벽하게 적용받는다.

// apps/web/src/app/layout.tsx
import "@repo/ui/styles.css";

③ Turborepo 캐싱 활용

JIT는 앱 빌드 시마다 패키지 코드도 매번 다시 읽어야 한다. 컴파일 전략은 결과물(dist)을 캐싱할 수 있다. UI 코드가 변경되지 않았다면 빌드를 건너뛰어(Cache Hit) CI/CD 속도를 최적화할 수 있다.

📌 트러블 슈팅: sideEffects

tsc는 번역만 할 뿐 최적화는 앱(Next.js)에게 위임한다. 이때 앱의 번들러가 "CSS 파일은 JS 변수로 안 쓰이네?"라고 판단하여 스타일을 삭제할 위험이 있다.

package.jsonsideEffects 설정을 추가하여 번들러에게 **"이 파일들은 부수 효과가 있으니 절대 지우지 마라"**고 명시한다.

{
  "sideEffects": ["**/*.css"]
}

📌 결론

모노레포 내부 패키지는 **"누가 소비하는가?"**를 고민하고 그에 맞는 빌드 전략을 세워야 한다.

나는 이번 프로젝트에서 안정성을 최우선으로 두었다.

  • 독립적인 빌드로 에러 차단
  • 스타일 캡슐화로 사용 편의성 확보
  • 캐싱으로 속도 최적화

"요즘 뜨는 도구"나 "설정이 편한 방법"보다, 프로젝트 특성에 맞는 선택이 안정적인 서비스를 만드는 첫걸음이다.

참고 자료