Published on

Turborepo로 모노레포 작업 구조와 빌드 최적화하기

Authors
  • avatar
    Name
    GW

개요

여러 서비스를 하나의 저장소에서 관리하다 보면 자연스럽게 공통으로 사용하는 코드가 생깁니다.

UI 컴포넌트나 유틸리티뿐 아니라 TypeScript, ESLint, Tailwind CSS 같은 개발 환경 설정도 여러 서비스에서 공유할 수 있습니다.

하나의 Repository
↓
여러 App과 공통 Package를 Workspace로 관리
↓
UI / Utils / 개발 설정 등을 Package로 공유
↓
각 App이 필요한 Package를 의존성으로 사용

이러한 구조에서는 공통 코드를 여러 서비스에서 재사용하기 쉬워지지만, 프로젝트가 많아질수록 새로운 문제가 생깁니다.

각 앱과 패키지마다 build, typecheck, test 같은 작업이 존재하고 서로 의존 관계까지 가지기 때문입니다.

예를 들어 web 앱이 ui 패키지를 사용한다면 web을 빌드하기 전에 ui의 빌드 결과가 필요할 수도 있습니다.

web App의 Build 실행
↓
web이 사용하는 Package 확인
↓
ui Package의 Build가 먼저 필요한지 확인
↓
필요하다면 ui Package를 먼저 Build
↓
공통 Package 준비 완료
↓
web App Build 실행

프로젝트가 몇 개 없을 때는 이러한 순서를 직접 관리할 수도 있습니다.

하지만 앱과 공통 패키지가 계속 늘어나면 어떤 작업을 어떤 순서로 실행해야 하는지, 그리고 이미 실행한 작업을 정말 다시 실행해야 하는지까지 함께 관리해야 합니다.

이번 글에서는 이러한 문제를 해결하기 위해 Turborepo를 중심으로 다음 내용을 살펴보겠습니다.

  • Monorepo에서 앱과 공통 패키지를 어떻게 관리할 수 있는지
  • Turborepo가 Package와 Task의 의존 관계를 어떻게 다루는지
  • dependsOn을 이용해 작업 순서를 어떻게 정의하는지
  • Build, Type Check, Test 같은 작업을 어떻게 통합할 수 있는지
  • Turborepo가 Task의 결과를 어떻게 Cache하고 재사용하는지
  • 변경된 코드의 영향을 기준으로 필요한 작업만 실행하는 방법

여러 프로젝트를 하나의 Repository에서 관리하기

먼저 모노레포는 여러 프로젝트를 하나의 Repository에서 함께 관리하는 방식입니다.

예를 들어 여러 웹 애플리케이션과 공통 패키지를 다음과 같이 구성할 수 있습니다.

repository
├── apps
│   ├── web
│   ├── admin
│   └── commerce
│
└── packages
    ├── ui
    ├── utils
    ├── eslint-config
    ├── typescript-config
    └── tailwind-config

apps에는 실제로 실행되는 애플리케이션을 두고, packages에는 여러 애플리케이션에서 함께 사용할 수 있는 코드를 분리합니다.

예를 들어 web과 admin이 동일한 Button 컴포넌트를 사용한다면 각각 구현하는 대신 ui 패키지를 의존하도록 만들 수 있습니다.

packages/ui
      ↑
      │ 사용
      │
┌─────┴─────┐
│           │
web       admin

여기서 중요한 것은 단순히 여러 프로젝트를 같은 폴더에 넣는 것만으로는 충분하지 않다는 점입니다.

각 패키지가 서로를 의존성으로 사용할 수 있도록 Workspace를 구성해야 합니다.

pnpm을 사용한다면 pnpm-workspace.yaml을 통해 Workspace에 포함할 패키지의 범위를 정의할 수 있습니다.

*pnpm Workspace

예를 들어 다음과 같이 구성할 수 있습니다.

packages:
  - 'apps/*'
  - 'packages/*'

이제 apps와 packages 아래의 각 프로젝트를 하나의 Workspace에서 관리할 수 있습니다.

예를 들어 web에서 공통 UI 패키지를 사용한다면 다음과 같은 의존 관계를 만들 수 있습니다.

{
  "dependencies": {
    "@repo/ui": "workspace:*"
  }
}

이렇게 구성하면 애플리케이션과 공통 패키지를 하나의 Repository에서 관리하면서 패키지 사이의 의존 관계도 표현할 수 있습니다.

하지만 여기까지는 패키지를 어떻게 관리할 것인가에 대한 문제를 해결한 것입니다.

프로젝트가 커지면 또 다른 문제가 생깁니다.

Monorepo가 커지면 작업 실행도 복잡해진다

각 패키지에는 보통 여러 작업이 존재합니다.

web
├── build
├── typecheck
└── test

admin
├── build
├── typecheck
└── test

ui
├── build
├── typecheck
└── test

utils
├── build
├── typecheck
└── test

패키지가 4개라면 이미 여러 개의 Task가 존재합니다.

여기에 애플리케이션과 공통 패키지가 계속 추가되면 실행해야 하는 Task 역시 빠르게 증가합니다.

더 큰 문제는 각 Task가 완전히 독립적이지 않을 수 있다는 것입니다.

예를 들어 다음과 같은 의존 관계가 있다고 가정해보겠습니다.

web 애플리케이션
↓ 공통 UI 패키지를 사용
ui 패키지
↓ 공통 유틸리티 패키지를 사용
utils 패키지

web을 빌드하기 위해 ui의 빌드 결과가 필요하고, 다시 ui를 빌드하기 위해 utils의 빌드 결과가 필요할 수 있습니다.

그렇다면 올바른 실행 순서는 다음과 같습니다.

web App Build 요청
↓
web이 의존하는 ui Package 확인
↓
ui가 의존하는 utils Package까지 확인
↓
utils Package Build
↓
ui Package Build
↓
web App Build

이를 매번 개발자가 직접 관리한다면 프로젝트가 커질수록 실행 스크립트 역시 복잡해집니다.

예를 들어 단순하게 생각하면 다음처럼 만들 수도 있습니다.

{
  "scripts": {
    "build:web": "pnpm --filter utils build && pnpm --filter ui build && pnpm --filter web build"
  }
}

처음에는 동작할 수 있습니다.

하지만 새로운 패키지가 추가되거나 의존 관계가 바뀔 때마다 스크립트도 함께 수정해야 합니다.

새로운 Package 추가
↓
Package 의존 관계 변경
↓
Build 실행 순서 변경 필요
↓
실행 스크립트도 함께 수정

즉, 실제 문제는 단순히 여러 명령어를 실행하는 것이 아니라

패키지와 작업 사이의 의존 관계를 기반으로 어떤 Task를 어떤 순서로 실행해야 하는지 결정하는 것

으로 바뀝니다.

Turborepo는 이러한 작업을 Task 단위로 정의하고 의존 관계에 따라 실행할 수 있도록 도와줍니다.

*Turborepo - Configuring tasks

Package Graph와 Task Graph

Turborepo를 이해할 때 중요한 개념 중 하나가 Graph입니다.

먼저 Package Graph는 패키지 사이의 의존 관계를 나타냅니다.

예를 들어 다음과 같은 구조가 있다고 해보겠습니다.

web 애플리케이션
↓ 공통 UI 패키지를 사용
ui 패키지
↓ 공통 유틸리티 패키지를 사용
utils 패키지

web/package.json에서 ui를 의존하고,

{
  "dependencies": {
    "@repo/ui": "workspace:*"
  }
}

ui/package.json에서 다시 utils를 의존한다면,

{
  "dependencies": {
    "@repo/utils": "workspace:*"
  }
}

패키지 사이에는 다음과 같은 관계가 만들어집니다.

web
↓ ui를 사용
ui
↓ utils를 사용
utils

하지만 실제 개발 과정에서는 패키지만 존재하는 것이 아닙니다.

각 패키지 안에는 build, typecheck, test 같은 Task가 존재합니다.

따라서 Turborepo는 실제 명령을 실행할 때 Task 사이의 관계까지 고려합니다.

개념적으로 보면 다음과 같습니다.

# Package Graph
web
↓
ui
↓
utils

# 각 Package의 Build Task
web#build
ui#build
utils#build

# Task Graph
web#build
↓ ui#build에 의존
ui#build
↓ utils#build에 의존
utils#build

즉, Package Graph가 "어떤 패키지가 어떤 패키지에 의존하는가" 를 표현한다면,

Task Graph는 "어떤 작업이 어떤 작업보다 먼저 실행되어야 하는가"를 표현한다고 이해할 수 있습니다.

이러한 관계를 turbo.json에서 정의할 때 중요한 옵션 중 하나가 dependsOn입니다.

dependsOn으로 작업의 의존 관계 정의하기

예를 들어 turbo.json에서 다음과 같이 build Task를 정의할 수 있습니다.

{
  "$schema": "https://turborepo.com/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "dist/**"]
    }
  }
}

여기서 중요한 부분은 다음 설정입니다.

"dependsOn": ["^build"]

^(캐럿)는 현재 패키지가 의존하고 있는 다른 패키지의 동일한 Task를 먼저 실행하도록 지정하는 의미로 사용됩니다.

예를 들어 다음과 같은 Package Graph가 있다고 가정해보겠습니다.

web
↓ ui를 사용
ui
↓ utils를 사용
utils

이 상태에서 다음 명령을 실행합니다.

turbo run build

각 패키지의 build가 실행되어야 하고 build에 ^build 의존성이 설정되어 있다면, 의존 관계를 고려해 실행 순서가 결정됩니다.

전체 Build Task 실행 요청 (turbo run build)
↓
각 Package의 의존 관계 확인
↓
web이 ui에 의존하고 있는 것을 확인
↓
ui가 utils에 의존하고 있는 것을 확인
↓
의존성이 없는 utils#build 실행
↓
utils 완료 후 ui#build 실행
↓
ui 완료 후 web#build 실행

이 방식의 중요한 점은 개발자가 패키지의 실행 순서를 하나씩 명령어에 작성하지 않아도 된다는 것입니다.

패키지의 의존 관계와 Task의 관계를 선언하면 Turborepo가 이를 바탕으로 실행 순서를 결정할 수 있습니다.

^build와 build는 무엇이 다를까?

처음 dependsOn을 보면 ^build의 ^가 조금 헷갈릴 수 있습니다.

예를 들어 다음 두 설정은 의미가 다릅니다.

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"]
    }
  }
}

그리고

{
  "tasks": {
    "build": {
      "dependsOn": ["build"]
    }
  }
}

^build는 현재 패키지가 의존하는 패키지의 build 를 의미합니다.

반면 build처럼 ^ 없이 작성하면 같은 패키지 안의 다른 Task 관계를 정의할 때 사용하는 방식입니다.

예를 들어 test를 실행하기 전에 같은 패키지의 build가 반드시 필요하다면 다음처럼 표현할 수 있습니다.

{
  "tasks": {
    "test": {
      "dependsOn": ["build"]
    }
  }
}

개념적으로 비교하면 다음과 같습니다.

dependsOn: ["^build"]

현재 Package가 의존하는 다른 Package 확인
↓
Dependency Package의 build를 먼저 실행
dependsOn: ["build"]

현재 Package 안의 build를 먼저 실행
↓
현재 Task 실행

즉 dependsOn을 이용하면 패키지 사이의 의존 관계와 하나의 패키지 안에서의 Task 관계를 함께 표현할 수 있습니다.

Build뿐 아니라 Type Check와 Test도 Task로 관리하기

Task Graph는 Build에만 사용할 필요는 없습니다.

모노레포에서는 다음과 같은 작업들도 여러 패키지에 반복해서 존재할 수 있습니다.

모노레포에서 반복될 수 있는 Task
├─ 빌드 (Build)
├─ 타입 검사 (Type Check)
├─ 린트 검사 (Lint)
├─ 단위 테스트 (Unit Test)
├─ 통합 테스트 (Integration Test)
└─ Storybook 빌드

각 패키지에서 동일한 이름의 script를 제공하고 루트에서는 Turborepo를 실행하도록 구성할 수 있습니다.

예를 들어 각 패키지가 다음과 같은 script를 가지고 있다고 가정해보겠습니다.

{
  "scripts": {
    "build": "next build",
    "typecheck": "tsc --noEmit",
    "test": "vitest run"
  }
}

루트에서는 다음과 같이 사용할 수 있습니다.

{
  "scripts": {
    "build": "turbo run build",
    "typecheck": "turbo run typecheck",
    "test": "turbo run test"
  }
}

그러면 개발자는 각각의 패키지로 이동해 명령을 실행할 필요 없이 Repository의 루트에서 동일한 진입점을 사용할 수 있습니다.

pnpm build
pnpm typecheck
pnpm test

실행 흐름을 단순화하면 다음과 같습니다.

Repository Root에서 pnpm build 실행
↓
turbo run build 호출
↓
Workspace에서 build Task를 가진 Package 확인
↓
Package / Task의 의존 관계 분석
↓
먼저 실행해야 하는 Dependency Task 결정
↓
서로 의존하지 않아 동시에 실행할 수 있는 Task는 병렬로 실행
↓
의존 Task가 완료되면 다음 Task 실행

이렇게 하면 앱이나 패키지가 추가되더라도 루트의 실행 방법 자체는 크게 달라지지 않습니다.

특히 로컬 개발 환경과 CI에서 동일한 명령어로 Task를 실행할 수 있다는 점도 장점입니다.

예를 들어 Pull Request가 생성되었을 때 테스트를 실행하는 CI 환경에서도 각 패키지의 테스트 명령어를 하나씩 작성할 필요 없이, Repository 루트에서 사용하는 동일한 pnpm test 명령어로 전체 테스트 Task를 실행할 수 있습니다.

# .github/workflows/ci.yml

- name: Run tests
  run: pnpm test

그리고 pnpm test가 내부적으로 turbo run test를 실행하도록 구성할 수 있습니다.

Pull Request 생성
↓
GitHub Actions의 CI Workflow 시작
↓
CI 서버에서 pnpm test 실행
↓
turbo run test 실행
↓
각 Package의 Test Task 확인
↓
Task Graph를 기준으로 필요한 Test Task 실행
↓
테스트 결과 확인

이렇게 구성하면 로컬과 CI가 서로 다른 테스트 실행 구조를 가지는 것을 줄이고 동일한 Task 정의를 재사용할 수 있습니다.

하지만 여기서 한 가지 문제가 남습니다.

Task의 실행 순서를 아무리 잘 구성하더라도 매번 모든 작업을 처음부터 다시 실행한다면 Repository가 커질수록 여전히 많은 시간이 필요합니다.

예를 들어 web의 일부 코드만 수정했는데 이전과 동일한 ui와 utils의 Build 결과까지 매번 다시 만들어야 한다면 불필요한 작업이 반복될 수 있습니다.

코드 변경
↓
Task Graph를 이용해 올바른 실행 순서 결정
↓
utils#build 실행
↓
ui#build 실행
↓
web#build 실행
↓
다음 실행에서도 동일한 작업을 다시 수행?

그래서 다음으로 필요한 것이 Cache입니다.

Task Graph가

어떤 작업을 어떤 순서로 실행해야 하는가?

를 해결한다면,

Cache는

이 작업을 정말 다시 실행해야 하는가?

라는 문제를 해결합니다.

다음에서는 Turborepo가 Task의 입력을 기반으로 어떻게 Hash를 만들고, 이전 실행 결과를 Cache에서 재사용하는지 살펴보겠습니다.

Turborepo는 어떻게 실행 결과를 Cache할까?

앞에서는 Task Graph를 이용해 어떤 작업을 어떤 순서로 실행할 것인지 살펴봤습니다.

하지만 실행 순서를 효율적으로 구성하는 것만으로는 충분하지 않습니다.

코드와 설정이 이전 실행과 동일한데도 매번 Build나 Type Check를 처음부터 다시 실행한다면 같은 작업을 반복하게 됩니다.

Turborepo는 이러한 반복 작업을 줄이기 위해 Task 단위의 Cache를 제공합니다.

Turborepo의 Task Cache

위 그림처럼 Turborepo는 Build, Test, Lint와 같은 Task의 실행 결과를 Cache할 수 있습니다.

그림의 12 successful, 12 total은 12개의 Task가 모두 성공했다는 의미이고, 12 cached, 12 total은 12개 Task 모두 Cache Hit이 발생해 실제 작업을 다시 수행하지 않고 이전 실행 결과를 재사용했다는 의미입니다.

따라서 그림에서는 전체 작업이 약 80ms 만에 완료된 것처럼, 동일한 입력을 가진 Task를 처음부터 다시 실행하지 않아 변경되지 않은 작업의 반복 실행 시간과 비용을 줄일 수 있습니다.

전체적인 흐름은 다음과 같습니다.

Build / Type Check / Test 등의 Task 실행 요청
↓
Task 실행 결과에 영향을 주는 Input 확인
↓
Input을 기반으로 Task Hash 계산
↓
동일한 Hash의 실행 결과가 Cache에 있는지 확인
↓
Cache Hit
→ 실제 Task 실행을 건너뛰고 이전 결과 재사용

Cache Miss
→ Task를 실제로 실행
→ 실행 결과를 Cache에 저장
→ 다음 실행에서 재사용할 수 있도록 준비

여기서 중요한 것은 이전에 같은 Task를 실행했는지만 확인하는 것이 아니라, 이번 Task가 이전과 동일한 조건에서 실행되는지를 판단한다는 점입니다.

이를 이해하려면 먼저 Input과 Hash가 무엇인지 살펴볼 필요가 있습니다.

*Turborepo - Caching

Task Input은 무엇일까?

Input은 쉽게 말하면 Task의 실행 결과에 영향을 줄 수 있는 입력 정보입니다.

예를 들어 ui 패키지의 Build Task라면 다음과 같은 정보들이 결과에 영향을 줄 수 있습니다.

ui#build의 Input
├─ 소스 코드
├─ package.json과 의존성 정보
├─ 의존하는 Package의 변경
├─ 환경 변수
└─ Task와 관련된 설정

예를 들어 Button.tsx의 코드가 변경되었다면 Build 결과도 달라질 수 있으므로 Task의 Input이 변경된 것으로 볼 수 있습니다.

Task Hash는 무엇일까?

Turborepo는 이러한 Input을 바탕으로 Task를 식별할 수 있는 Hash 값을 계산합니다.

개념적으로 보면 다음과 같습니다.

소스 코드
+
의존성 정보
+
환경 변수
+
Task 관련 설정
↓
Hash 계산
↓
a3f91c82...

Hash는 여러 Input을 바탕으로 계산된 Task의 현재 상태를 식별하기 위한 값이라고 이해할 수 있습니다.

Task의 Input이 이전 실행과 동일하다면 동일한 Hash가 계산될 수 있고, Turborepo는 해당 Hash에 저장된 Cache가 있는지 확인합니다.

Task Input
↓
Hash 계산
↓
이전과 동일한 Hash
↓
Cache Hit
↓
Task를 다시 실행하지 않고 이전 결과 재사용

반대로 소스 코드처럼 Task 결과에 영향을 주는 Input이 변경되면 Hash도 달라집니다.

소스 코드 변경
↓
Task Input 변경
↓
새로운 Hash 계산
↓
Cache에 동일한 Hash가 없음
↓
Cache Miss
↓
Task 다시 실행
↓
새로운 실행 결과를 Cache에 저장

따라서 Turborepo의 Cache는 단순히 "이전에 Build를 실행했으니 결과를 재사용한다" 는 방식이 아닙니다.

Task 결과에 영향을 주는 Input을 기반으로 Hash를 계산하고, 동일한 작업이라고 판단할 수 있을 때 이전 실행 결과를 재사용하는 방식입니다.

따라서 Cache를 정확하게 활용하려면 어떤 값이 Task의 결과에 영향을 주는지 올바르게 정의하는 것도 중요합니다.

inputs와 outputs는 무엇이 다를까?

Turborepo의 Cache를 처음 설정할 때 inputs와 outputs가 헷갈릴 수 있습니다.

두 설정은 역할이 다릅니다.

inputs
→ 무엇이 바뀌면 Task를 다시 실행해야 하는가?

outputs
→ Task 실행 후 어떤 결과물을 Cache에 저장할 것인가?

쉽게 말하면,

inputs는 Task를 다시 실행해야 하는지 판단하는 데 사용되고, outputs는 Task 실행으로 생성된 결과 중 Cache에 저장하고 복원할 파일을 지정합니다.

예를 들어 다음과 같이 설정할 수 있습니다.

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".next/**", "!.next/cache/**", "dist/**"]
    }
  }
}

Next.js 애플리케이션이라면 .next가 Build 결과물이 될 수 있고, 일반적인 라이브러리 패키지라면 dist가 Build 결과물이 될 수 있습니다.

Build 실행
↓
.next / dist 등의 Build 결과 생성
↓
Turborepo가 Task 결과를 Cache
↓
다음 실행에서 동일한 Task Hash가 확인되면
↓
Cache Hit
↓
Task 실행을 건너뜀
↓
필요한 Output을 Cache에서 복원

outputs는 "이 파일이 변경되면 Cache를 무효화한다"는 의미가 아니라는 점도 중요합니다.

outputs는 Task가 만든 결과물 중 어떤 파일을 Cache에 보관했다가 나중에 복원할 것인지 정의하는 설정입니다.

반대로 Task 실행 전에 결과에 영향을 주는 파일을 명시적으로 좁히거나 추가하고 싶다면 inputs를 고려할 수 있습니다.

환경 변수도 Cache에 영향을 줄 수 있다

Task의 소스 코드가 동일하다고 해서 항상 같은 Build 결과가 만들어지는 것은 아닙니다.

예를 들어 코드에서 다음과 같이 환경 변수를 사용한다고 가정해보겠습니다.

const apiUrl = process.env.NEXT_PUBLIC_API_URL;

소스 코드는 동일하더라도 개발 환경과 운영 환경에서 사용하는 값은 다를 수 있습니다.

같은 Source Code

Development
→ NEXT_PUBLIC_API_URL=https://dev.example.com

Production
→ NEXT_PUBLIC_API_URL=https://api.example.com

즉, 소스 코드는 같지만 환경 변수에 따라 Build 결과가 달라질 수 있습니다.

같은 Source Code
↓
서로 다른 환경 변수 사용
↓
서로 다른 Build 결과

이 차이는 Cache를 사용할 때 중요합니다.

앞에서 살펴본 것처럼 Turborepo는 Task의 Input을 기반으로 Hash를 계산하고, 동일한 Hash의 Cache가 존재하면 이전 실행 결과를 재사용할 수 있습니다.

만약 Build 결과에 영향을 주는 환경 변수가 Hash 계산에 제대로 반영되지 않으면 다음과 같은 문제가 발생할 수 있습니다.

Development
→ Source Code 동일
→ API URL = dev.example.com

Production
→ Source Code 동일
→ API URL = api.example.com

↓
환경 변수의 차이를 Hash에 반영하지 않음
↓
같은 Hash라고 잘못 판단
↓
Cache Hit
↓
Development의 Build 결과를
Production에서 잘못 재사용할 가능성 발생

따라서 Task 결과에 영향을 주는 환경 변수도 Hash 계산에 반영해야 합니다.

Turborepo에서는 env와 globalEnv 등을 통해 환경 변수의 값을 Task Hash 계산에 반영할 수 있습니다.

*Turborepo - Environment Variables

예를 들어 특정 Build Task에 영향을 주는 환경 변수라면 다음과 같이 설정할 수 있습니다.

{
  "tasks": {
    "build": {
      "env": ["NEXT_PUBLIC_API_URL"]
    }
  }
}

이 설정은 NEXT_PUBLIC_API_URL의 값이 Build 결과에 영향을 줄 수 있으므로 Build Task의 Hash를 계산할 때 해당 환경 변수도 고려하도록 정의한 것입니다.

여러 Task에 공통으로 영향을 주는 환경 변수는 globalEnv를 통해 정의할 수도 있습니다.

{
  "globalEnv": ["NODE_ENV", "NEXT_RUNTIME"]
}

두 설정의 차이를 간단히 정리하면 다음과 같습니다.

env
→ 특정 Task에 영향을 주는 환경 변수
→ 해당 Task의 Hash 계산에 반영

globalEnv
→ 여러 Task에 공통으로 영향을 주는 환경 변수
→ 전체 Task의 Hash 계산에 반영

결국 Cache를 구성할 때는 단순히 어떤 결과물을 Cache에 저장할 것인지뿐만 아니라, 어떤 변화가 Task의 결과를 바꿀 수 있는지도 함께 고려해야 합니다.

Build뿐 아니라 Type Check도 Cache할 수 있을까?

Turborepo의 Cache는 Build에만 사용하는 기능이 아닙니다.

Task의 결과가 결정적이고 반복해서 실행할 필요가 없는 작업이라면 Build 외의 작업에도 활용할 수 있습니다.

예를 들어 TypeScript의 증분 컴파일(Incremental Compilation)을 사용하는 경우 tsconfig.tsbuildinfo와 같은 파일이 생성될 수 있습니다.

{
  "tasks": {
    "typecheck": {
      "outputs": ["tsconfig.tsbuildinfo"]
    }
  }
}

개념적으로는 다음과 같은 구조입니다.

# 첫 번째 Type Check

Type Check 실행
↓
tsconfig.tsbuildinfo 생성
↓
Task 결과와 tsconfig.tsbuildinfo를 Cache에 저장


# 다음 Type Check

동일한 Task Hash
↓
Cache Hit
↓
Type Check 실행 생략
↓
Cache에 저장된 tsconfig.tsbuildinfo 복원

Lint 역시 .eslintcache처럼 Task가 생성하는 파일이 있다면 Output으로 관리하는 방식을 고려할 수 있습니다.

다만 모든 Task에 outputs를 설정할 필요는 없습니다.

outputs는 Task가 생성한 파일 중 Cache Hit 시 다시 복원해야 할 결과물이 있을 때 사용합니다.

별도의 결과 파일을 만들지 않는 Task도 Turborepo는 실행 결과와 로그를 Cache할 수 있습니다.

따라서 먼저 해당 Task가 어떤 결과를 생성하는지 파악하고, 실제로 복원이 필요한 파일이 있을 때 outputs를 정의하는 것이 중요합니다.

Local Cache는 어디에서 재사용될까?

기본적인 Cache는 같은 개발 환경에서 반복되는 Task 실행을 줄이는 데 사용할 수 있습니다.

예를 들어 개발자가 같은 Repository에서 Build를 반복한다고 생각해보겠습니다.

첫 번째 Build
↓
Cache Miss
↓
Task 실제 실행
↓
실행 결과 Local Cache 저장
↓
코드 변경 없이 다시 Build
↓
동일한 Task Hash
↓
Local Cache Hit
↓
이전 Task 결과 재사용

이 방식만으로도 로컬에서 반복되는 Build, Test 등의 작업을 줄일 수 있습니다.

하지만 Local Cache에는 한계가 있습니다.

개발자 A의 컴퓨터에서 만들어진 Local Cache를 개발자 B의 컴퓨터나 새로운 CI Runner가 자동으로 공유하는 것은 아니기 때문입니다.

개발자 A
↓
Task 실행
↓
Local Cache 저장


개발자 B
↓
A의 Local Cache는 없음
↓
동일한 Task라도 다시 실행

이 문제를 확장해서 해결할 수 있는 기능이 Remote Cache입니다.

Remote Cache는 무엇이 다를까?

앞에서 살펴본 Local Cache만으로도 같은 환경에서 반복되는 Task 실행을 줄일 수 있습니다.

하지만 Local Cache는 기본적으로 각 머신의 로컬 파일 시스템에 저장되기 때문에 다른 환경과 Cache를 공유하지 못한다는 한계가 있습니다.

각 머신이 독립적인 Local Cache를 사용하는 구조

예를 들어 개발자 A가 이미 Build를 수행했더라도 개발자 B의 컴퓨터나 CI에는 해당 Cache가 존재하지 않습니다.

개발자 A
→ Build 실행
→ Local Cache 저장

개발자 B
→ A의 Local Cache에 접근할 수 없음
→ 동일한 Build 다시 실행

CI
→ A와 B의 Local Cache에 접근할 수 없음
→ 동일한 Build 다시 실행

즉 Task의 Input이 완전히 동일하더라도 머신이 다르면 같은 작업을 다시 수행할 수 있습니다.

Remote Cache는 이러한 Local Cache의 범위를 여러 환경으로 확장합니다.

여러 머신이 하나의 Remote Cache를 공유하는 구조

Remote Cache를 사용하면 Task 결과를 로컬 환경에만 보관하지 않고, 개발자와 CI 등이 함께 접근할 수 있는 원격 Cache에 저장하고 재사용할 수 있습니다.

개념적으로는 다음과 같습니다.

개발자 A가 Task 실행
↓
Task Hash 계산
↓
Cache Miss
↓
Task 실제 실행
↓
실행 결과를 Remote Cache에 저장
↓
개발자 B 또는 CI에서 동일한 Task 실행
↓
동일한 Task Hash 계산
↓
Remote Cache에서 동일한 결과 확인
↓
Cache Hit
↓
Task를 다시 실행하지 않고 A가 만든 실행 결과 재사용

즉 Local Cache와 Remote Cache의 가장 큰 차이는 Cache를 재사용할 수 있는 범위입니다.

Local Cache

각 머신이 자신의 Cache를 관리
↓
같은 머신에서 반복되는 작업 감소
Remote Cache

여러 머신이 하나의 Cache를 공유
↓
개발자 / CI 등 서로 다른 환경에서도 동일한 Task 결과 재사용

따라서 Remote Cache를 활용하면 한 개발자가 이미 수행한 작업을 다른 개발자나 CI가 다시 수행하는 중복 작업까지 줄일 수 있습니다.

다만 Remote Cache를 사용하려면 팀의 CI/CD 환경과 보안 정책 등을 함께 고려해야 합니다.

특히 환경 변수나 민감한 정보가 Task Output에 포함될 가능성이 있다면 어떤 결과를 공유할 것인지도 주의해야 합니다.

따라서 Remote Cache는 단순히 켜는 기능이라기보다 프로젝트의 실행 환경과 Cache 대상에 맞춰 검토해야 합니다.

*Turborepo - Remote Caching

pnpm Cache와 Turborepo Cache는 같은 것일까?

CI를 구성하다 보면 pnpm의 Cache와 Turborepo의 Cache를 함께 보게 될 수 있습니다.

두 Cache는 비슷해 보이지만 해결하는 문제가 다릅니다.

pnpm은 패키지 설치 과정에서 사용하는 패키지 데이터를 효율적으로 관리하고 재사용합니다.

반면 Turborepo의 Task Cache는 build, test, typecheck 같은 Task의 실행 결과를 재사용하는 것이 목적입니다.

pnpm Cache

pnpm install 시 Package 데이터를 pnpm Store에 저장
↓
다음 설치에서 저장된 Package 데이터 재사용
↓
Dependency 설치 시간 및 비용 감소
Turborepo Cache

Build / Test / Type Check 등의 Task 실행 결과 저장
↓
동일한 Task Hash라면 이전 결과 재사용
↓
Task 실행 비용 감소

예를 들어 CI에서 pnpm 관련 Cache가 존재한다고 해서 다음 Build가 자동으로 생략되는 것은 아닙니다.

Turborepo Cache까지 활용한다면 그 다음 단계에서 Task의 Hash를 확인하고 이전 결과를 재사용할 수 있습니다.

Dependency 준비
↓
turbo run build
↓
Task Hash 계산
↓
Cache 확인
↓
Cache Hit
↓
Build Task 실행 생략 및 결과 재사용

따라서 모노레포의 CI 성능을 살펴볼 때는 Dependency 설치 Cache와 Task 실행 Cache를 구분해서 보는 것이 중요합니다.

변경된 코드의 영향을 기준으로 작업하기

Cache를 이용하면 동일한 입력을 가진 Task의 결과를 재사용할 수 있습니다.

하지만 모노레포에서는 그보다 한 단계 앞에서 생각해볼 수 있는 문제가 있습니다.

이번 변경이 어떤 App과 Package에 영향을 주는가?

예를 들어 다음과 같은 구조가 있다고 가정해보겠습니다.

repository
├── apps
│   ├── web
│   └── admin
│
└── packages
    ├── ui
    └── utils

web 내부의 코드만 변경되었다면 영향 범위를 비교적 쉽게 판단할 수 있습니다.

apps/web 변경
↓
web에 직접적인 영향

하지만 공통 패키지가 변경되면 이야기가 달라집니다.

packages/ui 변경
↓
ui를 사용하는 Package 확인
↓
web이 ui를 사용
admin도 ui를 사용
↓
web과 admin 모두 영향을 받을 수 있음

따라서 단순히 각 App 디렉터리의 변경 여부만 확인해서는 정확한 영향 범위를 판단하기 어렵습니다.

apps/web 변경 없음
↓
web은 변경의 영향을 받지 않았을까?
↓
packages/ui가 변경되었다면
↓
ui를 사용하는 web도 영향을 받을 수 있음

즉 모노레포에서는 직접 변경된 파일뿐 아니라 Package 사이의 의존 관계까지 함께 고려해야 합니다.

이러한 관점은 Build나 Test뿐 아니라 실제 배포에서도 중요합니다.

변경되지 않은 App의 배포까지 필요할까?

하나의 Repository에 여러 App이 있고 각각 별도의 배포 대상으로 연결되어 있다고 생각해보겠습니다.

하나의 Monorepo
↓
web / admin / commerce 등 여러 App
↓
각 App이 별도의 Deployment 대상

이 상태에서 특정 App의 코드만 변경되었습니다.

apps/web만 변경
↓
web은 Build 필요
↓
admin은?
commerce는?

admin이나 commerce가 web과 아무런 의존 관계가 없다면 이번 변경으로 다시 Build할 필요가 없을 수 있습니다.

그런데 모든 App의 Deployment가 시작된다면 변경과 관계없는 App에도 Build 자원이 사용됩니다.

web 변경
↓
여러 App의 Deployment 시작
↓
web Build + admin Build + commerce Build
↓
변경과 관계없는 Build까지 수행

App의 수가 많아질수록 이러한 작업은 Build Queue와 배포 시간에 영향을 줄 수 있습니다.

따라서 모노레포의 배포에서는

이번 변경으로 실제 영향을 받는 App만 Build할 수 없을까?

라는 문제를 함께 생각할 필요가 있습니다.

Vercel에서 불필요한 Build 건너뛰기

Vercel은 모노레포에서 변경의 영향을 받지 않은 프로젝트의 Deployment를 건너뛸 수 있는 기능을 제공합니다.

*Vercel - Using Monorepos

여기서 알아둘 것은 Vercel에는 불필요한 Build를 줄일 수 있는 방법이 하나만 있는 것이 아니라는 점입니다.

대표적으로 다음 두 가지를 구분해서 볼 수 있습니다.

1. Skipping Unaffected Projects (Deployment Skip)
Vercel이 변경된 프로젝트와 Dependency 확인
↓
현재 프로젝트가 변경의 영향을 받았는지 판단
↓
영향 없음
↓
Deployment Build 자체를 건너뜀

2. Ignored Build Step
Deployment 시작
↓
Ignored Build Step의 Command 실행
↓
개발자가 정의한 조건으로 Build 필요 여부 판단
↓
Build 불필요
↓
Build 취소

둘 다 불필요한 Build를 줄이는 데 사용할 수 있지만 판단되는 시점과 동작 방식이 다릅니다.

Skipping Unaffected Projects (Deployment Skip)

현재 Vercel은 지원되는 Workspace 기반 모노레포에서 프로젝트의 변경 여부를 판단해 영향을 받지 않은 프로젝트의 Build를 자동으로 스킵할 수 있습니다.

이 기능은 현재 지원 조건을 만족하는 모노레포에서 기본적으로 활성화되어 있으며, 필요하다면 Vercel의 Project Settings에서 비활성화할 수 있습니다.

그렇다면 Vercel은 어떤 기준으로 프로젝트가 변경의 영향을 받았는지 판단할까요?

단순히 해당 App 디렉터리의 변경 여부만 확인하는 것은 아닙니다.

Vercel 공식 문서에서는 다음과 같은 경우 프로젝트가 변경된 것으로 판단한다고 설명합니다.

  • 프로젝트 자체의 Source Code가 변경된 경우
  • 프로젝트가 사용하는 내부 Dependency가 변경된 경우
  • Lockfile 변경으로 해당 프로젝트가 사용하는 Dependency가 변경된 경우

따라서 다음과 같은 구조라면

        ui
       ↙  ↘
     web  admin

web과 admin 모두 ui를 사용

web 자체가 변경되지 않았더라도 ui가 변경되면 web이 영향을 받는 프로젝트로 판단될 수 있습니다.

packages/ui 변경
↓
ui를 사용하는 프로젝트 확인
↓
├─ web   → 영향 있음
└─ admin → 영향 있음
↓
web / admin Deployment 진행

반대로 어떤 App과 그 App이 사용하는 내부 Dependency에도 변경이 없다면 해당 프로젝트의 Deployment를 건너뛸 수 있습니다.

코드 변경
↓
App과 내부 Dependency의 변경 여부 확인
↓
영향 있음
→ Deployment 진행

영향 없음
→ Deployment Skip

이 방식의 중요한 장점은 영향받지 않은 프로젝트의 Build가 시작되기 전에 건너뛸 수 있다는 점입니다.

Vercel 문서에 따르면 이 방식으로 건너뛴 프로젝트는 동시 빌드 슬롯(Concurrent Build Slot)을 차지하지 않습니다.

동시 빌드 슬롯은 Vercel이 동시에 빌드할 수 있는 프로젝트의 수를 의미하며, 이 슬롯 개수만큼만 빌드가 동시에 진행되고 나머지는 대기열(Queue)에서 기다립니다.

따라서 여러 App이 하나의 Repository에 연결된 환경에서 Build Queue를 줄이는 데도 도움이 될 수 있습니다.

Ignored Build Step과 turbo-ignore

Vercel은 Ignored Build Step이라는 기능도 제공합니다.

*Vercel - Ignored Build Step

Ignored Build Step에는 특정 Command를 등록할 수 있습니다.

이 Command의 종료 코드에 따라 Build를 계속할지 취소할지가 결정됩니다.

Deployment가 Build 단계에 진입
↓
Ignored Build Step Command 실행
↓
Exit Code 확인
↓
0
→ Build 취소

1
→ Build 계속

일반적인 Shell Script의 성공/실패 의미만 생각하면 처음에는 조금 반대로 느껴질 수 있습니다.

하지만 여기서 Command가 판단하는 것은 "Build를 무시할 수 있는가?" 이기 때문에 0이면 Build를 무시하고, 1이면 Build를 계속합니다.

과거 Turborepo에서는 이 과정에서 turbo-ignore를 사용할 수 있었습니다.

npx turbo-ignore web --fallback=HEAD^1

turbo-ignore는 특정 Workspace와 그 Dependency가 Git 변경 사항의 영향을 받는지 판단하여 불필요한 CI 작업이나 Build를 건너뛰는 데 사용되던 도구입니다.

개념적으로 보면 다음과 같습니다.

web의 Build 여부 판단
↓
turbo-ignore 실행
↓
비교 기준이 되는 Commit과 현재 Commit의 변경 사항 확인
↓
web과 web이 의존하는 Package의 변경 영향 확인
↓
├─ 영향 있음 → Build 계속
└─ 영향 없음 → Build 건너뜀

여기서 중요한 것은 turbo-ignore가 이전 Vercel 배포 결과를 검색해 동일한 결과를 가져오는 Cache 기능은 아니라는 점입니다.

Task Cache와 turbo-ignore는 서로 다른 문제를 해결합니다.

Task Cache

Task 실행 대상
↓
Task Hash 확인
↓
동일한 실행 결과가 Cache에 있는가?
↓
있다면 이전 Task 결과 재사용

반면 turbo-ignore는 다음과 같은 역할에 가깝습니다.

Deployment Build 시작
↓
현재 App이 변경의 영향을 받았는가?
↓
No
↓
Build 자체를 취소

즉 하나는 이미 실행했던 Task 결과를 재사용하는 것이고, 다른 하나는 현재 변경으로 해당 작업 자체가 필요한지 판단하는 것입니다.

또한 Ignored Build Step은 앞에서 살펴본 Vercel의 Deployment Skip과도 차이가 있습니다.

Ignored Build Step은 Deployment가 이미 Build 단계에 진입한 뒤 Command를 실행합니다.

따라서 Vercel 공식 문서에 따르면 Ignored Build Step에서 취소된 Build도 Deployment와 동시 빌드(Concurrent Build) 한도에 포함됩니다.

Skipping Unaffected Projects (Deployment Skip)

Deployment 전에 영향 여부 판단
↓
영향 없음
↓
Build 시작 X
↓
동시 빌드 슬롯 사용 X
Ignored Build Step

Deployment가 Build 단계에 진입
↓
Ignore Command 실행
↓
Build 취소
↓
이미 Build 단계에 진입했기 때문에
Deployment / 동시 빌드 한도에는 포함

따라서 현재 Vercel에서는 일반적인 모노레포의 변경 영향 판단에는 Skipping Unaffected Projects를 우선적으로 활용하고, Branch 같은 추가 조건이 필요한 경우 Ignored Build Step을 활용하는 방식을 고려할 수 있습니다.

Branch와 변경 영향도를 함께 고려하기

실제 배포 환경에서는 Package의 변경 여부만으로 Deployment 여부가 결정되지 않을 수도 있습니다.

예를 들어 특정 Branch에서는 Preview Deployment가 필요하지 않거나, Production과 Staging처럼 일부 Branch에서만 Deployment를 수행하고 싶은 경우가 있습니다.

Vercel의 Ignored Build Step에는 Bash나 Node.js Script를 사용할 수 있어서 이러한 조건을 추가할 수 있습니다.

*Vercel - How do I use the Ignored Build Step?

예를 들어 개념적으로 다음과 같은 흐름을 구성할 수 있습니다.

Deployment 요청
↓
현재 Git Branch 확인
↓
배포 대상 Branch인가?
├─ 아니오 → Build 건너뜀
└─ 예
   ↓
   현재 App이 변경의 영향을 받았는지 확인
   ├─ 영향 없음 → Build 건너뜀
   └─ 영향 있음 → Build 진행

과거에는 이러한 Script와 turbo-ignore를 함께 사용해 Branch 조건과 Workspace의 변경 영향도를 모두 확인하는 방식도 사용할 수 있었습니다.

예를 들어 다음과 같은 형태입니다.

#!/bin/bash
# scripts/ignore-build.sh

if [[ "$VERCEL_GIT_COMMIT_REF" == feature/* ]]; then
  echo "Skip deployment"
  exit 0
fi

npx turbo-ignore web --fallback=HEAD^1

여기서 중요한 것은 Script 자체보다 서로 다른 두 가지 조건을 조합했다는 점입니다.

먼저 Branch를 확인하여 해당 환경에서 Deployment가 필요한지 판단하고, 배포 대상이라면 turbo-ignore를 통해 이번 변경이 해당 App과 Dependency에 영향을 주는지 추가로 확인합니다.

이를 통해 모든 Commit에서 모든 App을 Build하는 대신, 팀의 배포 정책과 Package의 변경 영향을 함께 고려하여 실제로 필요한 Build만 수행하도록 구성할 수 있습니다.

현재는 turbo query affected로

앞에서 살펴본 turbo-ignore는 당시에는 변경 영향도를 판단하는 데 사용할 수 있었지만 현재는 Deprecated되었습니다.

Turborepo 공식 문서에서는 기존 turbo-ignore 대신 turbo query affected로 마이그레이션하는 방법을 안내하고 있습니다.

*Turborepo - Query

과거에는 다음과 같이 특정 Package의 변경 영향을 확인했다면

npx turbo-ignore web

현재는 다음과 같이 확인할 수 있습니다.

turbo query affected --packages web

특정 Task까지 기준으로 판단할 수도 있습니다.

turbo query affected --packages web --tasks build

즉 단순히 Package가 변경되었는지만 보는 것이 아니라, Git 변경 사항과 Package의 의존 관계를 바탕으로 어떤 Package나 Task가 영향을 받았는지 판단할 수 있습니다.

Git 변경 발생
↓
Package와 의존 관계 확인
↓
이번 변경의 영향을 받는 Package / Task 판단
↓
필요한 작업만 실행할 수 있는 기준으로 활용

따라서 turbo-ignore를 사용했던 당시와 명령어는 달라졌지만, 변경의 영향 범위를 판단해 불필요한 작업을 줄인다는 핵심 목적은 이어지고 있습니다.

Task Graph, Cache, Affected는 서로 다른 문제를 해결한다

지금까지 살펴본 내용을 정리하면 Turborepo의 여러 기능은 모두 Build 최적화와 관련되어 있지만 서로 다른 문제를 해결합니다.

개념해결하는 문제
Task Graph어떤 Task를 어떤 순서로 실행할 것인가?
Cache동일한 Task를 다시 실행해야 하는가?
Affected이번 변경이 어떤 Package 또는 Task에 영향을 주는가?
Deployment Skip영향받지 않은 App의 Deployment가 필요한가?

전체 흐름으로 보면 각각의 역할을 조금 더 쉽게 구분할 수 있습니다.

Monorepo에 코드 변경 발생
↓
변경 영향 판단
→ 어떤 Package / Task가 영향을 받았는가?
↓
Affected 여부
├─ 영향 없음 → Deployment / Build 생략
└─ 영향 있음
   ↓
   Task Graph
   → 어떤 Task를 어떤 순서로 실행할 것인가?
   ↓
   Task Hash 계산
   → 이전과 동일한 실행 조건인가?
   ↓
   Cache 확인
   ├─ Hit  → Task 실행 생략 → 이전 결과 재사용
   └─ Miss → Task 실행 → 새로운 결과 Cache

이렇게 구분하고 나면 Turborepo가 단순히 Build 명령을 병렬로 실행하는 도구가 아니라 모노레포의 Package와 Task 사이의 관계를 바탕으로 작업 실행 자체를 관리하는 도구라는 점을 알 수 있습니다.

마치며

모노레포에서 여러 App과 공통 Package를 함께 운영하다 보면 단순히 코드를 한 Repository에 두는 것보다 그 위에서 발생하는 작업의 관계를 어떻게 관리할 것인가가 중요해집니다.

여러 App과 Package
↓
Package 사이의 의존 관계
↓
Task Graph로 실행 순서 관리
↓
Cache로 동일한 작업의 반복 실행 감소
↓
변경 영향도를 기준으로 필요한 작업 판단
↓
영향받지 않은 App의 불필요한 Build / Deployment 감소

특히 작업을 최적화한다는 것은 모든 Build를 단순히 더 빠르게 만드는 것만을 의미하지 않았습니다.

필요한 작업만 실행하고, 이미 수행한 동일한 작업은 재사용하며, 변경의 영향을 받지 않은 프로젝트라면 애초에 불필요한 Build를 시작하지 않는 것 역시 중요한 최적화 방법이었습니다.

결국 모노레포에서는 Package의 의존 관계와 변경 범위를 바탕으로 어떤 작업이 필요한지 판단하고, 필요한 Task만 효율적으로 실행하는 것이 중요합니다.

참고 자료