API

[API] 다중 파일 업로드 & 다운로드 (Ver. Part)

letsDoDev 2024. 3. 24. 22:53

[준비]

IDE : 인텔리제이

java : java version "21.0.2" 2024-01-16 LTS

DB : MySQL 8

View : thymeleaf (거의 사용 안 함)

기타 : PostMan , 스프링 이니셜라이저 사용 (Dependecy는 아래 build.gradle 코드 확인)

build : gradle

 

 

[project 구조]

[ application.yml ]

server:
  port: 8080
spring:
  datasource:
    url: "jdbc:mysql://localhost/files"
    username: "root"
    password: "각자 root 사용자 비밀번호"
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: create # 처음에는 create 나중에는 update로 변경
    properties:
      hibernate:
        show_sql: true
        format_sql: true
        dialect: org.hibernate.dialect.MySQL8Dialect

# view location 뷰리졸버 설정
view:
  spring:
    thymeleaf:
      suffix: .html

# encoding korean
encoding:
  spring:
    http:
      encoding:
        charset: UTF-8
    servlet:
      filter:
        character:
          encoding:
            enabled: true
            force-request: true
            force-response: true
            encoding: UTF-8
            force: true

 

[build.gradle]

plugins {
	id 'java'
	id 'org.springframework.boot' version '3.2.2'
	id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.fileupload'
version = '0.0.1-SNAPSHOT'

java {
	sourceCompatibility = '21'
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
}

repositories {
	mavenCentral()
}

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
	implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
	implementation 'org.springframework.boot:spring-boot-starter-web'
	compileOnly 'org.projectlombok:lombok'
	runtimeOnly 'com.h2database:h2'
	runtimeOnly 'com.mysql:mysql-connector-j'
	annotationProcessor 'org.projectlombok:lombok'
	testImplementation 'org.springframework.boot:spring-boot-starter-test'

	// 파일 다운로드를 위해 추가한 의존성
	implementation 'commons-io:commons-io:2.7'
}

tasks.named('test') {
	useJUnitPlatform()
}

 

<다중 파일 업로드 + 다운로드>

- Files.java ( 엔티티)

package com.fileupload.part.domain;

import com.fileupload.part.dto.FilesDto;
import jakarta.persistence.*;
import jakarta.servlet.http.Part;
import lombok.*;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;
import java.util.List;

@ToString
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Files {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name="id")
    private Long id;

    @Column(name = "name")
    private String name;

    @Column(name = "path")
    private String path;

    @Column(name = "domain_id")
    private Long domainId; //todo 여러 테이블에서 이 첨부파일 테이블을 함께 사용할 경우 비참조로 임의로 넣어줘야함.

    @Column(name = "domain")
    private String domain; //todo 어떤 게시물 종류에 첨부파일들인지

    @Column(name = "deleteTF")
    @ColumnDefault("0") // default
    private boolean deleteTF; // todo 1, 0 --> 0이면 미삭제 // 1이면 삭제 default 0임.

    @CreationTimestamp
    @Column(name = "upload_date")
    private LocalDateTime uploadDate; //todo 저장된 날짜.

    @Column(name = "size")
    private Long size;

    @Builder
    public Files(String name, String path, Long domainId, String domain, Long size) {
        this.name = name;
        this.path = path;
        this.domainId = domainId;
        this.domain = domain;
        this.size = size;
    }
    
    //todo dto를 entity로 바꿔주는 메소드
    public static Files toEntity(FilesDto filesDto){
        return Files.builder()
                .name(filesDto.getName())
                .path(filesDto.getPath())
                .domainId(filesDto.getDomainId())
                .domain(filesDto.getDomain())
                .size(filesDto.getSize())
                .build();
    }
}

 

- FilesDto.java

package com.fileupload.part.dto;

import jakarta.servlet.http.Part;
import lombok.Getter;
import lombok.NoArgsConstructor;

//todo requestDto 안 쓰는 파일
@Getter
@NoArgsConstructor
public class FilesDto {

    String name;
    String path;
    Long domainId;
    String domain;
    Long size;
}

 

- FilesResponseDto.java

package com.fileupload.part.dto;

import com.fileupload.part.domain.Files;
import jakarta.persistence.Column;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.ColumnDefault;
import org.hibernate.annotations.CreationTimestamp;

import java.time.LocalDateTime;

@Getter
@NoArgsConstructor
public class FilesResponseDto {

    private Long id;
    private String name;
    private String path;
    private Long domainId;
    private String domain;
    private boolean deleteTF;
    private LocalDateTime uploadDate;
    private Long size;

    @Builder
    public FilesResponseDto(Long id, String name, String path, Long domainId, String domain, boolean deleteTF, LocalDateTime uploadDate, Long size) {
        this.id = id;
        this.name = name;
        this.path = path;
        this.domainId = domainId;
        this.domain = domain;
        this.deleteTF = deleteTF;
        this.uploadDate = uploadDate;
        this.size = size;
    }

    public FilesResponseDto toRespDto(Files files) {

        return FilesResponseDto.builder()
                .id(files.getId())
                .name(files.getName())
                .path(files.getPath())
                .domainId(files.getDomainId())
                .domain(files.getDomain())
                .deleteTF(files.isDeleteTF())
                .uploadDate(files.getUploadDate())
                .size(files.getSize())
                .build();
    }
}

 

- FileController.java

package com.fileupload.part.controller;

import com.fileupload.part.dto.FilesDto;
import com.fileupload.part.dto.FilesResponseDto;
import com.fileupload.part.service.FileService;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.MultipartConfig;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FileUtils;
import org.springframework.web.bind.annotation.*;

import java.io.File;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.sql.Blob;
import java.util.List;



//todo 파일업로드할 때 경로, 처리 사이즈 단위, ,저장 가능 파일 사이즈 , 요청할 수 파일 사이즈 최대
@MultipartConfig(
        //location = "C:/User/fakeuser/file" //todo 다른 os에서는 디폴트 경로가 c가 아닐 수도 있으므로 비권장
        fileSizeThreshold = 1024*1024, //todo 아마 1mb 일 거임 검색 ㄱㄱ
        maxFileSize = 1024*1024*50, //todo 50mb
        maxRequestSize = 1024*1024*50 //todo 50mb
)

@Slf4j
@RestController
@RequestMapping("/api/file")
public class FileController {

    private final FileService fileService;

    public FileController(FileService fileService) {
        this.fileService = fileService;
    }

    @PostMapping("upload")
    public List<FilesResponseDto> fileUpload(HttpServletRequest request) throws ServletException, IOException {
        //todo
        // 파일업로드하는 서비스 메소드 실행
        return fileService.fileUpload(request);
    }

    @GetMapping("download")
    public void fileDownload(@RequestParam("domain") String domain,
                             @RequestParam("domainId") String domainId,
                             @RequestParam("fileId") String fileId,
                             HttpServletResponse response) throws IOException {
        //todo
        //파일다운로드 하는 서비스 메소드 실행
        FilesResponseDto filesInfo = fileService.getFile(domain.toUpperCase(), Long.parseLong(domainId), Long.parseLong(fileId));
        try{
            byte[] files = FileUtils.readFileToByteArray(new File(filesInfo.getPath()));

            Blob blob = new javax.sql.rowset.serial.SerialBlob(files);

            response.setContentType("application/octet-stream");
            response.setContentLength(files.length);
            response.setHeader("Content-Disposition","attachment; fileName=\""+ URLEncoder.encode(filesInfo.getName(), StandardCharsets.UTF_8)+"\";");
            response.setHeader("Content-Transfer-Encoding","binary");
            log.info(response.toString());

            response.getOutputStream().write(files);
            response.getOutputStream().flush();

        } catch (Exception e) {
            log.error(e.getMessage());
            e.getStackTrace();
        }

        response.getOutputStream().close();
    }

}

 

- FileService.java(인터페이스)

package com.fileupload.part.service;

import com.fileupload.part.dto.FilesDto;
import com.fileupload.part.dto.FilesResponseDto;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;

import java.io.IOException;
import java.util.List;

public interface FileService {

    public List<FilesResponseDto> fileUpload(HttpServletRequest request) throws ServletException, IOException;

    // 전체 파일 리스트 출력
    public List<FilesResponseDto> getAllFile();

    // 파일 다운로드를 위해 파일 정보 가지고오는 메소드
    public FilesResponseDto getFile(String domain, Long domainId, Long fileId);
}

 

- FileServiceImpl.java (구현체)

package com.fileupload.part.service;

import com.fileupload.part.domain.Files;
import com.fileupload.part.domain.FilesRepository;
import com.fileupload.part.dto.FilesDto;
import com.fileupload.part.dto.FilesResponseDto;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.Part;
import jakarta.transaction.Transactional;
import lombok.ToString;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.UUID;
import java.util.stream.Collectors;

@Slf4j
@Service
public class FileServiceImpl implements FileService{

    private final FilesRepository filesRepository;

    public FileServiceImpl(FilesRepository filesRepository) {
        this.filesRepository = filesRepository;
    }

    //todo 다중, 단일 둘다 사용가능한 파일 업로드
    @Transactional
    @Override
    public List<FilesResponseDto> fileUpload(HttpServletRequest request) throws ServletException, IOException {

        List<FilesResponseDto> filesResponseDtoList = new ArrayList<>(); //todo 반환시켜줄 respDto 리스트 빈 객체

       Collection<Part> fileCollection = request.getParts(); //todo 폼의 모든 데이터들을 가지고 오겠다.
        for(Part filePart : fileCollection){
            if(!filePart.getName().equals("file")) continue;

            String fileName = filePart.getSubmittedFileName(); //todo 확장자명을 포함한 파일이름
            long fileSize = filePart.getSize();
            InputStream fis = filePart.getInputStream();

            // String realPath = request.getServletContext().getRealPath() //todo -> 자바가 지정한 파일이 저장될 경로 : 권장
            // String realPath = request.getServletContext().getRealPath("\\FileDownloads") //todo -> 자바가 지정한 파일이 저장될 경로 + "\\마지막 임의 경로 "

            String myPath = "C:\\Users\\wnsqh\\Desktop\\FileDownloads"; //todo 내가 지정한 임의 경로

            File folder = new File(myPath);
            if (!folder.exists()){
                try {
                    folder.mkdir(); //todo 파일을 저장할 경로에 디렉터리가 존재하지 않으면 생성하겠다.
                }catch (Exception e) {
                    e.getStackTrace();
                }
            }

            //todo 실제 이 파일의 파일명을 포함한 전체 경로가 db에 저장되어야 한다.
            String filePath = myPath + "\\" + UUID.randomUUID() + "-" +fileName; //todo 파일 중복으로 인한 덮어쓰기를 예방하기 위한 uuid 붙여줌.

            FileOutputStream fos = new FileOutputStream(filePath);

            byte[] buf = new byte[1024]; //todo 파일 처리할 byte 단위
            int size;
             //todo 단위로 연산한 파일 크기 읽어서 size 변수에 저장
            while ( (size = fis.read(buf)) != -1) {
                fos.write(buf, 0, size);
            }

            Files files = Files.builder()
                    .name(fileName)
                    .path(filePath)
                    .domainId(1L)
                    .domain("NOTICE")
                    .size(fileSize)
                    .build();

            files = filesRepository.save(files); //todo INSERT 쿼리문 DB에 전달 실행.
            FilesResponseDto filesResponseDto = new FilesResponseDto().toRespDto(files);

            filesResponseDtoList.add(filesResponseDto); //todo 마지막에 반환시켜줄 리스트에 하나씩 쌓는다 어떤 파일을 업로드 했는지 정보를

            fos.close();
            fis.close();

        } // end of for

        return  filesResponseDtoList;
    }

    // 파일 리스트 전체 출력
    @Override
    public List<FilesResponseDto> getAllFile() {
        if ( filesRepository.findAll().isEmpty() ) {
           throw new IllegalArgumentException("not existed any file");
        }
        FilesResponseDto filesResponseDto = new FilesResponseDto();

        return filesRepository.findAll().stream().map(files -> new FilesResponseDto().toRespDto(files))
                .collect(Collectors.toList());
    }

    @Override
    public FilesResponseDto getFile(String domain, Long domainId, Long fileId) {
        Files findFiles = filesRepository.findByDomainAndDomainIdAndId(domain, domainId, fileId).orElseThrow(() -> new IllegalArgumentException("not existed file"));
        log.info("findFiles -> {}", findFiles);
        return new FilesResponseDto().toRespDto(findFiles);
    }
}

 

- FileRepository.java

package com.fileupload.part.domain;

import org.springframework.data.jpa.repository.JpaRepository;

import java.io.File;
import java.util.List;
import java.util.Optional;

public interface FilesRepository extends JpaRepository<Files, Long> {

    // domain과 domainId로 파일 정보 가지고 오기
    Optional<Files> findByDomainAndDomainIdAndId(String domain, Long domainId, Long fileId);
}

 

- IndexController.java (그냥 파일리스트 출력하고 다운로드 버튼 클릭 시 다운로드 되는지 확인하기 위해 생성)

package com.fileupload.part;

import com.fileupload.part.service.FileService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;

@Controller
@RequiredArgsConstructor
@RequestMapping("api/filelist")
public class IndexController {

    private final FileService fileService;

    @GetMapping
    public ModelAndView fileListPage(ModelAndView mav){
        mav.setViewName("index");
        mav.addObject("fileList", fileService.getAllFile());

        return mav;
    }
}

 

- index.html (파일리스트 출력 + 다운로드 진행할 view)

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.w3.org/1999/html">
<head>
    <meta charset="UTF-8">
    <title>FileList & FileDownLoad</title>
</head>
<script type="text/javascript" src="http://code.jquery.com/jquery.min.js"></script>
<body>
    <div>
        <h1>
            파일 다운로드 리스트 페이지
        </h1>
    </div>
    <div>
        <ul>
            <li th:each = "file: ${fileList}">
                <span th:text="${file.name}"></span>
                <button
                        type="button"
                        th:value="${file.id}"
                        onclick="fileDownload(this.value)"
                > 파일 다운로드
                </button>
            </li>
        </ul>
    </div>

    <!--
     미리 등록되어 있는 파일들은 service 에서 domain: "NOTICE", domainId: "1"로 임의 지정되어 저장되어 있는 형태이며,
     따라서 다운로드 url도 domain: "NOTICE", domainId: "1" 로 고정되어 있다.
     실제로 사용할 때에는 게시글 종류에 맞는 식별자가 domain에 그 게시물 종류에서 원하는 게시물의 pk가 domainId에 들어가면 된다.
    -->
    <script type="text/javascript">
        function fileDownload(fileId) {
            const url = "/api/file/download?domain=notice&domainId=1&fileId="+fileId;
            window.location.href=url;
        }
    </script>

</body>
</html>

 

- 실행

 

-- #1 postman 으로 파일 업로드

 

-- #2 DB INSERT 확인

 

-- #3 실제 파일이 약속된 경로에 업로드 되었는지 확인

 

-- #4 리스트 출력 및 다운로드 동작 확인을 위한 VIEW 페이지 접근

-- #5 다운로드 확인 완료


  • 이렇게 해서 Part 데이터 타입을 이용한 다중 파일 업로드 다운로드 api를 완성하였다.
  • Part 데이터 타입 대신 MultiPartFile 데이터 타입을 이용하고 싶은 경우
  • Part 대신 MultiPartFIle 로 선언한 후 getPath 이런 메소드만 MultiPartFile 에 맞춰 수정해주면 된다.
  • 추후 MultiPartFile 버전도  작업해서 올릴 예정이다.