Tailwind에서 동적 스타일코드 씹힘 현상 해결하기

.DS_Store·2025년 10월 11일

개발기

목록 보기
6/7
post-thumbnail

Next.js 15 + Tailwind 4 환경에서 재사용 가능한 Gap 컴포넌트를 만들다가 예상치 못한 벽에 부딪혔다. "왜 이렇게 간단한 컴포넌트가 동작을 안 하지?"라는 의문에서 시작해, 해결 과정을 정리해본다.

문제의 시작: 동작하지 않는 Gap 컴포넌트

프로젝트에서 반복되는 flex 레이아웃 패턴을 정리하기 위해 공통 컴포넌트를 만들었다:

interface GapProps {
  direction?: "flex-row" | "flex-col";
  children?: React.ReactNode | React.ReactNode[];
  gap: string;
  items?: "center" | "start" | "end" | "between" | "around" | "evenly";
  justify?: "center" | "start" | "end" | "between" | "around" | "evenly";
  width?: string;
  height?: string;
}

export default function Gap({
  direction,
  children,
  gap,
  items = "center",
  justify = "center",
  width = "auto",
  height = "auto",
}: GapProps) {
  return (
    <div
      className={`flex ${direction} items-${items} justify-${justify} w-${width} h-${height} gap-${gap}`}
    >
      {children}
    </div>
  );
}

재사용 가능해 보였다. 그런데 막상 사용해보니:

<Gap direction="flex-col" gap="40" width="full">  
  <Gap direction="flex-col" gap="20">
    <Image src="/logo.svg" alt="logo" width={100} height={100} />
    <p className="text-center text-head24 text-gray-900">
      복잡한 일정 조율, 만나에서 심플하게!
    </p>
  </Gap>

  <Gap direction="flex-col" gap="20" width="full">
    <button>카카오로 시작하기</button>
    <button>Google로 시작하기</button>
  </Gap>
</Gap>

바깥쪽 gap="40"이 완전히 무시되었다..! ㅠㅠ

더 이상한 점: 일관성 없는 동작

개발자 도구로 확인해보니 신기한 현상이 발견되었다:

<Gap gap="40">           {/* 동작 안 함! */}
  <Gap gap="20">         {/* 동작함 (?!) */}
    ...
  </Gap>
  <Gap gap="20">         {/* 동작함 (?!) */}
    ...
  </Gap>
</Gap>
  • 안쪽 gap-20: CSS가 정상 생성되어 동작함
  • 바깥쪽 gap-40: 클래스 자체가 생성되지 않음

같은 컴포넌트, 같은 방식인데 왜 어떤 건 되고 어떤 건 안 될까?

정확한 원인은 파악하지 못했지만, 동적으로 클래스를 생성하는 과정에서 Tailwind의 JIT 컴파일러가 일부 클래스를 누락하는 것으로 보인다. 이런 비일관적인 동작은 더 큰 문제다. 에러가 명확하게 나면 바로 알아차릴 수 있지만, "어떤 때는 되고 어떤 때는 안 되는" 상황은 디버깅을 매우 어렵게 만든다. ㅠㅠ

추정 원인: Tailwind는 동적 클래스를 인식하지 못한다

추정한 핵심 원인은 Tailwind의 JIT(Just-In-Time) 컴파일러가 동적으로 생성된 클래스명을 인식하지 못한다는 것이다.

className={`flex ${direction} items-${items} gap-${gap}`}

이렇게 템플릿 리터럴로 클래스를 조합하면, Tailwind는 빌드 시점에 코드를 스캔할 때 gap-40이라는 완전한 클래스명을 찾을 수 없다. 그저 gap-${gap} 패턴만 보일 뿐이다.

Tailwind의 클래스 감지 원리

Tailwind는 빌드 시점에 모든 파일을 스캔해서 완전한 클래스명만 CSS로 생성한다:

  • className="gap-4 flex-col" → 감지됨
  • className={condition ? "gap-4" : "gap-8"} → 둘 다 감지됨
  • className={\gap-${value}`}` → 감지 안 됨

참고 링크: https://tailwindcss.com/docs/detecting-classes-in-source-files

이는 Tailwind는 빌드 시점에 사용된 클래스만 생성함으로써 최종 CSS 번들 크기를 최소화할 수 있기 때문이다.

해결책: CSS 변수 + 인라인 스타일

디자인 시스템에 --spacing 변수를 정의해두었기 때문에, 인라인 스타일로 직접 적용하는 방식을 선택했다.
그리고 이외에도 동적으로 생성하던 flex-${direction} 같은 코드들도 전부 제거했다.

Global CSS 설정

먼저 내 디자인 시스템 설정:

@import "tailwindcss";

:root {
  --background: #f5f5f5;
  --foreground: #171717;
  font-size: 16px;
}

@theme {
  /* 디자인 기준 16px: 1rem */
  /* w-, h-, gap-, p- 등에서 디자인 숫자만 입력하여 사용 */
  --spacing: calc(1 / 16 * 1rem);
}

최종 Gap 컴포넌트

interface GapProps {
  direction?: "row" | "col";
  children?: React.ReactNode | React.ReactNode[];
  gap: number;
  items?: "center" | "start" | "end";
  justify?: "center" | "start" | "end" | "between" | "around" | "evenly";
  width?: "auto" | "full" | "fit";
  height?: "auto" | "full" | "fit";
  className?: string; // 추가로 필요한 경우를 위해
}

export default function Gap({
  direction = "row",
  children,
  gap,
  items = "center",
  justify = "center",
  width = "auto",
  height = "auto",
  className,
}: GapProps) {
  const classes = [
    "flex",
    direction === "row" ? "flex-row" : "flex-col",
    items === "center" && "items-center",
    items === "start" && "items-start",
    items === "end" && "items-end",
    justify === "center" && "justify-center",
    justify === "start" && "justify-start",
    justify === "end" && "justify-end",
    justify === "between" && "justify-between",
    justify === "around" && "justify-around",
    justify === "evenly" && "justify-evenly",
    width === "full" && "w-full",
    width === "fit" && "w-fit",
    width === "auto" && "w-auto",
    height === "full" && "h-full",
    height === "fit" && "h-fit",
    height === "auto" && "h-auto",
    className,
  ]
    .filter(Boolean)
    .join(" ");

  return (
    <div className={classes} style={{ gap: `calc(${gap} * var(--spacing))` }}>
      {children}
    </div>
  );
}

핵심 포인트:

  • gap prop을 number 타입으로 받아서 디자인 숫자 그대로 사용
  • calc(${gap} * var(--spacing))로 계산하여 rem 단위 적용
  • gap={40}calc(40 * 1/16 * 1rem)2.5rem (40px)

이 방식의 장점

  1. 디자인 시스템과 완벽한 호환: --spacing 변수를 그대로 활용
  2. 유연성: Tailwind에서 미리 정의하지 않은 값도 자유롭게 사용 가능 (예: gap={35})
  3. 일관성: 나중에 --spacing 값을 변경하면 모든 Gap에 자동 반영
  4. 타입 안정성: 숫자로 받아 실수 방지

대안들과 비교

옵션1: 모든 경우의 수 매핑

className={clsx(
  "flex",
  {
    "gap-4": gap === 4,
    "gap-8": gap === 8,
    "gap-12": gap === 12,
    "gap-16": gap === 16,
    "gap-20": gap === 20,
    "gap-40": gap === 40,
    // 필요한 모든 값 추가...
  }
)}

단점: 사용 가능한 값이 제한되고, 코드가 길어진다.

옵션2: className 직접 사용

<div className="flex flex-col gap-40 items-center justify-center w-full">
  <div className="flex flex-col gap-20 items-center justify-center">
    ...
  </div>
</div>

단점: 반복적이고, 디자인 시스템 규칙을 강제하기 어렵다.

성능 비교: 컴포넌트 vs 직접 className

"그래도 인라인 스타일이면 성능이 안 좋지 않을까?" 하는 의문이 들었다.

렌더링 성능

  • className 방식: 브라우저가 CSS 규칙을 한 번만 파싱하고 재사용
  • inline style 방식: 각 요소마다 style 객체를 파싱

이론적으로 className이 약간 빠르지만, 실제로는:

  • 모던 브라우저의 최적화로 차이가 거의 없음
  • gap 하나 정도의 간단한 속성은 측정 불가능한 수준

CSS 번들 크기

만약 gap-4, gap-8, gap-12, ..., gap-40 등 10개 값을 사용한다면:

  • Tailwind CSS: 각 클래스당 ~25 bytes × 10개 = ~250 bytes
  • Inline style: 0 bytes (HTML에만 존재)

사실 큰 차이는 아니지만, 많은 값을 사용할수록 inline style이 유리하다.

실용적 결론

성능 차이는 무시 가능한 수준이고, 오히려:

  • 코드의 명확성: <Gap gap={40}> vs className="gap-40"
  • 유지보수성: 한 곳에서 관리
  • 디자인 시스템 일관성: spacing 규칙 강제
  • 유연성: 임의의 값 사용 가능

이런 장점들이 훨씬 크다고 판단했다.

언제 className을 직접 사용할까?

물론 항상 컴포넌트가 답은 아니다. 이런 경우는 직접 className 사용할 예정이다.

// 한두 번만 사용하는 특수한 레이아웃
<div className="flex flex-col gap-8 p-4 bg-white rounded-lg shadow-md hover:shadow-lg">
  ...
</div>

// Tailwind의 다른 유틸리티와 복잡하게 조합
<div className="flex items-center gap-4 md:gap-6 lg:gap-8">
  ...
</div>

마무리

Styled-components같은 동적 스타일링 방식에 익숙해져있다 Next를 주로 사용하며 Tailwind 사용법을 익히려하니 생각보다 주의할 게 많음을 배웠다..!

같은 문제로 고민하는 분들께 도움이 되길 바라며..!

0개의 댓글