흔한 상황이다. 브랜드 컬러가 #EA580C(Tailwind의 orange-600) 같은 오렌지이고, 랜딩 페이지 본문을 #FFF7ED 크림색 배경 위에 올렸더니 접근성 감사에서 모든 본문 단락이 WCAG AA 미달이라는 결과가 나왔다. 대비비는 3.35 : 1, 임계값은 4.5 : 1. 이제 어쩐다——평범한 검정으로 바꾸고 브랜드 정체성을 포기해야 하나?

색상 명도 대비 검사기 열기 →

이 가이드가 다루는 것은 「감사가 떨어졌다」와 「수정안을 출시했다」 사이의 몇 시간이다. WCAG 2.x 대비 공식, 실제로 통과해야 할 세 단계의 임계값, 현장에서 빠지기 쉬운 실패 모드, 그리고 브랜드 색상은 유지하고 명도만 최소한으로 조정해 AA를 통과시키는 구체적인 알고리즘——순서대로 풀어 본다.

WCAG가 실제로 측정하는 것

WCAG 2.x의 대비는 상대 휘도(relative luminance) 비율이며, 사람이 느끼는 「밝기」 그 자체가 아니다. 공식은 세 단계:

  1. 각 sRGB 채널 c를 gamma 디코딩:
    • c / 255 ≤ 0.04045이면 c_lin = (c / 255) / 12.92(WCAG 2.0 / 2.1 원문은 0.03928. 둘 사이에 8비트 값이 없으므로 결과는 같다)
    • 그 외에는 c_lin = ((c / 255 + 0.055) / 1.055) ^ 2.4
  2. 선형화된 채널을 단일 휘도로 가중 합성:
    • L = 0.2126 * R_lin + 0.7152 * G_lin + 0.0722 * B_lin
  3. 더 밝은 쪽을 분자로 두고 비율을 구한다:
    • ratio = (L_max + 0.05) / (L_min + 0.05)

+ 0.05는 flare offset으로, 한쪽 휘도가 0에 근접할 때 비율이 발산하지 않도록 막는다. 결과는 1(대비 없음)부터 21(순수 검정 / 순수 흰색) 사이. AA/AAA의 모든 임계값——4.5, 7, 3——은 이 단일 숫자에 대한 컷오프일 뿐이다.

휘도 가중치 0.2126 / 0.7152 / 0.0722는 ITU-R BT.709 sRGB 사양에서 왔고, 물리적 사실을 부호화한다: 사람의 눈은 녹색에 가장 민감하고 파랑에 가장 둔감하다. 동일한 RGB 강도의 순적, 순녹, 순청은 휘도가 크게 다르며, 녹색이 지각되는 빛의 대부분을 차지한다.

실제로 통과해야 할 세 단계 임계값

WCAG는 텍스트와 비텍스트를 세 단계로 나누고, 각 단계마다 AA / AAA 목표를 둔다:

요소AAAAA적용
본문(AA 1.4.3, AAA 1.4.6)4.5 : 17 : 124 px 보통 미만 또는 18.66 px 굵게 미만
큰 글자(AA 1.4.3, AAA 1.4.6)3 : 14.5 : 118.66 px 굵게 이상 또는 24 px 보통 이상(WCAG 원문은 18 pt와 14 pt 굵게)
UI 구성요소 및 그래픽(1.4.11)3 : 1—테두리, 포커스 링, 아이콘, 차트 선

자주 놓치는 세 가지:

  1. 일반적인 법적 기준은 AAA를 요구하지 않는다. Section 508은 WCAG 2.0 AA를, EN 301 549(유럽 접근성법 EAA가 쓰는 표준)는 WCAG 2.1 AA를 따른다. W3C도 사이트 전체에 AAA를 일괄 요구하는 것은 권하지 않는다(Understanding Conformance). 계약이나 사내 기준이 특정 페이지에 AAA를 요구한다면 명시되어 있다.
  2. 큰 글자는 3 : 1로 훨씬 관대하다. 마케팅 랜딩 페이지의 대형 세리프 헤드라인이 본문보다 훨씬 낮은 대비의 강조색을 쓸 수 있는 이유다.
  3. UI에는 AAA가 없다. WCAG 2.1이 1.4.11을 3 : 1로 추가하고 거기서 멈췄다. 존재하지 않는 「UI AAA 규칙」을 찾아 시간 낭비하지 말 것; 사내 기준이 더 엄격해야 한다면, 자체 정의하고 근거를 문서화하라.

현장에서 자주 빠지는 실패 모드

아래는 모두 드문 케이스가 아니며, 모르는 사이에 출시되기 쉽다.

「AA 통과했으니 출시」——하지만 감사 도구는 다른 자를 쓴다

WCAG 2.x만이 대비 모델은 아니다. APCA(Accessible Perceptual Contrast Algorithm)는 지각 명도 대비를 다른 스케일(Lc)로 측정하며, 최신 감사 도구 중에는 둘 다 보여 주는 것도 있다. APCA는 WCAG 3용으로 제안되었지만 WCAG 3는 아직 워킹 드래프트이고 공식 대비 공식을 정하지 않았다. WCAG 2.x로 만들었는데 감사 도구가 APCA를 보고하면 숫자가 맞지 않는 것은 버그가 아니라 잣대가 둘이기 때문이다.

법률(Section 508 / EN 301 549 / EAA / 한국 KWCAG 2.2)이 인용하는 것은 여전히 WCAG 2.x이므로, 당분간은 2.x로 출시하고 APCA는 사내 연구 신호로 병행 추적한다.

반투명 텍스트

대비 공식은 불투명 색상에만 작동한다. 텍스트가 rgba(20, 20, 20, 0.6)이고 텍스처 배경 위에 있다면, 실제 렌더링되는 색은 아래 레이어에 따라 달라진다. 전경을 예상하는 실제 배경에 합성한 뒤 합성된 색에 대해 대비 공식을 돌려라. 많은 팀이 이 단계를 건너뛰어, 사진이나 그라데이션 배경에서만 드러나는 대비 버그를 출시한다.

중간톤 배경은 「전경 어둡게」를 무력화한다

불투명한 배경이라면 순수한 검정이나 흰색 중 하나는 반드시 4.5 : 1에 닿지만, 중간 톤에서는 아슬아슬하다. #767676에서 검정은 4.62 : 1, 흰색은 4.54 : 1이고, #777777이 되면 흰색은 4.48 : 1로 떨어진다. 이런 배경에서는 색이 있는 전경에 여유가 없으니 바꿔야 할 것은 전경이 아니라 배경이다. 디자이너는 반사적으로 「글자색을 조금 더 진하게」를 찾지만 중간 톤에서는 통하지 않는다.

Placeholder 텍스트도 대비 대상이다

브라우저는 기본적으로 ::placeholder를 입력 글자보다 옅게 그리며, 그 색은 브라우저마다 다르다. 플레이스홀더도 텍스트이므로 1.4.3이 적용된다. ::placeholder 색을 명시하고 그 색과 입력란 배경으로 대비를 측정하자.

폼 라벨과 disabled 상태

1.4.3이 면제하는 것은 「비활성 사용자 인터페이스 구성요소의 일부인 텍스트」다. disabled 컨트롤 옆의 가시 레이블이 구성요소의 일부인지는 성공 기준에 적혀 있지 않고 감사자마다 판단이 갈린다. 레이블을 4.5 : 1로 유지하면 논쟁할 필요가 없다.

「색상 유지, 명도만 조정」이 작동하는 방식

#FFF7ED 위의 #EA580C가 AA에서 떨어져 3.35 : 1일 때, 가장 먼저 떠오르는 수정은 「검정으로 바꾸자」. 하지만 브랜드 컬러는 색상 정체성 때문에 선택된 것이다. 검정으로 바꾸면 브랜드를 버린 셈이다. 올바른 동작: 색상과 채도를 유지하고 지각 명도만 대비가 통과할 때까지 옮긴다.

이 작업에는 OKLCH(Oklab을 극좌표로 표현한 것)가 적합하다. 이유는 L 축이 지각적으로 균일하기 때문이다——L = 0.6에서 L = 0.5로 가는 것이 L = 0.4에서 L = 0.3으로 가는 것과 같은 양의 변화로 보인다. sRGB 명도는 이 성질이 없으며, 등간격 sRGB 스텝이 어두운 쪽과 밝은 쪽에서 매우 다르게 보인다.

알고리즘:

function fix(fg, bg, target = 4.5) {
  const fgLch = rgbToOklch(fg);          // hue h와 chroma C 유지
  // 두 방향 모두 시도: L을 낮춤(어둡게) vs 높임(밝게)
  let best = null;
  for (const dir of [-1, 1]) {
    let lo = fgLch.L;
    let hi = dir < 0 ? 0 : 1;
    // 이분법: 시작점에서의 |delta L|이 최소이면서 목표를 만족하는 L을 찾는다
    for (let i = 0; i < 22; i++) {
      const mid = (lo + hi) / 2;
      const trial = clampToSrgb(oklchToRgb({ ...fgLch, L: mid }));
      if (contrast(trial, bg) >= target) hi = mid;
      else lo = mid;
    }
    const final = clampToSrgb(oklchToRgb({ ...fgLch, L: hi }));
    const r = contrast(final, bg);
    if (r >= target * 0.99) {
      const delta = Math.abs(hi - fgLch.L);
      if (!best || delta < best.delta) best = { rgb: final, ratio: r, delta };
    }
  }
  return best ?? blackOrWhiteFallback(bg);
}

두 가지 보충:

  • target * 0.99 여유폭은 OKLCH ↔ sRGB 행렬의 부동소수점 오차를 흡수하기 위한 것이다. 결과를 8비트 16진수로 반올림하면 비율이 위아래로 다시 움직이므로, 출시 전에 최종 16진수 값으로 다시 측정하자. WCAG 임계값은 반올림하지 않는다(4.499 : 1은 불합격).
  • 순수 검정/흰색 폴백은 색상이 sRGB 색역 안에서 목표에 도달하지 못할 때의 정직한 답이다. 네온 옐로를 노란 배경 위에서 명도를 어떻게 조정해도 AA를 통과시킬 수 없다. 알고리즘은 이를 인정해야지 자기 자신을 속여서는 안 된다.

#EA580C(브랜드 오렌지)를 #FFF7ED(따뜻한 크림) 위에서 돌리면 도구는 #D03F00을 제안하고, 16진수 값으로 측정하면 4.50 : 1(4.5025)이다. 오렌지로는 알아볼 수 있지만 색상이 완전히 유지되지는 않는다. 명도를 낮추면 파랑 채널이 음수가 되고, sRGB 색역으로 잘라 내면서 OKLCH 색상이 0.718에서 0.654 rad로 움직인다. 감사는 통과한다.

각 스택별 코드 예제

Python(Pillow + 직접 휘도 계산)

def srgb_to_lin(c):
    c = c / 255.0
    return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4

def luminance(rgb):
    r, g, b = (srgb_to_lin(c) for c in rgb)
    return 0.2126 * r + 0.7152 * g + 0.0722 * b

def contrast(c1, c2):
    L1, L2 = sorted([luminance(c1), luminance(c2)], reverse=True)
    return (L1 + 0.05) / (L2 + 0.05)

# (234, 88, 12) on (255, 247, 237)
print(round(contrast((234, 88, 12), (255, 247, 237)), 2))  # 3.35

외부 의존성 없음. Pillow는 이미지에서 픽셀 색을 읽을 때만 필요하다. 알려진 전경/배경 쌍이라면 계산은 열다섯 줄이면 충분.

JavaScript(브라우저, 라이브러리 없음)

const lin = c => (c /= 255) <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
const lum = ({ r, g, b }) => 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
const ratio = (a, b) => {
  const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
  return (hi + 0.05) / (lo + 0.05);
};

핵심은 이게 전부. OKLCH 변환을 더해도 마흔 줄이면 한 파일에 들어간다. 한 도구만 사용한다면 chroma.js나 culori를 import할 필요 없음——라이브러리는 색상 연산 열 종류가 필요할 때 쓰는 것이지, 한 종류만 필요할 때 쓰는 것이 아니다.

Bash(CI 게이트)

# tokens.json의 임의 토큰이 페이지 배경 대비 4.5:1 미만이면 CI를 떨어뜨린다
# wcagContrast(fg, bg)는 위 JavaScript 예제의 비율 함수(인수는 16진 문자열)
node -e '
  const tokens = require("./tokens.json");
  const bg = tokens["color.background.default"];
  let failed = 0;
  for (const [k, fg] of Object.entries(tokens)) {
    if (!k.startsWith("color.text.")) continue;
    const r = wcagContrast(fg, bg);
    if (r < 4.5) {
      console.error(`FAIL ${k} ${fg} on ${bg} = ${r.toFixed(2)}`);
      failed++;
    }
  }
  process.exit(failed ? 1 : 0);
'

이것이 살아남는 접근성 테스트의 형태다. 수동 감사는 흘러간다; design token을 읽고 PR마다 빌드를 떨어뜨리는 CI 게이트만이 사람이 리뷰를 열기 전에 회귀를 잡는다.

다른 검사기와의 차이

WebAIM 대비 검사기는 사실상의 표준이며, 본 도구가 그것을 대체하려는 것이 아니다. 알아 둘 가치가 있는 차이:

  • 색상 보존 수정: WebAIM은 통과 여부를 알려 주고, 고른 색을 단계적으로 밝게 / 어둡게 하는 버튼을 제공한다. Chrome DevTools 컬러 피커는 페이지의 요소 하나에 대해 통과하는 색을 제안할 수 있다. 본 도구는 입력한 임의의 색 쌍에 대해 OKLCH 명도 축을 탐색하고 AA 통과에 필요한 최소 명도 변동을 보고한다.
  • 세 단계 분해를 한 화면에: WebAIM과 마찬가지로 본문, 큰 글자, UI 구성요소를 나란히 놓고 각각의 AA / AAA 임계값과 비교해 보여 준다.
  • 4개 언어 지원: 비영어권 디자인 팀과 협업할 때, 또는 사내 a11y 가이드라인을 배포할 때 유용.
  • 업로드 없음: 모든 변환이 로컬에서 실행된다. 입력한 hex는 어떤 서버에도 도달하지 않는다. NDA 중인 브랜드 컬러에 이 점이 중요하다.

한국 시장에서의 보충

  • 공공 발주 SI 프로젝트는 KWCAG 2.2(WCAG 2.1 기반)를 사실상 참조점으로 두며, 「장애인차별금지법」하의 웹 접근성 인증 마크 획득에 직결된다.
  • 한글 글꼴의 「큰 글자」 경계: CJK 글리프는 18.66 px 굵게에서 자획이 라틴 문자보다 빨리 뭉개진다. 디자인 실측에서는 「큰 글자」 시작점을 22 px 굵게 / 28 px 보통으로 잡는 편이 안전하다.
  • placeholder는 한글 입력 필드(IME 활성화 전)에 자주 노출되므로, placeholder 대비는 본문 요건과 동등하게 다뤄야 한다.
  • Naver, Kakao 같은 주요 플랫폼의 다크 테마 토큰셋은 WCAG AA를 통과하지만 AAA는 모두 통과하지는 않는다. 사내 디자인 시스템을 만들 때 참고.

더 읽기

ZeroTool 관련 도구

  • 색상 팔레트 생성기 — 기본 색상에서 4가지 조화로운 팔레트 생성. 대비 검사 전에.
  • 색상 변환기 — HEX, RGB, HSL 상호 변환. 디자인과 코드 사이에서 값을 옮길 때.
  • 색상 셰이드 생성기 — Tailwind 스타일 50–950 11단계 스케일. 단일 색상이 아닌 토큰 세트가 필요할 때.