Tailwind의 복잡성과 추상화

Created at 2026년 03월 15일

Updated at 2026년 03월 15일

By 강병준

작년부터 Tailwind를 애용해왔지만, flexbg-blue-500이 실제로 어떻게 CSS가 변환되어 스타일로 적용되는지는 한 번도 들여다보지 않았다. 그래서 누군가 나에게 Tailwind가 어떻게 동작하는지, 어떤 점이 편리한지, AI는 왜 Tailwind를 선호하는지 물어봐도 명쾌하게 답하지 못했다.

그래서 이 글에서는 Tailwind v4의 내부 파이프라인을 소스코드 수준에서 추적하면서 하나씩 이해하고, 내가 답하지 못했던 질문들에 답을 찾아가려 한다.

Tailwind

Tailwind의 아이디어는 단순하다. 미리 만든 컴포넌트를 제공하는 게 아니라, 작은 유틸리티 클래스를 조합해서 직접 만든다.

<button class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
  클릭
</button>

btn-primary 같은 추상화된 이름이 없고, BEM(Block, Element, Modifier) 네이밍 컨벤션 등을 지킬 필요도 없다. Tailwind에서 제공하는 규칙에 맞춰 스타일을 작성하면 CSS 속성 하나에 1:1로 직접 대응한다.

Tailwind 창시자 Adam Wathan은 CSS Utility Classes and "Separation of Concerns"라는 글을 통해 이 방식을 옹호했다. 그의 핵심 주장은 이렇다. "HTML과 CSS를 분리하는 게 항상 옳은 건 아니다. 클래스 이름이 스타일을 숨기는 추상화 역할을 하면 오히려 유지보수가 어려워진다. 무엇을 하는지 바로 보이는 이름이 낫다."

Tailwind에서 각 유틸리티 클래스는 특정 CSS 속성값에 1:1로 매핑된다.

bg-blue-500        →  background-color: rgb(59, 130, 246)
text-white         →  color: rgb(255, 255, 255)
px-4               →  padding-left: 1rem; padding-right: 1rem
rounded            →  border-radius: 0.25rem
hover:bg-blue-600  →  :hover { background-color: rgb(37, 99, 235) }

자 그럼 이제 유틸리티 클래스가 어떻게 CSS 속성값으로 변환되는지 따라가보자.

@import "tailwindcss"

Tailwind는 빌드 타임 도구다. 런타임에 스타일을 주입하는 게 아니라, 빌드 시점에 정적 CSS 파일을 만들어낸다.

단, @import "tailwindcss"가 동작하려면 빌드 도구에 Tailwind 플러그인이 먼저 등록되어 있어야 한다. 플러그인이 없으면 그냥 일반 CSS import로 처리된다.

// vite.config.ts
import tailwindcss from '@tailwindcss/vite'
 
export default {
  plugins: [tailwindcss()]  // 이게 있어야 @import "tailwindcss"가 컴파일러를 거침
}

v4 기준으로 공식 플러그인은 @tailwindcss/vite와 @tailwindcss/postcss가 있다. PostCSS 플러그인이 있기 때문에 webpack, Parcel 등 PostCSS를 쓰는 빌드 도구라면 대부분 커버된다. 플러그인 없이 쓰려면 Tailwind CLI를 별도로 실행해야 한다.

진입점은 CSS 파일의 @import 지시어다.

/* globals.css */
@import "tailwindcss";
 
/* 테마 커스터마이징도 같은 파일에서 */
@theme {
  --color-brand: #ff6b00;  /* bg-brand, text-brand 등으로 사용 가능 */
  --spacing-18: 4.5rem;    /* p-18, m-18 등으로 사용 가능 */
}

빌드 도구(Vite, PostCSS 등)가 이 파일을 만나면 Tailwind 컴파일러가 실행된다. @import "tailwindcss" 한 줄이 내부적으로 어떻게 처리되는지 소스코드를 따라가보자.

Step 1. @import 해석과 치환

index.ts의 parseCss()가 가장 먼저 호출하는 것이 at-import.ts의 substituteAtImports()이다.

// index.ts의 parseCss() 내부
features |= await substituteAtImports(ast, base, loadStylesheet, 0, from !== undefined)

이 함수는 AST를 순회하면서 @import 노드를 찾고, 발견하면 그 안에서 loadStylesheet 콜백을 호출해 경로 해석 및 파일 로드를 수행한다. loadStylesheet은 import 대상이 상대 경로('./theme.css')든 패키지 이름("tailwindcss")이든 동일하게 호출되며, 내부의 enhanced-resolve가 자동으로 구분한다.

  • @import './theme.css''.'으로 시작 → 현재 파일 기준 상대 경로로 해석
  • @import "tailwindcss": 패키지 이름 → node_modules에서 찾고 package.json의 style 필드로 진입점 결정
// at-import.ts의 substituteAtImports() 핵심 부분
walk(ast, (node) => {
  if (node.name === '@import') {
    promises.push((async () => {
      // './theme.css'든 'tailwindcss'든 동일한 loadStylesheet을 호출
      // enhanced-resolve가 상대 경로 vs 패키지 이름을 자동 구분
      let loaded = await loadStylesheet(uri, base)     // 경로 해석 + 파일 로드가 여기서 발생
      let ast = CSS.parse(loaded.content)              // CSS 파싱
      await substituteAtImports(ast, loaded.base,      // 재귀: 로드된 파일의
            loadStylesheet, recurseCount + 1)          //      @import도 처리
    })())
  }
})

loadStylesheet의 실제 구현은 @tailwindcss-node/src/compile.ts에 있다. 내부적으로 webpack 팀이 만든 enhanced-resolve 라이브러리를 사용해서 tailwindcss라는 패키지 이름을 실제 파일 경로로 바꾼다.

// @tailwindcss-node/src/compile.ts
const cssResolver = EnhancedResolve.ResolverFactory.createResolver({
  extensions: ['.css'],
  mainFields: ['style'],        // package.json의 최상위 "style" 필드를 봄
  conditionNames: ['style'],    // exports의 "style" 조건을 봄
  modules: ['node_modules'],
})

mainFields는 package.json의 최상위 필드에서 진입점을 찾는 옵션이고, conditionNames는 package.json의 exports 맵에서 어떤 조건 키를 매칭할지 지정하는 옵션이다. 둘 다 'style'로 설정되어 있으므로, CSS 진입점을 찾는 데 특화된 resolver인 셈이다.

이 resolver가 node_modules/tailwindcss/를 찾고, package.json을 확인한다.

// tailwindcss/package.json
{
  "exports": {
    ".": {
      "style": "./index.css"   // conditionNames: ['style']에 매칭
    }
  },
  "style": "index.css"          // mainFields: ['style']에 매칭 (fallback)
}

"tailwindcss" → node_modules/tailwindcss/index.css가 로드되고, AST로 파싱된 뒤 원래 @import 노드를 교체한다. 핵심은 재귀다. 로드된 파일 안에 또 다른 @import가 있으면 같은 함수가 다시 호출된다.

Step 2. Tailwind의 진입점

index.css의 실제 내용을 보면, v3에서 우리가 직접 작성했던 @tailwind base;@tailwind utilities; 같은 지시문들이 그대로 남아있다. v4에서는 이것들을 없앤 게 아니라 @import "tailwindcss" 뒤에 감춘 것이다.

/* tailwindcss/index.css */
@layer theme, base, components, utilities;
 
@import './theme.css' layer(theme);
@import './preflight.css' layer(base);
@import './utilities.css' layer(utilities);

4줄이 전부다. 첫 줄의 @layer 선언은 CSS Cascade Layers의 우선순위 순서를 정한다. CSS Cascade Layers에서는 나중에 선언된 레이어가 우선순위가 높다.

@layer theme, base, components, utilities;
         ↑                           ↑
      가장 낮음                   가장 높음

utilities 레이어가 가장 높으므로, Tailwind 유틸리티 클래스는 리셋(base)이나 컴포넌트(components) 스타일을 항상 덮어쓸 수 있다. 예를 들어:

/* base 레이어 (preflight.css) */
@layer base {
  h1 { font-size: 2rem; margin-bottom: 0.5rem; }
}
 
/* utilities 레이어 (Tailwind가 생성) */
@layer utilities {
  .text-sm { font-size: 0.875rem; }
  .mb-0 { margin-bottom: 0; }
}
<h1 class="text-sm mb-0">제목</h1>

여기서 h1 태그에는 base의 font-size: 2rem과 utilities의 font-size: 0.875rem이 둘 다 적용되지만, utilities가 우선순위가 더 높으므로 0.875rem이 적용된다.각 @import에 layer(...)를 지정해서 해당 레이어에 배치하고, 이 @import들도 Step 1의 substituteAtImports()가 재귀적으로 처리한다.

파일레이어역할
theme.cssthemeCSS 변수로 정의된 디자인 토큰 (색상, 간격, 폰트 등)
preflight.cssbase브라우저 기본 스타일 리셋
-components사용자가 직접 작성하는 레이어
utilities.cssutilities생성된 유틸리티 CSS가 주입될 위치를 표시하는 마커

Step 3. @tailwind utilities의 역할

utilities.css의 실제 내용은 단 한 줄이다.

/* tailwindcss/utilities.css */
@tailwind utilities;

Step 1에서 substituteAtImports()가 모든 @import를 재귀적으로 치환하면, theme + preflight + @tailwind utilities가 모두 펼쳐진 하나의 큰 AST가 만들어진다.

AST (substituteAtImports 완료 후)
├── @layer theme, base, components, utilities
├── [theme.css 내용]
│   ├── --color-blue-500: oklch(62.3% 0.214 259.815)
│   ├── --color-white: #fff
│   ├── --spacing: 0.25rem
│   └── ...
├── [preflight.css 내용]
│   ├── *, ::after, ::before, ::backdrop { box-sizing: border-box; margin: 0; padding: 0; border: 0 solid; }
│   └── ...
└── [utilities.css 내용]
    └── @tailwind utilities   ← parseCss()가 이 노드를 utilitiesNode로 기록

그 다음 index.ts의 parseCss()가 이 AST를 위에서 아래로 순회하면서 특별한 노드들을 찾는데, @tailwind utilities 노드를 발견하면 utilitiesNode로 기록한다.

// index.ts의 parseCss() 내부
if (node.name === '@tailwind' && node.params === 'utilities') {
  utilitiesNode = node    // "여기에 나중에 유틸리티 CSS를 넣어라"는 위치 마커
  features |= Features.Utilities
}

여기까지가 CSS 쪽 준비 단계다. Step 1~3은 @import "tailwindcss"를 풀어서 생성된 CSS가 들어갈 자리(utilitiesNode)를 확보하는 과정이었다. 이제부터는 소스코드(.tsx.html 등)에서 실제 사용된 Tailwind 클래스를 찾아 CSS로 변환하는 단계를 따라간다.

Step 4. Scan

Scan은 소스 파일(.tsx.jsx.html 등)에서 Tailwind 클래스 후보 문자열을 추출하는 단계다.

v2 이전에는 이런 스캔 자체가 없었다. 가능한 모든 유틸리티 CSS를 전부 생성한 뒤, 프로덕션 빌드에서 PurgeCSS로 사용하지 않는 것을 삭제하는 방식이었다. 이 방식은 빌드가 느리고, dev 환경에서 수MB짜리 CSS를 통째로 로드해야 했으며, bg-[#ff6b00] 같은 임의값은 미리 만들어둘 수 없어서 지원 자체가 불가능했다.

v3에서 JIT(Just-In-Time) 엔진이 도입되면서 패러다임이 바뀌었다. 소스 파일을 먼저 스캔하고, 발견된 클래스만 즉시 생성하는 방식이다. v3까지는 JavaScript 정규식 기반 추출기를 사용했고, v4에서는 이 엔진을 Rust로 완전히 교체했다. 공식 블로그에서 그 이유를 밝히고 있다:

"Rust where it counts — we've migrated some of the most expensive and parallelizable parts of the framework to Rust, while keeping the core of the framework in TypeScript for extensibility."

Tailwind CSS v4.0 Alpha

소스 스캔은 대량의 파일을 순회하면서 문자열을 추출하는 CPU 집약적이고 병렬화 가능한 작업이기 때문에 Rust로 이관하기에 적합한 대상이었다. v4 릴리스 블로그에 따르면 풀 빌드 3.78x, 증분 리빌드 최대 182x 빠른 성능 개선을 달성했다.

Rust 구현은 crates/oxide/src/extractor/candidate_machine.rs에 있다.

#[derive(Debug, Default)]
pub struct CandidateMachine {
    start_pos: usize,
    last_variant_end_pos: Option<usize>,
    utility_machine: UtilityMachine,
    variant_machine: VariantMachine,
}

각 필드의 역할은 하위 모듈의 docstring에서 확인할 수 있다. named_utility_machine.rs에는 UtilityMachine이 처리하는 대상이 명시되어 있고, named_variant_machine.rs에는 VariantMachine이 처리하는 대상이 명시되어 있다.

// named_utility_machine.rs
/// Extracts named utilities from an input.
///
/// E.g.:
///   flex
///   bg-red-500
 
// named_variant_machine.rs
/// Extract named variants from an input including the `:`.
///
/// E.g.:
///   hover:flex
///   data-[state=pending]:flex

.tsx.jsx.html 파일 텍스트를 한 글자씩 순회하면서 공백, <>"' 같은 구분자를 만나면 그 앞까지를 클래스 후보로 추출한다. variant_machine과 utility_machine이 동시에 실행되어 유효한 variant + utility 조합인지 확인한다.

v3 이전에는 가능한 모든 클래스 조합을 미리 만들어두고 나중에 사용하지 않는 것을 제거(purge)했다. v3부터 JIT(Just-In-Time) 방식으로 바뀌어 소스에서 발견한 클래스만 즉시 생성한다. bg-[#ff6b00]처럼 대괄호 안의 임의값도 이 시점에 처리된다.

Step 5. Parsing

Parsing은 Scan으로 수집한 문자열("hover:bg-blue-500/50!" 같은 날것의 텍스트)을 구조화된 Candidate 객체로 분해하는 단계다. candidate.ts의 parseCandidate()가 이 역할을 담당한다.

Candidate 타입은 같은 파일에 정의되어 있으며, 세 가지 kind로 나뉜다:

// candidate.ts
export type Candidate =
  // "underline", "box-border" 같은 고정 유틸리티
  | { kind: 'static'; root: string; variants: Variant[]; important: boolean; raw: string }
  // "bg-red-500", "bg-[#0088cc]" 같은 값을 받는 유틸리티
  | { kind: 'functional'; root: string; value: ArbitraryUtilityValue | NamedUtilityValue | null;
      modifier: ArbitraryModifier | NamedModifier | null; variants: Variant[]; important: boolean; raw: string }
  // "[color:red]" 같은 임의 속성+값 유틸리티
  | { kind: 'arbitrary';  property: string; value: string;
      modifier: ArbitraryModifier | NamedModifier | null; variants: Variant[]; important: boolean; raw: string }

hover:bg-blue-500/50!를 예로 들면, parseCandidate()가 이 문자열을 어떻게 분해하는지 함수 코드를 따라가며 확인할 수 있다. 아래는 실제 함수에서 핵심 분기만 추출하고, 각 단계에서 입력이 어떻게 변하는지를 주석으로 표시한 것이다:

// candidate.ts의 parseCandidate() 핵심 흐름
// 입력: "hover:bg-blue-500/50!"
 
// ① variant 분리: ':' 기준으로 분할
let rawVariants = segment(input, ':')
// → ['hover', 'bg-blue-500/50!']
 
let base = rawVariants.pop()!
// → base = 'bg-blue-500/50!'
// → rawVariants = ['hover']
 
// 남은 rawVariants를 역순으로 파싱하여 Variant 객체로 변환
let parsedCandidateVariants: Variant[] = []
for (let i = rawVariants.length - 1; i >= 0; --i) {
  let parsedVariant = designSystem.parseVariant(rawVariants[i])
  parsedCandidateVariants.push(parsedVariant)
}
// → [{ kind: 'static', root: 'hover' }]
 
// ② important 감지: base 끝이 '!'이면 제거하고 플래그 설정
let important = false
if (base[base.length - 1] === '!') {
  important = true
  base = base.slice(0, -1)
}
// → important = true, base = 'bg-blue-500/50'
 
// ③ modifier 분리: '/' 기준으로 분할
let [baseWithoutModifier, modifierSegment = null] = segment(base, '/')
// → baseWithoutModifier = 'bg-blue-500', modifierSegment = '50'
 
let parsedModifier = modifierSegment === null ? null : parseModifier(modifierSegment)
// → { kind: 'named', value: '50' }
 
// ④ root/value 분리: findRoots()가 뒤에서부터 '-'를 잘라가며 탐색
//    'bg-blue-500' → has('bg-blue-500')? No
//    'bg-blue'     → has('bg-blue')?     No
//    'bg'          → has('bg')?          Yes ✓ → root = 'bg', value = 'blue-500'
let roots = findRoots(baseWithoutModifier, (root) => {
  return designSystem.utilities.has(root, 'functional')
})
 
// ⑤ value 종류 판별: '[' 로 끝나면 arbitrary, 아니면 named
for (let [root, value] of roots) {
  let candidate: Candidate = {
    kind: 'functional', root, modifier: parsedModifier,
    value: null, variants: parsedCandidateVariants, important, raw: input,
  }
 
  // value가 '[...]'이면 arbitrary, 아니면 named
  if (value[value.length - 1] === ']') {
    // arbitrary 처리 (예: bg-[#0088cc])
    candidate.value = { kind: 'arbitrary', dataType: null, value: decodeArbitraryValue(...) }
  } else {
    // named 처리 (예: blue-500)
    candidate.value = { kind: 'named', value, fraction: null }
  }
  // → { kind: 'named', value: 'blue-500', fraction: null }
 
  yield candidate
}

최종적으로 yield되는 Candidate 객체:

{
  kind: 'functional',
  root: 'bg',
  value: { kind: 'named', value: 'blue-500', fraction: null },
  modifier: { kind: 'named', value: '50' },
  variants: [{ kind: 'static', root: 'hover' }],
  important: true,
  raw: 'hover:bg-blue-500/50!',
}

variant → important → modifier → root/value → value 종류 판별 순으로, 바깥에서 안쪽으로 껍질을 벗기듯 파싱한다.

Step 6. CSS 생성

CSS 생성은 Parsing이 만든 Candidate 객체를 실제 CSS 선언(AstNode[])으로 변환하는 단계다. 이 과정은 두 파일에 걸쳐 일어난다:

  • utilities.ts: 유틸리티 등록 (어떤 클래스가 어떤 CSS를 생성하는지 정의)
  • compile.ts: 컴파일 실행 (Candidate를 받아 등록된 유틸리티를 매칭하고 CSS를 생성)

1. 유틸리티 등록 (utilities.ts)

utilities.ts에는 Tailwind의 모든 유틸리티 클래스가 등록되어 있다. 각 유틸리티는 compileFn을 가지는데, 이 함수가 Candidate를 받아 AstNode[](CSS 선언)를 반환한다.

// utilities.ts
type CompileFn<T extends Candidate['kind']> = (
  value: Extract<Candidate, { kind: T }>,
) => AstNode[] | undefined | null
 
export type Utility = {
  kind: 'static' | 'functional'
  compileFn: CompileFn<any>
  options?: UtilityOptions
}

이 Utility 객체를 직접 생성하지 않고, 헬퍼 함수가 compileFn을 내부적으로 구성해서 등록한다.

// static: 클래스 이름이 고정된 CSS에 1:1 대응
// "flex" → display: flex
staticUtility('flex', [['display', 'flex']])
 
// functional: 뒤에 붙는 값에 따라 다른 CSS를 생성
// "z-10" → z-index: 10, "z-auto" → z-index: auto
functionalUtility('z', {
  supportsNegative: true,
  handleBareValue: ({ value }) => {
    if (!isPositiveInteger(value)) return null
    return value
  },
  themeKeys: ['--z-index'],
  handle: (value) => [decl('z-index', value)],
  staticValues: {
    auto: [decl('z-index', 'auto')],
  },
})
 
// colorUtility: 색상 전용 헬퍼 (내부적으로 utilities.functional()을 호출)
// "accent-blue-500" → accent-color: ...
colorUtility('accent', {
  themeKeys: ['--accent-color', '--color'],
  handle: (value) => [decl('accent-color', value)],
})

2. 컴파일 실행 (compile.ts)

compile.ts의 compileCandidates()가 전체 흐름을 조율한다. 아래는 핵심 로직만 추출한 것이다.

// compile.ts의 compileCandidates() 핵심 흐름
// ① 원본 문자열 → Candidate 파싱
for (let rawCandidate of rawCandidates) {
  let candidates = designSystem.parseCandidate(rawCandidate)
  // → "hover:bg-blue-500/50!" → [{ kind: 'functional', root: 'bg', ... }]
  matches.set(rawCandidate, candidates)
}
 
// ② Candidate → CSS AST 생성
for (let [rawCandidate, candidates] of matches) {
  for (let candidate of candidates) {
    let rules = designSystem.compileAstNodes(candidate, flags)
    // → compileBaseUtility()에서 'bg'로 등록된 유틸리티를 찾아 compileFn 호출
    // → utilities.functional('bg', ...)의 compileFn → decl('background-color', 'rgb(59 130 246)')
    astNodes.push(rules)
  }
}
 
// ③ variant 적용 + important 처리 + 정렬
// compileAstNodes() 내부에서:
//   - candidate.important이면 모든 선언에 !important 추가
//   - candidate.variants를 순회하며 applyVariant()로 셀렉터를 감쌈
//     예: hover variant → .hover\:bg-blue-500\/50\!:hover { ... }
//   - 최종 AST 노드를 variant 순서 → 속성 순서 → 알파벳 순으로 정렬

bg-blue-500의 전체 흐름을 따라가면.

"bg-blue-500"
  ↓ Rust 스캔 → 클래스 후보 추출
  ↓ parseCandidate() → { kind: 'functional', root: 'bg', value: { kind: 'named', value: 'blue-500' } }
  ↓ compileBaseUtility() → designSystem.utilities.get('bg') → utilities.functional('bg', ...)의 compileFn
  ↓ compileFn 내부:
    ↓ resolveThemeColor()로 테마에서 --color-blue-500 조회 → 색상값 획득
    ↓ asColor() → modifier(투명도)가 있으면 적용
    ↓ [decl('background-color', 'rgb(59 130 246)')] 반환
  ↓ compileAstNodes() → .bg-blue-500 { background-color: rgb(59 130 246) }

Step 7. utilitiesNode에 결과 주입

Scan → Parsing → CSS 생성이 모두 끝나면 newNodes(생성된 CSS AST 노드 배열)가 만들어진다. 이 newNodes를 Step 3에서 기록해둔 utilitiesNode(AST에서 @tailwind utilities가 있던 위치)에 넣는다.

// index.ts의 build() 내부
utilitiesNode.nodes = newNodes   // 생성된 유틸리티 CSS를 utilitiesNode 자리에 넣음

최종적으로 @tailwind utilities가 있던 자리에 이런 CSS가 들어간다.

.flex { display: flex }
.bg-blue-500 { background-color: rgb(59 130 246) }
.text-white { color: rgb(255 255 255) }
.px-4 { padding-left: 1rem; padding-right: 1rem }
.hover\:bg-blue-600:hover { background-color: rgb(37 99 235) }
/* ... 소스에서 발견된 클래스만 생성됨 */

이것이 @import "tailwindcss" 한 줄이 최종 CSS가 되기까지의 전체 과정이다. Step 1-3에서 CSS 쪽을 준비하고(@import 해석 → index.css 펼침 → utilitiesNode위치 기록), Step 4-6에서 소스코드 쪽을 처리하고(Scan → Parsing → CSS 생성), Step 7에서 둘을 합친다. 사용자가 bg-blue-500이라고 쓰면 그 뒤에서 이 7단계가 자동으로 일어나고, 최종 결과물은 브라우저가 바로 읽을 수 있는 정적 CSS 파일이 된다.

2. 추상화의 가치

솔직히 Step 1부터 7까지 따라오면서 놀랐다. 모듈 리졸루션, AST 재귀 치환, CSS Cascade Layers, Rust 스캔 엔진, 껍질을 벗기듯 파싱하는 parseCandidate, 유틸리티 매칭과 CSS 생성까지..

이걸 하나하나 뜯어보기 전에는 "그냥 클래스 쓰면 CSS 나오는 거 아닌가?"라고 생각했는데, 실제로는 이렇게까지 정교한 파이프라인이 @import "tailwindcss" 한 줄 뒤에서 돌아가고 있었다. 그리고 그 복잡성을 전혀 느끼지 못하게 만든 추상화가 정말 대단하다고 느꼈다.

하지만 이 모든 복잡성은 사용자에게 완전히 감춰져 있다.

/* 사용자가 하는 일의 전부 */
@import "tailwindcss";
<!-- 그리고 클래스를 쓰면 된다 -->
<button class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">

이 추상화의 질이 AI 시대에 빛을 발한다.

복잡성은 숨기고, 의도는 드러낸다

<!-- CSS 클래스: 별도 파일을 열어봐야 스타일을 알 수 있음 -->
<button class="btn-primary">
 
<!-- Tailwind: 클래스 이름만 봐도 스타일이 전부 보임 -->
<button class="bg-blue-500 text-white px-4 py-2 rounded">

Tailwind 클래스는 길고 장황하다. 사람이 보면 피로하지만, AI 입장에서는 오히려 명시적이라서 좋다. bg-blue-500이라고 쓰면 그 뒤에서 테마 조회, 색상 변환, CSS 선언 생성이 자동으로 일어나지만, AI는 그걸 알 필요가 없다. 클래스 이름 자체가 의도를 담고 있기 때문이다.

사용자: "버튼 배경을 더 진한 파란색으로"
→ bg-blue-500 → bg-blue-700  (500, 700은 명도 스케일)
 
사용자: "모서리 더 둥글게"
→ rounded → rounded-xl

CSS 변수나 theme 설정을 거치는 방식보다 프롬프트 → 코드 수정의 거리가 훨씬 짧다. 이건 Tailwind가 내부의 복잡한 파이프라인을 감추고, 클래스 이름이라는 단순한 인터페이스만 노출했기 때문에 가능한 것이다.

AI가 Tailwind를 잘 다루는 이유

기존에는 사용성과 일관성이 중요했기 때문에 MUI, Ant Design 같은 완성형 프레임워크가 선두였다. 개발자가 직접 스타일을 작성하는 비용을 줄여주는 게 핵심 가치였다. 하지만 AI가 스타일을 대신 작성해주는 시대가 되면서 기준이 바뀌었다. 이제는 "개발자가 빠르게 조립할 수 있는가"보다 "최소한의 컨텍스트로 AI가 빠르게 의도를 파악하고 수정할 수 있는가"가 더 중요해졌다.

MUI 같은 프레임워크는 sx prop, theme override, styled() 같은 추상화 레이어가 많아서 AI가 맥락을 추적하기 어렵다. 반면 Tailwind는 클래스 이름 자체가 의도를 담고 있고, 바닐라 CSS는 코드에 스타일이 그대로 드러난다. State of CSS 조사에서 "프레임워크 없음(None)"의 비중이 눈에 띄게 늘어난 것도 이 흐름과 무관하지 않다. AI가 네이티브 CSS를 직접 잘 작성해주니까, 프레임워크로 추상화할 필요성 자체가 줄어든 것이다.

Tailwind가 여전히 사용률 1위를 유지하는 건 (2024: 75%, 2025: 51%) 앞에서 살펴본 것처럼 내부의 복잡성을 감추고 클래스 이름이라는 단순한 인터페이스만 노출하기 때문이다. AI 입장에서도 bg-blue-500이 무엇인지 파악하기 위해 별도 파일을 열거나 빌드 파이프라인을 추적할 필요가 없다. 추상화가 잘 되어 있으니까 사람도 AI도 쓰기 쉽고, 그래서 많이 쓰이고, 많이 쓰이니까 AI가 더 잘 학습하는 선순환이 만들어진다.

마치며

Tailwind의 내부 파이프라인은 결코 단순하지 않다.

@import "tailwindcss"

  │  Step 1. @import 해석과 치환
  │  substituteAtImports()가 @import를 발견하면
  │  loadStylesheet 콜백으로 경로 해석 + 파일 로드
  │  → enhanced-resolve가 package.json의 "style" 필드를 보고
  │  → "tailwindcss" → index.css로 해석
  │  → 파싱된 AST로 @import 노드를 교체

  Step 2. index.css 내용 펼침
  │  @layer theme, base, components, utilities
  │  @import './theme.css'      → CSS 변수 (디자인 토큰)
  │  @import './preflight.css'  → 브라우저 기본 스타일 리셋
  │  @import './utilities.css'  → @tailwind utilities 한 줄

  Step 3. @tailwind utilities → utilitiesNode로 기록 (위치 마커)

  Step 4. Scan     → Rust 엔진이 소스 파일에서 클래스 후보 추출
  Step 5. Parsing  → parseCandidate()가 구조화된 Candidate 객체로 분해
  Step 6. CSS 생성 → compileCandidates()가 Candidate를 CSS AST로 변환

  Step 7. 생성된 CSS를 utilitiesNode 위치에 주입

  .bg-blue-500 { background-color: rgb(59 130 246) }
  .flex { display: flex }
  .hover\:bg-blue-600:hover { background-color: rgb(37 99 235) }

하지만 사용자는 이 중 어느 것도 알 필요가 없다. @import "tailwindcss" 한 줄이면 시작이고, bg-blue-500 같은 클래스를 쓰면 끝이다. 이 추상화의 질이 사람에게도, AI에게도 Tailwind를 다루기 쉽게 만든다.

좋은 추상화는 복잡성을 없애는 게 아니라, 올바른 곳에 숨기는 것이다.

이건 Tailwind만의 이야기가 아니다. AI 시대에 우리가 만드는 소프트웨어도 같은 구조를 가진다. 사용자는 간편하게 사용할 수 있어야 하지만, 우리는 그 간편함 뒤에 숨은 복잡한 동작을 잘 만들어야 한다. Tailwind 팀이 @import "tailwindcss" 한 줄 뒤에 Rust 엔진, AST 조작, 모듈 리졸루션을 숨겼듯이, 우리도 사용자에게 보이는 인터페이스는 단순하게, 그 뒤의 구현은 견고하게 만들어야 한다.

레퍼런스