이미지 톤 보정, 흑백, 블러 같은 효과는 UI 완성도를 즉시 끌어올립니다. React에서는 CSS filter로 가볍게 처리하거나, Canvas로 픽셀 단위 조작과 내보내기까지 구현할 수 있습니다. 이 글은 실무에서 바로 붙일 수 있는 최소 예제와 성능/SEO 팁을 제공합니다.
1. CSS filter로 즉시 적용하기 (가볍고 빠름)
CSS filter는 GPU 가속이 잘 되고, 레이아웃에 영향을 주지 않아 스크롤/애니메이션에 유리합니다. 슬라이더로 밝기, 대비, 채도, 세피아, 블러를 제어하는 기본 예제입니다.
import React, { useMemo, useState } from 'react';
export default function CssFilterDemo() {
const [params, setParams] = useState({
brightness: 110, // %
contrast: 105, // %
saturate: 110, // %
sepia: 0, // %
blur: 0 // px
});
const filterStyle = useMemo(() => {
const { brightness, contrast, saturate, sepia, blur } = params;
const filterStr = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturate}%) sepia(${sepia}%) blur(${blur}px)`;
return {
filter: filterStr,
WebkitFilter: filterStr
};
}, [params]);
const onChange = (key) => (e) => {
setParams((prev) => ({ ...prev, [key]: Number(e.target.value) }));
};
return (
<div style={{ display: 'grid', gap: 12, gridTemplateColumns: '1fr' }}>
<img
src="/images/sample.jpg"
alt="해변의 노을 풍경"
width="640"
height="360"
loading="lazy"
style={{ width: '100%', height: 'auto', ...filterStyle }}
/>
<label>밝기 {params.brightness}%
<input type="range" min="50" max="200" value={params.brightness} onChange={onChange('brightness')} />
</label>
<label>대비 {params.contrast}%
<input type="range" min="50" max="200" value={params.contrast} onChange={onChange('contrast')} />
</label>
<label>채도 {params.saturate}%
<input type="range" min="0" max="200" value={params.saturate} onChange={onChange('saturate')} />
</label>
<label>세피아 {params.sepia}%
<input type="range" min="0" max="100" value={params.sepia} onChange={onChange('sepia')} />
</label>
<label>블러 {params.blur}px
<input type="range" min="0" max="10" value={params.blur} onChange={onChange('blur')} />
</label>
</div>
);
}
CSS filter는 미리보기, 인터랙션, 동적 UI에 적합합니다. 다만 결과 이미지를 파일로 저장하려면 Canvas를 통해 렌더링한 뒤 내보내는 단계가 필요합니다.
2. Canvas로 픽셀 단위 필터 + 내보내기 기반 만들기
Canvas는 픽셀을 직접 만져 복합 필터, LUT, 커스텀 알고리즘, 파일 저장이 가능합니다. 아래 예제는 흑백, 밝기/대비를 구현하고 로컬 이미지를 업로드해 적용합니다.
import React, { useEffect, useRef, useState } from 'react';
function applyFilters(img, canvas, { grayscale, brightness, contrast }) {
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const { width, height } = img;
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
const imgData = ctx.getImageData(0, 0, width, height);
const data = imgData.data;
// brightness: [-100..100], contrast: [-100..100]
const b = (brightness / 100) * 255;
const c = (contrast / 100) + 1; // scale factor
const intercept = 128 * (1 - c);
for (let i = 0; i < data.length; i += 4) {
let r = data[i] + b;
let g = data[i + 1] + b;
let bl = data[i + 2] + b;
// contrast
r = c * r + intercept;
g = c * g + intercept;
bl = c * bl + intercept;
if (grayscale) {
const avg = 0.2126 * r + 0.7152 * g + 0.0722 * bl;
r = g = bl = avg;
}
data[i] = Math.max(0, Math.min(255, r));
data[i + 1] = Math.max(0, Math.min(255, g));
data[i + 2] = Math.max(0, Math.min(255, bl));
}
ctx.putImageData(imgData, 0, 0);
}
export default function CanvasFilterDemo() {
const canvasRef = useRef(null);
const [src, setSrc] = useState('/images/sample.jpg');
const [grayscale, setGrayscale] = useState(false);
const [brightness, setBrightness] = useState(0);
const [contrast, setContrast] = useState(0);
useEffect(() => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = src;
img.onload = () => {
if (canvasRef.current) {
applyFilters(img, canvasRef.current, { grayscale, brightness, contrast });
}
};
}, [src, grayscale, brightness, contrast]);
const onFile = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const url = URL.createObjectURL(file);
setSrc(url);
};
return (
<div style={{ display: 'grid', gap: 8 }}>
<canvas ref={canvasRef} style={{ width: '100%', height: 'auto' }} />
<div>
<label>
<input type="checkbox" checked={grayscale} onChange={(e) => setGrayscale(e.target.checked)} />
흑백
</label>
</div>
<label>밝기 {brightness}
<input type="range" min="-100" max="100" value={brightness} onChange={(e) => setBrightness(Number(e.target.value))} />
</label>
<label>대비 {contrast}
<input type="range" min="-100" max="100" value={contrast} onChange={(e) => setContrast(Number(e.target.value))} />
</label>
<input type="file" accept="image/*" onChange={onFile} />
</div>
);
}
내보내기는 canvas.toDataURL('image/png') 또는 toBlob을 사용하면 됩니다. 크고 많은 이미지를 처리할 때는 Web Worker로 픽셀 루프를 분리해 메인 스레드 렌더링 저하를 막습니다.
3. 성능 최적화 실무 팁
슬라이더 입력은 디바운스하거나 requestAnimationFrame으로 묶어 잦은 재연산을 줄입니다. CSS filter는 blur 값이 클수록 비용이 커지므로 모바일에서는 상한을 낮춥니다.
Canvas는 getImageData/putImageData가 비쌉니다. 필요한 영역만 처리하거나, 한 번에 여러 슬라이더 변경을 모아 처리합니다.
이미지는 가급적 디스플레이 크기에 맞춰 리사이즈된 소스를 사용합니다. 너무 큰 원본은 먼저 canvas에 축소 렌더링 후 필터를 적용합니다.
React에서는 값 계산을 useMemo로 묶고, 재렌더 빈도를 낮추기 위해 상태를 객체 하나로 모으거나, 컨트롤과 프리뷰를 분리합니다.
4. 접근성/SEO 최적화
img에는 의미 있는 alt를 제공합니다. 예: alt="해변의 노을 풍경"처럼 콘텐츠와 맥락을 설명합니다.
lazy-loading과 width/height를 지정해 레이아웃 시프트를 방지합니다. 미리보기 썸네일을 먼저 보여주고 고해상도 이미지를 지연 로딩하면 LCP 개선에 도움이 됩니다.
파일명과 경로에 키워드를 반영합니다. 예: /images/react-image-filter-sample.jpg. 소셜 공유를 고려해 Open Graph 이미지도 준비합니다.
5. CSS vs Canvas 선택 가이드
UI 실시간 미리보기와 간단한 효과는 CSS filter가 적합합니다. 저장/다운로드, 커스텀 알고리즘, 다단계 합성, LUT 적용이 필요하면 Canvas(또는 WebGL 라이브러리)를 선택합니다.
팀에서 디자이너가 요구하는 룩을 정확히 재현해야 한다면 Canvas에 커스텀 커브/톤매핑을 구현하거나, WebGL 기반 라이브러리(glfx, regl 등)를 도입합니다.
6. 배포 체크리스트
이미지 크기 최적화(WebP/AVIF), 캐시 헤더, srcset/sizes 구성, 모바일에서 프레임 드랍 확인, 워커 분리 여부, 접근성 alt/대체 텍스트 확인을 점검합니다.
정리하면, React에서는 CSS filter로 빠른 인터랙션을, Canvas로 저장과 고급 보정을 담당하게 나누면 유지보수와 성능 모두 이점을 얻습니다.
'React' 카테고리의 다른 글
| React로 사용자 맞춤형 대시보드 구현하기 (0) | 2026.06.01 |
|---|---|
| React에서 비동기 데이터 다중 병합 처리하기 (0) | 2026.05.30 |
| React와 D3.js를 결합한 데이터 시각화 기법 (0) | 2026.05.29 |
| React에서 오디오 플레이어 컴포넌트 만들기 (0) | 2026.05.29 |
| React 앱에서 테마 변경 기능 다중 지원하기 (0) | 2026.05.29 |