튜토리얼

JavaScript 색상 조작: 라이브러리와 기법

10분 읽기

웹 애플리케이션에서 색상은 디자인 파일에서 붙여넣고 그대로 두는 정적인 값이 아니다. 색상은 호버 시 밝아지고, 클릭 시 어두워지며, 접근성 대비 요구 사항에 맞게 조정되고, 상태 간을 애니메이션하며, 사용자가 선택한 테마에 적응한다. 이 모든 것에는 프로그래매틱한 색상 조작 — 런타임에 JavaScript로 색상 값을 파싱하고, 변환하고, 생성하는 능력 — 이 필요하다.

이 가이드는 JavaScript가 색상을 네이티브로 처리하는 방법, 일반적인 변환의 수학적 원리, 가장 널리 사용되는 세 가지 라이브러리의 비교, 그리고 의존성 없이 최소한의 색상 유틸리티를 구축하는 방법을 다룬다.


헥스, RGB, HSL을 JavaScript로 파싱하기

JavaScript에는 내장된 색상 타입이 없다. 색상은 문자열로 도착한다 — "#FF5733", "rgb(255, 87, 51)", "hsl(11, 100%, 60%)" — 수학 연산을 하기 전에 직접 파싱해야 한다.

헥스 코드 파싱

헥스 색상은 세 개(또는 알파 포함 네 개)의 1바이트 정수를 16진법으로 압축 인코딩한 것이다. 파싱은 문자열을 슬라이스하고 parseInt를 호출하는 것이다:

function parseHex(hex) {
  // 정규화: # 제거, 단축 형식 확장 (#F53 → #FF5533)
  const clean = hex.replace('#', '');
  const full = clean.length === 3
    ? clean.split('').map(c => c + c).join('')
    : clean;

  return {
    r: parseInt(full.slice(0, 2), 16),
    g: parseInt(full.slice(2, 4), 16),
    b: parseInt(full.slice(4, 6), 16),
  };
}

parseHex('#FF5733'); // { r: 255, g: 87, b: 51 }
parseHex('#F53');    // { r: 255, g: 85, b: 51 }

반대로 — 정수를 헥스 문자열로 변환 — 는 toString(16)에 제로 패딩을 사용한다:

function toHex({ r, g, b }) {
  return '#' + [r, g, b]
    .map(v => Math.round(v).toString(16).padStart(2, '0'))
    .join('')
    .toUpperCase();
}

toHex({ r: 255, g: 87, b: 51 }); // '#FF5733'

RGB 문자열 파싱

DOM이나 CSS-in-JS에서 오는 RGB 문자열은 종종 "rgb(255, 87, 51)"로 도착한다. 정규식으로 세 값을 추출한다:

function parseRgb(str) {
  const match = str.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
  if (!match) throw new Error(`Invalid RGB: ${str}`);
  return {
    r: parseInt(match[1]),
    g: parseInt(match[2]),
    b: parseInt(match[3]),
  };
}

parseRgb('rgb(255, 87, 51)');      // { r: 255, g: 87, b: 51 }
parseRgb('rgba(255, 87, 51, 0.5)'); // { r: 255, g: 87, b: 51 }

HSL 문자열 파싱

HSL 문자열 — "hsl(11, 100%, 60%)" — 은 도수와 퍼센트 값을 추출해야 한다:

function parseHsl(str) {
  const match = str.match(/hsla?\((\d+),\s*([\d.]+)%,\s*([\d.]+)%/);
  if (!match) throw new Error(`Invalid HSL: ${str}`);
  return {
    h: parseFloat(match[1]),
    s: parseFloat(match[2]),
    l: parseFloat(match[3]),
  };
}

RGB와 HSL 간 변환

대부분의 색상 수학은 RGB(혼합용)나 HSL/HSV(직관적인 조정용)에서 작동한다. 이 둘 사이의 변환이 첫 번째로 필요한 기술이다:

function rgbToHsl({ r, g, b }) {
  const rn = r / 255, gn = g / 255, bn = b / 255;
  const max = Math.max(rn, gn, bn);
  const min = Math.min(rn, gn, bn);
  const l = (max + min) / 2;
  const d = max - min;

  if (d === 0) return { h: 0, s: 0, l: Math.round(l * 100) };

  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
  let h;
  switch (max) {
    case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
    case gn: h = ((bn - rn) / d + 2) / 6; break;
    default: h = ((rn - gn) / d + 4) / 6;
  }

  return {
    h: Math.round(h * 360),
    s: Math.round(s * 100),
    l: Math.round(l * 100),
  };
}

rgbToHsl({ r: 255, g: 87, b: 51 }); // { h: 11, s: 100, l: 60 }

색상 수학: 밝게, 어둡게, 채도 낮추기

색상을 HSL로 얻으면 조정이 산술이다. HSL의 세 축은 디자이너들이 가장 자주 요청하는 조정에 직접 매핑된다.

밝게 및 어둡게

l(명도) 채널을 증가 또는 감소시키는 것이 직접적인 접근 방식이다:

function lighten(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  hsl.l = Math.min(100, hsl.l + amount);
  return hslToHex(hsl);
}

function darken(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  hsl.l = Math.max(0, hsl.l - amount);
  return hslToHex(hsl);
}

lighten('#FF5733', 15); // 더 밝은 코랄
darken('#FF5733', 15);  // 더 어두운 빨강-주황

이것은 작은 조정에 잘 작동한다. 전체 50–950 척도(Tailwind CSS가 하는 것처럼)를 생성하려면 HSL에서 인식되는 밝기가 선형이 아니기 때문에 수학이 더 복잡해진다 — Shade Generator는 지각적으로 가중된 분포로 이를 처리한다.

채도 낮추기

s(채도) 채널을 0으로 줄이면 어떤 색상도 회색으로 만든다:

function desaturate(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  hsl.s = Math.max(0, hsl.s - amount);
  return hslToHex(hsl);
}

function grayscale(hex) {
  return desaturate(hex, 100);
}

desaturate('#FF5733', 50); // 채도가 낮은 주황색
grayscale('#FF5733');      // 같은 밝기의 순수 회색

두 색상 혼합

RGB 공간에서의 선형 보간이 가장 간단한 혼합이다:

function mix(hex1, hex2, weight = 0.5) {
  const c1 = parseHex(hex1);
  const c2 = parseHex(hex2);
  return toHex({
    r: Math.round(c1.r * weight + c2.r * (1 - weight)),
    g: Math.round(c1.g * weight + c2.g * (1 - weight)),
    b: Math.round(c1.b * weight + c2.b * (1 - weight)),
  });
}

mix('#FF5733', '#FFFFFF', 0.7); // 코랄 70%, 흰색 30% → 틴트
mix('#FF5733', '#000000', 0.7); // 코랄 70%, 검정 30% → 쉐이드

RGB 혼합은 보색 사이에서 지각적으로 평평한 결과를 만든다. 더 나은 중간 지점을 위해 chroma.js 같은 라이브러리는 OKLCH나 Lab 공간에서의 혼합을 제공한다.

대비 비율 (WCAG)

WCAG 대비 비율은 원시 채널 값이 아닌 상대 휘도에서 계산된다. 휘도는 감마 보정이 필요하다 — sRGB 값이 저장될 때 적용된 인코딩의 역수:

function relativeLuminance({ r, g, b }) {
  const linearize = (v) => {
    const sRGB = v / 255;
    return sRGB <= 0.04045
      ? sRGB / 12.92
      : Math.pow((sRGB + 0.055) / 1.055, 2.4);
  };
  const R = linearize(r), G = linearize(g), B = linearize(b);
  return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}

function contrastRatio(hex1, hex2) {
  const L1 = relativeLuminance(parseHex(hex1));
  const L2 = relativeLuminance(parseHex(hex2));
  const lighter = Math.max(L1, L2);
  const darker = Math.min(L1, L2);
  return (lighter + 0.05) / (darker + 0.05);
}

contrastRatio('#FF5733', '#FFFFFF'); // ~3.0 — 일반 텍스트 WCAG AA 실패
contrastRatio('#000000', '#FFFFFF'); // 21.0 — 최대 대비

WCAG AA는 일반 텍스트에 4.5:1, 큰 텍스트에 3:1을 요구한다. Color Converter로 모든 쌍을 확인하라.


인기 라이브러리 비교: chroma.js, culori, tinycolor2

간단한 조정 이상의 경우, 전용 라이브러리는 상당한 시간을 절약하고 색상 수학의 엣지 케이스 버그를 방지한다.

chroma.js

크기: ~13KB gzipped | 성숙도: 2013년, 활발히 유지보수 | 라이선스: BSD

chroma.js는 가장 널리 알려진 JavaScript 색상 라이브러리다. API가 유창하고 체이닝 가능하다:

import chroma from 'chroma-js';

// 어떤 형식이든 파싱
const color = chroma('#FF5733');

// 조정
color.darken(1).hex();      // '#D93B10'
color.lighten(1).hex();     // '#FF8066'
color.saturate(0.5).hex();  // '#FF4719'
color.desaturate(0.5).hex();// '#F2653F'

// 변환
color.rgb();  // [255, 87, 51]
color.hsl();  // [11, 1, 0.6]
color.lab();  // [52.8, 47.1, 44.7]
color.oklch();// [0.63, 0.19, 27.5]

// 다른 색 공간에서 혼합
chroma.mix('#FF5733', '#3B82F6', 0.5, 'rgb');   // RGB에서 (평평한 중간점)
chroma.mix('#FF5733', '#3B82F6', 0.5, 'oklch'); // OKLCH에서 (선명한 중간점)

// 척도 생성
const scale = chroma.scale(['#FFF5F5', '#FF5733', '#7F0000'])
  .mode('oklch')
  .colors(9);

// 대비
chroma.contrast('#FF5733', '#FFFFFF'); // 3.0

chroma.js는 OKLCH 혼합을 포함한 광범위한 색 공간 지원과 함께 포괄적이고 잘 문서화된 라이브러리가 필요할 때 올바른 선택이다.

culori

크기: ~6KB gzipped (트리 쉐이킹 가능) | 성숙도: 2019년, 활발히 유지보수 | 라이선스: MIT

culori는 트리 쉐이킹을 위해 설계된 현대적인 함수형 라이브러리다. 모든 연산이 독립적인 함수다 — 사용하는 것만 임포트한다:

import { parse, formatHex, oklch, interpolate, formatCss } from 'culori';

// 파싱
const color = parse('#FF5733'); // { mode: 'rgb', r: 1, g: 0.34, b: 0.2 }

// OKLCH로 변환
const inOklch = oklch(color); // { mode: 'oklch', l: 0.63, c: 0.19, h: 27.5 }

// 명도 조정
const lighter = { ...inOklch, l: inOklch.l + 0.1 };
formatHex(lighter); // '#FF8469'

// 그라디언트/애니메이션을 위한 보간
const gradient = interpolate(['#FF5733', '#3B82F6'], 'oklch');
formatCss(gradient(0.5)); // CSS 형식의 중간점 색상

// 척도 생성
import { samples } from 'culori';
const stops = samples(5).map(t => formatHex(gradient(t)));
// ['#FF5733', '#E85E56', '#9B6DE3', '#6186F0', '#3B82F6']

culori는 mode 속성을 가진 일반 객체로 작동하여 색상을 직렬화하고, 상태에 저장하거나, 네트워크를 통해 전송하기 쉽게 만든다. 번들 크기가 중요하고 트리 쉐이킹이 적용되는 현대 TypeScript 프로젝트에 최적의 선택이다.

tinycolor2

크기: ~5KB gzipped | 성숙도: 2012년, 안정적 (덜 활발) | 라이선스: MIT

tinycolor2는 가장 작고 가장 허용적인 파서 — CSS 명명 색상을 포함하여 거의 모든 색상 문자열 형식을 받아들이며, 설정 없이 작동한다:

import tinycolor from 'tinycolor2';

// 거의 모든 것을 파싱
tinycolor('red').toHexString();         // '#FF0000'
tinycolor('hsl(11, 100%, 60%)').toHexString(); // '#FF5733'
tinycolor('#F53').toHexString();        // '#FF5533'

// 조정
tinycolor('#FF5733').lighten(15).toHexString();    // '#FF8966'
tinycolor('#FF5733').darken(15).toHexString();     // '#C92B04'
tinycolor('#FF5733').desaturate(30).toHexString(); // '#EB6643'
tinycolor('#FF5733').spin(180).toHexString();      // '#33AEff' (보색)

// 가독성 / 대비
tinycolor.readability('#FF5733', '#FFFFFF'); // 3.0
tinycolor.isReadable('#FF5733', '#FFFFFF');  // false (WCAG AA 실패)
tinycolor.isReadable('#FF5733', '#FFFFFF', { level: 'AA', size: 'large' }); // true

// 색상 조화
tinycolor('#FF5733').triad();         // [TinyColor, TinyColor, TinyColor]
tinycolor('#FF5733').analogous();     // 6가지 유사색
tinycolor('#FF5733').complement();    // 단일 보색

tinycolor2는 (어떤 형식이든 될 수 있는) 사용자가 입력한 색상 문자열의 신뢰할 수 있는 파싱과 더 큰 의존성 없이 기본적인 조작이 필요한 프로젝트에 올바른 선택이다.

라이브러리 비교 요약

기능 chroma.js culori tinycolor2
번들 크기 ~13KB ~6KB (트리 쉐이킹 가능) ~5KB
색 공간 RGB, HSL, Lab, LCH, OKLCH OKLCH, P3 포함 20개+ RGB, HSL, HSV
API 스타일 유창/체이닝 함수형 객체 지향
TypeScript 커뮤니티 타입 내장 커뮤니티 타입
색상 혼합 RGB, HSL, Lab, OKLCH 모든 색 공간 RGB만
최적 용도 포괄적 사용, 데이터 시각화 현대 TS 번들 파싱, 간단한 작업

처음부터 색상 유틸리티 구축

헥스 파싱, 밝게, 어둡게, 대비 확인만 필요한 프로덕션 애플리케이션의 경우, 의존성 없는 유틸리티가 종종 더 나은 아키텍처적 선택이다. 완전한 구현이 여기 있다:

// color-utils.js

export function parseHex(hex) {
  const clean = hex.replace('#', '');
  const full = clean.length === 3
    ? clean.split('').map(c => c + c).join('')
    : clean;
  return {
    r: parseInt(full.slice(0, 2), 16),
    g: parseInt(full.slice(2, 4), 16),
    b: parseInt(full.slice(4, 6), 16),
  };
}

export function toHex({ r, g, b }) {
  return '#' + [r, g, b]
    .map(v => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0'))
    .join('').toUpperCase();
}

function rgbToHsl({ r, g, b }) {
  const rn = r / 255, gn = g / 255, bn = b / 255;
  const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn);
  const l = (max + min) / 2;
  const d = max - min;
  if (d === 0) return { h: 0, s: 0, l };
  const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
  let h;
  switch (max) {
    case rn: h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; break;
    case gn: h = ((bn - rn) / d + 2) / 6; break;
    default: h = ((rn - gn) / d + 4) / 6;
  }
  return { h, s, l };
}

function hslToRgb({ h, s, l }) {
  if (s === 0) {
    const v = Math.round(l * 255);
    return { r: v, g: v, b: v };
  }
  const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
  const p = 2 * l - q;
  const hue2rgb = (p, q, t) => {
    if (t < 0) t += 1;
    if (t > 1) t -= 1;
    if (t < 1/6) return p + (q - p) * 6 * t;
    if (t < 1/2) return q;
    if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
    return p;
  };
  return {
    r: Math.round(hue2rgb(p, q, h + 1/3) * 255),
    g: Math.round(hue2rgb(p, q, h) * 255),
    b: Math.round(hue2rgb(p, q, h - 1/3) * 255),
  };
}

export function lighten(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  return toHex(hslToRgb({ ...hsl, l: Math.min(1, hsl.l + amount / 100) }));
}

export function darken(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  return toHex(hslToRgb({ ...hsl, l: Math.max(0, hsl.l - amount / 100) }));
}

export function desaturate(hex, amount) {
  const hsl = rgbToHsl(parseHex(hex));
  return toHex(hslToRgb({ ...hsl, s: Math.max(0, hsl.s - amount / 100) }));
}

function linearize(v) {
  const sRGB = v / 255;
  return sRGB <= 0.04045 ? sRGB / 12.92 : Math.pow((sRGB + 0.055) / 1.055, 2.4);
}

export function relativeLuminance(hex) {
  const { r, g, b } = parseHex(hex);
  return 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b);
}

export function contrastRatio(hex1, hex2) {
  const L1 = relativeLuminance(hex1);
  const L2 = relativeLuminance(hex2);
  const lighter = Math.max(L1, L2);
  const darker = Math.min(L1, L2);
  return (lighter + 0.05) / (darker + 0.05);
}

export function isWcagAA(foreground, background, largeText = false) {
  const ratio = contrastRatio(foreground, background);
  return ratio >= (largeText ? 3.0 : 4.5);
}

export function bestTextColor(background) {
  const L = relativeLuminance(background);
  return L > 0.179 ? '#000000' : '#FFFFFF';
}

사용법:

import { lighten, darken, contrastRatio, bestTextColor } from './color-utils.js';

const brand = '#FF5733';
const hover = darken(brand, 10);       // :hover를 위한 어두운 색
const light = lighten(brand, 30);      // 배경을 위한 밝은 틴트
const text = bestTextColor(brand);     // 브랜드 색 위 텍스트로 검정 또는 흰색

console.log(contrastRatio(text, brand)); // ≥ 4.5여야 함

런타임 색상의 성능 고려사항

적극적으로 캐시하기

색상 계산은 참조적으로 투명하다 — 같은 헥스 입력은 항상 같은 출력을 만든다. 간단한 메모이제이션 래퍼는 매 렌더링마다 재계산을 피한다:

function memoize(fn) {
  const cache = new Map();
  return (...args) => {
    const key = JSON.stringify(args);
    if (!cache.has(key)) cache.set(key, fn(...args));
    return cache.get(key);
  };
}

const lightenCached = memoize(lighten);
const contrastCached = memoize(contrastRatio);

JavaScript 대신 CSS 커스텀 속성 사용

동적 테마를 위해 매 상태 변경마다 JavaScript로 색상을 재계산하지 마라. 한 번에 팔레트를 계산하고, CSS 커스텀 속성에 쓰고, CSS가 모든 컴포넌트를 처리하게 하라:

function applyTheme(brandHex) {
  const root = document.documentElement;
  root.style.setProperty('--brand', brandHex);
  root.style.setProperty('--brand-dark', darken(brandHex, 10));
  root.style.setProperty('--brand-light', lighten(brandHex, 30));
  root.style.setProperty('--on-brand', bestTextColor(brandHex));
}

// 브랜드 색상이 변경될 때 한 번만 호출, 매 렌더링마다 호출하지 않음
applyTheme('#FF5733');

렌더 루프에서 문자열 파싱 피하기

초당 60번 실행되는 React 렌더 함수 안에서 "rgb(255, 87, 51)"를 파싱하는 것은 낭비적이다. 한 번 파싱하고, 결과를 저장하고, 원시 문자열 대신 구조화된 색상 객체를 컴포넌트 트리를 통해 전달하라.

// 비용이 많이 드는: 매 호출마다 파싱
const color = chroma(colorString).darken(1).hex();

// 더 나은 방법: 한 번 파싱, 여러 번 변환
const parsed = chroma(colorString);
const darkVariant = parsed.darken(1).hex();
const lightVariant = parsed.lighten(1).hex();

런타임 색상 생성을 대규모로 하는 경우 — 마우스 이동마다 업데이트하는 색상 피커나 수백 가지 계산된 색상을 가진 시각화 — culori가 최선의 선택이다. 함수형, 무상태 아키텍처와 작은 번들 크기가 호출당 최소한의 오버헤드를 가진다.


핵심 요점

  • JavaScript에는 네이티브 색상 타입이 없다 — 수학 연산을 하기 전에 헥스, RGB, HSL 문자열을 구조화된 객체로 파싱하라.
  • HSL 산술(명도를 위한 l 조정, 채도를 위한 s 조정)은 대부분의 일반적인 디자인 변환을 직접 처리한다.
  • WCAG 대비 비율은 감마 보정으로 휘도 계산이 필요하다 — 명도 채널 수학으로 근사하지 마라.
  • chroma.js는 OKLCH 혼합을 포함한 탁월한 색 공간 지원을 가진 가장 완전한 라이브러리; culori는 TypeScript 프로젝트를 위한 현대적인 트리 쉐이킹 가능한 선택; tinycolor2는 사용자 입력 시나리오를 위한 가장 허용적인 파서.
  • 의존성 없는 유틸리티는 파싱, 밝게/어둡게, 대비만 필요한 애플리케이션에 적합하다 — 완전한 구현은 100줄 미만이다.
  • 계산된 색상을 적극적으로 캐시하고 렌더링마다 재계산하는 대신 CSS 커스텀 속성에 결과를 작성하라.
  • Color Converter로 색상 변환을 시각적으로 확인하고, Shade Generator로 단일 브랜드 헥스에서 50–950 Tailwind 호환 척도를 생성하라.

관련 색상

관련 브랜드

관련 도구