디자이너에게 받은 시안에는 #3B82F6 이라고 적혀 있고, 예전 코드에는 rgb(59, 130, 246) 이 있고, 새로 들어온 동료는 hsl(217 91% 60%) 로 쓰고 있다면? 셋 다 같은 파란색이에요. 결론부터 말하면 HEX·RGB·HSL은 모두 같은 sRGB 색을 다르게 적은 표기라 화면 결과는 같고, 주고받을 땐 HEX, 코드로 계산할 땐 RGB, 사람이 밝기·채도를 조절할 땐 HSL이 편해요.
다만 실무에서 바꿔 쓰다 보면 미묘한 함정이 있어요. 정수로 반올림한 HSL은 원래 HEX로 정확히 돌아오지 않고, HSL의 L 값이 같아도 색상에 따라 눈에 보이는 밝기는 열 배 넘게 차이 나요. 직접 계산한 결과와 변환 코드, 그리고 이 문제를 해결하려고 CSS에 들어온 oklch() 까지 정리했어요.
핵심 요약
HEX와 RGB는 정보가 완전히 같고 16진수냐 10진수냐만 달라요. HSL은 같은 색을 색상·채도·밝기 축으로 바꿔 적은 거예요.
hover 색, 비활성 색, 다크 모드 변수처럼 밝기만 바꿀 땐 HSL의 L만 조절하면 돼요.
정수 HSL로 저장하면 무작위 색의 약 90%가 원래 HEX로 정확히 돌아오지 않아요. 원본은 HEX로 보관하세요.
HSL의 L 50%는 노랑과 파랑에서 상대 휘도가 약 13배 차이 나요. 팔레트를 고르게 만들려면 oklch를 검토하세요.
HEX: 주고받기 좋은 이름표
#RRGGBB 형태로 빨강, 초록, 파랑을 각각 16진수 두 자리(00~FF, 즉 0~255)로 적어요. 짧고 복사하기 쉬워서 피그마 같은 디자인 툴과 코드 사이에서 색을 주고받는 사실상의 표준이에요.
| 형태 | 예시 | 의미 |
|---|---|---|
| 6자리 | #3B82F6 | R=0x3B(59), G=0x82(130), B=0xF6(246) |
| 3자리 축약 | #FA0 | #FFAA00 — 각 자리를 두 번 반복 |
| 8자리 (알파 포함) | #3B82F680 | 마지막 80 = 128/255 ≈ 0.502 불투명도 |
| 4자리 축약 | #FA08 | #FFAA0088 |
8자리 HEX의 알파는 퍼센트가 아니라 0~255예요. 80 이 정확히 50%가 아니라 50.2%인 이유죠. 50%에 가까운 값이 필요하면 80, 25%면 40(25.1%), 75%면 BF(74.9%)를 쓰면 돼요.
단점은 사람이 읽고 조절하기 어렵다는 거예요. #3B82F6 을 "조금 더 밝게" 바꾸려면 어느 자리를 얼마나 올려야 할지 감이 안 오죠.
RGB: 화면이 이해하는 방식 그대로
HEX와 정보는 똑같고 10진수로 적었을 뿐이에요. 모니터 픽셀이 빨강·초록·파랑 빛을 섞는 방식과 그대로 대응돼요. 자바스크립트에서 색을 계산하거나, 캔버스 픽셀(getImageData)을 다루거나, 두 색 사이를 보간할 때는 RGB 숫자가 편해요.
/* 최신 CSS 문법: 쉼표 없이, 알파는 슬래시 뒤 */
color: rgb(59 130 246);
background: rgb(59 130 246 / 0.5);
/* 예전 문법도 여전히 동작 */
color: rgba(59, 130, 246, 0.5);
CSS Color Level 4부터는 rgba() 를 따로 쓸 필요 없이 rgb() 에 알파를 넣을 수 있어요. RGB도 사람에게 직관적이진 않아요. 세 값을 같은 비율로 올리면 밝아지긴 하는데 채도가 같이 변해서 원하는 느낌이 잘 안 나와요.
HSL: 사람이 조절하기 좋은 방식
HSL은 색을 세 가지 축으로 나눠요.
- H(Hue, 색상): 색상환의 각도 0~360. 0 빨강, 60 노랑, 120 초록, 180 청록, 240 파랑, 300 자홍.
- S(Saturation, 채도): 0%면 회색, 100%면 가장 선명.
- L(Lightness, 밝기): 0% 검정, 100% 흰색, 50%가 순색.
이 덕분에 "같은 색인데 조금 더 밝게"가 L 값 하나만 바꾸는 일이 돼요.
:root { --brand-h: 217; --brand-s: 91%; }
.btn { background: hsl(var(--brand-h) var(--brand-s) 60%); }
.btn:hover { background: hsl(var(--brand-h) var(--brand-s) 52%); }
.btn:disabled { background: hsl(var(--brand-h) 30% 80%); }
최신 브라우저는 상대 색 문법(hsl(from var(--brand) h s calc(l - 8%)))과 color-mix() 도 지원해서, HEX 하나만 변수로 두고 파생 색을 CSS 안에서 계산할 수도 있어요. 지원 범위는 MDN에서 확인하고 쓰세요.
HEX → RGB → HSL 변환 공식과 코드
변환은 단순한 산술이에요. HEX는 16진수를 10진수로 바꾸면 RGB고, RGB → HSL은 최댓값·최솟값으로 계산해요.
function hexToRgb(hex) {
let h = hex.replace('#', '');
if (h.length === 3 || h.length === 4) h = [...h].map(c => c + c).join('');
const n = parseInt(h.slice(0, 6), 16);
const a = h.length === 8 ? parseInt(h.slice(6), 16) / 255 : 1;
return { r: n >> 16, g: (n >> 8) & 255, b: n & 255, a: +a.toFixed(3) };
}
function rgbToHsl({ r, g, b }) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b), d = max - min;
const l = (max + min) / 2;
let h = 0, s = 0;
if (d) {
s = d / (1 - Math.abs(2 * l - 1));
if (max === r) h = ((g - b) / d) % 6;
else if (max === g) h = (b - r) / d + 2;
else h = (r - g) / d + 4;
h = Math.round(h * 60 + 360) % 360;
}
return { h, s: Math.round(s * 100), l: Math.round(l * 100) };
}
function hslToRgb({ h, s, l }) {
s /= 100; l /= 100;
const k = n => (n + h / 30) % 12, a = s * Math.min(l, 1 - l);
const f = n => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
return { r: Math.round(f(0) * 255), g: Math.round(f(8) * 255), b: Math.round(f(4) * 255) };
}
hexToRgb('#3B82F6'); // { r: 59, g: 130, b: 246, a: 1 }
rgbToHsl(hexToRgb('#3B82F6')); // { h: 217, s: 91, l: 60 }
hslToRgb({ h: 217, s: 91, l: 60 }); // { r: 60, g: 131, b: 246 } ← 원본과 1씩 다름!
마지막 줄을 보세요. #3B82F6 을 HSL로 바꿨다가 다시 RGB로 돌리니 #3C83F6 이 됐어요. HSL을 정수로 반올림하면서 정보가 조금 사라진 거예요.
정수 HSL 반올림 오차 — 원본은 HEX로 보관
이게 얼마나 흔한지 무작위 색 20만 개로 계산해 봤어요. RGB → HSL(정수 또는 소수 첫째 자리 반올림) → RGB로 돌렸을 때 채널별 최대 오차예요.
정수 HSL은 원본으로 정확히 돌아오는 비율이 10.5%뿐이고, 최대 4까지 어긋났어요. 소수 첫째 자리까지 남기면 100% 복원됐고요. 눈으로는 구분이 거의 안 되는 차이지만, 디자인 토큰을 HSL 정수로 저장했다가 HEX로 내보내면 시안과 코드의 색 코드가 미묘하게 달라져서 "디자이너가 준 색이랑 왜 다르죠?"라는 질문을 받게 돼요. 원본 색은 HEX로 보관하고, HSL은 파생 색을 만들 때만 쓰는 게 안전해요.
HSL의 한계: L 50%라도 밝기는 다르다
HSL의 L은 "사람 눈에 보이는 밝기"와 일치하지 않아요. 여섯 가지 색을 모두 hsl(H 100% 50%) 로 만들고, WCAG 공식으로 상대 휘도를 계산해서 같은 휘도의 회색으로 바꿔 보면 차이가 확연해요.
사람 눈은 초록·노랑 계열에 훨씬 민감하고 파랑에 둔해요. 그래서 HSL로 여러 색상의 팔레트를 "같은 밝기"로 맞추면 숫자는 같은데 눈으로는 들쭉날쭉해 보이고, 같은 L의 흰 글자를 올리면 노랑 버튼에선 안 읽히고 파랑 버튼에선 잘 읽혀요. 글자 대비가 충분한지는 WCAG 명도 대비 가이드의 공식으로 따로 확인해야 해요.
이 문제를 해결하려고 CSS에 oklch() 가 들어왔어요. OKLCH의 L은 지각적 밝기라서, L을 같게 두면 색상이 달라도 비슷한 밝기로 보여요. 2023년 이후 주요 브라우저가 지원해서 디자인 시스템을 새로 만든다면 검토할 만해요. 팔레트를 단계별로 만드는 방법은 디자인 시스템 컬러 스케일 만들기에 정리했어요.
상황별로 고르기
| 상황 | 추천 표기 | 이유 |
|---|---|---|
| 디자인 시안과 색 주고받기 | HEX | 짧고 툴 호환성이 좋음 |
| 디자인 토큰 원본 보관 | HEX | 반올림 손실 없음 |
| 버튼 hover·active 색 만들기 | HSL (또는 상대 색 문법) | L만 조절하면 됨 |
| 다크 모드 테마 변수 | HSL 또는 OKLCH | 같은 H로 밝기만 다르게 |
| 반투명 오버레이 | rgb(... / 0.5) 또는 8자리 HEX | 알파 지정이 명확 |
| JS로 색 계산·보간·캔버스 | RGB | 채널별 산술이 쉬움 |
| 색상이 달라도 고른 밝기의 팔레트 | OKLCH | 지각적 밝기 기준 |
CSS 변수에는 어떤 형태로 넣을까
테마 작업을 하다 보면 "변수에 HEX를 넣을지, HSL 채널을 쪼개 넣을지"를 정해야 해요. 세 가지 방식이 많이 쓰여요.
| 방식 | 예시 | 장점 | 단점 |
|---|---|---|---|
| 완성된 색 | --brand: #3B82F6; | 시안과 1:1 대응, 읽기 쉬움 | 파생 색은 별도 변수나 color-mix 필요 |
| 채널만 저장 | --brand-hsl: 217 91% 60%; | hsl(var(--brand-hsl) / 0.5) 로 알파 조절 쉬움 | 변수만 보고 색을 떠올리기 어려움 |
| 단계별 토큰 | --blue-500, --blue-600 | 디자인 시스템과 일치, 대비 검증 가능 | 토큰 수가 많아짐 |
Tailwind CSS 같은 프레임워크가 오래 썼던 방식이 두 번째(채널만 저장)예요. 알파를 붙이기 쉽다는 장점 때문이었는데, 이제는 color-mix(in srgb, var(--brand) 50%, transparent) 나 상대 색 문법으로 완성된 색 변수에서도 알파를 만들 수 있어서 첫 번째 방식으로 돌아가는 추세예요. 규모가 있는 서비스라면 세 번째처럼 단계별 토큰을 두고, 각 토큰의 대비를 미리 검증해 두는 게 유지보수에 가장 편해요.
표기를 바꿔 보는 가장 빠른 방법은 브라우저 개발자 도구예요. 크롬의 Styles 패널에서 색 견본을 Shift+클릭하면 HEX → RGB → HSL → HWB 순으로 표기가 바뀌어요. 피그마에서는 색 값 옆의 형식 드롭다운으로 HEX·RGB·HSL·CSS 표기를 고를 수 있고요. 코드에 넣기 전에 여기서 한 번 확인하면 오타를 줄일 수 있어요.
자주 하는 실수
#RGB축약을 잘못 펼치기:#FA0은#F0A000이 아니라#FFAA00이에요. 각 자리를 두 번 반복해요.- 8자리 HEX 알파를 퍼센트로 착각:
#00000050은 50%가 아니라 31.4%(0x50=80) 불투명이에요. - HSL 채도 0%에서 H 값에 의미 부여: 회색은 색상이 없어서 H가 무엇이든 같은 색이에요. 변환기마다 0이나 다른 값을 내놓는 이유예요.
- sRGB 밖의 색: 최신 맥북·아이폰 디스플레이는 더 넓은 P3 색역을 표시해요.
color(display-p3 ...)나oklch()로 지정한 선명한 색은 HEX로 바꾸면 잘려서 덜 선명해져요.
자주 묻는 질문
HEX와 RGB는 무엇이 다른가요?
담고 있는 정보는 완전히 같아요. HEX는 빨강·초록·파랑 각 채널(0~255)을 16진수 두 자리로, RGB는 10진수로 적은 것뿐이에요. #3B82F6 과 rgb(59 130 246) 은 화면에서 똑같은 색이에요.
HSL로 hover 색을 만들 때 L을 얼마나 바꾸면 되나요?
보통 밝은 배경의 버튼은 L을 5~10% 낮추고, 어두운 배경에선 그만큼 올리면 자연스러워요. 다만 HSL의 L은 색상마다 체감 밝기가 달라서, 노랑 계열은 조금만 바꿔도 크게 보이고 파랑 계열은 더 많이 바꿔야 해요. 여러 색에 같은 규칙을 적용하려면 oklch가 더 고르게 동작해요.
HSL로 변환했다가 다시 HEX로 바꾸면 색이 달라지는 이유는 무엇인가요?
HSL 값을 정수로 반올림하면서 정보가 조금 사라지기 때문이에요. 무작위 색 20만 개로 계산해 보면 정수 HSL은 약 90%가 원래 HEX로 정확히 돌아오지 않았어요. 원본 색은 HEX로 보관하고, 소수점까지 유지하면 이 문제가 없어요.
8자리 HEX 코드의 마지막 두 자리는 무엇인가요?
투명도(알파)예요. 00이 완전 투명, FF가 완전 불투명이고 0~255 범위라서 80은 약 50.2%예요. 안드로이드 일부 API는 알파를 앞에 두는 #AARRGGBB 순서를 쓰니 플랫폼을 확인하세요.
oklch는 언제 써야 하나요?
여러 색상의 팔레트를 비슷한 밝기로 맞추거나, 밝기 단계를 일정하게 만드는 디자인 시스템 작업에 유리해요. 주요 브라우저가 지원하지만, 오래된 브라우저를 지원해야 한다면 HEX 대체 값을 함께 두세요.
정리
HEX·RGB·HSL은 같은 색을 다른 손잡이로 잡는 방법이에요. 주고받고 보관할 땐 HEX, 계산할 땐 RGB, 밝기와 채도를 조절할 땐 HSL. 다만 정수 HSL은 원래 색으로 정확히 안 돌아오고, L이 같아도 색상마다 밝기가 크게 다르다는 점만 기억하면 대부분의 함정을 피할 수 있어요.
변환은 색상 코드 변환기에 HEX를 넣으면 RGB와 HSL이 함께 나오니 그걸 보고 옮기면 편해요.