semtax의 개발 일지

스프링부트로 게시판 만들기 11 : 파일 업로드/다운로드 구현 본문

개발/Java

스프링부트로 게시판 만들기 11 : 파일 업로드/다운로드 구현

semtax 2020. 5. 2. 16:07
반응형

 

개요

 

이번 포스팅에서는, 파일 업로드 및 다운로드 기능을 구현하도록 하겠다.

 

또한, 구현 하기전에 실제로 파일 업로드/다운로드가 어떻게 이루어지는지에 대해서도 알아보도록 하겠다.

 

 

웹에서는 파일 업로드 / 다운로드를 어떻게 하는걸까?

 

 

기본적으로, HTTP 요청/응답 프로토콜의 생김새는 대충 아래와 같이 생겼다.

POST /cgi-bin/process.cgi HTTP/1.1
User-Agent: Mozilla/4.0 (compatible; MSIE5.01; Windows NT)
Host: www.tutorialspoint.com
Content-Type: application/x-www-form-urlencoded
Content-Length: length
Accept-Language: en-us
Accept-Encoding: gzip, deflate
Connection: Keep-Alive

licenseID=string&content=string&/paramsXML=string

위에서 보면 알겠지만, HTTP는 (일반적으로) 요청을 보내고 응답을 바로 받는, TCP나 TLS처럼 상태를 유지하지 않는(Stateless) 프로토콜이다.

 

 

따라서 파일의 메타정보와 실제 파일 컨텐츠를 한번에 보내줘야 한다. 하지만, 위의 예제만 보면 한번에 여러 HTTP 요청 또는 응답 을 묶어서 보내는 기능은 따로 없어보인다.

 

 

그렇다면 이러한 문제를 어떻게 해결 할 수 있을까?

 

 

사실, 여러가지 방법이 있기는 하지만, 가장 전형적인 방법으로 HTTP 프로토콜에서는 이러한 문제를 해결하기 위해 HTTP 인코딩(Encoding) 타입 중에 "multipart/form-data" 라는 인코딩 타입을 지원 해준다.

 

멀티파트 인코딩(multipart/form-data encoding)은 보통 아래와 같은 모양으로 이루어져 있다.

POST / HTTP/1.1
Host: localhost:8000
User-Agent: Mozilla/5.0 (X11; Ubuntu; Linux i686; rv:29.0) Gecko/20100101 Firefox/29.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Cookie: __atuvc=34%7C7; permanent=0; _gitlab_session=226ad8a0be43681acf38c2fab9497240; __profilin=p%3Dt; request_method=GET
Connection: keep-alive
Content-Type: multipart/form-data; boundary=---------------------------9051914041544843365972754266
Content-Length: 554

-----------------------------9051914041544843365972754266
Content-Disposition: form-data; name="text"

text default
-----------------------------9051914041544843365972754266
Content-Disposition: form-data; name="file1"; filename="a.txt"
Content-Type: text/plain

Content of a.txt.

-----------------------------9051914041544843365972754266
Content-Disposition: form-data; name="file2"; filename="a.html"
Content-Type: text/html

<!DOCTYPE html><title>Content of a.html.</title>

-----------------------------9051914041544843365972754266--

 

위에서 볼 수 있다시피, 멀티파트 인코딩은 아래와 같은 구성요소로 이루어져 있다.

 

구성요소 설명
boundary=-----------------------------xxxxxxx HTTP 요청안에 합쳐진 여러개의 데이터를 각각 구분하기 위한 구분자
Content-Disposition: form-data; name="text" filename="origina.jpg" HTTP 요청의 일부라는 사실을 알려주기 위한 헤더, 해당 헤더에 각 파트에 해당하는 이름을 name 파라미터를 이용해서 식별할 수 있게 해준다.
Content-Type HTTP 요청 안에 있는 각각의 데이터의 타입을 알려주는 헤더

 

이때, 여러개의 HTTP 데이터를 묶어서 보내므로, 각 HTTP 요청의 끝을 구분하기 위해 바운더리가 필요하다는 사실을 금방 유추 할 수 있다.

 

 

그리고, Content-Disposition 의 name도 HTML의 Form 태그를 사용 해보았다면, Form태그에서 작성했던 name이 그대로 들어간다는 사실도 알 수 있다. 또한, filename 속성을 통해 원래 전송하려고 했던 파일의 이름도 적어서 줄 수 있다.

 

 

(여담으로 HTTP응답을 보낼 때, Content-Disposition을, attachment 로 지정하고, filename을 적어주는 경우에, 만약 해당링크를 클릭하는 경우 브라우저에서 "파일을 받으시겠습니까? 또는 다운로드 하기" 라는 버튼을 통해서 파일을 다운 받을 수 있다.)

 

 

마지막으로, Content-Type도 약간 의아해 할 수는 있으나, Form 태그를 자세히 본다면 type을 이용해서 구분 할 수 있다는 사실을 알 수 있다.

 

 

구현

 

 

먼저, 스프링 부트에서 제공하는 파일 업로드/다운로드 기능을 사용하기 위해서는 먼저, 아래와 같이 설정을 해주어야 한다.

spring.servlet.multipart.enabled=true

spring.servlet.multipart.file-size-threshold=2KB

spring.servlet.multipart.max-file-size=200MB

spring.servlet.multipart.max-request-size=215MB

file.upload-dir=/Users/semtax/Desktop/uploads

 

 

다음으로, 아래와 같이 @ConfigurationProperties 를 이용해서, 위에서 작성한 프로퍼티를 등록해준다.

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "file")
public class FileStorageProperties {

    private String uploadDir;

    public String getUploadDir() {
        return uploadDir;
    }

    public void setUploadDir(String uploadDir) {
        this.uploadDir = uploadDir;
    }

}

 

 

일단, 파일 업로드/다운로드 API를 작성하기 전에 아래와 같이 파일 업로드/다운로드 정보를 저장하는 Entity와 DTO를 작성해주자.

import javax.persistence.*;

@Entity
public class UploadFile {

    @Id @GeneratedValue
    @Column(name = "upload_file_id")
    private Long Id;

    @Column
    private String fileName;

    @Column
    private String fileDownloadUri;

    @Column
    private String fileType;

    @Column
    private Long size;

    @ManyToOne
    @JoinColumn(name = "post_id")
    private Post post;

    public Long getId() {
        return Id;
    }

    public void setId(Long id) {
        Id = id;
    }

    public String getFileName() {
        return fileName;
    }

    public void setFileName(String fileName) {
        this.fileName = fileName;
    }

    public Post getPost() {
        return post;
    }

    public void setPost(Post post) {
        this.post = post;
    }



    public String getFileDownloadUri() {
        return fileDownloadUri;
    }

    public void setFileDownloadUri(String fileDownloadUri) {
        this.fileDownloadUri = fileDownloadUri;
    }

    public String getFileType() {
        return fileType;
    }

    public void setFileType(String fileType) {
        this.fileType = fileType;
    }

    public Long getSize() {
        return size;
    }

    public void setSize(Long size) {
        this.size = size;
    }
}
package com.semtax.application.dto;

public class UploadFileResponseDTO {
    private String fileName;
    private String fileDownloadUri;
    private String fileType;
    private long size;

    public UploadFileResponseDTO(String fileName, String fileDownloadUri, String fileType, long size) {
        this.fileName = fileName;
        this.fileDownloadUri = fileDownloadUri;
        this.fileType = fileType;
        this.size = size;
    }

    public String getFileName() {
        return fileName;
    }

    public void setFileName(String fileName) {
        this.fileName = fileName;
    }

    public String getFileDownloadUri() {
        return fileDownloadUri;
    }

    public void setFileDownloadUri(String fileDownloadUri) {
        this.fileDownloadUri = fileDownloadUri;
    }

    public String getFileType() {
        return fileType;
    }

    public void setFileType(String fileType) {
        this.fileType = fileType;
    }

    public long getSize() {
        return size;
    }

    public void setSize(long size) {
        this.size = size;
    }
}

 

 

다음으로, 실제로 파일 업로드 정보를 저장할 Repository를 아래와 같이 작성해주자.

import com.semtax.application.entity.Post;
import com.semtax.application.entity.UploadFile;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface UploadFileInfoRepository extends JpaRepository<UploadFile, Long> {

    @Query("select f from UploadFile f where post_id = :id")
    List<UploadFile> findAllByPostId(Long id);
}

 

위의 repository 코드와 같은 경우, UploadFile 엔티티에 이미 post_id가 들어가 있고,

post_id 이외에는 따로 post에서 필요한 내용이 없으므로 굳이 fetch 조인을 해서 가지고 오지 않아도 위와 같이 작성을 해주면 된다.

 

 

이제, 실제로 파일을 읽고 쓰는 서비스를 작성해보도록 하자.

 

아래와 같이 작성해주면 된다.

import com.semtax.application.config.FileStorageProperties;
import com.semtax.application.services.exceptions.FileStorageException;
import com.semtax.application.services.exceptions.MyFileNotFoundException;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.core.io.UrlResource;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;
import java.net.MalformedURLException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;

@Service
public class FileStorageService {

    private final Path fileStorageLocation;


    @Autowired
    public FileStorageService(FileStorageProperties fileStorageProperties) {
        this.fileStorageLocation = Paths.get(fileStorageProperties.getUploadDir())
            .toAbsolutePath().normalize();

        try{
            Files.createDirectories(this.fileStorageLocation);
        }catch(Exception ex){
            throw new FileStorageException("Could not create the directory where the uploaded files will be stored.", ex);
        }
    }

    public String storeFile(MultipartFile file){
        String fileName = StringUtils.cleanPath(file.getOriginalFilename());

        try{
            if(fileName.contains("..")) {
                throw new FileStorageException("Sorry! Filename contains invalid path sequence " + fileName);
            }

            Path targetLocation = this.fileStorageLocation.resolve(fileName);
            Files.copy(file.getInputStream(), targetLocation, StandardCopyOption.REPLACE_EXISTING);

            return fileName;
        }catch(IOException ex){
            throw new FileStorageException("Could not store file " + fileName + ". Please try again!", ex);
        }
    }

    public Resource loadFileAsResource(String fileName) {
        try{
            Path filePath = this.fileStorageLocation.resolve(fileName).normalize();
            Resource resource = new UrlResource(filePath.toUri());

            if(resource.exists()){
                return resource;
            }else{
                throw new MyFileNotFoundException("File not found" + fileName);
            }
        }catch(MalformedURLException ex){
            throw new MyFileNotFoundException("File not found " + fileName, ex);
        }
    }

}

 

 

또, 파일 업로드/다운로드를 실패했을때를 대비해서, 예외 상황발생시 던져줄 예외 클래스도 아래와 같이 작성해주자.

// 파일 못찾을때

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;

@ResponseStatus(HttpStatus.NOT_FOUND)
public class MyFileNotFoundException extends RuntimeException{

    public MyFileNotFoundException(String message) {
        super(message);
    }

    public MyFileNotFoundException(String message, Throwable cause) {
        super(message, cause);
    }
}



// 나머지 경우

public class FileStorageException extends RuntimeException {
    public FileStorageException(String message) {
        super(message);
    }

    public FileStorageException(String message, Throwable cause) {
        super(message, cause);
    }
}

 

 

이제 실제 파일을 업로드/다운로드 할 컨트롤러 코드를 아래와 같이 작성 해주자.

import com.semtax.application.dto.UploadFileResponseDTO;
import com.semtax.application.entity.Post;
import com.semtax.application.entity.UploadFile;
import com.semtax.application.repository.PostRepository;
import com.semtax.application.repository.UploadFileInfoRepository;
import com.semtax.application.services.FileStorageService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;


import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

@RestController
public class FileController {

    private static final Logger logger = LoggerFactory.getLogger(FileController.class);

    @Autowired
    private FileStorageService fileStorageService;

    @Autowired
    private UploadFileInfoRepository uploadFileInfoRepository;


    @CrossOrigin(origins = "*", allowedHeaders = "*")
    @PostMapping("/post/{id}/uploadFile")
    public UploadFileResponseDTO uploadFile(@PathVariable Long id, @RequestParam("file") MultipartFile file){
        String fileName = fileStorageService.storeFile(file);

        String fileDownloadUri = ServletUriComponentsBuilder.fromCurrentContextPath()
                .path("/post")
                .path("/downloadFile/")
                .path(fileName)
                .toUriString();

        UploadFile uploadFile = new UploadFile();
        uploadFile.setFileName(fileName);
        uploadFile.setFileDownloadUri(fileDownloadUri);
        uploadFile.setFileType(file.getContentType());
        uploadFile.setSize(file.getSize());

        Post post = new Post();
        post.setId(id);

        uploadFile.setPost(post);

        uploadFileInfoRepository.save(uploadFile);

        return new UploadFileResponseDTO(fileName, fileDownloadUri,
                file.getContentType(), file.getSize());
    }


    @CrossOrigin(origins = "*", allowedHeaders = "*")
    @PostMapping("/post/{id}/uploadMultipleFiles")
    public List<UploadFileResponseDTO> uploadMultipleFiles(@PathVariable Long id, @RequestParam("files") MultipartFile[] files) {
        return Arrays.asList(files)
                .stream()
                .map(file -> uploadFile(id, file))
                .collect(Collectors.toList());
    }

    @CrossOrigin(origins = "*", allowedHeaders = "*")
    @GetMapping("/post/{id}/files")
    public List<UploadFileResponseDTO> downloadFilesInfoInPost(@PathVariable Long id) {

        List<UploadFile> uploadFiles = uploadFileInfoRepository.findAllByPostId(id);

        return uploadFiles.stream().map(fileInfo -> new UploadFileResponseDTO(
            fileInfo.getFileName(),fileInfo.getFileDownloadUri(),
            fileInfo.getFileType(),fileInfo.getSize())
        ).collect(Collectors.toList());
    }


    @CrossOrigin(origins = "*", allowedHeaders = "*")
    @GetMapping("/post/downloadFile/{fileName:.+}")
    public ResponseEntity<Resource> downloadFile(@PathVariable String fileName, HttpServletRequest request) {
        Resource resource = fileStorageService.loadFileAsResource(fileName);

        String contentType = null;
        try{
            contentType = request.getServletContext().getMimeType(resource.getFile().getAbsolutePath());
        }catch(IOException ex) {
            logger.info("Could not determine file type.");
        }

        if (contentType == null){
            contentType = "application/octet-stream";
        }

        return ResponseEntity.ok()
                .contentType(MediaType.parseMediaType(contentType))
                .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + resource.getFilename() +"\"")
                .body(resource);
    }
}

 

위에서 나온 컨트롤러는 대략적으로 "파일 업로드","파일 다운로드 경로 얻어오기", "파일 다운로드" 로 나눌 수 있다.

 

 

먼저 uploadFile 함수와 같은 경우에는 사용자가 파일을 업로드 하면 해당 내용을 받아서 먼저 파일로 저장하고, 파일을 다운받을 링크를 생성해서 데이터베이스에 저장한 뒤에, 해당 링크와 파일이름, 파일 사이즈, 파일 타입을 전달해주는것을 알 수 있다.

 

 

uploadMultipleFiles 함수도, uploadFile과 비슷하지만, 스트림을 이용하여 여러개의 파일을 업로드한 경우도 처리 할 수 있다는 것을 알 수 있다.

 

 

downloadFilesInfoInPost 함수는, 해당 게시물에 속한 첨부파일들의 목록들을 얻을때 사용하는 함수이다.

 

 

UploadFileResponse 엔티티 에서, postID를 이용해서 해당 게시물에 속한 첨부파일들의 목록을 얻어낸 뒤, DTO로 매핑해서 반환하는 함수이다.

 

 

다음으로 파일 다운로드를 할 때, 위에서 언급했던 내용대로 header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename="" + resource.getFilename() +""") 를 통해 파일을 다운 받을 수 있게 해주는 것을 알 수 있다.

 

 

이제 그럼 테스트를 해보도록 하자.

 

 

 

테스트

 

먼저 포스트맨을 켜서 회원가입, 로그인을 하고 포스팅을 1개 생성해주자.

 

 

그리고 아래와 같이 테스트를 해주자.

 

먼저 파일 업로드를 아래와 같이 해주자.

 

 

정상적으로 동작한 것을 알 수 있다.

 

 

 

그리고, 반환 받은 링크를 그대로 붙여서 다운로드 해보자

 

정상적으로 동작한 것을 알 수 있다.

 

 

 

그리고 이번에는 여러 파일을 아래와 같이 업로드 해보자

 

 

 

그리고 나서 정상적으로 업로드 됬는지 아래와 같이 테스트 해주자.

 

 

실제 링크에 나온대로 다운을 받을 수 있는지도 테스트를 해보자.

정상적으로 된 것을 알 수 있다.

 

 

 

결론

 

 

이제, 대략적인 기능 구현은 다 끝났다.

 

(물론, 실제로 게시판 등을 만드는 경우에, 몇가지를 더 추가해야 하는게 있기는 하지만 그건 여러분들이 만들기를 바란다..)

 

 

다음 포스팅에서는, Swagger를 이용하여 자동으로 API 문서를 생성하는 것에 대해서 다루도록 하겠다.

반응형
Comments