시작하기 전에
이전 글(tailwind-merge에서 클래스네임은 어떻게 제어되는가?)에서 twMerge가 내부적으로 어떻게 클래스 충돌을 해소하는지 소스코드 레벨로 살펴봤다.
이번 글은 그 후속편으로, shadcn을 쓰면 항상 따라오는 cn() 함수가 무엇이고 왜 이런 모양인지를 다룬다.
cn()의 정체는 shadcn/ui 저장소의 lib/utils.ts에 그대로 들어있다.
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
cn 함수는 clsx라는 함수로 한번, twMerge라는 함수로 한번 더 감싸고 있는데, 왜 이렇게 되어 있을까?
className을 직접 조합하는 경우
예를 들어 버튼 컴포넌트를 만든다고 할 때, 이런 식으로 쓸 수 있다.
<button className={`btn ${isActive ? 'bg-blue-500' : ''}`}>
클릭
</button>
지금 상태는 괜찮지만 추후에 조건이 더 늘어나면 다음과 같은 형태로 가독성이 떨어질 우려가 있다. 설령 줄바꿈을 하게 된대도 말이다.
<button
className={`btn ${isActive ? 'bg-blue-500' : ''} ${isDisabled ? 'opacity-50' : ''} ${isLarge ? 'px-8 py-4' : 'px-4 py-2'}`}
>
Tailwind 클래스 충돌
// 기본 패딩 p-4가 있는 컴포넌트
<Button className="p-2">클릭</Button>
// className = "p-4 p-2" — 어떤 게 이길까?
앞선 글에서 작성했던 것처럼 Button이라는 컴포넌트에 기본 스타일이 있다는 것을 고려하지 않으면 덮어쓰려고 했다가 디버깅도 쉽지 않다.
clsx는 조건부 클래스를 어떻게 처리할까
clsx 함수의 주요 로직은 다음과 같다.
export function clsx() {
var i=0, tmp, x, str='', len=arguments.length;
for (; i < len; i++) {
if (tmp = arguments[i]) { // falsy 값 필터링
if (x = toVal(tmp)) {
str && (str += ' '); // 내용이 있을 때만 공백 추가
str += x
}
}
}
return str;
}
// lukeed/clsx — clsx.d.ts
type ClassValue = ClassArray | ClassDictionary | string | number | bigint | null | boolean | undefined;
실제로 코드로 확인해보면 다음과 같다.
// clsx는 false를 그냥 버린다
clsx('btn', isActive && 'bg-blue-500') // isActive=false → "btn"
// falsy 값은 전부 무시된다
clsx('foo', null, undefined, false, 0, '', 'bar')
// => "foo bar"
// value가 truthy인 키만 살아남는다
clsx({ 'bg-blue-500': true, 'opacity-50': true, 'hidden': false })
// => "bg-blue-500 opacity-50"
// 배열도 가능 (중첩 포함)
clsx(['foo', isActive && 'bar'])
clsx만으로는 클래스 충돌을 잡지 못한다
clsx는 문자열을 합치기만 한다. Tailwind 클래스가 서로 충돌하는지는 전혀 알지 못한다.
clsx('p-4', 'p-2')
// => "p-4 p-2" (둘 다 살아있음)
twMerge가 인자를 받는 방식
twMerge는 이렇게 정의되어 있다.
// src/lib/tw-merge.ts
export const twMerge = createTailwindMerge(getDefaultConfig)
여기서 createTailwindMerge의 반환값은 다음과 같다.
// src/lib/create-tailwind-merge.ts
return (...args: ClassNameValue[]) => functionToCall(twJoin(...args))
정리하자면 twMerge 는 인자를 여러 개 받고, 내부에서 twJoin 으로 합친 다음 병합하는 로직을 돌리는 방식이다.
twJoin은 왜 clsx를 베껴놓고 객체 분기만 뺐을까
그럼 twJoin은 뭘까? 파일 상단 주석에 답이 그대로 적혀 있다.
// src/lib/tw-join.ts
/**
* The code in this file is copied from https://github.com/lukeed/clsx and modified
* to suit the needs of tailwind-merge better.
* - Runtime code from https://github.com/lukeed/clsx/blob/v1.2.1/src/index.js
*/
clsx를 가져와서 수정한 것이라고 명시되어 있다. 그런데 clsx 원본과 나란히 놓고 비교해보면, 실질적으로 결정적인 차이가 하나 있다.
// clsx의 toVal — 분기 3개
if (typeof mix === 'string' || typeof mix === 'number') { ... } // 문자열/숫자
else if (Array.isArray(mix)) { ... } // 배열
else { for (y in mix) { ... } } // 객체 (O)
twJoin에는 객체 처리 분기가 아예 없다. 타입 정의를 봐도 마찬가지다.
// twJoin — 객체 타입이 없다
type ClassNameValue = ClassNameArray | string | null | undefined | 0 | 0n | false
// clsx — ClassDictionary(= Record<string, any>)가 있다
type ClassValue = ClassArray | ClassDictionary | string | number |
// ^^^^^^^^^^^^^^^
bigint | null | boolean | undefined
그래서 cn()에 clsx가 필요하다
실제로 실행해서 비교하면 이렇다.
// twMerge도 가변인자와 falsy는 처리한다 (twJoin 덕분)
twMerge('btn', false && 'bg-blue-500')
// => "btn" 문제없음
// 하지만 객체 문법은 조용히 무시된다
twMerge('btn', { 'bg-blue-500': true })
// => "btn" bg-blue-500이 사라졌다
// clsx를 거치면 객체도 정상 처리된다
twMerge(clsx('btn', { 'bg-blue-500': true }))
// => "btn bg-blue-500"
twMerge에 객체를 넘기면 에러가 나지 않고 조용히 무시된다. 이게 특히 위험하다. 타입스크립트를 쓰면 아래처럼 타입 에러로 잡히지만, 그렇지 않으면 디버깅이 까다로운 버그가 된다.
error TS2353: Object literal may only specify known properties, and ''bg-blue-500'' does not exist in type 'ClassNameArray'
정리하면 이렇다. cn()이 clsx를 한 겹 더 씌우는 이유는 객체 문법을 쓰기 위해서다. { 클래스명: 조건 } 이 문법은 조건이 많아질수록 가독성이 확 좋아진다.
// 객체 문법 — clsx가 있어야 동작한다
cn('btn', {
'bg-blue-500': variant === 'primary',
'bg-gray-100': variant === 'secondary',
'opacity-50': disabled,
})
cn() = clsx + tailwind-merge
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
shadcn을 설치하면 이 파일이 자동으로 생성된다.
clsx가 먼저, twMerge가 나중
순서가 바뀌면 안 된다.
먼저 clsx(inputs)가 객체·배열·falsy 값을 정리해 하나의 문자열로 만든다 그다음 twMerge(...)가 그 문자열에서 Tailwind 클래스 충돌을 해소한다
twMerge는 객체를 이해하지 못하므로, 반드시 clsx가 먼저 문자열로 평탄화해줘야 한다.
또 하나, clsx(inputs)에서 스프레드(...inputs)가 아니라 배열 그대로 넘기는 점도 눈여겨볼 만하다. clsx의 toVal이 배열을 재귀적으로 처리하니 결과는 동일하다.
// 1. clsx는 충돌을 못 잡는다
clsx('p-4', 'p-2') // => "p-4 p-2"
// 2. twMerge는 충돌을 잡는다
twMerge('p-4 p-2') // => "p-2"
// 3. twMerge는 객체를 못 읽는다
twMerge('btn', { 'p-2': true }) // => "btn"
// 4. cn()은 둘 다 된다
cn('p-4', { 'p-2': true }) // => "p-2"
순서가 결정적이다
외부 className은 항상 마지막에
twMerge는 뒤에 오는 클래스를 우선시한다. 따라서 외부에서 받은 className은 반드시 마지막에 와야 한다.
// 올바른 순서 — 뒤에 온 bg-red-500이 이긴다
cn('bg-blue-500 px-4', 'bg-red-500')
// => "px-4 bg-red-500"
// 잘못된 순서 — 내가 넘긴 bg-red-500이 사라진다
cn('bg-red-500', 'bg-blue-500 px-4')
// => "bg-blue-500 px-4"
컴포넌트로 옮기면 이렇다.
// O — className이 마지막
<button className={cn('bg-blue-500 px-4', className)} />
// X — 기본 스타일이 외부 className을 덮어쓴다
<button className={cn(className, 'bg-blue-500 px-4')} />
충돌 관계는 단방향이다
twMerge('px-4 p-8') // => "p-8" (p가 px를 덮어씀)
twMerge('p-8 px-4') // => "p-8 px-4" (px는 p를 못 덮음, 둘 다 남음)
p가 px를 충돌로 등록하고 있지만 그 반대는 아니기 때문이다.
shadcn은 이 순서를 어떻게 보장하는가
shadcn Button 컴포넌트에서 cn()이 쓰이는 부분만 떼어보면 이렇다.
// shadcn-ui/ui — button.tsx
const buttonVariants = cva("inline-flex shrink-0 items-center ...", {
variants: {
variant: { default: "bg-primary text-primary-foreground ...", /* ... */ },
size: { default: "h-9 px-4 py-2 ...", /* ... */ },
},
defaultVariants: { variant: "default", size: "default" },
})
function Button({ className, variant = "default", size = "default", ...props }) {
return (
<Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />
)
}
핵심은 이 한 줄이다.
className={cn(buttonVariants({ variant, size, className }))}
className을 cn()의 두 번째 인자로 넘기는 게 아니라, buttonVariants() 안으로 넘긴다. 왜 이렇게 할까?
// joe-bell/cva — src/index.ts (cva 마지막 return 발췌)
return cx(
base, // 1. 기본 스타일
getVariantClassNames, // 2. variant 스타일
getCompoundVariantClassNames, // 3. compoundVariant 스타일
props?.class,
props?.className, // 4. 외부 className ← 항상 마지막
);
props?.className이 가장 마지막 인자로 들어간다. cva가 이미 "base → variant → compoundVariant → className" 순서를 보장해주니, className을 cva에 넘기기만 하면 순서가 알아서 맞춰진다.
참고로 cva의 cx는 그냥 clsx다.
export const cx = clsx;
그래서 최종 흐름은 이렇게 된다.
cva({ variant, size, className }) → 올바른 순서로 문자열 조립 (내부적으로 clsx)
↓
cn(...) → clsx로 한 번 더 정리 + twMerge로 충돌 해소
↓
최종 className
사용하는 쪽에서는 className prop 하나만 넘기면 된다.
<Button className="w-full bg-red-500">전체 너비 빨간 버튼</Button>
variant 조합이 복잡해질수록 이 구조가 힘을 발휘한다. cva가 순서를 책임지고, cn()은 충돌 해소만 담당하기 때문이다.
정리
cn()은 거창한 함수가 아니다. 함수 본문 한 줄짜리 래퍼일 뿐이다. 그래도 왜 이런 모양인지 따라가 보면 몇 가지가 남는다.
clsx는 객체·배열·falsy를 정리해 문자열로 만들고, tailwind-merge는 그 문자열에서 클래스 충돌을 해소한다. 그런데 소스를 열어보니 twMerge도 가변인자와 falsy는 이미 처리하고 있었다. 내부의 twJoin이 clsx를 복사해 만든 함수라서 그렇다. 다만 그 twJoin이 객체 문법만 쏙 빼놓았고, 그래서 cn()은 clsx를 한 겹 더 씌운다.
마지막으로 인자 순서. twMerge가 뒤에 오는 클래스를 우선시하니 외부 className은 늘 마지막에 와야 한다.
참고 자료
Comments
Join the discussionNo comments yet. Be the first to comment.


