Nx 워크스페이스 시작하기: project.json, target, executor, inferred tasks
이 글은 Nx 시리즈 2편입니다.
- 1편: Nx를 알아보자: project graph 중심으로 보는 모노레포 운영
- 3편: Nx affected와 캐시로 CI 줄이기: Turborepo remote cache와 비교
- 4편: Nx 운영 고도화: module boundaries, generators, 그리고 Turborepo가 비워둔 자리
1편에서는 Nx가 project graph를 중심으로 레포를 바라본다는 이야기를 했습니다. 이번 편은 그 그래프가 파일 단위로 어떻게 생겼는지를 봅니다. Turborepo를 써본 분이라면 turbo.json 하나로 끝나던 설정이 Nx에서는 어디로 흩어지는지가 가장 궁금할 텐데, 그 대응 관계부터 잡고 가겠습니다.
한눈에 보면
- Turborepo는 루트
turbo.json하나가 거의 전부지만, Nx는nx.json(전역) + 프로젝트별 설정으로 나뉩니다. - 프로젝트별 설정은 명시적
project.json을 쓸 수도, inferred tasks로 거의 비워둘 수도 있습니다. target은 Turborepo의 task,executor는 그 task를 실행하는 표준 구현이라고 보면 됩니다.- 최근 Nx는
package.jsonscripts와 설정 파일만 보고 target을 추론해서, 체감 설정량이 Turborepo와 비슷해졌습니다. - 새 프로젝트를 손으로 만들지 않고
nx g로 생성하는 흐름이 Nx 셋업의 기본 감각입니다.
설정 파일 대응을 표로 보면 이렇습니다.
| 역할 | Turborepo | Nx |
|---|---|---|
| 전역 task 기본값 | turbo.json의 tasks |
nx.json의 targetDefaults |
| 캐시 입력 정의 | task별 inputs |
namedInputs + target inputs |
| 프로젝트별 task | 각 package.json scripts |
project.json 또는 inferred |
| 실행 방식 | scripts에 적힌 명령 직접 | executor 또는 추론된 명령 |
| 새 프로젝트 생성 | 수동 / 외부 도구 | nx g <generator> |
워크스페이스를 만드는 두 갈래
Turborepo는 보통 이미 있는 pnpm workspace 위에 turbo를 설치하고 turbo.json을 추가하는 식으로 시작합니다. Nx도 기존 레포에 얹을 수 있지만(nx init), 새로 시작할 때는 보통 이렇게 합니다.
npx create-nx-workspace@latest myorg --preset=npm--preset에 따라 초기 구조가 꽤 달라집니다. 프론트엔드 모노레포라면 두 가지 갈래를 알아두면 됩니다.
- package-based (npm/pnpm preset): Turborepo와 거의 같은 감각입니다. 각 패키지가 자기
package.jsonscripts를 갖고, Nx는 그 위에 캐시와 affected만 얹습니다. Turborepo에서 넘어올 때 가장 이질감이 적습니다. - integrated (
@nx/next등 플러그인 preset): Nx 플러그인이 프로젝트 생성·빌드·테스트까지 표준화해서 가져갑니다. executor와 generator의 이점을 최대로 쓰는 대신, Nx 방식에 더 깊게 들어갑니다.
처음 Turborepo에서 넘어온다면 package-based로 시작해서, 필요해질 때 플러그인을 하나씩 붙이는 쪽을 권합니다. "전부 Nx 방식"으로 한 번에 가지 않아도 됩니다.
nx.json: turbo.json의 전역 부분
nx.json은 워크스페이스 전역 설정입니다. Turborepo의 turbo.json에서 "모든 task에 공통으로 적용되는 부분"이 여기로 옵니다.
Turborepo에서 이렇게 쓰던 것을,
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env.production"],
"outputs": [".next/**", "dist/**"]
},
"test": { "dependsOn": ["^build"] }
}
}Nx에서는 nx.json의 targetDefaults와 namedInputs로 표현합니다.
{
"namedInputs": {
"default": ["{projectRoot}/**/*", "sharedGlobals"],
"production": ["default", "!{projectRoot}/**/*.spec.tsx", "!{projectRoot}/**/*.test.ts"]
},
"targetDefaults": {
"build": {
"dependsOn": ["^build"],
"inputs": ["production", "^production"],
"outputs": ["{projectRoot}/.next", "{projectRoot}/dist"],
"cache": true
},
"test": {
"dependsOn": ["^build"],
"cache": true
}
}
}대응 관계를 읽어보면 이렇습니다.
dependsOn: ["^build"]— 똑같습니다. 상위 의존 프로젝트의 build가 먼저.inputs— Turborepo는 task마다 직접 적지만, Nx는namedInputs로 "production이란 이런 파일들"을 한 번 정의하고 재사용합니다. 테스트 파일을 제외(!...spec)해서 "테스트만 바뀌면 build 캐시는 유지"를 깔끔하게 표현할 수 있습니다.cache: true— Turborepo는 기본 캐시이고 끄려면cache: false를 적지만, Nx는 캐시할 target을 명시하는 쪽입니다(최신 버전은 기본값이 더 똑똑해졌습니다).
핵심 차이는 named input입니다. "프로덕션 입력"이라는 개념을 한 번 정의해두고 여러 target이 공유하기 때문에, 레포가 커질수록 캐시 입력 정의가 덜 중복됩니다. 이건 3편에서 캐시 정확도와 함께 다시 봅니다.
project.json: 명시적으로 적는 방식
프로젝트별 target은 project.json에 적을 수 있습니다. Turborepo에는 대응물이 없는데, 굳이 말하면 "그 패키지의 package.json scripts"에 해당합니다.
// apps/web/project.json
{
"name": "web",
"projectType": "application",
"sourceRoot": "apps/web",
"tags": ["scope:web", "type:app"],
"targets": {
"build": {
"executor": "@nx/next:build",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/apps/web",
},
},
"serve": {
"executor": "@nx/next:server",
"options": { "dev": true },
},
"lint": {
"executor": "@nx/eslint:lint",
},
},
}여기서 Turborepo와 가장 다른 두 가지가 보입니다.
tags:scope:web,type:app같은 라벨입니다. 지금은 그냥 메타데이터로 보이지만, 4편의 module boundary 규칙이 이 태그를 기준으로 "web은 admin 라이브러리를 import하면 안 된다" 같은 제약을 강제합니다. Turborepo에는 이 개념이 없습니다.executor: 1편에서 말한 "어떻게 실행하는가"입니다.@nx/next:build는 Next.js 빌드를 표준 방식으로 감싸고 있습니다.
명시적으로 적는 만큼 통제력은 좋지만, 프로젝트마다 이걸 손으로 관리하면 Turborepo의 단순함이 그리워집니다. 그래서 최근 Nx는 다음 방식을 민다.
inferred tasks: project.json을 비우는 방향
project.json을 일일이 안 적고, 설정 파일을 보고 target을 자동으로 추론하는 방식입니다. nx.json에 플러그인만 등록해두면 됩니다.
{
"plugins": ["@nx/next/plugin", "@nx/eslint/plugin", "@nx/jest/plugin"]
}이렇게 해두면 @nx/next/plugin이 apps/web/next.config.js를 발견하는 순간, web 프로젝트에 build·dev·start target을 알아서 만들어 줍니다. project.json이 아예 없거나 거의 비어 있어도 됩니다.
추론된 target이 실제로 무엇인지는 이렇게 확인합니다.
nx show project web --web이게 중요한 이유는, Turborepo에서 넘어올 때 체감 설정량의 차이가 거의 사라지기 때문입니다. 예전 Nx는 "프로젝트마다 project.json을 빽빽하게 적는 도구"라는 인상이 있었는데, inferred tasks 이후로는 다음 정도면 시작이 됩니다.
- 루트
nx.json에 plugin 등록 +targetDefaults - 각 프로젝트는 평소처럼
next.config.js,jest.config.ts만 두기
Turborepo가 turbo.json 하나로 시작하던 것과 비교하면, Nx도 nx.json 하나 + 설정 파일들로 시작할 수 있게 된 셈입니다. 1편에서 "초반 학습량이 많다"고 했던 부담이 가장 줄어든 지점이 여기입니다.
실행 명령의 감각
같은 동작을 두 도구에서 어떻게 부르는지 나란히 보면 차이가 분명해집니다.
# Turborepo
turbo run dev --filter=web
turbo run build --filter=@repo/ui
turbo run build lint test
# Nx
nx serve web
nx build @repo/ui
nx run-many -t build,lint,test읽어보면 결이 다릅니다.
- Turborepo는 항상
turbo run <task> --filter라는 한 가지 형태입니다. 균일하고 외우기 쉽습니다. - Nx는
nx <target> <project>처럼 프로젝트를 1급으로 부릅니다.nx serve web이nx run web:serve의 축약이고요. 프로젝트 중심으로 생각하게 만드는 문법입니다.
전체를 한 번에 돌릴 때 Turborepo는 그냥 turbo run build이고, Nx는 nx run-many -t build입니다. 그리고 Nx의 진짜 무기는 run-many가 아니라 nx affected인데, 그건 3편의 주제입니다.
새 프로젝트는 손으로 만들지 않는다
Turborepo 레포에서 새 패키지를 추가할 때는 보통 폴더 만들고, package.json 쓰고, tsconfig 연결하고를 손으로 합니다(혹은 사내 스크립트). Nx는 이걸 generator로 합니다.
nx g @nx/react:library feature-cart --directory=packages/cart
nx g @nx/next:app storefront이 한 줄이 폴더 구조, project.json(또는 추론 설정), tsconfig 경로 매핑, 기본 테스트 셋업, 태그까지 한 번에 만듭니다. "새 라이브러리는 항상 이런 모양"이라는 표준을 도구가 강제하는 거죠.
지금 단계에서는 "이런 게 있다" 정도로만 알아두면 됩니다. generator를 제대로 쓰는 법과, 이게 왜 팀 표준화의 핵심인지는 4편에서 다룹니다.
정리하면
- Turborepo의
turbo.json하나가 Nx에서는nx.json(전역) + 프로젝트별 설정(project.json또는 inferred)으로 나뉩니다. targetDefaults와namedInputs가 Turborepo의 taskinputs/dependsOn/outputs에 대응하고, named input 덕에 입력 정의 재사용이 깔끔합니다.target은 task,executor는 그 task의 표준 실행 구현입니다. inferred tasks를 쓰면 executor를 직접 안 적어도 됩니다.- inferred tasks 이후 Nx의 초기 설정량은 Turborepo와 크게 차이 나지 않습니다.
- 새 프로젝트는 generator로 만드는 게 Nx의 기본 흐름입니다.
다음 편에서는 이 셋업 위에서 Nx의 진짜 강점인 affected와 캐시가 어떻게 CI 시간을 줄이는지, Turborepo의 remote cache와 정면으로 비교합니다.
