Node.js

[Monorepo] Turborepo를 사용하여 모노레포 구축하기

턴태 2023. 5. 6. 13:02

Turborepo 도입기

디프만에서 프로젝트를 진행하려고 하는데, 서버 언어로 TypeScript를 사용하고 있기 때문에 프론트엔드와 함께 레포지터리를 사용하기에 용이했다. 그래서 Yarn Berry를 통해서 monorepo를 구축할 계획이었다. 프론트와 백 모두 익숙하신 분께서 모노레포를 모두 구축해주셨고 이를 활용해서 프로젝트를 디벨롭 하고자 했다. 백엔드와 프론트엔드 모두 세팅이 안정적으로 완료됐지만, Next.js의 Vercel 배포가 Yarn Berry에서는 잘 동작하지 않는다는 것을 알게 됐고, 이를 대체하고자 Turborepo를 통해 모노레포를 구축하고자 했다.

 

Turborepo란?

공식 문서 설명에 따르면, JavaScript나 TypeScript 코드를 위해 최적화된 빌드 시스템이라고 한다. JavaScript와 TypeScript의 린트나 빌드, 테스트와 같은 코드베이스 작업은 시간이 꽤 소요되는 작업인데, Turborepo는 캐싱을 통해 로컬 설정을 진행하고 CI 속도를 높여준다.

 

그리고 자세히 살펴보면 증분적으로 코드베이스 관련된 작업을 할 수 있도록 채택되어 대부분의 코드베이스 작업을 몇 분 안에 수행할 수 있다.

또한, vercel에서 개발하고 있다는 점도 큰 장점이다.

💡 기능

터보레포는 향상된 빌드 시스템 기술을 사용하여 로컬에서나 CI/CD를 할 때 개발 속도를 높여준다.

 

먼저 캐싱을 사용하기 때문에 동일한 작업을 진행하지 않으며, 스케쥴링 및 CPU 유휴를 최소한으로 하며 병렬적으로 작업을 수행하기 때문에 멀티태스킹 능력을 극대화했다.

 

Turborepo 설치

터보레포는 아래의 세 가지 방법으로 설치할 수 있다.

npm install turbo --global

yarn global add turbo

pnpm install turbo --global

전역으로 설치하고 나면, 작업 공간으로부터 바로 실행할 수 있다.

 

혹은 레포지터리별로 실행하고자 할 때 아래와 같은 커맨드를 입력한다.

npm install turbo --dev

yarn add turbo --dev --ignore-workspace-root-check

pnpm add turbo --save-dev --ignore-workspace-root-check

새 모노레포 생성

1️⃣ create-turbo 실행

새롭게 모노레포를 사용하기 위해서 create-turbo 패키지를 사용한다.

npx create-turbo@latest

yarn dlx create-turbo@latest

pnpm dlx create-turbo@latest

혹은 turborepo 깃허브 레포지터리를 클론해 사용해도 좋다.

https://github.com/vercel/turbo/tree/main/examples

 

GitHub - vercel/turbo: Incremental bundler and build system optimized for JavaScript and TypeScript, written in Rust – includ

Incremental bundler and build system optimized for JavaScript and TypeScript, written in Rust – including Turbopack and Turborepo. - GitHub - vercel/turbo: Incremental bundler and build system opti...

github.com

 

2️⃣ 설치 파일 확인

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를 의존하고 있다.

 

이렇게 애플리케이션을 넘어 코드를 공유하는 패턴은 모노레포에서 매우 흔하게 사용되는 패턴이다.

imports와 exports

./apps/docs/pages/index.tsx 파일을 유심히 살펴보면, docs와 web 애플리케이션 모두 Next.js 기반 애플리케이션이며, 둘 모두 비슷한 방법으로 ui 라이브러리를 사용하고 있다.

ui에서 Button 을 직접 가져오는데 이는 ui 앱의 package.json을 보면 어떻게 사용할 수 있는 것인지 확인할 수 있다.

{
  "main": "./index.tsx",
  "types": "./index.tsx"
}

ui에서 작업공간을 불러올 때, main에서 내가 가져오려는 코드에 접근할 수 있는 위치를 알려주며, types는 TypeScript가 위치한 곳을 알려준다.

 

packages/ui/index.tsx 파일을 살펴보자.

import * as React from "react";
export * from "./Button";

해당 파일에 있는 것은 ui에 의존하는 web이나 docs 등 모든 작업 공간이 사용할 수 있다.

 

index.tsx는 ./Button에 있는 것들을 모두 내보낸다.

import * as React from "react";

export const Button = () => {
  return <button>Boop</button>;
};

여기서 변경하는 모든 코드들은 web과 docs 등 다른 작업 공간에 모두 공유된다.

tsconfig

packages 폴더에는 tsconfig와 eslint-config-custom이라는 작업 공간이 존재한다. 두 작업 공간은 각각 모노레포의 설정을 공유한다. tsconfig 파일을 먼저 확인해보자.

{
  "name": "tsconfig",
  "version": "0.0.0",
  "private": true,
  "license": "MIT",
  "publishConfig": {
    "access": "public"
  },
  "files": [
    "base.json",
    "nextjs.json",
    "react-library.json"
  ]
}

문서에서는 files 프로퍼티가 있다고 했었는데 없어서 추가해줬다.

files에는 세 가지 파일이 내보내지고 있다. 즉, tsconfig에 종속된 패키지들을 직접 가져올 수 있다는 것을 의미한다.

 

예를 들어, packages/ui/package.json에서 tsconfig을 의존하고 있다.

{
  "devDependencies": {
    "tsconfig": "*"
  }
}

그리고 ui의 tsconfig.json 파일 안에서는 extends 프로퍼티를 통해 tsconfig 앱의 파일을 가져오고 있다.

{
  "extends": "tsconfig/react-library.json"
}

이러한 패턴은 모노레포에서 tsconfig.json을 다른 모든 작업 공간이 공유하여 코드 중복을 줄여준다.

eslint-config-custom

eslint-config-custom은 다른 워크스페이스에 비해 살짝 다른 느낌이 든다. 해당 워크스페이스 안에 있는 .eslintrc.js를 통해 그 이유를 확인해볼 수 있다.

module.exports = {
  extends: ["next", "turbo", "prettier"],
  ...
};

ESLint는 eslint-config-*와 같은 이름을 가진 워크스페이스를 탐색하면서 설정을 적용한다.extends: ['custom']을 작성하여 ESLint가 로컬 워크스페이스를 찾을 수 있게 할 수 있다.

 

ESLint는 가장 가까이 위치한 .eslintrc.js 설정 파일을 통해 구성 파일을 찾는다. 현재 디렉터리에서 찾을 수 없다면, 다른 디렉터리를 둘러보며 찾아낸다.

 

즉, 이는 packages/ui에서 코드를 작성하고 있을 때, 해당 디렉터리가 아닌 루트를 참조하게 된다.

.eslintrc.js가 있는 워크스페이스는 같은 방식으로 custom을 참조할 수 있다. 예를 들어 docs 워크스페이스의 경우 다음과 같다.

// apps/docs/.eslintrc.js
module.exports = {
  root: true,
  extends: ["custom"],
};

tsconfig와 같이 eslint-config-custom은 ESLint 설정을 전체 모노레포에서 공유할 수 있게 해주어 일관성 있게 코드를 작성할 수 있다.

요약

  • web - ui, tsconfig, eslint-config-custom을 의존
  • docs - ui, tsconfig, eslint-config-custom을 의존
  • ui - tsconfig, eslint-config-custom을 의존
  • tsconfig - 의존성 없음
  • eslint-config-custom - 의존성 없음

3️⃣ turbo.json

모노레포에서 의존성들이 어떻게 사용되는지 확인했는데, 그럼 Turborepo는 무슨 역할을 하는 것일까?

 

Turborepo는 코드베이스 작업 실행을 간단하게 만들어주고, 더 효율적으로 만들어준다.

root에 있는 turbo.json파일을 확인해보자.

{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": ["**/.env.*local"],
  "pipeline": {
    "build": {
      "outputs": [".next/**", "!.next/cache/**"]
    },
    "lint": {},
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

4️⃣ Turborepo로 lint 하기

turbo lint

해당 명령어로 lint를 실행할 수 있다.

 

lint를 입력하면 terminal에서 3가지 일이 발생한다.

  1. 다양한 스크립트가 동시에 실행되는데, 각각 docs:lint, ui:lint, web:lint
  2. 각각이 성공으로 처리되는데, 터미널에 3 successful로 출력된다.
  3. 또한, 0 cached, 3 total이라고 나오는 것을 볼 수 있다.

이 스크립트는 각각의 작업공간에 있는 package.json에서 실행된다. 

// 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는 각 작업 공간에 있는 lint 스크립트를 바라보고 이를 실행한다.

 

Using the cache

lint 스크립트를 한 번 더 실행해보면, 몇 가지 새로운 일이 발생한 것을 볼 수 있다.

  1. docs:lint, web:lint, ui:lint 각각에 cache hit, replaying output 출력
  2. 3 cached, 3 total
  3. 런타임이 100ms 이하로 줄어들며, >>> FULL TURBO 출력

turborepo는 마지막으로 실행한 lint 스크립트를 통해 우리의 코드가 변하지 않음을 확인한다. 이전 실행으로부터 log를 저장하고, 이를 다시 실행한다.

 

이제 코드에 약간의 변화를 주었을 때 어떠한 변화가 발생하는지 확인해보자

// apps/docs/pages/index.tsx

import { Button } from "ui";
 
export default function Docs() {
  return (
    <div>
-     <h1>Docs</h1>
+     <h1>My great docs</h1>
      <Button />
    </div>
  );
}

이제 lint 스크립트를 다시 실행해보면 다음과 같은 결과를 얻을 수 있다.

  1. docs:lint는 cache miss, executing 를 출력하며, 이는 docs가 lint를 실행하고 있음을 보여줌
  2. 2 cached, 3 total를 출력

이는 이전에 수행한 결과가 아직까지 캐시로 저장된 것을 의미한다. 

5️⃣ Turborepo로 build하기

이제 build 명령어를 실행해보자

turbo build

명령어를 실행하면, 이전에 실행했던 lint 와 비슷한 결과를 확인할 수 있다. apps/doc과 apps/web 만이 package.json에 build 명령어를 명시하였기에 이 두 개에만 명령어가 실행된다.

 

turbo.json에 있는 build를 살펴보면 여러 설정이 있는 것을 볼 수 있다.

{
  "pipeline": {
    "build": {
      "outputs": [".next/**", "!.next/cache/**"]
    }
  }
}

여기에는 outputs라는 프로퍼티가 명시되어 있는데, outputs를 선언하는 것은 turbo가 작업을 끝냈을 때 결과물을 캐시로 저장하기 위함이다.

 

apps/docs와 apps/web은 Next.js의 애플리케이션으로, ./.next 폴더에 빌드를 내보낸다.

 

만약 apps/docs/.next 폴더를 제거하고 build 명령어를 다시 입력하면 아래와 같은 결과를 확인할 수 있다.

  1. FULL TURBO 출력
  2. .next 폴더가 다시 생성됨

Turborepo는 이전 빌드의 결과를 캐시한다. build 명령어를 다시 실행했을 때, 캐시는 .next 하위의 내용물을 복원한다.

6️⃣ dev 스크립트 실행

dev 명령어를 실행해보자

turbo dev

이후 터미널에서 몇가지 정보들을 확인할 수 있다.

  1. docs: dev와 web:dev 두 가지 명령어가 실행된다. 이 두 가지 작업공간만 dev를 명시하고 있다.
  2. 두 dev 스크립트는 동시에 실행되며, Next.js 앱을 3000, 3001번 포트에서 실행한다.
  3. 터미널에서 cache bypass, force executing 이라는 로그를 볼 수 있다.

그런데, 스크립트를 재실행하면 이전과 같은 FULL TURBO 로그를 확인할 수 없다.

turbo.json 파일을 자세히 보면 다음과 같다.

{
  "pipeline": {
    "dev": {
      "cache": false,
      "persistent": true
    }
  }
}

dev 프로퍼티의 값을 보면, "cache": false로 되어 있는 것을 확인할 수 있다. 이는 Turborepo에게 dev 명령어 실행의 결과를 캐시하지 않겠다는 것이다. dev 명령어는 지속가능한 dev 서버를 실행하며, 어떠한 결과물도 생성하지 않아서 캐시할 것이 없다. 추가적으로 "persistent": true로 설정했는데, turbo가 지속적으로 실행되는 개발 서버임을 알 수 있도록 설정해, 다른 작업이 이에 종속되지 않도록 하기 위함이다.

 

한 작업공간에서만 dev 스크립트 실행하기

기본적으로 turbo dev는 모든 작업공간에 있는 dev 명령어를 한번에 실행하지만, 때때로 한 가지 작업 공간만 수행하고자 할 때가 있다.

 

이를 위하여 --filter 플래그를 명령어에 추가할 수 있다.

turbo dev --filter docs