cn() 함수: 왜 모든 shadcn, radix UI 컴포넌트에 들어갈까?
shadcn/ui나 Radix UI를 사용해보신 분이라면 이런 의문이 한 번쯤 들었을 거라고 생각한다.
// shadcn/ui의 avatar ui 코드
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
"Tailwind로 UI 작업을 하다 보면 꼭 등장하는 cn()은 무슨 일을 할까?"
모든 shadcn 컴포넌트와 Radix UI 기반 라이브러리에서 이 함수를 볼 수 있다. 그리고 뭐.. 가끔 바이브 코딩하다가 AI가 cn()을 이용해 css 부분을 짜준 경험도 있을 거라고 생각한다. 그리고 cn()함수를 따라가다 보면, 이런 코드를 볼 수 있다.
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// This function is used to parse and stringify data
export const parseStringify = <T>(data: T): T => {
return JSON.parse(JSON.stringify(data));
}
겨우 몇 줄 짜리 함수인데 왜 이렇게 모든 곳에서 사용하는 걸까? 오늘은 그 이유에 대해 살펴보도록 하겠다.
shadcn/ui에서 발견한 패턴
shadcn ui 컴포넌트를 들여다 보면 공통점이 있다.
// Button 컴포넌트
<Button className="bg-red-500">기본 버튼</Button>
// Card 컴포넌트
<Card className="shadow-lg border-2">커스텀 카드</Card>
// Input 컴포넌트
<Input className="border-2 border-blue-500" placeholder="이메일을 입력하세요" />
모든 컴포넌트가 className prop을 받아서 기본 스타일 위에 추가 스타일을 덮어쓸 수 있게 할 수 있다. 이게 바로 shadcn/ui가 인기 있는 이유 중 하나라고 생각한다.
"기본 디자인은 정해져있지만, 필요할 때마다 자유롭게 커스터마이징 할 수 있다는 점"
그런데 문제가 있다.
단순히 className을 합치는 것만으로는 한계가 있다는 점이다.
문제 1. Tailwind 클래스 충돌
const baseStyles = "text-sm bg-blue-500 px-4";
const userStyles = "text-lg bg-red-500 px-6";
const result = `${baseStyles} ${userStyles}`;
이 경우 어떤 글자 크기와 배경색이 적용될까?
→ text-lg와 bg-red-500이 마지막에 선언되어서 적용된다.
같은 속성(text-, bg-, px-)이 중복되면, 나중 클래스가 덮어쓰므로 기본 스타일은 무시되고 코드만 길어지는 상황이 발생한다.
문제 2. 조건부 스타일 처리
컴포넌트에 여러 조건부 스타일을 적용해야 할 때도 문제가 생긴다. 템플릿 리터럴로 처리하면 가독성이 떨어지고 실수하기도 매우 쉬운 환경이 되어버린다.
// 이런 코드는 읽기 힘듦
<Button
className={`
${variant === 'destructive' ? 'bg-red-500 text-white' : ''}
${size === 'lg' ? 'px-8 py-3 text-lg' : ''}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className || ''}
`}
>
복잡한 버튼
</Button>
공백 처리도 신경써야 하고, 조건이 많아질수록 코드가 복잡해진다.
이런 불편한 점을 해결하기 위한 cn() 함수
shadcn과 Raid UI에서 자주 볼 수 있는 해답이 이 cn() 함수다.
cn()함수는 여러 개의 Tailwind 클래스가 충돌하거나 조건부로 클래스명을 설정해야 할 때 유용하다. 예를 들어, 기본 스타일과 사용자 커스텀 스타일이 중복되면 어떤 클래스가 실제로 적용될지 예측하기 어려줘지는 문제를 해결해준다.
cn("text-sm", "text-lg"); // → "text-lg"
이처럼 가장 마지막에 오는 text-lg만 적용되도록 클래스 병합과 충돌 방지를 자동으로 처리해준다.
프로젝트에서 이 cn()함수를 사용하고 싶으면, 아래처럼 shadcn/ui 초기화 명령어로 쉽게 시작할 수 있다.
npx shadcn@latest init
이 명령어를 실행하면 lib/utils.ts 파일이 생성되며, 그 안에는 다음과 같은 cn() 함수가 포함된다,.
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
이 함수는 내부적으로
- clsx()를 통해 조건부 클래스를 조합하고,
- twMerge()를 통해 Tailwind 클래스의 중복 충돌을 제거한다.
결과적으로, cn() 하나만으로도 Tailwind를 사용할 때 발생할 수 있는 클래스 병합 문제를 깔끔하게 해결할 수 있다.
clsx: 조건부 클래스 처리
tailwind-merge: Tailwind 클래스 충돌 해결
shadcn Button의 실제 구현
const Button = React.forwardRef(({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(
// 기본 스타일
"inline-flex items-center justify-center rounded-md font-medium ring-offset-background transition-colors",
// variant별 스타일
{
"bg-primary text-primary-foreground hover:bg-primary/90": variant === "default",
"bg-destructive text-destructive-foreground hover:bg-destructive/90": variant === "destructive",
"border border-input bg-background hover:bg-accent": variant === "outline",
},
// size별 스타일
{
"h-10 px-4 py-2": size === "default",
"h-9 rounded-md px-3": size === "sm",
"h-11 rounded-md px-8": size === "lg",
},
// 사용자 정의 스타일 (마지막에 와서 덮어씀)
className
)}
ref={ref}
{...props}
/>
)
})
동작 과정 →
<Button variant="destructive" size="lg" className="bg-purple-500 px-12">
커스텀 버튼
</Button>
1단계 - clsx 처리 : 조건부 스타일들은 모두 합쳐서 하나의 문자열로 만든다.
"inline-flex items-center justify-center rounded-md font-medium ring-offset-background transition-colors bg-destructive text-destructive-foreground hover:bg-destructive/90 h-11 rounded-md px-8 bg-purple-500 px-12"
2단계 - tailwind-merge 처리: 충돌하는 Tailwind 클래스들을 찾아서 뒤에 있는 것만 남긴다.
"inline-flex items-center justify-center rounded-md font-medium ring-offset-background transition-colors text-destructive-foreground hover:bg-destructive/90 h-11 bg-purple-500 px-12"
결과는 이러하다.
- bg-destructive → bg-purple-500 (배경색 덮어씀)
- px-8 → px-12 (패딩 덮어씀)
- 나머지 스타일은 그대로 유지
이런 과정을 통해 사용자가 원하는 커스텀 스타일이 적용되는 것이다.
cn() 방식은 왜 혁신적일까?
1. 예측 가능한 클래스 병합
cn("bg-blue-500", "bg-red-500") // → "bg-red-500"
cn("text-sm", "text-lg") // → "text-lg"
cn("p-4", "px-8") // → "py-4 px-8"
- 충돌되는 Tailwind 클래스는 항상 마지막 값이 우선 적용된다.
- 스타일 오버라이드가 명확하고 예측 가능하다.
2. 컴포넌트 재사용성 극대화
<Button variant="default">기본 버튼</Button>
<Button
variant="default"
className="bg-gradient-to-r from-purple-400 to-pink-400"
>
그라데이션 버튼
</Button>
<Button
variant="default"
className="rounded-full border-2 border-dashed"
>
완전 커스텀 버튼
</Button>
- 같은 컴포넌트로도 상황에 따라 다양한 스타일을 손쉽게 커스터마이징이 가능하다.
- 스타일 덮어쓰기 시에도 충돌 없이 적용됨.
3. 디자인 시스템 일관성
<Card className="p-6 shadow-sm">
<CardHeader className="pb-3">
<CardTitle className="text-lg">제목</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Button className="w-full bg-gradient-to-r from-blue-500 to-purple-600">
특별한 버튼
</Button>
</CardContent>
</Card>
- 기본 디자인 시스템 구조를 유지하면서도
- 필요할 때만 특정 스타일만 선택적으로 덮어쓸 수 있어 유지보수성과 확장성 모두 확보 가능.
실제 cn()함수 사용 예시
끝내는 말
cn()함수는 그냥 클래스 병합용 유틸 같지만, 실을 꽤 중요한 역할을 한다. 복잡한 스타일을 쌓을수록, 코드가 엉키기 쉬운데 이 함수 하나로 그걸 꽤 깔끔하게 정리할 수 있게 된다.
뭐 대단한 기술처럼 보이지는 않아도, 써보면 "아~ 이거 편한데?" 싶은 순간이 분명 온다. 앞으로 컴포넌트 커스터마이징할 때, cn()이 조용히 뒤에서 일을 다 해주고 있다는 거, 한 번쯤 생각해 보는 것도 좋을 거 같다.
진짜 일 잘하는 친구라고 생각함.