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

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

## 시작하기 전에

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

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

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

* * *

## 동작원리 알아보기

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

### mergeClassList — 전체 흐름

```ts
// 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문을 돌며 한 글자씩 순회하면서 세 가지를 체크한다.

```ts
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를 증가시킨다. `bracketDepth`와 `parenDepth`가 모두 0일 때만 `:`와 `/`를 modifier 구분자로 인식하기 때문에, 대괄호나 소괄호 안의 문자는 자연스럽게 무시된다.
    

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

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

```plaintext
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는 둘 다 지원한다. 위치만 다를 뿐 `!`를 떼어내는 동작은 동일하다.

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

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

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

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

* * *

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

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

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

```ts
// 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"` 노드로 이동한다.

```ts
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`가 이미 있다면? `p`는 `px`를 포함하는 클래스 그룹이므로, `p-4`가 있으면 `px-4`는 의미가 없다.

이런 충돌 관계가 `getDefaultConfig`의 `conflictingClassGroups`에 하드코딩되어 있다.

```ts
// 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` 등 앞쪽 클래스들은 모두 충돌로 간주되어 제거된다. 주의할 점은 **이 관계가 단방향**이라는 것이다. `p`가 `px`를 충돌로 등록하고 있지만, `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"`을 충돌 배열에 등록하고 결과에 추가.

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

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

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

* * *

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

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

```ts
// 의도대로 동작하는 경우: 뒤에 오는 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 컴포넌트의 올바른 패턴은 다음과 같다.

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

* * *

## 정리

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

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

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

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

* * *

## 참고 자료

*   [tailwind-merge GitHub Repository](https://github.com/dcastil/tailwind-merge)
    
*   [tailwind-merge 소스코드 — parse-class-name.ts](https://github.com/dcastil/tailwind-merge/blob/main/src/lib/parse-class-name.ts)
    
*   [tailwind-merge 소스코드 — merge-classlist.ts](https://github.com/dcastil/tailwind-merge/blob/main/src/lib/merge-classlist.ts)
