이미지 크롭은 업로드 전 썸네일 만들기, 프로필 사진 자르기 등 곳곳에서 필요합니다. 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로 초고해상도 내보내기 가속
위 컴포넌트를 프로젝트에 통합하면, 라이브러리 의존도 없이도 가벼운 고성능 이미지 크롭 편집기를 제공할 수 있습니다. 필요한 버튼과 테마만 입히면 즉시 실서비스에 활용 가능합니다.
'React' 카테고리의 다른 글
| React에서 OpenAPI 기반 API 클라이언트 자동 생성하기 (1) | 2026.07.26 |
|---|---|
| React 앱에서 OTP(일회용 비밀번호) 입력 UI 구현하기 (0) | 2026.07.24 |
| React 앱에서 Electron 연동하여 데스크톱 애플리케이션 개발하기 (0) | 2026.07.23 |
| React에서 Resize Observer API로 반응형 컴포넌트 만들기 (0) | 2026.07.23 |
| React 앱에서 Undo/Redo 상태 히스토리 기능 구현하기 (0) | 2026.07.22 |