Frontend Dev Log

cn() 함수: 왜 모든 shadcn, radix UI 컴포넌트에 들어갈까?

jinuk-io 2025. 6. 9. 22:25

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()은 무슨 일을 할까?"

 

cn() 함수: 왜 모든 shadcn, radix UI 컴포넌트에 들어갈까?

 

모든 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에서 발견한 패턴

https://medium.com/@enayetflweb/exploring-shadcn-ui-a-journey-to-mastering-modern-ui-design-45010fc8bfbd

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 컴포넌트 사용 예시

 

동작 과정 →

<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()함수 사용 예시

navigatoin을 만들 때, usePathname과 함께 cn함수 사용한 코드 이미지 + 실행 이미지

 

 

끝내는 말

cn()함수는 그냥 클래스 병합용 유틸 같지만, 실을 꽤 중요한 역할을 한다. 복잡한 스타일을 쌓을수록, 코드가 엉키기 쉬운데 이 함수 하나로 그걸 꽤 깔끔하게 정리할 수 있게 된다. 

 

뭐 대단한 기술처럼 보이지는 않아도, 써보면 "아~ 이거 편한데?" 싶은 순간이 분명 온다. 앞으로 컴포넌트 커스터마이징할 때, cn()이 조용히 뒤에서 일을 다 해주고 있다는 거, 한 번쯤 생각해 보는 것도 좋을 거 같다.

 

진짜 일 잘하는 친구라고 생각함.