티스토리 뷰
Next.js + Turborepo 기반 모노레포를 운영하다 보면 결국 CSS 구조를 다시 고민하게 됩니다.
참고로 이 글은 Tailwind v4를 기본으로 사용하는 프로젝트 기준으로 정리한 내용입니다. Tailwind v4부터는 native CSS Cascade Layers를 사용하기 때문에, @layer 기반 설계가 자연스럽게 어울립니다.
초기에는 단순히:
packages/ui
하나 만들어서 공통 컴포넌트만 공유하면 끝날 것 같지만, 실제로 앱이 늘어나기 시작하면 아래 문제가 생깁니다.
- globals.css import 순서 꼬임
- Tailwind utility override 충돌
- 서비스별 스타일 override
- 디자인 토큰 관리
- 앱별 브랜딩
- B2B SaaS 멀티 테넌트 테마 대응
특히:
apps/web
apps/admin
apps/landing
처럼 여러 앱이 공통 UI를 공유하기 시작하면 CSS 우선순위 관리가 굉장히 중요해집니다.
이번 글에서는 Next.js App Router + Turborepo + Tailwind v4 기준으로, 제가 직접 고민하면서 정리한 CSS Layer 구조를 공유합니다.
왜 기존 CSS 방식이 문제가 되는가
기존 CSS는 import 순서 기반입니다.
.button {
color: blue;
}
.button {
color: red;
}
나중에 import 된 red가 적용됩니다.
단일 앱에서는 큰 문제가 아닐 수 있습니다.
하지만 모노레포에서는 아래와 같이 여러 패키지와 앱이 CSS를 공유하게 됩니다.
packages/ui
packages/design-system
apps/web
apps/admin
이 순간부터:
- 어떤 CSS가 마지막에 번들되는지
- 어떤 스타일이 override 되는지
- Tailwind utility가 왜 안 먹는지
예측하기 어려워집니다. (실제로 운영하다보면 갑작스러운 css 충돌로 인한 버그를 만나게 되죠;)
CSS Layer(@layer)란?
CSS Cascade Layer는 CSS 우선순위를 "import 순서" 대신 "레이어 순서"로 제어하는 기능입니다.
@layer theme, base, components, utilities;
뒤에 선언될수록 우선순위가 높습니다.
↑ 강함
utilities
components
↑
base
↑
theme
↓ 약함
즉:
- theme → 디자인 토큰
- base → reset 및 기본 스타일
- components → 공통 UI
- utilities → override 및 유틸성 스타일
역할을 명확하게 분리할 수 있습니다.
중요한 포인트: @layer 밖의 CSS가 더 강합니다
여기서 많이 헷갈리는 부분이 있습니다.
unlayered CSS > layered CSS
@layer 안에 들어간 CSS(layered)보다 @layer 밖에서 선언된 CSS(unlayered)가 우선순위가 높습니다.
예:
@layer base {
body {
background: black;
}
}
body {
background: red;
}
최종적으로는 red가 적용됩니다.
단, 이건 specificity가 같을 때의 이야기입니다. unlayered라고 해서 specificity를 무시하는 건 아니고, 같은 조건이면 unlayered가 layered를 이긴다는 의미입니다.
즉:
- layered CSS = 구조화된 CSS
- unlayered CSS = 명시적 override
개념에 가깝습니다.
아래와 같은 원칙으로 가지고 갑니다.
가능하면 모든 CSS를 layer 안에서 관리한다
unlayered CSS가 섞이기 시작하면 "왜 이게 안 먹지?" 하는 순간이 옵니다.
모노레포 구조
제가 잡은 구조는 아래와 비슷합니다.
apps/
web/
admin/
landing/
packages/
ui/
design-system/
여기서 역할은 다음처럼 나눴습니다.
패키지 역할
| ui | React Component |
| design-system | CSS / token / theme |
| apps | 실제 서비스 조합 및 utility/page-level override |
핵심은:
packages = framework
apps = composition
개념으로 가져가는 것입니다.
여기서 한 가지 중요하게 생각한 원칙이 있습니다.
디자인 토큰은 design-system에서만 정의한다 (Single Source of Truth)
apps는 토큰을 소비할 뿐, 재정의하지 않습니다. 만약 apps마다 --primary를 다시 정의하기 시작하면 그건 이미 디자인 시스템이 아니라 그냥 변수일 뿐입니다.
packages/design-system 구조
packages/design-system/
styles/
index.css
theme.css
base.css
components.css
index.css
@layer theme, base, components, utilities;
@import "./theme.css";
@import "./base.css";
@import "./components.css";
여기서 layer 우선순위를 먼저 고정합니다.
Theme Layer
디자인 토큰은 가장 약해야 합니다.
@layer theme {
:root {
--background: #ffffff;
--foreground: #111111;
--primary: oklch(0.62 0.18 250);
--radius: 12px;
}
}
보통 여기에는:
- semantic color
- spacing
- radius
- typography
- motion token
등만 둡니다.
멀티 테넌트나 화이트라벨링이 필요하다면, 앱이 토큰을 재정의하는 게 아니라 design-system에서 미리 정의된 테마를 스코프로 분리해두고 앱은 선택해서 쓰는 방식으로 가져가려고 합니다.
@layer theme {
:root,
[data-theme="default"] {
--primary: oklch(0.62 0.18 250);
}
[data-theme="admin"] {
--primary: oklch(0.5 0.2 30);
}
}
// apps/admin/app/layout.tsx
<html data-theme="admin">
이렇게 하면 토큰의 단일 출처는 그대로 유지되면서 앱별 브랜딩이 가능합니다.
Base Layer
reset 및 기본 HTML 스타일입니다.
@layer base {
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
* {
border-color: var(--border);
outline-color: color-mix(
in oklab,
var(--ring) 50%,
transparent
);
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: var(--font-pretendard), sans-serif;
}
a {
color: inherit;
text-decoration: none;
}
}
여기서 중요한 점은
html/body 같은 기본 스타일도 @layer 밖이 아닌
@layer base 안에 두어야 합니다
왜냐하면 @layer 밖의 unlayered CSS는 layered CSS보다 우선순위가 높아서, base가 의도치 않게 utility까지 이겨버릴 수 있기 때문입니다.
Components Layer
공통 UI 컴포넌트 스타일입니다.
@layer components {
.btn {
display: inline-flex;
align-items: center;
border-radius: var(--radius);
}
.card {
border-radius: var(--radius);
border: 1px solid var(--border);
}
}
여기서 핵심은
component style은 utility보다 약해야 합니다
그래야 Tailwind utility override가 자연스럽게 동작합니다.
또 한 가지 유용한 패턴은 :where()를 사용해 specificity를 0으로 낮추는 것입니다.
@layer components {
:where(.btn) {
border-radius: var(--radius);
}
}
:where()는 안에 들어간 selector의 specificity를 0으로 만들기 때문에, 같은 layer 안에서도 override가 매우 쉬워집니다. 컴포넌트를 다른 곳에서 사용할 때 별도 selector 없이도 쉽게 덮을 수 있게 해주는 패턴입니다.
Utilities는 어디에 둬야 할까?
여기서 모노레포에서 중요한 설계 포인트가 나옵니다.
저는 utilities는:
packages 보다 apps 에 두는 것이 맞다
고 판단했습니다.
이유는 두 가지입니다.
1. 성격상 서비스에 종속됩니다
utilities는
- 서비스 특화 스타일
- 실험성 UI
- 페이지 전용 effect
- 브랜딩
성격이 강합니다.
2. 구조적으로도 마지막 layer라 apps에 두는 게 맞습니다
utilities가 가장 강한 layer이기 때문에, 이걸 design-system 같은 공통 패키지에 두면 정작 앱이 override할 layer가 없어집니다. 결국 apps에서 unlayered CSS로 강제로 덮어야 하는데, 그 순간 layer 전략 자체가 무너집니다.
apps에 utilities를 두면 자연스럽게 가장 마지막 layer가 되면서 override 흐름이 깔끔해집니다.
정리한 구조
packages/design-system
theme
base
components
apps/web
utilities
page effect
service override
여기서 "service override"는 토큰을 바꾸는 게 아니라, design-system이 제공한 토큰을 사용해 페이지/섹션 단위 스타일을 작성한다는 의미입니다.
실제 예시
packages/design-system
@layer components {
.btn {
border-radius: 12px;
}
}
apps/web
@layer utilities {
.hero-dot-grid {
background-image: radial-gradient(
circle,
rgba(255, 255, 255, 0.055) 1px,
transparent 1px
);
background-size: 28px 28px;
}
.hero-brand-glow {
background: radial-gradient(
ellipse at center bottom,
color-mix(
in oklch,
var(--brand-600) 28%,
transparent
) 0%,
transparent 60%
);
}
}
이런 스타일은
- landing 전용
- marketing 전용
- app branding 전용
성격이라 공통 패키지에 넣지 않았습니다.
Tailwind v4와의 관계
Tailwind v4는 내부적으로 native CSS Cascade Layers를 사용합니다.
theme
↓
base
↓
components
↓
utilities
<Button className="bg-red-500" />
처럼 utility class가 component style을 자연스럽게 override할 수 있습니다.
위에서 설계한 layer 구조는 Tailwind v4의 layer 체계와 그대로 정렬됩니다. 별도 레이어를 새로 만들 필요 없이, Tailwind가 깔아둔 레이어 위에 디자인 시스템을 얹는 구조가 됩니다.
Next.js App Router에서 Import
import "@repo/design-system/styles/index.css";
global css는 반드시:
app/layout.tsx
같은 root level에서 import 해야 합니다.
마무리
Next.js + Turborepo 모노레포에서 CSS 구조는 단순한 스타일 문제가 아니라 아키텍처 문제에 가깝다고 느꼈습니다.
특히
- 디자인 시스템 공유
- 멀티 앱
- B2B SaaS
- 화이트라벨링
- 서비스별 브랜딩
구조로 갈수록 CSS Layer 전략이 중요해집니다.
제가 잡은 방향은
packages
-> theme/base/components (토큰의 단일 출처)
apps
-> utilities/page-level override (토큰을 소비)
구조입니다.
그리고 아래와 같이 layer 안으로 관리하는 것이 훨씬 안정적인 구조를 만들 수 있을 것 입니다.
모든 CSS를 @layer 안에서 관리
unlayered CSS가 섞이기 시작하는 순간 우선순위 디버깅이 다시 어려워지기 때문입니다.
'개발..' 카테고리의 다른 글
| Query Factory 패턴을 Claude Code 스킬로 만들기 (0) | 2026.05.20 |
|---|---|
| Superpowers로 완성하는 AI 네이티브 엔지니어링 (0) | 2026.05.11 |
| B2B SaaS/OnPremise 프론트엔드 아키텍처 만들기 (0) | 2026.05.06 |
| 하네스 엔지니어링 도입 해보기 (0) | 2026.03.30 |
| 구글 Stitch 2.0 + Claude Code 조합으로 웹싸이트 만들어보기 (0) | 2026.03.23 |
- Total
- Today
- Yesterday
- nuxt2
- vscode
- 프론트엔드
- 서버 to 서버
- claude code
- Git
- nextjs15
- nextjs14
- Zustand
- 타입스크립트
- NextJS
- React
- github
- Ai
- 오블완
- AWS
- NUXT
- nodejs
- cors
- 깃허브
- Vite
- claude
- nextjs13
- 티스토리챌린지
- 클로드 코드
- openAI
- ChatGPT
- seo
- Github Actions
- vue composition api
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |