[여행/체험 예약 플랫폼] 트러블슈팅 - tailwind-merge 커스텀 토큰 충돌 해결기

ANN·2026년 4월 21일

LiveTrip

목록 보기
3/3
post-thumbnail

📌 문제

SecondaryButton는 내부적으로 ButtonBase를 사용하고 있었고, typography는 아래처럼 분리돼 있었습니다.

const typo = {
  primary: 'text-14 md:text-16 font-bold',
  secondary: 'text-14 md:text-16 font-medium',
  label: 'text-14 md:text-16 font-medium',
};

const toneClassMap = {
  secondary: {
    default:
      'bg-primary-500 text-white hover:bg-primary-hover disabled:bg-white disabled:text-gray-200 disabled:border disabled:border-gray-200',
    accent:
      'bg-white text-gray-600 border border-gray-200 disabled:bg-white disabled:text-gray-200 disabled:border-gray-200',
  },
};

className={cx(
  baseClassName,
  shapes[variant],
  typo[variant],
  toneClassMap[variant][tone],
  className
)}

겉으로 보면 text-14와 text-gray-600은 서로 다른 역할이라 같이 살아야 합니다.

text-14: 글자 크기
text-gray-600: 글자 색상
그런데 실제로는 text-14가 사라져서, 모바일 구간에서 글자 크기가 14px로 줄지 않았습니다.

📌 배경

프로젝트는 Tailwind CSS v4 스타일로 typography 토큰을 직접 정의하고 있었습니다.

@theme inline {
  --text-14: 0.875rem; /* 14px */
  --text-16: 1rem; /* 16px */
}

즉, text-14 자체는 잘못된 클래스가 아니었습니다.
문제는 클래스 생성이 아니라, 클래스를 합치는 과정에 있었습니다.

프로젝트의 cx() 유틸은 내부적으로 tailwind-merge를 사용하고 있었습니다.

import { cx as cvaCX } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';

export const cx = (...inputs: ClassValue[]): string => twMerge(cvaCX(inputs));

tailwind-merge는 충돌하는 Tailwind 클래스를 자동으로 정리해 주는 라이브러리입니다.
예를 들어 p-2 p-4가 같이 있으면 마지막 p-4만 남기는 식입니다.

문제는 이 라이브러리가 기본 설정만으로는 text-14를 프로젝트의 커스텀 font-size 토큰으로 알지 못했다는 점입니다.

접근

원인을 찾을 때는 아래 순서로 확인했습니다.

  • text-14가 Tailwind에서 실제로 정의되어 있는지 확인
  • SecondaryButton가 아니라 ButtonBase에서 최종 className이 어떻게 합쳐지는지 확인
  • twMerge()가 text-14를 제거하는지 직접 재현

직접 재현해보면 원인이 분명해집니다.

twMerge('text-14 text-gray-600')
// 결과: 'text-gray-600'

즉, tailwind-merge가 text-14와 text-gray-600를 같은 text-* 그룹으로 보고, 뒤에 오는 text-gray-600만 남기고 있었습니다.

여기서 중요한 포인트는 md:text-16은 살아남았다는 점입니다.

  • text-14는 기본 구간 클래스

  • md:text-16은 md 반응형 구간 클래스

    둘은 modifier가 다르기 때문에 md:text-16은 유지되고, 기본 구간의 text-14만 지워졌습니다.
    그래서 결과적으로 모바일에서는 기본 글자 크기처럼 보이고, md 이상에서는 16px이 적용되는 상태가 됐습니다.

📌 해결법

1. text-[14px] 같은 arbitrary value로 바꾸기

text-[14px] md:text-[16px] font-medium

이 방법의 이유는 단순합니다.

  • tailwind-merge가 arbitrary value는 비교적 명확하게 구분할 수 있음
  • 빠르게 문제를 해결할 수 있음
  • 해당 컴포넌트만 바로 수정 가능함

하지만 단점도 있습니다.

  • 디자인 토큰 체계를 컴포넌트 안으로 흩뿌리게 됨
  • --text-14, --text-16처럼 이미 정의한 토큰의 의미가 약해짐
  • 같은 문제가 다른 컴포넌트에서 반복될 수 있음

2. tailwind-merge를 확장해서 프로젝트 토큰을 알려주기

import { extendTailwindMerge } from 'tailwind-merge';
이 방법의 이유는 구조적인 해결이기 때문입니다.

  • text-14, text-16을 프로젝트의 정식 font-size 토큰으로 인식시킬 수 있음
  • 기존 컴포넌트 API를 바꾸지 않아도 됨
  • Button뿐 아니라 앞으로의 모든 컴포넌트에 같은 기준을 적용할 수 있음
  • typography 토큰 시스템을 유지할 수 있음

단점은 한 가지입니다.
초기에 merge 설정을 한 번 관리해줘야 함

3. tailwind-merge를 아예 제거하기

이 방법도 이론상 가능합니다.

클래스가 지워지지 않으니 당장 문제는 없어질 수 있음
하지만 추천하기 어렵습니다.

  • px-3 px-4, rounded-lg rounded-xl 같은 진짜 충돌 정리가 사라짐
  • 전체 프로젝트의 클래스 병합 안정성이 떨어짐
  • 지금 문제 하나를 해결하려고 더 넓은 범위의 편의성을 잃게 됨

📌 적용

이번에는 2번, 즉 tailwind-merge 확장 방식을 선택했습니다.

선택한 이유는 명확했습니다.

  • 이미 프로젝트가 text-14, text-16, text-14-body 같은 토큰 기반 구조를 갖고 있었음
  • 문제의 본질은 컴포넌트가 아니라 merge 설정이었음
  • 한 곳에서 고치면 모든 사용처에 동일하게 적용됨
  • 앞으로 body typography 토큰까지 같은 방식으로 안전하게 사용할 수 있음

즉, “버튼 하나만 급하게 고치는 방식”보다 “토큰 시스템 자체를 제대로 이해시키는 방식”이 더 맞는 해결책이었습니다.

📌 적용한 해결 코드

import { cx as cvaCX } from 'class-variance-authority';
import type { ClassValue } from 'clsx';
import { extendTailwindMerge } from 'tailwind-merge';

const twMerge = extendTailwindMerge({
  extend: {
    theme: {
      text: [
        '10',
        '12',
        '13',
        '14',
        '16',
        '18',
        '20',
        '24',
        '32',
        '14-body',
        '16-body',
        '18-body',
        '20-body',
      ],
    },
  },
});

export const cx = (...inputs: ClassValue[]): string => twMerge(cvaCX(inputs));

핵심은 theme.text에 프로젝트의 typography 토큰을 등록한 것입니다.
이제 tailwind-merge는 text-14를 “색상 후보”가 아니라 “font-size 토큰”으로 이해합니다.

적용 결과

적용 전에는 아래 병합 결과에서 text-14가 사라졌습니다.

twMerge('text-14 text-gray-600')
// 'text-gray-600'

적용 후에는 두 클래스가 함께 유지됩니다.

twMerge('text-14 text-gray-600')
// 'text-14 text-gray-600'

실제 UI 관점에서 보면 결과는 다음과 같습니다.

  • 모바일 구간에서 SecondaryButton의 글자 크기가 정상적으로 14px 적용
  • md 이상에서는 md:text-16이 그대로 적용
  • text-gray-600, text-white 같은 색상 클래스도 정상 유지
  • 기존 컴포넌트 사용 방식은 바꾸지 않아도 됨

정리하면, 이번 이슈는 Tailwind CSS의 문제가 아니라 tailwind-merge가 프로젝트의 커스텀 typography 토큰을 모르고 있었던 것이 원인이었고, 가장 좋은 해결책은 토큰 체계를 유지한 채 merge 설정을 확장하는 것이었습니다.

0개의 댓글