목표: 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로 씬을 컴포넌트처럼 다루는 게 깔끔하다.
⚠️ 함정 1 — Next.js 16에서 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 불어난 적이 있어서, 이런 무거운 라이브러리는 반사적으로 격리한다.)
⚠️ 함정 2 — three.js 정점색은 "선형 공간"이다
색역을 점구름으로 그리려면 각 점에 색을 입혀야 한다. 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) * 2, (fg - 0.5) * 2, (fb - 0.5) * 2);
// 색: 선형 공간
colors.push(srgbToLinear(fr), srgbToLinear(fg), srgbToLinear(fb));
}R3F에서는 이 버퍼를 <points> 에 얹으면 끝이다.
<points geometry={geometry}>
<pointsMaterial size={0.05} vertexColors sizeAttenuation />
</points>
🔀 sRGB ↔ OKLab — 같은 색, 다른 공간
여기가 핵심이다. 같은 색 집합을 두 좌표계에 배치하고 토글한다.
- sRGB 큐브: R/G/B를 그대로 축으로 — 우리가 아는 그 큐브
- OKLab 공간: OKLab의
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 });
return [a * 2.6, (l - 0.5) * 2, bb * 2.6]; // a→x, L→y(명도), b→z
}토글하면 반듯한 큐브가 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]) * progress;
}
geometry.attributes.position.needsUpdate = true;
});작은 함정 하나 — 굵은 경로선(drei <Line>)은 점구름과 갱신 방식이 다르다. 점구름은 BufferAttribute 의 needsUpdate 플래그면 되지만, 굵은 선은 React state로 매 프레임 갈아끼우면 안 되고 내부 Line2.geometry.setPositions() 를 직접 호출해야 한다. 리렌더 없이 GPU 버퍼만 갱신하는 게 60fps의 핵심이다.
결과적으로 큐브가 OKLab 덩어리로 흐물흐물 변하는 0.5초가 생겼다. 정적인 토글보다 "왜 OKLab을 쓰는가"가 훨씬 잘 와닿는다.
✅ 가져갈 것
- 색 하나 고를 땐 색 공간이 별 상관 없다. 색을 조작·보간할 때 OKLCH가 유리하다.
- 그라디언트 함몰 방지:
linear-gradient(in oklab, …)— 한 단어. - 색을 프로그래밍적으로 파생할 땐 상대 색 문법:
oklch(from var(--c) calc(l + 0.1) c h)— 명도만 10% 올린 호버 색을 한 줄로. - three.js를 Next.js에 붙일 땐
dynamic(ssr:false)+ 클라이언트 컴포넌트, 그리고 정점색 선형 변환을 잊지 말 것.
직접 돌려보는 게 제일 빠르다 → /playground/color-space. 좌표계를 OKLab으로 바꾸고 그라디언트를 골라, sRGB 경로가 어떻게 꺼지는지 보면 된다.
