본문 바로가기

React

React 앱에서 이미지 필터 효과 적용하기

이미지 톤 보정, 흑백, 블러 같은 효과는 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로 저장과 고급 보정을 담당하게 나누면 유지보수와 성능 모두 이점을 얻습니다.