파일 처리 구현과 관련하여 Servlet 라이브러리 버전때문에 골치가 아팠다. 기존의 javax
라이브러리를 이용한 레퍼런스들로는 jakarta
라이브러리를 적용한 프로젝트에 그대로 적용할 수가 없기 때문이다. 이를 해결하기 위한 방법으로는 다음과 같은 방법이 존재한다.
javax
라이브러리를 추가하여 진행- 내부 라이브러리(
jakarta
)를 이용하여 진행
편리한 건 1번이지만, 피드백을 받은 부분도 있고 해서 2번을 택했다. 검색해보니 많진 않지만 레퍼런스들이 있길래 참고해서 적용하였고, 덕분에 파일 업로드 및 다운로드 구현에 성공했다.
항목 | 버전 |
---|---|
Java | 11 |
Tomcat | 10.1.18 |
Servlet(jakarta) | 5.0 |
Gradle | 7.5.1 |
MySQL | 8.0.35 |
src
├─main
│ ├─java
│ │ └─com
│ │ └─study
│ │ ├─controller
│ │ │ ├─FileUploadController.java
│ │ │ └─FileController.java
│ │ ├─dao
│ │ │ └─FileDAO.java
│ │ ├─util
│ │ │ ├─DBUtil.java
│ │ │ └─FileUtil.java
│ │ └─vo
│ │ └─FileVO.java
│ │
│ ├─resources
│ │ └─config.properties
│ │
│ └─webapp
│ ├─WEB-INF
│ │
│ ├─fileUpload.jsp
│ └─fileDownload.jsp
│
└─test
Field | Type | Null | Key | Default | Extra |
---|---|---|---|---|---|
id | int unsigned | NO | PRI | auto_increment | |
original_name | varchar(100) | NO | |||
saved_name | varchar(255) | YES | |||
saved_path | varchar(255) | NO | |||
ext | varchar(10) | NO | |||
size | bigint | NO | 0 |
id
: 파일의 고유한 식별자original_name
: 파일의 원본 이름saved_name
: 서버에 저장되는 파일 이름saved_path
: 서버에 파일이 저장되는 경로ext
: 파일 확장자(extension)size
: 파일 크기dependencies {
// Lombok(롬복)
compileOnly 'org.projectlombok:lombok:1.18.30'
annotationProcessor 'org.projectlombok:lombok:1.18.30'
// Jakarta Servlet API(서블릿 API)
compileOnly 'jakarta.servlet:jakarta.servlet-api:5.0.0'
// MySQL Connector/J(MySQL 드라이버)
implementation group: 'mysql', name: 'mysql-connector-java', version: '8.0.28'
// Apache Commons IO
implementation 'commons-io:commons-io:2.11.0'
}
라이브러리 | 설명 |
---|---|
Lombok | • Java의 보일러플레이트 코드를 줄여주는 라이브러리 • 어노테이션을 사용하여 기능 사용 |
Jakarta Servlet API | • Java 웹 애플리케이션을 개발할 때 필요한 서블릿과 관련된 클래스 및 인터페이스를 정의한 API • HTTP 요청 처리 및 응답 생성 |
MySQL Connector/J | • MySQL DB와의 연결을 위한 JDBC 드라이버 • Java 애플리케이션에서 MySQL DB 접속 및 SQL 쿼리 실행 |
Apache Commons IO | • Java의 I/O 작업을 쉽게 처리할 수 있는 유틸리티 라이브러리 • 파일 처리 관련 작업 수행 시 사용 |
프로젝트에서 사용되는 설정 값을 저장하는 파일에 업로드할 파일의 디렉토리 경로를 저장해준다. 이 때, 경로는 파일 시스템에서의 절대 경로로 지정해야 한다.
# 업로드 할 디렉토리 경로
saved.directory=D:/test/file/upload
💡 설정 파일에서 디렉토리 경로를 설정하는 이유
이유 설명 유연성 프로그램을 쉽게 확장하거나 유지보수할 수 있음 환경 분리 서로 다른 환경에서 다른 디렉토리에 파일을 저장할 수 있도록
환경별로 설정을 분리할 수 있음보안 및 관리 설정 파일은 소스 코드와 분리되어 있어 보안상의 이점을 제공하며,
중앙에서 설정을 관리할 수 있음재사용성 설정 파일을 사용하면 동일한 설정을 여러 프로젝트나 모듈에서
재사용할 수 있음
파일의 여러 속성을 저장하고 해당 정보를 관리하기 위한 VO 클래스를 작성해준다.
package com.study.vo;
import lombok.*;
/**
* 파일 정보를 담는 Value Object 클래스
*/
@Setter
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class FileVO {
private long id;
private String originalName;
private String savedName;
private String savedPath;
private String ext;
private long size;
}
DB 연결과 관련된 유틸리티 메소드를 제공하는 클래스를 작성해준다. 필수는 아니며, 원한다면 DAO 클래스에서 직접 작성해도 된다.
package com.study.util;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
/**
* 데이터베이스 연결과 관련된 유틸리티 메소드를 제공하는 클래스
*/
public class DBUtil {
static final String DRIVER = "com.mysql.jdbc.Driver";
static final String DB_URL = "jdbc:mysql://localhost:13306/test";
static final String USER = "tester";
static final String PASS = "1234";
// JDBC 드라이버 로드
static {
try {
Class.forName(DRIVER);
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
/**
* 데이터베이스 연결을 생성하고 반환
*
* @return 데이터베이스와의 연결
* @throws SQLException 데이터베이스 연결 생성 중 오류 발생 시
*/
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(DB_URL, USER, PASS);
}
/**
* 주어진 자원들을 안전하게 종료
* 주로 데이터베이스 연결, statement, resultset 등을 닫는 데 사용
*
* @param resources 닫을 자원들
*/
public static void release(AutoCloseable... resources) {
for (AutoCloseable resource : resources) {
if (resource != null) {
try {
resource.close();
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
}
파일을 업로드하기 위한 HTML form을 포함한 JSP 파일을 작성해준다. 사용자가 선택한 파일은 서블릿으로 전송되어 서버 측에서 처리된다.
<%@ page contentType="text/html;charset=UTF-8" language="java" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>파일첨부 테스트</title>
</head>
<body>
<form action="/upload.do" method="post" enctype="multipart/form-data">
<input type="file" name="upload_file" />
<input type="submit" value="저장" />
</form>
</body>
</html>
파일 업로드를 처리하는 서블릿 클래스를 작성해준다. HTTP POST 요청 및 클라이언트로부터 받은 파일을 서버에 업로드하고 처리한다.
package com.study.controller;
import com.study.dao.FileDAO;
import com.study.util.FileUtil;
import com.study.vo.FileVO;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.IOException;
/**
* 파일 업로드 HTTP 요청을 처리하는 서블릿
*/
@MultipartConfig(
fileSizeThreshold = 1024 * 1024,
maxFileSize = 1024 * 1024 * 5,
maxRequestSize = 1024 * 1024 * 10
)
@WebServlet("/upload.do")
public class FileUploadController extends HttpServlet {
/**
* HTTP GET 요청 처리
* 모든 GET 요청을 processRequest 메소드로 전달
*
* @param request 클라이언트의 요청 정보를 담고 있는 HttpServletRequest 객체
* @param response 클라이언트에게 응답을 보내는 HttpServletResponse 객체
* @throws ServletException 요청 처리 중 발생하는 예외
* @throws IOException 요청 또는 응답 처리 중 입출력 예외가 발생할 경우
*/
@Override
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
processRequest(request, response);
}
/**
* HTTP POST 요청을 처리
* 모든 POST 요청을 processRequest 메소드로 전달
*
* @param request 클라이언트의 요청 정보를 담고 있는 HttpServletRequest 객체
* @param response 클라이언트에게 응답을 보내는 HttpServletResponse 객체
* @throws ServletException 요청 처리 중 발생하는 예외
* @throws IOException 요청 또는 응답 처리 중 입출력 예외가 발생할 경우
*/
@Override
protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
processRequest(request, response);
}
/**
* 사용자 요청에 따라 파일을 처리 및 저장
*
* @param request 사용자 요청 정보를 담고 있는 HttpServletRequest 객체
* @param response 사용자에게 응답을 보내기 위한 HttpServletResponse 객체
* @throws ServletException 요청 처리 중 발생하는 예외
* @throws IOException 입출력 처리 중 발생하는 예외
*/
private void processRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
// 파일을 업로드할 디렉토리 경로 가져오기
File uploadDirectory = FileUtil.getUploadDirectory();
// 파일 업로드 및 파일 정보 반환
FileVO file = handleUploadFile(request, uploadDirectory);
// 파일 정보를 DB에 저장
if (!file.isEmpty()) {
FileDAO fileDAO = new FileDAO();
fileDAO.insertFile(file);
}
response.sendRedirect("/upload.do");
}
}
@MultipartConfig
속성 | 설명 | 기본값 | 비고 |
---|---|---|---|
fileSizeThreshold | • 파일이 메모리에 저장되기 시작하는 임계치 지정 • 업로드한 파일이 임계치보다 클 경우 디스크에 저장 | 0 | |
maxFileSize | 각 업로드 파일의 최대 허용 크기 지정 | -1 (제한 X) | 초과 시 파일이 업로드 되지 않으며 예외 발생 |
maxRequestSize | 전체 요청의 최대 크기 지정 | -1 (제한 X) | 초과 시 파일이 업로드 되지 않으며 예외 발생 |
파일 데이터와 관련된 DB 연산을 수행하는 DAO 클래스를 작성해준다.
package com.study.dao;
import com.study.util.DBUtil;
import com.study.vo.FileVO;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
/**
* 파일 관련 데이터 액세스 객체
* - 파일 데이터와 관련된 데이터베이스 연산 수행
*/
public class FileDAO {
/**
* 업로드한 파일을 데이터베이스에 저장
*
* @param file 저장할 파일
*/
public void insertFile(FileVO file){
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
conn = DBUtil.getConnection();
String sql = "INSERT INTO tb_file (" +
"original_name, saved_name, saved_path, ext, size)" +
"VALUES (?, ?, ?, ?, ?);";
pstmt = conn.prepareStatement(sql);
pstmt.setString(1, file.getOriginalName());
pstmt.setString(2, file.getSavedName());
pstmt.setString(3, file.getSavedPath());
pstmt.setString(4, file.getExt());
pstmt.setLong(5, file.getSize());
pstmt.executeUpdate();
} catch (SQLException e) {
e.printStackTrace();
} finally {
DBUtil.release(rs, pstmt, conn);
}
}
}
파일 업로드 및 관리에 필요한 유틸리티 메소드를 제공하는 클래스를 작성해준다. 필수는 아니나, 코드의 재사용성과 가독성을 높이기 위해 작성해주었다. 원한다면 서블릿 클래스에서 직접 작성해도 된다.
package com.study.util;
import com.study.vo.FileVO;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
import org.apache.commons.io.FilenameUtils;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.util.*;
public class FileUtil {
/**
* 설정 파일에서 업로드 디렉토리 경로를 로드
*
* @return 업로드 디렉토리 경로
*/
public static String getSavedFileDirectoryPath() {
Properties properties = new Properties();
try (InputStream input =
FileUtil.class.getClassLoader()
.getResourceAsStream("config.properties")
) {
properties.load(input);
return properties.getProperty("saved.directory");
} catch (IOException ex) {
ex.printStackTrace();
return null; // 또는 기본값을 반환
}
}
/**
* 업로드 디렉토리가 존재하는지 확인하고, 존재하지 않으면 생성
*
* @return 파일 업로드 디렉토리
*/
public static File getUploadDirectory() {
String directoryPath = getSavedFileDirectoryPath();
// 파일을 upload 할 directory 생성
File uploadDirectory = new File(directoryPath);
if (!uploadDirectory.exists()) {
uploadDirectory.mkdirs();
}
return uploadDirectory;
}
private FileVO handleUploadFile(
HttpServletRequest request,
File uploadDir
) throws IOException, ServletException {
// 파일 처리
Part filePart = request.getPart("upload_file");
return saveFileAndGetVO(part, uploadDir);
}
private FileVO saveFileAndGetVO(Part part, File uploadDir) throws IOException {
// 파일 원본 이름, 확장자, 저장 이름 추출
String originalName = part.getSubmittedFileName();
String extension = FilenameUtils.getExtension(originalName);
String savedName = UUID.randomUUID() + "_" + originalName;
File file = new File(uploadDir, savedName);
// 파일 저장
try (InputStream inputStream = part.getInputStream();
OutputStream outputStream = Files.newOutputStream(file.toPath())) {
inputStream.transferTo(outputStream);
}
// 파일 메타데이터 반환
return FileVO.builder()
.originalName(originalName)
.savedName(savedName)
.savedPath(file.getAbsolutePath())
.ext(extension)
.size(part.getSize())
.build();
}
}
여기서는 앞서 [2-2. 단건 처리]에서 작성한 코드와 비교하여 변경된 부분 위주로 설명한다.
여러 개의 파일을 동시에 업로드할 수 있도록 소스코드를 수정한다.
✅ 변경 전
...
<form action="/upload.do" method="post" enctype="multipart/form-data">
<input type="file" name="upload_file" />
<input type="submit" value="저장" />
</form>
...
✅ 변경 후
...
<form action="/upload.do" method="post" enctype="multipart/form-data">
<% for (int index = 0; index < 3; index++) { %>
<input type="file" name="upload_file" />
<% } %>
<input type="submit" value="저장" />
</form>
...
여러 파일을 한 번에 처리할 수 있도록 소스코드를 수정해준다.
✅ 변경 전
public class FileUploadController extends HttpServlet {
...
private void processRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
...
// 파일 업로드 및 파일 정보 반환
FileVO file = handleUploadFile(request, uploadDirectory);
// 파일 정보를 DB에 저장
if (!file.isEmpty()) {
FileDAO fileDAO = new FileDAO();
fileDAO.insertFile(file);
}
...
}
}
✅ 변경 후
...
import java.util.List;
public class FileUploadController extends HttpServlet {
...
private void processRequest(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
...
List<FileVO> files = handleUploadFiles(request, uploadDirectory);
if (!files.isEmpty()) {
FileDAO fileDAO = new FileDAO();
fileDAO.insertFiles(files);
}
...
}
}
파일 정보 목록을 DB에 한 번에 저장할 수 있도록 소스코드를 변경해준다. 배치를 이용하여 INSERT
쿼리를 실행하도록 한다.
✅ 변경 전
public class FileDAO {
public void insertFile(FileVO file){
...
try {
...
pstmt.setString(1, file.getOriginalName());
pstmt.setString(2, file.getSavedName());
pstmt.setString(3, file.getSavedPath());
pstmt.setString(4, file.getExt());
pstmt.setLong(5, file.getSize());
pstmt.executeUpdate();
...
✅ 변경 후
...
import java.util.List;
public class FileDAO {
public void insertFiles(List<FileVO> files){
...
try {
...
for (FileVO file : files) {
pstmt.setString(1, file.getOriginalName());
pstmt.setString(2, file.getSavedName());
pstmt.setString(3, file.getSavedPath());
pstmt.setString(4, file.getExt());
pstmt.setLong(5, file.getSize());
pstmt.addBatch();
}
pstmt.executeBatch(); // 배치 실행
...
여러 파일을 한 번에 처리할 수 있도록 소스코드를 변경해준다.
✅ 변경 전
public class FileUtil {
...
private FileVO handleUploadFile(
HttpServletRequest request,
File uploadDir
) throws IOException, ServletException {
// 파일 처리
Part filePart = request.getPart("upload_file");
return saveFileAndGetVO(part, uploadDir);
}
...
}
✅ 변경 후
public class FileUtil {
...
private List<FileVO> handleUploadFiles(
HttpServletRequest request,
File uploadDir
) throws IOException, ServletException {
// 요청의 파트(파일)를 반복 처리
Collection<Part> parts = request.getParts();
List<FileVO> files = new ArrayList<>();
for (Part part : parts) {
if (part.getName().equals("upload_file") && part.getSize() > 0) { // 파일인 경우
files.add(saveFileAndGetVO(part, uploadDir));
}
}
return files;
}
...
}
파일이 제대로 업로드 되는지 확인한다. 아래는 한창 테스트 진행 중에 캡쳐한 내용이므로, 서버 스토리지와 DB 간 내용이 다르게 나타날 수 있다.
웹 브라우저에 접속하여 파일을 업로드한다.
config.properties
에서 설정해주었던 경로로 이동하여 파일이 업로드 되었는지 확인한다.
DB에 접속하여 파일 정보가 저장되었는지 확인한다.
$ mysql -u tester -p
mysql> USE test;
mysql> SELECT * FROM tb_file ORDER BY file_id DESC;
+-------+-----------------------------------------+-------------------------------------------------------+--------------------+----+-----+
|file_id|original_name |saved_name |saved_path |ext |size |
+-------+-----------------------------------------+-------------------------------------------------------+--------------------+----+-----+
|77 |ssds |1709029005893_ssds |D:/test/file/upload |xlsx|14430|
|76 |KakaoTalk_20220526_002113807 |1709029005853_KakaoTalk_20220526_002113807 |D:/test/file/upload |png |16161|
|75 |KakaoTalk_20220526_002113807 |1709019640310_KakaoTalk_20220526_002113807 |D:/test/file/upload |png |16161|
|74 |유럽숙소비 |1709014510842_유럽숙소비 |D:/test/file/upload |xlsx|16211|
|73 |스터디 공부인증 |1709013278702_스터디 공부인증 |D:/test/file/upload |xlsx|21995|
|72 |KakaoTalk_20220526_002113807 |1709011208756_KakaoTalk_20220526_002113807 |D:/test/file/upload |png |16161|
|... |... |... |... |... |... |
+-------+-----------------------------------------+-------------------------------------------------------+--------------------+----+-----+
📖 참고