# 색은 맞는데 대비는 틀렸다: CSS opacity가 파놓은 접근성 함정

> CSS에 진한 회색을 넣었는데 접근성 검사기는 대비 미달이라고 합니다. 범인은 opacity예요. opacity가 글자색을 배경과 섞어 대비를 깎는 원리와, 실제 블로그에서 겪은 사례·계산·해결법을 초보 눈높이로 정리합니다.

**Published:** 2026-07-21 | **Updated:** 2026-07-21

---


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**. 검사기가 말한 그 숫자입니다.

{{< img src="images/contents/opacity-dilution.png" alt="opacity가 색 대비를 희석시키는 과정을 보여주는 다이어그램 - 왼쪽에는 CSS에 적은 색 #595959 스와치가 대비 6.42대1 통과라고 표시되어 있고, 가운데 곱하기 opacity 0.8 화살표를 지나면, 오른쪽에는 실제 화면에 그려진 합성색 #787878 스와치가 대비 4.04대1 미달이라고 표시됩니다. 아래에는 합성색 공식이 적혀 있습니다" caption="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)로 겨우 통과하지만, 이걸 흰색이 아니라 회색 카드 위에 올리는 순간 또 아슬아슬해져요. 알파가 든 색은 **배경을 알아야 대비를 안다**는 게 요점입니다.

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

## 왜 눈으로는 안 잡히나

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

그게 또 함정입니다. 브라우저 개발자 도구의 색상 선택기가 보여 주는 대비 값은, 보통 **선언된 `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가 끼어 있다면, 대비는 코드가 아니라 화면에서 다시 계산돼요. 색을 골랐으면, 그 색이 화면까지 온전히 도착하는지도 한 번 확인해 주세요.

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

