회사에서 Java SpringBoot를 백엔드로, html, css, js를 프론트엔드로 구성한 서비스를 마이그레이션 하게 되었다.
풀스택으로 개발을 해야했고, 빠르게 프로토타입을 만드는 것이 목표였기에, TS 기반의 기술을 사용하여 빠르게 개발하고, 같은 언어가 주는 장점인 타입 안정성 유지하려 했다.
이를 위해 모노레포를 사용하기로 했다.
왜 모노레포인가?
먼저 모노레포를 선택한 이유 크게 다음 5가지이다.
1. 타입 공유의 완전성
- @koita/shared 패키지에 정의된 타입(예: IAttend, AttendStatus)을 백엔드와 프론트엔드가 공유한다.
- 이를통해 API의 응답 데이터나 상태 관리 시 일관된 타입 시스템 유지가 가능해진다.
// @koita/shared에서 정의한 타입을
// 백엔드와 프론트엔드가 동시에 사용
import { AttendStatus, IAttend } from '@koita/shared';
// 백엔드 (NestJS)
async createAttend(data: IAttend) { ... }
// 프론트엔드 (React)
const [status, setStatus] = useState<AttendStatus>(AttendStatus.NONE);
2. 동기화된 개발과 타입 안정성
- API 스키마 변경 시, 공유 패키지의 타입만 수정하면 프론트엔드에서 즉시 에러를 확인할 수 있어 API 문서화나 수동 동기화 과정이 필요 없없다.
- enum 같은 공통 상수를 추가하거나 수정할 때도 모든 프로젝트에 일관성이 보장되어 타입 안정성이 향상된다.
3. 코드 재사용성 향상
- 유틸리티 함수 공유: 날짜 포맷팅, 유효성 검사 등 공통 로직을 별도의 utils 패키지로 분리하여 코드 중복을 최소화할 수 있다.
4. 쉬운 리팩토링 및 유지보수
- 즉각적인 영향 파악: 공유 패키지의 코드를 변경하면, 이를 사용하는 모든 프로젝트에서 즉시 에러를 감지할 수 있어 예상치 못한 버그방지가 가능하다.
- 통합된 코드 베이스: 모든 코드가 한곳에 있어 특정 기능을 개선하거나 수정할 때 관련 코드를 한눈에 볼 수 있다.
5. 효율적인 개발 환경 구축
- 통합된 의존성 관리: pnpm install 한 번으로 모든 패키지의 의존성을 한 번에 설치하고 관리한다.
- 일관된 개발자 경험 (DX): 모든 개발자가 동일한 버전의 의존성과 개발 도구 설정을 사용하게 되어 '내 컴퓨터에서는 잘 되는데...'와 같은 문제를 해결할 수 있다!
이를 통해 개발의 효율성과 코드의 안정성을 동시에 확보할 수 있을 것이라 생각했다.
이러한 모노레포의 장점을 누리기 위해, 모노레포 구성을 도와주는 터보레포를 사용하기로 결정했다.
(참고로 모노레포가 처음이었기에 혼자 구성하기엔 무리가 있었다.)
모노레포에 대해 더 자세히 알고 싶다면 아래 글을 읽어보길 추천한다.
Monorepo Explained
Everything you need to know about monorepos, and the tools to build them.
monorepo.tools
터보레포란?
Turborepo
Turborepo is a build system optimized for JavaScript and TypeScript, written in Rust.
turborepo.com
Turborepo is a high-performance build system for JavaScript and TypeScript codebases. It is designed for scaling monorepos and also makes workflows in single-package workspaces faster, too.
터보레포 공식문서에 나와 있는 내용이다.
JS, TS 기반의 코드 베이스 빌드 시스템으로, 모노레포의 확장을 위해 만들어졌다고 한다.
즉, 모노레포가 가지는 문제들을 해결하기 위해 만들어졌다.
그래서 모노레포가 가지는 문제가 뭘까?
모노레포의 가장 큰 단점은 모노레포가 커지면서 성능과 빌드 효율성에 문제가 생긴다는 것이다.
코드베이스가 커지면 빌드, 테스트, Git 작업 등의 속도가 느려진다.
이를 해결하기 위해 터보레포는 내가 한 모든 작업을 로컬 캐시 스토어에 저장해, CI 시 같은 작업을 2번 반복하지 않도록 한다.
작업내용(빌드, 테스트, 의존성 등) 에 대한 고유 해시를 만들고 이를 캐시 스토어에 저장한다. 이 해시를 통해 작업이 실행되었는지 여부를 판단하고, 해시값이 동일하다면 스토어에서 작업 내용을 가져와 반복 작업을 줄인다.
또 여러 작업을 병렬적으로 실행하면서 CI 작업 속도를 높인다.
가장 큰 장점은 손 쉽고, 빠르게 어떤 레포지토리든 turbo.json 만 추가한다면 터보레포를 사용할 수 있다는 점이다.(어떤 패키지 매니저를 사용해도상관 없다!)
그러므로 터보레포는 모노레포 환경에서 캐싱과 병렬 처리를 통해 CI에 발생하는 반복작업을 줄여 개발 속도를 높이는 빌드 시스템이다.
모노레포를 구성해보자.
구성하기 전, 폴더 구조에 대해 정의해보자.
summer/ # 루트
├── package.json # 워크스페이스 루트 설정
├── pnpm-workspace.yaml # pnpm 워크스페이스 정의
├── turbo.json # Turbo 빌드 파이프라인 설정
├── pnpm-lock.yaml
├── apps/ # 애플리케이션들
│ ├── backend/ # NestJS 백엔드
│ │
│ └── frontend/ # React + Vite 프론트엔드
│
└── packages/ # 공유 패키지
TS 풀스택 프로젝트의 타입 안정성을 유지하기 위해 공통으로 사용되는 타입이나 인터페이스는 packages에 묶고, 백엔드와 프런트엔드는 apps라는 워크스페이스에 각각 구성했다
워크스페이스가 뭘까?
워크스페이스는 하나의 큰 저장소 안에서 독립적인 프로젝트를 의미한다.
각 워크스페이스는 자체적으로 package.json을 가지고 있기 때문에, 프론트엔드, 백엔드 API, 또는 공통 라이브러리 모두 개별 프로젝트처럼 관리할 수 있다.
이러한 구조 덕분에 워크스페이스 간 의존성 관리가 훨씬 편해진다.
(만약 따로 관리한다고 생각해 보자. 만약 백,프론트가 공통으로 사용하는 타입이 있다면 이 공통 코드를 npm 패키지로 배포하고, 이를 내려받아 쓰는 등 비효율이 발생한다. 공통 코드가 바뀌면 백 , 프론트 두 저장소 모두 수동으로 수정해야 하는 등 비효율적이다.)
예를 들어, 웹 애플리케이션 워크스페이스가 공통 컴포넌트 워크스페이스를 의존할 때, 패키지 매니저(npm, yarn, pnpm)가 이 두 워크스페이스를 자동으로 연결해준다.
(패키지 매니저가 모노레포 루트에 있는 설정 파일(package.json , pnpm-workspace.yaml) 을 보고 , 워크스페이스 간 의존 관계를 파악해 서로 참조할 수 있도록 해준다.)
덕분에 굳이 패키지를 따로 배포할 필요 없이 코드를 쉽게 가져다 쓸 수 있다.
이를 통해 모노레포 안에서 코드 공유를 쉽게 해주고 모노레포 속 프로젝트별 독립성을 유지할 수 있게 해준다.
모노레포 구성 순서
1. 물리적인 폴더 구조를 생성한다.
summer/ # 루트
├── package.json # 워크스페이스 루트 설정
├── pnpm-workspace.yaml # pnpm 워크스페이스 정의
├── turbo.json # Turbo 빌드 파이프라인 설정
├── pnpm-lock.yaml
├── apps/ # 애플리케이션들
│ ├── backend/ # NestJS 백엔드
│ │
│ └── frontend/ # React + Vite 프론트엔드
│
└── packages/ # 공유 패키지
│
└── shared/
나의 경우 apps 안에 backend, frontend 가 들어가고, 모든 곳에서 사용할 공유 패키지를 packages 폴더에 넣기로 결정했다.
pnpm을 패키지 매니저로 사용할 예정이므로,
pnpm dlx create-turbo@latest
해당 명령어를 통해 기본 구조를 설정한다.
그 후 삭제할 폴더들을 삭제하고, 내가 구현하려는 구조에 맞게 수정한다.
2. root 에서 관리할 의존성 및 스크립트를 package.json과 turbo.json에 작성한다.
pnpm init 을 통해 기본 package.json 을 생성한다.
공용으로 사용할 turbo, prettier, eslint 등을 pnpm을 통해 설치한다.
이후 각자 설정에 맞게 node, pnpm 버전, pnpm의 esbuild 버전을 고정하는 등 개인 환경에 맞게 추가한다.
난 최소 node, pnpm 버전, esbuild의 의존성 충돌을 해결하기 위해 특정 버전 및 값을 설정했다.
{
...
"engines": {
"node": ">=18.0.0",
"pnpm": ">=8.0.0"
},
"packageManager": "pnpm@8.12.1",
"pnpm": {
"overrides": {
"esbuild": "^0.25.8"
}
}
}
터보레포를 사용해 모노레포를 관리할 예정이므로 turbo.json에 설정을 추가한다.
여기서 중요한 것은 터보레포는 모노레포를 관리하는 빌드 시스템이다.
그러므로 모노레포에서 실행해야 한다.
터보레포는 pnpm-worksapce.yaml 파일이 존재하는 위치를 루트로 판단하기에 먼저 레포지토리 루트에 pnpm-workspace.yaml 을 생성한다.
( pnpm dlx create-turbo@version 스크립트로 초기 구성을 했다면 이미 pnpm-workspace.yaml 파일이 생성되었을 것이다. 그러므로 아래 단계는 생략할 수 있다.)
우리의 경우 apps와 packages 가 독립적인 워크스페이스로 한 레포지토리 안에 존재한다.
아래처럼 워크스페이스를 pnpm-workspace.yaml에 적어주자.
packages:
- 'apps/*'
- 'packages/*'
이제 turbo.json을 적으면 된다.
(이 역시 root에 위치해야하고, 스크립트를 통해 생성한 프로젝트라면 기본으로 turbo.json이 존재한다.)
turbo.json 은 Truborepo 의 태스크 파이프라인을 정의하는 설정파일이다.
turbo.json은 아래와 같은 역할을 한다.
1. 태스크 의존성 관리: 어떤 태스크가 먼저 실행되어야 하는지 정의
2. 캐싱 최적화: 변경되지 않은 태스크의 결과를 재사용
3. 병렬 실행: 의존성이 없는 태스크들을 동시에 실행
4. 입력/출력 정의: 캐싱 대상 파일들을 명시
등등...
turbo.json에 task로 설정한 이름 (build, dev, lint etc) 은 실제 각 프로젝트(나의 경우 frontend, backend, shared) 의 명령어와 같아야 한다.
터보레포는 아래처럼 동작한다.
turbo run build 명령어를 실행하면 , 각 레포의 package.json에 들어가 build 명령어를 찾아서 실행해준다.
내가 설정한 turbo.json 을 분석해보자.
(참고로 이번 프로젝트에선 Turbo v1을 사용했기에 tasks 명령어가 아닌 pipeline 명령어를 사용했다.
만약 Turbo v2를 사용한다면, pipeline을 tasks로 바꿔야 한다.)
{
"pipeline": {
"build": {
"dependsOn": [
"^build"
],
"outputs": [
"dist/**",
]
},
"dev": {
"cache": false,
"persistent": true
},
"type-check": {
"dependsOn": [
"^build"
]
},
"lint": {
"dependsOn": [
"^build"
]
},
"test": {
"dependsOn": [
"^build"
]
},
"clean": {
"cache": false
}
}
}
각 요소에 대해 살펴보자.
"build": {
"dependsOn": [
"^build"
],
"outputs": [
"dist/**",
]
},
먼저
"dependsOn" : ["^build"]
여기 ^는 터보레포에게 의존성 그래프를 분석해 현재 패키지의 의존성 패키지들을 먼저 build하고, 현재 패키지를 build 하란 뜻이다.
A 패키지가 B 패키지에 의존하고 있다면 , A를 빌드하기 전, B를 먼저 빌드하고 A를 빌드하게 된다.
우리의 경우, frontend, backend 모두 packages의 shared에 의존하고 있다.
backend/package.json
{
...
"scripts": {
...
},
"dependencies": {
"@koita/shared": "workspace:*",
...
},
"devDependencies": {
...
},
...
}
frontend/package.json
{
...
"scripts": {
...
},
"dependencies": {
"@koita/shared": "workspace:*",
...
},
"devDependencies": {
...
}
}
shared/package.json
{
"name": "@koita/shared",
"version": "1.0.0",
"description": "Shared types and utilities for KOITA project",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
"types": "dist/types/index.d.ts",
"exports": {
".": {
...
}
},
"scripts": {
...
},
"dependencies": {
"class-validator": "^0.14.0",
"class-transformer": "^0.5.1"
},
"devDependencies": {
...
},
}
즉, turbo run build를 실행하면 frontend, backend 모두 shared에 의존하고 있으므로 shared를 먼저 build하고, frontend 와 backend를 build 하게 된다.
그리고 해당 build 결과물을 dist/ 폴더에 생성한다.
"dev": {
"cache": false,
"persistent": true
},
위 설정은 dev 명령어(turbo run dev)를 실행 시 dev 작업 결과는 캐싱하지 않으며, persistent : true 설정 시 , 해당 dev 작업이 종료되지 않는 장기 실행 프로세스임을 알려준다. 이를 통해 다른 작업이 "dev"로 실행한 프로세스에 의존하지 못하게 막는다.
왜 이 설정을 했을까?
만약 persistne 설정을 안했다고 가정해보자.
// persistent 설정 없이
{
"tasks": {
"dev": {},
"test": {
"dependsOn": ["dev"]
}
}
}
그 후
turbo run test
를 실행했다고 가정하자.
이러면 test는 "dev" 에 의존하므로, turbo run dev 가 먼저 실행된다.
하지만 dev 작업은 끝나지 않는 작업이므로(개발 서버를 띄운 것이니까!)
test는 무한 대기를 하게 된다.
그러므로, persistent: true 설정을 통해 무한 대기를 방지하고, 해당 작업이 장기적으로 실행되는 작업임을 터보레포에게 알려주는 셈이다.
실제로 실험해보자.
{
"pipeline": {
"build": {
"dependsOn": [
"^build",
"dev" // dev에 의존
],
"outputs": [
"dist/**"
]
},
"dev": {
"cache": false
// persistent 설정 없음
},
"type-check": {
"dependsOn": [
"^build"
]
},
"lint": {
"dependsOn": [
"^build"
]
},
"test": {
"dependsOn": [
"^build"
]
},
"clean": {
"cache": false
}
}
}
그 후 turbo run build 를 실행하니
이렇게 dev가 완료되기를 무한 대기하고 있다!
"type-check": {
"dependsOn": [
"^build"
]
},
"lint": {
"dependsOn": [
"^build"
]
},
"test": {
"dependsOn": [
"^build"
]
},
위 내용은 각 명령어를 실행할 때, 의존성 build를 전부 끝내고 해당 명령어들이 실행된단 뜻이다.
현재 나의 경우, 백 , 프론트가 공통으로 사용하는 타입을 shared에 설정했기에, shared가 먼저 빌드 되어야 type-check, lint, test가 동작한다.
"clean": {
"cache": false
}
clean의 경우, 빌드 결과물이나 캐시 등을 삭제해 프로젝트를 깨끗한 상태로 만들기 위한 명령어다.
삭제 작업은 캐싱할 필요가 없으니, "cache":false 로 설정했다!
이렇게 Typescript 기반의 풀스택 모노레포를 구성해보았다.
Turborepo를 사용하면 매우 쉽게 모노레포를 구성할 수 있으니, 모노레포 구성을 원하는 사람들이면 도전해보길 추천한다!