Skip to main content

cn()을 왜 쓰는가?

Updated 7 min readMarkdown

시작하기 전에

이전 글(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은 늘 마지막에 와야 한다.

참고 자료

1 view

No comments yet. Be the first to comment.

More from this blog