반응형
Spring Boot에서 Java와 jQuery로 만드는 이미지 크롭(자르기) 기능 구현하기
◆ 동작 및 흐름 요약
이 기능은 다음과 같은 흐름으로 동작한다.
- "업로드" 버튼을 누른뒤 사용자가 자를 이미지를 업로드한다.
- 업로드된 이미지를 클라이언트(브라우저)에서 미리 보고, 크롭 박스(빨간 네모)를 이용해 원하는 영역을 선택한다.
- "확정" 버튼을 누르면
- 클라이언트는 선택한 영역의 좌상단 좌표와 크기(가로/세로) 정보를 원본 이미지와 함께 서버(Spring Boot/Java)로 전송한다.
- 서버는 해당 좌표와 크기를 기준으로 이미지를 잘라내어 새로운 이미지로 저장한 뒤 저장경로를 반환한다.
요약하면 박스와 이미지의 실제 표시 위치를 계산해 좌표를 원본 이미지의 픽셀 좌표로 변환하고, 서버는 받은 좌표/크기만큼만 getSubimage()로 자른다고 이해하면 된다. (클라이언트 단에서는 자르고 싶은 위치/크기만 전달하고, 실제 이미지 자르기는 서버단에서 처리)
◆ 구현 시 주안점
- 업로드 한 이미지는 원본 비율로 미리보기 영역에 꽉 채워서 보일 것
- 크롭 박스(빨간 네모)는 정사각형의 고정된 형태가 아니라 원하는 가로/세로 비율로 조절이 가능할 것
- 크롭 박스(빨간 네모)는 업로드한 이미지의 영역 안에서만 이동 및 크기조절이 가능할 것 (이미지 밖으로 못 넘어감)
◆ 결과물
미리보기 영역에 이미지를 업로드하고, 드래그로 이미지의 자를 영역을 지정한 뒤 "확정" 버튼을 누르면
선택한 부분만 잘려서 새 이미지로 저장된다.
※ 가로 이미지
※ 세로 이미지
◆ 예제 코드
※ 프론트엔드
- CSS -
<style>
#preview-box {
position: relative;
width: 400px;
height: 400px;
border: 2px solid #000;
margin-bottom: 10px;
overflow: hidden;
}
#preview-img {
width: 100%;
height: 100%;
object-fit: contain; /* 비율 유지하며 중앙 정렬 */
}
#crop-rect {
position: absolute;
border: 3px solid red;
width: 180px;
height: 180px;
top: 100px; left: 100px;
cursor: move;
box-sizing: border-box;
}
/* 네 모서리 크기조절 핸들 */
.resize-handle {
width: 16px;
height: 16px;
background: #fff;
border: 2px solid #ff0000;
position: absolute;
z-index: 10;
border-radius: 100%;
box-sizing: border-box;
}
.resize-handle.br {
right: -10px;
bottom: -10px;
cursor: nwse-resize;
}
.resize-handle.tr {
right: -10px;
top: -10px;
cursor: nesw-resize;
}
.resize-handle.bl {
left: -10px;
bottom: -10px;
cursor: nesw-resize;
}
.resize-handle.tl {
left: -10px;
top: -10px;
cursor: nwse-resize;
}
#buttons {
margin-top: 10px;
}
</style>
- HTML -
<form id="upload-form" enctype="multipart/form-data">
<input type="file" id="file-input" name="image" accept="image/*" style="display:none;">
<div id="preview-box">
<img id="preview-img" src="" style="display:none;">
<div id="crop-rect" style="display:none;">
<div class="resize-handle br"></div>
<div class="resize-handle tr"></div>
<div class="resize-handle bl"></div>
<div class="resize-handle tl"></div>
</div>
</div>
<div id="buttons">
<button type="button" id="btn-upload">업로드</button>
<button type="button" id="btn-crop" disabled>확정</button>
</div>
<input type="hidden" id="crop-x" name="cropX">
<input type="hidden" id="crop-y" name="cropY">
<input type="hidden" id="crop-size" name="cropSize">
</form>
- JS, jQuery -
<script src="https://code.jquery.com/jquery-3.7.1.min.js"></script>
<script>
// 크롭 박스의 현재 위치(left, top) 및 크기(width, height)
let rectX = 100; // 크롭 박스의 왼쪽 위치(px, preview-box 기준)
let rectY = 100; // 크롭 박스의 위쪽 위치(px, preview-box 기준)
let rectW = 180; // 크롭 박스의 가로 길이(px)
let rectH = 180; // 크롭 박스의 세로 길이(px)
// 드래그/리사이즈 시작 시 마우스 좌표 및 박스 상태 저장용
let startX; // 마우스 클릭 시 X좌표(preview-box 기준)
let startY; // 마우스 클릭 시 Y좌표(preview-box 기준)
let startRectX; // 드래그/리사이즈 시작 시 rectX 값 백업
let startRectY; // 드래그/리사이즈 시작 시 rectY 값 백업
let startRectW; // 리사이즈 시작 시 rectW 값 백업
let startRectH; // 리사이즈 시작 시 rectH 값 백업
// 현재 드래그(이동) 중인지, 리사이즈(크기조절) 중인지 상태
let dragging = false; // 박스 이동 중일 때 true
let resizing = false; // 박스 리사이즈(변경) 중일 때 true
let resizeDir = ""; // 어떤 리사이즈 핸들에서 시작했는지(br, tr, bl, tl 중 하나)
// 이미지 및 프리뷰 박스의 크기 정보
let imgNaturalWidth = 0; // 업로드된 원본 이미지의 실제 가로 픽셀
let imgNaturalHeight = 0; // 업로드된 원본 이미지의 실제 세로 픽셀
let boxWidth = 400; // 프리뷰 박스(고정) 가로 픽셀
let boxHeight = 400; // 프리뷰 박스(고정) 세로 픽셀
// 크롭 박스의 최소/최대 크기(px)
const minRectSize = 30; // 크롭 박스 최소 변 길이(가로/세로 중 더 짧은 쪽)
const maxRectSize = 400; // 크롭 박스 최대 변 길이(프리뷰 박스와 동일)
// 업로드 버튼
$("#btn-upload").click(function(){
$("#file-input").click();
});
// 파일 선택 시 이미지 미리보기, 크롭박스 초기화
$("#file-input").change(function(e){
const file = e.target.files[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = function(evt) {
$("#preview-img").attr("src", evt.target.result).show();
$("#crop-rect").show();
$("#btn-crop").prop("disabled", false);
};
reader.readAsDataURL(file);
const img = new Image();
img.onload = function(){
imgNaturalWidth = img.width;
imgNaturalHeight = img.height;
};
img.src = URL.createObjectURL(file);
// 크롭박스 위치/크기 초기화
rectX = 100; rectY = 100; rectW = 180; rectH = 180;
$("#crop-rect").css({left: rectX+"px", top: rectY+"px", width: rectW+"px", height: rectH+"px"});
});
// 이미지 실제 표시 영역 계산 (object-fit: contain 대응)
function getDisplayedImageRect() {
const imgW = imgNaturalWidth, imgH = imgNaturalHeight;
const boxW = boxWidth, boxH = boxHeight;
let displayW, displayH, offsetX, offsetY;
const imgRatio = imgW / imgH, boxRatio = boxW / boxH;
if (imgRatio > boxRatio) {
displayW = boxW;
displayH = boxW / imgRatio;
offsetX = 0;
offsetY = (boxH - displayH) / 2;
} else {
displayH = boxH;
displayW = boxH * imgRatio;
offsetX = (boxW - displayW) / 2;
offsetY = 0;
}
return {displayW, displayH, offsetX, offsetY};
}
// 이동: 이미지를 벗어날 수 없음
$("#crop-rect").on("mousedown", function(e){
if ($(e.target).hasClass('resize-handle')) return;
dragging = true;
const boxOffset = $("#preview-box").offset();
startX = e.pageX - boxOffset.left;
startY = e.pageY - boxOffset.top;
startRectX = rectX;
startRectY = rectY;
e.preventDefault();
});
// 리사이즈(모서리)
$("#crop-rect .resize-handle").on("mousedown", function(e){
resizing = true;
resizeDir = $(this).attr("class").split(" ")[1]; // br, tl, tr, bl
const boxOffset = $("#preview-box").offset();
startX = e.pageX - boxOffset.left;
startY = e.pageY - boxOffset.top;
startRectX = rectX;
startRectY = rectY;
startRectW = rectW;
startRectH = rectH;
e.stopPropagation();
e.preventDefault();
});
// 이동/리사이즈 동작
$(document).on("mousemove", function(e){
const boxOffset = $("#preview-box").offset();
const mouseX = e.pageX - boxOffset.left;
const mouseY = e.pageY - boxOffset.top;
const {displayW, displayH, offsetX, offsetY} = getDisplayedImageRect();
if (dragging && !resizing) {
// 크롭박스 이동: 항상 이미지 표시 영역 내에만 있게 제한
let newX = startRectX + (mouseX - startX);
let newY = startRectY + (mouseY - startY);
newX = Math.max(offsetX, Math.min(offsetX + displayW - rectW, newX));
newY = Math.max(offsetY, Math.min(offsetY + displayH - rectH, newY));
rectX = newX; rectY = newY;
$("#crop-rect").css({left: rectX+"px", top: rectY+"px"});
}
if (resizing) {
// 각 모서리 핸들별로 동작 (자유 비율, 이미지 표시 영역 내에서만 조절)
let newX = startRectX, newY = startRectY;
let newW = startRectW, newH = startRectH;
if (resizeDir === "br") { // 오른쪽 아래
newW = Math.max(minRectSize, Math.min(mouseX - startRectX, offsetX + displayW - startRectX));
newH = Math.max(minRectSize, Math.min(mouseY - startRectY, offsetY + displayH - startRectY));
} else if (resizeDir === "tl") { // 왼쪽 위
newX = Math.max(offsetX, Math.min(mouseX, startRectX + startRectW - minRectSize));
newY = Math.max(offsetY, Math.min(mouseY, startRectY + startRectH - minRectSize));
newW = startRectX + startRectW - newX;
newH = startRectY + startRectH - newY;
} else if (resizeDir === "tr") { // 오른쪽 위
newY = Math.max(offsetY, Math.min(mouseY, startRectY + startRectH - minRectSize));
newH = startRectY + startRectH - newY;
newW = Math.max(minRectSize, Math.min(mouseX - startRectX, offsetX + displayW - startRectX));
} else if (resizeDir === "bl") { // 왼쪽 아래
newX = Math.max(offsetX, Math.min(mouseX, startRectX + startRectW - minRectSize));
newW = startRectX + startRectW - newX;
newH = Math.max(minRectSize, Math.min(mouseY - startRectY, offsetY + displayH - startRectY));
}
// 크롭 박스가 이미지 영역을 벗어나지 않게 한 번 더 보정
if (newX < offsetX) { newW -= (offsetX - newX); newX = offsetX; }
if (newY < offsetY) { newH -= (offsetY - newY); newY = offsetY; }
if (newX + newW > offsetX + displayW) newW = offsetX + displayW - newX;
if (newY + newH > offsetY + displayH) newH = offsetY + displayH - newY;
if (newW < minRectSize) newW = minRectSize;
if (newH < minRectSize) newH = minRectSize;
rectX = newX; rectY = newY; rectW = newW; rectH = newH;
$("#crop-rect").css({left: rectX+"px", top: rectY+"px", width: rectW+"px", height: rectH+"px"});
}
}).on("mouseup", function(){
dragging = false;
resizing = false;
});
// 실제 원본 이미지 crop 좌표 변환 (자유 비율)
$("#btn-crop").click(function(){
const {displayW, displayH, offsetX, offsetY} = getDisplayedImageRect();
// 크롭박스가 이미지 표시 영역 내에서의 상대좌표
let cropLeft = rectX - offsetX;
let cropTop = rectY - offsetY;
let cropW = rectW, cropH = rectH;
// 실제 이미지 바깥부분은 자동으로 잘리니 걱정 X
cropLeft = Math.max(0, cropLeft);
cropTop = Math.max(0, cropTop);
cropW = Math.min(rectW, displayW - cropLeft);
cropH = Math.min(rectH, displayH - cropTop);
// 원본 이미지 비율 변환
const xRatio = imgNaturalWidth / displayW;
const yRatio = imgNaturalHeight / displayH;
const cropX = Math.round(cropLeft * xRatio);
const cropY = Math.round(cropTop * yRatio);
const cropWidth = Math.round(cropW * xRatio);
const cropHeight = Math.round(cropH * yRatio);
$("#crop-x").val(cropX);
$("#crop-y").val(cropY);
if ($("#crop-width").length == 0) {
$("#upload-form").append('<input type="hidden" id="crop-width" name="cropWidth">');
}
if ($("#crop-height").length == 0) {
$("#upload-form").append('<input type="hidden" id="crop-height" name="cropHeight">');
}
$("#crop-width").val(cropWidth);
$("#crop-height").val(cropHeight);
const formData = new FormData($("#upload-form")[0]);
$.ajax({
url: "/crop-image",
method: "POST",
data: formData,
processData: false,
contentType: false,
success: function(blobUrl){
alert("크롭 성공! 새 파일: " + blobUrl); // crop 이미지 저장경로
}
});
});
</script>
※ 백엔드
- Spring Boot, Java -
@PostMapping("/crop-image")
@ResponseBody
public String cropImage(
@RequestParam("image") MultipartFile image,
@RequestParam("cropX") int cropX,
@RequestParam("cropY") int cropY,
@RequestParam("cropWidth") int cropWidth,
@RequestParam("cropHeight") int cropHeight
) throws IOException {
// 원본 이미지 읽기
BufferedImage original = ImageIO.read(image.getInputStream());
// 자를 영역이 원본 범위 초과하지 않도록
int width = Math.min(cropWidth, original.getWidth() - cropX);
int height = Math.min(cropHeight, original.getHeight() - cropY);
BufferedImage cropped = original.getSubimage(cropX, cropY, width, height);
// 새 파일로 저장 (여기서는 간단히 임시 파일로 저장: 필요시 경로 지정해서 사용할 것)
File croppedFile = File.createTempFile("cropped_", ".png");
ImageIO.write(cropped, "png", croppedFile);
// 클라이언트단에 alert으로 출력 할 크롭된 파일의 경로나 접근 URL 반환
// 여기선 파일 경로, 실제 서비스라면 URL
return croppedFile.getAbsolutePath();
}
글 내용 중 잘못된 부분이 있거나, 첨부하실 내용이 있으시면 댓글로 남겨주세요. 공부하는데 많은 도움이 됩니다.
-- 기억의 유효기간은 생각보다 짧다. --
반응형
'프레임워크 > Spring (스프링)' 카테고리의 다른 글
[Spring] 개발 생산성 높이는 유용한 JPA 설정 (0) | 2025.06.09 |
---|---|
스프링 부트(Spring Boot)의 세션 만료 시간 (session timeout) 설정 방법 (1) | 2025.01.10 |
[Spring] MultipartFile 파일 업로드 최대크기 제한 조정 (1) | 2024.10.02 |
[Spring] MultipartFile 비어있는 파일 확인 - isEmpty() (0) | 2024.07.31 |
패키징 된 war나 jar파일내의 File 읽어오기 (4) | 2024.07.23 |