Turborepo를 사용한 이유
2026년 7월 1일
이제 프로젝트 세팅을 시작해야 합니다.
저장소 구조와 프레임워크, 언어 등 여러가지 사전에 문서로 정리한 것을 기반으로, 세팅을 하나하나 시작합니다.
시작하기 전, 용어와 흐름에 대해 정리할 필요가 있습니다.
| 층 | 선택 | 이유 |
|---|---|---|
| 저장소 구조 | 모노레포 | 웹·모바일 앱 + 공용 코드를 한 저장소에서 관리 |
| 패키지 매니저 | pnpm (workspaces) | 디스크 절약 · 엄격한 의존성 · 워크스페이스 내장 |
| 작업 오케스트레이터 | Turborepo | 여러 패키지 작업을 병렬·순서·캐시로 총괄 |
| 프레임워크 | Next.js 16 (App Router) | React 기반 웹 프레임워크 |
| 언어 | TypeScript | 실행 전 타입 검사 |
| 스타일 | Tailwind CSS v4 + 디자인 토큰 | 유틸리티 클래스 + 색/글자 단일 원본 |
| 포맷 | Prettier (+ tailwind plugin) | 코드 모양 자동 통일 |
| 린트 | ESLint (flat config) | 코드 품질/버그 검사 |
| 커밋 게이트 | Husky + lint-staged | 커밋 순간 자동 검사 |
| CI | GitHub Actions | 서버에서 자동 검증 |
| 런타임 고정 | Node 22 (.nvmrc) · .editorconfig | 환경 편차 제거 |
1. 개념 정의
④ Turborepo 감독: "전부 만들어" → 병렬·순서·캐시 ▲ 총괄 ③ 모노레포 주방 = 우리 패키지 다 모음 (apps/web, apps/mobile, packages/*) ▲ 연결 ② pnpm 창고 관리인: 패키지 받아오고 연결 ▲ 다룸 ① 패키지 라벨(package.json) 붙은 코드 봉지
1. 패키지
패키지는 한 줄로, pacakage.json을 가진 폴더 하나 라고 생각하면 됩니다.
남이 만든, react나 Next.js 같은 것도 패키지이고, 저희가 직접 만들 mobile이나 web 도 하나의 패키지입니다.
2. pnpm
패키지 매니저로 저희는 pnpm을 선택했습니다.
패키지 매니저는, package.json에 적힌 것들을 바탕으로 pnpm install 시 알아서 설치하고 정리해줍니다.
3. 모노레포
저희는 web 과 mobile을 하나의 레포에서 관리합니다. 또한 두 패키지가 공통으로 쓰는 ui나 tokens도 있습니다.
이것들을 한 저장소에 다 모은게 모노레포 입니다.
이 패키지를 앱이 가져다 쓸 때도 pnpm이 연결해줍니다.
4. Turborepo
현재 저희는 패키지가 여러개 입니다.
패키지가 여럿이라서, 전부 빌드or검사를 하나하나 따로 시키면 느리고 귀찮습니다.
Turborepo를 사용하면, 패키지가 서로 무관하면 동시에, 순서가 필요하면 줄을 세워서, 안바뀐것들은 알아서 재활용(캐시) 합니다.
5. 단일 웹앱과의 차이
패키지가 만약 하나뿐이라면, 총괄할 필요가 없어 Turborepo도, pnpm 워크스페이스도 불필요 합니다.
단순 next build 로 충분하니까요. 물론 pnpm 자체는 여전히 필요할 것입니다. react 같은 남의 패키지는 어차피 받아와야 하니까요.
2. 실제 세팅 과정
1. create-turbo
pnpm dlx create-turbo@latest
먼저 Turborepo와 pnpm워크스페이스를 설치합니다.
| pnpm 워크스페이스 | Turborepo | |
|---|---|---|
| 담당 층 | 의존성/설치 | 작업 실행 |
| node_modules 만들기 | ✅ | ❌ |
| 외부 패키지 다운로드 | ✅ | ❌ |
| 우리 패키지끼리 연결(심링크) | ✅ | ❌ |
| build/lint 병렬 실행 | ❌ | ✅ |
| 실행 결과 캐시 | ❌ | ✅ |
| 의존성 순서 맞춰 실행 | △ (순서만) | ✅ (순서+병렬+캐시) |
pnpm 워크스페이스는 pnpm-workspace.yaml을 읽어서
이 저장소에 어떤 로컬 패키지가 있는지 읽습니다.
그리고 pnpm install을 하면 외부 패키지를 다운로드하고
node_modules를 만들고 심링크로 연결합니다. 또한 우리 패키지 끼리 연결합니다.
Turborepo는 pnpm build를 하면 여러 패키지의 작업을 실행합니다.
서로 무관하면 병렬로 진행하고, 의존이 있다면 순서를 지킵니다.
또한 입력 해시로 캐시가 되어, 바뀌지 않은것은 재활용합니다.
그래서 pnpm dlx create-turo@latest를 하면
`apps/web`, `apps/mobile`, `packages/ui`, `packages/eslint-config`, `packages/typescript-config`, && `package.json`, `turbo.json`, `pnpm-workspace.yaml`, `pnpm-lock.yaml`, `.gitignore`
이런 것들이 생깁니다.
설치 후
우리 상황에 맞게, 스코프 이름을 원래 @repo/* 에서 @plick/* 로 수정했고,
개발 포트, tsconfig, eslint 설정을 손보았습니다.
3. 내부 동작
1. node_modules
우리 저장소에는 node_modules가 5곳에 있습니다. (web, mobile, package/…)
그러나 실제로 열어보면
apps/web/node_modules/react -> ../../../node_modules/.pnpm/react@19.2.0/.../react apps/web/node_modules/next -> ../../../node_modules/.pnpm/next@16.2.0/.../next
이렇게 심링크(바로가기) 가 되어있습니다.
즉 각 패키지의 node_modules는 진짜 파일이 아니라 바로가기 모음이고,
진짜 파일은 루트의 .pnpm 에만 있습니다.
이것이 pnpm을 선택한 이유입니다.
각 앱이 자기가 package.json에 선언한 것만 보이게 격리하고, 선언 안 한 패키지는 그 앱 node_modules에 없어 import하면 에러가 발생해, 유령 의존성을 구조적으로 차단합니다.
또한 내부적으로 파일들은 pnpm 전역 스토어에서 하드링크되므로, 프로젝트 100개가 같은 react를 써도 디스크엔 사실상 하나만 있습니다.
2. pnpm build 하면 생기는 일
1. pnpm build │ (package.json scripts: "turbo run build") ▼ 2. Turborepo가 패키지/작업 그래프를 만든다 │ web·mobile 둘 다 build 있음, 서로 무관 ▼ 3. web 빌드와 mobile 빌드를 병렬 실행 ├─ apps/web 에서 next build │ import "react" 만나면 → apps/web/node_modules/react(바로가기) │ → 루트 창고의 진짜 react 사용 → 결과물 apps/web/.next/ └─ apps/mobile 에서 next build (동일) → apps/mobile/.next/ ▼ 4. 결과물을 캐시에 저장 (다음엔 안 바뀌었으면 재활용)
3. Turborepo의 병렬, 순서, 캐시
작업 그래프를 위상 정렬해, 선행 조건 없는 것부터 동시에 실행합니다,
web#build와 mobile#build는 서로 무관하므로 동시에 합니다.
만약 web이 의존하는 @plick/ui에 build가 생기면, turbo.json의 dependsOn : [”^build”] 규칙으로 ui를 먼저 빌드하도록 자동 정렬합니다.
각 패키지#작업 의 입력(소스 파일, 작업 정의, 의존 작업 해시, 락파일/’engines’ 등 전역 입력)을 해시해 키를 만듭니다. 같은 키가 캐시에 있으면 명령을 안 돌리고 저장된 산출물,로그를 복원하고 없으면 실행 후 .turbo/cache/<해시>.tar.zst 에 저장합니다.
그래서 두번째 빌드 부터는 거의 즉시 끝납니다.
4. 개발 도구 세팅
이젠 개발 초기에 세팅해야 하는 그 외 부분들입니다.
1. Prettier — 포맷터
-
무엇/왜: 코드의 모양(따옴표·줄바꿈·세미콜론·들여쓰기)을 자동 통일. 스타일 diff로 리뷰가 더러워지는 걸 막는다. 동작은 안 바꾼다.
-
내부 동작: 소스를 AST(구문 트리)로 파싱한 뒤 원래 서식을 버리고 규칙대로 처음부터 재출력한다. 그래서 입력이 어떻든 출력이 결정적이다.
-
설치/설정:
pnpm add -D -w prettier-plugin-tailwindcss # (prettier 자체는 create-turbo가 이미 포함).prettierrc.json— 규칙 고정("따옴표, printWidth 80, tab 2,endOfLine: lf) +prettier-plugin-tailwindcss(Tailwind 클래스 자동 정렬)..prettierignore—.next·잠금파일·이미지 제외.package.jsonscripts:format(고침) /format:check(확인만, CI용).
-
ESLint와 충돌은?
eslint-config-prettier가 ESLint의 서식 규칙을 꺼서 역할이 안 겹치게 한다.
4-2. ESLint — 린터 (+ 모노레포 함정)
- 무엇: 코드 품질/버그 위험(안 쓰는 변수, 위험 패턴, React 훅 규칙 위반)을 검사.
- 내부 동작 & 함정: ESLint v9 flat config는 설정 파일을 "명령 실행 위치(cwd)"에서 찾는다
(파일별로 위로 탐색하지 않음). 우리는 설정을 루트가 아니라 각 워크스페이스에만 뒀다 →
루트에서
eslint를 한 번에 돌리면 설정을 못 찾아 실패한다. (이 함정을 4-3에서 정면으로 만난다.) - 공용 규칙은
packages/eslint-config(우리 패키지!)에 두고 각 앱이 import해서 확장한다.
4-3. Husky + lint-staged — 커밋 게이트
-
무엇/왜: 검사 명령이 있어도 사람이 깜빡하면 소용없으니, 커밋하는 순간 자동으로 검사.
-
내부 동작 — Husky: git은 원래
.git/hooks/의 스크립트를 이벤트마다 실행하는데.git/은 공유가 안 된다. Husky는git config core.hooksPath를.husky/_로 바꿔, 저장소에 커밋되는.husky/스크립트가 훅으로 돌게 한다.package.json의"prepare": "husky"가pnpm install때 이걸 자동 설정. -
내부 동작 — lint-staged: ① 스테이징 안 된 변경을 임시 stash → ② 스테이징된 파일 목록 확보 → ③ 설정된 명령 실행 → ④ 고쳐졌으면 다시 스테이징 → ⑤ 하나라도 실패하면 전부 원상복구하고 커밋 차단.
-
설치:
pnpm add -D -w husky lint-staged pnpm exec husky init # .husky/ 자동 생성 + prepare 스크립트 -
모노레포 대응(함정 해결): 4-2의 이유로 루트에서 eslint를 못 돌린다. 그래서
lint-staged.config.mjs에서 스테이징 파일이 속한 워크스페이스로 스코프해pnpm --filter <pkg> lint를 돌린다. 훅은pnpm exec lint-staged --concurrent false로 실행(읽기 전용 eslint와prettier --write가 같은 파일에 동시 접근하지 않게 직렬화).// lint-staged.config.mjs export default { "apps/web/**/*.{ts,tsx,js,mjs,cjs}": () => "pnpm --filter web lint", "apps/mobile/**/*.{ts,tsx,js,mjs,cjs}": () => "pnpm --filter mobile lint", "packages/ui/**/*.{ts,tsx,js,mjs,cjs}": () => "pnpm --filter @plick/ui lint", "**/*.{ts,tsx,js,mjs,cjs,json,css,md}": "prettier --write", };
4-4. CI — GitHub Actions
- 무엇/왜: 코드를 올리면(PR/푸시) 깨끗한 서버가 자동으로 검사·빌드. Husky는 로컬이라 우회 가능, CI는 모두에게 강제되는 최후 관문.
- 내부 동작:
.github/workflows/ci.yml을 GitHub가 읽어, 정의된 트리거가 오면 일회용 리눅스 VM(러너) 을 띄우고 스텝을 순서대로 실행. - 우리 파이프라인:
checkout → pnpm/node 준비 → install --frozen-lockfile → format:check → lint → check-types → build. 하나라도 실패하면 PR에 빨간불.--frozen-lockfile:pnpm-lock.yaml과package.json이 어긋나면 설치 거부(재현성 보장).concurrency + cancel-in-progress: 같은 브랜치에 새 커밋 오면 진행 중 CI 취소.
- 실제로 값을 한 사례: CI를 켜자마자 첫 PR에서 진짜 버그를 잡았다.
@plick/ui의 eslint 설정이 옛 스코프@repo/eslint-config를 import하고 있었는데, 내 로컬엔 옛 설치 잔여물이 있어 우연히 통과했지만--frozen-lockfile로 깨끗이 설치한 CI에선ERR_MODULE_NOT_FOUND로 실패 →@plick/eslint-config로 수정. 로컬 편차를 서버가 걸러낸 전형적 사례.
4-5. Node 버전 · 에디터 고정
.nvmrc(22):nvm use가 읽어 Node 버전 전환. CI도 이 파일을 버전 소스로 사용.package.jsonengines.node를>=18→>=20.9.0(Next 16 실제 요구치)으로 상향..editorconfig: 에디터 무관하게 들여쓰기·개행(lf)·파일 끝 빈 줄 통일.
4-6. 협업 편의 (PR 템플릿 · VS Code · Dependabot)
.github/pull_request_template.md: PR 열면 본문에 개요·관련 이슈·체크리스트 자동 채움..vscode/settings.json: 저장 시 Prettier 자동 포맷 + ESLint 자동 수정, 모노레포 flat config 인식 (eslint.workingDirectories: [{ mode: "auto" }]).extensions.json으로 공용 확장 추천..github/dependabot.yml: npm(pnpm)·GitHub Actions 의존성 주간 업데이트 PR 자동 생성(minor/patch 묶음).
5. 최종 세팅 과정
# 0) 사전: Node LTS + pnpm 준비 (corepack enable 또는 npm i -g pnpm) # 1) 뼈대 생성 (pnpm 워크스페이스 + Turborepo + 예제 앱/패키지 + install까지 자동) pnpm dlx create-turbo@latest # 2) 스코프/이름 정리: @repo/* → @plick/* (전 패키지 package.json + import 문) # 포트, README 등 취향껏 # 3) 공용 패키지 필요 시 추가 (예: 디자인 토큰) # packages/tokens/{package.json, theme.css} 직접 작성 # 4) Prettier pnpm add -D -w prettier prettier-plugin-tailwindcss # .prettierrc.json, .prettierignore 작성 # package.json scripts에 format / format:check 추가 # 5) Husky + lint-staged pnpm add -D -w husky lint-staged pnpm exec husky init # .husky/pre-commit → "pnpm exec lint-staged --concurrent false" # lint-staged.config.mjs 작성 (모노레포는 워크스페이스별 --filter 스코프!) # 6) CI # .github/workflows/ci.yml 작성 (install --frozen-lockfile → format:check → lint → check-types → build) # 7) 런타임/에디터 고정 # .nvmrc (Node 버전), .editorconfig, package.json engines 상향 # 8) 협업 편의 # .github/pull_request_template.md, .vscode/{settings,extensions}.json, .github/dependabot.yml # 9) 검증 pnpm install pnpm format:check && pnpm lint && pnpm check-types && pnpm build
모노레포에서 꼭 기억할 함정 2개
- ESLint flat config는 cwd 기준이라 루트에서 한 번에 못 돌린다 → lint-staged/CI에서 워크스페이스 스코프로.
- 로컬
node_modules엔 옛 설치 잔여물이 남을 수 있다 → CI는--frozen-lockfile로 깨끗이 설치해 편차를 잡는다.
6. 예상 질문
- 왜 모노레포인가?
- 웹·모바일이 공용 UI·토큰·타입을 공유한다. 저장소를 쪼개면 공용 코드 변경 시 버전·배포가 번거롭다. 모노레포는 소스 심링크라 공용 패키지 수정이 즉시 반영되고 원자적 커밋이 가능하다.
- 왜 pnpm인가? (npm/yarn 대비)
- 콘텐츠 스토어 + 하드링크로 디스크 절약, 비평탄 node_modules로 유령 의존성 차단(선언 안 한 패키지는 import 불가), 워크스페이스 내장.
- Turborepo가 하는 일과 빠른 이유?
- 패키지/작업 그래프를 만들어 위상 정렬 후 병렬 실행,
^로 업스트림 의존성 순서 보장, 입력 해시 기반 캐시로 안 바뀐 작업은 산출물·로그를 복원(재실행 안 함).
- 패키지/작업 그래프를 만들어 위상 정렬 후 병렬 실행,
- 품질 게이트를 어떻게 설계했나?
- 3단계 — 에디터(저장 시 포맷) → 커밋(Husky+lint-staged, 변경분만이라
빠름) → CI(모두 강제, 우회 불가). 역할이 겹치는 Prettier/ESLint는
eslint-config-prettier로 조율.
- 3단계 — 에디터(저장 시 포맷) → 커밋(Husky+lint-staged, 변경분만이라
빠름) → CI(모두 강제, 우회 불가). 역할이 겹치는 Prettier/ESLint는
- lint-staged가 처음에 왜 실패했고 어떻게 고쳤나?
- ESLint v9 flat config가 cwd에서 설정을 찾는데
모노레포는 설정이 각 워크스페이스에만 있어 루트 실행이 실패했다. 파일이 속한 워크스페이스로 스코프해
pnpm --filter로 해당 패키지 lint를 돌려 해결.
- ESLint v9 flat config가 cwd에서 설정을 찾는데
모노레포는 설정이 각 워크스페이스에만 있어 루트 실행이 실패했다. 파일이 속한 워크스페이스로 스코프해
- CI에서 로컬과 다른 문제를 어떻게 잡았나?
pnpm install --frozen-lockfile로 깨끗이 설치하니, 로컬에만 남아 있던 옛 스코프(@repo) 심링크에 가려졌던 버그가 드러나 수정했다. → "내 컴퓨터에선 됨"을 구조적으로 차단한 사례.
- node_modules가 왜 여러 개인가?
- 복사가 아니라 심링크 모음이다. 진짜 파일은 루트
.pnpm에 1벌, 각 패키지 node_modules는 자기가 선언한 것만 가리키는 바로가기 → 격리로 유령 의존성 차단.
- 복사가 아니라 심링크 모음이다. 진짜 파일은 루트
용어 사전
| 용어 | 뜻 |
|---|---|
| 패키지 | package.json을 가진 폴더 하나 |
| 워크스페이스 | 모노레포 안의 로컬 패키지(자리) |
workspace:* | "로컬 워크스페이스 패키지를 연결" pnpm 프로토콜 |
| 콘텐츠 스토어 | pnpm이 파일을 내용 해시로 1벌만 저장하는 전역 저장소 |
| 심링크/하드링크 | 복사 없이 파일/폴더를 가리키는 링크 |
| 유령 의존성 | 선언 안 했는데 우연히 import되는 것(pnpm이 차단) |
| 작업 그래프 | 패키지#작업 노드와 선행 관계 그래프(turbo) |
dependsOn/^ | 작업 선행 조건 / ^=업스트림 의존 패키지 먼저 |
| 캐시 HIT/MISS | 입력 해시가 캐시에 있음/없음 |
| flat config | ESLint v9 설정 방식(cwd 기준 탐색) |
core.hooksPath | Git이 훅을 찾는 경로(Husky가 .husky/_로 지정) |
--frozen-lockfile | 락파일과 다르면 설치 거부(재현성) |
이렇게 기본 개발 세팅을 마칠 수 있었다.