HSL 명도가 눈을 속이는 이유 — 그리고 OKLCH가 고치는 것
HSL은 세 번째 축을 "lightness(명도)"라고 부르기 때문에 지각적으로 균일해 보이지만, 실제로는 감마 인코딩된 sRGB 좌표를 원통형으로 재배치한 것에 불과합니다. L 값은 문자 그대로 세 RGB 채널의 (max + min) / 2이며, 인간의 시각이 파장별로 밝기를 다르게 인식한다는 사실을 전혀 반영하지 않습니다. 고전적인 예시: hsl(60, 100%, 50%)은 순수한 노랑, hsl(240, 100%, 50%)은 순수한 파랑입니다. 둘 다 L = 50%이지만 상대 휘도(relative luminance)는 0.928 대 0.072 — 노랑이 눈에는 12배 이상 밝습니다.
이 문제는 HSL 명도를 고정한 채 hue만 회전시켜 팔레트를 만드는 순간 터집니다. 노랑·시안 스톱은 형광처럼 빛나고 파랑·보라 스톱은 거의 검게 보이며, 한 색상에서 대비 검사를 통과한 흰색 텍스트가 옆 색상에서는 실패합니다. 결국 팀은 모든 스톱을 수작업으로 보정하게 되고, 체계적인 팔레트라는 목적 자체가 무너집니다.
현대적인 해법은 OKLCH입니다. CSS Color Module Level 4에 표준화되었고 2023년 이후 모든 주요 브라우저에서 사용할 수 있습니다(Chrome 111, Safari 15.4, Firefox 113). OKLCH의 L 축은 지각적으로 균일해서 oklch(70% 0.1 60)과 oklch(70% 0.1 240)은 실제로 똑같이 밝아 보입니다. L과 chroma를 고정하고 hue만 회전시키면 흰색/검은색과의 대비가 일정하게 유지되는 램프가 나옵니다. 일회성 미세 조정에는 HSL을, 팔레트가 체계적이어야 하는 순간에는 OKLCH를 쓰세요.
/* Same HSL "lightness", wildly different brightness */
.hsl-yellow { background: hsl(60 100% 50%); } /* luminance 0.928 */
.hsl-blue { background: hsl(240 100% 50%); } /* luminance 0.072 */
/* OKLCH: the L axis is perceptual, so these really match */
.ok-yellow { background: oklch(70% 0.15 100); }
.ok-blue { background: oklch(70% 0.15 260); }
/* Hue-rotated ramp with constant perceived lightness */
:root {
--cat-1: oklch(70% 0.12 30);
--cat-2: oklch(70% 0.12 150);
--cat-3: oklch(70% 0.12 270);
}직접 계산할 수 있는 WCAG 대비(Contrast) 수학
WCAG 대비율은 블랙박스가 아니라 코드 열 줄짜리 수식입니다. 먼저 각 sRGB 채널을 선형화합니다: 255로 나눈 뒤, 결과가 0.04045 이하면 12.92로 나누고, 그렇지 않으면 ((c + 0.055) / 1.055)의 2.4제곱을 계산합니다. 상대 휘도(relative luminance)는 가중합 0.2126 R + 0.7152 G + 0.0722 B입니다. 이 가중치는 각 원색이 지각 밝기에 기여하는 정도를 나타내며, 그래서 녹색이 지배적이고 파랑은 거의 영향이 없습니다. 두 색의 대비율은 밝은 쪽 휘도를 L1로 두고 (L1 + 0.05) / (L2 + 0.05)로 계산하며, 1:1부터 21:1(순백 위의 순흑)까지 나옵니다.
외워둘 기준값: 일반 본문 텍스트는 AA 레벨에서 4.5:1, 큰 텍스트(대략 24px, 또는 18.66px 볼드)와 — WCAG 2.1 SC 1.4.11 이후 — 입력 필드 테두리, 포커스 링, 아이콘 같은 비텍스트 UI는 3:1, AAA는 7:1입니다. 유용한 기준점 하나: 흰 배경 위의 #767676은 4.54:1로, 본문 텍스트 AA를 통과하는 가장 밝은 회색입니다. 어디서나 보이는 #999999는 2.85:1밖에 안 되어 불합격이며, 실제 접근성 감사에서 가장 흔히 지적되는 것이 placeholder와 비활성 라벨의 회색입니다.
한 가지 주의: WCAG 2.x 수식은 어두운 배경 위 밝은 텍스트에 점수를 후하게 주는 것으로 알려져 있어서, 수치상 4.5:1을 통과한 다크 모드 텍스트가 라이트 모드의 대응 색보다 실제로는 더 안 읽힐 수 있습니다. 다크 테마에서 4.5:1은 목표가 아니라 최저선으로 취급하세요.
function luminance(r, g, b) {
const lin = (c) => {
c /= 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
};
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
}
function contrast(rgb1, rgb2) {
const L1 = luminance(...rgb1);
const L2 = luminance(...rgb2);
const [hi, lo] = L1 > L2 ? [L1, L2] : [L2, L1];
return (hi + 0.05) / (lo + 0.05);
}
contrast([118, 118, 118], [255, 255, 255]); // 4.54 — #767676 barely passes AA
contrast([153, 153, 153], [255, 255, 255]); // 2.85 — #999999 failsHEX 표기의 코너 케이스: 3·4·6·8자리
CSS는 네 가지 길이의 hex를 허용합니다: #RGB, #RGBA, #RRGGBB, #RRGGBBAA. 축약형은 0을 채우는 게 아니라 각 자릿수를 두 번 반복해서 확장됩니다: #f0c은 #ff00cc가 되고 #f0c8은 #ff00cc88이 됩니다. 손으로 값을 줄일 때 이 차이가 중요합니다 — #abc는 #aabbcc이지, 절대 #0a0b0c가 아닙니다.
8자리 형식은 알파를 맨 끝에 둡니다. Android(그리고 구형 .NET/WPF)는 알파가 앞에 오는 ARGB, 즉 #AARRGGBB를 씁니다. 그래서 같은 문자열 #80FF0000이 Android에서는 불투명도 50%의 순수한 빨강이지만, CSS에서는 red 0x80, green 0xFF, blue 0x00, alpha 0x00 — 완전히 투명한 연두색으로 파싱됩니다. 아무것도 렌더링되지 않고 에러도 없어서, 누군가 두 코드베이스를 글자 단위로 diff 하기 전까지 버그가 살아남는 게 보통입니다. Android 앱과 웹 CSS 사이에서 색을 이식할 때는 반드시 알파 바이트 위치를 재배열하세요.
알파 퍼센트와 hex의 대응도 깔끔하지 않습니다. 불투명도 50%는 0.5 × 255 = 127.5이고, 반올림하면 128 = 0x80입니다 — 그래서 반투명은 #...80이지, 직관적으로 쓰기 쉬운 #...7F가 아닙니다. hex의 알파는 255단계뿐이지만 rgb(r g b / 50%)는 정확한 비율을 유지합니다. 디자인 토큰이 점점 슬래시 문법을 선호하는 이유가 여기 있습니다.
마지막으로 hex는 sRGB 전용 표기입니다. 광색역(wide-gamut) hex는 존재하지 않습니다: Display-P3 색상에는 hex 철자가 아예 없으므로, P3 값을 hex로 내보내는 도구는 조용히 sRGB로 gamut 매핑을 먼저 수행하고 있는 것입니다.
#f0c -> #ff00cc (digit doubling, not zero padding)
#f0c8 -> #ff00cc88 (4-digit form is RGBA)
50% alpha: 0.5 x 255 = 127.5 -> rounds to 0x80
CSS 50% red: #FF000080 (RRGGBBAA, alpha last)
Android 50% red: #80FF0000 (AARRGGBB, alpha first)
/* Same string, different platform, silent bug: */
CSS #80FF0000 = rgba(128, 255, 0, 0) -> invisiblesRGB vs Display-P3: rgb(255 0 0)이 가장 빨간 빨강이 아닐 때
이 페이지의 모든 hex와 rgb() 값은 sRGB 안에 존재합니다 — 1996년 CRT 형광체를 기준으로 표준화된 색역(gamut)입니다. 디스플레이는 이미 앞서갔습니다: iPhone 7 이후의 모든 iPhone, 2016년 이후의 모든 MacBook, 대부분의 플래그십 Android와 프리미엄 Windows 패널이 Display-P3를 커버하며, 이는 sRGB보다 약 25% 더 많은 색을 담습니다. 특히 채도 높은 빨강·주황·초록에서 이득이 큽니다. CSS Color 4는 이를 직접 지정할 수 있게 합니다: color(display-p3 1 0 0)은 P3 화면에서 rgb(255 0 0)보다 눈에 띄게 깊은 빨강으로 렌더링되고, sRGB 화면에서는 둘 다 같은 클램핑된 값으로 수렴합니다.
이 차이는 일상 워크플로에 교묘하게 스며듭니다. macOS 스크린샷은 Display-P3로 태깅되므로, 스크린샷에서 추출한 색은 그 색을 만든 CSS와 수치가 일치하지 않습니다. P3로 설정된 Figma 문서는 내보내거나 sRGB 하드웨어에서 볼 때 색이 틀어집니다 — "내 Mac에서는 과채도인데 사무실 모니터에서는 칙칙하다"는 고전적인 버그 리포트의 원인은 CSS가 아니라 gamut 변환입니다. 그리고 일부 브랜드 컬러, 특히 채도 높은 주황은 아예 sRGB 밖에 있어서 어떤 hex 코드로도 충실히 표현할 수 없습니다.
실전 가이드: sRGB 값을 기준선으로 유지하고, 광색역 색상은 @supports나 @media (color-gamut: p3) 체크 뒤에 겹쳐 넣으세요. 오래된 브라우저는 파싱할 수 없는 선언을 건너뛴다는 점을 기억하세요 — sRGB 폴백을 먼저 쓰고 color(display-p3 ...) 오버라이드를 그 다음 줄에 쓰는 것만으로 우아한 폴백이 공짜로 구현됩니다.
Hover·Active·Disabled·다크 모드 색상 파생하기
HSL로 변환하는 가장 일상적인 이유는 인터랙션 상태의 파생입니다. 명도를 8~10 포인트 이동시키는 방식 — hover는 더 어둡게, active는 그보다 더 어둡게 — 은 L 40~60% 부근의 중간 톤에서는 잘 작동합니다. 하지만 극단에서 깨집니다: L 92%에서 +8은 순백으로 클리핑되고, L 10% 부근에서 같은 이동은 색의 성격 자체를 바꿔버립니다. 파생된 명도를 대략 5~95% 범위로 클램핑하고, 한계에 부딪히면 대신 채도를 조정하세요.
다크 모드는 명도 반전이 아닙니다. L을 뒤집으면 hue는 유지되지만 순흑 표면 위의 순백 텍스트가 나오는데, 이는 OLED 패널에서 halation(번짐 현상)을 일으키고 난시가 있는 사용자에게는 측정 가능할 정도로 가독성을 떨어뜨립니다. Material Design의 가이드는 #000000이 아니라 #121212 부근의 near-black 표면과 채도를 낮춘 액센트 톤입니다 — 흰 배경에서 적절해 보이는 고채도 브랜드 컬러는 어두운 표면 위에서 진동하듯 보입니다. 라이트 모드 토큰을 그대로 재사용하지 말고 chroma를 줄이고 명도를 약간 올리세요.
비활성(disabled) 상태에는 밝은 회색보다 알파를 쓰세요: 불투명도 38%의 요소는 어떤 배경 위에서도 비활성으로 읽히지만, 하드코딩된 #C0C0C0은 색조가 있는 카드 위에 놓이는 순간 깨집니다.
마지막으로 반올림에 주의하세요. 이 변환기는 CSS와 마찬가지로 H를 정수 도(degree)로, S/L을 정수 퍼센트로 반올림합니다 — 약 360만 개의 HSL 격자점 대 1,670만 개의 RGB 값입니다. 따라서 왕복 변환 시 채널이 ±1씩 흔들릴 수 있습니다. 정본(source of truth) 형식 하나(보통 hex)를 저장하고 나머지를 거기서 파생하세요. 왔다 갔다 변환을 반복하면 안 됩니다.