Nx 모노레포 시작하기: Workspace 생성, 프로젝트·라이브러리 구성, Task 실행과 캐싱, 의존성 그래프

이 글의 핵심

저장소에 앱과 패키지가 늘어나면 작은 변경에도 전체를 빌드하고 테스트하느라 CI 시간이 길어집니다. Nx가 프로젝트 의존성 그래프로 영향받는 대상만 골라 실행하고 결과를 캐시하는 원리를 짚고, 풀스택 예제에서 프론트엔드와 백엔드가 타입을 공유하는 구조를 만들어 봅니다.

이 글의 핵심

Nx로 모노레포를 구성하는 방법을 정리한 글입니다. Workspace 생성, 앱과 라이브러리 분리, 계산 캐싱(Computation Caching), affected 실행, 의존성 그래프, Nx Cloud 원격 캐시까지 예제로 다룹니다.

실무에서 마주치는 문제들

빌드가 너무 느려요

모노레포에 앱과 패키지가 쌓이면 README 한 줄을 고쳐도 CI가 모든 프로젝트를 빌드·테스트합니다. Nx는 각 작업의 입력(소스 파일, 설정, 의존 프로젝트의 결과)을 해시로 만들고, 같은 해시로 이미 실행한 적이 있으면 결과물과 터미널 출력을 캐시에서 복원합니다. 얼마나 빨라지는지는 저장소 구조와 캐시 적중률에 달려 있지만, 변경이 한두 라이브러리에 국한되는 일상적인 PR에서는 대부분의 작업이 캐시 적중으로 끝납니다.

의존성 관리가 복잡해요

어떤 앱이 어떤 공유 코드를 쓰는지를 사람이 문서로 관리하면 금방 틀어집니다. Nx는 package.json, tsconfig.base.json의 경로 별칭, 소스 코드의 import 문을 분석해 프로젝트 그래프를 자동으로 만듭니다.

CI/CD가 비효율적이에요

모든 프로젝트를 매번 빌드하는 대신, Nx는 기준 커밋(보통 main)과 현재 커밋의 차이에서 바뀐 파일을 찾고, 그 파일이 속한 프로젝트와 그 프로젝트에 의존하는 프로젝트만 실행합니다.


Nx가 해결하는 문제

핵심 특징

Nx는 모노레포용 빌드 시스템이자 태스크 오케스트레이터입니다. npm/pnpm/yarn workspace가 “패키지를 서로 링크해 주는 것”까지를 담당한다면, Nx는 그 위에서 “무엇을, 어떤 순서로, 다시 실행할 필요가 있는지”를 결정합니다.

주요 장점:

  • Computation Caching: 로컬 + 원격 캐싱
  • Affected Commands: 변경된 것만 실행
  • Distributed Task Execution: 여러 CI 머신에 작업 분산
  • Plugins: Angular, React, Next.js, Nest 등 코드 생성기와 실행기
  • Dependency Graph: 프로젝트 관계 시각화

Turborepo도 캐싱과 태스크 파이프라인을 제공하지만 범위가 좁고 설정이 가볍습니다. Nx는 코드 생성기, 모듈 경계 lint 규칙(@nx/enforce-module-boundaries), 마이그레이션 자동화(nx migrate)까지 포함하는 대신 학습해야 할 개념이 많습니다. 패키지 몇 개짜리 저장소라면 Turborepo나 pnpm workspace만으로도 충분한 경우가 많고, 여러 팀이 한 저장소에서 앱 여러 개를 운영할수록 Nx의 추가 기능이 값어치를 합니다.


Workspace 만들기와 기존 프로젝트에 추가하기

새 Workspace 생성

npx create-nx-workspace@latest my-workspace
# 옵션 선택
# - Integrated monorepo
# - React / Angular / Next.js
# - Nx Cloud (Yes/No)

생성기가 묻는 첫 번째 선택이 중요합니다. Integrated 방식은 루트 package.json 하나에 모든 의존성을 두고 각 프로젝트를 project.json으로 정의합니다. 버전이 하나로 통일되어 “앱 A는 React 18, 앱 B는 React 19” 같은 불일치가 생기지 않는 대신, 하나를 올리면 전부를 같이 올려야 합니다. Package-based 방식은 프로젝트마다 package.json을 두는 기존 workspace 구조를 유지하고 Nx는 태스크 실행과 캐시만 맡습니다.

기존 프로젝트에 추가

npx nx@latest init

nx init은 기존 package.json 스크립트를 Nx 태스크로 인식하게 해 줄 뿐 구조를 바꾸지 않습니다. 이미 운영 중인 저장소라면 이렇게 캐싱만 먼저 도입하고, 효과를 확인한 뒤 플러그인과 생성기를 점진적으로 들이는 편이 안전합니다.


React, Next.js, Nest.js 프로젝트 생성

React 앱

nx generate @nx/react:app my-app

React 라이브러리

nx generate @nx/react:lib my-lib

Next.js 앱

nx generate @nx/next:app my-next-app

Nest.js 앱

nx generate @nx/nest:app my-api

각 생성기는 해당 플러그인(@nx/react, @nx/next, @nx/nest)이 설치되어 있어야 동작합니다. 없으면 Unable to resolve @nx/next:app 같은 오류가 나므로 npm i -D @nx/next처럼 먼저 추가합니다. 생성기는 파일을 만드는 것 외에 tsconfig.base.json에 경로 별칭을 등록하고 lint·test 설정을 붙여 주는데, 이 부분을 손으로 하면 빠뜨리기 쉬워서 라이브러리는 가능하면 생성기로 만드는 것이 좋습니다. --dry-run 옵션을 붙이면 실제로 쓰지 않고 어떤 파일이 생길지만 보여 줍니다.


apps와 libs 폴더 구조

my-workspace/
├── apps/
│   ├── web/              # Next.js 앱
│   ├── mobile/           # React Native 앱
│   └── api/              # Nest.js API
├── libs/
│   ├── shared/ui/        # 공유 UI 컴포넌트
│   ├── shared/utils/     # 공유 유틸리티
│   └── feature/auth/     # Auth 기능
├── nx.json
├── package.json
└── tsconfig.base.json

Nx가 권장하는 원칙은 “앱은 얇게, 라이브러리는 잘게”입니다. apps/에는 라우팅과 조립 코드만 두고 실제 로직은 libs/로 내립니다. 이렇게 하는 이유는 캐시 단위가 프로젝트이기 때문입니다. 로직이 전부 앱 안에 있으면 한 줄만 바꿔도 그 앱 전체의 테스트가 다시 돌지만, 라이브러리로 나뉘어 있으면 바뀐 라이브러리와 그 소비자만 다시 실행됩니다. 반대로 너무 잘게 쪼개면 프로젝트 수가 수백 개가 되어 그래프 계산과 설정 관리가 부담이 되므로, 기능(feature)·UI·데이터 접근·유틸리티 정도의 층으로 나누는 것이 일반적입니다.


공유 라이브러리 만들고 가져다 쓰기

라이브러리 생성

nx generate @nx/react:lib shared-ui

컴포넌트 생성

nx generate @nx/react:component button --project=shared-ui --export

--export는 생성한 컴포넌트를 라이브러리의 index.ts에서 다시 내보내도록 추가합니다. 라이브러리의 공개 API는 이 index.ts 하나로 제한하는 것이 관례이고, 다른 프로젝트가 @my-workspace/shared-ui/src/lib/button처럼 내부 경로를 직접 import하면 모듈 경계 lint 규칙이 경고합니다.

사용

// apps/web/src/app/page.tsx
import { Button } from '@my-workspace/shared-ui';
export default function Home() {
  return <Button>Click me</Button>;
}

@my-workspace/shared-ui는 npm에 퍼블리시된 패키지가 아니라 tsconfig.base.json의 paths에 등록된 별칭입니다. 에디터에서는 import가 잘 되는데 빌드에서 Cannot find module '@my-workspace/shared-ui'가 난다면, 별칭이 tsconfig.base.json에 없거나 앱의 tsconfig.json이 tsconfig.base.json을 extends하지 않는 경우가 대부분입니다.


태스크 실행과 affected 명령

단일 프로젝트

# 빌드
nx build web
# 테스트
nx test web
# Lint
nx lint web
# Dev 서버
nx serve web

nx build web은 nx run web:build의 줄임말입니다. 여러 프로젝트에 같은 타깃을 실행할 때는 nx run-many -t build test처럼 씁니다.

Affected Commands

# 변경된 프로젝트만 빌드
nx affected:build
# 변경된 프로젝트만 테스트
nx affected:test
# 변경된 프로젝트만 Lint
nx affected:lint

위의 affected:build 형태는 예전 문법이고, Nx 16 이후로는 nx affected -t build, nx affected -t lint test build처럼 -t(target)로 여러 타깃을 한 번에 지정하는 형태가 표준입니다. 최신 버전에서는 콜론 형태가 제거되었으므로 새로 작성하는 CI 스크립트에는 -t 형태를 쓰십시오.

affected는 “무엇과 비교할지”가 핵심입니다. 기본값은 --base=main --head=HEAD인데, CI에서 얕은 클론(fetch-depth: 1)을 하면 main 브랜치 이력이 없어서 fatal: Not a valid object name main 같은 오류가 나거나 전체가 affected로 잡힙니다. GitHub Actions라면 fetch-depth: 0으로 전체 이력을 받고, 가능하면 nrwl/nx-set-shas 액션으로 마지막으로 성공한 CI 커밋을 base로 지정하는 것이 정확합니다. main과 비교하면 이미 검증된 변경까지 다시 실행하게 되기 때문입니다.

처음 affected를 도입할 때 자주 겪는 문제는 “아무 파일이나 고쳐도 모든 프로젝트가 affected로 잡히는” 현상입니다. 원인은 대개 루트의 package.json, tsconfig.base.json, lockfile처럼 모든 프로젝트의 입력에 포함되는 전역 파일입니다. 의존성 하나를 추가하면 lockfile이 바뀌고, 그 결과 전체가 다시 실행되는 것은 정상 동작입니다. 문제는 전역 입력에 불필요한 파일(문서, 스크립트 폴더 등)이 들어가 있는 경우라서, nx.json의 namedInputs에서 sharedGlobals와 default를 좁혀 주면 해결됩니다.


nx.json 설정

{
  "tasksRunnerOptions": {
    "default": {
      "runner": "nx/tasks-runners/default",
      "options": {
        "cacheableOperations": ["build", "test", "lint"],
        "parallel": 3
      }
    }
  },
  "targetDefaults": {
    "build": {
      "dependsOn": ["^build"],
      "inputs": ["production", "^production"],
      "outputs": ["{projectRoot}/dist"]
    },
    "test": {
      "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"],
      "cache": true
    }
  },
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedGlobals"],
    "production": [
      "default",
      "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)",
      "!{projectRoot}/tsconfig.spec.json"
    ],
    "sharedGlobals": []
  }
}

설정에서 눈여겨볼 줄은 다음과 같습니다.

  • "dependsOn": ["^build"]: ^는 “의존하는 프로젝트의”라는 뜻입니다. web을 빌드하기 전에 web이 쓰는 라이브러리의 build를 먼저 실행하라는 의미이고, ^ 없이 "build"라고 쓰면 같은 프로젝트의 다른 타깃을 가리킵니다.
  • "inputs": ["production", "^production"]: 빌드 캐시 키에 자기 프로젝트의 production 파일과 의존 프로젝트의 production 파일을 넣습니다. production은 아래 namedInputs에서 테스트 파일을 제외한 집합으로 정의되어 있으므로, 테스트 파일만 고쳤을 때는 빌드 캐시가 깨지지 않습니다.
  • "outputs": 캐시에서 복원할 산출물 경로입니다. 이 값이 실제 빌드 출력 경로와 다르면 캐시 적중 시 “Nx read the output from the cache” 메시지는 나오는데 dist 폴더가 비어 있는 이상한 상황이 됩니다.

tasksRunnerOptions.cacheableOperations는 Nx 16 이하의 방식입니다. Nx 17부터는 targetDefaults의 각 타깃에 "cache": true를 지정하는 방식으로 바뀌었고, 병렬 수는 최상위 "parallel": 3으로 옮겨졌습니다. 오래된 설정을 그대로 두면 nx migrate가 자동으로 바꿔 주지만, 블로그나 예제를 보고 설정을 복사할 때는 어느 버전 기준인지 먼저 확인해야 합니다.

캐시와 관련해 가장 조심해야 할 부분은 입력에 잡히지 않는 값입니다. 빌드 결과가 process.env.API_URL 같은 환경 변수에 따라 달라지는데 그 값이 inputs에 없으면, 스테이징용으로 빌드된 결과가 프로덕션 빌드에서 캐시로 복원될 수 있습니다. 이런 경우 { "env": "API_URL" }을 inputs에 추가해 해시에 포함해야 합니다. 캐시가 이상하다고 느껴지면 nx build web --skip-nx-cache로 캐시를 우회해 결과를 비교하고, nx reset으로 로컬 캐시와 데몬 상태를 초기화할 수 있습니다.


Dependency Graph 보기

# 의존성 그래프 시각화
nx graph
# 특정 프로젝트의 의존성
nx graph --focus=web
# Affected 그래프
nx affected:graph

nx graph는 브라우저에서 프로젝트 간 화살표를 보여 줍니다. 마지막 줄의 affected:graph도 예전 문법이며 현재는 nx graph --affected로 씁니다. 그래프를 처음 열어 보면 예상하지 못한 화살표가 보이는 경우가 많습니다. 예를 들어 shared-ui가 feature-auth를 import하고 있으면 UI 라이브러리를 고칠 때마다 인증 기능이 다시 테스트됩니다. 이런 역방향 의존을 막으려면 project.json의 tags(예: type:ui, type:feature)와 @nx/enforce-module-boundaries 규칙을 함께 써서 lint 단계에서 차단합니다.


Nx Cloud 원격 캐싱

설정

nx connect-to-nx-cloud

최신 버전에서는 같은 기능이 nx connect라는 이름으로 제공됩니다.

원격 캐싱

{
  "tasksRunnerOptions": {
    "default": {
      "runner": "@nrwl/nx-cloud",
      "options": {
        "cacheableOperations": ["build", "test", "lint"],
        "accessToken": "YOUR_TOKEN"
      }
    }
  }
}

위 형태도 Nx 16 이하의 설정입니다. 현재는 nx.json 최상위에 "nxCloudId"(또는 예전의 "nxCloudAccessToken")만 두면 기본 러너가 원격 캐시를 사용합니다. 원격 캐시의 효과는 “다른 사람이나 CI가 이미 계산한 결과를 내가 재사용”하는 데서 나옵니다. 동료가 PR에서 빌드한 라이브러리를 내 로컬에서 다시 빌드하지 않아도 되고, CI의 여러 잡이 같은 결과를 공유합니다.

주의할 점은 토큰 권한입니다. 읽기·쓰기 토큰을 nx.json에 커밋하면 저장소를 읽을 수 있는 누구나 캐시에 결과를 써 넣을 수 있고, 이론적으로 오염된 빌드 산출물이 다른 사람의 빌드로 복원될 수 있습니다. 개발자 로컬에는 읽기 전용 토큰을 두고, 쓰기 권한 토큰은 CI의 시크릿으로만 주입하는 것이 권장 구성입니다.


공유 타입을 쓰는 풀스택 앱 구성

구조

my-workspace/
├── apps/
│   ├── web/              # Next.js
│   └── api/              # Nest.js
└── libs/
    ├── shared/types/     # 공유 타입
    └── shared/utils/     # 공유 유틸

모노레포를 쓰는 가장 실감 나는 이유 중 하나가 프론트엔드와 백엔드가 같은 타입 정의를 공유하는 것입니다. 별도 저장소에서는 API 응답 타입을 양쪽에 복사하거나 OpenAPI 스펙에서 생성해야 하지만, 한 저장소에서는 라이브러리 하나를 두 앱이 import하면 됩니다.

공유 타입

// libs/shared/types/src/lib/user.ts
export interface User {
  id: number;
  email: string;
  name: string;
}

API

// apps/api/src/app/users/users.controller.ts
import { Controller, Get } from '@nestjs/common';
import { User } from '@my-workspace/shared-types';
@Controller('users')
export class UsersController {
  @Get()
  getUsers(): User[] {
    return [
      { id: 1, email: '[email protected]', name: 'John' },
    ];
  }
}

Web

// apps/web/src/app/page.tsx
import { User } from '@my-workspace/shared-types';
export default async function Home() {
  const response = await fetch('http://localhost:3000/api/users');
  const users: User[] = await response.json();
  return (
    <ul>
      {users.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

이제 User에 필드를 추가하거나 이름을 바꾸면 shared-types에 의존하는 api와 web이 모두 affected로 잡히고, 타입 체크에서 한쪽만 고친 실수가 바로 드러납니다. 다만 이 구조에도 한계가 있습니다. 첫째, 타입은 컴파일 시점에만 존재하므로 response.json()의 결과가 실제로 User[]라는 보장은 없습니다. 배포 순서가 어긋나 구버전 API가 응답하는 경우를 생각하면 런타임 검증(zod 같은 스키마 라이브러리)을 공유 라이브러리에 함께 두는 것이 더 안전합니다. 둘째, 공유 타입 라이브러리에 Nest의 데코레이터나 서버 전용 코드를 넣기 시작하면 프론트엔드 번들에 서버 의존성이 딸려 들어갑니다. shared-types는 의존성이 없는 순수 타입과 상수만 담도록 유지하십시오.

제가 모노레포에서 이 구조를 쓸 때 가장 흔하게 부딪히는 문제는 apps/web이 apps/api의 파일을 상대 경로(../../api/src/...)로 직접 import하는 경우입니다. 당장은 동작하지만 Nx 그래프에서 앱이 앱에 의존하는 형태가 되어 affected 범위가 넓어지고, 서버 코드가 클라이언트 번들에 섞입니다. 공유할 코드가 생기면 앱에서 꺼내 라이브러리로 옮기는 습관을 들이는 것이 결국 캐시 효율과 빌드 안정성을 모두 지키는 방법입니다.


Nx 요약

  • Nx는 프로젝트 그래프를 바탕으로 무엇을 다시 실행할지 결정하는 모노레포 빌드 시스템입니다.
  • Computation Caching은 입력 해시가 같으면 결과를 복원합니다. 환경 변수처럼 파일이 아닌 입력은 명시적으로 inputs에 넣어야 합니다.
  • Affected는 base 커밋 설정이 정확해야 의미가 있습니다. CI에서는 전체 이력을 받고 마지막 성공 커밋을 base로 씁니다.
  • 예제나 블로그의 nx.json을 복사할 때는 cacheableOperations(Nx 16 이하)와 cache: true(Nx 17 이상) 중 어느 버전 기준인지 확인합니다.

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Turborepo와 비교하면 어떤가요?

A. Nx는 코드 생성기, 모듈 경계 규칙, 분산 실행, 자동 마이그레이션까지 제공하는 대신 개념이 많습니다. Turborepo는 기존 workspace 위에 캐싱과 파이프라인만 얹는 가벼운 도구라 도입이 쉽습니다.

Q. Lerna와 비교하면 어떤가요?

A. Lerna는 2022년에 Nx 팀(Nrwl)이 관리를 넘겨받았고, 현재 Lerna의 태스크 실행과 캐싱은 내부적으로 Nx를 사용합니다. 패키지 버전 관리와 퍼블리시가 주목적이면 Lerna, 빌드 오케스트레이션이 주목적이면 Nx를 직접 쓰는 식으로 나뉩니다.

Q. Nx Cloud가 필수인가요?

A. 아니요, 로컬 캐싱만으로도 충분히 효과가 있습니다. Nx Cloud는 캐시를 팀과 CI 사이에 공유하고 작업을 여러 머신에 분산할 때 유용합니다.

Q. Nx 메이저 버전은 어떻게 업그레이드하나요?

A. 메이저 버전마다 설정 형식이 바뀌어 왔으므로 업그레이드는 nx migrate latest로 진행하는 것이 안전합니다.