반응형
Spring Boot에서 Java와 jQuery로 만드는 이미지 크롭(자르기) 기능 구현하기

 

◆ 동작 및 흐름 요약


이 기능은 다음과 같은 흐름으로 동작한다.

  1. "업로드" 버튼을 누른뒤 사용자가 자를 이미지를 업로드한다.
  2. 업로드된 이미지를 클라이언트(브라우저)에서 미리 보고, 크롭 박스(빨간 네모)를 이용해 원하는 영역을 선택한다.
  3. "확정" 버튼을 누르면
  4. 클라이언트는 선택한 영역의 좌상단 좌표와 크기(가로/세로) 정보를 원본 이미지와 함께 서버(Spring Boot/Java)로 전송한다.
  5. 서버는 해당 좌표와 크기를 기준으로 이미지를 잘라내어 새로운 이미지로 저장한 뒤 저장경로를 반환한다.

요약하면 박스와 이미지의 실제 표시 위치를 계산해 좌표를 원본 이미지의 픽셀 좌표로 변환하고, 서버는 받은 좌표/크기만큼만 getSubimage()로 자른다고 이해하면 된다. (클라이언트 단에서는 자르고 싶은 위치/크기만 전달하고, 실제 이미지 자르기는 서버단에서 처리)

 

 

◆ 구현 시 주안점


  1. 업로드 한 이미지는 원본 비율로 미리보기 영역에 꽉 채워서 보일 것
  2. 크롭 박스(빨간 네모)는 정사각형의 고정된 형태가 아니라 원하는 가로/세로 비율로 조절이 가능할 것
  3. 크롭 박스(빨간 네모)는 업로드한 이미지의 영역 안에서만 이동 및 크기조절이 가능할 것 (이미지 밖으로 못 넘어감)

 

 

◆ 결과물


미리보기 영역에 이미지를 업로드하고, 드래그로 이미지의 자를 영역을 지정한 뒤 "확정" 버튼을 누르면

선택한 부분만 잘려서 새 이미지로 저장된다.

 

※ 가로 이미지

가로이미지 Crop
crop 전crop 후
Crop 전 → 후


※ 세로 이미지

세로이미지 Crop
Crop 전 → 후

 

 

◆ 예제 코드


※ 프론트엔드

- 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();
}

 


글 내용 중 잘못된 부분이 있거나, 첨부하실 내용이 있으시면 댓글로 남겨주세요. 공부하는데 많은 도움이 됩니다.
-- 기억의 유효기간은 생각보다 짧다. --
반응형