pnpm을 사용해 Turborepo를 설정해보고 자세히 살펴보도록 하겠습니다.
기존에는 yarn을 패키지 매니저로 주로 사용했습니다. yarn 1.x 버전은 성능면에서 느릴 뿐 아니라 의존성 중복 저장 문제를 호이스팅을 통해 해결하기 때문에 유령 의존 현상을 야기할 수 있다는 단점이 있습니다.
특히 모노레포 구조에서는 하나의 레포지토리에서 여러 프로젝트의 의존성을 관리해야 하기 때문에 프로젝트가 서로 다른 프로젝트의 의존성에 의존하는 등 더 빈번하고 크리티컬한 유령 의존 현상이 발생할 수 있습니다.
이러한 유령 의존성은 depcheck을 활용해 확인할 수 있습니다.
왼쪽과 같은 의존성 트리를 가진 프로젝트가 있다고 가정해보겠습니다. 왼쪽 트리에서 A(1.0)
과 B(1.0)
을 두 번 설치하는 것은 디스크 공간 측면에서 비효율 적입니다. 따라서 npm(ver 3 ~), yarn classic은 호이스팅 & 병합을 통해 오른쪽 트리와 같이 평탄화(Flat)된 종속성 트리로 모양을 바꿉니다. (npm ver 2 까지는 모든 의존성을 중볻 설치했다고 합니다.)
이를 통해 디스크 공간을 절약하고 트리 경로 깊이 내려가지 않아도 최 상위에서 원하는 의존성을 탐색할 수 있게 되므로 보다 효율적입니다.
그러나 이로 인해 프로젝트에서 직접 의존하고 있지 않은 패키지(위 이미지에서 B(1.0)
에 해당)를 암묵적으로 참조하게 되는 경우가 발생합니다. 이 것이 바로 유령 의존성 현상입니다.
이처럼 유령 의존성 현상이 발생하면 어떠한 의존성 파일을 지웠을 때 암묵적으로 참조했던 패키지도 삭제될 수 있는 등 의존성 트리의 유효성을 보증 받기 어렵습니다.
앞서 언급한 문제점을 해결할 수 있는 방법으로 크게 두 가지가 있습니다. 바로 yarn berry와 pnpm입니다. yarn berry에서는 Plug’n’Play 전략을 통해 종속성 중복 저장 문제를 해결하며 호이스팅을 사용하지 않는 nohoist가 기본 값입니다.
Turborepo에서는 yarn berry의 PnP를 지원하지 않기 떄문에 실질적인 선택지는 pnpm 하나뿐이였습니다. pnpm은 npm 또는 yarn classic과 비교했을 때 더 좋은 성능과 보안을 제공합니다.
또한 pnpm은 npm과 사용이 비슷해 마이그레이션 과정이 단순하다는 점도 장점입니다.
공식 문서 설명에 따르면, JavaScript나 TypeScript 코드를 위해 최적화된 빌드 시스템이라고 되어있습니다. JavaScript와 TypeScript의 린트나 빌드, 테스트와 같은 코드베이스 작업은 시간이 꽤 소요되는 작업인데, Turborepo는 캐싱을 통해 로컬 설정을 진행하고 CI 속도를 높여준다고 합니다.
스캐줄링 및 CPU 유휴를 최소한으로 하며 병렬적으로 작업을 수행하기 떄문에 멀티 태스킹 능력을 극대화했다고 합니다.
또한, NextJS를 개발한 vercel에서 개발하고 있다는 점도 큰 장점입니다.(보다 호환성이 좋다고 합니다.)
pnpm install turbo --global
위의 명령어를 사용해 전역으로 설치하고 나면 작업 공간에서 바로 시작할 수 있습니다.
pnpm dlx create-turbo@latest
create-turbo를 설치하면 아래와 같은 커맨드가 출력되는 것을 볼 수 있습니다.
apps
- apps/docs
- apps/web
packages
- **packages/eslint-config-custom**
- **packages/tsconfig**
- **packages/ui**
위 디렉토리는 각각 package.json을 포함하는 폴더들로 개별적으로 코드를 작성하고 의존성을 사용하지만, 다른 작업 공간에서도 그 코드를 사용할 수 있습니다.
[packages/ui]
./packages/ui/package.json
을 열면 상단에 "name": "ui"
로 적혀 있는것을 확인할 수 있습니다.
그다음 ./apps/web/package.json
에서도 "name": "web"
라고 적혀있 는데,
"web"
작업공간은 "ui"
라고 불리는 package를 의존하고 있는 것을 볼 수 있습니다.
이는 web 앱이 로컬 ui패키지를 의존하고 있음을 시사하며, apps/docs/package.json
도 마찬가지로 ui를 의존하고 있습니다.
이렇게 애플리케이션을 넘어 코드를 공유하는 패턴은 모노레포에서 매우 흔하게 사용되는 패턴입니다.
ui 폴더안의 컴포넌트를 사용하는 방법은 아래와 같습니다.
ui 폴더 내에서 변경되는 코드들은 web과 docs 등 다른 작업 공간에 모두 공유됩니다.
[tsconfig]
packages 폴더에는 tsconfig
와 eslint-config-custom
이라는 작업 공간이 존재합니다. 두 작업 공간은 각각 모노레포의 설정을 공유합니다. tsconfig 파일을 먼저 살펴보겠습니다.
// package.json
{
"name": "@repo/typescript-config",
"version": "0.0.0",
"private": true,
"license": "MIT",
"publishConfig": {
"access": "public"
},
"files": [
"base.json",
"nextjs.json",
"react-library.json"
]
}
files에는 세 가지 파일이 내보내지고 있습니다. 즉, tsconfig에 종속된 패키지들을 직접 가져올 수 있다는 것을 의미합니다.
예를 들어, packages/ui/package.json에서 typescript-config를 의존하고 있습니다.
// package.json
{
"devDependencies": {
...
"@repo/typescript-config": "workspace:*",
...
}
}
그리고 ui의 tsconfig.json 파일 안에서는 extends 프로퍼티를 통해 tsconfig 앱의 파일을 가져오고 있습니다.
// tsconfig.json
{
"extends": "@repo/typescript-config/react-library.json",
...
}
이러한 패턴은 모노레포에서 tsconfig.json을 다른 모든 작업 공간이 공유하여 코드 중복을 줄여줍니다.
[eslint-config]
ESLint는 eslint-config가 워크스페이스를 탐색하면서 설정을 적용합니다.
// library.js
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["eslint:recommended", "prettier", "turbo"],
...
ESLint는 가장 가까이 위치한 .eslintrc.js 설정 파일을 통해 구성 파일을 찾습니다. 현재 디렉토리에서 찾을 수 없다면, 다른 디렉토리를 둘러보며 찾아냅니다.
packages/ui에서 코드를 작성하고 있을 때, 해당 디렉터리가 아닌 루트를 참조하게 됩니다.
예를 들어 docs 워크스페이스의 경우 다음과 같습니다.
// .eslintrc.js
/** @type {import("eslint").Linter.Config} */
module.exports = {
root: true,
extends: ["@repo/eslint-config/next.js"],
parser: "@typescript-eslint/parser",
parserOptions: {
project: true,
},
};
web
: ui, tsconfig, eslint-config-custom을 의존docs
: ui, tsconfig, eslint-config-custom을 의존ui
: tsconfig, eslint-config-custom을 의존tsconfig
: 의존성 없음eslint-config-custom
: 의존성 없음모노레포에서 의존성들이 어떻게 사용되는지 확인했습니다. 그러면 Turborepo는 무슨 역할을 하는 것일까요?
root에 있는 turbo.json을 확인해보겠습니다.
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"ui": "tui",
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**"]
},
"lint": {
"dependsOn": ["^lint"]
},
"dev": {
"cache": false,
"persistent": true
}
}
}
Turborepo는 코드베이스 작업 실행을 간단하게 만들어주고, 더 효율적으로 만들어준다고 합니다.
turbo lint
위의 명령어로 lint를 실행할 수 있습니다. lint를 입력하면 terminal에서 3가지 일이 발생합니다.
docs:lint
, ui:lint
, web:lint
3 successful
로 출력됩니다.0 cached, 3 total
이라고 나오는 것을 볼 수 있습니다.// apps/web/package.json
{
"scripts": {
"lint": "next lint"
}
}
// apps/docs/package.json
{
"scripts": {
"lint": "next lint"
}
}
// packages/ui/package.json
{
"scripts": {
"lint": "eslint *.ts*"
}
}
turbo lint를 실행했을 때, Turborepo는 각 작업 공간에 있는 package.json에서 lint 스크립트를 실행합니다.
[Using the cache]
lint 스크립트를 한 번 더 실행해보면, 몇 가지 새로운 일이 발생한 것을 볼 수 있습니다.
Turborepo는 마지막으로 실행한 lint 스크립트를 통해 우리의 코드가 변하지 않음을 확인합니다. 이전 실행으로부터 log를 저장하고, 이를 다시 실행합니다.
turbo build
명령어를 실행하면, 이전에 실행했던 lint 와 비슷한 결과를 확인할 수 있습니다. apps/doc과 apps/web 만이 package.json에 build 명령어를 명시하였기에 이 두 개에만 명령어가 실행됩니다.
turbo.json에 있는 build를 살펴보면 여러 설정이 있는 것을 볼 수 있습니다.
// turbo.json
{
...
"tasks": {
"build": {
"dependsOn": ["^build"],
"inputs": ["$TURBO_DEFAULT$", ".env*"],
"outputs": [".next/**", "!.next/cache/**"]
},
...
}
}
여기에는 outputs이라는 프로퍼티가 명시되어 있는데, outputs를 선언하는 것은 turbo가 작업을 끝냈을 때 결과물을 캐시로 저장하기 위함입니다.
apps/docs와 apps/web은 Next.js의 애플리케이션으로, ./.next
폴더에 빌드를 내보냅니다.
만약 apps/docs/.next
폴더를 제거하고 build 명령어를 다시 입력하면 아래와 같은 결과를 확인할 수 있습니다.
Turborepo는 이전 빌드의 결과를 캐시합니다. build 명령어를 다시 실행했을 때, 캐시는 .next 하위의 내용물을 복원합니다.
turbo dev
dev 스크립트를 실행해보면,
docs: dev
와 web:dev
두 가지 명령어가 실행됩니다.그런데, 스크립트를 재실행하면 이전과 같은 FULL TURBO 로그를 확인할 수 없습니다.
turbo.json 파일을 보면 cache가 false로 되어 있는 것을 확인할 수 있습니다.
// turbo.json
{
"pipeline": {
"dev": {
"cache": false,
"persistent": true
}
}
}
이는 Turborepo에게 dev 명령어 실행의 결과를 캐시하지 않겠다는 것입니다. dev 명령어는 지속 가능한 dev 서버를 실행하며, 어떠한 결과물도 생성하지 않아 캐시할 것이 없습니다.
추가적으로 “persistent”: true
로 설정되어 있는데 이는 turbo가 지속적으로 실행되는 개발 서버임을 알 수 있도록 설정해, 다른 작업이 이에 종속되지 않도록 하기 위함입니다.
// package.json에
{
...
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"dev:docs": "turbo dev --filter=docs",
"dev:web": "turbo dev --filter=web",
"dev:새로만든프로젝트": "turbo dev --filter=새로만든프로젝트",
...
},
}
"dev:새로만든프로젝트": "turbo dev --filter=새로만든프로젝트"
를 추가하고 터미널에서
turbo dev:새로만든프로젝트
를 입력하거나 아래 명령어를 실행해줍니다.
turbo dev --filter docs