웹 애플리케이션, 특히 React와 같은 컴포넌트 기반 라이브러리 혹은 프레임워크를 사용하여 개발할 때, HTML 요소의 클래스 이름을 동적으로 조합해야 하는 상황은 빈번하게 발생한다. 상태 변화에 따른 스타일 변경, 사용자 설정 기반 테마 적용 등이 대표적인 예시이다. 이러한 경우 개발자는 주로 JavaScript의 템플릿 리터럴(백틱 ``) 방식 또는 cn과 같은 유틸리티 함수 활용 방식 중 하나를 선택하게 된다.
특히 최근 shadcn/ui와 같은 라이브러리의 사용이 증가하면서, 내부적으로 cn 함수를 활용하는 코드를 접하는 일이 많아졌다.
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
// cn 함수의 일반적인 구현 예시
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
본 글에서는 이 두 가지 방식, 즉 템플릿 리터럴과 cn 함수의 명확한 차이점을 비교하고, cn 함수의 핵심 기능 중 하나인 tailwind-merge가 구체적으로 어떤 상황에서 어떻게 동작하는지에 대해 심도 있게 분석한다.
1. 기본 방식: 템플릿 리터럴 (`)
ES6(ECMAScript 2015)에서 도입된 템플릿 리터럴은 JavaScript의 기본적인 문자열 처리 문법이다. 백틱(``)으로 문자열을 감싸고, ${expression} 형식을 통해 문자열 내부에 변수나 표현식을 삽입하는 방식은 단순한 클래스 조합에 있어 매우 직관적이다.
function Button({ intent, size, className }) {
const intentClasses = intent === 'primary' ? 'bg-blue-500 text-white' : 'bg-gray-200 text-gray-800';
const sizeClasses = size === 'large' ? 'py-3 px-6' : 'py-2 px-4';
// 템플릿 리터럴을 이용한 클래스 조합
return (
<button
className={`base-button ${intentClasses} ${sizeClasses} ${className || ''}`}
>
클릭
</button>
);
}
템플릿 리터럴의 장점은 명확하다.
- 별도의 라이브러리 설치 없이 JavaScript 내장 기능을 사용한다.
- 단순 문자열 조합의 경우, 가독성이 높고 이해가 용이하다.
그러나 실제 개발 환경에서는 몇 가지 한계점이 존재한다.
- 조건부 클래스 처리의 번거로움: condition ? 'class-name' : '' 형태의 삼항 연산자를 반복 사용하면 코드 가독성이 저하될 수 있다. 또한, false, null 등의 값이 의도치 않게 문자열에 포함될 가능성이 있다.
- 불필요한 공백 발생: 위 예시와 같이 className 프롭이 제공되지 않을 경우, 결과 문자열 끝에 불필요한 공백이 남을 수 있다.
- Tailwind CSS 사용 시의 결정적 단점: 클래스 간의 충돌 문제를 해결하지 못한다. 예를 들어, p-2 p-4와 같이 동일한 CSS 속성을 제어하는 클래스를 연속으로 사용할 경우, 두 클래스 모두 HTML 요소의 class 속성에 포함된다. 브라우저는 CSS 명시도(specificity)와 선언 순서에 따라 최종 스타일을 결정하는데, 이는 개발자의 의도와 다른 결과를 초래할 수 있다. 이는 결과적으로 padding: 0.5rem; padding: 1rem;과 유사한 CSS 규칙이 적용되는 것과 같다.
2. 효율적 대안: cn 유틸리티 함수
템플릿 리터럴의 단점을 보완하기 위해 cn (또는 clsx 등 유사한 이름의) 유틸리티 함수가 사용된다. 이 함수는 클래스 이름 문자열을 조건부로 결합하고 정리하는 데 특화된 기능을 제공한다. 많은 라이브러리에서 제공하는 cn 함수는 내부적으로 clsx와 tailwind-merge라는 두 유틸리티를 조합하여 구현된다.
- clsx: 조건부 클래스 적용 로직을 간결하게 작성하도록 지원한다. false, null, undefined와 같은 값은 결과에서 자동으로 제외하여 코드 안정성을 높인다.
- tailwind-merge: cn 함수의 핵심 기능으로, 서로 충돌하는 Tailwind CSS 유틸리티 클래스를 지능적으로 병합하여 최종 클래스 목록을 최적화한다.
앞서 살펴본 Button 컴포넌트를 cn 함수를 사용하여 리팩토링하면 다음과 같다.
import { cn } from "@/lib/utils"; // 프로젝트 내 cn 함수 경로 가정
function Button({ intent, size, className }) {
return (
<button
className={cn(
'base-button', // 기본 클래스
'p-2', // 초기 패딩 값 (p-4에 의해 병합/제거됨)
{ // 객체 리터럴을 통한 조건부 클래스 관리
'bg-blue-500 text-white': intent === 'primary',
'bg-gray-200 text-gray-800': intent !== 'primary',
'py-3 px-6': size === 'large',
'py-2 px-4': size !== 'large',
},
'p-4', // 패딩 재정의 (tailwind-merge가 p-2 대신 p-4를 최종 결과에 포함)
className // 외부 주입 클래스 또한 병합 로직 대상
)}
>
클릭
</button>
);
}
// 최종 결과 문자열 예시 (intent='primary', size='large', className='m-1' 조건):
// "base-button bg-blue-500 text-white py-3 px-6 p-4 m-1"
// (p-2는 p-4와의 충돌로 인해 tailwind-merge에 의해 자동 제거됨)
cn 함수의 장점은 다음과 같다.
- 조건부 클래스 로직이 간결해지고 가독성이 향상된다.
- false, null, undefined 값의 자동 필터링으로 오류 발생 가능성을 줄인다.
- 핵심 장점: tailwind-merge를 통해 Tailwind 클래스 충돌을 자동으로 해결하여 예측 가능한 스타일링을 보장한다. 예를 들어, p-2 뒤에 p-4가 입력되면 최종 결과에는 p-4만 남게 된다.
- 문자열, 객체, 배열 등 다양한 형식의 입력을 유연하게 처리할 수 있다.
단점은 아래와 같다.
- clsx, tailwind-merge와 같은 외부 라이브러리에 대한 의존성이 발생한다. (단, shadcn/ui와 같은 환경에서는 이미 내장된 경우가 많다.)
tailwind-merge: 동작 원리 분석
tailwind-merge는 단순한 문자열 결합 이상의 기능을 수행한다. Tailwind CSS의 유틸리티 클래스 시스템을 이해하고, 클래스 간 충돌을 해결하는 정교한 로직을 내장하고 있다.
- 클래스 분리 및 분석: 입력된 문자열(예: 'p-2 bg-red-500 p-4')을 개별 클래스('p-2', 'bg-red-500', 'p-4') 단위로 분리한다.
- 그룹화: 분리된 클래스들을 Tailwind CSS 규칙에 기반하여 동일한 CSS 속성을 제어하는 그룹으로 분류한다. 예를 들어, p-2, p-4, px-2, py-3 등은 'padding' 관련 그룹으로, bg-red-500, bg-blue-500은 'background-color' 관련 그룹으로 인식한다.
- 우선순위 결정 및 병합: 각 그룹 내에서 최종적으로 적용될 클래스를 결정한다. 일반적으로 가장 마지막에 선언된 클래스가 우선순위를 가진다. 예를 들어, 'p-2 p-4'가 입력되면 'padding' 그룹 내에서 p-2와 p-4가 충돌하며, 나중에 선언된 p-4가 최종 선택된다. 만약 'text-red-500 text-xl text-blue-500'이 입력되면, 'color' 그룹에서는 text-blue-500이 선택되고, 'font-size' 그룹의 text-xl은 충돌 대상이 없으므로 그대로 유지된다.
- 최종 조합: 각 그룹에서 선택된 최종 클래스들을 조합하여 하나의 문자열로 반환한다.
실행 시점의 이해: 빌드 타임 vs. 런타임
cn 함수의 정확한 동작 시점을 이해하는 것은 매우 중요하다. 이는 Tailwind CSS 자체의 처리 시점과 구분하여 파악해야 한다.
1. Tailwind CSS: 빌드 타임 처리
Tailwind CSS의 주요 특징 중 하나는 빌드 시점에 최적화된 CSS 파일을 생성한다는 점이다.
프로젝트 빌드 과정에서, Tailwind는 설정 파일(tailwind.config.js)에 명시된 경로의 소스 코드(JSX, HTML, Vue 등)를 스캔하여 사용된 모든 Tailwind 유틸리티 클래스를 식별한다.
JIT(Just-in-Time) 모드는 프로젝트 내에서 실제로 사용된 클래스에 해당하는 CSS 규칙만을 동적으로 생성한다. 예를 들어, rotate-90 클래스가 프로젝트 내에 없다면, 최종 생성되는 CSS 파일에는. rotate-90 { ... } 규칙이 포함되지 않는다. 이는 최종 CSS 번들 크기를 최소화하는 핵심 최적화 과정이다.
이렇게 생성된 최적화된 정적 .css 파일은 일반적인 CSS 파일과 동일하게 HTML 문서에 <link> 태그를 통해 포함된다.
Tailwind CSS 규칙의 생성 자체는 빌드 타임에 완료된다. 사용되지 않는 스타일은 최종 결과물에 포함되지 않으므로, 초기 로딩 성능에 긍정적인 영향을 미친다.
2. 브라우저: 런타임 렌더링
빌드 타임에 생성된 Tailwind CSS 파일은 브라우저에 의해 런타임에 처리된다. 브라우저는 다음과 같은 렌더링 파이프라인을 통해 화면을 구성한다.
- HTML 파싱 → DOM(Document Object Model) 트리 생성
- CSS 파싱 (Tailwind CSS 파일 포함) → CSSOM(CSS Object Model) 트리 생성
- DOM + CSSOM 결합 → 렌더 트리(Render Tree) 생성 (실제 화면에 표시될 요소와 해당 스타일 정보 결합)
- 레이아웃(Layout/Reflow) 계산 (각 요소의 화면상 위치 및 크기 결정)
- 페인팅(Painting) (계산된 정보를 기반으로 실제 픽셀을 화면에 그림)
빌드된 Tailwind CSS 파일 내의 스타일 규칙들이 실제로 DOM 요소에 적용되어 시각적으로 표현되는 시점은 브라우저의 런타임 렌더링 파이프라인 중이다.
3. cn / tailwind-merge: 런타임 실행
cn 함수와 그 내부의 tailwind-merge는 런타임에 동작한다.
- JavaScript 실행: React 컴포넌트 렌더링 과정에서 cn(...) 함수를 포함한 JavaScript 코드가 실행된다.
- 클래스 문자열 계산: cn 함수 내부에서 tailwind-merge가 호출되어 입력된 클래스들을 분석하고, 충돌하는 클래스를 병합하여 최종적인 클래스 문자열(예: 'base-button ... p-4 m-1')을 계산한다.
- DOM 업데이트: React (또는 다른 프레임워크/라이브러리)는 계산된 최종 클래스 문자열을 사용하여 해당 DOM 요소의 class 속성을 업데이트한다.
- 브라우저 재계산: 브라우저는 DOM 요소의 class 속성 변경을 감지한다. 이후, 미리 파싱하여 메모리에 보유 중인 CSSOM을 참조하여 변경된 요소에 어떤 스타일 규칙이 새롭게 매칭되는지 재계산한다. class 속성이 ... p-4...로 변경되면, CSSOM 내의 .p-4 { padding: 1rem; } 규칙이 해당 요소의 적용 스타일로 결정된다.
- 리플로우/리페인트 (필요시): 스타일 변경이 요소의 크기나 위치에 영향을 미치는 경우 리플로우(Layout)가 발생하며, 이후 시각적 표현을 갱신하기 위한 리페인트(Paint)가 수행된다.
cn 또는 tailwind-merge는 런타임에 CSSOM 자체를 변경하지 않는다. CSSOM은 빌드 타임에 생성된 CSS 파일에 기반하여 이미 구성되어 있다. cn 함수의 역할은 런타임에 DOM 요소의 class 속성 값을 동적으로 계산하고 설정하는 것이며, 이 변경 사항은 브라우저가 기존 CSSOM 규칙 중 어떤 것을 해당 요소에 매칭시킬지 재평가하도록 유도한다.
결론적으로, 어떤 방식을 선택할지는 프로젝트의 기술 스택, 특히 Tailwind CSS 사용 여부에 따라 결정된다.
- Tailwind CSS를 사용하는 경우: cn 함수 (내부적으로 tailwind-merge를 포함하는)를 사용하는 것이 압도적으로 유리하다. 클래스 충돌 자동 해결 기능은 스타일링의 예측 가능성과 안정성을 크게 향상시킨다. 이는 개발 경험(DX) 개선으로 직결된다.
- Tailwind CSS를 사용하지 않는 경우: 템플릿 리터럴은 여전히 유효한 선택지이다. 그러나 조건부 클래스 로직이 복잡하다면, tailwind-merge 없이 clsx 라이브러리만 단독으로 사용하여 조건부 클래스 관리를 개선하는 것을 고려할 수 있다.
cn 함수, 특히 tailwind-merge는 단순한 문자열 결합 도구를 넘어, Tailwind CSS 생태계 내에서 개발 생산성과 코드의 견고함을 높이는 핵심적인 유틸리티이다. tailwind-merge가 런타임에 동작하여 동적으로 클래스 문자열을 최적화한다는 사실을 이해하는 것은 복잡한 UI 상태 관리에 따른 스타일링 문제를 효과적으로 해결하는 데 중요한 통찰을 제공한다.
프로젝트의 요구사항과 환경에 가장 적합한 방식을 선택해야 하며, Tailwind CSS 환경에서는 cn 함수의 강력한 기능을 활용하는 것이 현명한 판단이다.