spring boot 3 multipart 사용법

오세훈·2023년 12월 4일
2

spring-boot

목록 보기
5/7

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;

// spring boot 3 이상부터 multipart를 사용할때 이걸 만들어줘야 한다.

@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

// spring boot 3 이상부터 이런식으로 사용해야 함
    // 강의 생성
    // 강의 정보, 강좌 번호, 파일 추출
    @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);      // cno 저장
        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());                  // name 속성값 출력
            log.info("req.getFiles(\"files\").size() : " +
                    req.getFiles("files").size());                            // 입력된 파일의 개수 출력
        }

        // 만약 저장 폴더가 없다면 생성
        File folder = new File(uploadFolder);
        if (!folder.exists()) folder.mkdirs();
        
        // log 출력
        log.info("-----------------------------------");
        log.info(" 현재 프로젝트 홈 : " + req.getContextPath());
        log.info(" 요청 URL : " + req.getServletPath());
        log.info(" 파일 저장 경로 : " + uploadFolder);
        
        // 첨부된 파일(MultipartFile)을 처리할 수 있습니다.
        if (files != null && files.size() > 0) {
            for (MultipartFile file : files) {
                // 파일 처리 로직 시작
                String randomUUID = UUID.randomUUID().toString();  // 파일 이름 중복 방지를 위한 랜덤 UUID 생성
                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) { // 추가된 파일이 없으면 submit 실패
            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로 form 데이터 전송
        $.ajax({
            method: 'POST',
            url: '/lecture/insert',
            data: formData,                 // 필수
            async: false,                   // 비동기를 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>
profile
자바 풀 스택 주니어 개발자

0개의 댓글