OKLCH 기반 디자인 시스템 설계하기

오성준·2026년 3월 3일

Inspired by Toss

목록 보기
1/1
post-thumbnail

Tailwind CSS 팔레트의 한계를 넘어, 인지적으로 균일한 색상 시스템을 구축한 과정

들어가며

디자인 시스템에서 색상은 가장 기본적이면서도 가장 어려운 영역이다. "green-400과 red-400이 같은 밝기로 보여야 한다"는 단순한 요구사항이 왜 그렇게 어려운지, 그리고 OKLCH 색상 공간이 이 문제를 어떻게 해결하는지 이야기하려 한다.

이 글에서는 React Native 프로젝트에서 Tailwind CSS의 색상 팔레트를 OKLCH 기반으로 전환하고, 시맨틱 컬러 시스템과 WCAG 접근성 검증까지 갖춘 디자인 시스템을 설계한 전체 과정을 다룬다.


1. 왜 색상 시스템이 필요한가

처음에는 Tailwind CSS의 기본 팔레트를 그대로 가져다 썼다.

const colors = {
  green: { 500: '#22C55E' },
  red: { 500: '#EF4444' },
  sky: { 500: '#0EA5E9' },
  // ...
};

나쁘지 않았다. Tailwind 팔레트는 이미 충분히 잘 정제된 색상이다. 하지만 라이트/다크 테마를 구현하면서 문제가 드러났다.

  • 같은 스케일 단계인데 밝기가 다르다. green[400]red[400]을 나란히 놓으면 체감 밝기가 다르다.
  • 다크 모드에서 색상 매핑이 직관적이지 않다. "라이트의 300 단계를 다크에서는 몇 단계로 뒤집어야 하지?"라는 질문에 명확한 답이 없다.
  • 접근성 검증이 예측 불가능하다. 같은 단계끼리 조합해도 WCAG 대비 비율이 색상 패밀리마다 제각각이다.

근본 원인은 하나다. RGB/HSL 기반 팔레트는 인간의 색상 인지와 일치하지 않는다.


2. 색상 공간 이해하기: RGB → HSL → OKLCH

RGB: 기계의 언어

RGB는 화면의 빨강/초록/파랑 서브픽셀 강도를 직접 제어한다. #FF0000(빨강)과 #00FF00(초록)은 RGB 값 구조가 동일하지만, 사람 눈에는 초록이 훨씬 밝아 보인다. RGB는 하드웨어의 동작 방식이지, 인간의 인지 방식이 아니다.

HSL: 직관적이지만 부정확

HSL(Hue, Saturation, Lightness)은 "색상·채도·밝기"라는 인간 친화적 축을 제공한다. 하지만 HSL의 Lightness는 수학적 밝기이지 지각적 밝기가 아니다.

hsl(120, 100%, 50%) → 초록  → 체감 밝기: 매우 밝음
hsl(240, 100%, 50%) → 파랑  → 체감 밝기: 매우 어두움

HSL에서 L=50%인 초록과 파랑은 체감 밝기가 완전히 다르다. 이것이 Tailwind를 포함한 대부분의 색상 팔레트가 스케일 단계 간 밝기 균일성을 보장하지 못하는 이유다.

OKLCH: 인간의 눈으로 보는 색상

OKLCH(Oklab Lightness, Chroma, Hue)는 2021년 Björn Ottosson이 제안한 지각 균일 색상 공간이다.

  • L (Lightness): 0~1 사이의 지각적 밝기. 같은 L 값이면 어떤 색상이든 같은 밝기로 느껴진다.
  • C (Chroma): 색의 선명도. 0이면 무채색, 높을수록 선명.
  • H (Hue): 0~360의 색상각.

핵심은 L의 균일성이다. OKLCH에서 L=0.62인 초록과 L=0.62인 파랑은 실제로 같은 밝기로 인지된다. 이것이 RGB/HSL과의 결정적 차이다.


3. 설계 목표

OKLCH 전환의 구체적인 목표를 세 가지로 잡았다.

  1. 인지 균일 명도: 모든 색상 패밀리의 같은 스케일 단계가 동일한 체감 밝기를 가진다.
  2. Tailwind 색감 유지: 기존 Tailwind 팔레트의 Hue(색상)와 Chroma(채도) 정체성은 보존한다.
  3. 접근성 예측 가능성: 같은 단계 조합이면 패밀리에 관계없이 비슷한 대비 비율을 기대할 수 있다.

4. 팔레트 생성: Tailwind에서 OKLCH로

4.1 Lightness 타겟 설정

먼저 50~900 각 스케일 단계에 OKLCH Lightness 타겟을 정했다.

const LIGHTNESS_TARGETS = {
  50: 0.98,   // 가장 밝은 단계
  100: 0.96,
  200: 0.92,
  300: 0.84,
  400: 0.70,
  500: 0.62,  // 기준 단계
  600: 0.55,
  700: 0.47,
  800: 0.37,
  900: 0.25,  // 가장 어두운 단계
};

모든 색상 패밀리가 이 타겟을 공유한다. green[400]이든 red[400]이든 sky[400]이든, L은 모두 0.70이다.

4.2 Hue/Chroma 추출

각 색상 패밀리의 정체성은 Tailwind 500 단계에서 가져왔다. 500은 팔레트의 "대표색"이기 때문이다.

import { parse, converter } from 'culori';

const toOklch = converter('oklch');

function extractHueChroma(hex) {
  const oklch = toOklch(parse(hex));
  return { hue: oklch.h ?? 0, chroma: oklch.c };
}

// Tailwind green[500] '#22C55E' → hue: 145.5, chroma: 0.196
// Tailwind red[500] '#EF4444'  → hue: 25.2,  chroma: 0.191

4.3 Chroma 스케일링

단순히 모든 단계에 같은 Chroma를 적용하면 안 된다. 밝은 색(L이 높은)과 어두운 색(L이 낮은)에서는 sRGB 색역(gamut) 한계로 높은 Chroma를 표현할 수 없다.

기준점(L=0.62, 500 단계)에서 멀어질수록 Chroma를 줄이는 스케일링을 적용했다.

function generateScale(hue, referenceChroma, lightnessOverrides = {}) {
  const scale = {};

  for (const [step, defaultLightness] of Object.entries(LIGHTNESS_TARGETS)) {
    const lightness = lightnessOverrides[step] ?? defaultLightness;

    // 기준점(0.62)에서 멀어질수록 Chroma를 줄임
    const lightnessRatio = Math.abs(lightness - 0.62) / 0.62;
    const chromaScale = 1 - lightnessRatio * 0.6;
    const targetChroma = referenceChroma * Math.max(chromaScale, 0.3);

    const oklchColor = { mode: 'oklch', l: lightness, c: targetChroma, h: hue };

    // sRGB 색역 클램핑
    const clamped = clampChroma(oklchColor, 'oklch');
    scale[step] = formatHex(clamped).toUpperCase();
  }

  return scale;
}

clampChroma은 culori 라이브러리의 핵심 함수로, OKLCH 색상이 sRGB 색역을 벗어나면 Chroma를 자동으로 줄여 표현 가능한 가장 가까운 색으로 매핑한다. 이 과정에서 Lightness와 Hue는 보존되므로, 밝기 균일성이 깨지지 않는다.

4.4 변환 결과

Tailwind 원본과 OKLCH 전환 결과를 비교하면 차이가 보인다.

// green
Step   Tailwind    → OKLCH      | L 변화
50     #F0FDF4     → #EAFFED    | 0.983 → 0.980
400    #4ADE80     → #30BC5E    | 0.794 → 0.700
500    #22C55E     → #00A148    | 0.723 → 0.620
900    #14532D     → #002A0D    | 0.358 → 0.250

// red
Step   Tailwind    → OKLCH      | L 변화
400    #F87171     → #FF635E    | 0.704 → 0.700
500    #EF4444     → #E93E3F    | 0.637 → 0.620

주목할 점은 green[400]의 L이 0.794에서 0.700으로 내려갔고, red[400]의 L은 0.704에서 0.700으로 거의 변하지 않았다는 것이다. 기존에 green[400]이 red[400]보다 눈에 띄게 밝았는데, 이제 둘 다 L=0.70으로 통일되어 체감 밝기가 같아졌다.


5. Dark Yellow Problem

OKLCH의 L이 인지적으로 균일하다고 했지만, 완벽하지는 않다. 노란색(Yellow)에서 예외가 발생한다.

노란색은 인간의 시각 시스템에서 특수한 위치에 있다. 상대 휘도(relative luminance) 계산식 0.2126R + 0.7152G + 0.0722B에서 볼 수 있듯이, 초록(G) 채널의 기여도가 압도적이다. 노란색은 R과 G가 모두 높은 색상이므로, 같은 OKLCH Lightness에서도 실제 화면 출력 시 다른 색보다 더 어둡게 느껴진다.

이것이 "Dark Yellow Problem"이다.

해결책은 노란색 팔레트의 200~400 단계에 Lightness 보정값을 적용하는 것이다.

const LIGHTNESS_OVERRIDES = {
  yellow: { 200: 0.95, 300: 0.89, 400: 0.75 },
  // 기본값:  200: 0.92, 300: 0.84, 400: 0.70
  // 보정:   +0.03,      +0.05,      +0.05
};

+0.03~0.05의 미세한 보정이지만, 다른 색상과 나란히 놓았을 때 시각적 동등 밝기를 맞추는 데 충분했다.


6. 시맨틱 컬러 시스템

원시 팔레트(primitive colors)만으로는 디자인 시스템이 아니다. 컴포넌트가 직접 green[400]을 참조하면 테마 전환이 불가능하다. 원시 색상과 사용처 사이에 시맨틱 레이어가 필요하다.

6.1 Target-Role-Variant 구조

시맨틱 컬러를 fill, text, icon, border 네 가지 대상(Target)으로 분류했다.

type SemanticColors = {
  fill: {
    brand: string;       // 브랜드 주요 배경
    brandWeak: string;   // 브랜드 약한 배경
    background: string;  // 앱 전체 배경
    surface: string;     // 카드/패널 배경
    success: string;     // 성공 상태
    error: string;       // 오류 상태
    // ...
  };
  text: {
    primary: string;     // 주요 텍스트
    secondary: string;   // 보조 텍스트
    onBrand: string;     // 브랜드 배경 위 텍스트
    // ...
  };
  icon: { /* ... */ };
  border: { /* ... */ };
};

이 구조의 장점은 이름만 보고 용도를 알 수 있다는 것이다.

  • fill.brand → "배경색으로 쓰이는 브랜드 색"
  • text.onBrand → "브랜드 배경 위에 올라가는 텍스트 색"
  • border.neutral → "중립적인 테두리 색"

처음에는 colors.textPrimary, colors.background 같은 플랫 구조를 썼었다. 토큰 수가 적을 때는 괜찮았지만, 다크 모드 대응과 상태 색상이 추가되면서 네이밍이 모호해지기 시작했다. brandWeak은 배경인가 텍스트인가? onSuccess는 아이콘에도 쓸 수 있나? 중첩 구조로 전환하니 이런 모호함이 사라졌다.

6.2 라이트/다크 테마 매핑

같은 시맨틱 토큰이 테마에 따라 다른 원시 색상을 가리킨다.

// lightTheme.ts
const colors = {
  fill: {
    brand: primitiveColors.green[400],      // 밝은 배경에 어울리는 강도
    brandWeak: primitiveColors.green[50],    // 매우 연한 초록 배경
    background: primitiveColors.white,
    surface: primitiveColors.white,
  },
  text: {
    primary: primitiveColors.slate[900],     // 어두운 텍스트
    secondary: primitiveColors.slate[600],
  },
  border: {
    neutral: primitiveColors.slate[300],
  },
} satisfies SemanticColors;

// darkTheme.ts
const colors = {
  fill: {
    brand: primitiveColors.green[400],      // 브랜드 정체성은 동일
    brandWeak: primitiveColors.green[900],   // 어두운 초록 배경 (반전)
    background: primitiveColors.slate[900],
    surface: primitiveColors.slate[800],
  },
  text: {
    primary: primitiveColors.slate[50],      // 밝은 텍스트 (반전)
    secondary: primitiveColors.slate[300],
  },
  border: {
    neutral: primitiveColors.slate[600],     // 반전
  },
} satisfies SemanticColors;

OKLCH 팔레트 덕분에 반전 로직이 단순해진다. 라이트에서 300을 쓰면 다크에서는 600을 쓰면 된다. 둘 다 기준점(500)에서 같은 거리에 있고, OKLCH Lightness가 대칭이기 때문이다.

라이트L 값다크L 값
50 (0.98)매우 밝음900 (0.25)매우 어두움
200 (0.92)밝음700 (0.47)어두움
300 (0.84)약간 밝음600 (0.55)약간 어두움

6.3 타입 안전한 색상 경로

컴포넌트에서 동적으로 색상을 지정해야 할 때를 위해 dot-notation 유니온 타입을 제공한다.

type SemanticColorPath =
  | `fill.${keyof SemanticColors['fill']}`   // 'fill.brand' | 'fill.background' | ...
  | `text.${keyof SemanticColors['text']}`   // 'text.primary' | 'text.onBrand' | ...
  | `icon.${keyof SemanticColors['icon']}`
  | `border.${keyof SemanticColors['border']}`;

// 사용
function resolveColor(colors: SemanticColors, path: SemanticColorPath): string {
  const dotIndex = path.indexOf('.');
  const category = path.slice(0, dotIndex) as keyof SemanticColors;
  const role = path.slice(dotIndex + 1);
  return (colors[category] as Record<string, string>)[role];
}

resolveColor(colors, 'fill.brnad')처럼 오타를 내면 타입 에러가 발생한다.


7. 테마 시스템

7.1 ThemeProvider

React Context와 MMKV를 결합해 테마 상태를 관리한다.

export function ThemeProvider({ children, initialColorScheme = 'system' }: ThemeProviderProps) {
  const systemColorScheme = useSystemColorScheme();
  const [preference, setPreference] = useState<ColorScheme>(() => {
    const saved = storage.getString(THEME_STORAGE_KEY);
    if (saved === 'light' || saved === 'dark' || saved === 'system') {
      return saved;
    }
    return initialColorScheme;
  });

  const resolvedColorScheme =
    preference !== 'system'
      ? preference
      : systemColorScheme === 'dark' ? 'dark' : 'light';

  const theme = resolvedColorScheme === 'dark' ? darkTheme : lightTheme;

  const setColorScheme = (scheme: ColorScheme) => {
    setPreference(scheme);
    storage.set(THEME_STORAGE_KEY, scheme);
  };

  return (
    <ThemeContext.Provider value={{ theme, colorScheme: resolvedColorScheme, setColorScheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

세 가지 선택지를 제공한다: light, dark, system. system이면 OS의 다크 모드 설정을 따른다. 사용자의 선택은 MMKV(C++ 기반 동기 스토리지)에 즉시 저장되어 앱 재시작 후에도 유지된다.

7.2 컴포넌트에서의 사용

import { useColors, spacing, rounded } from '@/design';

function ProductCard({ name, price }: ProductCardProps) {
  const colors = useColors();

  return (
    <View style={[
      { padding: spacing.md, backgroundColor: colors.fill.surface },
      rounded('lg'),
    ]}>
      <Text style={{ color: colors.text.primary }}>{name}</Text>
      <Text style={{ color: colors.text.secondary }}>{price}</Text>
    </View>
  );
}

컴포넌트는 원시 색상을 전혀 모른다. colors.fill.surface#FFFFFF인지 #354050인지는 현재 테마가 결정한다.


8. 접근성 자동 검증

OKLCH로 인지 균일 명도를 달성했지만, WCAG 접근성 기준은 별도로 검증해야 한다. WCAG의 대비 비율은 상대 휘도(relative luminance) 기반이고, OKLCH Lightness와는 다른 계산이기 때문이다.

8.1 대비 비율 유틸리티

export function contrastRatio(hex1: string, hex2: string): number {
  const l1 = hexToRelativeLuminance(hex1);
  const l2 = hexToRelativeLuminance(hex2);
  const lighter = Math.max(l1, l2);
  const darker = Math.min(l1, l2);
  return (lighter + 0.05) / (darker + 0.05);
}

export function meetsWcag(ratio: number, level: 'AA' | 'AAA', size: 'normal' | 'large'): boolean {
  const thresholds = {
    AA: { normal: 4.5, large: 3.0 },
    AAA: { normal: 7.0, large: 4.5 },
  };
  return ratio >= thresholds[level][size];
}

8.2 테마 간 대비 균형 감사

더 흥미로운 것은 라이트/다크 테마 간 대비 균형 감사 유틸리티다.

export function auditThemeContrast(
  lightTheme: Theme,
  darkTheme: Theme
): ContrastAuditResult {
  const lightBg = lightTheme.colors.fill.background;
  const darkBg = darkTheme.colors.fill.background;

  const lightEntries = extractTextEntries(lightTheme.colors, lightBg);
  const darkEntries = extractTextEntries(darkTheme.colors, darkBg);

  // 라이트/다크 간 대비 비율 차이가 1.5 이상이면 불균형으로 감지
  const imbalances = [];
  for (const lightEntry of lightEntries) {
    const darkEntry = darkEntries.find(d => d.token === lightEntry.token);
    if (!darkEntry) continue;

    const delta = Math.abs(lightEntry.ratio - darkEntry.ratio);
    if (delta > 1.5) {
      imbalances.push({
        token: lightEntry.token,
        lightRatio: lightEntry.ratio,
        darkRatio: darkEntry.ratio,
        delta,
      });
    }
  }

  return { light: lightEntries, dark: darkEntries, imbalances };
}

text.primary가 라이트에서 대비 비율 12.5인데 다크에서 8.2라면, delta는 4.3으로 불균형이 감지된다. 이런 불균형은 "라이트에서는 잘 보이는데 다크에서는 흐리다"는 사용자 불만으로 이어진다.


9. 전체 아키텍처

최종적으로 디자인 시스템의 구조는 다음과 같다.

src/design/
├── index.ts              # 모든 export 통합 (단일 진입점)
├── ThemeProvider.tsx     # Context + MMKV 테마 persistence
├── tokens/
│   ├── colors.ts         # OKLCH 기반 primitiveColors
│   ├── spacing.ts        # 4px 그리드 스페이싱
│   ├── typography.ts     # 폰트 스케일
│   ├── radius.ts         # 라운딩 + rounded() 헬퍼
│   ├── shadow.ts         # CSS boxShadow 문자열
│   ├── animation.ts      # Reanimated spring/timing
│   └── zIndex.ts         # 레이어 순서
├── themes/
│   ├── types.ts          # SemanticColors, Theme, SemanticColorPath 타입
│   ├── lightTheme.ts     # 라이트 시맨틱 매핑
│   └── darkTheme.ts      # 다크 시맨틱 매핑
├── hooks/
│   ├── useTheme.ts       # 전체 테마 객체
│   ├── useColors.ts      # 시맨틱 컬러만
│   ├── useSpacing.ts     # 스페이싱 토큰
│   ├── useTypography.ts  # 타이포그래피 토큰
│   └── useRadius.ts      # 라운딩 토큰
└── utils/
    ├── contrast.ts           # WCAG 대비 비율 계산
    ├── resolveColor.ts       # dot-notation 색상 리졸버
    └── themeContrastAudit.ts # 테마 간 대비 균형 감사

색상의 흐름을 정리하면 이렇다.

OKLCH 생성 스크립트 (빌드 타임)
    ↓
primitiveColors (원시 팔레트)
    ↓
lightTheme / darkTheme (시맨틱 매핑)
    ↓
ThemeProvider (Context + MMKV)
    ↓
useColors() / useTheme() (React Hook)
    ↓
컴포넌트 (colors.fill.brand, colors.text.primary)

원시 색상은 빌드 타임에 확정된 정적 hex 값이다. 런타임에 OKLCH 연산이 일어나지 않으므로 성능 오버헤드가 없다. culori 라이브러리는 devDependency로만 존재하고, 프로덕션 번들에 포함되지 않는다.


10. 도구: 팔레트 생성 스크립트

개발 과정에서 OKLCH 팔레트를 생성하는 Node.js 스크립트를 만들었다.

node scripts/generate-oklch-palette.mjs

이 스크립트는 세 가지를 출력한다.

  1. TypeScript 코드: colors.ts에 바로 붙여넣을 수 있는 primitiveColors 객체
  2. 비교표: 기존 Tailwind 색상과 새 OKLCH 색상의 1:1 비교
  3. Lightness 변화량: 각 색상의 원래 L값과 타겟 L값

이 스크립트가 팔레트 조정의 피드백 루프를 빠르게 만들어줬다. Lightness 타겟을 바꾸고, 스크립트를 돌리고, 결과를 확인하고, 다시 조정하는 사이클을 반복할 수 있었다.


11. 돌아보며

잘된 점

  • 인지 균일 명도는 실제로 효과가 있다. 서로 다른 색상 패밀리를 나란히 놓았을 때 밝기 편차가 거의 느껴지지 않는다.
  • 다크 모드 매핑이 직관적이 되었다. OKLCH Lightness가 대칭이므로 라이트↔다크 반전 로직이 단순하다.
  • 중첩 시맨틱 구조가 확장에 강하다. 새로운 상태 색상을 추가할 때 네이밍 충돌 없이 적절한 카테고리에 넣으면 된다.

아쉬운 점

  • 런타임 색상 조작이 없다. 현재는 미리 생성된 hex만 사용한다. 동적으로 밝기를 조절하거나 투명도를 적용하려면 추가 작업이 필요하다.
  • Dark Yellow Problem은 수동 보정이다. 지각 모델의 한계로, 특정 색상에 대한 예외 처리가 불가피했다.

배운 점

  • 색상 공간 선택이 디자인 시스템의 기반을 결정한다. RGB/HSL 위에서 아무리 정교한 팔레트를 만들어도, 인지 균일성이라는 구조적 한계를 넘을 수 없다. 기반부터 OKLCH로 시작하면 이후의 모든 결정이 쉬워진다.
  • 도구를 먼저 만들자. generate-oklch-palette.mjs 스크립트를 먼저 만든 덕분에, 팔레트 조정을 두려워하지 않고 여러 번 실험할 수 있었다.
  • 시맨틱 레이어는 처음부터 중첩 구조로. 플랫 구조에서 시작해 나중에 마이그레이션하는 것보다, 처음부터 Target-Role-Variant 구조로 설계하는 편이 낫다.

참고 자료

profile
React Native 개발자

0개의 댓글