차는 그대로, 엔진만 바꾼다
자동차의 엔진을 교체한다고 상상해보자. 차체, 시트, 핸들, 타이어는 그대로인데 엔진만 바뀐다. 겉보기에는 같은 차인데 가속 성능이 확 달라지는 것이다. Nx 모노레포에서 패키지 매니저를 npm/yarn에서 Bun으로 교체하는 것이 정확히 이런 작업이다. 프로젝트 구조, 코드, 빌드 설정은 그대로인데, 의존성을 설치하고 스크립트를 실행하는 엔진만 바뀐다.
컬리(Kurly) 기술팀은 Nx 18에서 21까지 메이저 버전 3단계를 업그레이드하면서 동시에 Bun을 패키지 매니저로 도입했다. 이 글은 그 과정에서 발생한 실전 이슈와 해결 전략을 정리한 것이다.
Nx란 무엇인가
용어: Nx: Nrwl(현 Nx)이 만든 모노레포 빌드 시스템이다. 여러 프로젝트(앱, 라이브러리)를 하나의 저장소에서 관리하면서, 캐싱, 병렬 실행, 의존성 그래프 기반 태스크 오케스트레이션을 제공한다.
모노레포는 비유하면 한 지붕 아래 여러 가족이 사는 대가족 집과 같다. 각 가족(프로젝트)은 독립적으로 생활하지만, 주방(공통 라이브러리)과 세탁기(빌드 도구)는 공유한다. Nx는 이 대가족 집의 관리인 역할을 한다. 누가 무엇을 바꿨는지 파악하고, 영향받는 프로젝트만 골라서 빌드하고, 이전 빌드 결과를 캐싱해서 불필요한 작업을 건너뛴다.
Nx의 핵심 기능 세 가지를 정리하면 다음과 같다.
| 기능 | 설명 | 비유 |
|---|---|---|
| 캐싱 | 이전 빌드/테스트 결과를 저장해 재사용 | 시험 답안지를 복사해두고, 같은 문제가 나오면 다시 풀지 않는 것 |
| 태스크 오케스트레이션 | 의존 관계에 따라 빌드 순서를 자동 결정 | 요리 순서를 정하는 것 — 밥부터 안치면 반찬이 먼저 식는다 |
| 영향 분석 | 변경된 파일이 어떤 프로젝트에 영향을 주는지 계산 | 배관이 터졌을 때 어떤 방에 물이 새는지 파악하는 것 |
Bun을 Nx에 도입하려는 이유
Bun은 Jarred Sumner가 만든 올인원 JavaScript 런타임이다. Node.js를 대체할 수 있으며, 패키지 매니저, 번들러, 테스트 러너까지 내장하고 있다. 컬리가 Bun을 도입하려 한 이유는 명확하다.
1. 설치 속도
Bun의 패키지 설치 속도는 npm 대비 최대 25배, yarn 대비 최대 5배 빠르다. 모노레포에서 수백 개의 패키지를 관리하면, npm install 한 번에 3~5분이 걸리는 것이 일상이다. Bun은 이것을 30초 이내로 줄여준다. CI/CD 파이프라인에서 매일 수십 번 돌아가는 설치 시간이 이렇게 줄어들면, 연간 수백 시간의 빌드 시간을 절약할 수 있다.
2. CI 시간 단축
설치 속도 개선은 CI 파이프라인 전체에 파급 효과를 낸다. 컬리의 경우 모노레포의 PR 하나당 CI 실행 시간이 평균 12분이었는데, Bun 도입 후 7~8분으로 줄었다. 하루에 수십 개의 PR이 올라오는 팀에서 PR당 4분 절약은 체감이 크다.
3. 네이티브 번들러
Bun은 자체 번들러를 내장하고 있다. 별도의 webpack이나 esbuild 설정 없이도 빌드가 가능하다. 다만 Nx 환경에서는 기존 빌드 파이프라인과의 호환성 문제가 있어, 번들러까지 Bun으로 교체하는 것은 별도의 단계로 진행해야 한다.
Bun이 Nx에서 공식 지원되기 전의 문제
컬리가 마이그레이션을 시작한 시점(Nx 18)에서는 Bun이 Nx의 공식 패키지 매니저로 지원되지 않았다. 이것이 핵심적인 어려움이었다.
Nx는 패키지 매니저를 감지해서 lock 파일 생성, 의존성 해석, 워크스페이스 구조를 자동으로 처리한다. npm은 package-lock.json, yarn은 yarn.lock, pnpm은 pnpm-lock.yaml을 사용한다. Bun은 bun.lockb(바이너리 형식)를 사용하는데, Nx 18은 이 lock 파일을 인식하지 못했다.
비유하면, 새 엔진을 가져왔는데 엔진 마운트(고정 장치)의 규격이 안 맞는 상황이다. 엔진 자체는 좋지만 차에 제대로 고정할 수 없는 것이다. 이 문제를 해결하려면 Nx 버전을 올려서 Bun 지원을 확보해야 했다.
마이그레이션 단계: Nx 18 → 19 → 20 → 21
한 번에 18에서 21로 점프하는 것은 위험하다. 메이저 버전마다 breaking change가 있고, 한꺼번에 적용하면 어떤 버전의 변경이 문제를 일으킨 건지 파악할 수 없다. 컬리는 한 버전씩 순차적으로 올리는 전략을 택했다.
Nx 18 → 19: 플러그인 시스템 변경
Nx 19에서 가장 큰 변화는 자동 플러그인 추론(inferred plugins) 시스템이 기본값으로 바뀐 것이다. 기존에는 project.json에 빌드 타겟, 린트 타겟 등을 명시적으로 선언했다면, Nx 19부터는 프로젝트 구조를 보고 자동으로 추론한다.
nx-18-project.json
{
"targets": {
"build": {
"executor": "@nx/webpack:webpack",
"options": {
"outputPath": "dist/apps/web"
}
},
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "apps/web/jest.config.ts"
}
}
}
}
nx-19-project.json
{
"// 대부분의 타겟이 자동 추론됨": "",
"targets": {}
}
이 변경으로 project.json이 간결해지지만, 기존에 커스텀 executor를 사용하던 프로젝트는 추론 로직과 충돌할 수 있다. 컬리는 이 단계에서 약 40개의 project.json을 수정해야 했다.
Nx 19 → 20: Executor에서 Plugin으로
Nx 20에서는 일부 executor가 deprecated되고 플러그인 기반으로 전환되었다. 특히 @nx/webpack:webpack executor가 @nx/webpack 플러그인으로 바뀌면서, 빌드 설정을 webpack.config.ts로 이관하는 작업이 필요했다.
Nx 20 → 21: Bun 공식 지원
Nx 21에서 드디어 Bun이 공식 패키지 매니저로 지원되었다. nx.json에 패키지 매니저를 명시하고, Bun의 workspace 프로토콜을 사용할 수 있게 되었다.
nx-21-nx.json
{
"packageManager": "bun",
"workspaceLayout": {
"appsDir": "apps",
"libsDir": "libs"
}
}
주요 이슈와 해결책
Lock 파일 호환 문제
npm의 package-lock.json에서 Bun의 bun.lockb로 전환할 때, 의존성 해석 결과가 미묘하게 달라질 수 있다. 같은 package.json이라도 lock 파일이 다르면 설치되는 버전이 달라지기 때문이다. 컬리는 전환 시점에 모든 의존성 버전을 고정(pinning)하는 방법으로 이 문제를 해결했다.
version-pinning-example.json
{
"dependencies": {
"react": "19.0.0",
"next": "15.1.0",
"typescript": "5.7.2"
}
}
^19.0.0(호환 범위)이 아니라 19.0.0(정확한 버전)으로 고정한 것이다. 비유하면 "19호 버스면 아무거나 타라"가 아니라 "오후 3시 15분에 출발하는 그 버스를 타라"고 지정한 것이다.
플러그인 Breaking Changes
Nx의 공식 플러그인(@nx/react, @nx/next, @nx/jest 등)이 메이저 버전마다 API를 바꾸었다. 특히 jest 설정 파일의 경로 해석 방식이 변경되면서, 기존에 동작하던 테스트가 깨지는 경우가 발생했다. 이것은 Nx의 마이그레이션 커맨드(nx migrate)로 대부분 자동 수정되지만, 커스텀 설정이 많은 프로젝트에서는 수동 개입이 필요했다.
nx-migration-commands.sh
# Nx 마이그레이션 실행
npx nx migrate @nx/workspace@20
# 마이그레이션 스크립트 확인
cat migrations.json
# 마이그레이션 적용
npx nx migrate --run-migrations
Executor 변경
@nx/node:node executor가 @nx/js:node 로 이동하면서, Node.js 애플리케이션의 실행 설정을 일괄 변경해야 했다. 모노레포에 Node.js 서비스가 12개 있었기 때문에, 하나씩 수동으로 바꾸는 대신 코드모드(codemod) 스크립트를 작성해 일괄 처리했다.
마이그레이션 체크리스트
컬리가 실전 경험에서 도출한 마이그레이션 체크리스트를 정리하면 다음과 같다.
nx-bun-migration-checklist.yaml
사전_준비:
- 현재 Nx 버전과 목표 버전 사이의 changelog를 모두 읽는다
- 각 메이저 버전의 breaking changes 목록을 별도 문서에 정리한다
- 의존성 버전을 모두 고정(pin)한다
- CI 파이프라인에서 현재 빌드 시간을 기록한다 (비교 기준)
단계별_실행:
- 한 번에 하나의 메이저 버전만 올린다
- 각 단계마다 nx migrate를 실행하고, 자동 마이그레이션 결과를 검토한다
- 각 단계마다 전체 빌드 + 테스트를 돌려 regression을 확인한다
- 문제가 발생하면 해당 단계에서 해결하고 넘어간다
bun_전환:
- Nx가 Bun을 공식 지원하는 버전까지 올린 후에 전환한다
- package-lock.json을 삭제하고 bun install을 실행한다
- bun.lockb를 git에 커밋한다 (바이너리 파일)
- CI의 setup-node를 setup-bun으로 교체한다
- postinstall 스크립트가 Bun에서도 동작하는지 확인한다
검증:
- 전체 빌드가 통과하는지 확인한다
- 전체 테스트가 통과하는지 확인한다
- CI 빌드 시간이 개선되었는지 측정한다
- 개발자 로컬 환경에서 설치 시간이 개선되었는지 확인한다
결과: 무엇이 달라졌나
| 지표 | 마이그레이션 전 (Nx 18 + npm) | 마이그레이션 후 (Nx 21 + Bun) |
|---|---|---|
install 시간 (CI) |
약 3분 20초 | 약 28초 |
| PR CI 전체 시간 | 약 12분 | 약 7분 30초 |
로컬 install 시간 |
약 2분 | 약 15초 |
| 캐시 적중 시 빌드 | 45초 | 38초 |
가장 체감이 큰 부분은 로컬 설치 시간이다. 브랜치를 전환하거나 새 클론을 받을 때마다 2분을 기다리던 것이 15초로 줄어든 것은 개발자 경험(DX) 측면에서 의미가 크다.
마무리
Nx 모노레포에서 Bun으로의 전환은 "엔진 교체"라는 비유가 정확하다. 차체(프로젝트 구조)는 건드리지 않으면서 엔진(패키지 매니저)만 바꾸는 작업이다. 단, 엔진 마운트 규격(Nx 버전)이 맞아야 하므로, Nx 버전 업그레이드가 선행 조건이다. 한 번에 뛰어넘지 말고, 한 메이저 버전씩 올리면서 각 단계의 breaking change를 소화하는 것이 안전한 경로다. 엔진 교체가 완료되면, 설치 속도와 CI 시간에서 즉각적인 개선을 체감할 수 있다.
'노트 > 끄적끄적' 카테고리의 다른 글
| z-index: 99999를 줬는데 왜 안 올라올까 — CSS Stacking Context 완전 이해 (0) | 2026.02.15 |
|---|---|
| Google SRE는 장애 대응에 AI를 어떻게 쓰는가 — Gemini CLI 실전 활용 (0) | 2026.02.15 |
| Steve Yegge의 AI 에이전트 시대 전망: 50% Dial과 드라큘라 효과 (0) | 2026.02.15 |
| React 성능 최적화 4단계: LCP 28초에서 1초로 (0) | 2026.02.15 |