className 지옥에서 벗어나기 — CSS 객체 패턴
Article
이 블로그를 만들면서 가장 오래 참았던 스트레스는 성능도 배포도 아니고 JSX의 가독성이었다. Tailwind는 좋아하지만, 컴포넌트가 조금만 복잡해지면 이런 코드가 나온다.
<article className="group relative flex flex-col gap-4 border border-fg/15 bg-surface p-6 transition-colors hover:border-fg/40 max-xl:gap-3 max-md:p-4">
<h3 className="font-serif text-xl font-bold leading-snug text-fg group-hover:text-accent max-md:text-lg">
{post.title}
</h3>
</article>
동작에는 아무 문제가 없다. 문제는 이걸 읽을 수가 없다는 거다. 마크업 구조를 보려고 파일을 열었는데 눈에 들어오는 건 클래스 문자열의 벽이다. 태그가 어디서 열리고 닫히는지, 시멘틱 구조가 어떻게 짜여 있는지가 스타일 뒤에 파묻힌다.
퍼블리셔로 일을 시작한 사람 입장에서 이건 그냥 불편함이 아니다. 마크업 가독성은 퍼블리셔의 자존심이다. HTML 구조만 훑어도 문서의 의미가 보여야 한다는 게 내 기준인데, 인라인 클래스 나열은 그 기준을 정면으로 무너뜨린다.
tw() 헬퍼 — 스타일을 객체로 밀어내기
해결 자체는 단순하다. 스타일 문자열을 컴포넌트 상단의 객체로 빼는 것. 다만 그냥 문자열로 빼면 에디터 지원(자동완성, 정렬)이 다 죽어버려서, 태그드 템플릿 헬퍼를 하나 만들었다.
// lib/tw.ts
export function tw(strings: TemplateStringsArray, ...values: string[]) {
return strings
.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "")
.replace(/\s+/g, " ")
.trim();
}
하는 일은 공백 정리뿐이다. 핵심은 tw 라는 이름 자체다. Tailwind IntelliSense 설정에 이 함수를 등록하면 템플릿 리터럴 안에서도 자동완성과 클래스 정렬이 그대로 동작한다.
// settings.json
"tailwindCSS.experimental.classRegex": [["tw`([^`]*)`", "([^\\s`]+)"]]
부모_자식 네이밍
객체로 빼고 나니 다음 문제가 생겼다. 프로퍼티 이름이 title, titleInner, cardTitle… 이런 식으로 늘어나면서 어떤 스타일이 어떤 요소 아래에 붙는지 다시 헷갈리기 시작했다. 그래서 규칙을 하나 세웠다. 부모 요소명 뒤에 언더스코어로 자식을 표기한다.
const css = {
/* ── Card ── */
card: tw`group relative flex flex-col gap-4 border border-fg/15 bg-surface p-6
transition-colors hover:border-fg/40 max-xl:gap-3 max-md:p-4`,
card_title: tw`font-serif text-xl font-bold leading-snug text-fg
group-hover:text-accent max-md:text-lg`,
card_meta: tw`meta-row text-fg/50`,
/* ── Thumb ── */
thumb: tw`relative aspect-video overflow-hidden`,
thumb_img: tw`object-cover transition-transform group-hover:scale-105`,
};
JSX는 이렇게 바뀐다.
<article className={css.card}>
<figure className={css.thumb}>
<Image className={css.thumb_img} src={post.thumb} alt="" fill />
</figure>
<h3 className={css.card_title}>{post.title}</h3>
<p className={css.card_meta}>{post.date}</p>
</article>
이제 JSX만 보면 구조가 읽히고, css 객체만 보면 스타일 계층이 읽힌다. 언더스코어 덕분에 thumb_img가 thumb 안에 있다는 게 이름만으로 드러난다. 예전에 SCSS로 BEM 쓰던 감각과 거의 같은데, 중첩 셀렉터의 부작용은 없다.
몇 주 써본 소감
- 리뷰가 빨라졌다. 마크업 변경과 스타일 변경이 diff에서 분리돼 보인다.
- 반복 스타일이 눈에 띈다. 객체로 모아두니 중복이 보이고, 자연스럽게 공통 클래스(
.card,.meta-row)로 승격하게 된다. - 비용은 파일당 몇 줄의 보일러플레이트 정도. 클래스 두세 개짜리 요소까지 전부 빼지는 않고, 문자열이 두 줄을 넘기 시작하면 뺀다.
Tailwind를 버리지 않으면서 마크업의 존엄을 지키는 방법으로, 지금까지는 이 패턴이 제일 만족스럽다.