spring boot 3 이상 버전부터 multipart를 사용하는 방식이 2.7 버전이랑 조금 다르게 바뀌었다.
gradle
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
plugins {
id 'java'
id 'war'
id 'org.springframework.boot' version '3.1.6'
id 'io.spring.dependency-management' version '1.1.4'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
sourceCompatibility = '17'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
// 기본 라이브러리 ( spring initializer 로 추가하기)
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
// implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-websocket'
// querydsl 라이브러리
// 참고: https://velog.io/@juhyeon1114/Spring-QueryDsl-gradle-%EC%84%A4%EC%A0%95-Spring-boot-3.0-%EC%9D%B4%EC%83%81
implementation "com.querydsl:querydsl-jpa:${queryDslVersion}:jakarta" // 기존에는 그냥 jpa, 3 버전 이상은 :jakarta 추가
implementation "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta"
// apt(annotation processing tool), Q도메인 생성. 컴파일 시에만 사용됨
implementation "com.querydsl:querydsl-core"
implementation "com.querydsl:querydsl-sql"
// modelMapper: DTO 와 엔티티 간 변환 처리해주는 라이브러리
implementation "org.modelmapper:modelmapper:3.2.0"
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'junit:junit:4.13.1'
compileOnly("org.projectlombok:lombok", "com.querydsl:querydsl-apt:${queryDslVersion}:jakarta") // 롬복 외에도 querydsl 어노테이션 추가
developmentOnly 'org.springframework.boot:spring-boot-devtools'
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
// 기존 롬복 이외도, jakarta 와 querydsl 어노테이션 추가 (spring boot 3.0 이상은 jakarta 대신 jakarta 사용)
annotationProcessor(
"org.projectlombok:lombok",
"jakarta.persistence:jakarta.persistence-api",
"jakarta.annotation:jakarta.annotation-api",
"com.querydsl:querydsl-apt:${queryDslVersion}:jakarta"); // querydsl-apt:jpa -> querydsl-apt:jakarta
providedRuntime 'org.springframework.boot:spring-boot-starter-tomcat'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
}
tasks.named('bootBuildImage') {
builder = 'paketobuildpacks/builder-jammy-base:latest'
}
tasks.named('test') {
useJUnitPlatform()
}
// 프로젝트의 소스 코드 및 리소스 디렉토리 구성
// 개발 시 작성하는 java 파일의 위치 (src/main/java)와 Q도메인이 저장되는 위치(build/generated)를 명시
// 기존 파일과 Q도메인이 gradle 빌드 시 자동 컴파일 되게 함
sourceSets {
main.java.srcDirs = ["$projectDir/src/main/java", "$projectDir/build/generated"]
}
MultipartConfig.java
package com.pro06.config;
import jakarta.servlet.MultipartConfigElement;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.MultipartConfigFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.unit.DataSize;
import org.springframework.web.multipart.MultipartResolver;
import org.springframework.web.multipart.support.StandardServletMultipartResolver;
@Configuration
public class MultipartConfig {
@Bean
public MultipartResolver multipartResolver() {
return new StandardServletMultipartResolver();
}
@Bean
public MultipartConfigElement multipartConfigElement() {
MultipartConfigFactory factory = new MultipartConfigFactory();
factory.setLocation("c:\\shop");
factory.setMaxRequestSize(DataSize.ofMegabytes(100L));
factory.setMaxFileSize(DataSize.ofMegabytes(100L));
return factory.createMultipartConfig();
}
}
controller.java
@PostMapping("insert")
public String lectureInsertPro(MultipartHttpServletRequest req) throws Exception {
List<MultipartFile> files = new ArrayList<>();
Lecture lecture = new Lecture();
List<Video> fileList = new ArrayList<>();
for(int i=0; i<req.getFiles("files").size(); i++) {
files.add(req.getFiles("files").get(i));
}
Course course = new Course();
Integer cno = Integer.parseInt(req.getParameter("cno"));
course.setNo(cno);
lecture.setCourse(course);
lecture.setId(req.getParameter("id"));
lecture.setTitle(req.getParameter("title"));
lecture.setContent(req.getParameter("content"));
lecture.setKeyword(req.getParameter("keyword"));
for(int i=0; i<req.getFiles("files").size(); i++) {
log.info("req.getParameter(\"title\") : " + req.getParameter("title"));
log.info("req.getFile(\"files\") : " + req.getFile("files"));
log.info("req.getFile(\"files\").getOriginalFilename() : " +
req.getFiles("files").get(i).getOriginalFilename());
log.info("req.getFiles(\"files\").get("+i+").getBytes() : " +
req.getFiles("files").get(i).getBytes());
log.info("req.getFiles(\"files\").get("+i+").getName() : " +
req.getFiles("files").get(i).getName());
log.info("req.getFiles(\"files\").size() : " +
req.getFiles("files").size());
}
File folder = new File(uploadFolder);
if (!folder.exists()) folder.mkdirs();
log.info("-----------------------------------");
log.info(" 현재 프로젝트 홈 : " + req.getContextPath());
log.info(" 요청 URL : " + req.getServletPath());
log.info(" 파일 저장 경로 : " + uploadFolder);
if (files != null && files.size() > 0) {
for (MultipartFile file : files) {
String randomUUID = UUID.randomUUID().toString();
String OriginalFilename = file.getOriginalFilename();
String Extension = OriginalFilename.substring(OriginalFilename.lastIndexOf("."));
String saveFileName = randomUUID + Extension;
Video data = new Video();
data.setSavefolder(uploadFolder);
data.setOriginfile(file.getOriginalFilename());
data.setSavefile(saveFileName);
data.setFilesize(file.getSize());
Date today = new Date();
fileList.add(data);
File saveFile = new File(uploadFolder, saveFileName);
try {
file.transferTo(saveFile);
} catch (IllegalStateException | IOException e) {
e.printStackTrace();
}
}
}
LectureVO lectureVO = new LectureVO();
lectureVO.setLecture(lecture);
lectureVO.setFileList(fileList);
lectureVO.setCno(cno);
lectureService.LectureVoInsert(lectureVO);
return "redirect:/course/detail?no=" + cno;
}
thymeleaf, ajax
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>강의등록</title>
<script src="http://code.jquery.com/jquery-3.2.1.min.js"></script>
<style>
.file-list {
height: 200px;
width: 400px;
overflow: auto;
border: 1px solid #989898;
padding: 10px;
}
.file-list .filebox p {
font-size: 14px;
margin-top: 10px;
display: inline-block;
}
.file-list .filebox .delete i{
color: #ff5353;
margin-left: 5px;
cursor: pointer;
}
</style>
</head>
<body>
<h1>강의등록</h1>
<form method="post" enctype="multipart/form-data">
<div>
<label for="id">아이디</label>
<input type="hidden" name="cno" id="cno" th:value="${cno}" readonly>
<input type="text" name="id" id="id" th:value="${id}" readonly>
</div>
<div>
<label for="title">제목</label>
<input type="text" name="title" id="title" required>
</div>
<div>
<label for="keyword">키워드</label>
<input type="text" name="keyword" id="keyword" required>
</div>
<div>
<label for="content">내용</label>
<input type="text" name="content" id="content" required>
</div>
<div>
<label for="file">강의영상</label>
<input type="file" id="file" name="file" onchange="addFile(this);" multiple accept="video/mp4"/>
<div class="file-list"></div>
</div>
<div>
<input type="button" th:onclick="return submitForm()" value="등록">
<input type="reset" value="초기화">
</div>
</form>
<script th:inline="javascript">
let fileNo = 0;
let filesArr = new Array();
<![CDATA[*/
let cno = [[${cno}]];
]]>*/
function addFile(obj){
let maxFileCnt = 5;
let attFileCnt = document.querySelectorAll('.filebox').length;
let remainFileCnt = maxFileCnt - attFileCnt;
let curFileCnt = obj.files.length;
if (curFileCnt > remainFileCnt) {
alert("첨부파일은 최대 " + maxFileCnt + "개 까지 첨부 가능합니다.");
} else {
for (const file of obj.files) {
if (validation(file)) {
var reader = new FileReader();
reader.onload = function () {
filesArr.push(file);
};
reader.readAsDataURL(file);
console.log(file);
let htmlData = '';
htmlData += '<div id="file' + fileNo + '" class="filebox">';
htmlData += ' <p class="name">' + file.name + '</p>';
htmlData += ' <a class="delete"token operator">+ fileNo + ');"><i>삭제</i></a>';
htmlData += '</div>';
$('.file-list').append(htmlData);
fileNo++;
} else {
continue;
}
}
}
document.querySelector("input[type=file]").value = "";
}
function validation(obj){
const fileTypes = ['video/mp4'];
if (obj.name.length > 100) {
alert("파일명이 100자 이상인 파일은 제외되었습니다.");
return false;
} else if (obj.size > (100 * 1024 * 1024)) {
alert("최대 파일 용량인 100MB를 초과한 파일은 제외되었습니다.");
return false;
} else if (obj.name.lastIndexOf('.') == -1) {
alert("확장자가 없는 파일은 제외되었습니다.");
return false;
} else if (!fileTypes.includes(obj.type)) {
alert("첨부가 불가능한 파일은 제외되었습니다.");
return false;
} else {
return true;
}
}
function deleteFile(num) {
if(!confirm('해당 파일을 삭제하시겠습니까?')) {
return false;
}
document.querySelector("#file" + num).remove();
filesArr[num].is_delete = true;
}
function submitForm() {
let title = $("#title").val();
let keyword = $("#keyword").val();
let content = $("#content").val();
if(title === '') {
alert('제목을 입력하세요');
$('#title').focus();
return false;
}
if(keyword === '') {
alert('키워드를 입력하세요');
return false;
}
if(content === '') {
alert('설명을 입력하세요')
return false;
}
let attFileCnt = document.querySelectorAll('.filebox').length;
if(attFileCnt === 0) {
alert('동영상 파일이 최소 1개이상 있어야 합니다.')
return false;
}
let form = document.querySelector("form");
let formData = new FormData(form);
for (var i = 0; i < filesArr.length; i++) {
if (!filesArr[i].is_delete) {
formData.append("files", filesArr[i]);
}
}
$.ajax({
method: 'POST',
url: '/lecture/insert',
data: formData,
async: false,
cache: false,
enctype: 'multipart/form-data',
processData: false,
contentType: false,
success: function () {
console.log('강의 업로드 성공');
location.href = '/course/detail?no=' + cno;
},
error: function () {
alert('강의 업로드 실패');
return false;
}
})
}
</script>
</body>
</html>