Tailwind CSS 커스텀 플러그인 | API·matchUtilities·테마·레이어
이 글의 핵심
tailwind.config의 plugins 배열이 빌드 시점에 어떻게 합성되는지부터, matchUtilities·matchVariant·theme() 해석, @layer 주입 순서, 프로덕션에서 플러그인을 안전하게 쓰는 패턴까지 플러그인 ‘내부’를 한 번에 정리합니다.
들어가며
Tailwind CSS 커스텀 플러그인은 “설정 객체에 몇 줄 추가” 수준을 넘어서면, PostCSS 파이프라인 안에서 어떤 순서로 AST가 확장되는지를 알아야 디버깅이 빨라집니다. 공식 유틸을 theme.extend로 흉내 낼 수 있을 때는 그만 두는 것이 맞지만, 디자인 시스템 전용 축약 유틸, 브랜드별 variant, 서드파티 컴포넌트와의 선택자 동기화처럼 코드로 규칙을 한곳에 묶어야 할 때 플러그인이 빛을 냅니다.
이 글은 v3 계열의 tailwindcss/plugin API를 기준으로 설명합니다. v4에서 달라지는 점은 FAQ에 정리했습니다.
플러그인 API 아키텍처
설정 트리와 실행 시점
tailwind.config의 plugins 배열에는 plugin() 또는 plugin.withOptions()로 만든 플러그인이 들어갑니다. 플러그인은 두 부분으로 나뉩니다. 하나는 테마 확장 같은 설정 조각(config)이고, 다른 하나는 유틸리티를 등록하는 함수(handler)입니다. Tailwind는 먼저 모든 플러그인의 설정 조각을 사용자 설정과 병합해 최종 설정을 만든 뒤, 각 handler에 API 객체를 넘겨 실행합니다. 이 API에는 대표적으로 다음이 포함됩니다.
addBase: 전역 기본 스타일(리셋에 가까운 층,@layer base에 대응)addComponents: 컴포넌트 층(.btn같은 패턴)addUtilities: 유틸리티 층(단일 목적 클래스)theme/config: 병합된 테마·원본 설정 조회matchUtilities: 값 슬롯이 있는 유틸 패턴 등록addVariant/matchVariant: 고정 variant 또는 값을 받는 variant 등록
중요한 점은 플러그인이 “CSS 문자열을 끼워 넣는 훅”이라기보다, 내부적으로 규칙 객체를 누적하고 마지막에 레이어·우선순위·variant 확장과 함께 한 번에 CSS로 직렬화된다는 점입니다. 따라서 같은 유틸을 여러 플러그인에서 중복 정의하면, 나중에 등록된 규칙이 이기거나, important·prefix 옵션에 의해 의도와 다른 순서로 보일 수 있습니다.
플러그인 체인과 순서
플러그인이 테마를 확장하려면 handler 안에서가 아니라 config 조각으로 제공해야 합니다. config 조각은 handler가 하나라도 실행되기 전에 모두 병합되므로, plugins 배열에서 토큰을 정의하는 플러그인이 뒤에 있어도 앞의 플러그인이 theme('colors.brand')를 읽을 수 있습니다. 배열 순서가 영향을 주는 것은 같은 레이어 안에서 생성 규칙이 나열되는 순서, 즉 같은 특이도의 규칙끼리 어느 쪽이 이기느냐입니다. 반대로 handler 안에서 객체를 직접 고쳐 토큰을 만들려는 시도는 다른 플러그인의 theme()에 반영되지 않습니다.
plugin.withOptions 패턴
옵션을 받는 플러그인은 plugin.withOptions(handlerFactory, configFactory)로 만듭니다. 두 인자 모두 옵션을 받아, 첫 번째는 handler 함수를, 두 번째는 병합할 설정 객체를 돌려줍니다. 사용자는 plugins: [require('./acme')({ scale: [4, 8, 12] })]처럼 호출하고, 옵션 없이 require('./acme')만 넣어도 동작합니다.
// 옵션을 받아 theme.extend와 유틸을 동시에 주입하는 뼈대
const plugin = require('tailwindcss/plugin')
module.exports = plugin.withOptions(
// 1) handler: 유틸리티 등록
(options = {}) =>
({ addUtilities, theme }) => {
addUtilities({
'.acme-surface': {
backgroundColor: theme('colors.white'),
borderRadius: theme('borderRadius.md'),
},
})
},
// 2) config: 사용자 설정과 병합될 객체
(options = {}) => ({
theme: {
extend: {
spacing: Object.fromEntries(
(options.scale ?? [4, 8]).map((n) => [`acme-${n}`, `${n}px`])
),
},
},
})
)
위 코드는 설명용 뼈대입니다. 실제로는 다크 모드 토큰, RTL, important 전략까지 맞춰야 합니다.
matchUtilities와 matchVariant 메커니즘
정적 addUtilities의 한계
addUtilities는 이미 확정된 선택자 → 선언 맵을 넣을 때 가장 단순합니다. 그러나 mt-4, w-1/2처럼 값 슬롯이 있으며, JIT가 min-[…]·임의 값까지 처리해야 하는 패턴은 정적 맵만으로는 비대해집니다. 이때 matchUtilities가 등장합니다.
matchUtilities: 값 스케일과 생성기
matchUtilities는 유틸 이름 뒤에 붙는 값을 패턴으로 받아, 그 값에 대한 선언을 생성하는 API입니다. 내부적으로는 Tailwind의 유틸리티 파서·JIT 생성기와 연결되어, 소스에 나타난 조합만 CSS로 뽑아냅니다.
전형적인 형태는 다음과 같습니다(개념 예시).
const plugin = require('tailwindcss/plugin')
module.exports = plugin(({ matchUtilities, theme }) => {
matchUtilities(
{
'scrollbar-gutter': (value) => ({
'scrollbar-gutter': value,
}),
},
{
values: theme('scrollbarGutter') ?? {
auto: 'auto',
stable: 'stable',
both: 'stable both-edges',
},
}
)
})
values의 키가 클래스 접미어가 되므로, 위 예제는 scrollbar-gutter-stable, scrollbar-gutter-both 같은 클래스를 만듭니다. scrollbar-gutter-[stable_both-edges]처럼 대괄호 임의 값도 받으며, 이때는 함수에 대괄호 안의 값이 그대로 전달됩니다. 콜백은 해당 값에 대한 CSS 선언 객체를 돌려줍니다. JIT 엔진은 콘텐츠 스캔에서 발견된 클래스만 생성하므로, values 맵이 커도 실제로 쓰지 않은 조합은 최종 CSS에 들어가지 않습니다.
matchVariant: 사용자 정의 variant 접두
hover:, md: 같은 variant는 내부적으로 선택자 변환 규칙입니다. matchVariant는 “새 접두어를 선언하며, 그 뒤에 오는 조건 토큰을 어떻게 선택자에 녹일지”를 정의합니다. 예를 들어 data-[size=lg]: 같은 패턴이나, 임의 식별자를 받는 variant를 만들 때 유용합니다.
시그니처는 matchVariant(name, (value, { modifier }) => selector, { values, sort })입니다. 콜백은 variant 값(대괄호 임의 값 또는 values의 키에 대응하는 값)을 받아 &가 들어간 선택자 문자열을 돌려주고, & 자리에 원래 유틸리티 선택자가 들어갑니다. sort는 여러 값이 생성될 때 출력 순서를 정합니다.
// 클래스 tab-settings:block → 선택자 [data-tab="settings"] .tab-settings\:block
matchVariant('tab', (value) => `[data-tab="${value}"] &`, {
values: { settings: 'settings', profile: 'profile' },
})
실무에서는 접근성 훅(data-state, aria-*), 라우터 상태, 실험 플래그를 variant로 올려 마크업 가독성을 지키는 패턴이 많습니다. 다만 과도한 variant는 클래스명 길이·팀 학습 비용을 키우므로, 디자인 시스템에서 허용 목록을 두는 편이 안전합니다.
theme() 함수의 내부
병합된 설정을 “경로 문자열”로 읽기
theme('colors.gray.500')처럼 점 경로로 접근하면 기본 테마, 사용자 extend, 플러그인 config의 extend가 합쳐진 최종 트리를 읽습니다. 존재하지 않는 경로는 undefined가 되고, 두 번째 인자로 기본값을 줄 수 있습니다.
theme('opacity.disable', '0.5')
경로 표기와 값 형태의 함정
간격 스케일에는 2.5 같은 소수점 키가 있어서 theme('spacing.2.5')로 쓰면 경로가 2와 5로 쪼개집니다. 이때는 theme('spacing[2.5]')처럼 대괄호로 써야 합니다. 또 fontSize의 값은 문자열이 아니라 ['0.875rem', { lineHeight: '1.25rem' }] 같은 배열이므로, theme('fontSize.sm')을 그대로 font-size에 넣으면 의도한 값이 나오지 않습니다. 플러그인에서는 배열인지 확인한 뒤 첫 요소를 꺼내 써야 합니다.
theme와 config의 역할 분리
theme: 디자인 토큰 중심(색, 간격, 브레이크포인트 이름 등).config: 콘텐츠 경로,corePlugins,prefix,important,separator등 엔진 동작에 가까운 설정.
플러그인에서 토큰이 아닌 엔진 옵션을 참조해야 할 때는 config() 쪽을 열어봐야 합니다. 예를 들어 prefix를 존중한 유틸 이름을 만들 때 config('prefix')를 함께 보는 식입니다.
addBase / addComponents / addUtilities와 레이어 주입
Tailwind의 레이어 모델
Tailwind v3는 생성한 규칙을 base, components, utilities 세 버킷으로 나눠 이 순서로 출력합니다. v3의 @layer는 브라우저의 네이티브 캐스케이드 레이어가 아니라 Tailwind가 처리하는 지시어이고, 우선순위는 출력 순서로 결정됩니다. 유틸리티가 맨 뒤에 오기 때문에 같은 특이도라면 유틸리티가 컴포넌트 클래스를 이깁니다. addBase는 요소 선택자 기본값이나 리셋처럼 문서 전역 스타일을, addComponents는 카드나 입력 래퍼처럼 여러 선언을 묶은 클래스를, addUtilities는 단일 목적 클래스를 넣는 데 씁니다.
“주입”이 의미하는 것
플러그인이 addUtilities({ '.foo': { color: 'red' } })를 호출하면, 내부적으로는 유틸리티 레이어 규칙 목록에 합류합니다. 사용자 CSS에서 @tailwind utilities가 펼쳐질 때 같은 레이어 안에서 순서 규칙이 적용됩니다.
커스텀 유틸이 공식 유틸과 충돌하면, 생성 순서·레이어·important 전략에 따라 의도와 다른 승자가 됩니다. 따라서 네이밍 충돌(짧은 클래스명)을 피하며, 필요하면 prefix를 쓰는 것이 프로덕션에서 안전합니다.
@layer와 소스 CSS 파일의 관계
사용자 진입 CSS에서 @tailwind base; @tailwind components; @tailwind utilities;를 선언하면, 플러그인이 주입한 규칙은 각 지시어 위치에 펼쳐집니다. v3에서 @layer 없이 같은 파일에 쓴 일반 CSS는 작성한 위치에 그대로 남으므로, @tailwind utilities 뒤에 쓰면 같은 특이도의 유틸리티를 이깁니다. v4에서는 네이티브 캐스케이드 레이어를 쓰기 때문에, 레이어 밖의 일반 CSS가 위치와 상관없이 모든 레이어보다 우선합니다. 플러그인 유틸이 “적용되지 않는다”는 문제는 대부분 이 차이에서 생기므로, 커스텀 규칙은 해당 @layer 안에 두는 것이 안전합니다.
프로덕션 플러그인 패턴
접두·중요도·다크 모드 일관성
addUtilities·addComponents·matchUtilities로 등록한 클래스에는 사용자의 prefix 설정이 자동으로 적용되지만, addBase나 handler 안에서 직접 만든 선택자 문자열에는 적용되지 않습니다. 레거시 CSS와 충돌을 피하려고 prefix를 쓰는 프로젝트라면 이 차이를 확인해야 합니다. important도 마찬가지로, true면 모든 유틸 선언에 !important가 붙고 '#app' 같은 선택자를 주면 유틸 선택자 앞에 그 선택자가 붙어 특이도가 올라가므로, 서드파티 위젯 CSS와의 승패가 달라집니다. 다크 모드는 darkMode 설정('media', 'class', v3.4부터 'selector')에 따라 dark: variant의 선택자가 달라지므로, 플러그인에서 다크 모드 전용 variant를 따로 만들 때는 같은 전략을 따라야 합니다.
JIT·content·safelist와 플러그인
플러그인이 아무리 훌륭한 유틸을 추가해도, 소스에 클래스 문자열이 잡히지 않으면 JIT는 CSS를 만들지 않습니다. 동적 템플릿 문자열로 클래스를 조합하는 코드와 함께 쓸 때는 safelist 또는 완전한 클래스 맵 패턴이 필요합니다. 플러그인 제작자라면 README에 “이 유틸은 문자열 동적 결합 시 safelist 필요”를 명시하는 것이 좋습니다.
성능과 유지보수
JIT 덕분에 큰 values 맵이 CSS 크기를 키우지는 않지만, 설정을 읽고 리뷰하기는 어려워집니다. 스케일 값은 theme에 토큰으로 두고 matchUtilities는 theme()로 읽기만 하게 만들면 토큰 변경이 한곳에서 끝납니다. matchVariant가 임의 값을 그대로 선택자에 넣으면 의도하지 않은 선택자가 생길 수 있으므로, 허용할 값만 통과시키는 검사를 두는 편이 좋습니다. 내부 플러그인은 package.json의 peerDependencies에 지원하는 Tailwind 버전 범위를 적어 두면 업그레이드 때 API 차이를 놓치지 않습니다.
테스트 전략
플러그인 테스트는 PostCSS로 Tailwind를 실제로 돌려, 대표 HTML 조각을 content로 넘기고 생성된 CSS에 기대한 규칙이 들어 있는지 스냅샷으로 확인하는 방식이 가장 확실합니다. prefix나 important를 쓰는 소비 프로젝트가 있다면 그 설정 조합도 한두 개 테스트에 포함합니다.