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), 즉 반투명한 색을 배경 위에 얹어 최종 색을 계산하는 방식이에요.
합성된 색 = 글자색 × α + 배경색 × (1 − α)α(알파)가 불투명도, 그러니까 opacity 값입니다. 제 경우에 대입해 볼게요. 글자색 #595959는 RGB로 (89, 89, 89), 배경은 #f5f5f3 = (245, 245, 243), opacity는 0.8이었습니다.
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. 검사기가 말한 그 숫자입니다.

CSS에 적은 색과 실제로 그려지는 색은 다를 수 있습니다. opacity가 그 사이에 끼어들거든요.
여기서 핵심은 이겁니다. color에 무슨 값을 적었든, 화면에 그려지는 건 opacity까지 섞인 결과색이라는 것. 대비는 우리가 코드에 적은 색이 아니라, 사용자 눈에 실제로 닿는 색으로 판정됩니다. 당연한 얘기 같지만, 코드를 읽을 땐 color: #595959 한 줄만 보고 “6.4:1이네, 통과” 하고 넘어가기 딱 좋아요.
함정은 세 가지 얼굴로 온다#
같은 원리가 조금씩 다른 모습으로 숨어 있습니다. 세 가지를 기억해 두면 대부분 잡을 수 있어요.
1. 요소 자신의 opacity#
제가 겪은 게 이 경우입니다. 텍스트 요소에 직접 opacity가 걸린 거죠.
.post-date {
color: #595959; /* 여기만 보면 6.4:1, 통과처럼 보인다 */
opacity: 0.8; /* 그런데 이 줄이 실제 색을 #787878로 끌어내린다 */
}색 지정과 opacity가 같은 규칙 안에 나란히 있어서 그나마 발견하기 쉬운 편이었습니다. 문제는 다음 두 경우예요.
2. 조상 요소의 opacity — 게다가 곱해진다#
opacity는 요소 하나가 아니라 그 아래 자식 전체에 적용됩니다. 부모에 opacity: 0.9를 걸면 그 안의 모든 텍스트가 90%로 그려지죠. 그리고 자식에 또 opacity가 있으면, 두 값이 곱해집니다.
.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()도 똑같은 일을 하거든요.
.muted { color: rgba(0, 0, 0, 0.5); } /* "검은색 반투명" */“검정 50%“라고 하면 진해 보이지만, 흰 배경에서 이건 #808080이 되고 대비는 3.95:1. 미달입니다. rgba(0,0,0,0.6)이면 #666(5.7:1)로 겨우 통과하지만, 이걸 흰색이 아니라 회색 카드 위에 올리는 순간 또 아슬아슬해져요. 알파가 든 색은 배경을 알아야 대비를 안다는 게 요점입니다.

세 얼굴이지만 뿌리는 하나입니다. 글자색이 배경과 섞인다는 것.
왜 눈으로는 안 잡히나#
여기까지 읽으면 이런 생각이 들 거예요. “그럼 개발자 도구로 색 확인하면 되잖아?”
그게 또 함정입니다. 브라우저 개발자 도구의 색상 선택기가 보여 주는 대비 값은, 보통 선언된 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 − α). 제 경우#595959가opacity 0.8에서#787878로 바뀌며 6.4:1 → 4.04:1로 추락했습니다. - 함정은 세 얼굴: ① 요소 자신의 opacity ② 조상의 opacity(값이 곱해짐, 0.9×0.8=0.72) ③
rgba·hsla의 알파. - 개발자 도구 색 스와치는 선언한 색을, 접근성 검사기는 합성된 색을 봅니다. 엇갈리면 검사기를 믿으세요.
- 텍스트를 흐리게 하려면 opacity가 아니라 검증된 색으로. opacity는 전환 애니메이션처럼 스쳐 가는 상태에만 쓰세요.
color에 적은 값이 곧 사용자가 보는 색이라는 보장은 없습니다. 그 사이에 opacity가 끼어 있다면, 대비는 코드가 아니라 화면에서 다시 계산돼요. 색을 골랐으면, 그 색이 화면까지 온전히 도착하는지도 한 번 확인해 주세요.
이 시리즈의 다른 글도 색과 대비를 다룹니다. 다크모드 제대로 구현하기에서는 다크 팔레트에서도 대비를 지키는 토큰 설계를, 고대비 모드 제대로 지원하기에서는 OS가 색을 강제하는 상황을 다뤄요. 프론트엔드 × 접근성 시리즈 전체 보기에서 이어 읽을 수 있습니다.
