logo

DowanKim

Turborepo를 사용한 이유

2026년 7월 1일

PLick

이제 프로젝트 세팅을 시작해야 합니다.

저장소 구조와 프레임워크, 언어 등 여러가지 사전에 문서로 정리한 것을 기반으로, 세팅을 하나하나 시작합니다.

시작하기 전, 용어와 흐름에 대해 정리할 필요가 있습니다.

선택이유
저장소 구조모노레포웹·모바일 앱 + 공용 코드를 한 저장소에서 관리
패키지 매니저pnpm (workspaces)디스크 절약 · 엄격한 의존성 · 워크스페이스 내장
작업 오케스트레이터Turborepo여러 패키지 작업을 병렬·순서·캐시로 총괄
프레임워크Next.js 16 (App Router)React 기반 웹 프레임워크
언어TypeScript실행 전 타입 검사
스타일Tailwind CSS v4 + 디자인 토큰유틸리티 클래스 + 색/글자 단일 원본
포맷Prettier (+ tailwind plugin)코드 모양 자동 통일
린트ESLint (flat config)코드 품질/버그 검사
커밋 게이트Husky + lint-staged커밋 순간 자동 검사
CIGitHub 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.json scripts: 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.yamlpackage.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.json engines.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개

  1. ESLint flat config는 cwd 기준이라 루트에서 한 번에 못 돌린다 → lint-staged/CI에서 워크스페이스 스코프로.
  2. 로컬 node_modules엔 옛 설치 잔여물이 남을 수 있다 → CI는 --frozen-lockfile깨끗이 설치해 편차를 잡는다.

6. 예상 질문

  • 왜 모노레포인가?
    • 웹·모바일이 공용 UI·토큰·타입을 공유한다. 저장소를 쪼개면 공용 코드 변경 시 버전·배포가 번거롭다. 모노레포는 소스 심링크라 공용 패키지 수정이 즉시 반영되고 원자적 커밋이 가능하다.
  • 왜 pnpm인가? (npm/yarn 대비)
    • 콘텐츠 스토어 + 하드링크로 디스크 절약, 비평탄 node_modules로 유령 의존성 차단(선언 안 한 패키지는 import 불가), 워크스페이스 내장.
  • Turborepo가 하는 일과 빠른 이유?
    • 패키지/작업 그래프를 만들어 위상 정렬 후 병렬 실행, ^로 업스트림 의존성 순서 보장, 입력 해시 기반 캐시로 안 바뀐 작업은 산출물·로그를 복원(재실행 안 함).
  • 품질 게이트를 어떻게 설계했나?
    • 3단계 — 에디터(저장 시 포맷) → 커밋(Husky+lint-staged, 변경분만이라 빠름) → CI(모두 강제, 우회 불가). 역할이 겹치는 Prettier/ESLint는 eslint-config-prettier로 조율.
  • lint-staged가 처음에 왜 실패했고 어떻게 고쳤나?
    • ESLint v9 flat config가 cwd에서 설정을 찾는데 모노레포는 설정이 각 워크스페이스에만 있어 루트 실행이 실패했다. 파일이 속한 워크스페이스로 스코프pnpm --filter로 해당 패키지 lint를 돌려 해결.
  • 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 configESLint v9 설정 방식(cwd 기준 탐색)
core.hooksPathGit이 훅을 찾는 경로(Husky가 .husky/_로 지정)
--frozen-lockfile락파일과 다르면 설치 거부(재현성)

이렇게 기본 개발 세팅을 마칠 수 있었다.