본문 바로가기
프로그래밍/스프링 & 스프링 부트

스프링 서블릿 api를 이용한 파일 업로드, 다운로드(1)-업로드

by 밍구몬 2019. 6. 21.

서블릿 3.0 이상은 자체적인 파일 업로드 처리를 API 상에서 지원한다.

파일 업로드 API를 사용하기 위해서는 Tomcat은 버전 7 이상 서블릿은 3.0 이상으로 변경해 주어야 한다.

이 예제는 이전에 사용했던 프로젝트에 업로드 기능을 추가적으로 만들 것 이다.

전체 페이지 소스를 올리게 되면 보기 힘들 것 같아 추가된 내용만 포스팅하고 깃허브에 전체 소스를 올릴 것이다.

github : https://github.com/ming9mon/spring

 

혹시 파일 업로드를 하는데 Unable to process parts as no multi-part configuration has been provided 에러가 난다면 https://ming9mon.tistory.com/89 이 글을 참고하면 된다

pom.xml

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

servlet-api의 버전은 3.0 이상으로 변경시켜 주고, lombok을 사용할 것이기 때문에 디펜던시를 추가시켜 준다.

web.xml

web.xml 상단을 보면 다음과 같이 되어있을 것이다.

2.5로 된 부분을 찾아 다음과 같이 수정해 준다.

수정한 뒤 조금 내려보면 <!--  Processes application requests --> 가 보일 것이다 그 아래에 다음과 같이 작성해 준다.

	<!-- Processes application requests -->
	<servlet>
		<servlet-name>appServlet</servlet-name>
		<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
		<init-param>
			<param-name>contextConfigLocation</param-name>
			<param-value>/WEB-INF/spring/appServlet/servlet-context.xml</param-value>
		</init-param>
		<load-on-startup>1</load-on-startup>
	</servlet>
	
	<!-- 파일 업로드 설정 -->
	<multipart-config>
		<location>D:\\upload</location>
	 	<!-- 1MB * 20 = 20MB  -->
		<max-file-size>20971520</max-file-size>
		<!--  60MB  -->
		<max-request-size>62914560‬</max-request-size>
		<!--  20MB  -->
		<file-size-threshold>20971520</file-size-threshold>
	</multipart-config>

location : 파일을 저장할 경로이다. 절대 경로로 입력해 주어야 하며, 폴더를 미리 생성해 놓아야 한다.

max-file-size : 파일 한 개의 최대 용량 (1024*1024*20 = 20MB)

max-request-size : 파일 여러 개를 추가했을 때의 총 용량

file-size-threshold 업로드하는 파일이 임시로 파일로 저장되지 않고 메모리에서 바로 스트림으로 전달되는 크기의 한계 (20MB로 설정하면 20MB 이상인 경우 임시 파일로 저장)

 

web.xml은 was의 설정일 뿐이기 때문에 servlet-context.xml에 MultipartResolver라는 타입의 객체를 빈으로 등록해 주어야 한다.

servlet-context.xml

<beans:bean id="multipartResolver" class="org.springframework.web.multipart.support.StandardServletMultipartResolver"> </beans:bean>

데이터베이스

CREATE TABLE attachments(
    uuid varchar2(36) not null,
    path varchar2(100) not null,
    file_name varchar2(100) not null,
    type char(1) not null,
    bno number(10,0)
);

alter table attachments add constraint pk_attach primary key(uuid);
alter table attachments add constraint fk_board_attache foreign key(bno)
references boardTest(idx);

첨부파일의 내용을 저장할 데이터 베이스를 만든다.

uuid는 고유한 값을 넣어주기 위해 사용하였고, path는 저장 경로, name은 파일 이름, type은 이미지 파일인지 아닌지, bno는 boardTest의 idx를 참조한다.

 

insertBoard.jsp

<form id="frm" name="frm" action="insertBoard.do" method="post" enctype="multipart/form-data">

...

<tr>
  <td colspan="2" align="center">
    <input type="file" name="uploadFile" id="uploadFile" multiple>
    <div id="preview"></div>
  </td>
</tr>

...

insertBoard에 파일을 업로드 할 수 있도록 <input type=file>을 추가해 주고, form에 enctype 의 속성값을 multipart/form-data로 지정해 주어야 한다. 

그리고 업로드 할 이미지를 미리볼 수 있도록 div태그를 추가해 준다.

 

multiple 속성은 한번에 파일을 여러개 올릴 수 있도록 해주는 것이다.

multiple 속성은 크롬은 6.0 이상 IE는 10.0 이상부터 지원한다고 한다.

 

<script type="text/javascript">
	$(document).ready(function (e){
		
		$('#write').click(function(){
				var frmArr = ["title","writer","content"];

				//입력 값 널 체크
				for(var i=0;i<frmArr.length;i++){
					//alert(arr[i]);
					if($.trim($('#'+frmArr[i]).val()) == ''){
						alert('빈 칸을 모두 입력해 주세요.');
						$('#'+frmArr[i]).focus();
						return false;
					}
				}

				//전송
				$('#frm').submit();
		});
		
		$("input[type='file']").change(function(e){

			//div 내용 비워주기
			$('#preview').empty();

			var files = e.target.files;
			var arr =Array.prototype.slice.call(files);
			
			//업로드 가능 파일인지 체크
			for(var i=0;i<files.length;i++){
				if(!checkExtension(files[i].name,files[i].size)){
					return false;
				}
			}
			
			preview(arr);
			
			
		});//file change
		
		function checkExtension(fileName,fileSize){

			var regex = new RegExp("(.*?)\.(exe|sh|zip|alz)$");
			var maxSize = 20971520;	//20MB
			
			if(fileSize >= maxSize){
				alert('파일 사이즈 초과');
				$("input[type='file']").val("");	//파일 초기화
				return false;
			}
			
			if(regex.test(fileName)){
				alert('업로드 불가능한 파일이 있습니다.');
				$("input[type='file']").val("");	//파일 초기화
				return false;
			}
			return true;
		}
		
		function preview(arr){
			arr.forEach(function(f){
				
				//파일명이 길면 파일명...으로 처리
				var fileName = f.name;
				if(fileName.length > 10){
					fileName = fileName.substring(0,7)+"...";
				}
				
				//div에 이미지 추가
				var str = '<div style="display: inline-flex; padding: 10px;"><li>';
				str += '<span>'+fileName+'</span><br>';
				
				//이미지 파일 미리보기
				if(f.type.match('image.*')){
					var reader = new FileReader(); //파일을 읽기 위한 FileReader객체 생성
					reader.onload = function (e) { //파일 읽어들이기를 성공했을때 호출되는 이벤트 핸들러
						str += '<img src="'+e.target.result+'" title="'+f.name+'" width=100 height=100 />';
						str += '</li></div>';
						$(str).appendTo('#preview');
					} 
					reader.readAsDataURL(f);
				}else{
					str += '<img src="resources/img/fileImg.png" title="'+f.name+'" width=100 height=100 />';
					$(str).appendTo('#preview');
				}
			});//arr.forEach
		}
	});
</script>

insertBoard의 file에 change 옵션으로 파일이 변경 될 때마다 업로드 가능한 파일인지 확인하고 이미지라면 미리보기를 만들어 주고, 이미지 파일이 아니라면 그냥 파일모양의 이미지를 띄워준다.

img에 title 속성으로 마우스를 올렸을 때, 전체 파일명을 볼 수 있다.

 

첨부파일을 하나씩 지우는 기능을 추가해 주려고 하였지만, file태그는 보안상의 이유로 직접 수정을 하지 못한다고 한다. 그래서 하나씩 지우는 기능은 패스 ..

 

FileVO

package com.wipia.study.domain;

import lombok.Data;

@Data
public class FileVO {
	private String fileName;
	private String uuid;
	private String path;
	private long bno;
}

파일의 이름, 경로 등을  입력받을 DTO를 만들어 주고,

여러 파일을 등록할 경우 한번에 처리하기 위하여 BoardVO에 List<FileVO>를 추가해 준다.

BoardVO

@Data
public class BoardVO {
	private int idx;
	private String title;
	private String writer;
	private String content;
	private Date regDate;
	private int cnt;
	
	private List<FileVO> fileList;
	
}

BoardController

	// 글 쓰기
	@RequestMapping(value="/insertBoard.do", method=RequestMethod.POST) 
	public String insertBoard(BoardVO vo,MultipartFile[] uploadFile) throws IOException {
			
			boardService.insertBoard(vo,uploadFile); 
			
			return "redirect:getBoardList.do";
	}

컨트롤러는 서비스로 vo와 uploadFile을 다시 넘겨준다.

BoardServiceImpl

	@Transactional
	@Override
	public void insertBoard(BoardVO vo, MultipartFile[] uploadFile) {
		List<FileVO> list = new ArrayList<>();
		
		//파일을 저장할 경로 생성
		String path = getPath();
		
		//ex) D:\\upload\\2019\\06\\21
		File uploadPath = new File("D:"+File.separator+"upload"+File.separator+path);
		
		//폴더가 없다면 생성
		if (uploadPath.exists() == false) {
			uploadPath.mkdirs();
		}
		
		//게시글 디비에 저장
		boardDAO.insertBoard(vo);
		
		//게시한 글 번호 얻어오기
		long bno = boardDAO.getBno();
		//파일이 없으면 에러남
		if(!uploadFile[0].getOriginalFilename().equals("")) {
			for(MultipartFile file : uploadFile) {
				FileVO fvo = new FileVO();
				String fileName = file.getOriginalFilename();
				
				//파일명만 추출
				fileName = fileName.substring(fileName.lastIndexOf(File.separator)+1);
				//확장자 추출
				String ext=fileName.substring(fileName.lastIndexOf("."));
				//uuid 생성 
				String uuid= UUID.randomUUID().toString();
				
				//파일명을 UUID+확장자로 서버에 저장
				File saveFile = new File(uploadPath,uuid+ext);
				
				//vo 세팅
				fvo.setBno(bno);
				fvo.setFileName(fileName);
				fvo.setPath(path);
				fvo.setUuid(uuid+ext);
				
				//파일 저장
				try {
					file.transferTo(saveFile);
				} catch (IllegalStateException | IOException e) {
					e.printStackTrace();
				}
				list.add(fvo);	//리스트에 추가
			}
			//파일 디비에 추가
			boardDAO.insertAttach(list);	
		}
	}
    
    
	private String getPath() {

		//날짜를 년-월-일로 포멧
		SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
		Date date = new Date();
		String str = sdf.format(date);
		//ex) 2019\\06\\21
		String path = str.replace("-",File.separator);
		
		return path;
	}

if(!uploadFile[0].getOriginalFilename().equals(""))  < -- 이 부분은 첨부 파일을 입력하지 않아도 length가 1이여서 에러가 나기에 저렇게 비교해 주었다.

 

업로드 파일은 파일명의 중복 또는 많은 양의 파일이 생길 수 있기 때문에 D:\upload 폴더에 년\월\일 폴더가 있는지 확인해 없다면 생성해주고, UUID+확장자로 파일을 저장하였다.

 

insertBoard의 어노테이션을 보면 @Transaction이 있는데 첨부 파일은 등록되지 않았는데 게시물 내용만 저장되는 경우를 방지하기 위하여 Transaction을 이용하였다.

@Transactioni 사용법은 https://ming9mon.tistory.com/87 에서 확일할 수 있다.

BoardDAO

	public long getBno() {
		return mybatis.selectOne("BoardMapper.getBno");
	}

	public void insertAttach(List<FileVO> list) {
		mybatis.insert("BoardMapper.insertAttach",list);
	}

testMapper (BoardMapper)

	<select id="getBno" resultType="long">
		<![CDATA[
		SELECT idx
		FROM boardTest
		WHERE ROWNUM < 2
		ORDER BY 1 DESC
		]]>
	</select>
	
	<insert id="insertAttach" parameterType="FileVO">
		<foreach item="item" index="index" collection="list" separator=" " open="INSERT ALL " close="SELECT * FROM DUAL">
		INTO attachments(uuid,path,file_name,bno)
		VALUES (#{item.uuid},#{item.path},#{item.fileName},#{item.bno})
		</foreach>
	</insert>

매퍼에서 getBno는 idx를 역순으로 정렬하여 제일 최근에 등록한 글의 idx를 가져왔다.

 

insertAttache는 list로 받기 때문에 foreach문을 이용하여 첨부파일을 등록해 주었다.