티스토리 뷰

반응형

 

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가 섞이기 시작하는 순간 우선순위 디버깅이 다시 어려워지기 때문입니다.

 

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
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
글 보관함