junyeokk
Blog
CSS·2026. 02. 15

OKLCH 색공간

웹에서 색상을 다루다 보면 디자인 시스템의 팔레트를 만들거나, 호버 시 색상을 밝게 하거나, 브랜드 색상에서 파생 색상을 계산하는 작업이 필요하다. 이런 작업을 하려면 "색상을 숫자로 조작"해야 하는데, 기존 CSS 색상 포맷들은 이 작업에서 예상치 못한 결과를 만들어낸다.

OKLCH는 CSS Color Module Level 4에서 추가된 색공간으로, 인간의 시각 인지에 기반한 지각적 균일성(perceptual uniformity)을 제공한다. 숫자를 같은 양만큼 바꾸면 눈으로 느끼는 변화도 같다는 뜻이다.


기존 방식의 문제

RGB/Hex: 읽을 수 없다

css
color: #6ea3db;
color: rgb(110 163 219);

이 색이 어떤 색인지 숫자만 보고 알 수 있는 사람은 거의 없다. RGB는 빨강/초록/파랑의 혼합량을 지정하는 방식인데, 인간은 색상을 "빨강 110, 초록 163, 파랑 219"로 생각하지 않는다. 색상 수정도 직관적이지 않다. "이 색을 좀 더 밝게"를 RGB로 표현하려면 세 값을 모두 적절히 올려야 하는데, 얼마나 올려야 하는지 감이 오지 않는다.

P3 광색역(wide-gamut) 색상도 표현할 수 없다. rgb()와 hex는 sRGB 색공간에 갇혀 있어서, 최신 디스플레이가 표현할 수 있는 더 넓은 범위의 색상을 지정할 방법이 없다.

HSL: 읽기 쉽지만 거짓말을 한다

css
color: hsl(210 50% 64%);

HSL은 색상(Hue), 채도(Saturation), 명도(Lightness)라는 직관적인 축을 사용해서 RGB보다 훨씬 읽기 쉽다. 그런데 심각한 문제가 하나 있다. HSL의 명도(L)는 지각적으로 균일하지 않다.

이게 무슨 뜻인지 예시를 보자.

css
/* HSL에서 같은 명도(50%)를 가진 두 색 */
.yellow { background: hsl(60 100% 50%); }
.blue   { background: hsl(240 100% 50%); }

이 두 색은 HSL 수치상 명도가 동일하다. 하지만 실제로 노란색은 훨씬 밝아 보이고, 파란색은 훨씬 어두워 보인다. HSL이 말하는 "명도 50%"는 수학적 중간값일 뿐, 인간 눈이 느끼는 밝기와는 다르다.

이 문제는 실제 개발에서 치명적인 결과를 만든다.

팔레트 생성이 깨진다. 디자인 시스템에서 "모든 색상의 500단계는 같은 밝기"라는 규칙을 세우고 HSL 명도를 동일하게 맞추면, 실제로는 노란색 500이 파란색 500보다 훨씬 밝아 보인다. 텍스트 대비 비율(contrast ratio)이 색상마다 달라져서 접근성도 들쭉날쭉해진다.

색상 조작 결과가 예측 불가능하다. Sass의 darken() 함수로 10% 어둡게 만들면, 파란색과 보라색에서 결과가 다르다. "명도를 10% 올린다"는 같은 연산인데 색상(hue)에 따라 체감 변화량이 다르기 때문이다. 호버 상태에서 색상을 밝게 만들거나, 에러 색상을 브랜드 색상에서 파생할 때 예상치 못한 결과가 나온다.

색조(hue) 변경이 명도를 바꿔버린다. 브랜드 색상(초록)에서 에러 색상(빨강)을 만들기 위해 hue만 바꾸면, 명도까지 같이 변해서 텍스트가 안 읽히게 될 수 있다. HSL에서는 축이 독립적이지 않다.

그리고 HSL도 RGB처럼 sRGB에 갇혀 있어서 P3 색상을 표현할 수 없다.


OKLCH가 해결하는 것

OKLCH는 이 모든 문제를 해결하기 위해 설계되었다.

css
color: oklch(0.7 0.14 210);
/*          L    C    H       */

세 개의 축:

의미범위설명
LPerceived Lightness0 ~ 1지각적 밝기. 0이면 검정, 1이면 흰색.
CChroma0 ~ 0.4+채도. 0이면 무채색(회색), 클수록 선명한 색.
HHue0 ~ 360색조 각도. 빨강 ≈ 25, 노랑 ≈ 100, 초록 ≈ 145, 파랑 ≈ 265.

네 번째 값으로 투명도도 넣을 수 있다.

css
color: oklch(0.7 0.14 210 / 50%); /* 50% 투명 */

지각적 균일성이란

"지각적 균일성"은 L 값을 같은 양만큼 바꾸면, 어떤 색상(hue)이든 눈으로 느끼는 밝기 변화가 동일하다는 것이다.

css
/* OKLCH: 같은 L 값이면 실제로 같은 밝기로 보인다 */
.yellow { background: oklch(0.8 0.18 100); }
.blue   { background: oklch(0.8 0.18 265); }

이 두 색은 실제로 비슷한 밝기로 보인다. HSL에서는 불가능했던 일이다.

이것이 가능한 이유는 OKLCH의 수학적 기반에 있다. OKLCH는 Oklab 색공간의 극좌표(polar coordinate) 표현이다. Oklab은 인간 시각의 비선형적 특성을 모델링한 색공간으로, 2020년에 Björn Ottosson이 기존 CIE Lab의 문제를 해결하기 위해 만들었다. CIE Lab/LCH도 지각적 균일성을 목표로 했지만, 파란색 영역(hue 270~330)에서 채도나 명도를 바꾸면 색조가 보라색으로 밀리는 버그가 있었다. Oklab은 이 문제를 수학적으로 수정한 버전이다.

Oklab과 OKLCH는 같은 색공간의 다른 좌표계다.

  • Oklab: 직교좌표 — oklab(L a b). a는 초록↔빨강 축, b는 파랑↔노랑 축.
  • OKLCH: 극좌표 — oklch(L C H). C(거리)와 H(각도)로 표현.

극좌표 쪽이 인간에게 직관적이다. "이 색의 채도를 줄여" = C를 낮추면 되고, "색조를 바꿔" = H를 돌리면 된다. 직교좌표에서 a, b를 조작해서 같은 결과를 내려면 삼각함수를 계산해야 한다.


HSL vs OKLCH 실전 비교

1. 명도 조절

버튼 호버 시 10% 밝게 만드는 경우:

css
/* HSL: 색상마다 체감 변화가 다르다 */
.btn-blue:hover   { color: hsl(210 80% 50%); } /* → hsl(210 80% 60%) */
.btn-yellow:hover { color: hsl(50 80% 50%);  } /* → hsl(50 80% 60%)  */
/* 파란색은 꽤 밝아졌는데, 노란색은 별로 안 변한 것 같다 */

/* OKLCH: 어떤 색이든 같은 체감 변화 */
.btn-blue:hover   { color: oklch(0.55 0.15 250); } /* → oklch(0.65 0.15 250) */
.btn-yellow:hover { color: oklch(0.55 0.15 90);  } /* → oklch(0.65 0.15 90)  */
/* 둘 다 동일한 밝기 변화로 느껴진다 */

2. 색조 변경

브랜드 색상에서 에러/성공/경고 색상 파생:

css
:root {
  --brand: oklch(0.65 0.18 145);  /* 초록 계열 */
}

.error   { color: oklch(0.65 0.18 25);  } /* 빨강: H만 변경 */
.warning { color: oklch(0.65 0.18 85);  } /* 노랑: H만 변경 */
.info    { color: oklch(0.65 0.18 250); } /* 파랑: H만 변경 */

L과 C는 그대로 두고 H만 바꿨다. OKLCH에서는 이렇게 하면 네 색상 모두 같은 밝기, 같은 선명도를 유지한다. 흰 텍스트를 올렸을 때 대비 비율이 거의 동일하므로, 접근성이 자동으로 맞춰진다.

HSL에서 같은 짓을 하면? 노랑은 지나치게 밝고 파랑은 지나치게 어두워서 대비가 들쭉날쭉해진다.

3. 팔레트 자동 생성

css
:root {
  --base-h: 250;  /* 파란 계열 */
  --base-c: 0.15;

  --color-100: oklch(0.95 var(--base-c) var(--base-h));
  --color-200: oklch(0.85 var(--base-c) var(--base-h));
  --color-300: oklch(0.75 var(--base-c) var(--base-h));
  --color-400: oklch(0.65 var(--base-c) var(--base-h));
  --color-500: oklch(0.55 var(--base-c) var(--base-h));
  --color-600: oklch(0.45 var(--base-c) var(--base-h));
  --color-700: oklch(0.35 var(--base-c) var(--base-h));
}

L 값을 0.1 간격으로 줄이기만 하면 균일한 밝기 단계의 팔레트가 만들어진다. --base-h만 바꾸면 다른 색상의 팔레트도 동일한 밝기 분포로 자동 생성된다. 디자이너가 수십 개의 색상을 일일이 눈으로 확인하며 조절할 필요가 없어진다.


P3 광색역 지원

sRGB는 인간이 볼 수 있는 색상의 약 35%만 표현할 수 있다. P3 색공간은 여기에 30% 더 넓은 범위를 추가한다. 최신 Apple 기기, 대부분의 OLED 스크린이 P3를 지원한다.

기존 rgb()hsl()로는 P3 색상을 표현할 수 없다. color(display-p3 r g b) 함수로 가능하긴 하지만, RGB 기반이라 가독성이 떨어진다.

OKLCH는 sRGB, P3, 그리고 그 너머의 모든 가시광 색상을 표현할 수 있다.

css
/* sRGB 범위 내의 파란색 */
.normal { color: oklch(0.55 0.15 260); }

/* P3에서만 표현 가능한 더 선명한 파란색 */
.vivid  { color: oklch(0.55 0.25 260); }

C(chroma) 값을 올리면 sRGB 한계를 넘어서는 더 선명한 색상이 된다. 브라우저는 모니터가 P3를 지원하면 그대로 표시하고, 지원하지 않으면 가장 가까운 sRGB 색상으로 자동 매핑한다(gamut mapping). 별도의 폴백 처리 없이도 안전하다.

단, 모든 L/C/H 조합이 특정 모니터에서 표시 가능한 건 아니다. 채도를 너무 높이면 표시 가능 범위(gamut)를 넘어갈 수 있다. 실무에서는 oklch.com 같은 도구로 sRGB 범위 안에 있는지 확인하면서 작업하는 게 좋다.


CSS Color 5: 네이티브 색상 조작

CSS Color Module Level 5에서 추가되는 상대 색상 문법(relative color syntax)을 쓰면, CSS만으로 색상을 조작할 수 있다. JavaScript나 Sass 없이 런타임에서 동작한다.

css
:root {
  --accent: oklch(0.65 0.18 145);
}

/* H(색조)만 변경: 에러용 빨간색 */
.error {
  background: oklch(from var(--accent) l c 25);
}

/* L(명도)를 10% 올림: 호버 상태 */
.button:hover {
  background: oklch(from var(--accent) calc(l + 0.1) c h);
}

/* C(채도)를 낮춤: 비활성 상태 */
.disabled {
  background: oklch(from var(--accent) l 0.05 h);
}

/* 투명도 추가 */
.overlay {
  background: oklch(from var(--accent) l c h / 0.3);
}

oklch(from <color> l c h) 문법에서 l, c, h는 원본 색상의 값을 참조하는 변수다. calc()와 조합해서 상대적인 변화를 줄 수 있다. 이 문법은 어떤 색상 포맷의 입력이든 받을 수 있지만, OKLCH로 통일하면 코드 일관성이 좋다.

이 문법은 HSL에서도 쓸 수 있지만, 앞서 설명한 이유(지각적 비균일성) 때문에 OKLCH에서 써야 예측 가능한 결과가 나온다.


실전 적용

Tailwind CSS와 함께 쓰기

Tailwind v4부터 OKLCH를 기본으로 사용한다. 그 이전 버전에서도 CSS Custom Properties를 통해 연동할 수 있다.

css
:root {
  --color-primary: oklch(0.55 0.18 250);
  --color-primary-light: oklch(0.70 0.14 250);
  --color-primary-dark: oklch(0.40 0.18 250);
}
js
// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: {
          DEFAULT: 'var(--color-primary)',
          light: 'var(--color-primary-light)',
          dark: 'var(--color-primary-dark)',
        }
      }
    }
  }
}

다크 모드 테마 전환

OKLCH의 진가는 테마 전환에서 드러난다. L 값만 조절하면 라이트/다크 모드 팔레트를 간단하게 만들 수 있다.

css
:root {
  --surface: oklch(0.98 0.01 250);
  --text: oklch(0.20 0.02 250);
  --accent: oklch(0.55 0.20 250);
}

[data-theme="dark"] {
  --surface: oklch(0.15 0.01 250);
  --text: oklch(0.90 0.02 250);
  --accent: oklch(0.70 0.20 250);
}

색조(H)와 채도(C)는 동일하게 유지하고 명도(L)만 반전시키면, 라이트/다크 간에 일관된 색상 느낌을 유지하면서 밝기만 바뀐다.

style.setProperty로 런타임 테마

사용자가 선택한 색상을 기반으로 전체 테마를 동적으로 생성할 수도 있다.

javascript
function applyTheme(hue) {
  const root = document.documentElement;
  root.style.setProperty('--color-primary', `oklch(0.55 0.18 ${hue})`);
  root.style.setProperty('--color-primary-light', `oklch(0.75 0.12 ${hue})`);
  root.style.setProperty('--color-primary-dark', `oklch(0.35 0.18 ${hue})`);
  root.style.setProperty('--color-error', `oklch(0.55 0.22 25)`);
  root.style.setProperty('--color-success', `oklch(0.55 0.18 145)`);
}

// 사용자가 파란색 테마 선택
applyTheme(250);

// 사용자가 보라색 테마 선택
applyTheme(300);

hue 값 하나만 바꾸면 전체 팔레트가 동일한 밝기/채도 분포를 유지하면서 색조만 변경된다. OKLCH의 지각적 균일성 덕분에 어떤 hue를 넣어도 디자인이 깨지지 않는다.


OKLCH vs LCH

OKLCH 이전에 LCH(lch())가 먼저 있었다. LCH도 CIE Lab 기반의 지각적 균일 색공간이고, L/C/H 세 축을 사용한다. 대부분의 경우 잘 작동하지만, 파란색 영역(hue 270~330)에서 명도나 채도를 변경하면 색조가 보라색으로 밀리는 현상이 있다.

css
/* LCH에서 파란색의 밝기를 단계적으로 올리면 */
.step1 { color: lch(30 60 290); }
.step2 { color: lch(50 60 290); }
.step3 { color: lch(70 60 290); } /* 보라색으로 밀린다 */

/* OKLCH에서는 같은 작업이 정상적으로 동작한다 */
.step1 { color: oklch(0.35 0.15 265); }
.step2 { color: oklch(0.55 0.15 265); }
.step3 { color: oklch(0.75 0.15 265); } /* 파란색 유지 */

OKLCH는 이 문제를 수학적으로 수정한 버전이다. CSS Working Group도 gamut mapping에 OKLCH를 권장한다.


주의할 점

모든 조합이 표시 가능한 건 아니다

OKLCH는 색공간을 있는 그대로 보여준다. HSL처럼 모든 조합이 유효한 색인 척 하지 않는다. 특정 hue에서 C를 너무 높이면 sRGB는 물론 P3 범위도 넘어서는 색이 된다.

css
/* C가 0.4면 대부분의 hue에서 sRGB 범위를 넘는다 */
color: oklch(0.7 0.4 145); /* 이 초록색은 sRGB 모니터에서 정확히 표현 못 함 */

브라우저가 자동으로 가장 가까운 표시 가능 색상으로 매핑해주지만, 의도한 색과 다를 수 있다. 실무에서는 oklch.com 같은 도구로 확인하면서 작업해야 한다.

에코시스템이 아직 성장 중이다

2024년 기준으로 모든 주요 브라우저(Chrome, Safari, Firefox)가 oklch()를 지원하고, PostCSS 플러그인을 통한 폴백도 가능하다. 다만 Figma는 아직 공식 지원이 아니라 플러그인에 의존해야 한다.

bash
# PostCSS 플러그인으로 구형 브라우저 폴백
npm install @csstools/postcss-oklab-function
javascript
// postcss.config.js
module.exports = {
  plugins: [
    require('@csstools/postcss-oklab-function'),
  ],
};

이 플러그인은 빌드 시 oklch() 값을 rgb() 폴백으로 변환해준다. oklch()를 지원하는 브라우저는 원본을 쓰고, 지원하지 않는 브라우저는 폴백을 쓴다.


정리

포맷가독성지각적 균일P3 지원색상 조작
RGB/Hex어려움
HSL불안정
LCH△ (파랑 버그)대체로 양호
OKLCH예측 가능

OKLCH는 "하나의 포맷으로 통일"할 수 있는 첫 번째 CSS 색상 포맷이다. 가독성, 지각적 균일성, 광색역 지원, 색상 조작의 예측 가능성을 모두 갖추고 있다. 새로운 프로젝트에서 색상 시스템을 설계한다면, OKLCH를 기본으로 쓰지 않을 이유가 없다.

관련 문서