Engineering
항공 프론트엔드 구축기 (3/10): prefix를 붙이자 tailwind-merge가 조용히 깨졌다
에릭Eric_jeong(정용욱)여기어때
2026년 8월 6일
원문에서 보기 ↗글. 정용욱(Eric) / 서비스웹개발팀

항공 프론트엔드 구축기: prefix를 붙이자 tailwind-merge가 조용히 깨졌다
안녕하세요. 서비스웹개발팀의 에릭입니다.
지난 편에서 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 파일에서 두 규칙 중 무엇이 뒤에 오느냐로 결정됩니다.
이게 이 문제의 성질입니다. 에러가 나지 않습니다. 빌드도 되고 타입도 통과합니다. 그저 가끔 스타일이 안 먹고, 클래스 순서를 바꾸면 갑자기 먹습니다.

덮어쓰기용 클래스를 얹었는데 클래스 수가 그대로인 네 가지 경우 넷 다 클래스가 하나도 안 줄었다. 그런데 배경만 바뀌고 여백과 모서리는 그대로다. 어느 쪽이 이길지는 CSS 파일 순서가 정한다.
선택지는 넷이었다
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도 색상으로 먹어 버립니다.

yf-text- 접두어를 한 그룹으로 등록했을 때와 다섯 그룹으로 쪼갰을 때의 차이
왼쪽처럼 묶으면 CSS 속성이 다른데도 서로를 밀어낸다. 오른쪽의 등록 순서가 곧 우선순위다.
같은 함정이 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만 덮습니다. 이걸 그대로 옮겨 적었습니다.
['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];
});
여기서 중요한 건 이 관계가 단방향이라는 점입니다.
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를 씁니다. 이것도 등록해야 하나 싶었는데, 확인해 보니 그냥 됩니다.
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를 이름으로 알아보는 게 아니라, “앞에 붙은 수식어 묶음이 같은가” 로 판정합니다. 그래서 처음 보는 이름이어도 같은 것끼리는 알아서 병합됩니다.
마지막으로, 두 줄
이 설정을 팀 전체가 매번 신경 쓸 필요는 없어야 했습니다. 그래서 템플릿 태그 하나로 감쌌습니다.
export const tw = (strings: TemplateStringsArray, ...values: any[]) =>
tv({ base: String.raw({ raw: strings }, ...values) })();
이제 컴포넌트에서는 이렇게만 씁니다.
const className = tw`yf-flex yf-items-center yf-gap-8`;
남은 것
classGroups는 손으로 유지해야 합니다. Tailwind 유틸리티를 새로 쓰기 시작하면 등록해 줘야 하고, 빠뜨리면 예전과 똑같이 조용히 안 먹습니다. 자동 생성할 방법을 아직 찾지 못했습니다.
yf-border-[1.5px] 같은 소수점 임의값도 그대로 남아 있습니다. 우리 스페이싱 스케일은 정수 픽셀만 있어서, 1.5px는 임의값으로 쓸 수밖에 없습니다.
돌아보면 두 세대의 차이는 이렇게 정리됩니다. 1세대는 twMerge를 속였고, 2세대는 가르쳤습니다.
속이는 쪽은 도구가 내부에서 무엇을 하는지 몰라도 쓸 수 있습니다. 실제로 그렇게 몇 년을 잘 굴렀고요. 반면 가르치는 쪽은 알아야만 쓸 수 있습니다. 어떤 클래스가 어떤 그룹이고 어느 그룹이 어느 그룹을 덮는지를, 한 번은 직접 적어 봐야 합니다.
클래스 이름 앞에 세 글자를 붙인 대가로 저는 그 분류표를 물려받았습니다. 공짜인 줄 알았던 옵션 하나가 실은 그런 조건이었다는 걸, 다 만들고 나서야 알았습니다.
다음 편에서는 “앱이냐”와 “좁냐”를 왜 다른 질문으로 나눠야 했는지 이야기하겠습니다.