2026-05-22·1분 읽기·
OKLAB/OKLCH 색 공간을 정적 그림이 아니라 직접 돌려보고 싶어서 /playground 에 인터랙티브 3D 뷰어를 붙였다. React Three Fiber + culori 로 색역을 점구름으로 그리고, sRGB↔OKLab 좌표계를 토글하고, 그라디언트 보간 경로의 "함몰"을 눈으로 보이게 만든 과정 — Next.js 16 의 ssr:false 함정과 three.js 정점색 색공간 함정까지 정리한다.
이 글이 도움이 됐나요?
목표: OKLAB/OKLCH 색 공간을 회전시켜 보는 인터랙티브 3D 뷰어 결과물: zino.kr/playground/color-space 스택: React Three Fiber(three.js) · culori · Next.js 16
OKLAB·OKLCH 색 공간 글을 읽다가 색 공간을 3D로 그린 그림이 눈에 들어왔다. 그런데 정적 이미지라 답답했다. RGB 큐브가 OKLab 공간에서 어떻게 일그러지는지, 그라디언트 보간이 어디로 휘는지는 직접 돌려봐야 감이 온다.
그래서 /playground 에 페이지를 하나 붙였다. 이 글은 그 제작기다.
요약부터 — 색 하나 고를 땐 RGB든 HSL이든 상관없다. 하지만 색을 프로그래밍적으로 다루거나(호버·상태 변형) 그라디언트·팔레트를 만들 때 OKLCH가 확실히 유리하다. 이유는 OKLab이 "지각 균등(perceptually uniform)" 공간이기 때문인데, 말로는 와닿지 않으니 만들어서 보기로 했다.
| 용도 | 선택 | 이유 |
|---|---|---|
| 3D 렌더 | React Three Fiber (@react-three/fiber v9) | three.js를 React 선언형으로. v9가 React 19 호환 |
| 카메라/컨트롤 | @react-three/drei | OrbitControls, Line 등 헬퍼 |
| 색 변환 | culori | sRGB↔OKLab/OKLCH 변환·보간. 가볍고 정확 |
three.js를 날것으로 써도 되지만, 사이트가 이미 React(Next.js)라 R3F로 씬을 컴포넌트처럼 다루는 게 깔끔하다.
ssr:false는 클라이언트 컴포넌트에서만three.js는 window·WebGL에 의존하는 브라우저 전용 라이브러리다. 서버 렌더링 단계에서 불러오면 깨진다. 그래서 next/dynamic 의 ssr:false 로 막아야 하는데, Next.js 16에서는 규칙이 하나 있다 — ssr:false는 서버 컴포넌트에서 금지다.
ssr: falseis not allowed withnext/dynamicin Server Components. Please move it into a Client Component.
playground 페이지는 기본이 서버 컴포넌트다. 그래서 페이지 자체를 클라이언트 컴포넌트로 두고, 그 안에서 dynamic 을 호출했다.
// page.tsx — "use client"
"use client";
import dynamic from "next/dynamic";
// three.js는 브라우저 전용 → ssr:false로 SSR을 건너뛰고
// 이 라우트 전용 청크로 분리해 사이트 공유 번들에 three가 안 섞이게 한다.
const ColorSpaceViewer = dynamic(() => import("./ColorSpaceViewer"), {
ssr: false,
loading: () => <Spinner />,
});dynamic 의 부수 효과가 하나 더 있는데, 번들 격리다. three.js는 수백 KB라 공유 청크에 섞이면 사이트 전체가 무거워진다. dynamic import 로 불러오면 three는 이 페이지 전용 lazy 청크에 들어가고, 블로그·홈 같은 다른 페이지는 영향이 없다. (전에 Shiki 문법 하이라이터를 클라이언트에서 잘못 import 했다가 번들이 1.5MB 불어난 적이 있어서, 이런 무거운 라이브러리는 반사적으로 격리한다.)
색역을 점구름으로 그리려면 각 점에 색을 입혀야 한다. BufferGeometry 의 color 어트리뷰트에 RGB 값을 넣으면 되는데 — 처음엔 색이 전부 떠 보였다(washed out).
원인: three.js는 r152부터 color management가 기본 켜짐이고, BufferAttribute 의 정점색을 선형(linear) 공간으로 간주한다. sRGB 값(우리가 흔히 쓰는 #rrggbb 의 그 값)을 그대로 넣으면 한 번 더 밝아진다.
그래서 sRGB → 선형 변환을 직접 해줬다.
function srgbToLinear(c: number): number {
const x = Math.min(1, Math.max(0, c));
return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
}색역 뷰어에서 색이 정확하지 않으면 의미가 없으니, 이건 타협할 수 없는 부분이었다.
sRGB 큐브를 격자로 샘플링(GRID³ 개)해서 각 점을 위치 + 색으로 만든다. 위치는 그냥 R/G/B를 축으로, 색은 위에서 만든 선형 변환을 거쳐서.
for (let r = 0; r < n; r++)
for (let g = 0; g < n; g++)
for (let b = 0; b < n; b++) {
const [fr, fg, fb] = [r, g, b].map((v) => v / (n - 1));
// 위치: sRGB 큐브 좌표 ([-1, 1])
positions.push((fr - 0.5
R3F에서는 이 버퍼를 <points> 에 얹으면 끝이다.
<points geometry={geometry}>
<pointsMaterial size={0.05} vertexColors sizeAttenuation />
</points>
여기가 핵심이다. 같은 색 집합을 두 좌표계에 배치하고 토글한다.
L(명도)·a·b를 축으로변환은 culori의 converter 가 해준다.
import { converter } from "culori";
const toOklab = converter("oklab");
function colorToPos(r, g, b, mode) {
if (mode === "srgb") return [(r-0.5)*2, (g-0.5)*2, (b-0.5)*2];
const { l, a, b: bb } = toOklab({ mode: "rgb", r, g, b
토글하면 반듯한 큐브가 OKLab 공간에서는 일그러진 덩어리로 변한다. 이 일그러짐이 곧 "지각 균등"의 정체다 — 사람 눈에 색 차이가 일정하게 느껴지도록 색들을 일부러 재배치한 공간이라, sRGB 큐브의 균일한 격자가 OKLab에서는 균일하지 않게 된다.

OKLCH를 써야 하는 가장 실질적인 이유는 그라디언트다. 두 색을 sRGB에서 선형 보간하면 중간 색이 칙칙하게 가라앉는다 — 이게 "함몰"이다.
뷰어에서는 두 색 사이를 두 방식으로 보간해 3D 경로로 그렸다.
import { interpolate } from "culori";
const srgbPath = interpolate([hexA, hexB], "rgb"); // sRGB 보간
const oklabPath = interpolate([hexA, hexB], "oklab"); // OKLab 보간
// 각 t에 대해 colorToPos()로 3D 위치를 구해 라인으로 연결OKLab 공간(명도가 세로축)에서 보면 sRGB 보간 경로가 명도축으로 푹 꺼지는 게 보인다. 두 끝점은 밝은데 중간이 어두워지는 것 — 그게 함몰이다. OKLab 보간 경로는 명도를 매끄럽게 유지한다.
그리고 결과를 2D로도 나란히 깔았다. CSS만으로:
/* 함몰 있음 — 기본 sRGB 보간 */
background: linear-gradient(90deg in srgb, #1d4ed8, #facc15);
/* 함몰 없음 — "in oklab" 한 단어 */
background: linear-gradient(90deg in oklab, #1d4ed8, #facc15);in oklab 한 단어. 이게 이 글의 실전 요약이다. 색조 이동이 큰 그라디언트라면 이거 하나로 중간 칙칙함이 사라진다.
핵심이 돌아간 뒤 두 가지를 더 붙였다.
색 피커. 프리셋 세 개로는 "내 색은 어떤데?"가 안 풀린다. 네이티브 <input type="color"> 면 의존성 0으로 시작·끝 색을 자유롭게 고를 수 있다. 고르는 즉시 3D 경로와 2D 띠가 다시 그려진다.
전환 애니메이션. 좌표계 토글이 처음엔 즉시 스냅이었는데, 그게 좀 아까웠다. 이 뷰어가 하려는 말이 "좌표계가 바뀌면 같은 색역이 일그러진다"인데 — 스냅해 버리면 정작 그 일그러지는 과정이 안 보인다. 변형 그 자체가 지각 균등의 시연이다. 그래서 sRGB ↔ OKLab 을 0~1 진행도로 두고 매 프레임 두 좌표를 보간했다.
useFrame((_, delta) => {
const target = mode === "oklab" ? 1 : 0;
// 프레임률 독립 damp — 진행도를 목표값으로 부드럽게 끌어당김
progress = THREE.MathUtils.damp(progress, target, 7, delta);
// 점 위치 = sRGB 좌표 ↔ OKLab 좌표 선형 보간
for (let i = 0; i < livePos.length; i++) {
livePos[i] = srgbPos[i] + (oklabPos[i] - srgbPos[i])
작은 함정 하나 — 굵은 경로선(drei <Line>)은 점구름과 갱신 방식이 다르다. 점구름은 BufferAttribute 의 needsUpdate 플래그면 되지만, 굵은 선은 React state로 매 프레임 갈아끼우면 안 되고 내부 Line2.geometry.setPositions() 를 직접 호출해야 한다. 리렌더 없이 GPU 버퍼만 갱신하는 게 60fps의 핵심이다.
결과적으로 큐브가 OKLab 덩어리로 흐물흐물 변하는 0.5초가 생겼다. 정적인 토글보다 "왜 OKLab을 쓰는가"가 훨씬 잘 와닿는다.
linear-gradient(in oklab, …) — 한 단어.oklch(from var(--c) calc(l + 0.1) c h) — 명도만 10% 올린 호버 색을 한 줄로.dynamic(ssr:false) + 클라이언트 컴포넌트, 그리고 정점색 선형 변환을 잊지 말 것.직접 돌려보는 게 제일 빠르다 → /playground/color-space. 좌표계를 OKLab으로 바꾸고 그라디언트를 골라, sRGB 경로가 어떻게 꺼지는지 보면 된다.
Comments
댓글
댓글을 남기려면 로그인이 필요해요. (네이버 · 구글 계정)
댓글 불러오는 중…