참고글

[서블릿/JSP] Apache Commons FileUpload를 이용한 파일업로드 구현하기

 
필수 사전 지식



파일 업로드
파일 업로드는 어느 웹 어플리케이션이든 거의 필수적으로 필요한 기능입니다. 게시판에 첨부파일을 올린다거나 회원가입시 프로필 사진을 올린다거나 자신의 블로그에 동영상등을 올릴때에도 사용합니다. 그러나 파일업로드는 일반적인 text 데이터를 전송할때와는 다른 처리 과정이 필요합니다. 파일은 일반적인 text 데이터와는 다른 특성을 가지고 있기 때문입니다. 자세한 사항은 상단의 필수 사전 지식 포스팅 링크를 참고해주시기 바랍니다.



첨부파일과 일반 form 데이터의 차이점
브라우저에서 <form>을 통해 전송하는 일반적인 데이터는 단순한 문자열이므로 Servlet상에서 request.getParameter()메서드를 통해 구할 수 있지만 파일의 경우는 조금 특별한 방법을 통해서만 얻을 수 있습니다.

첨부파일은 단순한 문자열이 아니고 0과 1로 이루어진 바이너리 데이터이기에 브라우저에서 전송시에 multipart/form-data라는 형식을 이용해 전송해야 합니다. 우리가 평소에 보내는 단순한 값들은 application/x-www-form-urlencoded라는 형식으로 전송하게 되고 이러한 값들은 request.getParameter()메서드를 통해서 쉽게 얻어낼 수 있습니다.

<application/x-www-form-urlencoded형식은 아래 HTTP 요청 메시지의 Body 부분에 보이는것과 같이 name=value&... 형식의 text데이터를 의미합니다.
POST http://localhost:8080/jspServletStudy/login HTTP/1.1
Host: localhost:8080
Content-Length: 24
Content-Type: application/x-www-form-urlencoded
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like  Gecko) Chrome/75.0.3770.142 Safari/537.36
Accept:  text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3
Accept-Encoding: gzip, deflate, br
 
id=dololak&password=1234
cs


그러나 첨부파일이 담긴 multipart/form-data형식의 데이터는 바이너리 데이터이므로 request.getInputStream()을 호출해 입력 스트림을 얻어서 데이터를 가공해서 파일의 바이너리 데이터를 추출해야 하는 복잡한 과정이 필요합니다. multipart/form-data가 어떻게 생겼는지 잠시 설명하자면 아래와 같은 구조를 가지고 있습니다. (자세한 내용은 글 상단의 링크를 참고 부탁드립니다.)
POST http://localhost:8080/jspServletStudy/fileUpload HTTP/1.1
Host: localhost:8080
Connection: keep-alive
Content-Length: 2049708
Upgrade-Insecure-Requests: 1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryg3IbmadDo87Bmh2R
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like  Gecko) Chrome/75.0.3770.142 Safari/537.36
 
------WebKitFormBoundaryg3IbmadDo87Bmh2R
Content-Disposition: form-data; name="file1"; filename="메이븐.pptx"
Content-Type: application/vnd.openxmlformats-officedocument.presentationml.presentation
 
ppt 파일에 대한바이너리 데이터...
cs

기존에 Servlet 2.5까지는 Servlet 스펙상에서 파일 업로드를 위한 API가 따로 제공되지 않아 직접 앞서 설명했던 복잡한 과정을 구현하거나 Aapache Commons FileUpload같은 라이브러리를 사용해야만 했습니다.

그러나 Servlet3.0 버전부터 파일업로드 기능을 쉽게 구현하기 위한 Part API가 추가되었습니다.




Part API를 이용한 파일 업로드 구현 예제
이 글에서는 Servlet 3.0부터 제공되는 Part API를 이용해 파일업로드를 구현해 보도록 하겠습니다.
완성된 내용은 최종 예제소스 링크를 참고해 주세요. 완성된 예제 프로젝트를 이클립스의 Dynamic Web Projectimport했을때의 구조는 다음과 같습니다.

  • uploadPage.jsp - 사용자가 파일을 업로드할 때 사용할 업로드 페이지입니다.
  • FileUploadServlet.java - uploadPage.jsp에서 파일을 전송했을때 실질적으로 파일을 서버에 저장해주는 역할을 수행하는 서블릿입니다.
  • WEB-INF/web.xml - 클라이언트로부터 multipart/form-data형식의 데이터를 전송받게 될 FileUploadServlet에 대해 업로드 파일 크기 제한이나 임시 파일 저장 디렉터리 경로 등을 설정합니다.



업로드 파일 저장 디렉터리 생성
클라이언트에서 업로드한 파일을 저장해둘 경로에 디렉터리를 생성해 주어야 합니다.
저의 경우에는 알기 쉽게 C 드라이브 하위에 C:\attaches 경로로 폴더를 생성해 두었습니다. 실제 Product 환경에서 구동되는 서버라면 보안정책에 따라서 접근을 제어할 수 있는 디렉터리로 설정하시기 바라비다.




파일업로드 JSP 페이지 작업하기
파일 업로드 관련 서버쪽 작업에 앞서 사용자가 파일을 등록할 수 있는 업로드 페이지를 만들어 볼 것입니다. 간단히 게시판이라 생각하고 업로드되는 파일이 어떤 파일인지를 설명하는 INPUT TEXT 태그와 파일첨부 버튼 그리고 전송 버튼을 만들어보겠습니다.

HTML에서는 기본적으로 파일을 첨부할 수 있는 API를 제공하고 있는데, <INPUT>태그의 type속성값을 FILE로 지정해주는 것입니다. 여기서 주의해야 할 점은 <form>태그의 method속성이 post여야 하며, enctype속성이 multipart/form-data여야 한다는 점 입니다. 
<%@ page language="java" contentType="text/html; charset=UTF-8"
    pageEncoding="UTF-8"%>
<!DOCTYPE html>
<html>
<head>
<title>첨부 업로드</title>
</head>
<body>
      <form action="<%= application.getContextPath() %>/fileUpload"  method="post" enctype="multipart/form-data">
            파일 설명 : <input type="text" name="description"><br>
            파일1 : <input type="file" name="file1"><br>
            파일2 : <input type="file" name="file2"><br>
            <input type="submit" value="전송">
      </form>
</body>
</html>
cs


enctype을 multipart로 변경하지 않으면?
만약 enctype속성을 변경해주지 않는 경우 글 초반에 설명한 것과 같이 application/x-www-form-urlencoded라는 형식의 인코딩을 통해 데이터를 전송하게 되고 여러가지 타입의 데이터를 동시에 전송할 수 없기 때문에 텍스트 데이터만 전송하게 됩니다. 따라서 파일의 실질적인 내용은 전송되지 않고 파일명 또는 사용자 컴퓨터상의 파일 경로만을 전송하게 됩니다. 단순히 파일명만 전송하는지 파일명을 포함한의 full path를 전송할지의 동작은 브라우저별로 차이가 있기때문에 서버에서는 이를 고려하여 업로드 처리 해주어야 합니다.

 

 

 

 



파일 업로드를 처리할 서블릿 작성
uploadPage.jsp에서 사용자가 파일을 전송 했을때 실질적으로 업로드 처리를 수행할 FileUploadServlet 클래스를 작성해 보도록 하겠습니다. 먼저 코드는 다음과 같습니다.
package servlet;
 
import java.io.File;
import java.io.IOException;
import java.io.PrintWriter;
import java.util.Collection;
import javax.servlet.ServletException;
import javax.servlet.annotation.MultipartConfig;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.Part;
 
//@MultipartConfig(
//        location = "C:\\attaches",
//        maxFileSize = -1,
//        maxRequestSize = -1,
//        fileSizeThreshold = 1024)
@WebServlet("/fileUpload")
public class FileUploadServlet extends HttpServlet {
 
 
    private static final String CHARSET = "utf-8";
    private static final String ATTACHES_DIR = "C:\\attaches";
 
 
    @Override
    protected void doPost(HttpServletRequest request,  HttpServletResponse response)
            throws ServletException, IOException {
 
 
        response.setContentType("text/html; charset=UTF-8");
        request.setCharacterEncoding(CHARSET);
        PrintWriter out = response.getWriter();
        String contentType = request.getContentType();
 
 
        if (contentType != null &&  contentType.toLowerCase().startsWith("multipart/")) {
            // getParts()를 통해 Body에 넘어온 데이터들을 각각의  Part로 쪼개어 리턴
            Collection<Part> parts = request.getParts();
 
 
            for (Part part : parts) {
                System.out.printf("파라미터 명 : %s, contentType :  %s,  size : %d bytes \n", part.getName(),
                        part.getContentType(), part.getSize());
 
 
                if  (part.getHeader("Content-Disposition").contains("filename=")) {
                    String fileName =  extractFileName(part.getHeader("Content-Disposition"));
                    
                    if (part.getSize() > 0) {
                        System.out.printf("업로드 파일 명 : %s  \n", fileName);
                        part.write(ATTACHES_DIR + File.separator  + fileName);
                        part.delete();
                    }
                } else {
                    String formValue =  request.getParameter(part.getName());
                    System.out.printf("name : %s, value : %s  \n", part.getName(), formValue);
                }
            }
            
            out.println("<h1>업로드 완료</h1>");
        } else {
            out.println("<h1>enctype이 multipart/form-data가  아님</h1>");
        }
    }
 
 
 
 
    private String extractFileName(String partHeader) {
        for (String cd : partHeader.split(";")) {
            if (cd.trim().startsWith("filename")) {
                String fileName = cd.substring(cd.indexOf("="+  1).trim().replace("\"""");;
                int index = fileName.lastIndexOf(File.separator);
                return fileName.substring(index + 1);
            }
        }
        return null;
    }
}
cs




코드 분석
업로드 처리시 사용한 코드를 분석해보도록 하겠습니다. 코스 분석에 앞서 Part의 메서드에 대해서 알아보도록 하겠습니다.


Part
메서드
설명
public InputStream getInputStream() throws IOException;
Part에 대한 InputStream을 리턴합니다. 직접 데이터를 추출할때 사용합니다.
public String getContentType()
Content-Type을 리턴합니다.
public String getName()
파라미터명을 리턴합니다.
public String getSubmittedFileName()
업로드한 파일명을 리턴합니다. servlet 3.1부터 사용 가능합니다.
public long getSize();
파일의 크기를 byte단위로 리턴합니다.

public void write(String fileName) throws IOException
임시저장되어 있는 파일 데이터를 복사하여 fileName에 지정한 경로로 저장합니다. 임시저장 되어있는 파일데이터가 메모리상에 있든 디스크에 있든 신경쓰지 않아도 됩니다.

public void delete() throws IOException
임시저장된 파일 데이터를 제거합니다. HTTP요청이 처리되고 나면 자동으로 제거되지만 그 전에 메모리나 디스크 자원을 아끼고 싶다면 수동으로 제거할 수 있습니다.
public String getHeader(String name)
Part로부터 지정한 name헤더값을 리턴합니다.



HTTP 요청객체인 HttpServletRequest 객체로부터 Content-Type 헤더값을 꺼내어 전송데이터의 타입이 파일을 전송할 수 있는 multipart/form-data인지 확인합니다.
String contentType = request.getContentType();
 
if (contentType != null &&  contentType.toLowerCase().startsWith("multipart/")) {
cs




request.getParts()메서드를 통해 여러개의 PartCollection에 담아 리턴합니다.
multipart/form-data형식의 데이터는 boundary를 기준으로 여러개의 부분(Part)으로 나누어지는데 이를 Part객체로 만들어 리턴합니다. 만약 파일 데이터가 하나라면 Collection에는 하나의 Part만 있으며, 여러개인 경우 여러개의 Part가 리턴될것입니다. 추가적으로 파일데이터 뿐만 아니라 일반 input text로 입력한 데이터까지 전송한 경우에는 이또한 Collection에 Part로 만들어진 상태로 리턴합니다.
Collection<Part> parts = request.getParts();
cs




Collection에 담긴 각각의 Part에 대해 for문을 통해 업로드 처리를 시작합니다.
for (Part part : parts) {
...
}
cs




각 Part에는 Content-Disposition 속성이 있습니다. 만약 이 속성에 filename= 부분이 있으면 파일 데이터가 담긴 Part인것으로 판단하여 파일 업로드를 진행합니다. 아닌 경우 일반 form 입력 데이터인것으로 판단하여 로그만 출력합니다.
if  (part.getHeader("Content-Disposition").contains("filename=")) {
...
else {
    String formValue = request.getParameter(part.getName());
    System.out.printf("name : %s, value : %s \n",  part.getName(), formValue);
}
cs




Part에 있는 Content-Disposition 속성값으로부터 파일명을 추출하여 part.write()를 통해 임시저장된 파일 데이터를 복사하여 지정한 경로에 저장합니다.
이후에 part.delete()를 통해 저장되어있던 임시저장 데이터를 제거합니다. 임시저장 데이터는 이후에 설명할 max-file-size설정에 의해 메모리에 저장될수도 디스크에 저장될수도 있습니다. 추가적으로 part.delete()가 호출되지 않더라도 언젠가는 임시저장 데이터가 제거되며, 톰캣의 경우 HTTP 요청이 처리되고 응답을 출력하는 시점에 제거시킵니다.
String fileName = extractFileName(part.getHeader("Content-Disposition"));
        
if (part.getSize() > 0) {
    System.out.printf("업로드 파일 명 : %s \n", fileName);
    part.write(ATTACHES_DIR + File.separator + fileName);
    part.delete();
}
cs




Part에 있는 Content-Disposition 속성값을 partHeader 변수로 받아 파일명을 추출합니다. String의 여러가지 메서드를 이용하여 추출하는 내용입니다.
private String extractFileName(String partHeader) {
    for (String cd : partHeader.split(";")) {
        if (cd.trim().startsWith("filename")) {
            String fileName = cd.substring(cd.indexOf("="+  1).trim().replace("\"""");
            int index = fileName.lastIndexOf(File.separator);
            return fileName.substring(index + 1);
        }
    }
    return null;
}
cs




브라우저별 파일명 추출
앞서 설명한 extractFileName()메서드에서 File.separator라는 상수값을 사용했습니다. File.separator는 운영체제별로 다른 파일경로 구분자를 담고 있습니다. 예를 들어 Windows 환경에서는 각 디렉터리를 구분할 때 \를 사용하며 linux계열은 /를 사용합니다. 따라서 업로드한 파일 경로의 마지막 separator뒤에 오는 값이 실제 파일명이라 할 수 있습니다. (예: C:\app\test.txt 의 경우 마지막 \ 뒤인 text.txt가 실제 파일명)
int index = fileName.lastIndexOf(File.separator);
return fileName.substring(index + 1);
cs

크롬의 경우 파일 전송시 단순히 파일명만을 넘겨주는 반면 Internet Explorer의 경우 사용자의 드라이버 경로(ex: C:\)부터 절대경로 전부를 리턴해주기 때문에 파일명을 추출할때 이를 고려해 주어야 합니다.



 

 




임시저장 경로 및 파일 크기 제한 설정
파일이 Part#write()메서드를 통해서 디스크에 저장되기 전에 데이터를 임시파일로 잠시 저장해두는데, 어느곳에 저장할지의 경로와 업로드 파일크기를 제한 설정을 해주어야 합니다. 설정 방법은 web.xml과 @MultipartConfig어노테이션을 이용한 방법 두가지로 나뉘어 지는데 각각 알아보도록 하겠습니다.

web.xml 설정
WEB-INF/web.xml을 다음과 같이 설정합니다. 파일업로드를 처리해주는 서블릿인 FileUploadServlet에 대해서 설정을 해주도록 합니다.
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xmlns="http://java.sun.com/xml/ns/javaee"
     xsi:schemaLocation="http://java.sun.com/xml/ns/javaee  http://java.sun.com/xml/ns/javaee/web-app_3_0.xsd"
     id="WebApp_ID" version="3.0">
    
    <servlet>
        <servlet-name>FileUploadServlet</servlet-name>
        <servlet-class>servlet.FileUploadServlet</servlet-class>
        <multipart-config>
            <location>C:\attaches</location>
            <max-file-size>-1</max-file-size>
            <max-request-size>-1</max-request-size>
            <file-size-threshold>1024</file-size-threshold>
        </multipart-config>
    </servlet>
    
    <servlet-mapping>
        <servlet-name>FileUploadServlet</servlet-name>
        <url-pattern>/fileUpload</url-pattern>
    </servlet-mapping>
    
</web-app>
cs


태그 설명
  • <multipart-config> - multipart/form-data 로 전송된 데이터에 대해 Part API로 처리할 수 있도록 하는 설정입니다.
  • <location> - 업로드한 파일이 임시적으로 저장될 경로를 지정합니다.
  • <max-file-size> - 업로드 가능한 최대 sizebyte단위로 지정합니다. -1인 경우 제한을 두지 않습니다. request.getParts()호출시 파일 크기가 이 값을 넘는 경우 IllegalStateException가 발생합니다.
  • <max-request-size> - 전체적인 multipart 데이터 최대 sizebyte단위로 지정합니다. -1인 경우 제한을 두지 않습니다.
  • <file-size-threshold> - 임시파일로 저장 여부를 결정할 데이터 크기를 byte로 지정합니다. 이 값을 넘지 않으면 업로드된 데이터를 메모리상에 가지고 있지만 이값을 넘는 경우 <location>으로 지정한 경로에 임시파일로 저장합니다. 메모리상에 저장된 파일 데이터는 언젠가 제거되겠지만 크기가 큰 파일을 메모리상에 적재하게 되면 서버에 부하를 줄 수 있으므로 적당한 크기를 지정해 곧바로 임시파일로 저장하는것이 좋습니다.




@MultipartConfig 어노테이션을 이용한 설정
web.xml을 이용하지 않고 @MultipartConfig어노테이션을 이용하는 경우 아래와 같이 업로드를 처리하는 서블릿에 설정을 해주도록 합니다. 속성은 web.xml의 태그설명을 참고하도록 합니다.
@MultipartConfig(
        location = "C:\\attaches",
        maxFileSize = -1,
        maxRequestSize = -1,
        fileSizeThreshold = 1024)
@WebServlet("/fileUpload")
public class FileUploadServlet extends HttpServlet {
cs





파일 업로드 크기 제한 넘겼을시 처리
max-file-size설정을 통해 업로드 파일 크기 제한을 걸어둔 경우 업로드 제한을 넘기게 되면 request.getParts()호출시 다음과 같이 IllegalStateException예외가 발생하게 됩니다.
심각: Servlet.service() for servlet [FileUploadServlet] in context with path [/jspServletStudy] threw exception
java.lang.IllegalStateException: org.apache.tomcat.util.http.fileupload.FileUploadBase$FileSizeLimitExceededException: The field file1 exceeds its maximum permitted size of 500 bytes.
    at org.apache.catalina.connector.Request.parseParts(Request.java:2891)
    at org.apache.catalina.connector.Request.getParts(Request.java:2730)
    at org.apache.catalina.connector.RequestFacade.getParts(RequestFacade.java:1083)
cs

따라서 getParts() 메서드를 호출하는 부분에서 try-catch를 통해 IllegalStateException예외를 핸들링하여 크기 제한 초과시에 행동을 처리할 수 있습니다.
Collection<Part> parts = null;
 
 
try {
    parts = request.getParts();
}catch (IllegalStateException e) {
    //업로드 크기 제한을 넘겼을 경우의 처리
}
cs






테스트
지금까지 구현한 내용을 테스트 해보도록 하겠습니다.
서버를 기동하고  http://localhost:8080/jspServletStudy/uploadPage.jsp 로 접근합니다.


앞서작성한 jsp에서 input태그의 속성을 file로 해두었기 때문에 파일 선택 버튼이 자동으로 생성되었습니다.
파일1과 파일2에 파일선택 버튼을 눌러 파일을 첨부하고 전송버튼을 눌러 업로드합니다. 이미지 파일과 ppt 파일을 하나 첨부하였습니다.

파일 업로드가 완료됩니다.


저장소로 지정한 경로에 업로드됩니다.





마치며
이번 글에서는 단순히 파일을 서버측 드라이브에 저장하고 끝이 나는 프로세스로 구현하였지만 실무에서는 Database에 첨부파일 관련 테이블을 생성해두고 첨부파일에 대한 정보들을 저장해두고 사용할 것입니다.



다음글
블로그 이미지

도로락

IT, 프로그래밍, 컴퓨터 활용 정보 등을 위한 블로그

,