본문 바로가기

React

React에서 Canvas 기반 이미지 크롭 편집기 구현하기

이미지 크롭은 업로드 전 썸네일 만들기, 프로필 사진 자르기 등 곳곳에서 필요합니다. DOM 기반 솔루션도 있지만, Canvas를 이용하면 성능과 제어력이 뛰어납니다. 본 글은 React에서 Canvas 기반 크롭 편집기를 직접 구현하는 방법을 단계별로 정리합니다. 모바일 드래그/줌, 고해상도(DPR), 회전, 내보내기까지 실무에 필요한 포인트 위주로 설명합니다.

1. 구현 목표

- 드래그로 이동, 휠/핀치로 줌, 키보드 회전 지원합니다.
- 고정 비율(aspect ratio) 크롭 프레임을 오버레이로 표시합니다.
- 고해상도(디바이스 픽셀 비율) 대응합니다.
- 크롭 결과를 Blob/DataURL로 내보냅니다.
- 성능: requestAnimationFrame, useRef 기반 상태 관리로 리렌더 최소화합니다.

2. 전체 컴포넌트 코드

아래는 재사용 가능한 Canvas 크롭퍼 컴포넌트입니다. 컨테이너 크기에 맞춰 반응하며, 마우스/터치/키보드 제스처를 지원합니다.

import React, { useRef, useEffect, useState, useCallback } from 'react';

function clamp(v, min, max) {
  return Math.min(Math.max(v, min), max);
}

function computeCropRect(w, h, aspect) {
  // 컨테이너의 80%를 사용하는 크롭 프레임 계산
  const pad = 0; // 필요 시 외곽 패딩
  const availW = w - pad * 2;
  const availH = h - pad * 2;
  let cropW = availW * 0.8;
  let cropH = cropW / aspect;
  if (cropH > availH * 0.8) {
    cropH = availH * 0.8;
    cropW = cropH * aspect;
  }
  const x = (w - cropW) / 2;
  const y = (h - cropH) / 2;
  return { x, y, w: cropW, h: cropH };
}

function computeMinScale(imgW, imgH, cropW, cropH, rotationRad) {
  const c = Math.abs(Math.cos(rotationRad));
  const s = Math.abs(Math.sin(rotationRad));
  // 회전된 이미지의 축정렬 경계 박스(ABB) 폭/높이
  const bboxW = imgW * c + imgH * s;
  const bboxH = imgH * c + imgW * s;
  const sx = cropW / bboxW;
  const sy = cropH / bboxH;
  return Math.max(sx, sy);
}

export function CanvasCropper({
  src,
  aspect = 1,
  maxScale = 8,
  onExport,
  className,
  initialRotation = 0,
}) {
  const wrapRef = useRef(null);
  const canvasRef = useRef(null);
  const imgRef = useRef(null);
  const rafRef = useRef(0);
  const needsDrawRef = useRef(true);

  const [size, setSize] = useState({ w: 0, h: 0 });
  const [loaded, setLoaded] = useState(false);

  const stateRef = useRef({
    x: 0, // 이미지 중심의 화면상 이동(px)
    y: 0,
    scale: 1,
    rotation: initialRotation * (Math.PI / 180),
  });

  const gestureRef = useRef({ dragging: false, lastX: 0, lastY: 0, pointerId: null });

  // 리사이즈 대응
  useEffect(() => {
    const el = wrapRef.current;
    if (!el) return;
    const ro = new ResizeObserver(entries => {
      for (const entry of entries) {
        const cr = entry.contentRect;
        setSize({ w: Math.max(1, cr.width), h: Math.max(1, cr.height) });
        needsDrawRef.current = true;
      }
    });
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // 이미지 로드
  useEffect(() => {
    if (!src) return;
    const img = new Image();
    img.crossOrigin = 'anonymous'; // CORS 이미지 처리 필요 시 설정
    img.onload = () => {
      imgRef.current = img;
      setLoaded(true);
      needsDrawRef.current = true;
    };
    img.onerror = () => {
      console.error('이미지를 불러오지 못했습니다.');
    };
    img.src = src;
  }, [src]);

  // 캔버스 DPR 설정 및 드로잉 루프
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    function setupDPR() {
      const dpr = Math.max(1, window.devicePixelRatio || 1);
      const rect = canvas.getBoundingClientRect();
      canvas.width = Math.max(1, Math.round(rect.width * dpr));
      canvas.height = Math.max(1, Math.round(rect.height * dpr));
      const ctx = canvas.getContext('2d');
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 이후 좌표계를 CSS 픽셀로 통일
    }

    setupDPR();
    const onResize = () => {
      setupDPR();
      needsDrawRef.current = true;
    };
    window.addEventListener('resize', onResize);

    function tick() {
      if (needsDrawRef.current) {
        draw();
        needsDrawRef.current = false;
      }
      rafRef.current = requestAnimationFrame(tick);
    }
    rafRef.current = requestAnimationFrame(tick);

    return () => {
      cancelAnimationFrame(rafRef.current);
      window.removeEventListener('resize', onResize);
    };
  }, [size.w, size.h, aspect, loaded]);

  // 초기 피팅
  useEffect(() => {
    if (!loaded || !size.w || !size.h) return;
    const img = imgRef.current;
    if (!img) return;
    const crop = computeCropRect(size.w, size.h, aspect);
    const minScale = computeMinScale(img.width, img.height, crop.w, crop.h, stateRef.current.rotation);
    stateRef.current = { x: 0, y: 0, scale: minScale * 1.05, rotation: stateRef.current.rotation };
    needsDrawRef.current = true;
  }, [loaded, size.w, size.h, aspect]);

  const draw = useCallback(() => {
    const canvas = canvasRef.current;
    const ctx = canvas.getContext('2d');
    const { w, h } = size;
    ctx.clearRect(0, 0, w, h);

    const crop = computeCropRect(w, h, aspect);
    // 배경
    ctx.fillStyle = '#111';
    ctx.fillRect(0, 0, w, h);

    const img = imgRef.current;
    if (img) {
      const { x, y, scale, rotation } = stateRef.current;
      // 이미지 그리기 (중심 기준 변환)
      ctx.save();
      ctx.translate(w / 2 + x, h / 2 + y);
      ctx.rotate(rotation);
      ctx.scale(scale, scale);
      ctx.imageSmoothingQuality = 'high';
      ctx.drawImage(img, -img.width / 2, -img.height / 2);
      ctx.restore();
    }

    // 오버레이: 바깥 검은 마스크 + 크롭 프레임 테두리/가이드
    ctx.save();
    ctx.fillStyle = 'rgba(0,0,0,0.5)';
    ctx.fillRect(0, 0, w, h);
    ctx.globalCompositeOperation = 'destination-out';
    ctx.fillRect(crop.x, crop.y, crop.w, crop.h);
    ctx.restore();

    // 프레임 테두리
    ctx.save();
    ctx.strokeStyle = '#36f';
    ctx.lineWidth = 2;
    ctx.strokeRect(crop.x, crop.y, crop.w, crop.h);
    // 3분할 가이드
    ctx.strokeStyle = 'rgba(255,255,255,0.6)';
    ctx.lineWidth = 1;
    for (let i = 1; i <= 2; i++) {
      const gx = crop.x + (crop.w / 3) * i;
      const gy = crop.y + (crop.h / 3) * i;
      ctx.beginPath();
      ctx.moveTo(gx, crop.y);
      ctx.lineTo(gx, crop.y + crop.h);
      ctx.stroke();
      ctx.beginPath();
      ctx.moveTo(crop.x, gy);
      ctx.lineTo(crop.x + crop.w, gy);
      ctx.stroke();
    }
    ctx.restore();
  }, [size, aspect]);

  // 경계 보정: 이미지가 크롭 프레임을 항상 덮도록 위치/스케일 조정
  const constrain = useCallback(() => {
    const img = imgRef.current;
    if (!img) return;
    const { w, h } = size;
    const crop = computeCropRect(w, h, aspect);
    const centerX = w / 2;
    const centerY = h / 2;

    const st = stateRef.current;
    const c = Math.abs(Math.cos(st.rotation));
    const s = Math.abs(Math.sin(st.rotation));
    const halfW = 0.5 * (img.width * c + img.height * s) * st.scale;
    const halfH = 0.5 * (img.height * c + img.width * s) * st.scale;

    // 이미지 중심의 화면 좌표
    let imgCx = centerX + st.x;
    let imgCy = centerY + st.y;

    // 크롭 프레임이 ABB 내부에 들어오도록 이동 클램프
    const minX = crop.x + crop.w - halfW; // 이미지 중심 x 최소
    const maxX = crop.x + halfW;           // 이미지 중심 x 최대
    const minY = crop.y + crop.h - halfH;
    const maxY = crop.y + halfH;

    imgCx = clamp(imgCx, minX, maxX);
    imgCy = clamp(imgCy, minY, maxY);

    st.x = imgCx - centerX;
    st.y = imgCy - centerY;

    // 스케일 하한선 보정
    const minScale = computeMinScale(img.width, img.height, crop.w, crop.h, st.rotation);
    st.scale = Math.max(st.scale, minScale);
  }, [size, aspect]);

  // 포인터 제스처
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const onPointerDown = (e) => {
      canvas.setPointerCapture?.(e.pointerId);
      gestureRef.current.dragging = true;
      gestureRef.current.lastX = e.clientX;
      gestureRef.current.lastY = e.clientY;
      gestureRef.current.pointerId = e.pointerId;
    };
    const onPointerMove = (e) => {
      if (!gestureRef.current.dragging) return;
      if (gestureRef.current.pointerId !== e.pointerId) return;
      const dx = e.clientX - gestureRef.current.lastX;
      const dy = e.clientY - gestureRef.current.lastY;
      gestureRef.current.lastX = e.clientX;
      gestureRef.current.lastY = e.clientY;
      stateRef.current.x += dx;
      stateRef.current.y += dy;
      constrain();
      needsDrawRef.current = true;
    };
    const onPointerUp = (e) => {
      if (gestureRef.current.pointerId !== e.pointerId) return;
      gestureRef.current.dragging = false;
      gestureRef.current.pointerId = null;
      constrain();
      needsDrawRef.current = true;
    };

    const onWheel = (e) => {
      // Ctrl/Cmd+휠로 브라우저 줌 대신 캔버스 줌 사용하고 싶다면 preventDefault 필요
      e.preventDefault();
      if (!imgRef.current) return;

      const rect = canvas.getBoundingClientRect();
      const px = e.clientX - rect.left; // 화면 좌표(캔버스 로컬)
      const py = e.clientY - rect.top;

      const st = stateRef.current;
      const zoomFactor = e.deltaY < 0 ? 1.08 : 0.92;
      const newScale = clamp(st.scale * zoomFactor, 0.01, maxScale);
      const k = newScale / st.scale;

      // 포인터 기준 줌: t' = t + (1 - k) * (p - (center + t))
      const centerX = canvas.clientWidth / 2;
      const centerY = canvas.clientHeight / 2;
      const vx = px - (centerX + st.x);
      const vy = py - (centerY + st.y);
      st.x = st.x + (1 - k) * vx;
      st.y = st.y + (1 - k) * vy;
      st.scale = newScale;

      constrain();
      needsDrawRef.current = true;
    };

    canvas.addEventListener('pointerdown', onPointerDown);
    canvas.addEventListener('pointermove', onPointerMove);
    window.addEventListener('pointerup', onPointerUp);
    canvas.addEventListener('wheel', onWheel, { passive: false });

    return () => {
      canvas.removeEventListener('pointerdown', onPointerDown);
      canvas.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('pointerup', onPointerUp);
      canvas.removeEventListener('wheel', onWheel);
    };
  }, [constrain, maxScale]);

  // 키보드 접근성: 방향키 이동, +/- 줌, Q/E 회전
  const onKeyDown = useCallback((e) => {
    const step = 10;
    const st = stateRef.current;
    let handled = false;

    if (e.key === 'ArrowLeft') { st.x -= step; handled = true; }
    if (e.key === 'ArrowRight') { st.x += step; handled = true; }
    if (e.key === 'ArrowUp') { st.y -= step; handled = true; }
    if (e.key === 'ArrowDown') { st.y += step; handled = true; }
    if (e.key === '+' || e.key === '=') { st.scale *= 1.08; handled = true; }
    if (e.key === '-' || e.key === '_') { st.scale *= 0.92; handled = true; }
    if (e.key.toLowerCase() === 'q') { st.rotation -= Math.PI / 180 * 1; handled = true; }
    if (e.key.toLowerCase() === 'e') { st.rotation += Math.PI / 180 * 1; handled = true; }

    if (handled) {
      constrain();
      needsDrawRef.current = true;
      e.preventDefault();
    }
  }, [constrain]);

  const exportCropped = useCallback(async (type = 'image/png', quality) => {
    const canvas = canvasRef.current;
    const img = imgRef.current;
    if (!canvas || !img) return null;

    const { w, h } = size;
    const crop = computeCropRect(w, h, aspect);

    const out = document.createElement('canvas');
    const dpr = Math.max(1, window.devicePixelRatio || 1);
    out.width = Math.max(1, Math.round(crop.w * dpr));
    out.height = Math.max(1, Math.round(crop.h * dpr));
    const ctx = out.getContext('2d');
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // CSS 픽셀 좌표계로 통일
    ctx.imageSmoothingQuality = 'high';

    const st = stateRef.current;
    // 화면 좌표를 기준으로 동일한 변환을 적용하되, 크롭 프레임의 좌상단만큼 반대로 이동해서 잘라냅니다.
    ctx.translate(-(crop.x), -(crop.y));

    // 원본 그리기와 동일한 변환
    ctx.save();
    ctx.translate(w / 2 + st.x, h / 2 + st.y);
    ctx.rotate(st.rotation);
    ctx.scale(st.scale, st.scale);
    ctx.drawImage(img, -img.width / 2, -img.height / 2);
    ctx.restore();

    return new Promise((resolve) => {
      out.toBlob((blob) => {
        if (onExport) onExport(blob);
        resolve(blob);
      }, type, quality);
    });
  }, [size, aspect, onExport]);

  return (
    <div ref={wrapRef} className={className} style={{ width: '100%', height: '480px', position: 'relative', background: '#0b0b0b', borderRadius: 8, overflow: 'hidden' }}>
      <canvas
        ref={canvasRef}
        role="img"
        aria-label="이미지 크롭 편집기"
        tabIndex={0}
        onKeyDown={onKeyDown}
        style={{ width: '100%', height: '100%', cursor: 'grab', touchAction: 'none', outline: 'none' }}
      />
      <div style={{ position: 'absolute', right: 12, bottom: 12, display: 'flex', gap: 8 }}>
        <button onClick={() => { stateRef.current.rotation -= Math.PI / 2; constrain(); needsDrawRef.current = true; }}>↺ 90°</button>
        <button onClick={() => { stateRef.current.rotation += Math.PI / 2; constrain(); needsDrawRef.current = true; }}>↻ 90°</button>
        <button onClick={() => exportCropped()}>내보내기</button>
      </div>
    </div>
  );
}

3. 사용법

import React, { useState } from 'react';
import { CanvasCropper } from './CanvasCropper';

export default function ProfileCropPage() {
  const [preview, setPreview] = useState(null);

  return (
    <div style={{ maxWidth: 720, margin: '40px auto', padding: 16 }}>
      <h2>프로필 이미지 편집</h2>
      <CanvasCropper
        src="https://images.unsplash.com/photo-1503023345310-bd7c1de61c7d"
        aspect={1}
        onExport={async (blob) => {
          const url = URL.createObjectURL(blob);
          setPreview(url);
        }}
      />
      {preview && (
        <div style={{ marginTop: 16 }}>
          <h4>결과 미리보기</h4>
          <img src={preview} alt="cropped" style={{ width: 160, height: 160, borderRadius: '50%' }} />
        </div>
      )}
    </div>
  );
}

4. 제스처와 수학적 핵심

- 포인터 기준 줌: 스케일 변경 시 포인터 아래 픽셀이 고정되도록 번역(translation)을 보정합니다. t' = t + (1 - k) * (p - (center + t)) 입니다.
- 최소 스케일: 회전된 이미지의 경계 박스를 이용해 crop을 덮는 최소 배율을 계산합니다.
- 경계 클램프: 이미지 중심을 crop 경계에 맞춰 clamp하여 빈 공간이 보이지 않도록 합니다.

5. 성능 최적화 체크리스트

- useRef로 상태를 관리하고 requestAnimationFrame 루프에서 그립니다. React 렌더 사이클을 최소화합니다.
- wheel 이벤트는 preventDefault가 필요하므로 { passive: false }로 등록합니다.
- imageSmoothingQuality = 'high'로 보간 품질을 개선하되, 저성능 기기에서 프레임이 흔들리면 'medium'으로 낮춥니다.
- 큰 원본 이미지는 업로드 전에 압축/리사이즈하는 것이 전체 UX에 유리합니다.

6. 접근성과 모바일 고려

- 캔버스에 role="img"와 aria-label을 부여합니다.
- 키보드: 방향키 이동, +/- 줌, Q/E 회전 등을 제공합니다.
- touchAction="none"으로 터치 스크롤 충돌을 방지합니다. 복잡한 핀치 제스처가 필요하면 PointerEvent 2포인터 거리/각도 계산을 추가하세요.

7. 흔한 버그와 해결

- CORS 차단으로 toBlob 결과가 null인 경우: 이미지 서버에 CORS 헤더를 설정하거나 crossOrigin='anonymous'와 함께 동일 출처로 제공해야 합니다.
- 흐릿한 캔버스: DPR 스케일링(setTransform) 누락 여부를 확인합니다.
- 빠른 회전 후 이미지가 잘리는 문제: 회전 각도를 반영한 최소 스케일 재계산과 constrain 호출을 잊지 마세요.

8. 확장 아이디어

- 핸들러를 둔 자유로운 크롭 프레임 리사이즈
- 가이드 라인(정중앙, 대각), 격자 밀도 설정
- 히스토리(undo/redo), 프리셋 비율(1:1, 4:5, 16:9) 버튼
- WebWorker/OffscreenCanvas로 초고해상도 내보내기 가속

위 컴포넌트를 프로젝트에 통합하면, 라이브러리 의존도 없이도 가벼운 고성능 이미지 크롭 편집기를 제공할 수 있습니다. 필요한 버튼과 테마만 입히면 즉시 실서비스에 활용 가능합니다.