Frontend/🔨 JS

Tailwind-a11y 개발 기록 2편 (Babel AST로 Tailwind 접근성 분석기 만든 과정)

haeunkim.on 2026. 7. 27. 18:40

https://kimhaeun.com/136

 

Tailwind-a11y 개발 기록 (라이브러리 npm 배포!)

Tailwind 접근성 검사 라이브러리를 처음 만들어 npm에 배포해봤다.프론트엔드 개발자로 일하면서 언젠가는 작은 오픈소스 라이브러리 하나쯤은 직접 만들어 보고 싶다는 생각을 계속 하고 있었다

kimhaeun.com

1편에서는 tailwind-a11y를 만든 계기나 전반적인 내용을 적었다면 이 글은 기술적인 부분을 기록하는 것이 주가 될 예정이다. JSX를 파싱하는 부분, Tailwind 클래스를 CSS 값으로 변환하는 로직, 그리고 같은 패턴으로 두 번 발생한 미탐 버그의 원인을 코드와 함께 정리했다.

분석 구조

tailwind-a11y의 분석 과정은 세 단계로 나뉜다.

  1. 파싱: Babel로 JSX 소스코드를 AST로 만든다.
  2. 추출: AST를 순회하면서 각 엘리먼트가 어떤 Tailwind 클래스를 갖고 있는지 뽑아낸다.
  3. 판정: 추출한 클래스 이름을 실제 CSS 값(hex 색상, px 크기)으로 변환해서 WCAG 기준과 비교한다.

(이번 글에서 다룰 두 버그는 모두 2단계와 3단계의 경계에서 발생했다.) 2단계는 이 문자열이 색상 클래스처럼 생겼는가까지 판단하고, 실제 값 해석은 3단계에서 한다.

AST 파싱

@babel/parser로 소스코드를 파싱하면 JSX가 트리 구조의 노드로 바뀐다.

import { parse } from "@babel/parser";

const ast = parse(code, {
  sourceType: "module",
  plugins: ["jsx", "typescript"],
});

<p className="text-gray-400">hi</p>는 다음과 비슷한 구조가 된다.

JSXElement
└─ openingElement: JSXOpeningElement
   ├─ name: JSXIdentifier ("p")
   └─ attributes: [
        JSXAttribute
        ├─ name: JSXIdentifier ("className")
        └─ value: StringLiteral ("text-gray-400")
      ]

 

@babel/traverse는 이 트리를 순회하면서 원하는 노드 타입마다 콜백을 실행한다.

import traverse from "@babel/traverse";

traverse(ast, {
  JSXElement(path) {
    // ...
  },
});

 

path로 현재 노드의 부모, 형제 노드에 접근할 수 있다. 부모 엘리먼트의 배경색을 찾을 때 이 기능을 그대로 사용한다.

참고: @babel/traverse import 이슈

moduleResolution: "NodeNext" 환경에서 @babel/traverse를 import할 때 문제가 있었다.

이 패키지는 CJS이고 환경에 따라 default import가 함수 자체가 아니라 { default: 함수 } 형태로 들어오는 경우가 있다.

import _traverse from "@babel/traverse";

const traverse = (
  typeof _traverse === "function" ? _traverse : (_traverse as any).default
) as unknown as TraverseFn;

 

타입 선언과 런타임 동작이 일치하지 않는 경우로, 실행해서 직접 확인해야 알 수 있었다.

className 정적 분석

className의 값이 항상 문자열은 아니다. 삼항연산자나 템플릿 리터럴을 쓰면 값 타입이 JSXExpressionContainer가 된다.

이 라이브러리에서는 값이 정적 문자열(StringLiteral)이 아닌 경우 전부 분석 대상에서 제외하기로 했다. 

export function getStaticClassName(
  attributes: t.JSXOpeningElement["attributes"]
): string | null {
  const attr = attributes.find(
    (a): a is t.JSXAttribute => t.isJSXAttribute(a) && a.name.name === "className"
  );
  if (!attr || !attr.value) return null;
  if (t.isStringLiteral(attr.value)) return attr.value.value;
  return null;
}

 

동적 클래스는 값을 추론하지 않고 null을 반환한다.

색상 클래스 추출

Tailwind는 text-/bg- 접두어를 색상 이외의 용도로도 많이 쓴다. text-lg, text-center, bg-cover, bg-gradient-to-r 등이 그렇다.

색상이 아닌 것들을 하나씩 제외하는 방식 대신, "색상 클래스는 이런 모양이다"라는 정규식을 먼저 만들었다.

const COLOR_TOKEN =
  /^\[(#[0-9a-fA-F]{3,8})\]$|^[a-z]+-\d{2,3}(\/\d{1,3})?$|^(white|black|transparent|current|inherit)$/;

 

이 정규식은 세 가지 형태를 허용한다.

  • [#123456] 형태의 임의 hex 값
  • 단어-숫자 형태 (gray-400, blue-500 등)
  • white, black 같은 시맨틱 색상 키워드

클래스 목록에서 마지막으로 매칭된 text-*/bg-* 토큰을 하나 선택한다. hover:, dark: 같은 variant 접두어는 제거하고 판단한다.

function lastColorToken(className: string, prefix: "text" | "bg"): string | null {
  let found: string | null = null;
  for (const raw of className.split(/\s+/).filter(Boolean)) {
    const base = raw.slice(raw.lastIndexOf(":") + 1);
    if (!base.startsWith(`${prefix}-`)) continue;
    const rest = base.slice(prefix.length + 1);
    if (!COLOR_TOKEN.test(rest)) continue;
    found = base;
  }
  return found;
}

 

여기까지가 추출 단계이고, 클래스를 찾았다는 사실만 확인했을 뿐, 이 값이 실제 색상값인지는 아직 확인하지 않았다. (bg-white인건 파싱으로 알아냈지만 #ffffff인지는 확인하지 못한 상태) 

색상 값 변환

판정 단계에서는 추출한 클래스 이름을 실제 색상 값으로 변환한다.

export function resolveColorValue(utilityClass: string): string | null {
  const match = /^(?:text|bg)-(.+)$/.exec(utilityClass);
  if (!match) return null;
  const token = match[1];

  const arbitrary = /^\[(#[0-9a-fA-F]{3,8})\]$/.exec(token);
  if (arbitrary) return arbitrary[1];
  if (token.startsWith("[")) return null;      // url()/var() 같은 비-hex 임의값
  if (token.includes("/")) return null;        // bg-white/50 같은 opacity 표기

  if (token in semanticColors) return semanticColors[token];

  const [scale, shade] = token.split("-");
  if (!scale || !shade) return null;
  return defaultPalette[scale]?.[shade] ?? null; // 팔레트에 없는 커스텀 색상
}

 

defaultPalette는 Tailwind 기본 색상 팔레트를 { gray: { "400": "#9ca3af", ... }, ... } 형태로 담은 객체다.

이 값은 직접 입력하지 않고, tailwindcss 패키지를 설치해서 tailwindcss/colors 모듈에서 그대로 추출하는 스크립트로 생성했다. 

이 단계에서 해석할 수 없는 값은 전부 null을 반환한다. 커스텀 테마 색상(text-brand-500), opacity 표기(bg-white/50) 모두 여기서 걸러진다.

부모 탐색 범위

부모 엘리먼트가 배경색을 갖고 자식이 글자색을 갖는 패턴을 처리하는 부분은 다음과 같다. 

const ownBg = lastColorToken(className, "bg");
if (ownBg) {
  checks.push({ file, line, textColorClass: textClass, bgColorClass: ownBg, bgSource: "self" });
  return;
}

// 딱 한 단계 위 부모까지만 확인 
const parentNode = path.parentPath?.node;
if (parentNode && t.isJSXElement(parentNode)) {
  const parentClassName = getStaticClassName(parentNode.openingElement.attributes);
  const parentBg = parentClassName ? lastColorToken(parentClassName, "bg") : null;
  if (parentBg) {
    checks.push({ file, line, textColorClass: textClass, bgColorClass: parentBg, bgSource: "parent" });
  }
}

 

path.parentPath.node는 바로 위 부모 노드를 가리킨다.

조상을 계속 타고 올라가거나, <Card>처럼 다른 파일에 정의된 컴포넌트 내부까지 분석하진 않는다.

컴포넌트 내부를 분석하려면 타입 추론을 포함한 전체 프로그램 분석이 필요하다고 판단해서 스코프에서 제외했다.

버그 1: 원인

아래 코드에서 실제로 문제가 발생했다.

className="bg-white bg-opacity-50"

 

bg-opacity-50에서 bg- 접두어를 제거하면 opacity-50이 남는다. 이 값은 COLOR_TOKEN 정규식의 ^[a-z]+-\d{2,3}$ 패턴에 매칭된다. opacity도 [a-z]+ 조건을 만족하기 때문이다.

lastColorToken은 클래스 목록을 순서대로 훑으면서 매칭될 때마다 found 값을 덮어쓴다. bg-white를 먼저 찾은 뒤, 다음 토큰인 bg-opacity-50도 같은 모양이라고 판단해서 found를 덮어쓴다.

이후 판정 단계에서 resolveColorValue("bg-opacity-50")을 호출하면 팔레트에 없는 값이라 null이 반환된다.

const textHex = resolveColorValue(check.textColorClass);
const bgHex = resolveColorValue(check.bgColorClass);
if (!textHex || !bgHex) continue; // 여기서 스킵된다

 

bg-white라는 실제 배경색 정보는 이미 덮어써진 상태였다. 위반 여부를 판단할 근거가 없어서 검사 자체가 스킵됐다. 대비가 부족한 상황인데도 경고가 뜨지 않는 미탐(False Negative)이었다.

버그 1: 수정

모양은 같지만 색상이 아닌 값을 예외 처리했다.

const NON_COLOR_SCALE_NAMES = new Set(["opacity"]);

function lastColorToken(className: string, prefix: "text" | "bg"): string | null {
  let found: string | null = null;
  for (const raw of className.split(/\s+/).filter(Boolean)) {
    const base = raw.slice(raw.lastIndexOf(":") + 1);
    if (!base.startsWith(`${prefix}-`)) continue;
    const rest = base.slice(prefix.length + 1);
    if (!COLOR_TOKEN.test(rest)) continue;
    const scaleName = /^([a-z]+)-\d/.exec(rest)?.[1];
    if (scaleName && NON_COLOR_SCALE_NAMES.has(scaleName)) continue; // 추가된 부분
    found = base;
  }
  return found;
}

 

해당 케이스는 회귀 테스트로 남겨뒀다. (/src/parser/extractClasses.test.ts)

it("does not let a trailing opacity utility overwrite the real color match", () => {
  const code = `<p className="text-gray-400 bg-white bg-opacity-50">x</p>;`;
  const checks = extractChecks(code, "fake.tsx");
  expect(checks).toEqual([
    { file: "fake.tsx", line: 1, textColorClass: "text-gray-400", bgColorClass: "bg-white", bgSource: "self" },
  ]);
});

버그 2: 재발

몇 주 뒤 포커스 인디케이터 검사(focus:outline-none을 대체 스타일 없이 쓰는 경우를 검사하는 기능)를 추가하는 과정에서 비슷한 문제가 다시 나타났다. 수정 전 코드는 다음과 같았다.

function isReplacement(raw: string): boolean {
  const base = baseUtility(raw);
  return /^(ring|border|shadow|bg|outline)(-|$)/.test(base);
}

 

이 정규식은 ring, border, shadow, bg, outline으로 시작하는 클래스를 모두 "포커스 대체 스타일"로 판단한다.

focus:ring-offset-2, focus:bg-opacity-50도 이 조건에 매칭된다. 하지만 이 클래스들은 실제로 화면에 아무것도 그리지 않는 모디파이어다. ring-offset-2는 이미 그려진 ring의 여백을 조정할 뿐, 그 자체로 ring을 만들지 않는다.

이번에도 형태는 조건에 맞지만 의미가 다른 값이 판정 로직을 통과한 경우였다.

 

const DEGENERATE_BASES = new Set([
  "outline-none", "ring-0", "border-0", "shadow-none", "bg-transparent",
]);

const MODIFIER_ONLY = /^(bg|border|ring)-opacity-\d{1,3}$|^(ring|outline)-offset-\d{1,3}$|^ring-inset$/;

function isReplacement(raw: string): boolean {
  const base = baseUtility(raw);
  if (DEGENERATE_BASES.has(base) || MODIFIER_ONLY.test(base)) return false;
  return /^(ring|border|shadow|bg|outline)(-|$)/.test(base);
}

 

두 버그 모두 원인이 같다. 정규식은 문자열의 형태만 확인하고 의미는 확인하지 않는다.

bg-opacity-50과 bg-white는 형태상 동일한 패턴(단어-숫자)이지만 하는 일은 다르다. focus:ring-offset-2와 focus:ring-2도 마찬가지다.

두 경우 모두 코드를 작성한 세션이 아니라, 별도로 새로 시작한 세션에서 리뷰하는 과정에서 발견했다. 코드를 작성한 세션은 "이 정규식이 색상을 찾는다"는 전제를 이미 갖고 있어서 예외 케이스를 놓치기 쉬웠다.

현재 한계

위에도 언급했지만 이 분석 방식에는 몇 가지 한계가 있다.

  • 정규식 기반 매칭이라 새로운 Tailwind 유틸리티가 추가될 때마다 비슷한 충돌이 생길 수 있다.
  • 컴포넌트 경계를 넘는 분석은 지원하지 않는다.
  • 커스텀 테마 값은 해석하지 못한다.

이런 케이스는 값을 추정하지 않고 검사 대상에서 제외하는 방식으로 처리하고 있다.

개발 후기

정규식/토큰 매칭 로직을 추가할 때는 같은 형태를 가진 다른 의미의 토큰이 있는지 먼저 확인하는 편이 안전하다. 이 프로젝트의 CLAUDE.md에도 이 내용을 기록해두라고 했다. 

전체 코드는 GitHub에 있다. tailwind-a11y는 npm, eslint-plugin-tailwind-a11y는 여기에서 확인할 수 있다.