Skip to main content

Command Palette

Search for a command to run...

tailwind-merge에서 클래스네임은 어떻게 제어되는가?

Updated
6 min read
tailwind-merge에서 클래스네임은 어떻게 제어되는가?

tailwind-merge에서 클래스네임은 어떻게 제어되는가?

시작하기 전에

최근 shadcn을 이용해서 UI 작업을 하고 있다. 많은 사람들이 꼽는 장점 중에 하나로 커스텀이 쉽다는 것인데, 최근에 개인 프로젝트에서 컴포넌트를 만들면서 tailwind-merge를 사용하다가 고생한 경험이 있는데, 겪었던 경험에 대해 작성하고 tailwind-merge는 대체 어떻게 돌아가는지 알아보기로 했다.

shadcn 컴포넌트에 적용된 스타일을 제대로 확인하지 않은 채로 디자인 시안만 보고 작업하고 있었는데, 브라우저에 스타일이 제대로 적용되지 않고 오히려 내가 적용한 스타일이 없어지는 듯한 현상을 겪었다.

결국 tailwind-merge가 원인인 것을 알게 됐고, 코드 레벨에서 어떻게 돌아가는지 확인해보기로 했다.


동작원리 알아보기

tailwindMerge 함수 내부에서 캐시 로직은 부수적인 부분이고, 핵심은 mergeClassList다. 이 함수가 실제로 클래스 충돌을 해소한다.

mergeClassList — 전체 흐름

// src/lib/merge-classlist.ts
const classGroupsInConflict: string[] = []
const classNames = classList.trim().split(SPLIT_CLASSES_REGEX)

for (let index = classNames.length - 1; index >= 0; index--) {  // 역순 순회
    const { modifiers, hasImportantModifier, baseClassName } = parseClassName(classNames[index]!)
    const classGroupId = getClassGroupId(baseClassName)

    // modifier(hover:, focus:)와 !important 조합으로 고유 ID 생성
    const classId = (hasImportantModifier ? '!' : '') + modifiers.join(':') + classGroupId

    if (classGroupsInConflict.includes(classId)) continue  // 충돌 → 제거

    classGroupsInConflict.push(classId, ...getConflictingClassGroupIds(classGroupId))
    result = classNames[index] + (result ? ' ' + result : '')
}

for문이 역순 순회를 하는 게 핵심이다. 뒤에 있는 클래스를 먼저 처리해서 충돌 배열을 선점하기 때문에, 같은 그룹의 앞쪽 클래스는 자연스럽게 제거된다.


parseClassName — 클래스명을 조각내기

parseClassName"hover:focus:!bg-red-500/50" 같은 문자열을 받아 의미 있는 조각으로 분리한다. for문을 돌며 한 글자씩 순회하면서 세 가지를 체크한다.

for (let index = 0; index < className.length; index++) {
    const ch = className[index]

    if (bracketDepth === 0 && parenDepth === 0) {
        if (ch === ':') { modifiers.push(className.slice(modifierStart, index)); modifierStart = index + 1 }
        if (ch === '/') { postfixModifierPosition = index }
    }

    if (ch === '[') bracketDepth++
    else if (ch === ']') bracketDepth--
    else if (ch === '(') parenDepth++
    else if (ch === ')') parenDepth--
}
  • :를 만나면 modifier로 분리한다. "hover:focus:!bg-red-500/50"에서 "hover", "focus"를 차례로 modifiers 배열에 push한다.

  • /를 만나면 postfixModifierPosition에 인덱스를 기록한다. 나중에 opacity 같은 postfix modifier를 분리할 때 사용된다.

  • [, (를 만나면 depth를 증가시킨다. bracketDepthparenDepth가 모두 0일 때만 :/를 modifier 구분자로 인식하기 때문에, 대괄호나 소괄호 안의 문자는 자연스럽게 무시된다.

예를 들어 text-[color:var(--my-color)]에서 color: 뒤의 :bracketDepth = 1인 상태라 modifier로 처리되지 않는다. 덕분에 단 한 번의 for 루프 순회만으로 arbitrary value도 안전하게 파싱할 수 있다.

for문이 끝난 뒤의 상태는 다음과 같다.

modifiers = ["hover", "focus"]
modifierStart = 12
postfixModifierPosition = 24

baseClassName 추출: modifiers.length === 0이면 className 전체가 baseClassName이고, 그렇지 않으면 className.slice(modifierStart)로 잘라낸다. 예시에서는 "hover:focus:!bg-red-500/50".slice(12) = "!bg-red-500/50"이 된다.

important modifier 처리: !를 확인해서 제거하고 hasImportantModifier = true로 설정한다. Tailwind v4는 접미사(text-lg!), v3는 접두사(!text-lg) 방식을 사용하는데 tailwind-merge는 둘 다 지원한다. 위치만 다를 뿐 !를 떼어내는 동작은 동일하다.

// v4 접미사 방식
if (baseClassNameWithImportantModifier.endsWith('!')) {
    baseClassName = baseClassNameWithImportantModifier.slice(0, -1)
    hasImportantModifier = true
}

postfix modifier 위치 보정: postfixModifierPosition은 원래 전체 className 기준 인덱스이므로 postfixModifierPosition - modifierStart로 baseClassName 기준으로 보정한다.

모든 단계가 끝난 "hover:focus:!bg-red-500/50"의 최종 파싱 결과는 다음과 같다.

{
    modifiers: ["hover", "focus"],
    hasImportantModifier: true,
    baseClassName: "bg-red-500/50",
    maybePostfixModifierPosition: 12
}

getClassGroupId — 트라이(Trie) 기반 클래스 분류

parseClassName이 클래스명을 조각낸 다음, mergeClassListbaseClassNamegetClassGroupId에 넘긴다. 이 함수의 역할은 "bg-red-500" 같은 클래스명을 받아서 "bg-color"라는 그룹 ID를 돌려주는 것이다.

핵심은 트라이(Trie) 자료구조다. tailwind-merge는 초기화 시점에 classGroups 설정을 파싱해서 클래스명의 각 파트를 - 기준으로 쪼개어 트라이 노드로 등록한다.

// src/lib/class-group-utils.ts
export interface ClassPartObject {
    nextPart: Map<string, ClassPartObject>  // 다음 단계 자식 노드
    validators: ClassValidatorObject[] | null  // 동적 값 검사 함수 목록
    classGroupId: AnyClassGroupIds | undefined  // 이 노드가 그룹의 끝이면 채워짐
}

const getClassGroupId = (className: string) => {
    const classParts = className.split('-')
    return getGroupRecursive(classParts, startIndex, classPartObject)
}

"bg-red-500"이 들어오면 ["bg", "red", "500"]으로 쪼개어 트라이를 한 단계씩 내려간다. 마치 파일 경로 bg/red/500을 탐색하듯이, 루트에서 "bg" 노드로, "red" 노드로, "500" 노드로 이동한다.

const getGroupRecursive = (classParts, startIndex, node) => {
    if (classParts.length === startIndex) return node.classGroupId

    const next = node.nextPart.get(classParts[startIndex])
    if (next) {
        const result = getGroupRecursive(classParts, startIndex + 1, next)
        if (result) return result
    }

    // 정적 매칭 실패 시 validator로 동적 검증
    const rest = classParts.slice(startIndex).join('-')
    return node.validators?.find(({ validator }) => validator(rest))?.classGroupId
}

트라이에 정적으로 등록된 값과 일치하면 바로 그룹 ID를 반환하고, 일치하지 않으면 validators로 동적 검증을 시도한다. "bg-red-500"에서 "500" 같은 숫자는 isInteger validator를 통해 통과된다.


getConflictingClassGroupIds — 충돌 관계는 어디에 정의되어 있을까?

classGroupId를 얻었으면 이 그룹과 충돌하는 다른 그룹이 무엇인지를 알아야 한다. 예를 들어 px-4가 들어왔다면 그룹 ID는 "px"다. 그런데 p-4가 이미 있다면? ppx를 포함하는 클래스 그룹이므로, p-4가 있으면 px-4는 의미가 없다.

이런 충돌 관계가 getDefaultConfigconflictingClassGroups에 하드코딩되어 있다.

// src/lib/default-config.ts
conflictingClassGroups: {
    p: ['px', 'py', 'ps', 'pe', 'pt', 'pr', 'pb', 'pl'],
    px: ['pr', 'pl'],
    py: ['pt', 'pb'],
    m: ['mx', 'my', 'ms', 'me', 'mt', 'mr', 'mb', 'ml'],
    mx: ['mr', 'ml'],
    my: ['mt', 'mb'],
    size: ['w', 'h'],
    'font-size': ['leading'],
    // ...
}

p 그룹이 뒤에 오면, px, py 등 앞쪽 클래스들은 모두 충돌로 간주되어 제거된다. 주의할 점은 이 관계가 단방향이라는 것이다. ppx를 충돌로 등록하고 있지만, px에는 p가 없다. 즉 "px-4 p-8"p-8만 남지만, "p-8 px-4"는 둘 다 남는다.


구체적인 예시로 전체 흐름 따라가기

twMerge("px-4 px-6")가 호출되었을 때 전체 과정을 따라가 보자.

입력이 ["px-4", "px-6"]으로 분리되고, for문은 역순이므로 "px-6"부터 처리한다.

"px-6" 처리: classGroupId = "px" 반환. classGroupsInConflict는 비어 있으므로 충돌 없음. "px", "pr", "pl"을 충돌 배열에 등록하고 결과에 추가.

classGroupsInConflict = ["px", "pr", "pl"]
result = "px-6"

"px-4" 처리: 동일한 과정으로 classGroupId = "px"를 얻는다. classGroupsInConflict"px"가 이미 존재하므로 충돌로 판정, 제거(continue).

최종 결과는 "px-6". 뒤에 온 클래스가 살아남았다.


다시 돌아온 문제 — 왜 내 스타일이 사라졌을까

이제 처음에 겪었던 문제로 돌아가 보자. 문제는 cn() 함수에 넘기는 인자 순서에 있었다.

// 의도대로 동작하는 경우: 뒤에 오는 bg-blue-500이 살아남음
cn("bg-primary", "bg-blue-500")

// 문제가 되는 경우: 뒤에 오는 bg-primary가 살아남아 내 스타일이 사라짐
cn("bg-blue-500", "bg-primary")
// twMerge("bg-blue-500 bg-primary") → 결과: "bg-primary"

twMerge는 항상 뒤에 오는 클래스를 우선시하기 때문에, cn() 인자 순서가 결정적으로 중요하다. shadcn 컴포넌트의 올바른 패턴은 다음과 같다.

// shadcn Button 컴포넌트 내부 (올바른 순서)
const Button = ({ className, ...props }) => (
    <button className={cn("bg-primary text-white px-4", className)}>
        {/* className이 뒤에 와야 외부 스타일이 기본 스타일을 덮어쓸 수 있다 */}
    </button>
)

정리

내가 겪었던 문제는 shadcn 컴포넌트에 이미 적용된 스타일을 제대로 확인하지 않은 채 작업했기 때문이었다. tailwind-merge 내부를 살펴보면서 알게 된 핵심은 두 가지다.

첫째, twMerge는 뒤에 오는 클래스를 우선시한다. for문이 역순 순회를 하기 때문에, 먼저 처리된 클래스가 충돌 배열을 선점해서 같은 그룹의 앞쪽 클래스는 자연스럽게 제거된다.

둘째, 충돌 관계는 단방향이다. conflictingClassGroups에 정의된 관계는 상위 개념이 하위 개념을 덮어쓰는 방향으로만 작동한다. ppx를 충돌로 등록하고 있지만 그 반대는 아니다.

shadcn 컴포넌트를 커스텀할 때는 내부에 어떤 클래스가 적용되어 있는지 반드시 확인하고, cn() 함수에 넘기는 순서에 신경 써야 한다.


참고 자료

More from this blog

토스 Frontend Fundamentals 2회차 모의고사 후기

진행하면서 느낀 점 1회차에 이어 2회차 토스 모의고사 후기를 쓴다. 지난 회차와 달리 기능 구현이 전부 완료되어 있는 상태에서 시작을 했고,어떤 관점으로 추상화와 유지보수성을 바라보는 시각을 비교하는 것이었다. 기능을 구현하면서 진행했을때보다 아예 모든 기능이 이미 구현된 상태에서 시작을 하다보니 어디서 어떻게 리팩토링을 진행하다보니 더 막막했던 것 같다

Mar 28, 20262 min read59
토스 Frontend Fundamentals 2회차 모의고사 후기

Unicode LB13 규칙과 CSS 줄바꿈에 대하여

시작하기 전에 댓글 input에 한글,영어를 제외한 특수문자에서 줄바꿈이 되지 않고 overflow가 발생하는 문제를 겪었다. 특수문자에서는 왜 줄바꿈이 일어나지 않는지 알아보고, 한글/영어와 특수문자에서의 줄바꿈 차이를 알기 위해 글을 작성해보려고 한다. 한글 및 영어와 특수문자에서의 줄바꿈 댓글 컴포넌트의 CSS는 다음과 같이 폰트 관련 속성만 적용되어

Jan 29, 20263 min read31
Unicode LB13 규칙과 CSS 줄바꿈에 대하여

haseung-log

30 posts