- Published on
현대 CSS 전략의 두 가지 관점 (with Tailwindcss, Emotion)
- Authors
- Name
- 이민기
- Github
- @lapidix
1. Introduction
프론트엔드 개발자라면 매일 마주하는 CSS, 개발에서 TailwindCSS와 Emotion 같은 라이브러리를 사용하면서 문득 이런 생각이 들었습니다.
className="bg-blue-500"
이나css={{ color: 'red' }}
를 작성하면서도, 이 코드들이 어떻게 실제 CSS로 변환되는지 생각해본 적이 있었나?
저는 자연스럽게 CSS 라이브러리들의 내부 동작 원리에 대한 궁금증이 생겼습니다.
현대 웹 개발에서 CSS 솔루션은 크게 두 가지 전략으로 나뉘어진다고 생각합니다. TailwindCSS처럼 빌드타임에 모든 스타일을 미리 생성하는 정적 방식과, Emotion처럼 런타임에 동적으로 스타일을 생성하는 동적 방식입니다. 이 두 접근법은 단순히 "어떻게 쓰는가"의 차이를 넘어, 근본적으로 다른 철학을 가지고 있습니다.
대부분의 개발자는 사용법만 알아도 충분히 개발할 수 있습니다. 그러나 성능 이슈, SSR 문제, 번들 사이즈 최적화 같은 상황에서는 각 라이브러리의 내부 동작 원리를 이해해야만 근본적인 해결책을 찾을 수 있다고 생각합니다.
이 글에서는 TailwindCSS와 Emotion의 소스코드를 통해, CSS가 빌드타임과 런타임에서 어떻게 처리되는 과정을 살펴보고, 실제 프로젝트에서 어떤 선택을 해야 할지에 대한 트레이드오프를 찾아갑니다
해당 포스트는 Tailwindcss는 v4.1.10
, Emotion은 @emotion/react@11.14.0
을 기준으로 작성되었습니다.
2. Tailwind CSS
TailwindCSS는 유틸리티 퍼스트(Utility-First) CSS 프레임워크로, 미리 정의된 클래스들을 조합해 스타일을 구성합니다. 전통적인 CSS와 달리 HTML에서 직접 스타일을 표현하는 방식을 사용합니다.
<!-- 전통적인 CSS -->
<button class="btn btn-primary">Click me</button>
<!-- TailwindCSS -->
<button class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600">Click me</button>
기술 스택과 아키텍처
TailwindCSS v4는 기존 버전의 성능 한계를 극복하고 더욱 강력한 기능을 제공하기 위해 하이브리드 아키텍처를 채택했습니다. 성능이 중요한 핵심 로직은 Rust로 최적화된 Oxide 엔진으로 구현하고, 확장성과 유연성이 필요한 부분은 TypeScript로 유지하는 전략을 통해 성능과 개발 편의성을 동시에 확보했습니다.
이러한 하이브리드 구조를 통해 TailwindCSS는 성능과 유연성을 모두 확보할 수 있습니다. 특히 Rust로 구현된 Oxide 엔진은 다음과 같은 핵심 컴포넌트들로 이루어져 있습니다.
- Oxide 엔진 (
crates/oxide
): Rust로 구현된 고성능 클래스명 추출 엔진 - Scanner (
crates/oxide/src/scanner
): 파일 시스템 탐색과 콘텐츠 스캔 - Extractor (
crates/oxide/src/extractor
): 상태 머신 기반 토큰 파싱 - Node.js 바인딩 (
crates/node
): JavaScript 환경에서 Rust 엔진 사용 - Ignore 시스템 (
crates/ignore
): Git ignore 규칙과 파일 필터링

2.1 기존 CSS 솔루션과의 비교
동일한 버튼 컴포넌트를 module.css
, SCSS
, tailwindcss
세 가지 방식으로 구현해보면서 각각의 특징과 차이점을 간단하게 살펴보겠습니다.
CSS Modules 방식
CSS Modules는 전통적인 CSS 문법을 사용하면서도, 각 컴포넌트의 스타일이 전역 스코프에 영향을 주지 않도록 로컬 스코프를 자동으로 생성해줍니다. 아래 코드는 .module.css
파일과 이를 React 컴포넌트에서 사용하는 예시코드입니다.
/* Button.module.css */
.button {
background-color: #3b82f6;
padding: 0.5rem 1rem;
border: none;
}
.primary {
background-color: #dc2626;
}
import styles from './Button.module.css'
function Button({ variant, children }) {
return (
<button className={`${styles.button} ${variant === 'primary' ? styles.primary : ''}`}>
{children}
</button>
)
}
SCSS 방식
SCSS는 CSS에 변수, 믹스인, 중첩 등 프로그래밍 언어의 기능을 도입하여 스타일을 더 체계적이고 재사용 가능하게 만듭니다. 아래는 SCSS의 믹스인(@mixin
)을 활용하여 버튼 스타일을 정의하고 React 컴포넌트에서 사용하는 예시 코드입니다.
// Button.scss
$primary-color: #3b82f6;
$danger-color: #dc2626;
@mixin button-variant($bg-color) {
background-color: $bg-color;
padding: 0.5rem 1rem;
border: none;
}
.button {
@include button-variant($primary-color);
}
.button--primary {
@include button-variant($danger-color);
}
function Button({ variant, children }) {
return (
<button className={`button ${variant === 'primary' ? 'button--primary' : ''}`}>
{children}
</button>
)
}
TailwindCSS 방식
TailwindCSS는 CSS 파일을 직접 작성하는 대신, px-4
, bg-blue-500
과 같은 미리 정의된 유틸리티 클래스를 HTML 혹은 JSX에 직접 적용하여 스타일을 구성합니다
function Button({ variant, children }) {
const baseClasses = 'px-4 py-2 border-none'
const variantClasses =
variant === 'primary' ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-500 hover:bg-blue-600'
return <button className={`${baseClasses} ${variantClasses}`}>{children}</button>
}
세 가지 접근법의 핵심 차이점
CSS Modules는 전통적인 CSS 작성 방식을 유지하면서 클래스명 충돌만 해결합니다. .module.css
파일을 별도로 관리하고, 빌드 시점에 클래스명을 해시화해서 스코프를 격리합니다.
SCSS는 CSS에 프로그래밍 기능을 더해서 변수, 믹스인, 중첩 규칙으로 체계적인 스타일 시스템을 구축합니다. 하지만 전체 스타일시트를 컴파일해야 하고, BEM 같은 네이밍 컨벤션에 의존해야 합니다.
TailwindCSS는 CSS 파일을 아예 작성하지 않고 px-4
, bg-blue-500
같은 유틸리티 클래스를 HTML에서 직접 조합합니다. JIT 방식으로 사용된 클래스만 생성해서 효율적이지만, 유틸리티 클래스를 암기해야 하는 학습 비용이 있습니다.
위에서 살펴본 세 가지 접근법 중에서 TailwindCSS는 특히 독특한 방식으로 동작합니다. 일반적인 CSS 전처리기와 달리, HTML이나 JSX에 직접 작성된 클래스명을 분석하고 필요한 CSS만 생성하는 방식을 취합니다. 그렇다면 TailwindCSS는 어떻게
hover:bg-blue-500/50!
같은 복잡한 클래스명을 이해하고 올바른 CSS로 변환할 수 있는지, 이 과정을 단계별로 살펴보겠습니다.
2.2 Oxide Engine
TailwindCSS의 핵심인 Oxide 엔진은 Rust로 구현된 파일 스캐너입니다. 이 엔진이 어떻게 프로젝트 전체에서 클래스를 추출하는지 간단하게 살펴보겠습니다.
Scanner
Oxide 엔진의 시작점인 Scanner 구조체입니다. 이 스캐너는 프로젝트의 소스 파일들을 탐색하고, 각 파일의 내용을 읽어들여 클래스 후보들을 추출하는 역할을 합니다.
// crates/oxide/src/scanner/mod.rs
#[derive(Debug, Clone, Default)]
pub struct Scanner {
/// 콘텐츠 소스
sources: Sources,
/// 스캔할 파일들을 탐지하는 워커
walker: Option<WalkBuilder>,
/// 변경된 콘텐츠들
changed_content: Vec<ChangedContent>,
/// 발견된 모든 확장자들
extensions: FxHashSet<String>,
/// 추적할 고유한 후보 집합
candidates: FxHashSet<String>,
// ... 기타 필드들
}
impl Scanner {
pub fn scan(&mut self) -> Vec<String> {
// 소스 스캔
self.scan_sources();
// 후보 추출
let _new_candidates = self.extract_candidates();
// 정렬된 후보 목록 반환
let mut candidates = self.candidates.iter().cloned().collect::<Vec<_>>();
candidates.par_sort_unstable();
candidates
}
}
Oxide 엔진은 상태 머신 기반 파서를 통해 HTML/JSX 파일에서 hover:bg-blue-500/50!
, flex
와 같은 클래스명을 빠르게 식별하고 추출합니다. Rust로 구현된 이 고성능 추출 엔진이 클래스명을 수집하면, TypeScript의 parseCandidate
함수가 이를 받아 의미 분석 후 최종적으로 최적화된 CSS로 변환합니다.
**상태 머신 기반 파서(State Machine Based Parser)**는 유한 상태 기계(FSM)의 원리를 활용한 텍스트 분석 방식입니다. 각 상태에서 입력에 따라 다음 상태로 전환되는 규칙을 정의하고, 현재는 항상 하나의 상태만 가집니다.
상태 머신 기반 파서(State Machine Based Parser) 는 유한 상태 기계(FSM)의 원리를 활용한 텍스트 분석 방식입니다. 각 상태에서 입력에 따라 다음 상태로 전환되는 규칙을 정의하고, 현재는 항상 하나의 상태만 가집니다. Oxide 엔진은 CandidateMachine
, UtilityMachine
등 특화된 상태 머신들을 조합해 클래스명을 추출하며, 이 선형 스캔 방식과 병렬 처리가 결합되어 뛰어난 성능을 구현합니다.
2.3 parseCandidate()
Oxide 엔진이 프로젝트에서 클래스 후보들을 성공적으로 추출했다면, 이제 TypeScript 레이어에서 이 클래스명들을 파싱하여 구조화된 객체로 변환하는 과정이 필요합니다. hover:bg-blue-500/50!
처럼 복잡한 TailwindCSS 클래스명은 여러 의미를 내포하고 있기 때문에, 이를 체계적으로 분리하는 것이 중요합니다.
`hover:bg-blue-500/50!`
- `hover:` → 마우스 호버 시에만 적용
- `bg-blue-500` → 파란색 배경색
- `/50` → 50% 투명도
- `!` → !important
TailwindCSS는 이런 복잡한 문자열을 체계적으로 분해하여 각각의 의미를 파악합니다. 이 과정을 담당하는 것이 parseCandidate()
함수입니다.
// tailwindcss/packages/tailwindcss/src/candidate.ts
export function* parseCandidate(input: string, designSystem: DesignSystem): Iterable<Candidate> {
// hover:focus:bg-blue-500/50! 같은 복잡한 클래스명을 파싱
let rawVariants = segment(input, ':')
// 마지막 부분이 실제 유틸리티 (bg-blue-500/50!)
let base = rawVariants.pop()!
// 변형들을 역순으로 파싱 (hover, focus)
let parsedCandidateVariants: Variant[] = []
for (let i = rawVariants.length - 1; i >= 0; --i) {
let parsedVariant = designSystem.parseVariant(rawVariants[i])
if (parsedVariant === null) return
parsedCandidateVariants.push(parsedVariant)
}
// 중요도 표시 확인 (!)
let important = false
if (base[base.length - 1] === '!') {
important = true
base = base.slice(0, -1)
}
// 수정자 분리 (/50 부분)
let [baseWithoutModifier, modifierSegment = null] = segment(base, '/')
let parsedModifier = modifierSegment === null ? null : parseModifier(modifierSegment)
// 유틸리티 타입 결정
if (designSystem.utilities.has(baseWithoutModifier, 'static')) {
yield {
kind: 'static',
root: baseWithoutModifier,
variants: parsedCandidateVariants,
important,
raw: input,
}
}
// 함수형 유틸리티 처리 (bg-blue-500)
let roots = findRoots(baseWithoutModifier, (root: string) => {
return designSystem.utilities.has(root, 'functional')
})
for (let [root, value] of roots) {
yield {
kind: 'functional',
root, // 'bg'
value: { kind: 'named', value }, // 'blue-500'
modifier: parsedModifier, // '50'
variants: parsedCandidateVariants,
important,
raw: input,
}
}
}
hover:focus:bg-blue-500/50!
클래스가 어떻게 파싱되는지 단계별로 보면 다음과 같습니다.
// 1단계: 콜론으로 분리
// input: "hover:focus:bg-blue-500/50!"
// rawVariants: ["hover", "focus", "bg-blue-500/50!"]
let rawVariants = segment(input, ':')
let base = rawVariants.pop()! // "bg-blue-500/50!"
// 2단계: 변형(variants) 파싱
parsedCandidateVariants: [
{ kind: 'static', root: 'hover' },
{ kind: 'static', root: 'focus' },
]
// 3단계: 중요도 확인
base: 'bg-blue-500/50!' // → "bg-blue-500/50"
important: true
// 4단계: 수정자 분리
baseWithoutModifier: 'bg-blue-500'
modifierSegment: '50'
// 5단계: 루트와 값 찾기
findRoots('bg-blue-500') // → [["bg", "blue-500"]]
최종 파싱 결과
최종적으로 생성되는 Candidate
객체는 다음과 같습니다.
const candidate: Candidate = {
kind: 'functional',
root: 'bg',
value: { kind: 'named', value: 'blue-500' },
modifier: { kind: 'named', value: '50' },
variants: [
{ kind: 'static', root: 'hover' },
{ kind: 'static', root: 'focus' },
],
important: true,
raw: 'hover:focus:bg-blue-500/50!',
}
이렇게 파싱된 클래스 정보는 다음 단계인 compileCandidates()
로 전달되어 실제 CSS로 변환됩니다.
2.4 compileCandidates()
앞서 parseCandidate()
를 통해 구조화된 Candidate
객체들을 얻었다면, 이제 이를 실제 CSS로 변환해야 합니다. 이 과정을 담당하는 것이 compileCandidates()
함수입니다.
compileCandidates()
함수는 추출된 클래스 후보들을 CSS AST 노드로 변환하는 핵심 빌드 로직입니다. 이 함수는 파싱된 Candidate 객체들을 순회하며, 각 클래스에 해당하는 CSS 규칙을 생성하고, 최종적으로 이 AST 노드들을 정렬하여 효율적인 CSS를 만듭니다.
// tailwindcss/packages/tailwindcss/src/compile.ts
export function compileCandidates(
rawCandidates: Iterable<string>,
designSystem: DesignSystem,
{ onInvalidCandidate }: { onInvalidCandidate?: (candidate: string) => void } = {}
) {
let astNodes: AstNode[] = []
let matches = new Map<string, Candidate[]>()
// 1단계: 클래스 파싱 및 검증
for (let rawCandidate of rawCandidates) {
// 이미 무효한 클래스는 건너뛰기
if (designSystem.invalidCandidates.has(rawCandidate)) {
onInvalidCandidate?.(rawCandidate)
continue
}
// parseCandidate()를 통해 구조화된 객체 얻기
let candidates = designSystem.parseCandidate(rawCandidate)
if (candidates.length === 0) {
onInvalidCandidate?.(rawCandidate)
continue
}
matches.set(rawCandidate, candidates)
}
// 2단계: CSS AST 노드 생성
for (let [rawCandidate, candidates] of matches) {
for (let candidate of candidates) {
// 각 클래스를 실제 CSS 규칙으로 변환
let rules = designSystem.compileAstNodes(candidate)
if (rules.length === 0) continue
for (let { node, propertySort } of rules) {
astNodes.push(node)
}
}
}
// 3단계: CSS 규칙 정렬
astNodes.sort(/* 복잡한 정렬 로직 */)
return { astNodes }
}
hover:bg-blue-500/50!
클래스가 compileCandidates()
를 통해 어떻게 CSS로 변환되는지 단계별로 살펴보면 다음과 같습니다.
// 입력: 파싱된 Candidate 객체
{
kind: 'functional',
root: 'bg',
value: { kind: 'named', value: 'blue-500' },
modifier: { kind: 'named', value: '50' },
variants: [{ kind: 'static', root: 'hover' }],
important: true,
raw: 'hover:bg-blue-500/50'
}
// 출력: CSS AST 노드
let baseRule = {
kind: 'rule',
selector: '.hover\\:bg-blue-500\\/50',
nodes: [
{
kind: 'declaration',
property: 'background-color',
value: 'rgb(9 130 246 / 0.5)' // blue-500 + 50% 투명도
}
]
}
CSS AST (Abstract Syntax Tree)
TailwindCSS는 CSS를 문자열로 직접 생성하지 않고, AST(추상 구문 트리) 형태로 관리합니다. 이를 통해 CSS 규칙을 구조화된 객체로 표현하여 조작과 최적화가 용이합니다.
// CSS AST의 기본 구조
type AstNode =
| { kind: 'rule'; selector: string; nodes: AstNode[] } // .class { ... }
| { kind: 'declaration'; property: string; value: string } // color: blue;
| { kind: 'at-rule'; name: string; nodes: AstNode[] } // @media { ... }
2.5 빌드 타임 최적화
지금까지 살펴본 TailwindCSS의 빌드 과정을 전체 처리 플로우로 요약하면 다음과 같습니다. 각 단계가 어떻게 유기적으로 연결되어 최종 CSS를 생성하는지 한눈에 파악할 수 있습니다.
1. Oxide 엔진(Rust): 파일 스캔 → 클래스 추출 → 후보 목록 생성
2. TypeScript: parseCandidate() → compileCandidates() → CSS 생성
3. Lightning CSS: 최적화 → 압축 → 최종 CSS 출력
실제로 사용된 클래스만 선별적으로 생성하여 최소화된 CSS 번들을 생성합니다.
이러한 빌드 과정의 성능을 극대화하기 위해 TailwindCSS는 여러 최적화 기법을 활용합니다. 그중 핵심은 중복 작업을 최소화하는 것이며, 이를 위해 다층적인 캐싱 전략을 사용합니다. 이 캐싱 전략은 빠른 빌드 속도와 즉각적인 피드백을 가능하게 하는 핵심 요소입니다.
다층 캐싱 전략
주요 캐싱 전략은 다음과 같습니다.
1. 무효한 클래스 캐싱 (Negative Caching)
한 번 무효하다고 판단된 클래스는 invalidCandidates
Set에 저장되어 다시 검사하지 않습니다.
// 예: 'bg-invalid-color' 같은 존재하지 않는 클래스
if (designSystem.invalidCandidates.has(candidate)) {
onInvalidCandidate?.(candidate) // 콜백만 호출하고 건너뛰기
continue
}
// 파싱 실패 시 무효 목록에 추가
let candidates = designSystem.parseCandidate(rawCandidate)
if (candidates.length === 0) {
designSystem.invalidCandidates.add(rawCandidate) // 다음번엔 건너뛰기
continue
}
2. 유효한 클래스 중복 제거
Set
자료구조를 사용해서 동일한 클래스가 여러 번 처리되는 것을 방지합니다.
let allValidCandidates = new Set<string>()
// 중복 클래스 자동 제거
allValidCandidates.add('bg-blue-500') // 첫 번째: 추가됨
allValidCandidates.add('bg-blue-500') // 두 번째: 무시됨
allValidCandidates.add('text-white') // 새로운 클래스: 추가됨
// Set 크기 변화로 실제 변경사항 감지
didChange ||= allValidCandidates.size !== prevSize
3. 컴파일 결과 캐싱 (Memoization)
가장 비용이 큰 CSS 생성과 최적화 과정의 결과를 캐싱합니다.
let compiled = null as AstNode[] | null
// 변경사항이 없으면 캐시된 결과 반환
if (!didChange) {
compiled ??= optimizeAst(ast, designSystem, opts.polyfills)
return compiled
}
// 새로운 CSS 생성 후 캐시 업데이트
let newNodes = compileCandidates(allValidCandidates, designSystem)
compiled = optimizeAst(ast, designSystem, opts.polyfills)
4. 점진적 빌드 (Incremental Build)
전체를 다시 빌드하는 대신, 새로 추가된 클래스만 처리하는 점진적 접근법을 사용합니다.
초기 빌드: 모든 파일 스캔 → 모든 CSS 생성
증분 빌드: 변경된 파일만 스캔 → 새로운 클래스만 추가
이러한 다층 캐싱 덕분에 TailwindCSS는 수천 개의 클래스를 사용하는 대규모 프로젝트에서도 빠른 빌드 속도를 유지할 수 있습니다.
2.6 빌드타임 접근법의 장점
TailwindCSS의 빌드타임 처리 방식은 여러 핵심 장점을 제공합니다. 가장 중요한 것은 제로 런타임 오버헤드로, JavaScript 실행 없이 브라우저가 CSS를 즉시 파싱하여 스타일을 적용할 수 있습니다. 또한 사용된 클래스만 포함하여 극도로 작은 CSS 번들을 생성하며, 런타임 변수가 없어 예측 가능한 성능을 보장합니다.
특히 SSR(Server-Side Rendering) 환경에서 큰 장점을 발휘하는데, 서버에서 완전한 HTML과 CSS를 생성할 수 있어 초기 페이지 로딩 속도가 빠릅니다. 이러한 빌드타임 최적화가 TailwindCSS와 런타임 CSS-in-JS 라이브러리인 Emotion과의 핵심 차이점입니다.
3. Emotion
Emotion은 CSS-in-JS 라이브러리로, JavaScript 코드 안에서 CSS를 작성하고 런타임에 동적으로 스타일을 생성하는 방식입니다. TailwindCSS가 빌드타임에 모든 것을 미리 결정하는 정적 방식이라면, Emotion은 런타임에 JavaScript의 힘을 빌려 조건부 스타일링, 테마 변경, 동적 값 계산을 자유롭게 처리하는 동적 방식입니다.
// Emotion: 런타임에 동적으로 생성
import { css } from '@emotion/react'
const Button = ({ primary, disabled }) => {
const buttonStyle = css`
padding: 16px;
background-color: ${primary ? 'blue' : 'gray'};
opacity: ${disabled ? 0.5 : 1};
&:hover {
background-color: ${primary ? 'darkblue' : 'darkgray'};
}
`
return <button css={buttonStyle}>Dynamic Button</button>
}
기술 스택과 아키텍처
Emotion은 모듈러 아키텍처를 채택하여 필요한 기능만 선택적으로 사용할 수 있도록 설계되었습니다. 핵심 모듈들은 아래와 같습니다.
@emotion/react
: React 통합과 css prop 지원@emotion/styled
: styled-components와 유사한 API 제공@emotion/css
: 바닐라 JavaScript에서 사용할 수 있는 CSS 함수@emotion/cache
: 스타일 캐싱과 해시 관리@emotion/serialize
: 스타일 객체를 CSS 문자열로 직렬화@emotion/sheet
: 브라우저 StyleSheet API 추상화

Emotion의 핵심은 JavaScript 표현식을 CSS로 실시간 변환하는 시스템입니다. 이 과정을 단계별로 살펴보겠습니다.
3.1 serializeStyles()
Emotion을 사용할 때는 아래와 같이 다양한 방식으로 스타일을 작성할 수 있습니다.
import { css } from '@emotion/react'
// 1. 템플릿 리터럴 (동적 값 포함)
const buttonStyle = (primary) => css`
background: ${primary ? 'blue' : 'gray'};
padding: 16px;
`
// 2. 객체 스타일
const cardStyle = css({
backgroundColor: 'white',
padding: 20,
'&:hover': { boxShadow: '0 4px 8px rgba(0,0,0,0.1)' },
})
// 3. 배열 스타일 (조건부 결합)
const complexStyle = css([{ padding: 16 }, primary && { color: 'blue' }])
놀랍게도 이렇게 다양한 입력들이 serializeStyles()
함수 하나로 처리됩니다.
Emotion의 serializeStyles()
는 진입점 역할을 하며, 개발자가 작성한 모든 형태의 스타일을 입력받아 후속 처리를 위해 일관된 SerializedStyles
객체로 변환합니다.
// emotion/packages/serialize/src/index.ts
export function serializeStyles(
args: Array<TemplateStringsArray | Interpolation<unknown>>, // Interpolation: 문자열, 숫자, 스타일 객체, 함수 등 CSS에 삽입될 수 있는 모든 동적 값
registered?: RegisteredCache,
mergedProps?: unknown
): SerializedStyles {
// 이미 직렬화된 객체인지 확인
if (
args.length === 1 &&
typeof args[0] === 'object' &&
args[0] !== null &&
(args[0] as SerializedStyles).styles !== undefined
) {
return args[0] as SerializedStyles
}
let stringMode = true
let styles = ''
cursor = undefined
let strings = args[0]
// 템플릿 리터럴 vs 일반 객체/함수 구분
if (strings == null || (strings as TemplateStringsArray).raw === undefined) {
stringMode = false
styles += handleInterpolation(mergedProps, registered, strings as Interpolation)
} else {
// 템플릿 리터럴의 첫 번째 문자열 부분
const templateStringsArr = strings as TemplateStringsArray
styles += templateStringsArr[0]
}
// 나머지 인자들 순차 처리
for (let i = 1; i < args.length; i++) {
styles += handleInterpolation(mergedProps, registered, args[i] as Interpolation)
if (stringMode) {
const templateStringsArr = strings as TemplateStringsArray
styles += templateStringsArr[i]
}
}
// 라벨 추출 및 해시 생성
labelPattern.lastIndex = 0
let identifierName = ''
let match
while ((match = labelPattern.exec(styles)) !== null) {
identifierName += '-' + match[1]
}
let name = hashString(styles) + identifierName
return {
name,
styles,
next: cursor,
}
}
3.2 handleInterpolation()
serializeStyles()
가 전체 구조를 잡는다면, handleInterpolation()
은 실질적인 내용물을 채우는 역할을 합니다. 이 함수는 스타일 중간에 삽입된 switch 문을 통해 값의 타입에 따라 값을 재귀적으로 처리하는 역할을 합니다.
// emotion/packages/serialize/src/index.ts
function handleInterpolation(
mergedProps: unknown | undefined,
registered: RegisteredCache | undefined,
interpolation: Interpolation // 템플릿 리터럴 내 ${} 안에 들어가는 동적 값들
): string | number {
if (interpolation == null) {
return ''
}
switch (typeof interpolation) {
case 'boolean': {
return ''
}
case 'object': {
// 키프레임 애니메이션 처리
const keyframes = interpolation as Keyframes
if (keyframes.anim === 1) {
cursor = {
name: keyframes.name,
styles: keyframes.styles,
next: cursor,
}
return keyframes.name
}
// 이미 직렬화된 스타일 처리
const serializedStyles = interpolation as SerializedStyles
if (serializedStyles.styles !== undefined) {
let next = serializedStyles.next
while (next !== undefined) {
cursor = {
name: next.name,
styles: next.styles,
next: cursor,
}
next = next.next
}
return `${serializedStyles.styles};`
}
// 객체나 배열 스타일 처리
return createStringFromObject(
mergedProps,
registered,
interpolation as ArrayInterpolation | CSSObject
)
}
case 'function': {
// 동적 스타일 함수 실행
if (mergedProps !== undefined) {
let previousCursor = cursor
let result = interpolation(mergedProps)
cursor = previousCursor
return handleInterpolation(mergedProps, registered, result)
}
break
}
case 'string': {
// 등록된 스타일 확인 후 반환
if (registered == null) {
return interpolation
}
const cached = registered[interpolation]
return cached !== undefined ? cached : interpolation
}
}
return interpolation as string
}
3.3 createStringFromObject()
handleInterpolation()
이 스타일 객체를 만나면 이 createStringFromObject()
함수를 호출합니다. 이름에서 알 수 있듯이, 이 함수는 JavaScript 객체를 순회하며 실제 CSS인 key: value;
문자열로 변환합니다.
// emotion/packages/serialize/src/index.ts
function createStringFromObject(
mergedProps: unknown | undefined,
registered: RegisteredCache | undefined,
obj: ArrayInterpolation | CSSObject
): string {
let string = ''
if (Array.isArray(obj)) {
// 배열 처리: 각 요소를 재귀적으로 처리
for (let i = 0; i < obj.length; i++) {
string += `${handleInterpolation(mergedProps, registered, obj[i])};`
}
} else {
// 객체 처리: 각 속성을 CSS로 변환
for (let key in obj) {
let value: unknown = obj[key as never]
if (typeof value !== 'object') {
// 단순 CSS 속성
const asString = value as string
if (registered != null && registered[asString] !== undefined) {
string += `${key}{${registered[asString]}}`
} else if (isProcessableValue(asString)) {
string += `${processStyleName(key)}:${processStyleValue(key, asString)};`
}
} else {
// 중첩된 선택자나 미디어 쿼리
const interpolated = handleInterpolation(mergedProps, registered, value as Interpolation)
switch (key) {
case 'animation':
case 'animationName': {
string += `${processStyleName(key)}:${interpolated};`
break
}
default: {
string += `${key}{${interpolated}}`
}
}
}
}
}
return string
}
실제 처리 과정 예시
아래는 지금까지 살펴본 세 함수를 통해 어떻게 동작하는지에 대한 간단한 예시입니다.
// 입력
const complexStyle = css`
color: blue;
padding: 16px;
&:hover { opacity: 0.8; }
`;
// 1단계: 템플릿 리터럴 감지 및 문자열 조합
styles = 'color: blue; padding: 16px; &:hover { opacity: 0.8; }'
// 2단계: 해시 생성
hashString('color: blue; padding: 16px; &:hover { opacity: 0.8; }') // → '1a2b3c4'
// 최종 결과
{
name: '1a2b3c4',
styles: 'color: blue; padding: 16px; &:hover { opacity: 0.8; }',
next: undefined
}
이렇게 직렬화된 스타일 정보는 다음 단계에서 캐시되고 DOM에 주입됩니다.
3.4 StyleSheet 관리와 DOM 주입
앞서 생성된 CSS를 실제 브라우저 DOM에 주입하는 시스템을 살펴보겠습니다.
StyleSheet 클래스
지금까지의 과정이 CSS 문자열을 만드는 과정이었다면, StyleSheet 클래스는 그 문자열을 실제 DOM의 <style>
태그에 '주입하고 관리'하는 최종 단계의 실행자입니다.
// emotion/packages/sheet/src/index.ts
export class StyleSheet {
isSpeedy: boolean
ctr: number
tags: HTMLStyleElement[]
container: Node
key: string
nonce: string | undefined
constructor(options: Options) {
this.isSpeedy = options.speedy === undefined ? !isDevelopment : options.speedy
this.tags = []
this.ctr = 0
this.nonce = options.nonce
this.key = options.key
this.container = options.container
}
private _insertTag = (tag: HTMLStyleElement): void => {
let before
if (this.tags.length === 0) {
if (this.insertionPoint) {
before = this.insertionPoint.nextSibling
} else if (this.prepend) {
before = this.container.firstChild
} else {
before = this.before
}
} else {
before = this.tags[this.tags.length - 1].nextSibling
}
this.container.insertBefore(tag, before)
this.tags.push(tag)
}
}
실제 DOM 주입 과정
지금까지의 모든 과정을 요약과 함께 DOM 주입 과정을 살펴보면 다음과 같습니다.
- 스타일 직렬화: css 태그나 styled API로 작성된 스타일을 serializeStyles를 통해 해시값(name)과 CSS 문자열(styles)로 변환합니다.
- 캐시 조회: 생성된 해시값을 cache.inserted 객체에서 조회하여, 이미 DOM에 주입된 스타일인지 확인합니다.
- CSS 전처리: 새로운 스타일이라면 Stylis를 통해 벤더 프리픽스 추가, 중첩 선택자 변환 등 전처리 과정을 거칩니다.
- DOM 주입 및 캐싱: StyleSheet가 전처리된 CSS 규칙을
<style>
태그에 주입하고, 해당 스타일의 해시값을 cache.inserted에 기록하여 중복 삽입을 방지합니다. - 클래스명 반환: 최종적으로 css-해시값 형태의 고유한 클래스명을 반환하여 엘리먼트의 className으로 사용됩니다
3.5 런타임 오버헤드 최소화 및 최적화
다층적인 최적화 전략
Emotion은 단순히 스타일을 동적으로 생성하는 것을 넘어, 런타임 오버헤드를 최소화하기 위한 다층적인 최적화 전략을 사용합니다. 이 모든 최적화의 중심에는 효율적인 캐시 시스템이 있습니다.
1. 캐싱 시스템
Emotion의 성능 최적화에서 가장 중요한 핵심은 @emotion/cache
패키지의 createCache()
함수입니다. 이 함수는 캐싱과 스타일 주입 로직을 관리하는 EmotionCache
객체를 생성하고 초기화합니다.
단순히 캐시 객체를 만드는 것을 넘어, 서버에서 렌더링된(SSR) 스타일을 클라이언트에서 재사용(Hydration)하는 로직과, 앞으로 생성될 스타일을 주입할 StyleSheet
인스턴스를 설정하는 등 복잡한 초기화 과정을 담당합니다.
// emotion/packages/cache/src/index.ts
let createCache = (options: Options): EmotionCache => {
let key = options.key
let inserted: EmotionCache['inserted'] = {}
let container: Node
const nodesToHydrate: HTMLStyleElement[] = []
// 브라우저 환경에서 SSR 스타일 처리 및 하이드레이션용 노드 수집
if (isBrowser) {
container = options.container || document.head
// 1. SSR로 생성된 스타일을 document.head로 이동 (일관성 유지)
const ssrStyles = document.querySelectorAll(`style[data-emotion]:not([data-s])`)
Array.prototype.forEach.call(ssrStyles, (node: HTMLStyleElement) => {
const dataEmotionAttribute = node.getAttribute('data-emotion')!
if (dataEmotionAttribute.indexOf(' ') === -1) return
document.head.appendChild(node)
node.setAttribute('data-s', '')
})
// 2. 기존 스타일 노드 수집하여 캐시에 등록 (하이드레이션)
Array.prototype.forEach.call(
document.querySelectorAll(`style[data-emotion^="${key} "]`),
(node: HTMLStyleElement) => {
const attrib = node.getAttribute(`data-emotion`)!.split(' ')
for (let i = 1; i < attrib.length; i++) {
inserted[attrib[i]] = true // 이미 삽입된 것으로 표시
}
nodesToHydrate.push(node)
}
)
}
const cache: EmotionCache = {
key,
sheet: new StyleSheet({
key,
container: container!,
nonce: options.nonce,
speedy: options.speedy,
prepend: options.prepend,
insertionPoint: options.insertionPoint,
}),
nonce: options.nonce,
inserted, // 중복 삽입 방지를 위한 캐시
registered: {}, // 직렬화 캐시
insert,
}
// 3. 수집된 노드를 StyleSheet에 등록하여 하이드레이션 실행
cache.sheet.hydrate(nodesToHydrate)
return cache
}
2. 중복 삽입 방지
Emotion의 가장 기본적인 최적화는 동일한 스타일이 중복으로 DOM에 삽입되는 것을 막는 것입니다. css
함수는 스타일을 직렬화한 후, cache.inserted
객체를 확인하여 이미 주입된 스타일인지 검사합니다.
이 간단한 확인 과정 덕분에 같은 스타일을 여러 번 사용하더라도 실제 <style>
태그에는 단 한 번만 삽입됩니다.
// emotion/packages/css/src/create-instance.ts
let css: Emotion['css'] = (...args) => {
let serialized = serializeStyles(args, cache.registered, undefined)
// 중복 삽입 방지: 이미 삽입된 스타일인지 확인
if (cache.inserted[serialized.name] === undefined) {
insertStyles(cache, serialized, false)
}
return `${cache.key}-${serialized.name}`
}
3. 전처리 단계의 최적화
Emotion은 Stylis라는 경량 CSS 전처리기를 사용하여 중첩 선택자(&:hover
), 벤더 프리픽스(-webkit-
) 등을 표준 CSS로 자동 변환합니다. 이 과정은 cache 객체 내부의 insert
함수가 호출될 때 발생하며, 최종적으로 DOM에 주입될 CSS를 생성합니다.
// emotion/packages/cache/src/index.ts
const stylis = (styles: string) => serialize(compile(styles), serializer)
// CSS 삽입 함수
insert = (selector, serialized, sheet, shouldCache) => {
// ... 소스맵 처리 로직 ...
// Stylis로 CSS 전처리 후 삽입
stylis(selector ? `${selector}{${serialized.styles}}` : serialized.styles)
if (shouldCache) {
cache.inserted[serialized.name] = true
}
}
또한 Emotion은 스타일 처리 과정 곳곳에 메모이제이션(Memoization)을 적용하여 반복 계산을 최소화합니다. 예를 들어 CSS 속성명을 kebab-case로 변환하는 함수나, 서버 환경에서 Stylis 캐시를 생성하는 부분에 메모이제이션이 적용되어 있습니다.
특히 WeakMap을 활용한 캐시는 가비지 컬렉션에 친화적이어서 메모리 누수를 방지합니다.
// emotion/packages/serialize/src/index.ts
// 속성명 변환 캐싱
const processStyleName = memoize((styleName: string) =>
isCustomProperty(styleName) ? styleName : styleName.replace(hyphenateRegex, '-$&').toLowerCase()
)
// emotion/packages/cache/src/index.ts
// WeakMap 기반 캐싱 (가비지 컬렉션 친화적)
const getServerStylisCache = isBrowser
? undefined
: weakMemoize(() => memoize<Record<string, string>>(() => ({})))
WeakMap은 키에 대한 약한 참조(weak reference)를 유지하는 Map 객체입니다. 키로 사용된 객체에 대한 다른 참조가 없을 경우, 가비지 컬렉터가 해당 객체와 WeakMap의 관련 데이터를 자동으로 메모리에서 제거합니다. 이로 인해 캐시와 같이 동적으로 생성/소멸되는 객체를 다룰 때 메모리 효율성을 높이며, 메모리 누수를 방지합니다. 이 때문에 Emotion에서 컴포넌트 관련 캐시 데이터 관리에 활용됩니다.
// 1. 일반 Map (강한 참조)
let map = new Map()
let keyObject = { id: 1 }
map.set(keyObject, '데이터')
keyObject = null // 객체는 여전히 map에 의해 참조되어 메모리에 남음
// 2. WeakMap (약한 참조)
let weakMap = new WeakMap()
let keyObject2 = { id: 2 }
weakMap.set(keyObject2, '데이터')
keyObject2 = null // 객체가 자동으로 가비지 컬렉션됨 (메모리 누수 방지)
3.6 런타임 접근법의 장점
Emotion의 런타임 처리 방식은 여러 장점을 제공합니다. 가장 중요한 것은 동적 스타일링으로, JavaScript의 모든 기능을 활용하여 컴포넌트 상태, props, 테마에 따라 스타일을 실시간으로 변경할 수 있습니다. 또한 강력한 테마 시스템을 구축할 수 있어 다크 모드나 브랜드별 커스터마이징이 용이하며, 컴포넌트 기반 캡슐화를 통해 스타일 응집도와 재사용성을 높입니다.
특히 JavaScript 생태계와의 완벽한 통합이 큰 장점인데, 개발자가 이미 익숙한 JavaScript 도구와 패턴을 스타일링에 그대로 적용할 수 있어 학습 비용이 낮습니다. 이러한 런타임 유연성 덕분에 Emotion은 빌드타임 방식으로는 구현하기 어려운 복잡한 동적 UI와 인터랙티브 컴포넌트 개발에 적합합니다.
4. TailwindCSS vs Emotion
앞서 살펴본 두 라이브러리의 내부 동작을 바탕으로, 실제 프로젝트에서 고려해야할 사항들에 대해 비교해보겠습니다.
4.1 TailwindCSS
TailwindCSS는 빌드타임에 최적화된 접근 방식을 취하며, JavaScript 실행 없이 순수 CSS만 전달하는 제로 런타임 오버헤드의 장점이 있습니다. 이러한 특성 덕분에 블로그, 마케팅 사이트, 문서 사이트처럼 디자인이 고정적이고 동적 요소가 적은 정적 콘텐츠 중심 프로젝트에 적합합니다. SEO와 초기 로딩 속도가 중요한 경우 TailwindCSS의 제로 런타임 특성이 큰 장점이 되며, 유틸리티 클래스만으로도 완성도 높은 UI를 빠르게 구현할 수 있어 프로토타이핑에도 유리합니다.
JIT 컴파일을 통해 실제 사용된 클래스만 포함하여 작은 최종 번들을 생성하고, CSS 파싱만으로 즉시 스타일을 적용할 수 있어 빠른 초기 로딩을 제공합니다. 하지만 이러한 접근 방식은 파일 스캔과 CSS 생성 과정이 필요하여 빌드 시간이 증가하고, 런타임에서 동적 값을 기반으로 한 스타일링에 제한이 있다는 단점이 있습니다.
4.2 Emotion
Emotion은 런타임에 최적화된 유연한 접근 방식을 제공하며, JavaScript 표현식을 활용하여 모든 스타일을 동적으로 제어할 수 있습니다. 이러한 동적 스타일링 능력은 대시보드, 어드민 패널, SaaS 제품처럼 사용자 인터랙션과 상태 변화가 많은 프로젝트에 특히 적합합니다. 데이터에 따라 스타일이 달라지거나, 사용자 설정에 따른 테마 변경, 실시간 상태 반영 등이 필요한 경우 Emotion의 동적 스타일링 능력이 필수적입니다.
별도의 CSS 생성 과정이 필요 없어 빌드 시간이 단축되는 장점이 있습니다. 그러나 이러한 유연성은 JavaScript 번들 크기 증가, 스타일 직렬화와 DOM 조작에 따른 런타임 오버헤드, 그리고 JavaScript가 실행된 후에야 스타일이 적용되어 초기 렌더링이 지연되는 단점을 수반합니다.
결국 당연한 이야기지만, 프로젝트의 성격과 요구사항을 정확히 파악한 후에 각 도구의 강점을 활용하는 방향으로 선택하는 것이 중요합니다.
5. Conclusion
소스코드 분석을 통해 단순히 "TailwindCSS는 빌드타임, Emotion은 런타임"이라는 표면적 이해를 넘어서는 인사이트들을 얻을 수 있었습니다. 오픈소스를 분석하는 과정이 사실 좀 막막했는데 AI의 도움을 받아 핵심적인 로직과 구현 방식을 쉽게 파악할 수 있었습니다.
TailwindCSS에서 가장 인상적이었던 것은 Rust 기반 Oxide 엔진과 TypeScript 레이어의 조합입니다. 각 기능을 최적화하기 위해 각 언어들의 장점을 사용한 것이 인상적이었습니다. 또한 상태 머신 기반 토큰 파싱, 다층 캐싱 전략 등이 조합되어 여러 클래스를 사용해도 빠른 빌드 속도를 유지할 수 있다는 점도 인상 깊었습니다.
Emotion에서는 런타임 CSS-in-JS의 진화된 설계를 있었습니다. 단순 CSS-in-JS뿐만 아니라 SSR 하이드레이션 지원 등이 조합되어 런타임임에도 불구하고 실용적인 성능을 달성하는 방식은 인상 깊었습니다.
이런 내부 구현을 알게 되면서 각 라이브러리를 더욱 적절하게 사용할 수 있는 방법을 알게 된 것 같습니다.
결과적으로 이번 경험을 통해 단순한 호기심 충족뿐만 아니라, 이제는 프로젝트 요구사항에 맞는 최적의 도구를 선택의 기준을 제시할 수 있게 되었고, 훌륭한 개발자들의 고민과 노력이 담긴 코드를 통해 문제 해결에 대한 근본적인 접근법을 배울 수 있었습니다.
각 라이브러리가 최적화하는 방식을 알게 되면서 실제 프로젝트에서의 성능 최적화에 대한 구체적인 가이드라인과 사고 방식도 얻을 수 있었습니다.
무엇보다 오픈소스를 통해 전 세계 개발자들의 집단 지성을 직접 경험할 수 있었습니다. 이런 지식과 경험이 누구에게나 열려있다는 것, 그리고 그것을 통해 서로 배우고 성장할 수 있다는 것이 오픈소스 생태계의 진정한 가치라는 생각이 들었습니다.
앞으로도 자주 사용하는 도구들의 내부 구현을 들여다보는 시간을 가져보려 합니다. 이를 통해 좋은 코드들을 읽으며 공부할 수 있는 계기가 되기도 하고 깊이 있는 이해는 더 나은 개발자가 되기 위한 필수 과정이자, 오픈소스 커뮤니티에 기여할 수 있는 첫걸음이라고 믿기 때문입니다.