CSS에서 컴포넌트, 접근성 역할, 구성, 확장, 유틸리티를 중심으로 한 클래스 및 셀렉터 네이밍 관례에 대한 생각을 정리한다.
여기 또 왔습니다. CSS, 그리고 더 구체적으로는 네이밍 관례에 대해 이야기하려고요.
사물의 이름을 짓는 제 접근법은 시간에 따라 천천히 그러나 확실하게 진화해 왔습니다. 이 여정을 글로 쓰는 건 생각을 정리하는 데 도움이 되었지만, 원하시면 이 섹션은 건너뛰셔도 됩니다!
CSS를 제대로 배우기 위해 시간을 들이기 전에는, CSS를 조금이라도 건드릴 때마다 늘 거대한 난장판을 만들어 버리곤 했습니다. 커리어 초창기에는 다른 사람들이 하는 걸 따라 해 보려고 했죠. 그리고 독자 여러분, 말씀드리자면— 다른 사람들이 하던 방식은 좋지 않았습니다.
수십 년 경력의 엔지니어들이 유지보수하는 “프로덕션급” 소프트웨어를 작업하면서, #id 셀렉터, !important 선언, 하드코딩된 hex 값, 그리고 죽은 코드가 뒤섞인 썩어가는 수프 속에서 허우적대게 될 거라고는 기대하지 않았습니다. 모든 것의 모든 것이 잘못된 느낌이었지만, 다들 “그냥 CSS잖아”라며 대수롭지 않게 넘겼죠. 지금도 기억나는 일이 하나 있습니다. CDN에서 로드하던 서드파티 스타일시트가 내려갔는데, 그 스타일시트의 정확한 버전을 어디서 어떻게 구해 와야 UI가 다시 정상으로 돌아오는지 아무도 알아내지 못했어요.
저는 견고하고 유지보수 가능한 프론트엔드 코드를 작성하는 법을 배우게 될 줄 알았는데, 대신 대부분의 개발자들이 프론트엔드에 대해 전혀 신경 쓰지 않는다는 걸 배웠습니다. 제 기대가 너무 순진했던 걸지도 모르지만, 적어도 이제는 이런 감정을 느끼는 게 저 혼자가 아니라는 것은 압니다.
프론트엔드 장인정신에 관심 없는 사람들의 집단과 AI 도구에 신나 하는 사람들의 집단이 얼마나 겹치는지에 대한 불평으로 변질되기 전에 여기서 멈추겠습니다. 네…
스타일시트의 혼돈에 꼭 필요한 질서를 가져오고 싶을 때, 우리 중 많은 사람이 BEM에서 시작(혹은 결국 도착)합니다. 솔직히 BEM은 지금도 꽤 괜찮아요. 단순하고, 지루하고, 예측 가능하고, 신뢰할 수 있죠. 클래스만 봐도 의미가 통하고, specificity는 평평하게 유지되고, 온보딩이 쉽고, 등등. BEM을 팔아야 할 필요도 없겠네요. 그리고 오늘 저는 BEM을 까려는 것도 아닙니다.
BEM은 제 CSS 접근법에 영향을 주었고, 결국엔 더 나은 방법이 있다고 깨닫게 되었지만— 그전에 한 번의 거친 우회로가 있었습니다.
한동안 저는 CSS 클래스에 이름 붙이는 일을 적극적으로 피하던 시기가 있었습니다!
어느 시점에는 (대부분은 농담이지만) 유니크한 클래스 이름을 생성하는 라이브러리도 하나 만들었습니다. 또 다른 시점에는 어떤 클래스도 쓰지 않고(그런데 그뿐만 아니라 div도 쓰지 않고) 웹앱을 통째로 만든 적도 있어요.
클래스를 피하는 건 정당한 전략이 될 수도 있습니다. 하지만 여기서 독단적으로 굴 이유는 없다고 봅니다. 새로움은 시간이 지나면 사라지죠. 저는 UI를 만드는 데 시간을 쓰고 싶지, 클래스를 피하는 창의적인 방법을 떠올리는 데 시간을 쓰고 싶진 않습니다.
그리고 오늘, 이 글의 나머지 내용이 바로 그 지점입니다. 제 결정들 뒤에 있는 생각을 설명해 보려고 합니다. 제가 동의하지 않는 특정 결정을 두고 저를 @ 하기 전에, 이게 엄격한 규칙이 아니라는 점을 기억해 주세요.
솔직히 저는 상황에 따라 “느낌이 맞는” 대로 합니다. 다만 “느낌이 맞는 것”에 대한 정의는 꽤 강한 편이죠. 예를 들어, 원칙의 문제로서 이름을 말할 수 없는 CSS 프레임워크 💨 는 어떤 형태로도 느낌이 맞지 않습니다.
OOCSS와 CUBE 같은 더 유명한 CSS 방법론들도 언급하지 않으면 아쉬울 것 같습니다. 저는 이들을 꽤 깊게 공부했지만 여러 이유로 결국 제게는 잘 맞지 않았습니다. ITCSS가 아마 제가 하는 방식에 가장 가깝지만, 그것에도 전적으로 동의하진 않습니다.
서론은 이쯤 하고…
요즘 저는 모든 것을 컴포넌트 관점으로 생각하는 편입니다. 이 아이디어는 프레임워크 세계에서 빌려 왔지만, 특정 프레임워크의 정의에만 갇혀 있진 않습니다.
저는 전역 스타일(예: CSS 리셋, 디자인 토큰)부터 시작하고, 그 다음부터는 끝까지 컴포넌트입니다.
컴포넌트는 정말 다양한 형태와 크기로 존재합니다— 페이지도 컴포넌트고; 메타 관련 것들도 컴포넌트고; 버튼도 컴포넌트고; 버튼 안의 텍스트와 아이콘도 컴포넌트고; 심지어 스타일시트도 컴포넌트입니다— 존재하는 것이라면 무엇이든 컴포넌트로 만들 수 있어요!
그리고 모든 컴포넌트엔 이름이 필요하죠. 어차피 컴포넌트에 이름을 붙일 거라면, 클래스에도 같은 이름을 쓰는 게 낫습니다. 보통 컴포넌트 이름과 클래스 이름은 1:1로 대응합니다. 파일 이름조차도 컴포넌트 이름(혹은 최소한 그 컴포넌트의 가장 바깥 요소 이름)과 일치합니다.
이름 붙이기 가장 쉬운 건 디자인 컴포넌트입니다. 보통 일반적이고 재사용 가능한 패턴들이죠. 어떤 건 HTML 엘리먼트의 단순한 래퍼일 수도 있고(예: <button>, <input>), 또 어떤 건 더 복잡한 ARIA 기반 패턴일 수도 있습니다(예: menu + menuitem).
대체로 저는 표준 접근성 역할을 기반으로 한 이름을 훨씬 선호합니다. 그러면 의미론을 더 진지하게 생각하게 되거든요. 또 네이밍을 둘러싼 논쟁의 한 부류(말장난 완전 의도했습니다)를 통째로 없애줍니다. “Dialog라고 불러야 해, Modal이라고 불러야 해?” 같은 것들 말이죠. (정답은 늘 Dialog입니다. dialog 역할에서 이름을 따야 하거든요.)
코드 스니펫
.Button {…}
.Input {…}
.Menu {…}
.MenuItem {…}
PascalCase 클래스 이름을 사용하는 건 의도적인데, 제 컴포넌트도 그렇게 이름 짓기 때문입니다. “MenuItem”을 검색하면 컴포넌트와 연관된 CSS의 모든 인스턴스를 한 번에 쉽게 찾을 수 있어요!
(프로젝트가 극도로 진지함을 추구하지 않는다면, 가끔은 소문자 이름을 쓰는 것도 좋아합니다.)
컴포넌트는 종종 여러 요소로 이루어져 있고, 각 요소를 따로 스타일링해야 할 때가 있습니다. 저는 이런 것들을 “서브컴포넌트” 혹은 “파트”라고 부릅니다. 디자인 컴포넌트의 경우, 저는 이런 서브컴포넌트들을 export해서 사용되는 위치에서 수동으로 조립할 수 있게 하는 편입니다. 이렇게 하면 모든 파트가 추상화되어 감춰져 있을 때는 불가능한 수준의 유연성을 얻을 수 있습니다.
이 파트들 또한 컴포넌트이므로 동일한 네이밍 관례를 따르되, 끝에 식별자를 하나 더 붙입니다. 메인 컴포넌트 이름과 구분하기 위해 특수문자를 쓰지는 않습니다. 예를 들어 DialogHeading, DialogContent, DialogActions 같은 클래스들을 Dialog.css 안에 정의할 수 있죠.
접근성 역할은 컴포넌트와 클래스 이름을 짓는 데 탄탄한 기반을 제공하지만, 그게 접근성 역할의 주된 목적은 아닙니다.
같은 종류의 요소/역할이라도 디자인 방향에 따라 완전히 다른 방식으로 표현될 수 있습니다. 예를 들어 list는 카드 그리드로 표현될 수도 있는데, 그런 경우 저는 특화된 CardList와 Card 컴포넌트를 만들 겁니다.
또 의미론적으로는 다른 컴포넌트들 사이에 공유 스타일이 있을 수도 있습니다. 이럴 때는 공통의 상위 역할(혹은 추상적인 역할)이 있어서 그걸 기반으로 삼을 수 있으면 도움이 됩니다. 예를 들어 MenuItem과 TreeItem은 더 저수준의 ListItem 컴포넌트를 통해 코드를 공유할 수 있습니다.
스타일을 공유해야 하는 컴포넌트들 사이에 의미론적 공통점이 없다면, 저는 그냥 스타일을 반복합니다. 그런 경우 유틸리티 클래스를 쓰는 건 피하는데, 그렇게 하면 추상화가 너무 새어 나오는(leaky) 경우가 많다고 생각하기 때문입니다. 브라우저 네이티브 믹스인이 널리 제공된다면 유틸리티 CSS에 대한 생각이 달라질 수도 있겠죠.
컴포넌트는 더 특화된 형태로 조합될 수도 있습니다. 제가 아마 가장 좋아하는 컴포넌트는 IconButton입니다. 이건 Button의 더 특화된 형태로, Button과 많은 시각적 특징(그리고 설정!)을 공유하지만, 크기와 여백 같은 몇몇 영역에서는 다릅니다. 또한 접근 가능한 레이블을 처리하는 방식이 다소 독특한데, 이상적으로는 visually-hidden 기법을 사용합니다. 저는 이 컴포넌트에 툴팁도 내장하는 걸 좋아하지만, 오늘은 그 얘기까진 하지 않겠습니다.
IconButton의 마크업은 대략 이렇게 생깁니다(축약):
코드 스니펫
<button class="Button IconButton">
<svg class="Icon" aria-hidden="true">…</svg>
<span visually-hidden>Label</span>
</button>
분해해 보면:
Button과 IconButton을 둘 다 쓰고 있으며, 후자는 Button 위에 추가 스타일만 담고 있습니다(중복을 피함).Icon은 이 컴포넌트에 특화되지 않은 일반적이고 재사용 가능한 클래스이지만, IconButton은 Icon이 어떻게 동작해야 하는지에 대해 어느 정도 의견을 가질 수 있습니다.visually-hidden을 속성(attribute)으로 쓰기 시작했지만, 클래스여도 똑같이 괜찮습니다.하나의 요소에 여러 클래스가 설정되면, 스타일의 적용 순서가 의미를 갖게 됩니다— 심지어 스타일시트가 진짜로 캐스케이딩을 시작한다고 말할 수도 있겠죠! 같은 파일 안에서는 등장 순서로 해결해도 괜찮다고 생각하지만, 이런 클래스들은 보통 서로 다른 파일에 정의되어 있고, 그러면 더 견고한 방식이 필요합니다.
여기서 Button과 IconButton은 단방향 관계를 가지는데, 후자가 전자에 의존하며 더 구체적입니다. 저는 이 상황을 처리하는 가장 좋은 방법이 :is()로 두 셀렉터를 체이닝하는 것이라고 느꼈습니다. 이 기법은 원래 Nathan에게서 배운 걸로 기억합니다.
코드 스니펫
.IconButton:is(.Button) {…}
끝내줍니다!
컴포넌트는 적용되는 스타일에 영향을 주는 서로 다른 “구성(configuration)”을 가질 수 있습니다. 그중 일부는 순수하게 표현상의 차이입니다. 예를 들면 “outlined” 버튼 vs “chonky” 버튼 같은 거죠. 또 다른 일부는 의미론적으로 중요한데, 예를 들어 “toggled”, “selected”, “expanded” 상태 같은 것들입니다.
이런 시나리오에서는 저는 클래스를 적극적으로 피합니다. 구성은 속성 셀렉터로 다루는 게 가장 좋다고 생각하거든요. 많은 경우, 올바른 접근성을 위해 필요한 HTML 또는 ARIA 속성은 상태를 가지는 의미론적 셀렉터로도 겸용될 수 있습니다.
IconButton을 다시 보면, aria-pressed 속성만 설정하면 “토글”로 바꿀 수 있습니다.
코드 스니펫
<button class="…" aria-pressed="false">…</button>
이 속성이 "true"로 설정되면 다르게 스타일링할 수 있습니다. 저는 CSS 네스팅과 :where()를 함께 사용해 specificity를 평평하게 유지합니다— :is()와 달리 specificity를 추가하지 않거든요.
코드 스니펫
.IconButton:is(.Button) {
// …
&:where([aria-pressed="true"]) {…}
}
모든 구성을 표준 속성으로 표현할 수 있는 건 아닙니다. 그럴 때 저는 (modifier 클래스 대신) data 속성을 쓰는 걸 좋아합니다.
코드 스니펫
.Button {
&:where([data-variant="chonky"]) {…}
}
애니메이션 친화적인 라이브러리들에서도 이 기법을 본 적이 있습니다— JS로 수명이 짧은 data 속성을 설정하고, CSS에서 쉽게 타겟팅하는 식이죠.
부모 요소에 설정된 속성에 따라 서브컴포넌트 파트를 구성해야 할 때는, “리버스 네스팅”을 사용합니다. 여기서도 specificity를 평평하게 유지하기 위해 :where()를 씁니다.
코드 스니펫
.DialogContent {
:where(.Dialog[data-bleed]) & {…}
}
속성 셀렉터를 사용할 때마다, 저는 그것을 메인 클래스와 체이닝합니다. 속성 이름은 전역적으로 유니크하지 않기 때문에 이게 중요합니다.
위에서 보여준 예시들은 Button처럼 꽤 일반적인 이름들을 사용하고 있습니다. 작은 개인 프로젝트에서는 괜찮을 수 있지만, 서드파티 스타일을 다루게 되면 충돌을 일으키기 시작할 수 있습니다. 이때 네임스페이싱이 필요합니다. 가장 단순한 형태로는 모든 클래스에 공통 프리픽스를 포함시키는 겁니다. 예를 들어 🥖-Button 같은 식이죠. 코드베이스의 서로 다른 부분이 서로 다른 네임스페이스를 쓰는 것도 가능합니다! 덜 중요하긴 하지만, data 속성도 이상적으로는 네임스페이스를 갖는 게 좋습니다.
클래스 네임스페이싱은 도구(tooling)에 맡겨서, 소스 코드 전반을 지저분하게 만들지 않고도 쉽게 조정할 수 있는 게 이상적입니다. 하지만 손으로 하는 것도 완전히 괜찮습니다. 특히 불필요한 복잡성을 피하고 싶은 작은 프로젝트라면요.
“디자인 시스템 얘기 그만해, Mayank.”
좋아요, 네, 알겠습니다. 디자인 컴포넌트는 그 자체로는 쓸모가 없고, 결국 페이지 위에 배치되고 맥락과 콘텐츠에 맞게 더 구체적으로 스타일링되어야 합니다. 아마 대부분의 사람에게 가장 흥미로운 부분은 여기겠죠.
하지만 중요한 건 이겁니다:
SaveDialog 컴포넌트를 두는 것도 완전히 괜찮습니다.Utilities가 제 CSS에 있다고요? 맞습니다.
디자인 컴포넌트에서는 유틸리티 CSS를 피하려고 하지만, 레이아웃 같은 다른 영역에서는 유틸리티가 꽤 합리적입니다.
레이아웃은 보통 “보이는” 스타일링이 없습니다. 그 역할은 다른 컴포넌트들을 결합하는 것이죠. media object (원래는 Nicole Sullivan이 만들었습니다)은 한 문제를 아주 잘 해결하는 레이아웃 유틸리티의 고전적인(으) 예시로 자주 언급됩니다. 레이아웃을 재사용 가능한 유틸리티로 추상화하는 더 많은 영감을 원한다면 Every Layout도 참고해 보세요.
유틸리티가 유용하다고 생각하는 또 다른 경우는 디자인 토큰을 일회성으로 적용할 때(예: margin이나 border)입니다. 이 또한 디자인 시스템 영역으로 기우는 이야기라(저도 어쩔 수가 없네요), 여기서는 이쯤 하겠습니다.
유틸리티는 그냥 독립적인 CSS 파일에 정의해 두는 일반 스타일입니다. 저는 디자인 컴포넌트 스타일보다 유틸리티에 더 높은 우선순위를 부여합니다.
하지만 유틸리티가 항상 클래스일 필요는 없습니다. 사실 앞에서 visually-hidden 유틸리티를 속성으로 설정해 둔 예시를 이미 공유했죠.
흥미로운 점 하나는, 유틸리티가 때때로 인라인 커스텀 프로퍼티로 구성된다는 겁니다.
코드 스니펫
<div data-layout="Stack" style="--Stack-gap: var(--space-2)">…</div>
언젠가 더 강력한 attr() 함수가 이를 더 인체공학적으로 만들어 줄까요? 두고 봐야겠죠. 그래도 인라인 스타일로 커스텀 프로퍼티를 전달하는 건 저는 그렇게까지 거슬리진 않습니다.
오늘은 여기까지입니다. 커스텀 프로퍼티는 아마 별도의 글 하나(사실은 여러 개일지도요)가 필요할 것 같네요.