
필터 32종 만들기 — 미리보기와 결과물이 절대 안 갈라지게
이미지 도구 사이트에 필터 기능을 넣었다. 시작은 "필터 값을 서버에서 받아올까?"라는 질문이었는데, 답을 찾다 보니 질문 자체가 틀려 있었다.
"필터 정의를 서버에서 받아오면 어떨까"
기능을 설계하기 전에 이 질문이 먼저 나왔다. 필터가 무거운 기능처럼 느껴지니까, 처음 렌더링할 때 서버에서 값을 받아오는 lazy loading이 맞지 않냐는 것.
숫자를 재 보면 답이 바로 나온다.
| 실제 | |
|---|---|
| 서버 왕복 | 50~300ms |
| 아끼는 것 | 2KB 미만 (필터 32종 정의 = 그냥 숫자 배열) |
| 잃는 것 | "페이지 로드 후 오프라인 동작"이라는 이 사이트의 존재 이유 |
게다가 이 사이트엔 서버가 없다. Netlify 정적 호스팅에 서버리스 함수도 없다.
핵심은 이거였다. 무거운 건 정의가 아니라 픽셀이다. 필터 정의는 { fn: 'saturate', v: 1.75 } 같은 객체 몇십 개고, 진짜 비용은 5천만 화소짜리 사진에 색 변환을 돌리는 쪽에 있다. 최적화할 대상을 잘못 짚으면 왕복 비용만 추가된다.
미리보기가 공짜인 이유
기존 편집기 코드를 열어 보고 운이 좋다는 걸 알았다. 미리보기가 <canvas>가 아니라 <img> + CSS transform이었다.
<img
src={current.url}
style={{
width: innerW / srcCrop.w,
left: -(srcCrop.x / srcCrop.w) * innerW,
filter: previewFilter, // ← 여기 한 줄
}}
/>
CSS filter 속성 한 줄이면 끝이다. GPU가 처리하고 프레임당 JS는 0바이트다. 세기 슬라이더를 아무리 흔들어도 바뀌는 건 문자열 하나뿐이다.
실제 픽셀 연산은 내보낼 때 딱 한 번 돈다.
진짜 문제 — 미리보기와 결과물이 다르면 기능 전체가 무의미하다
브라우저 이미지 도구를 써 보면 다운로드한 파일이 화면에서 승인한 그림과 미묘하게 다른 경우가 흔하다. 이유는 대개 하나다. 미리보기와 내보내기를 서로 다른 코드로 처리하기 때문이다.
값을 두 군데 적으면 반드시 갈라진다. 그래서 규칙을 하나 세웠다.
필터 하나는 연산 목록 하나로만 정의한다.
CSS 문자열과 색 행렬을 둘 다 그 목록에서 파생시킨다.
export interface Op {
fn: 'brightness' | 'contrast' | 'saturate' | 'grayscale' | 'sepia' | 'invert' | 'hue-rotate';
v: number;
}
{
id: 'y2k',
name: 'Y2K',
ops: [
{ fn: 'saturate', v: 1.75 },
{ fn: 'contrast', v: 1.22 },
{ fn: 'brightness', v: 1.06 },
{ fn: 'hue-rotate', v: -6 },
],
layers: [{ kind: 'color', rgb: [255, 64, 196], blend: 'screen', alpha: 0.1 }],
}
여기서 미리보기용 문자열이 나오고,
export function cssFilter(def: FilterDef | null, k: number): string {
if (!def || k <= 0) return 'none';
return scaledOps(def, k)
.map((o) => (o.fn === 'hue-rotate' ? `hue-rotate(${o.v}deg)` : `${o.fn}(${o.v})`))
.join(' ');
}
같은 목록에서 내보내기용 행렬이 나온다. 정의가 하나뿐이니 어긋날 방법이 없다.
ctx.filter를 안 쓴 이유
캔버스에도 ctx.filter = 'saturate(1.75) contrast(1.22)' 가 있다. 이게 있으면 위 문자열을 그대로 재사용하면 끝인데, 쓰지 않았다.
브라우저마다 지원 여부와 반올림이 다르다. Chrome에서 내보낸 파일과 구버전 Safari에서 내보낸 파일이 달라진다. 사용자는 자기 브라우저의 미리보기를 보고 승인했으니 화면과는 맞겠지만, "같은 설정이면 같은 파일"이라는 성질을 잃는다.
그래서 CSS filter 명세(= SVG 필터의 색 행렬)를 직접 구현했다. 연산마다 4×5 행렬을 만들고 순서대로 곱해서 하나로 접은 다음, 픽셀 데이터에 한 번만 통과시킨다.
case 'contrast': {
// CSS 명세: out = (in - 0.5) * v + 0.5
const o = 0.5 * (1 - v);
return [v,0,0,0,o, 0,v,0,0,o, 0,0,v,0,o, 0,0,0,1,0];
}
case 'saturate': {
const s = v; // s=1이면 항등, s=0이면 휘도(흑백)
return [
LR + (1 - LR) * s, LG - LG * s, LB - LB * s, 0, 0,
LR - LR * s, LG + (1 - LG) * s, LB - LB * s, 0, 0,
LR - LR * s, LG - LG * s, LB + (1 - LB) * s, 0, 0,
0, 0, 0, 1, 0,
];
}
명세를 그대로 따르니 브라우저의 CSS 구현과 같은 결과가 나온다. 검증은 알려진 값으로 했다.
grayscale(1) 순수 빨강 → 휘도 54 ok
grayscale(1) 순수 초록 → 휘도 182 ok
contrast(2) 중간회색 유지 ok
contrast(2) 64 → 0 ok
sepia(1) 흰색 → 255,255,239 ok
hue-rotate(360) = 항등 ok
행렬 곱이 항등에 가까우면 null을 돌려주고 픽셀 순회를 통째로 건너뛴다. saturate(1)이나 세기 0에서 공짜가 되는 이유다.
강도 슬라이더 = 항등으로의 보간
세기는 각 연산을 "아무것도 안 하는 상태" 쪽으로 선형 보간한다. 출발점이 연산 종류마다 다르다.
function scaleOp(op: Op, k: number): Op {
const multiplicative = op.fn === 'brightness' || op.fn === 'contrast' || op.fn === 'saturate';
return { fn: op.fn, v: multiplicative ? 1 + (op.v - 1) * k : op.v * k };
}
배율형은 1에서, 비율형(grayscale·sepia·invert)과 각도형(hue-rotate)은 0에서 출발한다.
재밌는 부작용이 하나 있다. Negative 필터를 세기 50%에 두면 완전히 평평한 회색이 된다. invert(0.5)는 절반만 반전이 아니라 모든 채널을 중간값으로 보내는 연산이기 때문이다. 명세대로 동작하는 거라 그냥 뒀다. 나름 재밌기도 하고.
레이어 — CSS와 캔버스가 같은 이름을 쓴다
색 행렬만으로는 비네팅이나 색조 오버레이를 못 만든다. 그래서 색 변환 뒤에 레이어를 얹는데, 여기서 운 좋은 사실이 하나 있다.
CSS mix-blend-mode와 캔버스 globalCompositeOperation이 같은 이름을 쓴다. multiply, screen, overlay, soft-light, difference, exclusion, color, luminosity … 문자열 하나를 양쪽에 그대로 넣으면 된다.
비네팅은 조금 손이 갔다. CSS는 radial-gradient(ellipse at center, …)로 타원을 그리는데, 캔버스의 createRadialGradient는 원만 그린다. 단위원을 그린 뒤 컨텍스트를 늘려서 맞췄다.
ctx.translate(w / 2, h / 2);
ctx.scale(w / 2, h / 2); // 단위원 → 캔버스 비율의 타원
const grad = ctx.createRadialGradient(0, 0, 0, 0, 0, 1);
grad.addColorStop(0.45, 'rgba(0,0,0,0)');
grad.addColorStop(1, 'rgba(0,0,0,1)');
투명 PNG 문제도 있었다. 레이어는 캔버스 전체를 덮는 사각형이라, 블렌드 모드에 따라 투명한 부분까지 색이 칠해진다. 알파 채널을 미리 떠 두었다가 되돌려 놓는 것으로 해결했다. 불투명한 캔버스면 이 과정이 아무것도 바꾸지 않는다.
조용히 갈라지는 버그를 자동으로 잡기
이게 이번 작업에서 제일 마음에 드는 부분이다.
캔버스는 모르는 블렌드 모드를 넣으면 에러 없이 무시한다. 대입이 그냥 no-op이 되고 이전 값이 유지된다. 즉 'soft-light'를 'softlight'로 오타 내면 미리보기는 멀쩡한데 내보낸 파일만 조용히 달라진다. 배포하고 한참 뒤에 발견할 종류의 버그다.
그래서 검사를 자동화했다.
const VALID = new Set(['source-over','multiply','screen','overlay','darken','lighten',
'color-dodge','color-burn','hard-light','soft-light','difference','exclusion',
'hue','saturation','color','luminosity']);
for (const f of FILTERS)
for (const l of f.layers ?? [])
if (l.kind === 'color' && !VALID.has(l.blend)) fail(f.id + ':' + l.blend);
같이 도는 검사 세 개를 더 붙였다.
캔버스가 아는 블렌드 모드인가 ok
강도 0에서 32개 전부 원본과 동일 ok ← 슬라이더 왼쪽 끝이 진짜 원본인가
강도 1에서 32개 전부 변화 있음 ok ← 정의는 있는데 아무것도 안 하는 필터 방지
중복 id / 이름 ok
일부러 뺀 것 — 블러와 그레인
둘 다 만들고 싶었지만 뺐다. CSS와 캔버스에서 결과를 똑같이 맞추기가 어렵다.
블러는 ctx.filter 없이는 캔버스에서 재현이 까다롭고, 그레인은 노이즈 패턴을 양쪽에 동일하게 얹어야 한다. 미리보기와 결과물이 다르면 필터 기능 전체의 신뢰가 무너진다. 필터 개수 두 개보다 "보이는 대로 저장된다"는 성질이 비싸다고 판단했다.
진짜로 lazy하게 만든 것
정의를 미룰 이유는 없었지만, 미룰 값어치가 있는 게 하나 있었다. 칩 미리보기용 견본 이미지다.
필터 32개를 고르려면 각각이 내 사진에 어떻게 보이는지 알아야 한다. 순진하게 짜면 칩마다 이미지를 굽게 되는데, 그러면 32번 인코딩한다.
대신 128px짜리 견본 한 장을 만들어 32개 칩이 CSS filter로 나눠 쓴다. 그것도 필터 탭을 처음 열 때 만들고, 선택한 이미지가 바뀔 때만 다시 만든다.
useEffect(() => {
if (tab !== 'filter' || !current) return;
if (chipFor === current.id) return;
// … 128px로 줄여 data URL 한 장
}, [tab, current, chipFor]);
"필터를 누를 때 서버에서 받아온다"의 실제 대응물이 이거였다. 받아올 값은 없고, 미룰 건 이 한 장뿐이다.
처리 순서가 성능을 결정한다
회전 → 크롭 → 크기 조절 → 필터 → 인코딩
필터가 축소 뒤에 온다는 게 핵심이다. 5천만 화소 원본이 아니라 출력 크기(보통 200만 화소)만 훑으면 되므로, 큰 사진을 1600px로 줄이며 필터를 거는 비용이 처음부터 1600px이던 사진과 같다.
인코딩이 마지막인 것도 중요하다. 필터가 이미 반영된 상태로 딱 한 번 압축된다. 순서를 뒤집으면 압축 → 해제 → 재압축이 되고, 두 번째 압축이 첫 번째의 아티팩트를 디테일인 줄 알고 굳혀 버린다.
용량 추정 견본에도 필터를 그대로 걸었다. 대비와 채도를 올리면 인코더가 보존할 디테일이 늘어나 실제 파일이 커지기 때문이다.
숫자
필터 32종 · 6그룹 (Y2K 5 · 쨍하게 3 · 분위기 5 · 필름 4 · 흑백 3 · 특수효과 12)
번들 증가 +11.3KB (비압축) · 편집기 청크 전체 gzip 16.3KB
이 정도 크기면 코드 스플리팅으로 나눠도 왕복 비용이 더 크다. 처음 질문으로 돌아가면, 서버에서 받아올 이유가 없다는 결론이 숫자로도 확인된다.
정리하면
- 최적화 대상을 먼저 재라. "무거울 것 같은 기능"의 무게가 실제로 어디 있는지 확인하지 않으면 엉뚱한 데를 고친다. 여기서는 정의(2KB)가 아니라 픽셀이었다.
- 값을 두 군데 적지 마라. 미리보기와 결과물이 갈라지는 버그는 대부분 여기서 나온다. 정의 하나에서 양쪽을 파생시키면 갈라질 방법이 없다.
- 조용히 실패하는 API는 검사로 감싸라. 캔버스의 블렌드 모드처럼 오타를 에러 없이 삼키는 지점이 있으면, 그 지점에 자동 검사를 두는 게 주석보다 낫다.
- 정확도를 위해 기능을 빼는 것도 선택지다. 블러와 그레인을 넣었다면 필터는 34종이 됐겠지만, "보이는 대로 저장된다"가 흔들렸을 것이다.
댓글 0
불러오는 중…