본문 바로가기
실습/리눅스 서버 + 스프링 부트

[Spring] 자바 이미지 핸들링

by 이민우 2024. 7. 21.
728x90
반응형

이번 프로젝트에서 개발한 웹 사이트는 이미지를 핸들링할 일이 많은 프로젝트였다.

 

게다가 이미지를 그냥 가져오는 것이 아니라 "특정 부위만 잘라서" 가져와야 했는데, 이를 하려다보니 아무래도 시간이 오래 걸려 "28개의 이미지가 출력되는 웹 페이지가 출력되는 데 0.2 초 내로 걸렸으면 좋겠다. 단, 성능을 위해 이미지 품질 저하는 없어야 하고 반드시 원본 이미지를 출력해야 한다." 는 요구사항을 충족할 수가 없었다.

 

그래서 이런 저런 방법을 사용하며 삽질을 한 끝에 해당 기능을 완료했고,  추후 필요 시 재사용을 위한 포스팅을 해볼까 한다.

 

프로젝트 준비

우선 핸들링할 이미지를 선택하자. 구글에서 귀여운 시츄 사진을 가져왔다.

https://www.pexels.com/photo/shih-tzu-sitting-on-the-floor-2623968/

 

해당 사진은 D:\images\ 내에 넣어놓았다.

 

그리고 스프링 프로젝트를 생성했다.

화면에 출력을 할 것이기 때문에 Thymeleaf 디펜던시까지 추가했다.

JAVA, Spring boot 버전
디펜던시 추가

 

 

그리고 앞서 설명했듯 이번 프로젝트에서는 이미지의 "일부 부분만 가져와서" 출력해야 하므로, 위 사진에서 해당 부위만을 가져와 잘라볼까 한다.

확인 결과 해당 부위는 (1000, 2000, 1500, 3000) 이었다.

* 시작점이 (1000, 2000), width가 1500, height가 3000 이라는 의미이다.

 

#1. Base64 Byte Array로 가져오기 (이미지 원본)

다음으로 코드를 작성한다.

 

우선은 성능 확인을 위해 이미지 원본만을 가져와볼 것이다.

 

우선 타임리프 설정을 application.properties에 추가했다.

spring.thymeleaf.encoding=UTF-8
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html

 

이미지를 로딩하는 유틸을 작성했다.

기본 이미지 저장 경로인 D:\images는 테스트 용이므로 application.properties 환경변수로 빼지 않고 멤버변수로 선언했다.

 

ImgLoadUtil.java

package com.mwlee.imgtest;

import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Base64;

import javax.imageio.ImageIO;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ImgLoadUtil {

	private static String basePath = "D:\\images";
	
	
	public static String getImage(String fileName) {
		
		String result = null;

		try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {

			// 캐시 사용 설정 해제
			ImageIO.setUseCache(false);

			// 이미지 경로 생성
			StringBuffer path = new StringBuffer();
			path.append(basePath);
			path.append(File.separator);
			path.append(fileName);
			
			// 파일 불러오기
			File file = new File(path.toString());

			// 존재하지 않는 파일에 대한 예외처리
			if(!file.exists()) {
				log.warn("@@ Image not exists.");
				throw new FileNotFoundException();
			}

			// 파일에서 이미지를 불러와서 byte[]로 변경
			BufferedImage image = ImageIO.read(file);
			ImageIO.write(image, "png", baos);
			byte[] bytes = baos.toByteArray();

			// base64 인코딩 후 result에 저장해서 컨트롤러로 전달
			result = Base64.getEncoder().encodeToString(bytes);

		} catch (Exception e) {
			log.error("@@ Some Error Occrued : {}" + e.getMessage());
			return null;
		}

		return result;
	}
}

 

위 코드에 대한 설명은 주석을 달아놓았으며, 주석을 제한 설명은 아래와 같다.

  • ImageIO.setUseCache(false) 사용 이유 : ImageIO는 임시 파일을 가져와 이미지를 캐시하는데, 실제 개발에서는 동일한 이미지보다는 다양한 이미지를 로딩해야 했기 때문에 로딩 시간보다 메모리 효율성을 더 중요한 관점으로 생각했기에 false로 설정.
  • Base64인코딩 이유 : HTTP는 바이너리 데이터를 처리하는데 적합하지 않기에 Base64로 인코딩하면 전송 과정에서 손상을 최소화할 수 있다고 함.

 

다음으로 방금 전 작성된 백엔드에 연동되어 이미지를 화면에 출력하는 HTML 코드를 작성해보자.

 

test.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" 
lang="ko">
<head>
	<meta charset="UTF-8">
</head>
<body style>
	
	<!-- 이미지를 출력하는 부분 -->
	<img 
		src="" 
		alt="" 
		id="img_output"
		style="
			width: 300px;
			height: 600px;
		"
	>
	
	<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
	<script th:inline="javascript">
	
		const imgFileName = "pexels-miguelconstantin-2623968.jpg";
		const imgLoadUrl = "/image/" + imgFileName;
		
		$.ajax({
	        type: "GET",
	        url: imgLoadUrl,
	        success: function(data) {
	        	
	        	// data 정상 로드 여부 확인
	        	const img = data ? 
	        			// null이 아닐 경우
	        			'data:image/png;base64,' + data :
	        			// null일 경우 기본 출력 이미지 출력하기.
	        			'/images/default.png';
	        			
        		$("#img_output").attr("src", img)
                
        	}
	    });
		
	</script>
</body>

 

위코드를 해석하면, AJAX로 이미지를 가져온 후 NULL이면 해당하는 이미지를, 만약 NULL이 아니라면 default.png 이미지를 가져와 출력하는 JS 코드이다.

 

참고로 default.png 파일은 그냥 아무 그림이나 대충 그려서 사용했다.

src/main/resources/static/images/default.png

 

마지막으로 이제 이미지 반환 API와 test.html 반환을 위한 컨트롤러를 작성하자.

 

ImgController.java

package com.mwlee.imgtest;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class ImgController {
	
	/**
	 * 화면 반환 컨트롤러
	 * 
	 * @return
	 */
	@GetMapping("/")
	public String getTestPage() {
		return "test";
	}

	/**
	 * 이미지 반환 컨트롤러
	 * @param fileName
	 * @return
	 */
	@GetMapping("/image/{fileName}")
	public @ResponseBody String getImage(@PathVariable(name = "fileName", required=true) String fileName) {
		return ImgLoadUtil.getImage(fileName);
	}
}

 

 

이제 테스트를 해보면 아래와 같이 이미지가 잘 출력됨을 확인할 수 있다.

존재하는 이미지를 가져올 경우 정상 출력

 

존재하지 않는 이미지를 가져올 경우 default.png 출력

 

 

 

 


#2. Base64 Byte Array로 가져오기 (CROP 이미지)

 

만약 위 코드를 실행시켜보았다면 알겠지만, 위 코드에는 한 가지 문제점이 있다.

그것은 바로 이미지 파일의 로딩 시간이 너무 길다는 것이다.

 

F12를 누르고 이미지를 로딩하는 데 걸린 시간을 확인해보니 5.55초가 소요되었다. 참고로 원본 파일의 크기는 2.38MB이다.

 

Base64 인코딩을 하면 데이터가 원본보다 더 늘어나고, 또한 Spring-Compression이 Base64 인코딩을 압축하지 않아 발생하는 문제라고 생각했다.

 

https://stackoverflow.com/questions/59454425/how-to-compress-base64-string

 

How to compress base64 string

i'm sending the 5mb base64 string to the backend api from the unity android app. while post and get the app gets slow because of heavy data is incoming. i want to compress the base64 5mb string to ...

stackoverflow.com

 

즉, "이미지가 커서 시간이 오래 걸린 거" 라고 생각을 했기에 "crop을 해서 가져오면 결과가 좀 다르지 않을까?" 라는 생각을 했다.

 

그래서 CROP을 해서 이미지를 가져오면 얼마나 오랜 시간이 걸리는 지 확인해보았다. 아래는 crop 부분을 추가한 ImgLoadUtil.java 코드이다.

 

ImgLoadUtil.java

package com.mwlee.imgtest;

import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.util.Base64;

import javax.imageio.ImageIO;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ImgLoadUtil {

	private static String basePath = "D:\\images";
	
	
	public static String getImage(String fileName) {
		
		String result = null;

		try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {

			// 캐시 사용 설정 해제
			ImageIO.setUseCache(false);

			// 이미지 경로 생성
			StringBuffer path = new StringBuffer();
			path.append(basePath);
			path.append(File.separator);
			path.append(fileName);
			
			// 파일 불러오기
			File file = new File(path.toString());

			// 존재하지 않는 파일에 대한 예외처리
			if(!file.exists()) {
				log.warn("@@ Image not exists.");
				throw new FileNotFoundException();
			}

			// 파일에서 이미지를 불러오기
			BufferedImage image = ImageIO.read(file);
			
			// 특정 부위만 자르기. (추가)
			BufferedImage croppedImage = image.getSubimage(1000, 2000, 1500, 3000);
			ImageIO.write(croppedImage, "png", baos);
			byte[] bytes = baos.toByteArray();

			// base64 인코딩 후 result에 저장해서 컨트롤러로 전달
			result = Base64.getEncoder().encodeToString(bytes);

		} catch (Exception e) {
			log.error("@@ Some Error Occrued : {}" + e.getMessage());
			return null;
		}

		return result;
	}
}

 

 

코드에서 보다시피 이미지 로딩 후 getSubimage 메서드를 통해 일부 이미지만 가져오도록 코드를 구성해보았다.

 

이제 한 번 확인을 해보자.

 

보다 작은 이미지를 가져왔으니, 당연히 Response 시간은 줄었다. 다만 그래봤자 1초 차이라서 0.2 초 내에 이미지를 출력해야 하는 입장에서 만족으로운 수치는 아니었다.

 

만약 데이터가 작다면, Base64만으로 할 수 있다. 실제로 구글에서 이미지를 검색할 경우 나오는 이미지들에는 Base64 인코딩이 많다.

 

하지만 이는 다량의 이미지를 출력하기 위해 섬네일로 사용을 해서 데이터가 작기 때문이다. 게다가 실제 프로젝트에서는 원본 이미지 한 장을 여러 장으로 CROP해서 사용해야 했는데, 그렇다보니 CROP 과정이 길게 소요되어 사용할 수 조차 없는 코드가 되었다. 아마 CROP 과정이 Resource를 많이 사용하는 듯 하다.

 

고로 Base64 인코딩을 통한 웹 사이트 이미지 출력은 현재 프로젝트에서는 사용할 수가 없었다.

 

#3. Resource로 반환하기 (원본 이미지)

 

다음으로 채택한 다음 방법은 Resource를 그대로 반환해서 img 태그의 src 속성에 넣는 방법이다.

 

위 방법으로 데이터를 가져오면 아래와 같은 src가 img 내에 들어간다.

 

그리고 여느 타 웹 사이트들은 img 내 src에 url이 들어간다.

 

Resource를 이용한 방법은 이와같이 이미지 자체를 return하는 api를 만들어 Base64 인코딩을 거치지 않고 곧바로 return하는 방법이다.

 

구현을 위해 코드를 아래와 같이 Resource를 반환하도록 수정했다.

 

ImgLoadUtil.java

package com.mwlee.imgtest;

import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Base64;

import javax.imageio.ImageIO;

import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ImgLoadUtil {

	private static String basePath = "D:\\images";
	
	
	public static Resource getImage(String fileName) throws Exception {

		// 이미지 경로 생성
		StringBuffer path = new StringBuffer();
		path.append(basePath);
		path.append(File.separator);
		path.append(fileName);
		
		Path uriPath = Paths.get(path.toString());
		try {
			Resource resource = new UrlResource(uriPath.toUri());
        	
	    	if(!resource.exists()) {
	    		// 파일 미존재
	    		throw new FileNotFoundException();
	    	}
	    	else if(!resource.isReadable()) {
	    		// 여러가지 이유 (권한 등)로 read가 불가능할 경우
	    		throw new IOException();
	    	}
	    	
	    	return resource;
		}
		catch(MalformedURLException e) {
			throw e;
	    }
	}
}

 

ImgController.java

package com.mwlee.imgtest;

import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ResponseBody;

@Controller
public class ImgController {
	
	/**
	 * 화면 반환 컨트롤러
	 * 
	 * @return
	 */
	@GetMapping("/")
	public String getTestPage() {
		return "test";
	}

	/**
	 * 이미지 반환 컨트롤러
	 * @param fileName
	 * @return
	 */
	@GetMapping("/image/{fileName}")
	public ResponseEntity<Resource> getImage(@PathVariable(name = "fileName", required=true) String fileName) {

		Resource resource = null;
		
		HttpHeaders headers = new HttpHeaders();
		headers.setContentType(MediaType.IMAGE_JPEG);
		try {
			resource = ImgLoadUtil.getImage(fileName);
			
	        return new ResponseEntity<>(resource, headers, HttpStatus.OK);
		}
		catch(Exception e) {
			return new ResponseEntity<>(null, HttpStatus.BAD_REQUEST);
		}
        

	}
}

 

 

위 코드에 맞춰 html도 수정했다.

코드 내에도 주석으로 달아놓았지만, src를 이용한 이유는 이미지 존재 여부 확인 및 crop을 위해서이다.

굳이 저렇게 하지 않고 img 태그 내에 곧바로 url을 넣어도 된다.

*이미지 존재여부 확인은 api를 새로 파거나 하는 등으로 얼마든지 갈음이 가능하다.

 

test.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" 
lang="ko">
<head>
	<meta charset="UTF-8">
</head>
<body style>
	
	<!-- 이미지를 출력하는 부분 -->
	<img 
		src="" 
		alt="" 
		id="img_output"
		style="
			width: 300px;
			height: 600px;
		"
	>
	
	<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
	<script th:inline="javascript">
	
		const imgFileName = "pexels-miguelconstantin-2623968.jpg";
		var imgLoadUrl = "/image/" + imgFileName;
		
		/*
			ajax를 거친 이유는 null일 경우 (에러가 발생할 경우) default.png를 출력하고,
			또한 이후 이미지를 crop 하는 코드를 보여주기 위해 굳이 ajax를 거쳐서 사용.
		*/
		$.ajax({
	        type: "GET",
	        url: imgLoadUrl,
	        success: function(data) {
	        	
	        	// data 정상 로드 여부 확인
	        	const img = data ? 
						// null이 아닐 경우 그대로 출력
						imgLoadUrl :
						// null일 경우 기본 출력 이미지 출력하기.
						'/images/default.png';
                        
				$("#img_output").attr("src", img)
                
        	}
	    });
		
	</script>
</body>

 

위에서 ajax를 한 번 더 사용해서 정상적으로 Response가 회신될 때만 imgLoadUrl을 src에 삽입하는 코드를 사용했다.

다만 이 부분은 두 번이나 서버와 통신하므로, 굳이 그럴 필요 없이 onerror="" 태그를 이용해서 에러 시 디폴트 행동을 지정할 수 있다.

 

이렇게 하면 원본 이미지 로딩이 다음과같이 매우 빨라짐을 확인할 수 있다. (0.019초)

 

#4. Resource로 반환하기 (프론트단에서 CROP)

이제 여기서 끝이 아니라, CROP을 해야한다.

 

다만 Resource를 그대로 가져왔으므로 백엔드에서 CROP을 할 수는 없고, 프론트엔드에서 CROP을 해야만 한다.

 

CROP에는 Canvas를 이용했으며, 사용법은 아래 링크를 참고했다.

https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/drawImage

 

CanvasRenderingContext2D: drawImage() method - Web APIs | MDN

The CanvasRenderingContext2D.drawImage() method of the Canvas 2D API provides different ways to draw an image onto the canvas.

developer.mozilla.org

 

이제 위 링크에서 확인한 방법에 따라 crop을 수행해보자.

 

test.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout" 
lang="ko">
<head>
	<meta charset="UTF-8">
</head>
<body style>
	
	<!-- 이미지를 출력하는 부분 -->
	<img 
		src="" 
		alt="" 
		id="img_output"
		style="
			width: 300px;
			height: 600px;
		"
	>
	
	<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
	<script th:inline="javascript">
	
		const imgFileName = "pexels-miguelconstantin-2623968.jpg";
		var imgLoadUrl = "/image/" + imgFileName;
		
		/*
			ajax를 거친 이유는 null일 경우 (에러가 발생할 경우) default.png를 출력하고,
			또한 이후 이미지를 crop 하는 코드를 보여주기 위해 굳이 ajax를 거쳐서 사용.
		*/
		$.ajax({
			type: "GET",
			url: imgLoadUrl,
			// blob으로 받지 않으면 서버에서 blob 형식으로 이미지를 가져오지 않아 아래 에러 발생.
			// Failed to execute 'createObjectURL' on 'URL': Overload resolution failed.
            xhrFields: {
                responseType: 'blob'
            },
			success: function(data) {
				
				// null이면 default.png 출력
				if(!data) {
					$("#img_output").attr("src", '/images/default.png')
					return;
				}
				
				// null이 아니라면 crop 과정 수행
				var img = new Image();
				img.src = URL.createObjectURL(data);

				img.onload = function() {
					// 캔버스로 자르기 위해 선언
					var canvas = document.createElement('canvas');
					var ctx = canvas.getContext('2d');

					var cropWidth = 1500;
					var cropHeight = 3000;

					canvas.width = cropWidth;
					canvas.height = cropHeight;

					// 여기서 헷갈려서 많이 헤맸는데,
					// img 뒤 첫 두 개 파라미터에서 시작점을 지정하고, 이후 width와 height를 지정한다.
					// 그리고 그 뒤 파라미터에서는 저장할 이미지의 시작점과 widgh, height를 지정한다.
					// 즉 뒤 파라미터의 는 0, 0으로 시작해야 한다.
					ctx.drawImage(img, 1000, 2000, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight);
					
					// URL로 바꿔서 src에 탑재
					var croppedImageUrl = canvas.toDataURL('image/jpeg');
					$("#img_output").attr("src", croppedImageUrl);
				};
				
			},
			error: function() {
				$("#img_output").attr("src", "/img/default.png");
			}
		});
		
	</script>
</body>

 

결과를 확인하면 아래와 같이 데이터가 빠르게 잘 출력됨을 확인할 수 있다.

이미지 로딩 및 crop까지 0.42초가 소요되었다.

 

 

 

결론

 

확실히 Base64 인코딩보다는 Resource를 가져와서 사용하는게 훨씬 빨랐다.

 

그리고 결과적으로 말하자면, 위 방법 중 채택한 방법은 3번 방법인 Resource로 원본 이미지를 가져오는 방법이다.

 

잉? 그런 CROP은? 이라고 생각할 수 있지만, 앞서 말했듯 해당 웹 페이지는 한 장이 아니라 여러 장의 이미지를 CROP해서 가져와야만 하는 페이지였다. 그리고 이미지 축소를 위해 섬네일을 만드는 등 품질을 저하시키는 등의 작업은 할 수가 없었다.

 

그러나 위에서 예시로 든 것은 결국 한 장의 이미지였다. 한 장의 이미지를 BE에서 CROP하는 게 시간이 얼마 소요되지 않았지만, 여러 장을 CROP하니 시간이 오래 소요된 것처럼, FE에서 CROP을 할 때도 canvas가 많은 Resource를 소모하고, 이로 인해 페이지 로딩 시간 요구사항인 0.2초를 도저히 충족할 수가 없었다.

 

그래서 결국은 배치 프로그램을 만들어 입력되는 이미지들을 사전에 CROP하고, 이를 별도의 CROP 과정 없이 출력하는 방식으로 진행할 수 밖에 없었다.

728x90
반응형