Nx 워크스페이스 시작하기: project.json, target, executor, inferred tasks

Frontend

이 글은 Nx 시리즈 2편입니다.

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.json scripts와 설정 파일만 보고 target을 추론해서, 체감 설정량이 Turborepo와 비슷해졌습니다.
  • 새 프로젝트를 손으로 만들지 않고 nx g로 생성하는 흐름이 Nx 셋업의 기본 감각입니다.

설정 파일 대응을 표로 보면 이렇습니다.

역할 Turborepo Nx
전역 task 기본값 turbo.jsontasks nx.jsontargetDefaults
캐시 입력 정의 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.json scripts를 갖고, 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.jsontargetDefaultsnamedInputs로 표현합니다.

{
  "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/pluginapps/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 webnx 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)으로 나뉩니다.
  • targetDefaultsnamedInputs가 Turborepo의 task inputs/dependsOn/outputs에 대응하고, named input 덕에 입력 정의 재사용이 깔끔합니다.
  • target은 task, executor는 그 task의 표준 실행 구현입니다. inferred tasks를 쓰면 executor를 직접 안 적어도 됩니다.
  • inferred tasks 이후 Nx의 초기 설정량은 Turborepo와 크게 차이 나지 않습니다.
  • 새 프로젝트는 generator로 만드는 게 Nx의 기본 흐름입니다.

다음 편에서는 이 셋업 위에서 Nx의 진짜 강점인 affected와 캐시가 어떻게 CI 시간을 줄이는지, Turborepo의 remote cache와 정면으로 비교합니다.

다음 글에서 이어서 보기

같이 보면 좋은 글