TOC
- 들어가며
- tailwindcss 커스텀 클래스 - CSS
- tailwindcss 커스텀 클래스 - tailwind.config.ts
- tailwindcss class 중복 문제와 tailwind-merge
- tailwind-merge의 문제와 해결
- 마치며
들어가며
핵심 주제
- tailwindcss 커스텀 클래스 작성은 어떻게 하는지
- tailwindcss 커스텀 클래스와 내장 클래스가 충돌하는 문제를 어떻게 해결했는지
작성 배경
지난 Tailwind CSS 딥다이브 포스트 이후, 개인 프로젝트와 사내 프로젝트 모두에서 tailwindcss를 적극적으로 사용하고 있습니다.
개인 프로젝트의 경우, tailwindcss의 내장 class만으로 모두 커버가 되지만, 사내 프로젝트의 경우 디자이너가 지정한 color, typography 등의 foundation 스타일은 tailwindcss로 사용하기 위해 재정의를 해주어야 합니다. 이러한 재정의를 위해 커스텀 클래스를 작성하게 되었습니다. 이 과정을 오늘의 첫 번째 글감으로 가져갑니다.
그런데, 문제가 발생했어요. tailwindcss와 tailwind-merge를 사용하며 같은 스타일을 설정하는 class의 중복을 제거해 적용하고 있었는데, 이게 커스텀 클래스와 내장 클래스가 충돌하는 문제가 있었습니다. 이걸 어떻게 해결했는지 과정을 두 번째 글감으로 가져갑니다.
본 포스트는 tailwindcss v4.1을 기준으로 작성되었습니다. (작성일 시점 latest)
tailwindcss 커스텀 클래스 - CSS
tailwindcss에서 제공하는 내장 클래스를 확장하기 위해 커스텀 클래스를 작성하는 방법을 소개합니다.
CSS 변수 선언
먼저 CSS 변수를 설정해보겠습니다.
/* typography.css */
:root {
font-family: 'Pretendard', sans-serif;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--text-title1-size: 40px;
--text-title1-height: 135%;
--text-title1-spacing: -2.5%;
...
}
반응형은 이렇게 작성합니다.
/* typography.css */
@media (max-width: 800px) {
:root {
--text-title1-size: 28px;
--text-title1-height: 145%;
--text-title1-spacing: -2.5%;
...
}
}
참고로 @media
내부의 값은 css 변수로 따로 정의할 수 없습니다. --breakpoint-mobile: 800px
, @media (max-width: var(--breakpoint-mobile))
형태로 사용해보려다가 안 돼서 직접 넣었습니다. 더 좋은 방법이 있으면 댓글로 알려주세요.
CSS 변수 적용한 class 생성
/* typography.css */
...
.text-title1 {
font-size: var(--text-title1-size);
line-height: var(--text-title1-height);
letter-spacing: var(--text-title1-spacing);
}
...
.font-unica {
font-family: 'Unica', 'Pretendard', sans-serif;
}
...
globals.css 적용
tailwindcss를 설정한다면, 기본적으로 globals.css
파일이 있습니다. 기본 코드 최상단에는 @import 'tailwindcss';
코드가 있죠. 이 코드 아래에 커스텀 클래스를 적용하는 코드 파일을 import합니다.
@import 'tailwindcss';
@import '../typography.css'; /* 커스텀 클래스 css 파일 적용 */
@custom-variant dark (&:is(.dark *));
@theme inline { ... }
:root { ... }
.dark { ... }
구조 개선
커스텀 클래스를 typography 뿐만 아니라, color 등 다른 분류로 그룹화해서 css 파일을 구성할 수도 있겠죠. 이런 경우에는 이렇게 하면 더 깔끔합니다.
만약 이런 파일 구조라고 가정하겠습니다.
/style
/custom
color.css
typography.css
index.css
globals.css
...
이렇게 분류별로 커스텀 css 파일들을 모아서 import하고, 이를 index.css
파일이라고 이름 지었습니다.
/* index.css */
@import './typography.css';
@import './color.css';
...
다음엔, 아까 globals.css
파일에서 커스텀 클래스를 적용하는 코드를 이렇게 묶음 css 파일을 한 번에 import하는 방식으로 사용합니다.
/* globals.css */
@import 'tailwindcss';
@import './custom/index.css'; /* 커스텀 클래스 css 파일 적용 */
/* index.css로 했다면, @import './custom'; 도 가능 */
위는 구조적 흐름으로 생각해주시면 좋겠습니다. 저는 사내에서는 turborepo를 사용하여 모노레포를 구성했습니다. packages/ui
라는 별도 패키지에서 공통 tailwindcss와 globals.css
파일을 정의한 뒤, 각 app의 globals.css
파일에서 import하는 방식을 사용하고 있습니다.
tailwindcss 커스텀 클래스 - tailwind.config.ts
tailwind.config.ts
tailwind.config.ts(.js)
파일은 커스텀 클래스와 plugin 등 tailwindcss 설정들을 정의하던 파일입니다. 하지만, tailwindcss가 v4로 버전이 올라가면서 이 방식은 더 이상 공식적으로 안내하지 않게 되었습니다. 다만, 프로젝트 내의 js 상수와 연계하거나 기존 v3의 전유물을 유지할 목적으로, 아직 deprecated되지 않고 사용할 수 있는 상황입니다.
이를 이용해서 color class를 정의한 방식을 소개합니다. 흐름은 color.ts
-> tailwind.config.ts
-> globals.css
순으로 진행합니다.
color.ts
color.ts
파일에서는 `ts, tsx 파일에서 사용하는 색상 상수를 정의합니다.
// color.ts
export const COLORS = {
sky01: "#F0F9FF",
sky02: "#DFF2FE",
sky03: "#B8E8FE",
...
}
tailwind.config.ts
원래 css였다면, 이를 --color-${colorName}
형태로 정의하고, 이를 활용해서 커스텀 클래스를 작성했을 겁니다. 하지만, tailwind.config.ts
파일에서는 이를 활용해서 커스텀 클래스를 작성할 수 있습니다.
// tailwind.config.ts
import { COLORS } from './color';
// COLORS 객체를 Tailwind CSS 색상 형식으로 변환하는 함수
const transformColorsToTailwind = (colors: typeof COLORS) => {
const tailwindColors: Record<string, string> = {};
Object.entries(colors).forEach(([key, value]) => {
// camelCase를 kebab-case로 변환
// 예: cyan01 -> cyan-01, accent06 -> accent-06
const kebabKey = key.replace(/([a-z])(\d)/g, '$1-$2');
tailwindColors[kebabKey] = value;
});
return tailwindColors;
};
/** @type {import('tailwindcss').Config} */
const tailwindConfig = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
colors: { ...transformColorsToTailwind(COLORS) },
},
},
plugins: [],
};
export default tailwindConfig;
핵심은 tailwindConfig입니다.
/** @type {import('tailwindcss').Config} */
const tailwindConfig = {
content: ['./src/**/*.{js,ts,jsx,tsx,mdx}'],
theme: {
extend: {
colors: {
'sky-01': '#F0F9FF',
'sky-02': '#DFF2FE',
'sky-03': '#B8E8FE',
},
},
},
plugins: [],
};
export default tailwindConfig;
원래는 이런 구조로 정의를 해야 합니다. 그런데 수많은 색상을 tailwind.config.ts 파일에 정의할 거라면, 그냥 공식문서의 권장대로 css 파일에 정의했을 것입니다. 하지만, tailwind.config.ts 파일을 사용한 이유는 이미 정의된 색상 객체를 활용하기 위해서였죠.
const transformColorsToTailwind = (colors: typeof COLORS) => {
const tailwindColors: Record<string, string> = {};
Object.entries(colors).forEach(([key, value]) => {
const kebabKey = key.replace(/([a-z])(\d)/g, '$1-$2');
tailwindColors[kebabKey] = value;
});
return tailwindColors;
};
색상 객체로부터 커스텀 클래스에 대한 key-value 쌍을 생성했고, 이를 tailwindConfig
에 그대로 넣었습니다. 이렇게 하면, 커스텀 클래스를 사용할 때, 이미 정의된 색상 객체를 활용할 수 있습니다.
지금은 예시로 색상을 들었고, theme.extend.colors
속성에 넣은 걸 보여드렸습니다. 실제로 사용할 때는 bg-sky-01
의 방식으로 color를 적용하게 될 겁니다. 때문에 이건 예약어입니다. 각 용도에 맞춰 적절한 키에서 객체를 정의해주면 됩니다. 자세한 extend key 또는 tailwind.config.ts
파일 설정 방법은 tailwind v3 공식문서 - configuration를 참고해주세요.
globals.css
위에서 정의된 tailwind.config.ts
파일을 사용하기 위해서는, globals.css
파일에서 이렇게 적용해야 합니다. (프로젝트의 구조에 맞게 경로를 설정해주세요.)
/* globals.css */
@import 'tailwindcss';
@import './custom.css'; /* 커스텀 클래스 css 파일 적용 */
@config '../../tailwind.config.ts'; /* tailwind.config.ts 파일 적용 */
...
이렇게 쉽게, js 상수를 활용해서 커스텀 클래스를 작성해볼 수 있었습니다.
tailwindcss class 중복 문제와 tailwind-merge
tailwindcss class 중복 문제
tailwindcss를 사용하다보면 종종 이런 문제를 만납니다.

이는 tailwindcss에서 class로 설정하고자 하는 스타일이 공통/중첩되는 부분이 있음을 경고하며, Tailwind CSS Intellisense 확장 프로그램의 기능입니다.

두 utility class가 같은 속성을 스타일링할 때, globals.css
파일에서 후순위로 정의된 class 속성이 적용됩니다. 즉, 컴포넌트에서 정의된 class의 후순위 순서로 override되는 것이 아니기 때문에 예측 가능하지 않습니다.
tailwind-merge와 cn 함수
이런 병합 문제를 해결해주는 라이브러리가 tailwind-merge입니다. 인간적인 예측대로 후순위의 class가 override되는 결과가 나옵니다.
그리고 shadcn/ui는 다양한 조건부 처리 및 구조를 가지게 해주는 clsx라는 라이브러리와 함께 엮어 cn 함수를 만든다고 앞선 포스팅에서 소개했습니다.
// src/lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
저는 이 util 함수를 shadcn/ui를 사용하지 않을 때에도 많이 애용하고 있습니다.
적용 결과
종합적인 결과를 보여드리겠습니다. cn 함수를 사용한 경우와 아닌 경우를 살펴봅시다.
<div className="flex flex-col items-center justify-center h-screen">
<h2 className={"text-title1 text-red-500 text-blue-500"}>Hello World</h2>
<h2 className={cn("text-title1 text-red-500 text-blue-500")}>
Hello World
</h2>
</div>

cn 함수를 적용하지 않으면 모든 class가 남아있어, css 파일에 후순위로 정의된대로 적용된다고 말씀드렸습니다.
하지만 cn 함수를 적용한 경우 text-title1
, text-red-500
, text-blue-500
순으로 적용되어, text-blue-500
만 남아 적용되는 것을 확인할 수 있습니다.
tailwind-merge의 문제와 해결
커스텀 클래스 중복 문제
그런데 위의 사진을 다시 보면 뭔가 이상한 게 보입니다. text-title1
을 통해 font-size 등 폰트 색상을 제외한 나머지 속성들을 적용하고 있는데 cn 함수를 적용한 경우 text-title1
클래스도 사라져 폰트 속성이 적용되지 않는 것을 확인할 수 있습니다.
tailwind-merge는 커스텀 클래스를 모른다
아무래도 prefix가 text-
로 같아서 tailwind-merge는 이를 중첩으로 생각하고 제거하는 것으로 보입니다. 위와 같은 문제는 다른 사용자들에게도 동일하게 발생했고, tailwind-merge github issue에는 관련 질문들이 몇몇 올라왔습니다. 한 이슈에서 tailwind-merge maintainer는 이런 댓글을 남겼습니다.

네, 방법을 알려줬으니 해보죠.
tailwind-merge에게 내 커스텀 클래스 알려주기
extendTailwindMerge의 공식 사용 가이드를 살펴보면 이렇게 나와있습니다.
import { extendTailwindMerge } from 'tailwind-merge'
const twMerge = extendTailwindMerge({
extend: {
theme: {
shadow: ['100', '200', '300'],
},
},
})
위의 경우에는 shadow-100
, ... 커스텀 클래스를 tailwind-merge에게 알려주는 코드입니다. tailwind-merge는 이런 theme prefix mapping을 가지고 있습니다.

classGroups 설정
그런데 가만보니 이런 사용 예시도 있습니다.
import { extendTailwindMerge } from 'tailwind-merge'
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
shadow: [{ shadow: ['100', '200', '300'] }],
},
},
})
위의 classGroups
를 사용하는 것과 아닌 것은 무슨 차이가 있을까요?
classGroups를 사용하지 않는 경우
classGroups
를 정의하지 않으면 기본 tailwind-merge 설정만 사용됩니다. 말 그대로 @theme
에 정의한 커스텀 클래스들만 알려줄 뿐, 이전처럼 커스텀 클래스는 prefix
를 통해 제거해버립니다.
const twMerge = extendTailwindMerge({
extend: {
theme: {
"font": ['text-title1', ...],
"text-color": ['text-red-01', 'text-blue-01', ...],
}
}
})
twMerge('text-title1 text-red-500 text-blue-500') // → 'text-blue-500' (문제 발생)
classGroups를 사용하는 경우
classGroups
를 정의하면 새로운 클래스 그룹을 생성하거나 기존 그룹을 확장할 수 있습니다.
클래스 그룹은 동일한 CSS 속성을 수정하는 Tailwind 클래스들의 배열이고, 그렇기 때문에 같은 그룹 내에서는 중복된다면 중복을 제거합니다.
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
"font-size": ["text-title1", "text-title2", "text-title3"],
"text-color": ["text-red-01", "text-blue-01", "text-custom-01"],
},
},
});
twMerge('text-title1 text-red-01 text-custom-01') // → 'text-title1 text-custom-01' (충돌 해결됨)
결론
classGroups
를 사용하면 커스텀 클래스들 간의 충돌을 해결할 수 있지만, 사용하지 않으면 기본 Tailwind 클래스들만 병합됩니다. 커스텀 클래스나 플러그인을 사용하는 경우 classGroups
정의가 필수적입니다.
classGroups prefix short-hand syntax
위의 코드를 보면 이런 게 보입니다. "font-size": ["text-title1", "text-title2", "text-title3"],
text-
가 계속 반복되네요. 개발자는 반복을 싫어합니다. 줄일 방법이 있지 않을까요? 정답은 '있습니다'
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
"font-size": [{
"text": ["title1", "title2", "title3"],
}],
"text-color": [{
"text": ["red-01", "blue-01", "custom-01"],
}],
},
},
});
이렇게 적용하면 됩니다.
최종 코드
그렇다면 저는 어떻게 적용했을까요? 저는 이렇게 적용했습니다.
import { clsx, type ClassValue } from 'clsx';
import { extendTailwindMerge } from 'tailwind-merge';
import { COLORS } from '../styles';
import { camelCaseToKebabCase } from '../styles/tailwind.config';
const customTwMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-size': [
{
text: [
'title1',
'title2',
...
],
},
],
'text-color': [
{
text: Array.from(Object.keys(COLORS)).map((color) =>
camelCaseToKebabCase(color),
),
},
],
},
},
});
export function cn(...inputs: ClassValue[]) {
return customTwMerge(clsx(inputs));
}

이렇게 적용하니 문제가 잘 해결되었습니다.
마치며
좋은 경험이었다
그동안 tailwind-merge(cn 함수)를 사용할 때에는 대부분 tailwind 내장 클래스를 사용했고, 커스텀 클래스를 적용할 일이 없었다보니 이런 문제를 만나본 적이 없었습니다. 그래서 많이 해맸습니다. 하지만 결국 해결했고, 이번 포스팅을 작성하며 더 깊게 공부하다보니 더더욱 실무에서 개선할 수 있는 요소들이 보여 유익한 경험이 되었습니다.
피드백은 감사드립니다
커스텀 클래스를 적용하는 방법들도 흐름을 잘 구조화하며 정리했는데, 정보를 전달하는 입장에서는 조심스럽지만 최상의 방식은 아닐 수 있습니다. 아무래도 회사에서는 빠르게 결과물을 내려다보니 최상의 구조보다는 최선의 구조에 타협하게 되더라고요. 오늘 포스팅을 위해 tailwindcss와 tailwind-merge 공식문서를 보면서 몰랐던 가이드들이 몇 있어서 조금 더 살펴보고 개선해보려 합니다. 혹시 더 좋은 구조들이나 방법들을 알고 계신다면 댓글로 공유해주시면 너무나 감사드리겠습니다.