CSS 클래스 기반 스타일링의 한계와 대안을 역사부터 현대 웹 표준까지 폭넓게 다루는 글입니다.
웹 UI의 코드를 한 번이라도 들여다본 적이 있다면, class
속성이 무엇을 위한 것인지 잘 알고 있을 것입니다. 바로 HTML을 CSS와 연결하기 위한 것이죠? 이제 우리는 더 이상 클래스 속성을 쓰지 말아야 한다고 말하고자 합니다. 클래스 네임은 UI의 기본 단위를 제대로 표현할 수 없는 구식 시스템이며, 더 나쁘게는 곳곳에서 억지로 활용되어 조합이 많아질수록 이상한 예외 상황이 폭발적으로 늘어날 뿐입니다. 이제, 모두가 지루하게 들어왔던 역사 이야기부터 시작해 보겠습니다.
HTML 2.0(1996)은 공식적으로 발표된 첫 HTML 명세였지만, 태그 목록 및 각 태그의 허용 속성이 고정되어 있었습니다. HTML 2.0 문서는 스타일링조차 불가능했습니다. 스타일을 커스터마이즈 할 방법이 거의 없었죠. 최고였던 것이 <pre>
태그의 width
속성이었습니다. HTML 3.0이 작업 중인 동안 넷스케이프와 마이크로소프트는 <marquee>
, <blink>
같은 별난 확장 태그를 난립시켰습니다. 결국 모두가 의견을 모아 1997년 HTML 3.2를 발표했고, 이때부터 <body>
태그에 bgcolor
, text
등 스타일 속성이 추가되었습니다.
이때쯤 CSS가 도입되어 좀 더 덜 밋밋한 웹 페이지를 만들 수 있게 되었습니다. HTML 4.0(1997년)은 곧이어 발표되었고, CSS 지원을 확대하며 새로운 "요소 식별자"였던 id
와 class
속성이 등장합니다:
"요소에 대한 제어의 세분성을 높이기 위해 HTML에 새로운 속성 'CLASS'가 추가되었습니다. 모든 BODY 내의 요소는 클래스를 가질 수 있고, 스타일시트에서 해당 클래스를 참조할 수 있습니다."
이 속성 덕분에 우리는 한정된 태그 내에서 다양한 스타일의 "클래스"를 만들어서 적용할 수 있게 되었습니다. 예를 들어 <div class="panel">
과 <div class="card">
는 태그는 같더라도 전혀 다른 스타일을 적용할 수 있습니다. 마치 클래스 상속처럼 class Card extends Div
개념으로 생각할 수도 있죠.
그로부터 20년 이상의 웹 혁신이 있었습니다. CSS 구조화 방식도 다양해졌죠.
"클래스가 오래됐다"는 의미만으로 클래스를 쓰지 말자는 주장은 아닙니다. 다만, 클래스로 해결했던 과거의 문제가 당시의 제약적 상황이었음을 말하려는 겁니다. 당시엔 더 복잡한 시스템이 필요 없었습니다.
class
속성을 OOP의 클래스 개념으로 보면, 파라미터도 없고 상태도 없는 클래스는 드뭅니다. 진짜 클래스의 장점은 파라미터로 "모드"를 지정하고 상태도 변경할 수 있다는 점이죠. CSS에도 :hover
같은 제한된 상태 표현이 있지만, 커스텀 상태나 모드를 나타내려면 또 다른 클래스를 덧붙여야 합니다. 그런데 클래스는 그냥 문자열의 나열일 뿐입니다…
예시로 Card
를 생각해 봅시다. size
(Big|Medium|Small), rounded
(boolean), align
(Left|Right|Center), 그리고 로딩 상태(Loading
, Loaded
)까지 옵션화한다고 해봅시다.
<div class="Card big">
문제는 네임스페이스가 없어서, 다른 CSS에서 big
을 달리 써도 충돌이 날 수 있습니다. 이를 CSS에서 .Card.big {}
처럼 조합해 막으려다 보면 특이성 문제 등 추가 문제가 생깁니다.<div class="BigCard">
중복 코드가 많이 생기고, 옵션이 늘수록 조합 폭발이 일어나(323=18가지), 유지보수가 힘들어집니다.<div class="Card Card--big">
충돌 및 조합 폭발 문제는 어느 정도 해결되지만, 장황해지고 오/남용 위험(Card--big
만 쓸 때 등)도 여전합니다.현대 CSS의 :is()
, :where()
등의 함수나 Sass 같은 전처리기 도구로 개발 경험은 개선할 수 있지만, 문제의 뿌리까지 해결하지는 못합니다.
클래스가 아무리 개선돼도 본질적으로 해결 불가한 문제들이 있습니다:
loading
, loaded
같은 상태 클래스는 실제 요소가 로딩 중이 아닌데도 임의로 클래스값 주입이 가능합니다. 이를 막으려면 추가 도구나 엔지니어링 룰이 필요합니다.Big
, Small
같은 상호 배타 클래스가 동시에 붙는 것도 막을 수 없습니다. 역시 걷잡을 수 없습니다.클래스 시스템의 한계를 극복하겠다며 등장한 대안들도 완벽하지 않습니다.
BEM(Block Element Modifier)은 클래스 네임스페이스 문제를 해결하고 일관성을 높이긴 하지만 결국 클래스 본연의 한계(중복, 불변성 컨트롤 불가 등)를 해결하지는 못합니다. 코딩 규칙이 있어도, 실제로 class="Card--size-big Card--size-small"
을 한 요소에 동시에 줄 수도 있고, 룰을 어겨도 강제성이 없습니다. 뿐만 아니라, 동적 상태 표현을 JS로 바꾸려면 다음처럼 상당히 번거로운 부수 코드가 필요합니다:
jsfunction changeCardSize(card, newSize) { card.classList.toggle('.Card--size-big', newSize === 'big') card.classList.toggle('.Card--size-medium', newSize === 'medium') card.classList.toggle('.Card--size-small', newSize === 'small') }
도우미 함수를 도입하면 조금 나아지지만 문제를 근본 해결하지는 못합니다.
Atomic CSS(유틸리티 클래스)는 디자인 시스템 컴포넌트를 OOP처럼 쓰지 않고, CSS 프로퍼티의 추상화로써 짧은 클래스명으로 속성을 직접 나열하는 방식입니다. 대표격은 Tailwind입니다. 예시:
html<div class="w-big h-big al-l br-r"></div>
여전히 상호 배타성/불변성 문제 미해결이며, 가독성 저하와 특이성 제어의 어려움, 미디어 쿼리에 따라 조합 폭발 및 유지보수 난이도 등이 큰 문제입니다.
미디어 쿼리를 적용하면 Tailwind에서 다음과 같아집니다:
html<div class="w-96 sm:w-80 al-l br-r"></div>
이처럼 마크업이 복잡해지고, 컴포넌트의 실체를 파악하기가 더 어려워집니다. Utility CSS 방식의 단점은 매우 많아서, 별도의 연구와 검토가 필요합니다.
CSS Modules는 이름 충돌을 방지할 뿐, 클래스의 구조적 문제를 해결하지 못합니다. 오히려 빌드 및 개발 스택 전반에 걸친 추가 도구와 의존성이 필요해집니다. 또한 class="big small"
문제나 "보호 클래스" 문제, 캐싱 문제 등도 남아 있습니다.
이 방식들은 모두 클래스 프로퍼티, 즉 임의의 문자열 집합에만 의존합니다. 키-값 맵도, 프라이빗 상태도, 복합 타입도 아니기에 IDE 지원도 한계가 있고, 조금이라도 사용성을 높이려면 BEM 같은 커스텀 DSL까지 필요합니다. 결국 원하는 것은 Set<string>
이 아니라 Map<string, T>
라는 것입니다.
현대 웹 기술로 충분히 더 견고하게 만들 수 있습니다. 다음과 같은 간단한 변화들로 말이죠:
속성(Attributes)은 키-값(key-value)의 매핑이므로, 우리에게 필요한 파라미터 표현에 안성맞춤입니다. 브라우저는 다양한 속성 값을 선택할 수 있는 강력한 셀렉터도 지원합니다. 앞의 예시를 속성으로 작성하면 다음과 같습니다:
css.Card { /* ... */ } .Card[data-size=big] { width: 100%; } .Card[data-size=medium] { width: 50%; } .Card[data-size=small] { width: 25%; } .Card[data-align=left] { text-align: left; } .Card[data-align=right] { text-align: right; } .Card[data-align=center] { text-align: center; }
HTML 속성은 한 번만 나타날 수 있으므로, <div data-size="big" data-size="small">
이라 써도 big 값만 적용됩니다. 이는 다른 방식들이 가진 상호배타성 문제를 해결합니다.
BEM과 비슷해 보일 수도 있지만, HTML을 작성할 때 각 상태를 명확하게 구별할 수 있다는 장점이 있습니다.
html<div class="Card" data-size="big" data-align="center"></div>
동적으로 JS로도 훨씬 간편하게 처리할 수 있습니다:
jsfunction changeCardSize(card, newSize) { card.setAttribute('data-size', newSize) }
data-
접두어는 다소 길지만, 호환성과 안전성을 보장합니다. 커스텀 네임스페이스를 정해(my-align
등) 쓰면 가독성도 높아집니다.
속성 선택자를 활용하면 여러 값(리스트형)도 쉽게 지원할 수 있어, 예를 들어 한 쪽의 border만 제거하고 싶을 때 다음과 같이 작성할 수 있습니다:
css.Card { border: 10px solid var(--brand-color) } .Card[data-border-collapse~="top"] { border-top: 0 } .Card[data-border-collapse~="right"] { border-right: 0 } .Card[data-border-collapse~="bottom"] { border-bottom: 0 } .Card[data-border-collapse~="left"] { border-left: 0 }
html<div class="card" data-border-collapse="left right"></div>
최신 CSS Values 5 스펙은 속성 값을 CSS 변수처럼 프로퍼티로 읽어올 수 있게 해 줍니다. 예를 들어 pad-size
를 1~6의 값에 매핑해 padding으로 쓰고 싶다면:
html<div class="card" pad-size="2"></div>
css.Card { --padding-size: attr(pad-size px, 1px); --padding-px: calc(var(--padding-size) * 3px); padding: var(--padding-px); }
아직은 모든 브라우저에서 쓰지 못하더라도, 제한 조건 내에서는 다음과 같이 워크어라운드도 가능합니다:
css.Card { --padding-size: 1; --padding-px: calc(var(--padding-size) * 3px); padding: var(--padding-px); } .Card[pad-size=2] { --padding-size: 2 } .Card[pad-size=3] { --padding-size: 3 } .Card[pad-size=4] { --padding-size: 4 } .Card[pad-size=5] { --padding-size: 5 } .Card[pad-size=6] { --padding-size: 6 }
여기까지 읽으셨다면 "Keith, 그래봤자 여전히 클래스명을 쓰고 있잖아!"라고 소리칠 수도 있겠습니다. 맞는 말씀입니다. 그런데 HTML5 표준은 커스텀 태그를 허용합니다. 모르는 태그는 그냥 <span>
처럼 취급되어 스타일만 주면 되고, 노출 위험도 없습니다. 이름에 -
만 포함시키면 spec에서 정한 대로 충돌도 없습니다:
html<my-card data-size="big"></my-card>
cssmy-card { /* ... */ } my-card[data-size="big"] { width: 100% }
이는 명백히 유효한 HTML5 문법이며, 추가 정의, DTD, JS 없이도 사용할 수 있습니다. 필요하다면 Custom Elements도 붙일 수 있어, JS와 연동된 상호작용도 가능합니다.
컴포넌트에 상호작용이 있으면 상태 변화에 따라 스타일도 바뀌면 좋겠죠. 예를 들어 input[type=checkbox]
에 :checked
가 있듯, 우리의 카드 예제에도 loading
, loaded
같은 가상 상태를 CSS에서 잡고 싶다면 Custom Elements, internals API, JS를 써서 다음처럼 할 수 있습니다:
jscustomElements.define('my-card', class extends HTMLElement { #internal = this.attachInternals(); async connectedCallback() { this.#internal.states.add('loading'); await fetchData(); this.#internal.states.delete('loading'); this.#internal.states.add('loaded'); } });
cssmy-card:state(loading) { background: url(./spinner.svg); } my-card:state(loaded) { border: 2px solid green; }
커스텀 상태는 마크업을 건드리지 않아도 엘리먼트 내부에서만 조작되고 외부 제어 위험도 없습니다. 진정한 "내부 상태"인 셈이죠. 현대 브라우저는 이미 지원 중이며, 구형 브라우저엔 폴리필도 존재합니다.
컴포넌트의 상태와 파라미터를 반드시 옛날 시스템인 class
속성에 우겨넣을 필요 없습니다. 지금도 충분히 이를 대체할 도구와 표준이 있고, 앞으로는 더 강력해질 것입니다.
유틸리티 클래스를 포기 못 하시겠나요? 커스텀 엘리먼트가 마음에 안 드시나요? 의견 있으시다면 언제든 제 소셜 링크(헤더 참고)로 연락해 주세요.