Nx 운영 고도화: module boundaries, generators, 그리고 Turborepo가 비워둔 자리
이 글은 Nx 시리즈 4편이자 마지막 편입니다.
- 1편: Nx를 알아보자: project graph 중심으로 보는 모노레포 운영
- 2편: Nx 워크스페이스 시작하기: project.json, target, executor, inferred tasks
- 3편: Nx affected와 캐시로 CI 줄이기: Turborepo remote cache와 비교
지금까지 본 차이(project graph, affected, 분산 실행)는 결국 속도와 범위에 관한 것이었습니다. 그런데 Turborepo와 Nx가 가장 크게 갈리는 곳은 사실 속도가 아닙니다. 레포가 시간이 지나도 망가지지 않게 지키는 일, 즉 경계 관리와 코드 생성입니다.
Turborepo 시리즈 4편에서 shared package가 잡동사니가 되고 경계가 무너지는 안티패턴을 다뤘는데, 그때 결론은 "이건 도구가 막아주기보다 팀 규칙과 리뷰로 풀어야 한다"였습니다. Nx는 바로 그 부분을 도구 안으로 가져옵니다. 이번 편은 Turborepo가 의도적으로 비워둔 자리를 Nx가 어떻게 채우는가에 관한 이야기입니다.
한눈에 보면
- 모노레포는 시간이 지나면 경계가 샙니다.
web이admin내부를 import하고,shared가 모든 걸 빨아들입니다. - Turborepo는 이걸 막는 직접 장치가 없습니다. 팀 규칙·리뷰·문서로 지켜야 합니다.
- Nx는 tag + module boundary lint 규칙으로 "이 경계를 어기는 import"를 빌드/린트에서 에러로 만듭니다.
- 코드 생성도 마찬가지입니다. Turborepo는 수동, Nx는 generators로 "새 라이브러리는 항상 이 모양"을 강제합니다.
- 이 둘이 Nx를 "실행 도구"가 아니라 "운영 체계"로 만드는 핵심이고, 동시에 작은 레포엔 과한 부분입니다.
| 영역 | Turborepo | Nx |
|---|---|---|
| 경계 위반 차단 | 팀 규칙 / 코드 리뷰 | @nx/enforce-module-boundaries lint |
| 의미 부여 | 폴더 구조 컨벤션 | project tags (scope/type) |
| 새 프로젝트 생성 | 수동 / 사내 스크립트 | generators (nx g) |
| 표준 변경 전파 | 손으로 / codemod 직접 | generators + (선택) 자동 migration |
| 지향점 | 빠른 실행 레이어 | 워크스페이스 운영 체계 |
경계는 왜 무너지는가
모노레포의 매력은 "다 한 곳에 있어서 import 한 줄이면 쓸 수 있다"입니다. 그런데 그 매력이 그대로 함정이 됩니다.
// apps/web 어딘가
import { AdminUserTable } from '../../admin/src/features/users/table';
import { internalParse } from '@repo/ui/src/internal/parse';둘 다 동작은 합니다. import 경로만 맞으면 번들러는 불평하지 않으니까요. 하지만 이런 게 쌓이면,
web이admin의 내부에 묶여서, admin을 못 바꿉니다.@repo/ui의internal이 외부에 노출돼서, 공개 API와 내부 구현 구분이 사라집니다.- 3편의 affected가 부정확해집니다. 의도 없던 의존이 그래프에 생기니, 사소한 변경이 엉뚱한 앱을 affected로 끌고 옵니다.
Turborepo는 이 import 자체를 막지 못합니다. package graph는 "있는 의존성"을 그릴 뿐, "있어선 안 되는 의존성"을 판단하지 않습니다. 그래서 Turborepo 레포에서 경계는 결국 사람이 지킵니다. 리뷰에서 잡거나, 별도 ESLint 규칙을 직접 설계하거나.
Nx의 답: tag + module boundaries
Nx는 2편에서 본 tags를 여기서 씁니다. 먼저 프로젝트마다 의미를 붙입니다.
// apps/web/project.json
{ "name": "web", "tags": ["scope:web", "type:app"] }
// apps/admin/project.json
{ "name": "admin", "tags": ["scope:admin", "type:app"] }
// packages/ui/project.json
{ "name": "ui", "tags": ["scope:shared", "type:ui"] }
// packages/auth/project.json
{ "name": "auth", "tags": ["scope:shared", "type:feature"] }그리고 ESLint에 @nx/enforce-module-boundaries 규칙으로 "누가 누구를 의존할 수 있는가"를 선언합니다.
{
"@nx/enforce-module-boundaries": [
"error",
{
"depConstraints": [
{
"sourceTag": "scope:web",
"onlyDependOnLibsWithTags": ["scope:web", "scope:shared"],
},
{
"sourceTag": "scope:admin",
"onlyDependOnLibsWithTags": ["scope:admin", "scope:shared"],
},
{
"sourceTag": "type:app",
"onlyDependOnLibsWithTags": ["type:feature", "type:ui", "type:util"],
},
{
"sourceTag": "type:util",
"onlyDependOnLibsWithTags": ["type:util"],
},
],
},
],
}이제 앞에서 본 위반은 lint 에러가 됩니다.
A project tagged with "scope:web" can only depend on libs tagged with
"scope:web", "scope:shared".
Violation: web → admin (scope:admin)핵심은 두 가지입니다.
- 경계가 문서가 아니라 실행되는 규칙입니다. 리뷰어가 깜빡해도 CI가 막습니다.
- 규칙이 project graph(2편)와 같은 데이터를 씁니다. 즉 "그릴 수 있는 의존"과 "허용된 의존"을 같은 모델 위에서 비교합니다.
type:app은 라이브러리만 의존하고 다른 app은 의존 못 한다, type:util은 다른 util만 본다 — 이런 레이어 규칙을 태그로 표현하면, 도메인이 늘어도 경계가 코드로 유지됩니다. Turborepo에서 "팀이 합의하고 리뷰로 지키자"고 했던 걸, Nx는 lint 규칙 한 블록으로 바꿉니다.
코드 생성: 표준을 손이 아니라 도구가
경계가 "하지 말아야 할 것"의 강제라면, generators는 "해야 할 것"의 표준화입니다.
Turborepo 레포에서 새 feature 라이브러리를 만드는 과정을 떠올려 보면 보통 이렇습니다.
packages/feature-cart폴더 생성package.json작성, 이름·exports 설정tsconfig경로 매핑 추가- 테스트 설정 복붙
- 옆 패키지 보고 구조 흉내
사람마다 미세하게 다른 결과가 나오고, 시간이 지나면 "옛날 방식 패키지"와 "요즘 방식 패키지"가 섞입니다. 이걸 막으려면 사내 스크립트를 직접 만들어야 합니다.
Nx는 이걸 generator로 합니다.
nx g @nx/react:library feature-cart \
--directory=packages/cart \
--tags=scope:shop,type:feature \
--bundler=none --unitTestRunner=jest한 줄이 폴더, 설정, tsconfig 경로, 테스트 셋업, 그리고 태그까지 한 번에 만듭니다. 방금 본 경계 규칙과 자연스럽게 이어지죠 — 생성 시점에 type:feature가 박히니까요.
여기서 끝이 아닙니다. 팀만의 규칙이 있으면 custom generator를 만들 수 있습니다.
nx g @nx/plugin:generator my-feature --project=workspace-plugin"우리 팀에서 feature 라이브러리는 항상 이 폴더 구조, 이 배럴 파일, 이 태그, 이 lint 설정"을 코드로 박아두면, 신규 입사자도 nx g @myorg/workspace:feature cart 한 줄로 팀 표준을 그대로 따릅니다. 표준이 위키 문서가 아니라 실행 가능한 generator로 존재하는 겁니다.
Turborepo가 "실행을 단순하게"라면, Nx generators는 "생성을 표준으로". 이 차이는 사람이 많아지고 라이브러리가 빠르게 늘어나는 조직에서 특히 벌어집니다.
보너스: 표준이 바뀔 때
라이브러리가 50개인데 tsconfig 표준이나 빌드 방식을 바꿔야 한다고 해봅시다. Turborepo 레포에서는 codemod를 직접 짜거나, 50개를 손으로 고칩니다.
Nx는 플러그인이 버전업될 때 nx migrate로 워크스페이스 설정을 자동 마이그레이션하는 경로를 제공합니다.
nx migrate latest
nx migrate --run-migrations모든 변경을 자동으로 해주진 않지만, "도구가 깔아준 표준"에 대해서는 도구가 업그레이드 경로를 같이 책임진다는 감각입니다. 직접 깐 표준이 많을수록 이 가치는 줄지만, Nx 플러그인 위에 올라탄 부분은 이 흐름으로 유지보수가 됩니다.
그래서 이 모든 게 항상 좋은가
시리즈를 닫으면서 다시 균형을 잡겠습니다. 4편에서 본 경계 규칙과 generators는 Nx의 가장 큰 차별점이지만, 공짜도 아니고 항상 이득도 아닙니다.
비용 쪽을 정직하게 적으면 이렇습니다.
- 태그 체계와 경계 규칙을 설계해야 합니다. 잘못 설계하면 규칙이 개발을 방해하고, 다들
// eslint-disable을 답니다. - generator와 plugin은 그 자체로 유지보수 대상입니다. 팀에 이걸 관리할 사람이 있어야 합니다.
- Nx 방식(executor, plugin)에 깊이 들어갈수록 도구 종속이 커집니다. 빠져나오는 비용도 같이 커집니다.
그래서 선은 1편에서 그은 것과 같습니다.
- 앱 1~3개, 도메인 단순 → 경계는 리뷰로 충분하고 generator는 과합니다. Turborepo가 맞습니다.
- 도메인이 여럿, 라이브러리가 빠르게 늘고, 여러 팀이 한 레포를 만짐 → 경계가 새는 비용이 도구 도입 비용을 넘어섭니다. Nx의 자리입니다.
레포가 커질수록 진짜 비용은 "누가 더 빨리 빌드하느냐"가 아니라 **"누가 레포 건강도를 더 오래 지키게 돕느냐"**에서 나옵니다. Turborepo는 그 일을 팀에게 맡기고 대신 단순함을 줍니다. Nx는 그 일을 도구로 가져오는 대신 복잡함을 받습니다. 어느 쪽이 옳다기보다, 지금 우리 레포의 경계가 사람 손으로 지켜지고 있는가가 판단 기준입니다.
시리즈를 정리하면
네 편을 한 문장씩으로 줄이면 이렇습니다.
- 1편 — Turborepo는 package graph, Nx는 project graph. 출발점이 다르다.
- 2편 —
turbo.json하나가 Nx에선nx.json+ 프로젝트 설정으로 나뉘지만, inferred tasks로 체감 설정량은 비슷해졌다. - 3편 — 캐시는 동점, 차이는 앞단의 affected와 뒷단의 분산 실행.
- 4편 — Nx의 진짜 차별점은 속도가 아니라 경계와 생성을 시스템화하는 것.
그리고 시리즈 전체를 관통하는 한 줄은 이겁니다. Turborepo는 "빠르게 실행하는 도구", Nx는 "오래 운영하는 도구". 둘은 경쟁자라기보다, 레포가 지금 어느 단계에 있는지를 비추는 서로 다른 거울에 가깝습니다. 속도가 급하면 Turborepo로 시작하고, 운영 복잡도가 사람을 넘어서기 시작하면 Nx를 들여다볼 때입니다.
