핵심 요약
Tailwind CSS는 JavaScript를 실행해 런타임 문자열을 예측하지 않고, 소스 파일을 일반 텍스트로 스캔해 발견한 완성된 클래스 토큰만 생성합니다. 따라서 bg-${color}-500처럼 조각을 합친 className은 실패할 수 있습니다. 가능한 값이 정해졌다면 완성된 클래스 매핑을 먼저 사용하고, 완성된 클래스가 들어 있는 파일이 스캔 범위 밖일 때만 @source를 추가합니다. 소스 어디에도 존재할 수 없는 제한된 후보만 @source inline()으로 생성합니다.
- 증상부터 감지 문제와 CSS 충돌로 구분하기
- Tailwind가 클래스를 감지하는 원리
- 동적 조립 대신 완성된 클래스 매핑하기
- v4 자동 감지 범위와 제외 대상 확인하기
@source로 모노레포·외부 패키지 등록하기- CMS·JSON·API 상태를 안전한 variant로 바꾸기
@source inline()과 brace expansion 사용하기- 런타임 arbitrary value는 CSS 변수로 전달하기
- 원인별 해결 순서와 FAQ
증상부터 감지 문제와 CSS 충돌로 구분하기
화면에 스타일이 보이지 않는다고 모두 클래스 감지 실패는 아닙니다. 먼저 개발자 도구의 Elements에서 최종 class 속성을 보고, Styles 또는 Computed 패널에서 해당 CSS 규칙이 존재하는지 확인합니다.

| 확인 결과 | 가능성이 큰 원인 | 다음 행동 |
|---|---|---|
| DOM에 원하는 클래스 자체가 없음 | React 조건식·props 전달·오타 문제 | 컴포넌트의 최종 className 값을 확인 |
| DOM에는 있지만 해당 CSS 규칙이 없음 | Tailwind 감지 누락 또는 동적 문자열 조립 | 완성된 토큰과 스캔 범위를 확인 |
| CSS 규칙은 있지만 다른 규칙이 이김 | 충돌 유틸리티·specificity·순서 문제 | 동시에 붙은 상충 클래스를 하나로 줄임 |
| 개발에서는 되고 배포에서만 실패 | 빌드 기준 경로, 모노레포 CWD, 생성 파일 차이 | 실제 배포 명령 위치와 source base path 확인 |
| CMS에서 새 값이 들어올 때만 실패 | 빌드 시점에 후보 클래스가 없었음 | 허용 variant 매핑 또는 좁은 inline allowlist 적용 |
이 순서를 생략하고 곧바로 safelist를 넓히면 JSX 버그나 CSS 충돌을 숨길 수 있습니다. 감지 문제라면 빌드 결과에 해당 utility가 없다는 증거가 있어야 합니다.
Tailwind가 클래스를 감지하는 원리
Tailwind 공식 문서는 소스 파일을 코드로 파싱하거나 실행하지 않고 일반 텍스트로 취급한다고 설명합니다. 즉, React의 template literal 결과를 계산하지 않습니다. 다음 코드는 브라우저에서는 bg-red-500이라는 문자열을 만들지만 소스에는 그 완성된 토큰이 없으므로 CSS가 생성되지 않을 수 있습니다.
type BadgeProps = { color: 'red' | 'blue' };
function Badge({ color }: BadgeProps) {
return (
<span className={`bg-${color}-500 px-2 py-1 text-white`}>
상태
</span>
);
}
반면 조건식의 양쪽에 완성된 클래스가 있으면 Tailwind가 두 후보를 모두 발견할 수 있습니다.
function ToggleBadge({ active }: { active: boolean }) {
return (
<span
className={
active
? 'bg-blue-600 px-2 py-1 text-white'
: 'bg-slate-200 px-2 py-1 text-slate-700'
}
>
{active ? '활성' : '비활성'}
</span>
);
}
clsx, 배열의 filter(Boolean).join(' '), 템플릿 리터럴은 모두 최종 문자열을 조합하는 도구입니다. 이 도구들이 bg-${color}-500을 Tailwind가 감지할 수 있는 정적 후보로 바꿔주지는 않습니다.
동적 조립 대신 완성된 클래스 매핑하기
props의 가능한 값이 정해져 있다면 가장 안정적인 해법은 각 값에 완성된 클래스 문자열을 매핑하는 것입니다. Tailwind가 후보를 찾을 수 있고, TypeScript가 허용되지 않은 값을 막으며, 디자인 규칙도 한곳에서 확인할 수 있습니다.
const badgeClassByStatus = {
pending: 'border-yellow-200 bg-yellow-100 text-yellow-800',
paid: 'border-green-200 bg-green-100 text-green-800',
failed: 'border-red-200 bg-red-100 text-red-800',
} as const;
type OrderStatus = keyof typeof badgeClassByStatus;
function OrderBadge({ status }: { status: OrderStatus }) {
return (
<span
className={`rounded-full border px-2 py-1 text-xs font-medium ${badgeClassByStatus[status]}`}
>
{status}
</span>
);
}
색상 이름을 UI API로 그대로 노출하기보다 success, warning, danger처럼 의미 기반 variant로 바꾸면 나중에 팔레트가 바뀌어도 사용처를 수정하지 않아도 됩니다.
const buttonClassByVariant = {
primary: 'bg-slate-900 text-white hover:bg-slate-700',
secondary: 'border border-slate-300 bg-white text-slate-900 hover:bg-slate-50',
danger: 'bg-red-600 text-white hover:bg-red-500',
} as const;
type ButtonVariant = keyof typeof buttonClassByVariant;
type ButtonProps = {
variant?: ButtonVariant;
children: React.ReactNode;
};
function Button({ variant = 'primary', children }: ButtonProps) {
return (
<button
type='button'
className={`rounded-lg px-4 py-2 font-medium ${buttonClassByVariant[variant]}`}
>
{children}
</button>
);
}
매핑 객체의 key와 클래스 후보가 소스에 그대로 남기 때문에 코드 리뷰와 검색도 쉬워집니다. 조건부 className을 어디까지 나눌지는 React Tailwind className 정리에서 컴포넌트 규모별 기준을 이어서 확인할 수 있습니다.
v4 자동 감지 범위와 제외 대상 확인하기
Tailwind CSS v4는 프로젝트 소스를 자동 감지하지만 모든 파일을 무조건 읽지는 않습니다. 공식 기준상 다음 대상은 기본 감지에서 제외됩니다.
.gitignore에 포함된 파일node_modules디렉터리- 이미지·영상·압축 파일 같은 바이너리
- CSS 파일
- 일반적인 package manager lock 파일
또한 기본 스캔 시작점은 보통 현재 작업 디렉터리입니다. 모노레포 루트에서 빌드하는 앱과 앱 폴더에서 빌드하는 로컬 환경의 기준 경로가 다르면 배포에서만 클래스가 빠질 수 있습니다. import의 source()로 base path를 명시할 수 있습니다.
@import 'tailwindcss' source('../src');
v3의 content 배열을 그대로 v4 문법으로 옮기는 문제가 아니라, 현재 사용하는 Tailwind major version과 빌드 도구, CSS entry 위치를 먼저 확인해야 합니다. v3 프로젝트라면 해당 버전 문서를 기준으로 content 설정을 유지합니다.
@source로 모노레포·외부 패키지 등록하기
@source는 동적 문자열을 해석하는 기능이 아닙니다. 완성된 Tailwind 클래스가 들어 있지만 자동 감지에서 빠진 파일이나 경로를 명시적으로 등록합니다. 경로는 stylesheet를 기준으로 작성합니다.
node_modules의 공용 UI 패키지 등록
@import 'tailwindcss';
@source '../node_modules/@acme/ui-components';
모노레포의 외부 packages/ui 등록
@import 'tailwindcss';
@source '../../packages/ui/src';
자동 감지를 끄고 범위를 직접 관리
@import 'tailwindcss' source(none);
@source '../app';
@source '../components';
불필요한 레거시 경로 제외
@import 'tailwindcss';
@source not '../src/components/legacy';
source(none)은 여러 stylesheet가 서로 다른 utility 집합을 가져야 하는 경우에는 유용하지만, 새 폴더를 등록하지 않으면 화면 전체 스타일이 누락될 수 있습니다. 일반 앱에서는 자동 감지를 유지하고 확인된 누락 경로만 추가하는 편이 관리하기 쉽습니다.
CMS·JSON·API 상태를 안전한 variant로 바꾸기
빌드 시 로컬 JSON 파일 안에 완성된 클래스가 있고 그 파일이 스캔 범위에 포함된다면 Tailwind가 토큰을 발견할 수 있습니다. 반면 배포 후 원격 CMS나 API가 처음 보내는 클래스명은 빌드 시점에 존재하지 않으므로 CSS가 생성되지 않습니다. 외부 데이터에 raw Tailwind 클래스를 맡기기보다 제한된 도메인 값을 앱의 variant로 변환합니다.
type ApiStatus = 'PENDING' | 'PAID' | 'FAILED';
type BadgeVariant = 'warning' | 'success' | 'danger';
const variantByApiStatus = {
PENDING: 'warning',
PAID: 'success',
FAILED: 'danger',
} satisfies Record<ApiStatus, BadgeVariant>;
const classByVariant = {
warning: 'bg-amber-100 text-amber-900 ring-amber-200',
success: 'bg-emerald-100 text-emerald-900 ring-emerald-200',
danger: 'bg-rose-100 text-rose-900 ring-rose-200',
} satisfies Record<BadgeVariant, string>;
function ApiStatusBadge({ status }: { status: ApiStatus }) {
const variant = variantByApiStatus[status];
return (
<span className={`rounded-full px-2 py-1 text-xs ring-1 ${classByVariant[variant]}`}>
{status}
</span>
);
}
이 구조는 API 응답이 예상 범위를 벗어났을 때 검증 단계에서 걸러낼 수 있고, 콘텐츠 운영자가 디자인 시스템 밖의 utility를 임의로 주입하는 문제도 줄입니다. CMS에서 색상 전체를 선택해야 한다면 허용 팔레트를 명시하고 실제 사용처와 함께 관리합니다.
@source inline()과 brace expansion 사용하기

소스 파일에는 후보가 없지만 특정 utility를 반드시 생성해야 한다면 @source inline()으로 좁게 등록할 수 있습니다. 단일 후보는 다음처럼 작성합니다.
@import 'tailwindcss';
@source inline('underline');
여러 상태와 색상 범위를 생성할 때는 GNU brace expansion과 유사한 문법을 사용할 수 있습니다. 아래 예제는 기본·hover·focus 상태의 red 배경 50, 100부터 900까지 100 간격, 950을 생성합니다.
@import 'tailwindcss';
@source inline('{hover:,focus:,}bg-red-{50,{100..900..100},950}');
편리하다는 이유로 모든 색상과 모든 상태를 열어두면 쓰지 않는 CSS와 유지보수 범위가 함께 커집니다. 먼저 정적 매핑, 다음으로 실제 source path 누락, 마지막으로 inline allowlist 순서로 판단합니다. 명시적으로 생성하지 않을 후보는 @source not inline()으로 제외할 수도 있지만, 광범위한 포함·제외 규칙이 겹치지 않게 테스트해야 합니다.
런타임 arbitrary value는 CSS 변수로 전달하기
서버에서 hex 색상처럼 사실상 무한한 값을 받아야 할 때 bg-[${color}]를 런타임에 조립해도 Tailwind가 그 결과를 미리 생성할 수 없습니다. 클래스 토큰은 정적으로 유지하고 실제 값만 inline CSS 변수로 전달할 수 있습니다.
import type { CSSProperties } from 'react';
type ColorBadgeProps = {
label: string;
color: string;
};
function ColorBadge({ label, color }: ColorBadgeProps) {
const style = { '--badge-color': color } as CSSProperties;
return (
<span
style={style}
className='rounded-full bg-[var(--badge-color)] px-2 py-1 text-white'
>
{label}
</span>
);
}
bg-[var(--badge-color)]은 소스에 완성된 토큰으로 남고, 값만 런타임에 바뀝니다. 외부 입력을 style에 전달할 때는 허용 형식과 대비, 보안 정책을 검증해야 합니다. 제한된 브랜드 팔레트라면 CSS 변수보다 의미 기반 정적 매핑이 더 읽기 쉽습니다.
도구별 역할을 섞지 않기
| 도구 | 해결하는 문제 | 해결하지 않는 문제 |
|---|---|---|
| 삼항식·정적 객체 매핑 | 허용 variant와 완성 클래스 표현 | 스캔 범위 밖 파일 등록 |
clsx·배열 join |
조건에 따른 className 문자열 조합 | 동적 토큰 감지와 Tailwind 충돌 제거 |
@source |
자동 감지 밖의 파일·패키지 등록 | bg-${color}-500 실행 |
@source inline() |
소스에 없는 제한된 utility 강제 생성 | 무제한 런타임 값 추론 |
| CSS 변수 + 정적 arbitrary utility | 연속적인 런타임 값 전달 | 허용 값 검증과 접근성 보장 |
원인별 해결 순서
- Elements에서 최종
class문자열이 의도대로 붙었는지 확인합니다. - CSS 규칙이 생성됐는지 Styles 패널과 빌드 결과에서 확인합니다.
- 소스에
bg-${color}-500같은 조각난 클래스가 있는지 검색합니다. - 가능한 값이 정해졌다면 완성된 클래스 객체 매핑으로 바꿉니다.
- 클래스가 들어 있는 파일이
.gitignore,node_modules, 모노레포 외부 경로에 있는지 확인합니다. - 빌드 명령의 CWD가 다르면 import의
source()base path를 명시합니다. - 확인된 외부 경로만
@source로 등록합니다. - 소스에 존재할 수 없는 제한된 후보만
@source inline()으로 생성합니다. - 개발·프로덕션 빌드에서 hover, focus, responsive, dark 상태까지 확인합니다.
기본 utility 자체가 전혀 적용되지 않는다면 Tailwind CSS 클래스 적용 오류 체크리스트에서 import와 build pipeline부터 확인하고, 전체 학습 순서는 Tailwind CSS 실무 로드맵으로 이어서 볼 수 있습니다.
자주 묻는 질문
clsx를 쓰면 동적 클래스 문제가 해결되나요?
완성된 후보를 조건부로 조합하는 데는 유용하지만 조각난 클래스명을 Tailwind가 감지하도록 만들지는 않습니다. clsx(active && 'bg-blue-600')은 괜찮지만 clsx(`bg-${color}-600`)은 같은 문제가 남습니다.
@source에 프로젝트 전체를 넣으면 안전한가요?
문제는 가려질 수 있지만 불필요한 파일까지 스캔하고 원인을 추적하기 어려워집니다. 실제로 감지에서 빠진 경로만 등록합니다.
CMS가 className을 직접 보내도 되나요?
빌드 이후 처음 등장한 utility는 CSS가 없을 수 있고 디자인·보안 통제도 약해집니다. CMS에는 의미 기반 variant를 저장하고 앱에서 정적 클래스 매핑으로 변환하는 편이 안전합니다.
Tailwind v3에서도 같은 문법을 쓰나요?
@source는 v4 기준입니다. v3 프로젝트는 해당 버전의 content와 safelist 문서를 기준으로 설정해야 합니다.
공식 문서와 함께 확인하기
- Tailwind CSS: Detecting classes in source files — 정적 감지, 제외 대상,
@source,inline()기준 - Tailwind CSS: Styling with utility classes — utility 생성, 충돌, CSS 변수 활용 기준
동적 클래스 문제의 핵심은 라이브러리 선택이 아니라 빌드 시 Tailwind가 완성된 후보를 볼 수 있는가입니다. 정적 매핑으로 UI 규칙을 먼저 고정하고, source path와 inline allowlist는 확인된 예외만 다루면 모노레포와 CMS에서도 재현 가능한 스타일 빌드를 만들 수 있습니다.
“Tailwind CSS 동적 클래스 해결: 감지 원리와 @source 사용법”에 대한 6개의 생각