때는 지난 해 겨울, 팀 내에서 CSS Variants의 타입 미지원에 대한 불편함을 토로하는 목소리가 스멀스멀 새어 나오기 시작했습니다. 우리는 SCSS 문법을 이용하여 컴포넌트의 스타일을 추가하고 있어서 클래스명의 오타를 잡아내거나, 자동완성 등의 기능을 이용할 수 없었기 때문입니다. 마침 저도 같은 불편함을 느끼고 있던 터라, 여러가지 대안을 찾아 야심차게 발을 내딛었습니다. 아마 이 때, 우리의 고생길도 함께 활짝 열렸던 것 같습니다.
Tailwindcss + cva
첫 번째 시도는 tailwindcss였습니다, 그런데 이제 cva를 곁들인..
tailwindcss만으로는 타입 지원에 대한 요구사항을 충분히 만족할 수 없었기 때문에 결정하게 된 조합입니다. 게다가 사내 개발자들이 부트스트랩의 유틸리티 클래스를 유용하게 사용하고 있었기 때문에, 부트스트랩과의 호환성을 고려한 선택이기도 했습니다.
다만 아래의 두가지 이유로 해당 선택은 반려되었습니다.
1. Variants 기반 스타일 정의 방식에 대한 학습 비용
이제는 Variants 기반의 스타일 정의 방식이 꽤나 광범위하게 채택되고 있기 때문에 해당 방식이 CSS를 작성하는 주요 패턴 중 하나로 자리 잡았다고 생각합니다만, 새롭게 패턴을 익히는 데에 발생하는 시간 비용은 결과에 대해 다시 한 번 생각해볼만한 충분한 이유가 되었습니다.
2. 과도하게 길어지는 클래스명
사실 첫 번째 이유는 소소하게 곁들여진 내용에 불과했고, Tailwindcss 사용을 결정적으로 취소한 이유는 바로 클래스명의 길이였습니다. 백 번 양보해서 단순히 개발 과정에서 컴포넌트의 className이 길어지는 문제였다면 감수할 수 있었겠지만, 개발자 모드를 켜고 확인한 DOM 구조는 정말 참고 넘길 수준이 아니라고 판단했습니다.

(사내 디자인 시스템이라서 혹시 몰라 모자이크 처리한 점 양해부탁드립니다.)
해당 자료에서 눈여겨 볼 점은 이게 컴포넌트 하나의 클래스명이라는 것입니다. 얼핏봐도 10줄은 넘어보이네요.
이 상태로 사용자들에게 전달이 되었다가는, 디버깅을 하기 위해 개발자 모드를 켠 개발자들에게 예상치 못한 보물 찾기 이벤트를 선사할 수 있을 것입니다.
tailwindcss를 CSS-in-JS 방식으로 사용할 수 있는 twin.macro
라는 라이브러리를 잠깐 고려해보기도 했지만, 비교적 사이즈가 큰 Emotion이나 styled-components를 추가로 설치해야 했기 때문에 고려 대상에서 제외했습니다.
결국 길을 잃은 우리는 새로운 CSS 라이브러리를 찾아 나섭니다. 기존의 요구사항인 타입 지원 가능 여부에다, 고유한 클래스명 생성이라는 추가 요구사항과 함께 말이죠. 우리는 이 두 가지 요구사항을 만족하려면 CSS-in-JS 방식이 가장 합리적일 것이라고 판단했고, 내부 결정을 통해 최종 고려 대상을 두 가지로 좁혔습니다.
Pandacss
첫 번째는 Pandacss입니다. 최근 관심 있게 찾아보던 ark-ui와 chakra-ui의 메인테이너가 개발한 라이브러리인데, Utility First CSS 방식과 CSS-in-JS 방식을 모두 지원합니다. 아직까지 메이저 버전이 출시되지 않았다는 것이 조금 아쉬웠지만, 그것을 만회할만 한 몇가지 장점이 있다고 판단했습니다.
- 굉장히 활발한 메인테이너의 활동
- 주요 버전 릴리즈가 얼마 남지 않았다는 메인테이너의 언급
- Chakra UI, Ark UI 등, 대형 오픈소스의 사용 예시
- 뛰어난 타입 지원
- 가상 선택자, data attributes 등에 대한 간편한 사용법
그래서 Pandacss로의 마이그레이션을 결정했고, 실제로 도입하게 되었습니다. 만...
문제 1)
실제로 Pandacss를 도입하며 정말(x10) 많은 어려움이 있었습니다. 하나를 해결하고 나면, 또 다른 하나가 발목을 붙잡는 그런 느낌이었습니다. 그 중 하나가 설정 파일에 모든 레시피를 추가해야 하는 것입니다.
Pandacss는 기본적으로 Tailwindcss와 비슷하게 Utility First CSS 방식을 제공하지만, 경우에 따라 고유한 클래스명을 생성할 수 있는 레시피(recipes)를 이용할 수 있습니다. 이 경우에는 특정 파일에서 작성한 레시피를 루트 경로에 있는 panda.config.ts 파일에 모두 추가해주어야 합니다.
// button.recipe.ts
const buttonRecipe = defineRecipe({ ... });
// panda.config.ts
export default defineConfig({
//...
jsxFramework: 'react',
theme: {
extend: {
recipes: {
button: buttonRecipe // import button recipes and add to config
}
}
}
});
뿐만 아니라 각종 토큰들, 그리고 컴포넌트에서 지엽적으로 사용될 애니메이션이나 keyframes 등, 모든 요소를 config 파일에 추가해야 하는데, 이는 내부적으로 결정한 co-location 원칙을 위배하는 사용법이었습니다. 또 컴포넌트 하나에 대한 관리 포인트가 여기저기 흩어져 있다는 것도 불편한 부분 중 하나였습니다.
문제 2)
레시피를 이용할 때, 정의한 레시피에 대한 타입이 아래와 같이 함께 제공됩니다.
// button.recipe.ts
const buttonRecipe = defineRecipe({
variants: {
size: { sm: {}, md: {}, lg: {} },
variant: { neutral: {}, positive: {}, critical: {} },
},
});
// button.d.ts
type ButtonVariants = {
size: 'sm' | 'md' | 'lg';
variant: 'neutral' | 'positive' | 'critical';
};
export type ButtonVariantProps = {
[key in keyof ButtonVariant]?: ConditionalValue<ButtonVariant[key]> | undefined;
};
우리는 내부 로직 상, ButtonVariants를 사용하고 싶었지만, 보시는 것과 같이 밖으로 내보내지 않고 있습니다. 그래서 ButtonVariantProps를 사용하면서, 각 프로퍼티 타입에 붙어있는 ConditiontalValue 타입을 제거하는 과정이 필수적으로 동반됩니다. 이 부분이 굉장히 불필요하고 번거롭다고 느껴졌어요.
Chakra나 Ark UI의 Pandacss 사용 예시에 매몰된 채 생각한 탓인지는 몰라도, 사용자로부터 Props를 전달 받는 위치나 토큰명 등 Pandacss를 사용하면서 강제되는 부분이 꽤 많다고 느낄만큼 라이브러리 수준을 넘어선 일종의 프레임워크 같다고도 생각했습니다. 이외에도 자잘한 문제들을 겪으며, 결국 Pandacss를 도입하는 것은 무리라고 결론 내렸습니다.
Vanilla Extract
그래서 이번에는 Vanilla Extract에 주목했습니다. Vanilla Extract는 최근 주목받고 있는 Zero Runtime CSS 라이브러리 중 하나이며, 그중에서도 특히 많은 사랑을 받고 있습니다. 활발한 커뮤니티와 꾸준한 업데이트 덕분에, 신뢰할 수 있다고 판단하기도 했습니다.
다만, 앞서 두 번의 선택 번복을 경험한 우리는 조금 더 신중할 필요가 있었습니다. 그래서 각자 기술 스파이크를 진행했고, 그 결과를 바탕으로 다시 한 번 논의한 끝에 Vanilla Extract를 도입하기로 최종 결정하게 되었습니다.
참고로 선택의 이유는 다음과 같습니다.
- CSS in Typescript라는 점에서 우수한 타입 지원
- 빌드 타임에 CSS를 생성하여 뛰어난 성능
- Zero Runtime CSS 라이브러리들 중 가장 활발한 커뮤니티
- 간단한 초기 설정
- 자유로운 Theme Token 설정
개인적으로 인상 깊었던 것은 4번과 5번인데, Pandacss와 비교했을 때 더욱 크게 와닿는 부분들입니다. 레시피를 설정 파일에 모아줄 필요도 없었고, 라이브러리 단에서 지정한 토큰명을 사용하도록 강제되지도 않았습니다. 새롭게 디자인 시스템을 구축하는 것이 아니라, 기존의 디자인 시스템을 기반으로 도구만 갈아 끼우는 작업이었기 때문에 이 부분도 정말 중요한 부분이었습니다.
Variants 기반의 스타일 정의 방식이 어색하다는 점과 자식 선택자 사용이 불편하다는 점은 여전히 걸림돌이었습니다만, 이 두 가지 모두 유지보수성과 안정성을 위한 트레이드오프라고 생각했습니다. 참고로 공식 문서에서도 자식 선택자 사용을 제한한 이유에 대해 설명하고 있는데, 개인적으로 꽤 설득되었습니다. 그래서 이 부분에 대해서는 더이상 불만 갖지 않기로 했습니다. ㅋㅋ
결론
CSS 도구 선정을 위해 정말 많은 도구를 찾아봤습니다. 확실히 모든 기술에는 저마다의 장단점이 있었고, 어느 하나 정답이라고 할 수는 없겠더라고요. 그래서 어떤 문제를 해결하고자 하는지 명확히 인지하는 것이 정말 중요했습니다. 이번 경험에서도 CSS Variants에 대한 타입 지원이라는 분명한 기준이 없었더라면, 선택하는 것이 더욱 힘들었을 것 같아요.
또한, 최선이라 생각했던 선택이 최선이 아님을 깨닫게 되는 순간도 있습니다. 복잡하고 고차원적인 사용 사례에서는 기존에 보이지 않았던 불편함이 드러나기도 하고, 개인마다 불편함을 느끼는 지점이 다르기 때문입니다. 이런 경우에 기존 선택을 고수하기보다는, 새로운 기술을 선택할 때 발생하는 비용과 기존 문제를 해결하는 비용을 고려해서 빠르게 전환할 수 있는 용기도 필요하다고 느꼈습니다.
이런 과정을 통해 어떤 기준과 요소를 더 세세하게 고려해야 하는지 배울 수 있고, 이는 다음 선택에서도 분명히 큰 도움이 될 것입니다.
아무튼 이런저런 시행착오를 겪으며, 지금은 Vanilla Extract에 정착했습니다. 많이 고생했던만큼 꽤 만족스럽게 CSS를 적용하고 있기도 해요. 혹시 유틸리티 클래스 방식의 긴 클래스네임이나, 런타임 CSS-in-JS의 성능에 대해 아쉬움을 느끼는 분들이 계신다면, 이 글이 도움이 되길 바라며 마무리 하겠습니다.
긴 글 읽어주셔서 감사합니다!