코드로 배우는 스프링 부트 웹 프로젝트/MovieReview

MovieReview 프로젝트 (3) (파일 업로드)

soohykeee 2023. 1. 4. 22:01
728x90

 

 

MovieReview 프로젝트 (2) (@Query, 효율적 Join)

 

MovieReview 프로젝트 (2) (@Query, 효율적 Join)

MovieReview 프로젝트 (1) (MovieReview 프로젝트 설정, 다대다, Entity, Repository, Test) MovieReview 프로젝트 (1) (MovieReview 프로젝트 설정, 다대다, Entity, Repository, Test) GuestBook 프로젝트 (마지막) (Reply 댓글 추가

soohykeee.tistory.com

 

 


 

 

파일 업로드

파일 업로드를 프로젝트에 적용하기 전, 연습하는 과정을 준비했다. 아직 파일업로드에 대해 작년 학교 기말 프로젝트 때 해본적이 있기에 조금은 익숙했다.

우선적으로 application.yml 파일에서 servlet 설정을 추가해줄것이다. 설정된 코드의 의미는 다음과 같다.

  • spring.servlet.multipart.enabled : 파일 업로드 가능 여부를선택
  • spring.servlet.multipart.location : 업로드된 파일의 임시 저장 경로
  • spring.servlet.multipart.max-request-size : 한 번에 되대 업로드 가능 용량
  • spring.servlet.multipart.max-file-size : 파일 하나의 최대 크기

 

spring:
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:tcp://localhost/~/movieReview
    username: sa
    password:
  servlet:
    multipart:
      enabled: true
      location: C:\\upload
      max-request-size: 30MB
      max-file-size: 10MB

 

 

업로드된 파일 처리는 컨트롤러로 할 것이다. 이와 관련해서 Spring 에서는 Multipart File 타입을 제공하기에, 다른 처리방식이 필요하지 않고 바로 사용이 가능하다. 
controller 디렉토리 생성 후, UploadController, UploadTestController 클래스를 추가해준다.

 

package com.example.moviereview.controller;

import lombok.extern.log4j.Log4j2;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

@RestController
@Log4j2
public class UploadController {


    @PostMapping("/uploadAjax")
    public void uploadFile(MultipartFile[] uploadFiles) {
        for (MultipartFile uploadFile : uploadFiles) {
            String originalName = uploadFile.getOriginalFilename();
            String fileName = originalName.substring(originalName.lastIndexOf("\\") + 1);

            log.info("fileName : " + fileName);
        }
    }
}

 

package com.example.moviereview.controller;

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class UploadTestController {

    @GetMapping("/uploadEx")
    public void uploadEx() {

    }
}

 

 

 

해당 uploadEx.html 파일은 아래와 같이 생성해준다.

 

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

    <input name="uploadFiles" type="file" multiple>
    <button class="uploadBtn">Upload</button>

    <script
        src="https://code.jquery.com/jquery-3.5.1.min.js"
        integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0="
        crossorigin="anonymous">
    </script>

    <script>
        $('.uploadBtn').click(function (){
            var formData = new FormData();
            var inputFile = $("input[type='file']");
            var files = inputFile[0].files;

            for (var i = 0; i < files.length; i++) {
                console.log(files[i]);
                formData.append("uploadFiles", files[i]);
            }

        });
    </script>

</body>
</html>

 

위와 같이 작성한 후, 해당 url로 가면 업로드 파일을 등록할 수 있다. 해당 파일을 올리고, Upload 버튼을 누르면 아직 업로드를 처리하는 코드를 작성해주지 않고, console에 파일을 보여주도록 작성했기에 console 창에서 업로드 파일을 확인할 수 있다.

 

 


 

업로드 처리 및 업로드 파일 저장

파일을 저장하는 단계에서는 다음과 같은 사항을 고려해야한다.

  • 업로드된 확장자가 이미지만 가능하도록 검사
  • 동일한 이름의 파일이 업로드 된다면 기존 파일을 덮어쓰는 문제
  • 업로드된 파일을 저장하는 폴더의 용량

 

첨부파일의 동일한 이름으로 인해 파일이 덮어쓰이는 문제를 해결하기 위해서 고유한 이름으로 파일이 생성될 수 있도록 해줘야 한다. 이를 위해 가장 많이 사용하는 방식은 2가지가 존재한다.

  1. 시간 값을 파일 이름에 추가
  2. UUID를 이용하여 고유한 값을 만들어 사용

 

또한 업로드되는 파일을 동일한 디렉토리에 모두 저장하게 된다면, 파일이 많이 쌓이게 되면 성능이 저하되는 문제가 발생할 수 있다. 따라서, 파일을 올릴 시점의 '년/월/일' 로 구분지어 디렉토리를 생성하여 파일을 저장하는 방식이 일반적이다.

이러한 방식을 추가해주어 코드를 아래와 같이 작성해줘야한다.

 

package com.example.moviereview.controller;

import lombok.extern.log4j.Log4j2;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;

@RestController
@Log4j2
public class UploadController {

    @Value("C:\\upload")
    private String uploadPath;


    @PostMapping("/uploadAjax")
    public void uploadFile(MultipartFile[] uploadFiles) {
        for (MultipartFile uploadFile : uploadFiles) {

            // 이미지 파일만 업로드 가능
            if (uploadFile.getContentType().startsWith("image") == false) {
                log.warn("this file is not image type");
                return;
            }

            String originalName = uploadFile.getOriginalFilename();
            String fileName = originalName.substring(originalName.lastIndexOf("\\") + 1);

            log.info("fileName : " + fileName);

            // 날짜 폴더 생성
            String folderPath = makeFolder();

            // UUID
            String uuid = UUID.randomUUID().toString();

            // 저장할 파일 이름 중간에 _ 를 이용해서 구분
            String saveName = uploadPath + File.separator + folderPath + File.separator + uuid + "_" + fileName;

            Path savePath = Paths.get(saveName);

            try {
                uploadFile.transferTo(savePath);
            } catch (IOException e) {
                e.printStackTrace();
            }

        }

    }

    private String makeFolder (){
        String str = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));

        String folderPath = str.replace("//", File.separator);

        File uploadPathFolder = new File(uploadPath, folderPath);

        if (uploadPathFolder.exists() == false) {
            uploadPathFolder.mkdirs();
        }

        return folderPath;
    }
}

 

 

앞서 말한내용과 동일하게, '년/월/일'에 맞추어 디렉토리를 생성하여 파일이 저장이 되었고, 파일의 일므이 UUID를 이용하여 구분지어 저장이 되었다.

 

 


 

 

업로드 결과 반환 및 화면 처리

위까지 완료했다면, 정상적인 사이즈의 이미지 파일일 경우 업로드 처리가 되지만 아직 브라우저에는 아무런 결과가 반환되지 않는다. 결과 데이터는 JSON 방식으로 전송할 것이기에, 어떤 구조의 데이터를 전송할 것이지 결정해야한다. 

  • 업로드된 파일의 원래이름
  • 파일의 UUID 값
  • 업로드된 파일의 저장 경로

위 3가지 구조를 JSON 방식으로 변환해주어 전달해줄것이다. 이러한 업로드 결과 처리를 위해 DTO 파일을 생성해줘야 한다.

 

 

package com.example.moviereview.dto;

import lombok.AllArgsConstructor;
import lombok.Data;

import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;

@Data
@AllArgsConstructor
public class UploadResultDTO implements Serializable {

    private String fileName;

    private String uuid;

    private String folderPath;

    public String getImageURL() {
        try {
            return URLEncoder.encode(folderPath + "/" + uuid + "_" + fileName, "UTF-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return "";
    }
}

 

 

위와 같이 DTO 파일을 작성해주고, 앞서 작성한 UploadController의 수정이 필요하다. 업로드 결과를 반환해주기 위해서 ResponseEntity를 이용해서 이를 처리하는 형태로 수정해줘야 한다.

또한 업로드 처리된 이미지를 브라우저를 통해 확인하기 위해 GetMapping으로 받는 /display를 추가해줘야한다.

@PostMapping("/uploadAjax")
public ResponseEntity<List<UploadResultDTO>> uploadFile(MultipartFile[] uploadFiles) {
    List<UploadResultDTO> resultDTOList = new ArrayList<>();
    for (MultipartFile uploadFile : uploadFiles) {

        // 이미지 파일만 업로드 가능
        if (uploadFile.getContentType().startsWith("image") == false) {
            log.warn("this file is not image type");
            return new ResponseEntity<>(HttpStatus.FORBIDDEN);
        }

        String originalName = uploadFile.getOriginalFilename();
        String fileName = originalName.substring(originalName.lastIndexOf("\\") + 1);

        log.info("fileName : " + fileName);

        // 날짜 폴더 생성
        String folderPath = makeFolder();

        // UUID
        String uuid = UUID.randomUUID().toString();

        // 저장할 파일 이름 중간에 _ 를 이용해서 구분
        String saveName = uploadPath + File.separator + folderPath + File.separator + uuid + "_" + fileName;

        Path savePath = Paths.get(saveName);

        try {
            uploadFile.transferTo(savePath);
            resultDTOList.add(new UploadResultDTO(fileName, uuid, folderPath));
        } catch (IOException e) {
            e.printStackTrace();
        }

    }

    return new ResponseEntity<>(resultDTOList, HttpStatus.OK);
}

@GetMapping("/display")
public ResponseEntity<byte[]> getFile(String fileName) {
    ResponseEntity<byte[]> result = null;

    try {
        String srcFileName = URLDecoder.decode(fileName, "UTF-8");
        log.info("fileName: " + srcFileName);

        File file = new File(uploadPath + File.separator + srcFileName);
        log.info("file: " + file);

        HttpHeaders header = new HttpHeaders();

        header.add("Content-Type", Files.probeContentType(file.toPath()));

        result = new ResponseEntity<>(FileCopyUtils.copyToByteArray(file), header, HttpStatus.OK);

    } catch (Exception e) {
        return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR);
    }

    return result;
}

 

이에 맞춰서 uploadEx.html 파일도 수정해준다.

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>

    <input name="uploadFiles" type="file" multiple>
    <button class="uploadBtn">Upload</button>

    <div class="uploadResult">


    </div>

    <script
        src="https://code.jquery.com/jquery-3.5.1.min.js"
        integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0="
        crossorigin="anonymous">
    </script>

    <script>
        $('.uploadBtn').click(function (){
            var formData = new FormData();
            var inputFile = $("input[type='file']");
            var files = inputFile[0].files;

            for (var i = 0; i < files.length; i++) {
                console.log(files[i]);
                formData.append("uploadFiles", files[i]);
            }

            function showUploadedImages(arr) {
                console.log(arr);

                var divArea = $(".uploadResult");

                for (var i = 0; i < arr.length; i++) {
                    divArea.append("<img src='/display?fileName=" + arr[i].imageURL + "'>");
                }
            }

            //실제 업로드 부분 코드
            $.ajax({
                url: '/uploadAjax',
                processData: false,
                contentType: false,
                data: formData,
                type: 'POST',
                dataType: 'json',
                success: function (result){
                    showUploadedImages(result);
                },
                error: function(jqXHR, textStatus, errorThrown){
                    console.log(textStatus);
                }
            });

        });
    </script>

</body>
</html>

 

 

업로드 후 바로 업로드에 성공한 파일이 보이는 것을 확인할 수 있다.

 

 


섬네일 생성 + 처리

이미지를 원본으로 저장이 되는것은 확인했다. 하지만 목록이나 조회화면에서 원본 이미지 파일이 보여지게 되면, 데이터를 많이 소비하게 된다. 따라서 섬네일을 통해 전송해주고, 상세 화면을 누르면 원본 파일이 보이도록 해주는 것이 효율적이다.

섬네일 이미지의 처리는 다음과 같은 과정으로 처리한다.

  • 업로드된 파일을 저장하고 섬네일 라이브러리를 활용해서 섬네일 파일을 만들어 준다.
  • 섬네일 파일은 파일의 맨 앞에 's_'를 붙여 구분이 되도록 한다.
  • UploadResultDTO에 getThumbnailURL() 을 추가해서 섬네일의 경로를 <img> 태그로 처리한다.

 

build.gradle에 의존성을 추가해준다.

implementation group: 'net.coobird', name: 'thumbnailator', version: '0.4.1'

 

UploadController내 uploadFile()을 수정해준다. 앞서 말했듯이 섬네일 파일은 원본파일과 구분되도록 맨 앞에 's_'를 붙여주고 저장을 한다.

try {
    // 원본 파일 저장
    uploadFile.transferTo(savePath);

    //섬네일 생성 - 섬네일은 s로 시작하도록 저장
    String thumbnailSaveName = uploadPath + File.separator + folderPath + File.separator + "s_" + uuid + "_" + fileName;
    File thumbnailFile = new File(thumbnailSaveName);
    Thumbnailator.createThumbnail(savePath.toFile(), thumbnailFile, 100, 100);

    resultDTOList.add(new UploadResultDTO(fileName, uuid, folderPath));
} catch (IOException e) {
    e.printStackTrace();
}

 

UploadResultDTO 클래스에 브라우저에서의 섬네일 처리를 위한 getThumbnailURL 메소드를 추가해준다.

public String getThumbnailURL() {
    try {
        return URLEncoder.encode(folderPath + "/s_" + uuid + "_" + fileName, "UTF-8");
    } catch (UnsupportedEncodingException e) {
        e.printStackTrace();
    }
    return "";
}

 

uploadEx.html 파일에서 기존의 imageURL을 가져오는 것을 thumbnailURL을 가져오도록 수정해준다.

function showUploadedImages(arr) {
    console.log(arr);

    var divArea = $(".uploadResult");

    for (var i = 0; i < arr.length; i++) {
        divArea.append("<img src='/display?fileName=" + arr[i].thumbnailURL + "'>");
    }
}

 

 

 

해당 url에서 실행해보고 사진을 업로드하면, 섬네일로 화면에 보여지는것과, 저장될때 원본파일과 섬네일파일이 구분되어 저장되는것을 확인할 수 있다.

 

 


 

업로드 파일 삭제

업로드된 파일의 삭제는 파일의 URL로 쉽게 처리할 수 있다. 파일의 URL이 '년/월/일/uuid_파일명'으로 되도록 설정해주었기 때문에 이를 이용해서 삭제할 파일의 위치를 찾아 삭제할 수 있다.

 

UploadController 클래스에 removeFile() 메소드를 추가해준다.

removeFile()은 경로와 UUID가 포함된 파일 이름을 파라미터로 받아와서 삭제 결과를 boolean으로 전송한다. 또한 삭제할 때 원본 파일뿐 아니라, 섬네일 파일도 지워줘야 한다.

@PostMapping("/removeFile")
public ResponseEntity<Boolean> removeFile(String fileName) {
    String srcFileName = null;

    try {
        srcFileName = URLDecoder.decode(fileName, "UTF-8");
        File file = new File(uploadPath + File.separator + srcFileName);
        boolean result = file.delete();

        File thumbnail = new File(file.getParent(), "s_" + file.getName());

        result = thumbnail.delete();

        return new ResponseEntity<>(result, HttpStatus.OK);
    } catch (UnsupportedEncodingException e) {
        e.printStackTrace();
        return new ResponseEntity<>(false, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

 

 

삭제를 위해서는 브라우저에 삭제버튼을 추가해줘야한다. uploadEx.html에 버튼을 추가해준다. 또한 delete 버튼 클릭 시 동작 방식을 작성해준다.

function showUploadedImages(arr) {
    console.log(arr);

    var divArea = $(".uploadResult");

    var str = "";
    
    for (var i = 0; i < arr.length; i++) {
        str += "<div>";
        str += "<img src='/display?fileName=" + arr[i].thumbnailURL + "'>";
        str += "<button class='removeBtn' data-name='" + arr[i].imageURL + "'>REMOVE</button>";
        str += "</div>";
    }
    
    divArea.append(str);
}

 

$(".uploadResult").on("click", ".removeBtn", function (e) {
    var target = $(this);
    var fileName = target.data("name");
    var targetDiv = $(this).closest("div");

    console.log(fileName);

    $.post('/removeFile', {fileName: fileName}, function (result) {
        console.log(result);
        if (result === true) {
            targetDiv.remove();
        }
    });
});

 

업로드 결과로 만들어지는 <div> 는 동적으로 생성되기 때문에 바로 클릭 이븐테 처리를 할 수 없다. 따라서 위임하는 방식으로 이벤트를 처리해야한다. 삭제 작업은 POST방식으로 호출하고 정상적으로 서버에서 원본 파일과 섬네일 파일이 삭제된 후에는 해당 이미지가 포함된 <div>를 삭제한다. 

 

 

 

업로드 후 delete 버튼 클릭 시 원본 파일과, 섬네일 파일 모두 삭제되는 것을 확인할 수 있다.

 

 

 


 

 

 

728x90