ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 스프링(Spring) 개발 - (14) 파일 업로드 & 다운로드 (2/3)
    Spring 2015. 7. 20. 10:37

    이번글에서는 첨부파일의 다운로드에 대해서 이야기를 하려고 합니다.


    지난글에서 첨부파일을 업로드하였고, 이번글에서는 그 파일을 다운로드 하는 방법입니다.


    그리고 다음글에서는 기존 소스를 약간 변경하여 다중 첨부파일 업로드를 하는 방법을 이야기하겠습니다.


    ------------------------------------------------------------------------------------


    1. 첨부파일 보여주기

    지난글에서는 게시판에 첨부파일을 등록하는 기능을 작성했었다. 이제 해당 게시글에서 첨부파일을 보여주는것을 먼저 시작하자.


    1. SQL

    이번에는 쿼리부터 시작을 해보자. 

    다음의 쿼리를 Sample_SQL.xml 파일에 작성하자.

    <select id="selectFileList" parameterType="hashmap" resultType="hashmap">
    	<![CDATA[
    		SELECT
    		    IDX,
    		    ORIGINAL_FILE_NAME,
    		    ROUND(FILE_SIZE/1024,1) AS FILE_SIZE
    		FROM
    		    TB_FILE
    		WHERE
    		    BOARD_IDX = #{IDX}
    		    AND DEL_GB = 'N'
    	]]>
    </select>
    

    쿼리는 간단하다. 선택된 게시글 번호에 해당하는 첨부파일의 목록을 조회하는 쿼리이다.

    첨부파일의 크기를 Kb 단위로 보여주기 위해서 ROUND 함수를 사용하였다. 

    그 외에는 별다른게 없는 쿼리임을 알 수 있다.


    2. Java

    이제 java단을 수정할 차례이다. 

    먼저 기존 소스를 수정할 SampleController와 SampleServiceImp을 살펴보자.


    1) SampleController

    SampleController.java 파일에서 게시글의 상세정보를 가져오는 openBoardDetail 부분을 다음과 같이 변경하자.

    @RequestMapping(value="/sample/openBoardDetail.do")
    public ModelAndView openBoardDetail(CommandMap commandMap) throws Exception{
    	ModelAndView mv = new ModelAndView("/sample/boardDetail");
    	
    	Map<String,Object> map = sampleService.selectBoardDetail(commandMap.getMap());
    	mv.addObject("map", map.get("map"));
    	mv.addObject("list", map.get("list"));
    	
    	return mv;
    }
    

    기존 소스와 비교했을 때, 큰 변화는 없다. 

    살펴봐야 할것은 6,7번째 줄이다. 

    기존에는 sampleService.selectBoardDetail()의 리턴값을 그대로 map이라는 이름으로 바로 화면으로 전송하였는데, 

    이번에는 map에서 2가지를 가져온 후, 각각 mv에 넣어주는 것을 확인해야한다.

    6번째 줄의 map.get("map")은 기존의 게시글 상세정보이다. 

    7번째 줄의 map.get("list")는 첨부파일의 목록을 가지고 있다. 게시글 상세정보와 첨부파일 정보를 각각 보내주는것을 확인하자.

    그럼 다음으로 SampleServiceImpl을 수정하자.


    2) SampleServiceImpl

    SampleServiceImp.java 파일에서 selectBoardDetail 부분을 다음과 같이 변경하자.

    @Override
    public Map<String, Object> selectBoardDetail(Map<String, Object> map) throws Exception {
    	sampleDAO.updateHitCnt(map);
    	Map<String, Object> resultMap = new HashMap<String,Object>();
    	Map<String, Object> tempMap = sampleDAO.selectBoardDetail(map);
    	resultMap.put("map", tempMap);
    	
    	List<Map<String,Object>> list = sampleDAO.selectFileList(map);
    	resultMap.put("list", list);
    	
    	return resultMap;
    }
    

    지난글과 비교해서 약간 수정이 된 것을 알 수 있다.

    먼저 6번째 줄 sampleDAO.selectBoardDetail()을 통해서 게시글의 상세정보를 가져온다. 

    그리고 그 결과값을 "map" 이라는 이름으로 resultMap에 저장한다.


    그 다음으로 sampleDAO.selectFileList()를 통해서 게시글의 첨부파일 목록을 가져온다.

    앞에서 각 게시글에는 하나의 첨부파일만 저장할 수 있도록 했었지만, 곧 다중 업로드가 가능하도록 수정할 계획이라서

    가능한 소스의 수정을 적게하도록, 미리 첨부파일의 목록을 가져오도록 하였다.

    그 다음으로 resultMap에 "list"라는 이름으로 저장하였다. 


    여기서 resultMap에 "map"과 "list"라는 키를 다시한번 확인하자.

    이 키는 앞의 SampleController에서 map.get("map), map.get("list") 라는 키로 사용되었다. 


    이제 마지막으로 selectFileList 메서드를 구현하자.


    3) SampleDAO

    SampleDAO.java에 다음의 소스를 작성하자.

    @SuppressWarnings("unchecked")
    public List<Map<String, Object>> selectFileList(Map<String, Object> map) throws Exception{
    	return (List<Map<String, Object>>)selectList("sample.selectFileList", map);
    }
    

    맨 처음에 만들었던 selectFileList 쿼리를 호출하는 역할을 한다.


    이제 여기까지 작성했으면, 쿼리 및 서버부분은 끝났다. 

    마지막으로 JSP화면에서 첨부파일 목록을 보여주면 된다.


    3. JSP

    boardDetail.jsp를 다음과 같이 변경하자.

    <%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%>
    <!DOCTYPE html>
    <html lang="ko">
    <head>
    <%@ include file="/WEB-INF/include/include-header.jspf" %>
    </head>
    <body>
    	<table class="board_view">
    		<colgroup>
    			<col width="15%"/>
    			<col width="35%"/>
    			<col width="15%"/>
    			<col width="35%"/>
    		</colgroup>
    		<caption>게시글 상세</caption>
    		<tbody>
    			<tr>
    				<th scope="row">글 번호</th>
    				<td>${map.IDX }</td>
    				<th scope="row">조회수</th>
    				<td>${map.HIT_CNT }</td>
    			</tr>
    			<tr>
    				<th scope="row">작성자</th>
    				<td>${map.CREA_ID }</td>
    				<th scope="row">작성시간</th>
    				<td>${map.CREA_DTM }</td>
    			</tr>
    			<tr>
    				<th scope="row">제목</th>
    				<td colspan="3">${map.TITLE }</td>
    			</tr>
    			<tr>
    				<td colspan="4">${map.CONTENTS }</td>
    			</tr>
    			<tr>
    				<th scope="row">첨부파일</th>
    				<td colspan="3">
    					<c:forEach var="row" items="${list }">
    						<input type="hidden" id="IDX" value="${row.IDX }">
    						<a href="#this" name="file">${row.ORIGINAL_FILE_NAME }</a> 
    						(${row.FILE_SIZE }kb)
    					</c:forEach>
    				</td>
    			</tr>
    		</tbody>
    	</table>
    	<br/>
    	
    	
    	<a href="#this" class="btn" id="list">목록으로</a>
    	<a href="#this" class="btn" id="update">수정하기</a>
    	
    	<%@ include file="/WEB-INF/include/include-body.jspf" %>
    	<script type="text/javascript">
    		$(document).ready(function(){
    			$("#list").on("click", function(e){ //목록으로 버튼
    				e.preventDefault();
    				fn_openBoardList();
    			});
    			
    			$("#update").on("click", function(e){ //수정하기 버튼
    				e.preventDefault();
    				fn_openBoardUpdate();
    			});
    			
    			$("a[name='file']").on("click", function(e){ //파일 이름
    				e.preventDefault();
    			});
    		});
    		
    		function fn_openBoardList(){
    			var comSubmit = new ComSubmit();
    			comSubmit.setUrl("<c:url value='/sample/openBoardList.do' />");
    			comSubmit.submit();
    		}
    		
    		function fn_openBoardUpdate(){
    			var idx = "${map.IDX}";
    			var comSubmit = new ComSubmit();
    			comSubmit.setUrl("<c:url value='/sample/openBoardUpdate.do' />");
    			comSubmit.addParam("IDX", idx);
    			comSubmit.submit();
    		}
    	</script>
    </body>
    </html>
    

    기존과 변경된 부분은 36~45번째 줄에 첨부파일을 보여주는 부분이 추가된 것이다. 

    소스를 살펴보면 기존에 게시판 목록을 보여주는것과 다른점이 없다. 

    그리고 파일이름을 클릭했을 때 첨부파일을 다운로드 할 수 있도록 파일이름에 클릭 이벤트를 바인딩 해놓은것을 볼 수 있다.(67~69번 줄)


    이제 이렇게 작성하고 소스를 실행시켜 보자.


    위와같이 지난글에서 첨부했던 파일의 이름과 파일크기를 정상적으로 보여주는 것을 볼 수 있다.

    만약 첨부파일이 없을 경우, "첨부파일이 없습니다." 와 같은 메시지를 보여주고 싶으면 <c:forEach> 구문안에 분기문을 작성해서 따로 처리해주면 된다.

    게시글 목록인 boardList.jsp에서도 한번 이야기를 했었기 때문에 따로 설명하지 않겠다.

    이제 앞에서 이야기한거 다시 이야기 안해도 될 때 아닌가?


    그럼 이제 첨부파일을 다운로드 하는 방법을 살펴보자.


    2. 첨부파일 다운로드

    1. JSP

    먼저 방금 작성한 boardDetail.jsp에서 파일 이름을 클릭할 때, 해당 첨부파일을 다운로드 하는 주소로 이동시키도록 수정하자.

    boardDetail.jsp의 스크립트 부분을 다음과 같이 수정하자.

    <script type="text/javascript">
    	$(document).ready(function(){
    		$("#list").on("click", function(e){ //목록으로 버튼
    			e.preventDefault();
    			fn_openBoardList();
    		});
    		
    		$("#update").on("click", function(e){ //수정하기 버튼
    			e.preventDefault();
    			fn_openBoardUpdate();
    		});
    		
    		$("a[name='file']").on("click", function(e){ //파일 이름
    			e.preventDefault();
    			fn_downloadFile($(this));
    		});
    	});
    	
    	function fn_openBoardList(){
    		var comSubmit = new ComSubmit();
    		comSubmit.setUrl("<c:url value='/sample/openBoardList.do' />");
    		comSubmit.submit();
    	}
    	
    	function fn_openBoardUpdate(){
    		var idx = "${map.IDX}";
    		var comSubmit = new ComSubmit();
    		comSubmit.setUrl("<c:url value='/sample/openBoardUpdate.do' />");
    		comSubmit.addParam("IDX", idx);
    		comSubmit.submit();
    	}
    	
    	function fn_downloadFile(obj){
    		var idx = obj.parent().find("#IDX").val();
    		var comSubmit = new ComSubmit();
    		comSubmit.setUrl("<c:url value='/common/downloadFile.do' />");
    		comSubmit.addParam("IDX", idx);
    		comSubmit.submit();
    	}
    </script>
    

    첨부파일의 파일명을 클릭하면 fn_downloadFile 이라는 함수가 실행되도록 하였다.

    fn_downloadFile 함수에서는 해당 파일의 IDX값을 가져와서 /common/downloadFile.do 라는 주소로 submit을 하는것을 알 수 있다.

    클라이언트, 즉 화면에서는 이렇게만 하면 된다. 다음으로는 서버측을 살펴보자.


    2. Java 및 SQL

    먼저 src/main/java 밑의 first/common 패키지 밑에 공통기능을 수행할 공통Controller, Service, DAO를 생성하자.

    controller, service 패키지를 생성하고 CommonController, CommonService, CommonServiceImpl, CommonDAO를 생성한다.

    그 후, src/main/resources 밑의 mapper 폴더 밑에 공통쿼리를 작성할 Common_SQL 파일을 생성하자.

    common 폴더 생성 후 Common_SQL.xml 파일을 생성한다.


    1) CommonController

    package first.common.controller;
    
    import javax.annotation.Resource;
    
    import org.apache.log4j.Logger;
    import org.springframework.stereotype.Controller;
    
    import first.common.service.CommonService;
    
    @Controller
    public class CommonController {
    	Logger log = Logger.getLogger(this.getClass());
    	
    	@Resource(name="commonService")
    	private CommonService commonService;
    }
    
    

    2) CommonService

    package first.common.service;
    
    public interface CommonService {
    
    }
    


    3) CommonServiceImpl

    package first.common.service;
    
    import javax.annotation.Resource;
    
    import org.apache.log4j.Logger;
    import org.springframework.stereotype.Service;
    
    import first.common.dao.CommonDAO;
    
    @Service("commonService")
    public class CommonServiceImpl implements CommonService{
    	Logger log = Logger.getLogger(this.getClass());
    	
    	@Resource(name="commonDAO")
    	private CommonDAO commonDAO;
    }
    


    4) CommonDAO

    package first.common.dao;
    
    import org.springframework.stereotype.Repository;
    
    @Repository("commonDAO")
    public class CommonDAO extends AbstractDAO{
    
    }
    

    5) Common_SQL.xml

    <?xml version="1.0" encoding="UTF-8"?>
    <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
    
    <mapper namespace="common">
    
    </mapper>
    


    여기까지 완료되면 다음과 같은 구조를 가진다.

    이제 하나씩 작성을 하자. 

    먼저 첨부파일을 다운로드 하는 방식을 살펴보자.

    여기서는 다음과 같은 과정을 거쳐서 다운로드 기능을 작성할 것이다.


    화면에서 특정 첨부파일 다운로드 요청 -> 서버에서 해당 첨부파일 정보 요청 -> DB에서 파일정보 조회 -> 조회된 데이터를 바탕으로 클라이언트에 다운로드가 가능하도록 데이터 전송


    먼저 "화면에서 특정 첨부파일 다운로드 요청" 부분은 위에서 작성을 완료하였다. 


    이제 서버측의 동작을 살펴봐야하는데, 원래는 Controller부터 작성을 해야겠지만 Controller에서는 설명할 부분이 좀 있어서 별다른 부분이 없는 Service, DAO, SQL을 먼저 작성한 후 Controller를 살펴보도록 하겠다.


    다음의 소스를 작성하자.

    1) CommonService

    Map<String, Object> selectFileInfo(Map<String, Object> map) throws Exception;
    


    2) CommonServiceImpl

    @Override
    public Map<String, Object> selectFileInfo(Map<String, Object> map) throws Exception {
    	return commonDAO.selectFileInfo(map);
    }
    


    3) CommonDAO

    @SuppressWarnings("unchecked")
    public Map<String, Object> selectFileInfo(Map<String, Object> map) throws Exception{
    	return (Map<String, Object>)selectOne("common.selectFileInfo", map);
    }
    


    4) Common_SQL

    <select id="selectFileInfo" parameterType="hashmap" resultType="hashmap">
    	<![CDATA[
    		SELECT
    			STORED_FILE_NAME,
    			ORIGINAL_FILE_NAME
    		FROM
    			TB_FILE
    		WHERE
    			IDX = #{IDX}
    	]]>
    </select>
    

    특별한 부분은 없는것을 알 수 있다.

    쿼리에서는 저장된 파일명과 원본 파일명 두가지를 모두 조회하는것만 살펴보면 된다.


    이제 Controller를 작성할 차례이다.

    Controller에 다음의 소스를 작성하자.

    @RequestMapping(value="/common/downloadFile.do")
    public void downloadFile(CommandMap commandMap, HttpServletResponse response) throws Exception{
    	Map<String,Object> map = commonService.selectFileInfo(commandMap.getMap());
    	String storedFileName = (String)map.get("STORED_FILE_NAME");
    	String originalFileName = (String)map.get("ORIGINAL_FILE_NAME");
    	
    	byte fileByte[] = FileUtils.readFileToByteArray(new File("C:\\dev\\file\\"+storedFileName));
    	
    	response.setContentType("application/octet-stream");
    	response.setContentLength(fileByte.length);
    	response.setHeader("Content-Disposition", "attachment; fileName=\"" + URLEncoder.encode(originalFileName,"UTF-8")+"\";");
    	response.setHeader("Content-Transfer-Encoding", "binary");
    	response.getOutputStream().write(fileByte);
    	
    	response.getOutputStream().flush();
    	response.getOutputStream().close();
    }
    

    이제 소스를 하나씩 살펴보자.

    먼저, 메서드의 파라미터로 기존에 보지 못했던 HttpServletResponse response 라는것이 추가되었다.

    기존에 첨부파일을 업로드 할때는 HttpServletRequest request가 추가되었었는데, 이번에는 response가 추가되었다.

    화면에서 서버로 어떤 요청을 할때는 request가 전송되고, 반대로 서버에서 화면으로 응답을 할때는 response에 응답내용이 담기게 된다.


    위에서 "조회된 데이터를 바탕으로 클라이언트에 다운로드가 가능하도록 데이터 전송" 라는 과정이 있다고 이야기를 했었다. 여기서 다운로드가 가능한 데이터 전송이라는 것은 파일정보를 response에 담아주는것을 의미한다.


    3번째 줄의 commonService.selectFileInfo를 통해서 첨부파일의 정보를 가져온다.

    그 후, 실제로 파일이 저장된 위치에서 해당 첨부파일을 읽어서 byte[] 형태로 변환을 해야한다. 

    7번째 줄이 그 역할을 수행하는데, FileUtils 클래스는 기존에 우리가 만들었던 클래스가 아니라 org.apache.commons.io 패키지의 FileUtils 클래스이다.

    지난글에서 pom.xml에 commons-io와 commons-fileupload dependency를 추가했었다. (http://addio3305.tistory.com/83)

    그때 추가했던 라이브러리를 사용하는 것이다. 

    이 라이브러리를 사용하지 않더라도 파일을 읽어와서 byte[] 형식으로 변환할 수는 있지만, 그러면 상당히 복잡한 과정을 거쳐야한다. 

    읽어오는 파일의 위치는 "C:\\dev\\file\\" 에 저장된 파일이름을 붙이고 있다.

    앞에서 파일을 저장하는 위치를 C:\\dev\\file\\로 했었던 것을 기억하자. 거기에 저장된 파일명을 붙여서 가져오도록 하였다.


    그 다음으로 중요한 부분이 9~16번째 줄이다.

    읽어들인 파일정보를 화면에서 다운로드 할 수 있도록 변환을 하는 부분이다.

    우리가 인터넷을 통해서 데이터를 전송하면 request나 response에는 전송할 데이터 뿐만이 아니라 여러가지 정보가 담겨있다. 

    그 정보를 설정해 주는 부분을 9~13번째 줄에서 하는 것이다.

    해당 정보의 자세한 설명은 하지 않도록 하겠다. 


    여기서 꼭 확인해야 할것은 11번째 줄이다. 

    response.setHeader에 "Content-Disposition"이라는 속성을 지정하는 부분이다. 여기서 "attachment"로 설정하고 있다. 이는 첨부파일을 의미한다.

    기존에 첨부파일을 전송할 때 패킷을 분석해보면, request의 Content-Disposition 부분은 "multipart-form/data"로 설정이 되어있다. 

    즉 Content-Disposition 속성을 이용하여 해당 패킷이 어떤 형식의 데이터인지 알 수 있다. 


    그 다음으로 fileName=\"" + URLEncoder.encode(originalFileName,"UTF-8")+"\";" 부분이 있다.

    이것은 첨부파일의 이름을 지정해주는 역할을 수행한다. 

    우리가 파일을 다운로드 받으려고 하면, 파일을 저장할 위치를 선택하는 창이 뜨고 파일의 이름이 지정되어있는데, 이 부분이 그 역할을 수행한다.

    잠시후 결과화면에서 무슨말인지 다시 확인하도록 하겠다.

    여기서 중요한것은 첨부파일을 다운로드 할때, UTF-8로 인코딩 하는 것을 봐야한다.

    파일명이 한글인데 UTF-8로 인코딩하지 않으면 파일명이 깨지게 된다. 

    인터넷에서 첨부파일을 다운받을 때, 아주 가끔 이상한 파일명으로 저장된 기억이 있을것이다. 그게 바로 UTF-8로 인코딩이 되지 않을때이다.


    인터넷에서 첨부파일을 다운로드 하는 소스는 쉽게 찾아볼 수 있다. 그런데 필자가 예전에 진행했던 프로젝트의 경우, 다운로드가 되지 않거나 파일명이 깨지는 경우가 많았다. 위 소스는 필자의 환경에서는 정확히 동작했지만, 혹시라도 문제가 생긴다면 이야기 해주기를 바란다.


    여기서 소스를 작성할 때, 띄어쓰기, 대소문자 구분 등도 주의해야한다. 

    잘 살펴보면 "attachment; fileName" 사이에 띄어쓰기가 되어있다. 필자의 경우, 저 띄어쓰기를 안해서 다운로드 기능이 안되기도 했었다.

    그 외에도 \"" 부분도 꼭 붙여줘야지 다운로드가 가능했었다. 

    기능을 구현할 때 그런 부분을 놓치지 않고 꼭 확인하길 바란다.


    그 외에 15~16번째 줄에서 보듯이 response를 정리하고 닫아주는 것을 잊지말자. 


    이제 실행시켜서 파일이 다운로드가 되는지 확인해보자.



    먼저, 첨부파일 이름을 클릭하면 위에서 보는것과 같이 파일을 저장할 수 있는 창이 뜬다.

    여기서 파일 이름이 1.PNG로 되어있는데, 위에서 Content-Disposition 속성에 fileName값을 지정한 것이 여기에 반영이 된 것이다. 

    만약 위에서 fileName을 다른 이름으로 한다면 첨부파일은 그 이름으로 저장된다. 


    그리고 파일을 저장하면 바탕화면에 1.PNG 파일이 새로 생성된다. (여기서 바탕화면에 1.PNG라는 이름으로 저장하였다.)


    이제 이클립스의 로그도 확인해보자.


    위에서 보는것과 같이 선택한 파일명의 정보를 정확히 조회하는것을 볼 수 있다.


    이렇게해서 첨부파일의 다운로드는 완료되었다. 


    ------------------------------------------------------------------------------------


    여기까지 해서 첨부파일의 다운로드에 대한 설명도 끝이 났습니다.


    지금 여기서 제가 설명한 방법은 파일 다운로드의 기초입니다. 실제로 여기서는 예외처리나 보안에 관련된 문제는 하나도 신경쓰지 않았습니다.


    또한 파일의 정보를 가져오는것도 프로젝트별로 다를 수 있습니다.


    실제로 제가 예전에 참여했던 프로젝트에서는 첨부파일을 서버에 물리적인 파일을 생성하지 않고 DB에 BLOB으로 저장한 경우도 있었고, 


    물리적인 파일을 파일관리 솔루션을 이용하여 관리한 경우도 있었습니다. 


    따라서 진행하는 프로젝트에서 파일을 어떻게 관리할 것인지에 따라서 세부적인 구현은 달라져야합니다.


    여기서 설명한 내용을 바탕으로 프로젝트의 상황에 맞춰서 파일의 데이터를 가져오면 됩니다.


    그리고 파일의 수정에 관련된 부분은 다음 포스팅에서 한번에 다룰 예정입니다. 


    다중첨부파일을 등록하는것과 파일을 수정하는것을 합쳐서 이야기 하겠습니다. 


    first.zip





    댓글

Designed by Tistory.