[Spring] File Upload & Download

2024. 7. 6. 23:08BE/Spring

0. 기존 포스팅

 

 

[Spring] 도서 등록, 검색

0. 출처 아직 배우고 있는 중이라 부정확한 정보가 포함되어 있을 수 있습니다! 주의하세요! 올인원 스프링 프레임워크 참고. 올인원 스프링 프레임워크 : 네이버 도서 네이버 도서 상세정보를

ramen4598.tistory.com

 

추가한 이유:

  1. java 라이선스 문제로 6.x 버전부터는 CommnonsMultipartResolver를 사용할 수 없다.
  2. 업로드할 파일의 정보를 저장하는 별도의 FileInfoDto를 정의해 업로드하는 방법이니깐 알아두자.

 


1. File Upload

 

가. pom.xml

<dependencies>
    ...
    <!-- File Upload -->
    <!-- https://mvnrepository.com/artifact/commons-fileupload/commons-fileupload -->
    <dependency>
        <groupId>commons-fileupload</groupId>
        <artifactId>commons-fileupload</artifactId>
        <version>${commons-fileupload-version}</version>
    </dependency>
</dependencies>

 

나. servlet-context.xml

<!-- servlet-context.xml -->
<beans:beans>
    ...
    <!-- fileUpload -->
    <!-- spring 5.x fileupload -->
    <!-- <beans:bean id="multipartResolver" class="org.springframework.web.multipart.commons.CommonsMultipartResolver">
        <beans:property name="defaultEncoding" value="UTF-8"/>
        <beans:property name="maxUploadSize" value="52428800"/> 
        <beans:property name="maxInMemorySize" value="1048576"/> 
    </beans:bean> -->

    <!-- 6.x -->
    <beans:bean id="multipartResolver" class="org.springframework.web.multipart.support.StandardServletMultipartResolver"/>
    <!-- // fileUpload -->
    ...
</beans:beans>
  • java 라이센스 문제로 6.x 버전부터는 CommnonsMultipartResolver를 사용할 수 없다. (javax naming issue)
  • 대신에 StandardServletMultipartResolver를 사용한다.

 

<!-- servlet-context.xml -->
<!-- 6.x -->
<beans:bean id="multipartResolver" class="org.springframework.web.multipart.support.StandardServletMultipartResolver"/>

id는 반드시 multipartResolver로 등록한다.

 


다. web.xml

    <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>
        <multipart-config>
            <max-file-size>52428800</max-file-size><!-- 파일 하나당 최대 파일 크기 -->
            <max-request-size>52428800</max-request-size><!-- 업로드 파일의 총 크기 -->
            <file-size-threshold>0</file-size-threshold><!-- 업로드하는 파일이 임시로 파일로 저장되지 않고 메모리에서 바로 스트림으로 전달되는 크기의 한계 1024 * 1024로 설정하면 1MB 이상인 경우에만 임시 파일로 저장. -->
            <!-- <location></location> --><!-- 임시저장 경로 -->
        </multipart-config>
    </servlet>

servlet이 Multipart를 처리하도록 설정.

 

 

설정 설명
<max-file-size> 업로드 하는 파일의 업로드 가능한 최대 파일 사이즈. 바이트 단위. -1로 설정 시 크기 제한 없음(default)
<max-request-size> 한번의 요청에 포함된 전체 Multipart 요청 데이터의 최대 크기. 바이트 단위. -1로 설정 시 크기 제한 없음(default)
<location> 임시 저장 경로
<file-size-threshold>0</file-size-threshold> 업로드하는 파일이 임시로 파일로 저장되지 않고 메모리에서 바로 스트림으로 전달되는 크기의 한계. 1024 * 1024로 설정하면 1MB 이상인 경우에만 임시 파일로 저장.

 

 

MultipartConfigElement (Servlet 6.1 API Documentation - Apache Tomcat 11.0.0-M22)

JavaScript is disabled on your browser. Constructor Summary Constructors Create a programmatic configuration from an annotation. Create a programmatic multi-part configuration with a specific location and defaults for the remaining configuration elements.

tomcat.apache.org

 


라. JSP

<form id="form-register" method="POST" enctype="multipart/form-data" action="">
        <input type="file" class="form-control border" id="upfile" name="upfile" multiple="multiple">
</form>
  • <input type="file" class="form-control border" id="upfile" name="upfile" multiple="multiple"> : multiple을 설정하면 여러 파일을 업로드할 수 있다.

 


마. Java

 

1) DTO

public class BoardDto {

    private int articleNo;
    private String userId;
    private String userName;
    private String subject;
    private String content;
    private int hit;
    private String registerTime;
    private List<FileInfoDto> fileInfos;
    // getter, setter method 생략
}
public class FileInfoDto {

    private String saveFolder;
    private String originalFile;
    private String saveFile;
    // gettter, setter method 생략
}
  • saveFolder : 저장된 파일 경로.
  • originalFile : 원래 파일 이름.
  • saveFile : 서버에 저장될 때 파일 이름.

 

2) Controller

// BoardController
@Controller
@RequestMapping("/article")
public class BoardController {

    @PostMapping("/write")
    public String write(@ModelAttribute("boardDto") BoardDto boardDto,
            @RequestParam(value = "upfile", required = false) MultipartFile[] files, HttpSession session,
            RedirectAttributes redirectAttributes) throws Exception {
        logger.debug("write boardDto : {}", boardDto);
        MemberDto memberDto = (MemberDto) session.getAttribute("userinfo");
        boardDto.setUserId(memberDto.getUserId());

    //        FileUpload 관련 설정.
        logger.debug("MultipartFile.isEmpty : {}", files[0].isEmpty());
        if (!files[0].isEmpty()) {
            String realPath = servletContext.getRealPath("/upload");
    //            String realPath = servletContext.getRealPath("/resources/img");
            String today = new SimpleDateFormat("yyMMdd").format(new Date());
            String saveFolder = realPath + File.separator + today;
            logger.debug("저장 폴더 : {}", saveFolder);
            File folder = new File(saveFolder);
            if (!folder.exists())
                folder.mkdirs();

            List<FileInfoDto> fileInfos = new ArrayList<FileInfoDto>();
            for (MultipartFile mfile : files) {
                FileInfoDto fileInfoDto = new FileInfoDto();
                String originalFileName = mfile.getOriginalFilename();
                if (!originalFileName.isEmpty()) {
                    String saveFileName = UUID.randomUUID().toString()
                            + originalFileName.substring(originalFileName.lastIndexOf('.'));
                    fileInfoDto.setSaveFolder(today);
                    fileInfoDto.setOriginalFile(originalFileName);
                    fileInfoDto.setSaveFile(saveFileName);
                    logger.debug("원본 파일 이름 : {}, 실제 저장 파일 이름 : {}", mfile.getOriginalFilename(), saveFileName);
                    mfile.transferTo(new File(folder, saveFileName));
                }
                fileInfos.add(fileInfoDto);
            }
            boardDto.setFileInfos(fileInfos);
        }

        boardService.writeArticle(boardDto);
        redirectAttributes.addAttribute("pgno", "1");
        redirectAttributes.addAttribute("key", "");
        redirectAttributes.addAttribute("word", "");
    //        redirectAttributes.addFlashAttribute("test", "1234");
        return "redirect:/article/list";
    }
  • @RequestParam(value = "upfile", required = false) MultipartFile[] files
    : input tag의 multiple="multiple"을 설정하면 받을 때 배열이나 리스트로 받아야 한다.
  • originalFileName : 원래 이름. 클라이언트에게 표시되는 이름.
  • saveFileName : 서버에 저장되는 이름. 서버 안에서 사용되는 이름.
  • mfile.transferTo(new File(folder, saveFileName)); : 실제 파일을 저장하는 부분. foldersaveFileName으로 저장.
  • redirectAttributes.addAttribute("pgno", "1"); : redirect 시 넘겨줄 정보.

 

3) DAO

// BoardDao
public void writeArticle(BoardDto boardDto) throws SQLException {
    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;
    try {
        conn = dataSource.getConnection();
        conn.setAutoCommit(false);
        StringBuilder sql = new StringBuilder();
        sql.append("insert into board (user_id, subject, content, hit, register_time) \n");
        sql.append("values (?, ?, ?, 0, now())");
        pstmt = conn.prepareStatement(sql.toString());
        pstmt.setString(1, boardDto.getUserId());
        pstmt.setString(2, boardDto.getSubject());
        pstmt.setString(3, boardDto.getContent());
        pstmt.executeUpdate();
        pstmt.close();

        List<FileInfoDto> fileInfos = boardDto.getFileInfos();
        if (fileInfos != null && !fileInfos.isEmpty()) {
            String lastNo = "select last_insert_id()";
            pstmt = conn.prepareStatement(lastNo);
            rs = pstmt.executeQuery();
            rs.next();
            int articleno = rs.getInt(1);
            pstmt.close();

            StringBuilder reigsterFile = new StringBuilder();
            reigsterFile.append("insert into file_info (article_no, save_folder, original_file, save_file) \n");
            reigsterFile.append("values");
            int size = fileInfos.size();
            for (int i = 0; i < size; i++) {
                reigsterFile.append("(?, ?, ?, ?)");
                if (i != fileInfos.size() - 1)
                    reigsterFile.append(",");
            }
            pstmt = conn.prepareStatement(reigsterFile.toString());
            int idx = 0;
            for (int i = 0; i < size; i++) {
                FileInfoDto fileInfo = fileInfos.get(i);
                pstmt.setInt(++idx, articleno);
                pstmt.setString(++idx, fileInfo.getSaveFolder());
                pstmt.setString(++idx, fileInfo.getOriginalFile());
                pstmt.setString(++idx, fileInfo.getSaveFile());
            }
            pstmt.executeUpdate();
        }
        conn.commit();
    } catch (SQLException e) {
        e.printStackTrace();
        conn.rollback();
        throw new SQLException();
    } finally {
        dbUtil.close(rs, pstmt, conn);
    }
}
  • last_insert_id() : 테이블의 마지막 auto_increment 값을 반환하는 함수.
  • insert into file_info (article_no, save_folder, original_file, save_file) values(?, ?, ?, ?), … : 파일을 저장하기 위한 별도의 테이블을 사용하고 있다.
  • conn.setAutoCommit(false);, conn.rollback();, conn.commit(); : 두 개의 SQL 중 하나라도 정상적으로 동작하지 않은 경우 rollback한다.

 

// BoardDao
@Override
public BoardDto getArticle(int articleNo) throws SQLException {
    BoardDto boardDto = null;
    Connection conn = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;
    try {
        conn = dataSource.getConnection();
        StringBuilder listArticle = new StringBuilder();
        listArticle.append(
                "select b.article_no, b.user_id, b.subject, b.content, b.hit, b.register_time, m.user_name \n");
        listArticle.append("from board b, members m \n");
        listArticle.append("where b.user_id = m.user_id \n");
        listArticle.append("and b.article_no = ? \n");
        pstmt = conn.prepareStatement(listArticle.toString());
        pstmt.setInt(1, articleNo);
        rs = pstmt.executeQuery();
        if (rs.next()) {
            boardDto = new BoardDto();
            boardDto.setArticleNo(rs.getInt("article_no"));
            boardDto.setUserId(rs.getString("user_id"));
            boardDto.setUserName(rs.getString("user_name"));
            boardDto.setSubject(rs.getString("subject"));
            boardDto.setContent(rs.getString("content"));
            boardDto.setHit(rs.getInt("hit"));
            boardDto.setRegisterTime(rs.getString("register_time"));

            PreparedStatement pstmt2 = null;
            ResultSet rs2 = null;
            try {
                StringBuilder fileInfos = new StringBuilder();
                fileInfos.append("select save_folder, original_file, save_file \n");
                fileInfos.append("from file_info \n");
                fileInfos.append("where article_no = ?");
                pstmt2 = conn.prepareStatement(fileInfos.toString());
                pstmt2.setInt(1, articleNo);
                rs2 = pstmt2.executeQuery();
                List<FileInfoDto> files = new ArrayList<FileInfoDto>();
                while (rs2.next()) {
                    FileInfoDto fileInfoDto = new FileInfoDto();
                    fileInfoDto.setSaveFolder(rs2.getString("save_folder"));
                    fileInfoDto.setOriginalFile(rs2.getString("original_file"));
                    fileInfoDto.setSaveFile(rs2.getString("save_file"));

                    files.add(fileInfoDto);
                }

                boardDto.setFileInfos(files);
            } finally {
                dbUtil.close(rs2, pstmt2);
            }
        }
    } finally {
        dbUtil.close(rs, pstmt, conn);
    }
    return boardDto;
}
  • 게시물을 읽을 때 사용하는 방법이다.
  • 게시물을 먼저 조회하고 성공하면 파일을 조회한다.

 


2. File Download

 

가. JSP

<!-- view.jsp -->
<li>${file.originalFile} 
<a href="#" class="filedown" sfolder="${file.saveFolder}" sfile="${file.saveFile}" ofile="${file.originalFile}">
    [다운로드]
</a>

<form id="downform" action="${root}/article/download">
  <input type="hidden" name="sfolder">
  <input type="hidden" name="ofile">
  <input type="hidden" name="sfile">
</form>

<script>
    let files = document.queryselectorall(".filedown");
    files.foreach(function(file) {
        file.addeventlistener("click", function() {
            document.queryselector("[name='sfolder']").value = file.getattribute("sfolder");
            document.queryselector("[name='ofile']").value = file.getattribute("ofile");
            document.queryselector("[name='sfile']").value = file.getattribute("sfile");
            document.queryselector("#downform").submit();
        });
    });
</script>

 


나. servlet-context.xml

<!-- servlet-context.xml -->
<beans:beans>
    ...
    <!-- fileDownload -->
    <beans:bean id="fileDownLoadView" class="com.company.board.view.FileDownLoadView"/>
    <!-- BeanNameViewResolver 설정. -->
    <beans:bean id="fileViewResolver" class="org.springframework.web.servlet.view.BeanNameViewResolver">
        <beans:property name="order" value="0" />
    </beans:bean> 
    <!-- // fileDownload -->
    ...
</beans:beans>
  • <beans:property name="order" value="0" /> : 0순위로 설정. InternalResourceViewResolver보다 우선적으로 처리한다.

 


다. FileDownLoadView

package com.company.board.view;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.util.Map;

import org.springframework.util.FileCopyUtils;
import org.springframework.web.servlet.view.AbstractView;

import jakarta.servlet.ServletContext;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

public class FileDownLoadView extends AbstractView {

    public FileDownLoadView() {
        setContentType("apllication/download; charset=UTF-8");
    }

    @Override
    protected void renderMergedOutputModel(Map<String, Object> model, HttpServletRequest request, HttpServletResponse response) throws Exception {
        ServletContext ctx = getServletContext();
        String realPath = ctx.getRealPath("/upload");

        Map<String, Object> fileInfo = (Map<String, Object>) model.get("downloadFile"); // 전송받은 모델(파일 정보)

        String saveFolder = (String) fileInfo.get("sfolder");    // 파일 경로
        String originalFile = (String) fileInfo.get("ofile");    // 원본 파일명(화면에 표시될 파일 이름)
        String saveFile = (String) fileInfo.get("sfile");        // 암호화된 파일명(실제 저장된 파일 이름)
        File file = new File(realPath + File.separator  + saveFolder, saveFile);

        response.setContentType(getContentType());
        response.setContentLength((int) file.length());

        String header = request.getHeader("User-Agent");
        boolean isIE = header.indexOf("MSIE") > -1 || header.indexOf("Trident") > -1;
        String fileName = null;
        // IE는 다르게 처리
        if (isIE) {
            fileName = URLEncoder.encode(originalFile, "UTF-8").replaceAll("\\+", "%20");
        } else {
            fileName = new String(originalFile.getBytes("UTF-8"), "ISO-8859-1");
        }
        response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\";");
        response.setHeader("Content-Transfer-Encoding", "binary");
        OutputStream out = response.getOutputStream();
        FileInputStream fis = null;
        try {
            fis = new FileInputStream(file);
            FileCopyUtils.copy(fis, out);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            if(fis != null) {
                try { 
                    fis.close(); 
                }catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        out.flush();
    }
}

 


라. Controller

@Controller
@RequestMapping("/article")
public class BoardController {

    @GetMapping("/download")
    public ModelAndView downloadFile(@RequestParam("sfolder") String sfolder, @RequestParam("ofile") String ofile,
            @RequestParam("sfile") String sfile) {
        Map<String, Object> fileInfo = new HashMap<String, Object>();
        fileInfo.put("sfolder", sfolder);
        fileInfo.put("ofile", ofile);
        fileInfo.put("sfile", sfile);
        return new ModelAndView("fileDownLoadView", "downloadFile", fileInfo);
    }
  • Map<String, Object> fileInfo = new HashMap<String, Object>();
    : 다운로드할 파일의 경로(sfolder), 원본 파일명(ofile), 저장된 파일명(sfile)를 담는 Map을 생성한다.
  • return new ModelAndView("fileDownLoadView", "downloadFile", fileInfo);
    : ModelAndView 객체를 반환한다.
    : View name은 "fileDownLoadView"
    : model name은 "downloadFile"
    : model Object는 앞서 만든 Map 객체
  • return new ModelAndView("fileDownLoadView", "downloadFile", fileInfo);
    : servlet-context.xml에서 등록한 FileDownLoadView로 가서 다운로드를 마무리짓는다.

 


3. Multipart

 

form에는 다양한 타입의 데이터가 존재할 수 있다. (예시 : 문자열, 업로드할 파일)

 

이렇게 하나의 request에 서버가 해석해야 하는 Content-type이 두 가지 이상 되는 경우가 생긴다.

 

서버는 이를 구분해서 해석하기 위해서 mulitpart type을 사용한다.

 

form의 attribute에 반드시 enctype="multipart/form-data를 추가해야 하고 method는 POST만 가능하다.

 


4. 공식 홈페이지

 

위의 설명과는 다른 방식으로 파일 업로드를 구현했다.

 

참고.

 

 

Getting Started | Uploading Files

To start a Spring Boot MVC application, you first need a starter. In this sample, spring-boot-starter-thymeleaf and spring-boot-starter-web are already added as dependencies. To upload files with Servlet containers, you need to register a MultipartConfigEl

spring.io

 


'BE > Spring' 카테고리의 다른 글

[MyBatis] MyBatis란?  (0) 2024.07.06
[Spring] Connection Pool  (0) 2024.07.06
[Spring] ControllerAdvice  (0) 2024.07.06
[Spring] Java Config  (0) 2024.07.06
[Spring] AOP  (0) 2024.07.06