<!-- mobian-agent-page publisher="dailydev" canonical="https://daily.dev/posts/3-10-prefix-tailwind-merge--uffcv3h76" -->

---
title: 항공 프론트엔드 구축기 (3/10): prefix를 붙이자 tailwind-merge가 조용히 깨졌다
description: Discussion about "항공 프론트엔드 구축기 (3/10): prefix를 붙이자 tailwind-merge가 조용히 깨졌다" on daily.dev - join the developer community
canonical: https://daily.dev/posts/3-10-prefix-tailwind-merge--uffcv3h76
twitter:card: summary_large_image
twitter:site: @dailydotdev
og:type: website
og:site_name: daily.dev
og:title: 항공 프론트엔드 구축기 (3/10): prefix를 붙이자 tailwind-merge가 조용히 깨졌다 | daily.dev
og:description: Discussion about "항공 프론트엔드 구축기 (3/10): prefix를 붙이자 tailwind-merge가 조용히 깨졌다" on daily.dev - join the developer community
og:url: https://daily.dev/posts/3-10-prefix-tailwind-merge--uffcv3h76
og:image: https://api.daily.dev/og/posts/uFFcv3h76.png
og:image:alt: 항공 프론트엔드 구축기 (3/10): prefix를 붙이자 tailwind-merge가 조용히 깨졌다
og:image:width: 1200
og:image:height: 630
og:locale: en
---

> ## Documentation Index
> Fetch the complete documentation index at: https://daily.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# 항공 프론트엔드 구축기 (3/10): prefix를 붙이자 tailwind-merge가 조용히 깨졌다

**[Korean Devs](https://daily.dev/sources/koreandevs)** · [@seyoungcho](https://daily.dev/seyoungcho) · 0 upvotes · 0 comments

## Content

**_출처: 여기어때 기술 블로그_**

> [_글 원문 보기_](https://techblog.gccompany.co.kr/%ED%95%AD%EA%B3%B5-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B5%AC%EC%B6%95%EA%B8%B0-2-10-vue2-%EB%94%94%EC%9E%90%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%9D%84-react%EB%A1%9C-%EC%98%AE%EA%B8%B0%EA%B8%B0-b16284a907e9)

안녕하세요. 서비스웹개발팀의 에릭입니다.

지난 편에서 Vue2 컴포넌트를 React로 옮긴 이야기를 하면서 예고를 하나 남겼습니다. 클래스 이름 앞에 세 글자를 붙였다가 겪은 일입니다. 에러도 없이 스타일만 어긋나던 문제를 따라가 봤더니, 생각보다 깊은 곳까지 내려가게 됐습니다.

## 클래스 이름 앞에 세 글자를 붙였을 뿐인데

항공 서비스를 새로 만들면서 공용 컴포넌트 라이브러리를 함께 구성했습니다. 여러 프로젝트가 가져다 쓰는 패키지라, 우리 컴포넌트의 스타일이 그걸 쓰는 앱의 스타일과 섞이면 안 됐습니다.

Tailwind는 이런 상황을 위해 `prefix` 옵션을 제공합니다. 그리고 어떤 prefix를 쓸지는 제가 정할 필요가 없었습니다.

사내 디자인 시스템의 파운데이션(색상 팔레트, 시맨틱 컬러, 타이포그래피, 폰트 웨이트)은 이미 별도 패키지로 정리되어 있었고, **거기서 쓰는 Tailwind prefix**를 `yf-`로 하기로 이미 약속되어 있었습니다. 우리는 그 패키지에서 디자인 토큰을 그대로 가져다 씁니다.

우리 라이브러리의 Tailwind 설정은 그 토큰을 받아서 preset을 만드는 게 전부입니다.

```
// 색상·타이포·폰트 웨이트는 디자인 시스템에서 그대로 가져온다
export const ydsPreset = {
  prefix: 'yf-',
  theme: {
    fontWeight: FontWeight,
    extend: { colors: { ...PaletteColor, ...SemanticColor } },
  },
};
```

토큰을 가져다 쓰는 이상 prefix도 같이 따라옵니다. 그러니 `yf-`는 선택이라기보다 **전제**였습니다. `flex`는 `yf- flex`가 되고, `p-16`은 `yf-p-16`이 됩니다.

참고로 이건 사내에서 처음 있는 일도 아니었습니다. 오래 쓰던 Vue2 기반 공용 라이브러리도 같은 이유로 `twl-` prefix를 쓰고 있었습니다. 그래서 저는 이 부분을 별로 고민하지 않았습니다. 고민이 필요한 부분은 그다음에 있었습니다.

## 문제: 오버라이드가 안 되는데, 에러는 안 난다

컴포넌트 라이브러리에서 `className`으로 스타일을 덮어쓸 수 있게 하는 건 기본입니다.

```
// 버튼의 기본 배경을 이 화면에서만 바꾸고 싶다
<BoxButton className="yf-bg-red-500" />
```

`BoxButton`의 기본 스타일에는 이미 `yf-bg-button-primary`가 들어 있습니다. 두 클래스가 만나면 나중 것이 이겨야 합니다. 그 역할을 하는 게 `tailwind-merge`(이하 twMerge)입니다.

그런데 안 먹혔습니다. 정확히는, **먹힐 때도 있고 안 먹힐 때도 있었습니다.**

원인은 twMerge가 클래스를 인식하는 방식에 있습니다. twMerge는 `bg-`로 시작하는 클래스를 background 그룹으로 분류하고, 같은 그룹에서 마지막 것만 남깁니다. 그런데 `yf-bg-red-500`은 `bg-`로 시작하지 않습니다.

```
twMerge('bg-blue-500 bg-red-500')       // → 'bg-red-500'                   ✅
twMerge('yf-bg-blue-500 yf-bg-red-500') // → 'yf-bg-blue-500 yf-bg-red-500' ❌
```

twMerge 입장에서 `yf-bg-red-500`은 그냥 모르는 문자열입니다. 모르니까 충돌 판정을 못 하고, 둘 다 그대로 남깁니다. 그러면 어느 쪽이 이길지는 CSS 파일에서 두 규칙 중 무엇이 뒤에 오느냐로 결정됩니다.

이게 이 문제의 성질입니다. **에러가 나지 않습니다.** 빌드도 되고 타입도 통과합니다. 그저 가끔 스타일이 안 먹고, 클래스 순서를 바꾸면 갑자기 먹습니다.

![333.webp](https://media.daily.dev/image/upload/s--I-CxtDal--/f_auto/v1786759937/ugc/content_4a7214e8-6d9b-467e-917b-d88983f16e16?_a=BAMAMicg0)

## 선택지는 넷이었다

**prefix를 포기한다.** 가장 간단하지만, 이 라이브러리는 사내 다른 프로젝트에서도 가져다 씁니다. 그쪽에 Tailwind가 이미 있으면 클래스가 그대로 겹칩니다. 라이브러리로 배포하는 이상 격리는 협상 대상이 아니라고 봤습니다.

**twMerge를 포기하고** `clsx`만 쓴다. 클래스를 이어 붙이기만 하고 충돌 해소는 하지 않는 방식입니다. 그러면 `className`은 "덧붙이기"는 되지만 "덮어쓰기"는 안 됩니다. 덮어쓰려면 컴포넌트가 구멍을 직접 뚫어 줘야 합니다.

```
export interface BoxButtonProps {
  variant?: 'primary' | 'highlight' | 'secondary' | 'destructive' | 'custom';
  bgColor?: string;    // variant를 무시하고 배경색 직접 지정
  textColor?: string;  // variant를 무시하고 글자색 직접 지정
}
```

```
// 클래스로는 못 이기니 인라인 style로 우회한다
style={{ backgroundColor: bgColor, color: textColor, ...style }}
```

사실 이건 가정이 아닙니다. Vue2 라이브러리의 버튼에 이미 있던 prop이고, React로 옮긴 지금도 남아 있습니다. `variant`에 `custom`이라는 값이 따로 있는 것도 같은 이유입니다.

문제는 이게 **두 개까지만 버틴다**는 겁니다. 테두리 색을 바꾸고 싶으면 `borderColor`가 필요하고, hover 배경은 인라인 style로 표현할 수도 없습니다. 결국 컴포넌트마다 prop이 늘어나고, 그 목록은 컴포넌트 수만큼 곱해집니다.

**CSS-in-JS로 간다.** 충돌 문제 자체가 사라지지만, 이건 Tailwind를 버린다는 뜻입니다. Vue2 라이브러리의 클래스 문자열을 거의 그대로 옮겨올 수 있었던 게 이번 포팅의 최대 이점이었는데, 그걸 통째로 포기하게 됩니다.

**prefix와 twMerge를 함께 쓸 방법을 찾는다.** 결국 이걸 택했습니다. 그리고 이 길에는 이미 앞선 발자국이 하나 있었습니다.

## 1세대 해법: twMerge를 속이기

Vue2 라이브러리는 이 문제를 이미 만났고, 이미 풀어두고 있었습니다. `prefixTwMerge`라는 유틸입니다.

발상은 단순하고 명확합니다. **twMerge가 prefix를 모른다면, 잠깐 떼어내면 된다.**

```
export const prefixTwMerge = (...classLists) => {
  // 1. prefix 제거: 'twl-text-R500 twl-bg-x' → 'text-R500 bg-x'
  const replaced = classLists.join(' ').replace(new RegExp(prefix, 'g'), '');
  // 2. 평범한 twMerge 실행
  const merged = twMerge(replaced);
  // 3. prefix 재부착: variant 뒤에 붙여야 한다
  return merged.split(' ').map((clz) => {
    const i = clz.lastIndexOf(':');
    return i < 0
      ? `${prefix}${clz}`                                      // text-C600 → twl-text-C600
      : `${clz.slice(0, i + 1)}${prefix}${clz.slice(i + 1)}`;  // hover:text-C850 → hover:twl-text-C850
  }).join(' ');
};
```

3번의 `lastIndexOf(':')`가 이 코드의 핵심입니다. `hover:text-C850`에 prefix를 앞에 그냥 붙이면 `twl-hover:text-C850`이 되어 버립니다. variant 뒤, 유틸리티 앞에 넣어야 합니다.

이 방식은 실제로 동작했고, 몇 년간 프로덕션에서 문제없이 굴러갔습니다. 저도 처음엔 이걸 그대로 옮길 생각이었습니다.

다만 옮기기 전에 한계를 정리해 봤습니다.

- `replace(/twl-/g, '')`는 무차별 치환입니다. 클래스 값 안에 `twl-`이 들어가면 그것도 지워집니다.
- variant를 문자열로 자르고 붙입니다. 중첩 variant가 늘어날수록 취약해집니다.
- 렌더할 때마다 join → replace → split → map → join을 거칩니다.

그리고 결정적인 게 하나 더 있었습니다.

**커스텀 유틸리티는 prefix를 떼도 twMerge가 모릅니다.** 우리 디자인 시스템에는 `yds6-TypoUi-14` 같은 자체 타이포 클래스가 있습니다. prefix를 떼면 `yds6-TypoUi-14`가 되는데, 이건 Tailwind 기본 유틸이 아니니 twMerge에게는 여전히 모르는 문자열입니다. 즉 타이포 클래스끼리는 계속 충돌 해소가 안 됩니다.

1세대 해법은 문제의 절반만 풀고 있었던 셈입니다. 그건 이 방식이 허술해서가 아니라, prefix를 떼는 접근으로는 거기까지가 한계였기 때문입니다.

## 2세대 해법: twMerge에게 가르치기

그래서 방향을 뒤집었습니다. 클래스 문자열을 건드리는 대신, **prefix가 붙은 그대로를 twMerge에게 등록하기로** 했습니다.

### twMerge는 클래스를 어떻게 분류하나

설정을 쓰기 전에 twMerge의 모델을 먼저 이해해야 했습니다. 설정 항목이 두 개인데, 이름이 비슷해서 헷갈립니다.

`classGroups`는 "이 클래스는 어느 그룹인가" 입니다. 같은 그룹에 속한 클래스가 여러 개면 마지막 하나만 남습니다. `bg-blue-500`과 `bg-red-500`이 둘 다 background 그룹이라 뒤엣것이 이기는 게 이 규칙입니다.

`conflictingClassGroups`는 **"이 그룹이 나오면 어떤 다른 그룹들을 지우는가"** 입니다. 그룹끼리의 관계죠.

둘이 나뉘어 있는 이유는 CSS 속성과 유틸리티가 1:1이 아니기 때문입니다. `p-16`은 `padding` 하나지만, 그 안에는 `padding-left·padding-right·padding-top·padding-bottom`이 다 들어 있습니다. 같은 그룹은 아닌데 서로 영향을 줍니다. 그 관계를 표현하는 게 두 번째 설정입니다.

### prefix 붙은 그대로 등록하기

모델을 알고 나면 등록 자체는 단순합니다.

```
ALL_UTILITY_KEYS.forEach((key) => {
  const groupName = `yf-${key}`;                        // 'yf-p', 'yf-gap', 'yf-bg', ...
  classGroups[groupName] = [{ [groupName]: [isAny] }];
  conflictingClassGroups[groupName] = [groupName];
});

export const tv = createTV({
  twMerge: true,
  twMergeConfig: { extend: { classGroups, conflictingClassGroups } },
});
```

문자열 조작이 사라졌습니다. 커스텀 유틸리티도 그냥 그룹에 추가하면 되니, 1세대가 못 풀던 부분도 같이 해결됩니다.

여기까지는 순조로웠는데, 실제로 등록하면서 예상 못 한 것들을 만났습니다.

### `yf-text-ellipsis`가 색상으로 분류된다

`yf-text-`로 시작하는 클래스를 한 그룹으로 묶었더니, 말줄임이 사라졌습니다.

`yf-text-14`(크기), `yf-text-center`(정렬), `yf-text-ellipsis`(오버플로), `yf-text-content-primary`(색상). 전부 `yf-text-`로 시작하지만 **CSS 속성이 다릅니다.** 한 그룹에 넣으면 서로를 밀어냅니다.

그래서 다섯 개로 쪼갰습니다. 등록 순서도 중요했습니다.

```
// 주의: catch-all인 yf-text-color(isAny)보다 먼저 등록해야
// ellipsis/clip/wrap/nowrap 등이 color 그룹에 흡수되지 않음.
classGroups['yf-text-align']    = [{ 'yf-text': TEXT_ALIGN }];
classGroups['yf-text-size']     = [{ 'yf-text': [isValue] }];
classGroups['yf-text-overflow'] = [{ 'yf-text': TEXT_OVERFLOW }];
classGroups['yf-text-wrap']     = [{ 'yf-text': TEXT_WRAP }];
classGroups['yf-text-color']    = [{ 'yf-text': [isAny] }];   // 나머지 전부
```

색상 그룹이 `isAny`, 즉 무엇이든 받는 catch-all이라 마지막에 있어야 합니다. 먼저 등록하면 `ellipsis`도 색상으로 먹어 버립니다.

![444.webp](https://media.daily.dev/image/upload/s---ZxkEyeC--/f_auto/v1786760323/ugc/content_2bf08aef-4260-440c-8f73-6185c0983bed?_a=BAMAMicg0)

같은 함정이 border에도 있었습니다. `yf-border-t`는 위쪽 두께이고, `yf-border-border-primary`는 색상입니다. 방향별로 두께 그룹과 색상 그룹을 나눠 등록해야 했습니다. `yf-grid`(display)와 `yf-grid-cols`(레이아웃)도 마찬가지였습니다.

### 계층적 충돌: `yf-p-16`은 `yf-px-8`을 덮어야 한다
가장 손이 많이 간 건 `conflictingClassGroups` 쪽이었습니다.

`padding`을 예로 들면 관계가 이렇습니다. `p`는 `px`·`py`·`pt`·`pb`·`pl`·`pr`을 전부 덮습니다. `px`는 `pl`·`pr`만 덮습니다. `py`는 `pt`·`pb`만 덮습니다. 이걸 그대로 옮겨 적었습니다.

```ts
['p', 'm'].forEach((key) => {
  const base = `yf-${key}`;
  const [x, y, t, b, l, r] = ['x','y','t','b','l','r'].map((d) => `yf-${key}${d}`);
  conflictingClassGroups[base] = [x, y, t, b, l, r];
  conflictingClassGroups[x] = [l, r];
  conflictingClassGroups[y] = [t, b];
});
```

여기서 중요한 건 이 관계가 단방향이라는 점입니다.

```ts
tw`yf-px-8 yf-p-16`           // → 'yf-p-16'           p가 px를 지운다
tw`yf-p-16 yf-px-8`           // → 'yf-p-16 yf-px-8'   px는 p를 지우지 않는다
tw`yf-pt-4 yf-pb-4 yf-py-20`  // → 'yf-py-20'          py가 pt/pb를 지운다
```

두 번째가 핵심입니다. `yf-px-8`이 `yf-p-16`을 지워 버리면 위아래 패딩까지 사라집니다. 지우지 않고 남겨 둬야 "전체 16, 좌우만 8"이 됩니다. 나머지는 CSS 캐스케이드가 알아서 합니다.

`rounded`도 같은 구조인데 한 단계 더 깊습니다. `rounded`가 여덟 방향을 전부 덮고, `rounded-t`는 `tl`·`tr`만, `rounded-l`은 `tl`·`bl`만 덮습니다. 모서리 하나가 두 개의 부모를 갖는 셈입니다.

돌아보면 이건 prefix 때문에 생긴 문제가 아닙니다. **twMerge가 원래 내부에서 하고 있던 분류를 우리가 직접 다시 하게 되면서 드러난 것**입니다. prefix를 붙이는 순간, 그 분류표를 물려받게 되는 셈입니다.

### 등록하지 않아도 되는 것도 있었다
반대로, 걱정했지만 아무것도 안 해도 되는 게 있었습니다. 커스텀 브레이크포인트입니다.

우리는 `mobile:`, `desktop:`, `under375Mobile:` 같은 자체 screens를 씁니다. 이것도 등록해야 하나 싶었는데, 확인해 보니 그냥 됩니다.

```ts
tw`mobile:yf-p-4 mobile:yf-p-8`    // → 'mobile:yf-p-8'
tw`mobile:yf-p-4 desktop:yf-p-8`   // → 'mobile:yf-p-4 desktop:yf-p-8'
```

twMerge는 variant를 이름으로 알아보는 게 아니라, “앞에 붙은 수식어 묶음이 같은가” 로 판정합니다. 그래서 처음 보는 이름이어도 같은 것끼리는 알아서 병합됩니다.

### 마지막으로, 두 줄
이 설정을 팀 전체가 매번 신경 쓸 필요는 없어야 했습니다. 그래서 템플릿 태그 하나로 감쌌습니다.

```ts
export const tw = (strings: TemplateStringsArray, ...values: any[]) =>
  tv({ base: String.raw({ raw: strings }, ...values) })();
```

이제 컴포넌트에서는 이렇게만 씁니다.

```tsx
const className = tw`yf-flex yf-items-center yf-gap-8`;
```

## 남은 것
`classGroups`는 손으로 유지해야 합니다. Tailwind 유틸리티를 새로 쓰기 시작하면 등록해 줘야 하고, 빠뜨리면 예전과 똑같이 조용히 안 먹습니다. 자동 생성할 방법을 아직 찾지 못했습니다.

`yf-border-[1.5px]` 같은 소수점 임의값도 그대로 남아 있습니다. 우리 스페이싱 스케일은 정수 픽셀만 있어서, 1.5px는 임의값으로 쓸 수밖에 없습니다.

돌아보면 두 세대의 차이는 이렇게 정리됩니다. **1세대는 twMerge를 속였고, 2세대는 가르쳤습니다.**

속이는 쪽은 도구가 내부에서 무엇을 하는지 몰라도 쓸 수 있습니다. 실제로 그렇게 몇 년을 잘 굴렀고요. 반면 가르치는 쪽은 알아야만 쓸 수 있습니다. 어떤 클래스가 어떤 그룹이고 어느 그룹이 어느 그룹을 덮는지를, 한 번은 직접 적어 봐야 합니다.

클래스 이름 앞에 세 글자를 붙인 대가로 저는 그 분류표를 물려받았습니다. 공짜인 줄 알았던 옵션 하나가 실은 그런 조건이었다는 걸, 다 만들고 나서야 알았습니다.

> _다음 편에서는 “앱이냐”와 “좁냐”를 왜 다른 질문으로 나눠야 했는지 이야기하겠습니다._

---

[View this post on daily.dev](https://daily.dev/posts/3-10-prefix-tailwind-merge--uffcv3h76)

```json
{"@context":"https://schema.org","@graph":[{"@type":"Organization","@id":"https://daily.dev/#organization","name":"daily.dev","url":"https://daily.dev","logo":{"@type":"ImageObject","url":"https://daily.dev/apple-touch-icon.png","width":180,"height":180},"sameAs":["https://twitter.com/dailydotdev","https://github.com/dailydotdev","https://www.linkedin.com/company/daily-dev-ltd"]},{"@type":"WebSite","@id":"https://daily.dev/#website","url":"https://daily.dev","name":"daily.dev","publisher":{"@id":"https://daily.dev/#organization"},"potentialAction":{"@type":"SearchAction","target":{"@type":"EntryPoint","urlTemplate":"https://daily.dev/search?q={search_term_string}"},"query-input":"required name=search_term_string"}}]}
{"@context":"https://schema.org","@type":"DiscussionForumPosting","mainEntityOfPage":"https://daily.dev/posts/3-10-prefix-tailwind-merge--uffcv3h76","headline":"항공 프론트엔드 구축기 (3/10): prefix를 붙이자 tailwind-merge가 조용히 깨졌다","text":"Discussion about \"항공 프론트엔드 구축기 (3/10): prefix를 붙이자 tailwind-merge가 조용히 깨졌다\" on daily.dev - join the developer community","url":"https://daily.dev/posts/3-10-prefix-tailwind-merge--uffcv3h76","datePublished":"2026-08-15T02:23:01.796Z","dateModified":"2026-08-15T02:23:01.796Z","author":{"@type":"Person","name":"SeyoungCho","url":"https://daily.dev/seyoungcho","image":"https://media.daily.dev/image/upload/s---QniJFEK--/f_auto/v1724901542/avatars/avatar_O3ucZSadxc0iLADSOH3rX","interactionStatistic":{"@type":"InteractionCounter","interactionType":{"@type":"EndorseAction"},"userInteractionCount":9190}},"image":"https://media.daily.dev/image/upload/s--mKlqY6M8--/f_auto/v1786760582/posts/uFFcv3h76?_a=BAMAMicg0","interactionStatistic":[{"@type":"InteractionCounter","interactionType":{"@type":"LikeAction"},"userInteractionCount":0},{"@type":"InteractionCounter","interactionType":{"@type":"CommentAction"},"userInteractionCount":0}],"isPartOf":{"@type":"WebPage","url":"https://daily.dev/squads/koreandevs","name":"Korean Devs"}}
{"@context":"https://schema.org","@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://daily.dev"},{"@type":"ListItem","position":2,"name":"Korean Devs","item":"https://daily.dev/squads/koreandevs"},{"@type":"ListItem","position":3,"name":"항공 프론트엔드 구축기 (3/10): prefix를 붙이자 tailwind-merge가 조용히 깨졌다"}]}
```

