
CSS는 지난 몇 년간 크게 달라졌다. 별도 스타일시트는 여전히 합리적인 선택이지만, 웹 애플리케이션의 스타일을 구성하는 유일한 방법은 아니다.
CSS Modules는 이름 충돌을 해결하고, Tailwind는 유틸리티 우선 작업 방식을 제공한다. CSS-in-JS 라이브러리는 스타일을 JavaScript 안에 두며, 런타임을 사용하지 않는 도구는 그 작업의 상당 부분을 빌드 과정으로 옮긴다. 한편 CSS 자체에도 과거에는 전처리기나 JavaScript, 타사 라이브러리가 필요했던 기능이 추가됐다.
이 접근법들 가운데 모든 프로젝트에 통하는 승자는 없다. 소규모 콘텐츠 웹사이트와 인터랙티브 대시보드는 요구사항이 다르다. 컴포넌트 라이브러리에는 랜딩 페이지와 다른 보장이 필요하다. 이미 디자인 시스템을 갖춘 대규모 애플리케이션에는 유용한 규칙이 소규모 프로젝트에서는 불필요할 수 있다.
중요한 구분은 어떤 문법이 더 깔끔해 보이는지가 아니다. 스타일 작업이 어디서 이뤄지는지, 브라우저에 무엇이 전달되는지, 결과 코드를 얼마나 쉽게 유지보수할 수 있는지가 핵심이다. 이 가이드는 2026년에 쓸 수 있는 주요 접근법의 장단점과 최신 CSS가 추가 도구의 필요성을 줄이는 방법을 설명한다.
1. 네이티브 CSS: 예전보다 더 많은 일을 한다
스타일 라이브러리를 추가하기 전에 브라우저가 이미 지원하는 기능을 살펴볼 만하다. 최신 CSS에는 사용자 지정 속성, 중첩, 캐스케이드 레이어, 컨테이너 쿼리, 범위 지정 스타일이 포함된다. 이 기능들은 과거에 개발자가 전처리기나 CSS-in-JS를 찾게 만들었던 여러 문제를 해결한다. 많은 프로젝트에서 네이티브 CSS는 현실적인 출발점이다.
CSS 사용자 지정 속성
사용자 지정 속성을 쓰면 선택자마다 값을 되풀이하지 않고 스타일끼리 값을 공유할 수 있다.
:root {
--surface-primary: #ffffff;
--surface-secondary: #f4f4f5;
--text-primary: #18181b;
--text-muted: #71717a;
--border-default: #e4e4e7;
--radius-card: 12px;
--space-card: 24px;
}
[data-theme="dark"] {
--surface-primary: #18181b;
--surface-secondary: #27272a;
--text-primary: #fafafa;
--text-muted: #a1a1aa;
--border-default: #3f3f46;
}
.content-card {
padding: var(--space-card);
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-primary);
color: var(--text-primary);
}
data-theme 속성을 바꾸면 이에 해당하는 모든 컴포넌트가 사용하는 값이 바뀐다. 새 스타일시트를 만들거나 각 요소에 별도 규칙을 계산할 필요가 없다. JavaScript는 속성만 바꾸면 된다.
function applyColorScheme(preference) {
const supportedSchemes = new Set(["light", "dark"]);
if (!supportedSchemes.has(preference)) {
return;
}
document.documentElement.dataset.theme = preference;
}
applyColorScheme("dark");
이 방식은 일반 HTML, Astro, Vue, Svelte, React를 비롯해 사실상 어떤 프런트엔드 환경에서도 쓸 수 있다. 스타일 시스템이 특정 컴포넌트 프레임워크가 아니라 CSS에 속하기 때문이다.
CSS 중첩
네이티브 CSS 중첩은 선택자가 공통 부모를 공유할 때 반복을 줄인다.
.article-card {
padding: 1.5rem;
border: 1px solid #e4e4e7;
border-radius: 0.75rem;
& h2 {
margin: 0;
font-size: 1.25rem;
}
& p {
color: #52525b;
line-height: 1.6;
}
&:hover {
border-color: #a1a1aa;
}
@media (width >= 768px) {
padding: 2rem;
}
}
선택자를 중첩하려는 이유만으로 별도의 Sass 컴파일 단계를 둘 필요는 더 이상 없다. Sass는 믹스인과 빌드 시점 함수 등 추가 기능을 제공하지만, 중첩만으로 Sass를 도입할 이유는 약해졌다.
캐스케이드 레이어
큰 스타일시트는 선택자끼리 명시도와 소스 순서로 경쟁하면서 유지보수가 어려워지는 경우가 많다. 캐스케이드 레이어는 캐스케이드를 명시적으로 구성하는 방법을 제공한다.
@layer reset, base, components, utilities;
@layer base {
body {
margin: 0;
font-family: system-ui, sans-serif;
}
button {
font: inherit;
}
}
@layer components {
.action-button {
padding: 0.75rem 1rem;
border: 0;
border-radius: 0.5rem;
background: #18181b;
color: white;
}
}
@layer utilities {
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
일반 선언에서는 뒤에 오는 레이어가 앞선 레이어보다 우선한다. 앞선 레이어의 선택자가 더 구체적이어도 마찬가지다. 이 기능은 스타일시트 그룹별 우선순위를 더 쉽게 파악하도록 돕는다. 다만 레이어에 속하지 않은 일반 선언은 레이어 안의 일반 선언보다 우선한다. !important 선언에는 다른 레이어 우선순위 규칙이 적용된다. 레이어는 유용하지만 신중한 전략이 필요하다.
컨테이너 쿼리
기존 미디어 쿼리는 뷰포트에 반응하지만, 컨테이너 쿼리는 특정 컨테이너 안에서 사용할 수 있는 공간에 반응한다. 재사용 가능한 컴포넌트에서 이 차이가 유용하다.
<div class="product-region">
<article class="product-card">
<img
src="/images/headphones.webp"
alt="Wireless headphones"
width="640"
height="480"
/>
<div class="product-details">
<h2>Wireless Headphones</h2>
<p>Lightweight design with active noise cancellation.</p>
<strong>$149</strong>
</div>
</article>
</div>
.product-region {
container: product-region / inline-size;
}
.product-card {
display: grid;
gap: 1rem;
padding: 1rem;
border: 1px solid #e4e4e7;
border-radius: 0.75rem;
}
.product-card img {
display: block;
width: 100%;
height: auto;
border-radius: 0.5rem;
}
@container product-region (width >= 520px) {
.product-card {
grid-template-columns: minmax(0, 180px) minmax(0, 1fr);
align-items: center;
}
}
카드는 브라우저 창 너비가 아니라 .product-region의 너비에 따라 배치를 바꾼다. 컴포넌트 구현을 여러 개 만들지 않아도 좁은 사이드바와 넓은 콘텐츠 영역에 모두 배치할 수 있다.
CSS `@scope`
@scope 규칙은 선택자가 적용되는 범위를 제한하는 또 다른 방법이다.
@scope (.documentation-panel) {
h2 {
margin-block: 0 1rem;
font-size: 1.5rem;
}
p {
line-height: 1.7;
color: #52525b;
}
a {
color: #2563eb;
text-underline-offset: 3px;
}
}
이 규칙은 .documentation-panel 안에서 일치하는 요소를 대상으로 한다. CSS Modules와 달리 @scope는 클래스 이름을 바꾸지 않는다. 브라우저에서 선택자의 범위를 지정한다. 두 방법은 관련된 문제를 서로 다른 방식으로 해결한다.
네이티브 CSS는 콘텐츠 웹사이트, 소규모·중간 규모 애플리케이션, CSS 변수 중심의 디자인 시스템, 의존성을 최소화하려는 프로젝트에 적합하다. 다만 네이티브 CSS만으로는 이름 규칙, 파일 구성, 컴포넌트 경계가 자동으로 정해지지 않는다. 언어가 도구를 제공할 뿐, 아키텍처를 만들어 주지는 않는다.
2. CSS Modules: 런타임 생성 없는 지역 스타일
CSS Modules는 규모가 커지는 거의 모든 코드베이스에서 나타나는 클래스 이름 충돌 문제를 해결한다. 전역 .card 선택자는 관계없는 요소에 우연히 영향을 줄 수 있다. 컴포넌트가 늘어날수록 클래스 이름을 고유하게 유지하기 어렵다.
CSS Modules는 컴파일 과정에서 이 문제를 처리한다. .notification 같은 클래스는 생성된 식별자로 바뀌고, JavaScript에는 원래 이름과 생성된 이름을 연결하는 매핑이 전달된다.
실제 예
notification.module.css
.notice {
display: flex;
align-items: flex-start;
gap: 0.875rem;
padding: 1rem 1.25rem;
border: 1px solid var(--notice-border);
border-radius: 0.75rem;
background: var(--notice-background);
}
.notice[data-kind="success"] {
--notice-border: #86efac;
--notice-background: #f0fdf4;
}
.notice[data-kind="error"] {
--notice-border: #fca5a5;
--notice-background: #fef2f2;
}
.message {
margin: 0;
line-height: 1.5;
}
notification.js
import classes from "./notification.module.css";
function createNotification(message, kind = "success") {
const allowedKinds = new Set(["success", "error"]);
const notification = document.createElement("div");
const content = document.createElement("p");
notification.className = classes.notice;
notification.dataset.kind = allowedKinds.has(kind)
? kind
: "success";
notification.setAttribute(
"role",
notification.dataset.kind === "error" ? "alert" : "status"
);
content.className = classes.message;
content.textContent = message;
notification.append(content);
return notification;
}
document.body.append(
createNotification("The file was uploaded successfully.")
);
JavaScript는 클래스 이름을 가져오지만 CSS 규칙을 생성하지 않는다. 빌드 도구가 규칙을 추출하고 처리한다. CSS Modules에는 도구가 필요하지만 브라우저에서 실행되는 스타일 런타임은 필요하지 않다는 점이 중요하다.
동적 값도 사용할 수 있다
CSS Modules는 정적인 스타일에만 적합하다고 여겨지기도 하지만 이는 오해다. 사용자 지정 속성을 이용하면 동적 값을 컴파일된 CSS에 전달할 수 있다.
progress.module.css
.progress-track {
width: 100%;
height: 0.625rem;
overflow: hidden;
border-radius: 999px;
background: #e4e4e7;
}
.progress-fill {
width: var(--completion);
height: 100%;
border-radius: inherit;
background: #18181b;
transition: width 200ms ease;
}
@media (prefers-reduced-motion: reduce) {
.progress-fill {
transition: none;
}
}
progress.js
import classes from "./progress.module.css";
function createProgressIndicator(initialValue = 0) {
const track = document.createElement("div");
const fill = document.createElement("div");
track.className = classes["progress-track"];
fill.className = classes["progress-fill"];
track.setAttribute("role", "progressbar");
track.setAttribute("aria-label", "Upload progress");
track.setAttribute("aria-valuemin", "0");
track.setAttribute("aria-valuemax", "100");
track.append(fill);
function updateProgress(nextValue) {
const normalizedValue = Number.isFinite(nextValue)
? Math.min(100, Math.max(0, nextValue))
: 0;
fill.style.setProperty(
"--completion",
`${normalizedValue}%`
);
track.setAttribute(
"aria-valuenow",
String(normalizedValue)
);
}
updateProgress(initialValue);
return {
element: track,
update: updateProgress,
};
}
const uploadProgress = createProgressIndicator(35);
document.body.append(uploadProgress.element);
uploadProgress.update(70);
스타일시트는 그대로 두고 사용자 지정 속성만 바뀐다. 진행 표시기, 테마 색상, 컴포넌트 크기 등 다른 동적 값에도 대개 이 방법이면 충분하다.
CSS Modules는 완전한 디자인 시스템을 제공하지 않는다. 지역 클래스 이름은 처리하지만 디자인 토큰, 테마 구성, 컴포넌트 변형은 다루지 않는다. 이런 부분은 추가 규칙이나 도구로 구현해야 한다. 일반 CSS를 선호하는 팀에는 대체로 관리할 만한 수준이며, 더 구조화된 스타일 API를 원하는 팀에는 다른 선택지가 있을 수 있다. 장기간 유지되는 애플리케이션, 컴포넌트 기반 인터페이스, 런타임 생성 없이 예측 가능한 CSS가 필요한 프로젝트에 적합하다.
3. Tailwind CSS: 유틸리티 우선 스타일링
Tailwind는 스타일 작성 방식을 바꾼다. 컴포넌트마다 사용자 지정 클래스를 작성하는 대신 마크업에서 미리 정의된 유틸리티를 조합한다. 간격, 색상, 타이포그래피, 레이아웃, 반응형 동작을 모두 유틸리티 클래스로 표현할 수 있다.
간단한 제품 카드
<article
class="rounded-xl border border-zinc-200 bg-white p-6"
>
<span
class="inline-flex rounded-md bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-700"
>
New release
</span>
<h2 class="mt-4 text-xl font-semibold text-zinc-900">
Wireless Headphones
</h2>
<p class="mt-2 text-sm leading-6 text-zinc-600">
Comfortable everyday headphones with active noise cancellation.
</p>
<div class="mt-6 flex items-center justify-between">
<strong class="text-2xl font-bold text-zinc-900">
$149
</strong>
<button
type="button"
class="rounded-lg bg-zinc-900 px-4 py-2 text-sm font-medium text-white hover:bg-zinc-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-zinc-900"
>
Add to cart
</button>
</div>
</article>
별도의 컴포넌트 스타일시트가 없다. HTML에 스타일 결정이 직접 들어간다. Tailwind에 익숙한 개발자라면 마크업과 CSS 파일 사이를 오갈 일이 줄어 작은 UI 변경을 더 빠르게 할 수 있다. 반면 클래스 속성이 길어지고, 특히 시각적 변형이 많은 큰 컴포넌트에서는 마크업을 훑어보기 어려워질 수 있다.
Tailwind의 CSS 생성 방식
Tailwind는 소스 파일을 살펴 필요한 유틸리티 클래스를 찾고 빌드 과정에서 해당 CSS 규칙을 생성한다. 프레임워크가 지원하는 모든 유틸리티를 전달할 필요는 없다. Tailwind v4는 빠른 빌드, 자동 소스 탐지, CSS 우선 구성을 강조하는 새 엔진을 사용한다. 출력물은 일반 CSS이며, 브라우저가 유틸리티 클래스를 해석하기 위해 Tailwind JavaScript 런타임을 실행할 필요는 없다.
동적으로 클래스 이름을 만드는 문제
Tailwind의 소스 탐지는 완성된 클래스 이름을 찾아야 한다. 다음처럼 클래스 이름을 실행 시점에 조합하면 빌드 시점 스캐너가 가능한 결과를 모두 안정적으로 알아내기 어렵다.
function getBadgeClasses(tone) {
return `bg-${tone}-100 text-${tone}-800`;
}
대신 완성된 변형을 미리 선언할 수 있다.
const badgeVariants = {
success: "bg-green-100 text-green-800",
warning: "bg-amber-100 text-amber-800",
error: "bg-red-100 text-red-800",
};
function createStatusBadge(label, variant = "success") {
const badge = document.createElement("span");
const selectedClasses =
badgeVariants[variant] ?? badgeVariants.success;
badge.className = [
"inline-flex rounded-md px-2.5 py-1",
"text-sm font-medium",
selectedClasses,
].join(" ");
badge.textContent = label;
return badge;
}
document.body.append(
createStatusBadge("Payment received", "success")
);
가능한 유틸리티가 모두 완성된 문자열로 소스에 나타나므로 Tailwind가 찾아 CSS를 생성할 수 있다.
Tailwind는 공통 간격·색상 체계를 지키면서 빠르게 인터페이스를 만들려는 팀에 유용하다. 프로토타입, 대시보드, 마케팅 웹사이트, 대규모 프로덕션 애플리케이션에 쓸 수 있다. 적합성은 프로젝트 크기만으로 결정되지 않는다. 잘 구성한 Tailwind 코드베이스는 확장할 수 있고, 잘 정리되지 않은 코드베이스는 CSS 파일 모음과 마찬가지로 유지보수가 어려워질 수 있다. 반복되는 패턴을 재사용 가능한 컴포넌트나 템플릿으로 추출하는 것이 중요하다.
4. 런타임 CSS-in-JS: JavaScript에서 스타일 생성
CSS-in-JS는 JavaScript API를 통해 스타일을 작성하는 여러 도구를 가리킨다. 일부는 애플리케이션 실행 중 CSS를 생성하고, 다른 도구는 빌드 시점에 추출한다. Emotion과 styled-components 같은 런타임 CSS-in-JS 라이브러리는 전자에 속한다. 이 라이브러리들은 동적 표현식을 평가하고 CSS를 직렬화하며 클래스 이름을 만들고 문서에 규칙을 삽입할 수 있다.
프레임워크에 종속되지 않은 예
다음 예는 작은 CSS-in-JS API로 기본 원리를 보여준다. 단순화한 구현이며 프로덕션 스타일 라이브러리를 대체하지 않는다.
const generatedRules = new Map();
function registerStyle(declarations) {
const serializedStyle = JSON.stringify(declarations);
if (generatedRules.has(serializedStyle)) {
return generatedRules.get(serializedStyle);
}
const generatedClass = `dynamic-style-${generatedRules.size + 1}`;
const cssDeclarations = Object.entries(declarations)
.map(([property, value]) => {
const cssProperty = property.replace(
/[A-Z]/g,
character => `-${character.toLowerCase()}`
);
return `${cssProperty}: ${value};`;
})
.join(" ");
const stylesheet = document.createElement("style");
stylesheet.textContent =
`.${generatedClass} { ${cssDeclarations} }`;
document.head.append(stylesheet);
generatedRules.set(serializedStyle, generatedClass);
return generatedClass;
}
const highlightedClass = registerStyle({
backgroundColor: "#18181b",
color: "#ffffff",
padding: "12px 18px",
borderRadius: "8px",
});
const highlightedElement = document.createElement("div");
highlightedElement.className = highlightedClass;
highlightedElement.textContent = "Dynamic styling";
document.body.append(highlightedElement);
이 예에서는 스타일 정의를 직렬화하고, 같은 스타일이 다시 나타나면 기존 클래스를 재사용하며, 새 스타일이 발견될 때만 CSS를 삽입한다. 실제 CSS-in-JS 라이브러리는 훨씬 더 복잡하다. 선택자, 의사 클래스, 미디어 쿼리, 브라우저별 호환성, 서버 렌더링, 캐싱, 스타일시트 삽입을 처리한다. 위의 단순한 예는 CSS 선언을 검증하거나 신뢰할 수 없는 입력을 처리하지도 않는다. 프로덕션 유틸리티가 아니라 작동 원리를 설명하는 예로 봐야 한다.
런타임 CSS-in-JS가 인기를 얻은 이유
JavaScript 기반 스타일은 시각 규칙이 애플리케이션 상태에 크게 좌우될 때 편리할 수 있다. 컴포넌트 속성에 따라 색상, 크기, 변형이 달라져도 가능한 모든 조합을 별도 매핑으로 만들 필요가 없다. 라이브러리는 테마 제공자와 스타일 조합 API도 제공할 수 있다. 다만 이런 편의에는 애플리케이션 실행 중에 처리해야 하는 작업이 따른다.
런타임은 실제로 어떤 일을 하는가
라이브러리에 따라 스타일이 적용된 요소를 렌더링할 때 다음 작업이 일어날 수 있다.
- 스타일 표현식 평가
- 결과 직렬화
- 캐시 키 또는 클래스 이름 계산
- 이미 생성된 스타일 조회
- 필요한 경우 새 CSS 규칙 삽입
캐싱은 반복 작업을 줄인다. 여러 번 렌더링되는 컴포넌트가 매번 CSS를 다시 삽입하는 것은 아니다. 애플리케이션이 계속 고유한 스타일을 만들어낼 때 문제가 생길 가능성이 더 크다. 예를 들어 모든 마우스 좌표나 애니메이션 프레임마다 별도 클래스를 만드는 일은 대개 유용하지 않다. 자주 바뀌는 값에는 CSS 사용자 지정 속성이 더 나은 선택인 경우가 많다.
Emotion과 styled-components
Emotion은 스타일 객체와 템플릿 리터럴을 지원하며 프레임워크별 통합도 제공한다. styled-components는 스타일 컴포넌트 API에 초점을 맞추며 컴포넌트 기반 애플리케이션에서 널리 사용됐다. 두 도구 모두 기존 코드베이스에서 여전히 쓰인다. 다만 styled-components는 2025년에 유지보수 모드에 들어갔다. 기존 애플리케이션은 계속 성공적으로 사용할 수 있지만 새 프로젝트에서는 중요한 고려사항이다.
런타임 CSS-in-JS가 본질적으로 나쁜 선택인 것은 아니다. 개발 편의성과 브라우저 측 작업 사이에서 다른 균형을 선택하는 방식이다.
5. 런타임을 사용하지 않는 CSS-in-JS: 작업을 빌드로 이동
런타임을 사용하지 않는 CSS-in-JS는 같은 문제에 다른 방향으로 접근한다. 개발자는 JavaScript나 TypeScript 문법으로 스타일을 작성하지만 도구가 컴파일 중에 스타일을 추출한다. 브라우저에는 생성된 CSS 파일과 클래스 이름이 전달된다. 렌더링 중 정적인 규칙을 직렬화하고 삽입할 필요가 없다. Linaria와 vanilla-extract가 이 방식의 예다.
vanilla-extract를 이용한 타입 안전 스타일
vanilla-extract를 사용하면 TypeScript에서 스타일을 정의할 수 있다.
status.css.ts
import {
style,
styleVariants,
} from "@vanilla-extract/css";
export const statusBase = style({
display: "inline-flex",
alignItems: "center",
padding: "6px 12px",
borderRadius: "999px",
fontSize: "14px",
fontWeight: 600,
});
export const statusVariants = styleVariants({
neutral: {
backgroundColor: "#f4f4f5",
color: "#27272a",
},
positive: {
backgroundColor: "#dcfce7",
color: "#166534",
},
warning: {
backgroundColor: "#fef3c7",
color: "#92400e",
},
});
생성된 클래스 이름은 애플리케이션 코드에서 가져올 수 있다.
status.ts
import {
statusBase,
statusVariants,
} from "./status.css";
type StatusVariant = keyof typeof statusVariants;
export function createStatusElement(
label: string,
variant: StatusVariant = "neutral"
): HTMLSpanElement {
const statusElement = document.createElement("span");
statusElement.className = [
statusBase,
statusVariants[variant],
].join(" ");
statusElement.textContent = label;
return statusElement;
}
브라우저는 스타일을 생성하기 위해 vanilla-extract를 실행할 필요가 없다. 스타일은 이미 컴파일된 CSS에 들어 있다. TypeScript는 유효하지 않은 변형 이름을 방지하는 데도 도움을 준다.
CSS 변수를 이용한 동적 값
빌드 시점 추출을 사용해도 모든 런타임 사용자 지정을 막는 것은 아니다. 정적인 CSS 규칙에서 사용자 지정 속성을 참조하고 JavaScript가 값을 제공할 수 있다.
panel.css.ts
import {
createVar,
style,
} from "@vanilla-extract/css";
export const panelWidthVariable = createVar();
export const panelSurface = style({
width: panelWidthVariable,
maxWidth: "100%",
padding: "20px",
border: "1px solid #e4e4e7",
borderRadius: "12px",
});
panel.ts
import { assignInlineVars } from "@vanilla-extract/dynamic";
import {
panelSurface,
panelWidthVariable,
} from "./panel.css";
export function createResizablePanel(
requestedWidth: number
): HTMLElement {
const panelElement = document.createElement("section");
const calculatedWidth = Number.isFinite(requestedWidth)
? Math.max(200, requestedWidth)
: 320;
panelElement.className = panelSurface;
Object.assign(
panelElement.style,
assignInlineVars({
[panelWidthVariable]: `${calculatedWidth}px`,
})
);
return panelElement;
}
CSS 규칙은 빌드 중 생성되고 JavaScript는 변수만 바꾼다. CSS Modules 예시와 비슷하지만 스타일 정의를 타입이 지정된 API로 작성한다.
런타임을 사용하지 않는 방식의 한계
CSS 변수는 속성값을 바꿀 수 있지만 임의의 선택자나 미디어 쿼리 조건을 동적으로 만들 수는 없다. 예를 들어 런타임 값에 따라 달라지는 중단점은 CSS 사용자 지정 속성을 통해 정적으로 추출된 @media 규칙에 그대로 넣을 수 없다. 구조를 컴파일 중에 알고 있거나, 미리 정의된 변형으로 표현하거나, 다른 방법을 써야 한다.
스타일 대부분을 정적 규칙과 제한된 변형으로 표현할 수 있다면 런타임을 사용하지 않는 스타일링이 매력적이다. CSS 구조 자체를 동적으로 바꿔야 하는 경우에는 덜 편리하다.
6. 빌드 시점과 런타임: 실제 아키텍처 차이
도구 간 가장 중요한 차이는 CSS 생성이 어디서 일어나는지다.
- 정적 CSS와 CSS Modules: 빌드 도구가 스타일을 처리해 CSS 파일을 만든다. 브라우저는 그 파일을 내려받아 규칙을 적용한다. JavaScript는 클래스나 사용자 지정 속성을 바꿀 수 있지만 스타일시트를 생성할 필요는 없다.
- Tailwind: 빌드 도구가 소스 파일에서 유틸리티를 검색하고 발견한 클래스에 필요한 CSS를 생성한다. 브라우저에는 일반 CSS가 전달되고 요소에는 해당 클래스 이름이 적용된다.
- 런타임 CSS-in-JS: 애플리케이션에 스타일을 처리하는 JavaScript가 포함된다. 라이브러리와 렌더링 방식에 따라 컴포넌트가 렌더링될 때 CSS 규칙을 생성하거나 삽입할 수 있다. 캐싱과 서버 측 추출로 비용을 줄일 수 있지만 스타일 작업 일부는 런타임 아키텍처에 남는다.
- 런타임을 사용하지 않는 CSS-in-JS: JavaScript나 TypeScript로 스타일을 작성한 뒤 컴파일 중 추출한다. 결과 애플리케이션은 생성된 클래스 이름과 정적 CSS를 사용한다. 사용자 지정 속성으로 런타임 값을 전달할 수도 있다.
| 접근법 | CSS 생성 시점 | 런타임 스타일 작업 | 주요 절충점 |
|---|---|---|---|
| 네이티브 CSS | 직접 작성 | 브라우저의 스타일 처리 | 수동 구성 |
| CSS Modules | 빌드 시점 | 클래스 및 변수 업데이트 | 빌드 도구 필요 |
| Tailwind CSS | 빌드 시점 | 클래스 및 변수 업데이트 | 유틸리티가 많은 마크업 |
| 런타임 CSS-in-JS | 런타임, 서버 렌더링 추출이 가능할 수 있음 | 직렬화, 캐싱, 삽입 | JavaScript 오버헤드 |
| 런타임을 사용하지 않는 CSS-in-JS | 빌드 시점 | 클래스 및 변수 업데이트 | 컴파일 시점 제약 |
모든 접근법에서 브라우저는 여전히 스타일을 계산해야 한다. 런타임을 사용하지 않는다는 말은 브라우저 작업이 전혀 없다는 뜻이 아니다. 스타일 도구가 애플리케이션 실행 중 CSS 규칙을 생성하지 않는다는 뜻이다.
7. CSS가 브라우저에 도달하면 어떤 일이 일어나는가
스타일을 어떻게 작성하든 브라우저는 결국 이를 해석해야 한다. 어떤 스타일 라이브러리가 빠르다는 추측에 기대기보다 이 과정을 이해하는 편이 유용하다.
CSS 파싱과 CSSOM
브라우저는 스타일시트를 파싱하고 CSS 객체 모델(CSSOM)이라는 규칙 표현을 만든다. 이 규칙은 요소의 계산된 스타일을 결정하는 데 참여한다. 브라우저는 DOM, 선택자 일치 여부, 상속, 캐스케이드도 고려한다.
스타일 재계산
클래스, 사용자 지정 속성, 스타일시트 규칙이 바뀌면 브라우저가 스타일을 다시 계산할 수 있다. 작업량은 선택자와 영향을 받는 요소에 따라 달라진다. 작은 변경이 반드시 페이지 전체의 재계산을 뜻하지는 않는다.
레이아웃
일부 속성은 요소의 기하 구조에 영향을 준다. width, height, padding 등 레이아웃 관련 값을 바꾸면 브라우저가 위치와 크기를 다시 계산해야 할 수 있다. 특히 문서가 큰 경우 레이아웃 작업이 잦으면 비용이 커질 수 있다.
페인트와 합성
다른 변경은 요소가 그려지는 방식에 영향을 준다. 배경색, 테두리, 그림자는 페인팅을 일으킬 수 있다. transform과 opacity를 이용하는 애니메이션은 반복적인 레이아웃 계산을 피할 수 있는 경우가 많지만, 실제 동작은 브라우저와 페이지 구조에 따라 달라진다. 성능 문제를 스타일링 방식 탓으로 돌리기 전에 측정해야 하는 이유다.
CSS 성능 문제 찾기
Chrome DevTools의 Performance 패널에서는 다음과 같은 이벤트를 확인할 수 있다.
- Recalculate Style
- Layout
- Paint
- Long Tasks
상호작용 중 애플리케이션이 새 CSS 규칙을 반복해서 삽입한다면 스타일 런타임이 문제에 기여할 수 있다. 비용이 큰 작업이 레이아웃이라면 CSS-in-JS를 CSS Modules로 바꾸는 것만으로 실제 원인을 해결하지 못할 수 있다. 성능 개선은 추측이 아니라 근거를 따라야 한다.
8. CSS 아키텍처와 서버 렌더링
서버 렌더링에서는 초기 HTML이 브라우저에 도달할 때 스타일도 준비되어 있어야 한다. 서버 측 렌더링, 정적 생성, 서버와 클라이언트 렌더링을 결합한 애플리케이션 모두에 해당한다.
정적 CSS 접근법은 비교적 간단하다. CSS Modules, Tailwind, 빌드 시점 추출 도구는 HTML과 함께 전달할 수 있는 스타일시트를 만든다. 런타임 CSS-in-JS는 추가 통합이 필요할 수 있다. 서버가 렌더링된 콘텐츠에 사용된 스타일을 수집하거나 생성해 응답에 포함해야 한다. 그렇지 않으면 클라이언트 스타일링 코드가 실행되기 전에 브라우저에 잠시 스타일이 적용되지 않은 콘텐츠가 보일 수 있다. 이를 스타일 없는 콘텐츠의 깜박임(FOUC)이라고 한다.
정확한 설정은 프레임워크와 스타일 라이브러리에 따라 달라진다. 클라이언트 렌더링 애플리케이션에서 잘 작동하는 라이브러리라도 서버 렌더링 환경에서는 추가 구성이 필요할 수 있다. 애플리케이션이 서버 측 렌더링을 쓴다는 이유만으로 런타임 CSS-in-JS를 배제할 필요는 없다. 실제 렌더링 아키텍처와의 호환성을 확인해야 한다.
새 프로젝트에서 런타임 스타일시트 생성이 필요하지 않다면 정적 CSS가 더 단순한 출발점인 경우가 많다.
9. CSS 아키텍처 선택
스타일링 결정은 프로젝트 요구사항에서 시작해야 한다. 다음은 엄격한 규칙이 아니라 유용한 출발점이다.
| 프로젝트 유형 | 합리적인 출발점 | 이유 |
|---|---|---|
| 소규모 콘텐츠 웹사이트 | 네이티브 CSS | 의존성이 적고 유지보수가 단순함 |
| 컴포넌트 기반 애플리케이션 | CSS Modules | 지역 스타일과 예측 가능한 출력 |
| 빠르게 변화하는 제품 | Tailwind CSS | 빠른 유틸리티 우선 작업 방식 |
| 대규모 타입 지정 디자인 시스템 | vanilla-extract | 타입이 지정된 변형과 빌드 시점 추출 |
| 기존 런타임 CSS-in-JS 애플리케이션 | 당장은 현재 방식 유지 | 마이그레이션 비용이 계속 지원하는 비용보다 클 수 있음 |
| 성능에 민감한 인터페이스 | 정적 CSS 또는 런타임을 사용하지 않는 도구 | 런타임 스타일시트 생성 방지 |
| 프레임워크에 종속되지 않는 컴포넌트 라이브러리 | 네이티브 CSS, CSS Modules 또는 추출 CSS | 특정 렌더링 런타임에 대한 의존성 감소 |
이 표가 모든 요구사항을 반영하는 것은 아니다. 팀의 경험, 프레임워크 지원, 브라우저 대상, 디자인 시스템의 복잡성, 기존 인프라에 따라 선택이 달라질 수 있다. 팀이 이해하지 못하는 기술적으로 세련된 대안보다 익숙하고 규칙이 명확한 도구가 더 나을 수도 있다.
10. 라이브러리보다 중요한 원칙
어떤 도구를 쓰든 비슷하게 나타나는 스타일링 문제도 있다.
디자인 토큰을 한곳에 모은다
색상, 간격, 타이포그래피, 모서리 반경을 모든 컴포넌트에 복사해서는 안 된다. CSS 사용자 지정 속성은 여러 방식으로 쓸 수 있는 기반을 제공한다.
:root {
--color-brand: #2563eb;
--color-surface: #ffffff;
--color-text: #18181b;
--space-small: 0.5rem;
--space-medium: 1rem;
--space-large: 1.5rem;
--radius-small: 0.375rem;
--radius-medium: 0.75rem;
}
이 값은 네이티브 CSS, CSS Modules 또는 다른 스타일 시스템에서 사용할 수 있다.
명시적인 변형을 선호한다
시각적 상태가 예측 가능하면 컴포넌트를 유지보수하기 쉽다. 모든 곳에서 임의의 스타일 값을 허용하기보다 지원하는 변형을 몇 가지로 정한다.
const actionVariants = {
primary: "action action--primary",
secondary: "action action--secondary",
destructive: "action action--destructive",
};
function createActionElement(
label,
appearance = "primary"
) {
const actionElement = document.createElement("button");
actionElement.type = "button";
actionElement.className =
actionVariants[appearance] ?? actionVariants.primary;
actionElement.textContent = label;
return actionElement;
}
이렇게 하면 스타일 구현을 나중에 바꿔도 호출하는 쪽에서 내부 세부사항을 모두 이해할 필요가 없다.
불필요한 동적 CSS 생성을 피한다
자주 바뀌는 값은 사용자 지정 속성으로 처리하는 편이 나은 경우가 많다. 진행률이 업데이트될 때마다, 드래그 위치마다, 계속 변하는 측정값마다 새 CSS 규칙을 만들 이유는 대개 없다.
아키텍처를 일관되게 유지한다
명확한 경계 없이 네이티브 CSS, 유틸리티 클래스, 런타임 CSS-in-JS, 여러 전처리기를 섞으면 유지보수가 어려워진다. 여러 접근법을 함께 써도 되지만 각각 목적을 정해야 한다. 사용할 수 있는 스타일 도구를 모두 도입할 필요는 없다. 개발자가 이해하고 유지보수할 수 있는 스타일 시스템이 필요하다.
마무리
최신 CSS는 어느 때보다 선택지가 많지만, 그 선택지들이 처음 등장한 이유 가운데 일부도 줄어들었다. 중첩, 사용자 지정 속성, 캐스케이드 레이어, 컨테이너 쿼리, 범위 지정 스타일은 네이티브 CSS의 역량을 키운다.
CSS Modules는 지역 스타일을 구성하는 믿을 만한 방법으로 남아 있다. Tailwind는 빠른 유틸리티 우선 작업 방식을 제공한다. 런타임 CSS-in-JS는 JavaScript 기반 스타일링의 유연성을 제공하고, 런타임을 사용하지 않는 도구는 더 많은 작업을 컴파일 과정으로 옮긴다. 각각 장점과 한계가 있다.
새 프로젝트에서는 런타임 생성이 뚜렷한 이점을 주지 않는 한 네이티브 CSS와 빌드 시점 스타일링이 합리적인 기본 선택이다. 기존 애플리케이션은 새 라이브러리의 인기가 아니라 실제 유지보수와 성능 요구사항을 기준으로 평가해야 한다.
최선의 CSS 아키텍처가 반드시 기능이 가장 많은 것은 아니다. 스타일을 예측 가능하게 유지하고, 애플리케이션의 요구사항을 뒷받침하며, 프로젝트가 성장해도 이해할 수 있는 아키텍처가 적합하다.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요