Turborepo 모노레포에서 ESLint·TypeScript·Tailwind 설정 공유하기: config-* 패턴 실전 가이드
모노레포를 셋업하고 얼마 지나지 않아 이런 상황을 경험해보셨을 겁니다. apps/web의 ESLint 규칙을 하나 수정했는데 apps/admin에는 반영이 안 돼서 CI가 터지는 것이죠. 설정 파일을 여러 군데 복붙하는 순간부터 "단일 진실의 원천"은 사라지고, 파일이 몇 개인지도 헷갈리는 유지보수가 시작됩니다.
저도 처음엔 각 앱마다 tsconfig.json을 따로 관리했는데, 규칙 하나 바꾸려다 5개 파일을 뜯어고치고 나서야 뭔가 잘못됐다는 걸 느꼈습니다. Turborepo가 공식적으로 권장하는 packages/config-* 패턴이 바로 이 문제를 해결하는 방법입니다. 이 글을 읽고 나면, 새 앱을 추가할 때 devDependencies 세 줄만으로 ESLint·TypeScript·Tailwind 표준 환경을 완성할 수 있게 됩니다.
사전 조건: 이 글은 pnpm 워크스페이스 기본 개념과 Turborepo를 어느 정도 써보신 중급 이상 개발자를 대상으로 합니다. Turborepo를 처음 접하신다면 공식 Quick Start를 먼저 살펴보시는 것을 권장합니다. 2024~2025년 사이 ESLint(v9), Tailwind CSS(v4) 두 도구가 큰 변화를 겪었기 때문에, 최신 트렌드도 함께 짚고 넘어가겠습니다.
핵심 개념
왜 설정을 별도 패키지로 분리하는가
모노레포에서 설정 공유 방식은 크게 두 가지입니다. 루트에 설정 파일 하나 두고 모든 앱이 상속받는 방식, 그리고 각 앱이 독립적으로 설정을 갖는 방식. 전자는 단순하지만 앱마다 다른 설정이 필요해질 때 유연하지 않고, 후자는 중복이 폭발합니다.
packages/config-* 패턴은 그 중간 지점입니다. 설정을 별도의 내부 패키지로 만들고, 각 앱이 필요한 것만 골라서 참조하는 방식이죠.
monorepo/
├── apps/
│ ├── web/ ← Next.js 앱
│ └── admin/ ← 어드민 앱
└── packages/
├── config-eslint/ # @repo/eslint-config
├── config-typescript/ # @repo/typescript-config
├── config-tailwind/ # @repo/tailwind-config
└── ui/ # 공유 UI 컴포넌트Just-in-Time 패키지 전략
설정 패키지의 핵심 특징은 빌드 단계가 없다는 점입니다. 일반적인 라이브러리 패키지는 TypeScript 소스를 빌드해서 dist/ 폴더를 만들고, 소비자가 그걸 사용합니다. 설정 패키지는 소스 파일 자체를 직접 참조하고, 소비하는 앱이 컴파일 시점에 처리합니다.
JIT(Just-in-Time) 패키지: 자체 빌드 스텝 없이 소스 파일을 직접 노출하는 내부 패키지. Turborepo가 빌드 캐시를 생성하지 않는 대신, 설정 변경이 즉시 모든 소비자에게 반영됩니다.
워크스페이스 참조로 연결하기
pnpm을 사용한다면 pnpm-workspace.yaml에 패키지 경로를 선언하고, 각 앱의 package.json에서 workspace:* 프로토콜로 참조합니다.
# pnpm-workspace.yaml
packages:
- "apps/*"
- "packages/*"// apps/web/package.json
{
"devDependencies": {
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@repo/tailwind-config": "workspace:*"
}
}workspace:*는 "npm 레지스트리에서 찾지 말고 로컬 워크스페이스에서 찾아라"는 의미입니다. 버전을 맞출 필요가 없고, pnpm이 자동으로 심링크(symbolic link)를 걸어줍니다. 설정 패키지의 package.json에는 "version": "0.0.0"으로 설정하고 "private": true를 붙여서 외부에 배포되지 않도록 하는 것이 관례입니다.
실전 적용
TypeScript 설정 공유
TypeScript 설정 패키지부터 시작하는 것을 권장합니다. 빌드도 없고 .json 파일만 노출하면 되기 때문에 변경 범위가 가장 작고, 기존 모노레포에 점진적으로 도입하기도 편합니다.
// packages/config-typescript/package.json
{
"name": "@repo/typescript-config",
"version": "0.0.0",
"private": true,
"files": ["base.json", "nextjs.json", "react-library.json"]
}base.json은 모든 패키지가 공통으로 상속하는 최소한의 기반입니다. strict 모드를 켜두는 것이 포인트인데, 이걸 공유 설정으로 관리하면 "어느 앱은 strict, 어느 앱은 non-strict" 같은 상황이 발생하지 않습니다.
// packages/config-typescript/base.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true
}
}용도별로 파일을 분리해두면 각 앱이 자신에게 맞는 설정만 extends할 수 있습니다. nextjs.json은 Next.js가 요구하는 옵션들을 base.json 위에 추가합니다.
// packages/config-typescript/nextjs.json
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "./base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"allowJs": true,
"jsx": "preserve",
"incremental": true
}
}// apps/web/tsconfig.json
{
"extends": "@repo/typescript-config/nextjs.json",
"compilerOptions": {
"outDir": "dist"
},
"include": ["src"]
}| 파일 | 용도 |
|---|---|
base.json |
모든 패키지의 공통 기반 설정 |
nextjs.json |
Next.js 앱에 최적화된 설정 (base 상속) |
react-library.json |
React 라이브러리 패키지용 설정 (base 상속) |
참고: Turborepo 팀이 2024년에 "TypeScript Project References가 불필요할 수 있다"는 입장을 발표했습니다.
extends기반의 단순 공유 전략이 대부분의 경우 충분하고, Project References는 오히려 설정 복잡도만 높이는 경우가 많습니다. 저도 처음엔 Project References를 붙여봤다가 결국 되돌렸는데, 번잡함에 비해 실익이 크지 않았습니다.
TypeScript는 이렇게 단순한데, ESLint는 조금 더 고려할 것이 많습니다. 특히 2024년 이후로 Flat Config 전환이라는 큰 변화가 있었기 때문입니다.
ESLint Flat Config 공유
솔직히 말씀드리면, ESLint 설정 공유는 2024년을 기점으로 상당히 달라졌습니다. ESLint v8이 2024년 10월 5일부로 EOL(End of Life)을 맞이했고, ESLint 9부터는 eslint.config.js 기반의 Flat Config 방식이 기본입니다. 기존 .eslintrc 방식에 익숙하셨다면 마이그레이션이 필요합니다.
패키지 구성 시 "type": "module" 선언과 의존성 명시가 빠지면 초심자가 따라하다 바로 막힙니다. 꼭 포함시켜 주세요.
// packages/config-eslint/package.json
{
"name": "@repo/eslint-config",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./base": "./src/base.js",
"./next": "./src/next.js",
"./react-internal": "./src/react-internal.js"
},
"dependencies": {
"@eslint/js": "^9.0.0",
"eslint-plugin-turbo": "^2.0.0"
},
"peerDependencies": {
"eslint": ">=9.0.0"
}
}"type": "module" 선언이 특히 중요합니다. Flat Config는 ESM 방식으로 동작하는데, 이게 없으면 .js 파일이 CommonJS로 해석되어 예상치 못한 오류가 발생합니다. 저도 이 부분에서 한참 헤맸던 기억이 납니다.
// packages/config-eslint/src/base.js
import js from "@eslint/js";
import turboPlugin from "eslint-plugin-turbo";
export const baseConfig = [
js.configs.recommended,
{
plugins: { turbo: turboPlugin },
rules: {
"turbo/no-undeclared-env-vars": "warn",
},
},
];Next.js 전용 설정을 추가할 때는 eslint-config-next의 Flat Config 지원 방식이 Next.js 버전에 따라 다를 수 있어서 주의가 필요합니다. 아래는 Next.js 14.x 이하 버전처럼 Flat Config를 아직 네이티브로 지원하지 않는 경우에 @eslint/eslintrc의 FlatCompat으로 브릿지하는 방식입니다.
// packages/config-eslint/src/next.js
// (Next.js 14.x 이하 또는 eslint-config-next이 flat config를 지원하지 않는 경우)
import { FlatCompat } from "@eslint/eslintrc";
import { baseConfig } from "./base.js";
const compat = new FlatCompat();
export const nextjsConfig = [
...baseConfig,
...compat.extends("next/core-web-vitals"),
];앱에서 사용할 때는 이렇게 됩니다.
// apps/web/eslint.config.mjs
import { nextjsConfig } from "@repo/eslint-config/next";
export default [...nextjsConfig];기존 플러그인 중 Flat Config를 아직 지원하지 않는 것이 있다면 @eslint/compat 패키지가 도움이 됩니다. 저도 사내 커스텀 플러그인을 마이그레이션할 때 이 방법을 사용했는데, 대부분의 경우 별다른 코드 수정 없이 동작했습니다.
// packages/config-eslint/src/base.js (레거시 플러그인 포함 예시)
import { fixupPluginRules } from "@eslint/compat";
import legacyPlugin from "some-legacy-eslint-plugin";
export const baseConfig = [
// ...기존 설정
{
plugins: {
legacy: fixupPluginRules(legacyPlugin),
},
},
];ESLint 설정이 잡혔다면 이제 Tailwind 차례입니다. Tailwind는 v3와 v4의 방식이 꽤 다르기 때문에 둘 다 살펴보겠습니다.
Tailwind CSS 설정 공유
Tailwind v3 방식 — 스프레드로 공유
v3에서는 tailwind.config.ts를 작성하고 앱에서 스프레드로 가져오는 방식이 가장 일반적입니다. 여기서 content를 의도적으로 제외하는 게 포인트입니다. 파일 경로는 앱마다 다르기 때문에 공유 설정에 포함시키면 모든 앱이 동일한 경로를 바라보는 문제가 생깁니다.
// packages/config-tailwind/tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Omit<Config, "content"> = {
theme: {
extend: {
colors: {
brand: "#0070f3",
},
},
},
plugins: [],
};
export default config;// apps/web/tailwind.config.ts
import type { Config } from "tailwindcss";
import sharedConfig from "@repo/tailwind-config";
const config: Config = {
...sharedConfig,
content: [
"./src/**/*.{ts,tsx}",
"../../packages/ui/src/**/*.{ts,tsx}", // 공유 UI 패키지 경로 명시
],
};
export default config;공유 UI 패키지 경로를 content에 포함시키지 않으면, 해당 패키지에서 사용하는 Tailwind 클래스가 프로덕션 빌드 시 purge(미사용 클래스 제거) 대상이 됩니다. 처음엔 개발 환경에서 잘 보이다가 프로덕션 배포 후 스타일이 사라지는 황당한 경험을 하게 되죠. 꼭 넣어두세요.
Tailwind v4 방식 — CSS 파일 기반 설정
v4에서는 tailwind.config.js가 사라지고 CSS 파일 안에서 설정합니다. 공유 CSS 파일을 외부 패키지로 노출할 때는 package.json의 exports 필드에 CSS 파일 경로를 명시해야 합니다. 이 부분이 빠지면 @import가 동작하지 않아 처음 셋업할 때 당황하기 쉽습니다.
// packages/config-tailwind/package.json
{
"name": "@repo/tailwind-config",
"version": "0.0.0",
"private": true,
"exports": {
"./theme.css": "./theme.css"
}
}/* packages/config-tailwind/theme.css */
@import "tailwindcss";
@theme {
--color-brand: #0070f3;
--font-sans: "Inter", sans-serif;
}v4의 자동 콘텐츠 스캔은 현재 패키지 범위만 감지합니다. 공유 UI 패키지를 사용한다면 @source 지시어로 경로를 명시해줘야 합니다.
/* apps/web/src/styles/globals.css */
@import "@repo/tailwind-config/theme.css";
/* 공유 UI 패키지를 Tailwind가 스캔하도록 명시 */
@source "../../../packages/ui/src";| 버전 | 설정 방식 | 모노레포 공유 포인트 |
|---|---|---|
| v3 | tailwind.config.ts 스프레드 |
content 제외하고 공유, 각 앱에서 경로 지정 |
| v4 | CSS @theme, @source 지시어 |
exports 필드에 CSS 경로 명시 후 @import, @source로 경로 추가 |
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 일관성 | 모든 앱이 동일한 규칙과 설정을 사용해 코드 품질이 균일해집니다 |
| 유지보수 단순화 | 설정 변경 시 한 곳만 수정하면 전체에 반영됩니다 |
| 빠른 반영 | JIT 방식이라 빌드 없이 변경 사항이 즉시 소비자에게 전달됩니다 |
| 온보딩 용이 | 새 앱 추가 시 devDependencies 세 줄이면 표준 설정이 완성됩니다 |
| 유연한 확장 | 공통 base를 두고 앱별로 커스터마이징이 가능합니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| JIT 캐싱 불가 | 설정 패키지는 Turborepo 빌드 캐시를 생성하지 않아, 설정이 자주 바뀌면 하위 패키지가 매번 리빌드됩니다 | 설정 변경을 의미 있는 단위로 묶어서 한 번에 반영하는 것을 권장합니다 |
| 번들러 의존성 | JIT 방식은 소비자 앱이 번들러를 통해 TypeScript를 처리할 수 있어야 합니다 | 번들러 없이 Node.js에서 직접 실행하는 패키지엔 적합하지 않습니다 |
| ESLint v9 전환 비용 | Flat Config 마이그레이션 시 기존 .eslintrc 기반 플러그인 호환성 문제가 발생할 수 있습니다 |
@eslint/compat의 fixupPluginRules로 대응할 수 있습니다 |
| Tailwind v4 스캔 범위 | 자동 콘텐츠 감지가 공유 UI 패키지를 감지하지 못합니다 | @source 지시어로 경로를 명시적으로 추가하면 됩니다 |
paths 별칭 문제도 실무에서 종종 만납니다. JIT 패키지 내에서 TypeScript의 compilerOptions.paths 별칭을 사용하면 소비자가 해석하지 못합니다. TypeScript 5.4 이상에서는 package.json의 imports 필드를 통한 subpath imports(패키지 내부 경로를 #utils처럼 #으로 시작하는 별칭으로 정의하는 Node.js 기능)로 대체하는 것을 권장합니다.
실무에서 가장 흔한 실수
-
content경로를 Tailwind 설정 패키지에 포함시키는 경우: v3에서 공유 config에content까지 넣으면 모든 앱이 동일한 경로를 바라봅니다.content는 반드시 각 앱의tailwind.config.ts에서 지정해야 합니다. -
ESLint 공유 패키지에
"type": "module"누락: Flat Config는 ESM 방식으로 동작하는데, 이게 없으면 import 구문이 CommonJS로 해석되어SyntaxError: Cannot use import statement가 발생합니다. -
공유 UI 패키지의 Tailwind 클래스가 프로덕션 빌드에서 사라지는 경우: 앱의
content설정(v3) 또는@source지시어(v4)에 공유 UI 패키지 경로가 빠지면, 개발 환경에서는 멀쩡한데 프로덕션 배포 후 스타일이 증발합니다.../../packages/ui/src/**/*.{ts,tsx}형태로 경로를 명시해두는 것이 안전합니다.
마치며
packages/config-* 패턴은 모노레포의 설정 복잡도를 근본적으로 줄여주는 전략입니다. 처음 셋업하는 데 시간이 조금 걸리지만, 이후에는 새 앱을 추가할 때 설정 걱정 없이 비즈니스 로직에만 집중할 수 있게 됩니다. 저는 이 구조를 도입하고 나서 온보딩 시간이 눈에 띄게 줄었습니다.
새 프로젝트라면 1번부터, 기존 프로젝트에 도입하신다면 2번부터 시작해보시면 좋을 것 같습니다.
-
npx create-turbo@latest my-monorepo로 스캐폴딩을 생성해 보시면 좋습니다.config-eslint,config-typescript패키지가 자동으로 만들어져 실제 구조를 바로 확인할 수 있습니다. -
기존 모노레포에 적용하신다면 TypeScript 설정부터 시작하는 것을 권장합니다.
packages/config-typescript/base.json을 작성하고 기존 앱의tsconfig.json에"extends": "@repo/typescript-config/base.json"을 추가해보시면 됩니다. 변경 범위가 가장 작습니다. -
ESLint 마이그레이션 전에 현재 버전을 확인해 보시는 것을 권장합니다. v8 이하라면 v9 Flat Config 전환을 함께 고려하는 것이 장기적으로 유리하며,
@eslint/migrate-config도구가 기존.eslintrc설정을 Flat Config로 변환하는 데 도움이 됩니다.
다음 편에서는 Turborepo 파이프라인 최적화 — turbo.json 캐싱 전략과 원격 캐시(Remote Cache) 셋업으로 팀 전체 빌드 시간을 단축하는 방법을 다뤄볼 예정입니다.
참고 자료
- Internal Packages | Turborepo
- Structuring a Repository | Turborepo
- Managing Dependencies | Turborepo
- ESLint 가이드 | Turborepo
- TypeScript 가이드 | Turborepo
- Tailwind CSS 가이드 | Turborepo
- You might not need TypeScript project references | Turborepo Blog
- How to Create an ESLint Config Package in Turborepo | DEV Community
- Setting up Tailwind CSS v4 in a Turbo Monorepo | Medium
- Building a Scalable Frontend Monorepo with Turborepo, Vite, TailwindCSS V4 | DEV Community
- Monorepo Setup with Turborepo - Complete Guide | NashTech Blog
- Creating an Internal Package | Turborepo