CSS에는 분명 진한 회색을 적었습니다. color: #595959. 흰 배경에서 이 색의 대비는 6.4:1이니까, 접근성 기준인 4.5:1을 넉넉히 넘깁니다. 통과예요.

그런데 접근성 검사기를 돌렸더니 빨간 줄이 떴습니다.

이 텍스트는 대비가 4.04:1로 부족합니다. 기대 명암비: 4.5:1

분명 6.4:1짜리 색을 넣었는데 왜 4:1이라고 할까요. 검사기가 틀린 걸까요?

아니었습니다. 틀린 건 저였고, 범인은 두 줄 떨어진 곳에 조용히 앉아 있던 opacity: 0.8 한 줄이었습니다.

대비비가 뭐였죠

먼저 용어부터 짚고 갈게요. 대비비(contrast ratio) 는 글자색과 배경색이 얼마나 밝기 차이가 나는지를 1:1부터 21:1까지의 숫자로 나타낸 값입니다. 흰 바탕에 검은 글씨가 최대치인 21:1이고, 같은 색끼리면 1:1이죠.

WCAG(Web Content Accessibility Guidelines, 웹 콘텐츠 접근성 지침)는 일반 크기 본문 텍스트에 최소 4.5:1 을 요구합니다. 큰 글씨(24px 이상, 또는 굵은 18.7px 이상)는 3:1이면 되고요. 이 기준이 있는 이유는 단순합니다. 저시력 사용자나, 햇빛 쏟아지는 야외에서 화면을 보는 사람이 글자를 읽을 수 있어야 하니까요.

그러니 6.4:1이면 안심할 만한 값입니다. 문제는, 화면에 실제로 그려진 색이 6.4:1짜리가 아니었다는 데 있었습니다.

opacity는 ‘투명도’가 아니라 ‘색 섞기’다

우리는 opacity를 보통 이렇게 이해합니다. “요소를 살짝 흐리게 만드는 것.” 페이드 인, 비활성 느낌, 은은한 보조 텍스트. 눈으로 보기엔 그냥 옅어지는 거죠.

그런데 브라우저 입장에서 opacity: 0.8은 “이 요소를 배경과 80:20으로 섞어라"는 명령입니다. 물감에 물을 타는 것과 같아요. 잉크(글자색) 자체는 그대로인데, 물(배경색)이 섞이면서 옅어집니다. 종이가 하야면 희끄무레해지고, 종이가 회색이면 회색 쪽으로 끌려가죠.

이걸 식으로 쓰면 이렇습니다. 알파 합성(alpha compositing), 즉 반투명한 색을 배경 위에 얹어 최종 색을 계산하는 방식이에요.

text
합성된 색 = 글자색 × α + 배경색 × (1 − α)

α(알파)가 불투명도, 그러니까 opacity 값입니다. 제 경우에 대입해 볼게요. 글자색 #595959는 RGB로 (89, 89, 89), 배경은 #f5f5f3 = (245, 245, 243), opacity는 0.8이었습니다.

text
R: 89 × 0.8 + 245 × 0.2 = 71.2 + 49.0 = 120.2 ≈ 120
G: 89 × 0.8 + 245 × 0.2 = 120.2 ≈ 120
B: 89 × 0.8 + 243 × 0.2 = 119.8 ≈ 120
→ 합성된 색 = (120, 120, 120) = #787878

#787878. 검사기가 리포트에 찍어 준 바로 그 색입니다. 소수점 하나 안 틀리고 맞아떨어졌어요. 그리고 이 색을 배경 #f5f5f3과 다시 대비 계산하면 4.04:1. 검사기가 말한 그 숫자입니다.

opacity가 색 대비를 희석시키는 과정을 보여주는 다이어그램 - 왼쪽에는 CSS에 적은 색 #595959 스와치가 대비 6.42대1 통과라고 표시되어 있고, 가운데 곱하기 opacity 0.8 화살표를 지나면, 오른쪽에는 실제 화면에 그려진 합성색 #787878 스와치가 대비 4.04대1 미달이라고 표시됩니다. 아래에는 합성색 공식이 적혀 있습니다
opacity가 색 대비를 희석시키는 과정을 보여주는 다이어그램 - 왼쪽에는 CSS에 적은 색 #595959 스와치가 대비 6.42대1 통과라고 표시되어 있고, 가운데 곱하기 opacity 0.8 화살표를 지나면, 오른쪽에는 실제 화면에 그려진 합성색 #787878 스와치가 대비 4.04대1 미달이라고 표시됩니다. 아래에는 합성색 공식이 적혀 있습니다
CSS에 적은 색과 실제로 그려지는 색은 다를 수 있습니다. opacity가 그 사이에 끼어들거든요.

여기서 핵심은 이겁니다. color에 무슨 값을 적었든, 화면에 그려지는 건 opacity까지 섞인 결과색이라는 것. 대비는 우리가 코드에 적은 색이 아니라, 사용자 눈에 실제로 닿는 색으로 판정됩니다. 당연한 얘기 같지만, 코드를 읽을 땐 color: #595959 한 줄만 보고 “6.4:1이네, 통과” 하고 넘어가기 딱 좋아요.

함정은 세 가지 얼굴로 온다

같은 원리가 조금씩 다른 모습으로 숨어 있습니다. 세 가지를 기억해 두면 대부분 잡을 수 있어요.

1. 요소 자신의 opacity

제가 겪은 게 이 경우입니다. 텍스트 요소에 직접 opacity가 걸린 거죠.

css
.post-date {
  color: #595959; /* 여기만 보면 6.4:1, 통과처럼 보인다 */
  opacity: 0.8;   /* 그런데 이 줄이 실제 색을 #787878로 끌어내린다 */
}

색 지정과 opacity가 같은 규칙 안에 나란히 있어서 그나마 발견하기 쉬운 편이었습니다. 문제는 다음 두 경우예요.

2. 조상 요소의 opacity — 게다가 곱해진다

opacity는 요소 하나가 아니라 그 아래 자식 전체에 적용됩니다. 부모에 opacity: 0.9를 걸면 그 안의 모든 텍스트가 90%로 그려지죠. 그리고 자식에 또 opacity가 있으면, 두 값이 곱해집니다.

css
.card       { opacity: 0.9; }        /* 카드 전체를 살짝 흐리게 */
.card .date { color: #595959;
              opacity: 0.8; }         /* 날짜를 한 번 더 흐리게 */
/* 실제 알파 = 0.9 × 0.8 = 0.72 */

0.9 × 0.8 = 0.72. 이 최종 알파로 계산하면 #595959#858584가 되고, 대비는 3.38:1 까지 떨어집니다. 정작 .date 규칙만 열어 보면 opacity는 0.8이라 적혀 있으니, 왜 이렇게까지 옅은지 코드만 봐선 도무지 감이 안 와요. 범인이 두 파일 건너, 조상에 숨어 있는 거죠.

3. rgba / hsla의 알파 — 이름만 다른 같은 함정

opacity를 안 썼다고 안심할 순 없습니다. 색 자체에 알파를 넣는 rgba()hsla()도 똑같은 일을 하거든요.

css
.muted { color: rgba(0, 0, 0, 0.5); } /* "검은색 반투명" */

“검정 50%“라고 하면 진해 보이지만, 흰 배경에서 이건 #808080이 되고 대비는 3.95:1. 미달입니다. rgba(0,0,0,0.6)이면 #666(5.7:1)로 겨우 통과하지만, 이걸 흰색이 아니라 회색 카드 위에 올리는 순간 또 아슬아슬해져요. 알파가 든 색은 배경을 알아야 대비를 안다는 게 요점입니다.

opacity 대비 함정 세 가지 유형을 나란히 보여주는 다이어그램 - 첫째 요소 자신의 opacity, 둘째 조상 요소의 opacity로 값이 0.9 곱하기 0.8은 0.72로 곱해진다는 표시, 셋째 rgba와 hsla 색의 알파값. 세 경우 모두 최종적으로 글자색이 배경과 섞여 대비가 떨어진다는 공통 원리로 연결됩니다
opacity 대비 함정 세 가지 유형을 나란히 보여주는 다이어그램 - 첫째 요소 자신의 opacity, 둘째 조상 요소의 opacity로 값이 0.9 곱하기 0.8은 0.72로 곱해진다는 표시, 셋째 rgba와 hsla 색의 알파값. 세 경우 모두 최종적으로 글자색이 배경과 섞여 대비가 떨어진다는 공통 원리로 연결됩니다
세 얼굴이지만 뿌리는 하나입니다. 글자색이 배경과 섞인다는 것.

왜 눈으로는 안 잡히나

여기까지 읽으면 이런 생각이 들 거예요. “그럼 개발자 도구로 색 확인하면 되잖아?”

그게 또 함정입니다. 브라우저 개발자 도구의 색상 선택기가 보여 주는 대비 값은, 보통 선언된 color 값과 바로 옆 배경만 놓고 계산합니다. 요소나 조상에 걸린 opacity를 반영하지 못하는 경우가 많아요. 그래서 개발자 도구에선 “6.4:1, 통과 ✓“라고 초록 불이 뜨는데, axe-core로 돌리면 “4.04:1, 실패"가 나오는 어긋남이 생깁니다.

이때 맞는 쪽은 대개 검사기입니다. axe-core나 Lighthouse 같은 도구는 요소를 실제로 렌더링한 뒤, opacity까지 전부 합성한 결과색으로 대비를 계산하거든요. 사람이 실제로 보는 색을 기준으로 판정하는 거죠. 그러니 두 값이 엇갈리면, 선언색만 본 개발자 도구가 아니라 합성색을 본 검사기를 믿는 편이 안전합니다.

정리하면 이렇습니다.

  • 개발자 도구 색 스와치: 내가 코드에 적은
  • 접근성 검사기: 사용자가 실제로 보는

이 둘 사이의 간극에 opacity가 살고 있습니다.

잡는 법, 그리고 고치는 법

원리를 알았으니 대응은 오히려 단순합니다.

찾기. 두 갈래로 뒤지면 됩니다.

  • 자동: axe-core, Lighthouse, 혹은 브라우저 확장형 접근성 검사기를 돌립니다. 이들은 opacity를 반영하니, 이 함정을 잡는 데 가장 확실해요.
  • 수동: CSS에서 텍스트 요소와 그 조상을 대상으로 opacity:rgba( · hsla( 를 검색합니다. “이 텍스트 위에 알파가 몇 번 곱해지고 있나"를 따라가 보세요.

고치기. 원칙은 하나입니다.

텍스트를 흐리게 만들고 싶으면, opacity 말고 그 흐린 색 자체를 쓴다.

생각해 보면 당연합니다. 내가 원하는 최종 모습이 그 옅은 회색이라면, 그 색을 처음부터 color에 박고 대비를 맞추면 됩니다. 굳이 진한 색을 적어 놓고 opacity로 깎아 내릴 이유가 없어요. 제가 한 수정도 딱 이거였습니다. opacity: 0.8을 지우고 #595959를 그대로 두니 대비가 6.4:1로 돌아왔습니다. 화면상 느낌은 거의 그대로인데, 검사기는 초록 불로 바뀌었고요.

그럼 opacity는 언제 써도 되냐고요. 전환(transition) 상태엔 괜찮습니다. 페이드 인/아웃 애니메이션의 중간값처럼, 잠깐 스쳐 지나가는 상태는 WCAG가 문제 삼지 않아요. WCAG는 멈춰 있는 정지 상태(resting state) 의 대비를 봅니다. 비활성(disabled) 요소도 예외예요 — 클릭할 수 없는 회색 버튼은 대비 기준을 적용받지 않습니다(WCAG 1.4.3의 명시적 예외).

경계선은 여기입니다. “읽으라고 만든 텍스트"인가, 아닌가. 사용자가 읽어야 할 정보를 opacity로만 흐리게 했다면, 그건 은은한 디자인이 아니라 조용한 위반입니다. 저시력 사용자에겐 그 20%가 ‘읽을 수 있음’과 ‘못 읽음’을 가르거든요.

한 장 요약

  • opacity < 1은 글자를 흐리게 만드는 게 아니라, 글자색을 배경색과 섞습니다. 실제 대비가 떨어져요.
  • 공식은 합성색 = 글자색 × α + 배경색 × (1 − α). 제 경우 #595959opacity 0.8에서 #787878로 바뀌며 6.4:1 → 4.04:1로 추락했습니다.
  • 함정은 세 얼굴: ① 요소 자신의 opacity ② 조상의 opacity(값이 곱해짐, 0.9×0.8=0.72) ③ rgba·hsla의 알파.
  • 개발자 도구 색 스와치는 선언한 색을, 접근성 검사기는 합성된 색을 봅니다. 엇갈리면 검사기를 믿으세요.
  • 텍스트를 흐리게 하려면 opacity가 아니라 검증된 색으로. opacity는 전환 애니메이션처럼 스쳐 가는 상태에만 쓰세요.

color에 적은 값이 곧 사용자가 보는 색이라는 보장은 없습니다. 그 사이에 opacity가 끼어 있다면, 대비는 코드가 아니라 화면에서 다시 계산돼요. 색을 골랐으면, 그 색이 화면까지 온전히 도착하는지도 한 번 확인해 주세요.

이 시리즈의 다른 글도 색과 대비를 다룹니다. 다크모드 제대로 구현하기에서는 다크 팔레트에서도 대비를 지키는 토큰 설계를, 고대비 모드 제대로 지원하기에서는 OS가 색을 강제하는 상황을 다뤄요. 프론트엔드 × 접근성 시리즈 전체 보기에서 이어 읽을 수 있습니다.