본문 바로가기
Java & Kotlin/Spring

[Spring] 파일첨부

by heekng 2021. 5. 28.
반응형

[Spring] 파일첨부

 

파일첨부 방식에는 페이지 이동방식(form태그 이용)과 Ajax 이용방식, iframe을 이용하는 방식이 있다.

이중 iframe을 이용하는 방식은 화면이 하나 더 뜬다는 점과, 보안에 취약해 대부분의 웹사이트에서 이용하지 않고 있는 방식이다.


파일 업로드 API

cos.jar

cos.jar을 이용하는 방식은 2002년 이후로 개발이 종료되었고, 더 이상 사용하지 않는 것을 권장한다.

commos-fileupload

commos-fileupload는 일반적으로 많이 활용되며, 서블릿 3.0 이전에도 사용 가능하다.

서블릿의 API상에서 지원

서블릿 버전 3.0 이상부터는 API상에서 파일 업로드를 지원한다.


서블릿 버전 변경 및 설정

pom.xml 버전변경

...
<!-- Servlet -->
<dependency>
   <groupId>javax.servlet</groupId>
   <artifactId>javax.servlet-api</artifactId>
   <version>3.1.0</version>
   <scope>provided</scope>
</dependency>

...

위와 같이 서블릿 api 버전을 3.1.0버전으로 변경

web.xml 수정

<multipart-config>
	<location>/Users/heekng/upload/temp</location> 
	<max-file-size>20971520</max-file-size> 
	<max-request-size>41943040</max-request-size> 
	<file-size-threshold>20971520</file-size-threshold> 
</multipart-config>

<servlet>태그 안에 multipart-config를 추가

  • <location> : 문제가 발생했을 때 파일이 저장되는 임시 경로
    되도록 location의 경로는 파일 업로드 처리가 완료된 디렉토리 속에 temp폴더를 생성하여 설정하는 것이 좋다.
  • <max-file-size> : 업로드 되는 파일의 최대 크기(20M)
  • <max-request-size> : 한번에 올릴 수 있는 최대 크기(40M)
  • <file-size-threshold> : 20M의 메모리를 사용한다.

Form submit을 통한 파일 업로드

<form enctype="multipart/form-data">태그를 submit하여 파일을 저장한다면 페이지를 이동하는 과정을 거친다.

UploadController

@PostMapping("/uploadFormAction")
	public void uploadFormPost(MultipartFile[] uploadFile) {
		String uploadFolder="/Users/heekng/upload";
		
		for(MultipartFile multipartFile : uploadFile) {
			log.info("================");
			log.info("업로드 파일명" + multipartFile.getOriginalFilename());
			log.info("업로드 파일크기" + multipartFile.getSize());
			
			File saveFile = new File(uploadFolder, multipartFile.getOriginalFilename());
			try {
				multipartFile.transferTo(saveFile);
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
	}
  • 외부에서 여러 개의 파일이 전달될 수 있으므로 배열로 받는다.
    MultipartFile타입으로 받는다.
  • foreach문을 이용하여 각 multipart객체를 순서대로 가져온다
  • File객체에 경로와 multipartFile.getOriginalFilename()을 매개변수로 담아 빈 파일을 생성하고
  • multipartFile.transferTo메소드를 이용하여 multipartFile에 담겨있는 파일을 새로 만든 파일에 담아준다.

Ajax를 통한 파일 업로드

input태그 속의 파일을 javascript ajax통신을 이용해 파일을 업로드하는 방법이다.

$("#uploadBtn").on("click", function(){
	var formData = new FormData();
	var inputFile = $("input[name='uploadFile']");
	var files = inputFile[0].files;
	
	console.log(files);
	
	for(let i=0; i < files.length; i++){
		if(!check(files[i].name, files[i].size)){
			return false;
		}
		formData.append("uploadFile", files[i]);
	}
	
	$.ajax({
		url: contextPath + "/uploadAjaxAction",
		processData: false, 
		contentType : false, 
		data: formData,
		type: "post",
		dataType: "json",
		success: function(result){
			console.log(result);
			showUploadFile(result.succeedList, uploadResult);
			if(result.failureList.length != 0){
				showUploadFile(result.failureList, uploadFail);
				$(".uploadFail").show();
			}else{
				$(".uploadFail").hide();
			}
			$(".uploadDiv").html(cloneObj.html());
		}
	});
});
  • FormData객체를 만들어 파일의 유효성 검사 이후 map방식으로 담는다.
  • processData: false;는 ajax로 데이터를 보낼 때 내부적으로 쿼리스트링을 만들어주는데, true로 값을 주게되면 파일에 &가 있을 때 문제가 생기므로 false로 줘야 한다.

pom.xml Thumbnailator라이브러리 설치

용량이 큰 파일을 모바일과 같은 환경에서 처리한다면 많ㅇ느 데이터를 소비해야 한다.

따라서 특별한 경우가 아니라면 썸네일을 제작해야 한다.

하나의 이미지 파일을 업로드하면 Application에서 사용되는 다양한 해상도 썸네일로 만들어 낼 수 있으며, 다운로드 하지 않고도 미리보기가 가능하다.

<!-- Thumbnailator -->
      <dependency>
         <groupId>net.coobird</groupId>
         <artifactId>thumbnailator</artifactId>
         <version>0.4.8</version>
      </dependency>

UploadCotroller

@PostMapping(value="/uploadAjaxAction", produces = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public ResponseEntity<AllFileDTO> uploadAjaxAction(MultipartFile[] uploadFile) {
	log.info("upload ajax post........");
	
	String uploadFolder = "/Users/heekng/upload";
	String uploadFolderPath = getFolder();
	File uploadPath = new File(uploadFolder, uploadFolderPath);
	AllFileDTO allFile = new AllFileDTO();
	List<AttachFileDTO> succeedList = new ArrayList<AttachFileDTO>();
	List<AttachFileDTO> failureList = new ArrayList<AttachFileDTO>();
	
	if(!uploadPath.exists()) {
		uploadPath.mkdirs();
	}
	
	for(MultipartFile multipartFile : uploadFile) {
		log.info("================");
		log.info("업로드 파일명" + multipartFile.getOriginalFilename());
		log.info("업로드 파일크기" + multipartFile.getSize());
		
		AttachFileDTO attachDTO = new AttachFileDTO();
		
		String uploadFileName = multipartFile.getOriginalFilename();
		uploadFileName = uploadFileName.substring(uploadFileName.lastIndexOf("\\") + 1);
		
		log.info("실제 파일 명 : " + uploadFileName);
		attachDTO.setFileName(uploadFileName);
		
		UUID uuid = UUID.randomUUID();
		uploadFileName = uuid.toString() + "_" + uploadFileName;
		InputStream in = null;
		try {
			File saveFile = new File(uploadPath, uploadFileName);
			multipartFile.transferTo(saveFile);
			in = new FileInputStream(saveFile);
			
			attachDTO.setUuid(uuid.toString());
			attachDTO.setUploadPath(uploadFolderPath);
			
			log.info("들어옴");
			if(checkImg(saveFile)) {
				attachDTO.setImage(true);
				FileOutputStream thumbnail = new FileOutputStream(new File(uploadPath, "s_" + uploadFileName));
				Thumbnailator.createThumbnail(in, thumbnail, 100, 100);
				thumbnail.close();
			}
			succeedList.add(attachDTO);
		} catch (Exception e) {
			failureList.add(attachDTO);
			log.info("오류다");
			log.info(e);
			log.error(e.getMessage());
		} finally {
			try {
				in.close();
			} catch (IOException e) {
				e.printStackTrace();
			}
		}
	}
	allFile.setSucceedList(succeedList);
	allFile.setFailureList(failureList);
	return new ResponseEntity<AllFileDTO>(allFile,HttpStatus.OK);
}
  • 컨트롤러가 @Restcontroller가 아닐 경우이기 때문에 메소드에 @ResponseBody어노테이션을 할당한다.
  • 전체적인 과정은 form을 이용하는 방식과 동일하다.
  • UUID란 네트워크 상에서 각각의 객체들을 식별하기 위하여 사용된다.
    중복될 가능성이 거의 없다고 인정되기 때문에 많이 사용한다.
    UUID uuid = UUID.randoomUUID(); uuid.toString();의 형태로 사용한다.
    파일 이름이 중복되더라도 이름 앖에 UUID를 붙여주며 중복될 가능성이 희박하게 만든다.
  • FileInputStream을 이용하여 저장한 파일을 불러온다.
  • 저장한 파일이 이미지일 경우에 FileOutputStream을 이용하여 썸네일파일을 저장할 경로(파일)을 만든다.
    Thumbnailator.createThumbnail(fileInputStream, fileOutputStream, x축px, y축px);으로 이미지 파일의 해상도를 변환한다.
private boolean checkImg(File file) throws IOException{
		log.info("메소드들어옴");
		log.info("file"+file);
		
		boolean result;
		MimetypesFileTypeMap mime = new MimetypesFileTypeMap();
		log.info("check: "+mime.getContentType(file));
		log.info("path: "+file.toPath());
		log.info("check2: "+Files.probeContentType(file.toPath()));
		if(mime.getContentType(file).contains("image")) {
			result = true;
		}else {
			result = false;
		}
		
		return result;
		
//		return Files.probeContentType(file.toPath()).startsWith("image");
	}
  • 파일의 확장자가 이미지인지 확인한다.
  • 가장 쉽고 간단하게 확인하는 방법은 Files.probeContentType(file.toPath()).startsWith("image");를 이용한다.
    하지만 probeContentType의 오류로 인해 null만 리턴되는 상황이 있다.
  • 때문에 MimetypesFileTypeMap을 이용하여 ContentType을 확인하고, boolean타입의 결과를 리턴했다.
private String getFolder() {
	SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
	//현재 날짜에서 -를 \\로 변경해준다.
	Date date = new Date();
	String str = sdf.format(date);
	return str.replace("-", File.separator); //separator : 파일 경로에서의 //
}

파일 다운로드

파일을 다운로드할 때에는 byte단위로 데이터를 전송하기 위해 MediaType.APPLICATION_OCTET_STREAM_VALUE로 데이터를 return 한다.

@GetMapping(value = "/download", produces=MediaType.APPLICATION_OCTET_STREAM_VALUE)
@ResponseBody
public ResponseEntity<Resource> downloadFile(String fileName, @RequestHeader("User-Agent") String userAgent) {//userAgent는 브라우저 정보를 받아온다.
	log.info("downlopad file: " + fileName);
	Resource resource = new FileSystemResource("/Users/heekng/upload/" + fileName);
	log.info("resource : " + resource);
	
	String resourceName = resource.getFilename();
	String originalName = resourceName.substring(resourceName.indexOf("_")+1);
	//파일을 전송할 때에는 UTF-8이 아닌 ISO-8859-1을 이용
	HttpHeaders headers = new HttpHeaders();
	//다운로드 시 저장되는 이름: Content-Disposition
	try {
		String downloadName = null;
		if(userAgent.contains("Trident")) {
			//Trident : MSIE
			log.info("IE Browser");
			downloadName = URLEncoder.encode(originalName, "UTF-8");
		}else if(userAgent.contains("Edg")) {
			//Edg : 엣지
			downloadName = URLEncoder.encode(originalName, "UTF-8");
		}else {
			//그 외(크롬)
			downloadName = new String(originalName.getBytes("UTF-8"), "ISO-8859-1");
		}
		System.out.println(userAgent);
		headers.add("Content-Disposition", "attachment; filename=" + downloadName);
	} catch (UnsupportedEncodingException e) {
		e.printStackTrace();
	}
	return new ResponseEntity<Resource>(resource, headers, HttpStatus.OK);
}
  • 파일의 이름과 @RequestHeader("User-Agent")를 이용하여 브라우저 정보를 받아온다.
  • FileSystemResource생성자에 파일 이름에 따른 파일 경로를 입력하여 resource 객체를 생성한다.
    이 resource객체를 통해 파일의 정보를 받아온다.
  • userAgent를 판단하여 IE, 엣지에서는 URLEncoder.encode를 이용하여 파일명을 UTF-8로 인코딩해준다.
    그 외의 브라우저는 기존의 파일명에서 getBytes("UTF-8")하고, ISO-8859-1로 저장한다.
    new String(byte[], charset) : 해당 바이트 배열을 charset으로 설정한다.
  • HttpHeaders객체에.add("Content-Disposition", "attachment; filename=" + downloadName);하여 파일의 정보를 담는다.
    Content-Disposition: 다운로드시 저장되는 이름

파일 삭제

@PostMapping("/deleteFile")
@ResponseBody
public ResponseEntity<String> deleteFile(String fileName, String type){
	log.info("deleteFile : " + fileName);
	File file = null;
    
	try {
		file = new File("/Users/heekng/upload/" + URLDecoder.decode(fileName, "UTF-8"));
		file.delete();
		
		if(type.equals("image")) {
			String imageFileName = file.getPath().replace("s_", "");
			file = new File(imageFileName);
			file.delete();
		}
		
	} catch (UnsupportedEncodingException e) {
		e.printStackTrace();
		return new ResponseEntity<String>(HttpStatus.NOT_FOUND);
	}
	
	return new ResponseEntity<String>("deleted", HttpStatus.OK);
}
  • 매개변수로 fileName과 type을 받아온다.
  • File객체에 삭제하려는 파일의 경로를 넣어 생성한다.
    이 때 경로상에 역슬래시(\)와 같이 경로에 영향을 줄 수 있는 예외를 피하기 위해 인코딩했던 fileName을 URLDecoder.decode를 이용하여 "UTF-8"로 디코딩한다.

uploadAjax.jsp

<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>File Upload - Ajax</title>
<style>
	.uploadResult{
		width: 100%;
		
	}
	.uploadResult ul{
		display: flex;
		justify-content: center;
	}
	
	.uploadResult ul li {
		list-style: none;
		padding: 10px;
	}
	
	.bigPicture{
		text-align: center;
	}
	
</style>
</head>
<body>
	<h1>File Upload - Ajax</h1>
	<div class="uploadDiv">
		<input type="file" name="uploadFile" multiple>
	</div>
	<div class="uploadResult">
		<h3>지원하는 형식입니다.</h3>
		<ul></ul>
	</div>
	<div class="uploadFail" style="display:none;">
		<h3>지원하지 않는 형식입니다.</h3>
		<ul></ul>
	</div>
	<button id="uploadBtn">upload</button>
	
	<div class="bigPictureWrapper">
		<div class="bigPicture"></div>
	</div>
</body>
<script src="https://code.jquery.com/jquery-3.3.1.min.js"></script>
<script>
		var check = false;
		
		function showImage(fileCallPath){
		   //alert(fileCallPath);
			if(check){return;}		
		   $(".bigPictureWrapper").css("display", "flex").show();
		   
		   $(".bigPicture").html("<img src='/upload/display?fileName=" + encodeURIComponent(fileCallPath) + "'>")
		      .animate({width:"100%", height:"100%"}, 3000);
		      check = true;
		}
		
		$(document).ready(function(){
			var contextPath = "${pageContext.request.contextPath}"
			var uploadResult = $(".uploadResult ul");
			var uploadFail = $(".uploadFail ul");
			var cloneObj = $(".uploadDiv").clone();
			
			
			$(".bigPictureWrapper").on("click", function(e){
				if(!check){return;}
				$(".bigPicture").animate({width:"0%", height:"0%"}, 1000);
				check = false;
				setTimeout(function(){
					$(".bigPictureWrapper").hide();
				}, 1000)
			});
			
			function showUploadFile(uploadResults, tag){
				str = "";
				$(uploadResults).each(function(i, obj){
					if(!obj.image){
						var fileCallPath = encodeURIComponent(obj.uploadPath + "/" + obj.uuid + "_" + obj.fileName);
						str += "<li><div><a href='/upload/download?fileName="+fileCallPath+"'><img src='/upload/resources/image/noImage.png'>" + obj.fileName + "</a>";
						/*
							data속성은 map구조로 DOM객체에서 사용할 수 있다.
							<span data-key="value">
							$("span").data("key") == "value"
						*/
						str += "<span data-file='"+fileCallPath+"' data-type='file'>x</div></span></li>";
					}else{
						//encodeURIComponent("문자열값")
						//get방식으로 전송 시 파라미터로 전달할 때, 값에 인식할 수 없는 문자가 있을 경우 쿼리 스트링 문법에 맞게
						//변경해야 한다.
						//이 때 사용하는 메소드이다.
						var fileCallPath = encodeURIComponent(obj.uploadPath + "/s_" + obj.uuid + "_" + obj.fileName);
						var originPath = obj.uploadPath + "/" + obj.uuid + "_" + obj.fileName;
						originPath = originPath.replace(new RegExp(/\\/g), "/");
						str += "<li><div><a href=\"javascript:showImage(\'" + originPath + "\')\"><img src='/upload/display?fileName=" + fileCallPath + "'>" + obj.fileName + "</a>";
						str += "<span data-file='"+fileCallPath+"' data-type='image'>x</div></span></li>";
					}
				});
				$(tag).append(str);
			}
			
			$(".uploadResult").on("click", "span", function(e){
				var targetFile = $(this).data("file");
				var type = $(this).data("type");
				var thisTag = $(this);
				console.log(targetFile);
				$.ajax({
					url: contextPath + "/deleteFile",
					data: {fileName:targetFile, type:type},
					dataType:"text",
					type:"post",
					success:function(result){
						alert(result);
						$(thisTag).closest("li").remove();
					}
				});
				
				
			})
			
			function check(fileName, fileSize){
				//확장자 확인 정규식
				var regex = new RegExp("(.*?)\.(exe|sh|zip|alz)$");
				var maxSize = 5242880; //5MB
				if(regex.test(fileName)){
					alert("업로드 할 수 없는 파일의 형식입니다.");
					return false;
				}
				if(fileSize > maxSize){
					alert("파일 사이즈 초과");
					return false;
				}
				return true;
			}
			
			$("#uploadBtn").on("click", function(){
				var formData = new FormData();
				var inputFile = $("input[name='uploadFile']");
				var files = inputFile[0].files;
				
				
				console.log(files);
				
				for(let i=0; i < files.length; i++){
					if(!check(files[i].name, files[i].size)){
						return false;
					}
					formData.append("uploadFile", files[i]);
				}
				
				$.ajax({
					url: contextPath + "/uploadAjaxAction",
					processData: false, //내부적으로 쿼리스트링을 만들어주는데 true로 하면 파일에&가있을때 문제가 생기므로 false로 줘야한다.
					contentType : false, 
					data: formData,
					type: "post",
					dataType: "json",
					success: function(result){
						console.log(result);
						showUploadFile(result.succeedList, uploadResult);
						if(result.failureList.length != 0){
							showUploadFile(result.failureList, uploadFail);
							$(".uploadFail").show();
						}else{
							$(".uploadFail").hide();
						}
						$(".uploadDiv").html(cloneObj.html());
					}
				});
			});
		});
</script>
</html>
반응형