MultipartFile을 통한 파일 업로드

chrkb1569·2023년 3월 19일
2

개인 프로젝트

목록 보기
12/28

네 안녕하세요!

오랜만에 글을 작성하네요.

최근에 UMC 연합 동아리 지원하느라 서류, 면접 준비도 있었고, 개강 첫주다 보니까 좀 노느라고 공부를 못했습니다ㅋㅋ...

오늘은 게시글에 이미지 첨부 기능을 구현하기 위하여 MultipartFile을 사용해보았습니다.

이미지를 서버에 전송하고, 이를 컴퓨터에 저장하는 것까지 확인하는 것이 주 목적이기 때문에, 간단하게 업로드한 이미지를 파일에 저장하고, 이를 수정해보는 기능만 구현해보았습니다.

프로젝트 설정

간단하게 이정도로 Dependecy를 설정하고 프로젝트를 생성해주었습니다.

프로젝트를 생성해준 뒤, 다음처럼 application.yml 파일을 설정해줍니다.

spring:
  datasource:
    url: 
    username: 
    password: 
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      naming:
        physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
      use-new-id-generator-mappings: false
    properties:
      hibernate:
        format_sql: true
        show_sql: true
    database: mysql
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect

file:
  dir: /Users/minsub/Desktop/store/

여기서

file:
  dir: /Users/minsub/Desktop/store/

이 부분은 저희가 이미지를 저장하기 위한 폴더의 경로를 입력해주시면됩니다.
저는 바탕화면에 store라는 이름의 폴더에 이미지를 저장하려고 하기 때문에 다음처럼 경로를 입력해주었습니다.

마지막으로 이미지 파일을 서버로 업로드하기 위한 페이지를 하나 만들어줍니다.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>파일 업로드 테스트</title>
</head>
<body>
<form name="form" method="post" action="http://localhost:8080/board" enctype="multipart/form-data">
  <input type="file" name="files" multiple="multiple"/>
  <input type="submit" id="submit" value="전송"/>
</form>

<form name="form" method="post" action="http://localhost:8080/board/1" enctype="multipart/form-data">
  <input type="file" name="files" multiple="multiple"/>
  <input type="submit" id="revise" value="수정"/>
</form>

</body>
</html>

다음처럼 파일을 전송, 수정을 담당하는 간단한 페이지를 만들어줍니다.

수정 폼의 경우에는 게시글 아이디가 1로 고정되어있는데, 기능이 동작하는지만 확인하고자 이렇게 해놨습니다.

프로젝트 초기 설정이 끝났을 경우, 다음의 페이지를 확인할 수 있습니다.

Entity 설계

먼저, 게시글을 위한 Board Entity와 이미지를 위한 Image Entity를 설계하였으며, 연관 관계를 맺어주었습니다.

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.DynamicInsert;

import javax.persistence.CascadeType;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Lob;
import javax.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@DynamicInsert
@Builder
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, name = "title")
    private String title;

    @Column(nullable = false, name = "content")
    @Lob
    private String content;

    @Column(nullable = false, name = "writer")
    private String writer;

    @OneToMany(mappedBy = "board", orphanRemoval = true, cascade = CascadeType.ALL)
    private List<Image> imageList = new ArrayList<>();
}
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.OnDelete;
import org.hibernate.annotations.OnDeleteAction;

import javax.persistence.*;

@Entity
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Image {

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

    @Column(nullable = false)
    private String originalName; // 파일이 본래 가지고 있던 이름

    @Column(nullable = false)
    private String storedName; // 파일이 저장될 경우 가지게 될 이름

    @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(nullable = false)
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Board board;
}

Repository 설계

각 Entity를 관리하기위한 Repository 파일을 만들어줍니다.

import minsub.multipartfile.BoardImage.domain.Image;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ImageRepository extends JpaRepository<Image, Long> {
}
import minsub.multipartfile.BoardImage.domain.Board;
import org.springframework.data.jpa.repository.JpaRepository;

public interface BoardRepository extends JpaRepository<Board, Long> {
}

ImageManager 설계

다음으로는 이미지를 관리하기 위한 ImageManager를 만들어줍니다.

import lombok.RequiredArgsConstructor;
import minsub.multipartfile.BoardImage.domain.Board;
import minsub.multipartfile.BoardImage.domain.Image;
import minsub.multipartfile.BoardImage.repository.ImageRepository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;

@Component
@RequiredArgsConstructor
public class ImageManager {

    private final ImageRepository imageRepository;

    @Value("${file.dir}")
    private String directory;

    // 확장자 추출 메소드, 저장하는 이미지 파일의 확장자를 구하는 기능을 수행합니다.
    public String extractExtension(String originalFileName) {
        int fileExtensionIndex = originalFileName.lastIndexOf('.');

        String fileExtension = originalFileName.substring(fileExtensionIndex + 1);

        if(validateExtension(fileExtension)) return fileExtension;
        throw new RuntimeException("지원하지않는 확장자");
    }

    // 저장할 파일 이름 구성 메소드, 저장되는 이미지 파일이 어떠한 이름으로 저장될지를 결정합니다.
    public String organizeStoredFileName(String originalFileName) {
        String uuidValue = UUID.randomUUID().toString();

        String rmExt = extractExtension(originalFileName);

        return uuidValue + "." + rmExt;
    }

    // 저장 경로 반환 메소드, 해당 이미지 파일이 저장될 경로를 반환해줍니다.
    public String getImageDirectory(String storedFileName, String imageType) {
        String extension = imageType + "/";

        return directory + extension + storedFileName;
    }

    // 이미지 저장 로직
    public Image saveImage(MultipartFile multipartFile, Board board) throws IOException {
        if(multipartFile.isEmpty()) {
            return null;
        }

        String originalFilename = multipartFile.getOriginalFilename();
        String storedFileName = organizeStoredFileName(originalFilename);
        String imageType = extractExtension(originalFilename);

        multipartFile.transferTo(new File(getImageDirectory(storedFileName, imageType)));

        Image savedImage = Image.builder()
                .originalName(originalFilename)
                .storedName(storedFileName)
                .board(board)
                .build();

        return imageRepository.save(savedImage);
    }

    // 전체 이미지 저장
    public List<Image> saveImages(List<MultipartFile> multipartFiles, Board board) throws IOException{
        List<Image> imageList = new ArrayList<>();

        for(MultipartFile multipartFile : multipartFiles) {
            if(!multipartFile.isEmpty()) {
                imageList.add(saveImage(multipartFile, board));
            }
        }

        return imageList;
    }

    // 파일 확장자 확인
    public boolean validateExtension(String fileExtension) {
        String[] extension = {"jpg", "jpeg", "bmp", "gif", "png"};

        if(Arrays.stream(extension).anyMatch(value -> value.equals(fileExtension))) return true;
        return false;
    }
}

@Value 어노테이션을 통하여 초반에 application.yml에 설정해두었던 파일의 경로를 통하여 이미지 파일을 저장할 수 있습니다.

이미지 파일을 저장할경우, 이미지 원본의 이름이 다른 이미지 파일과 충돌할 수 있기 때문에 이미지의 이름을 UUID로 변경하여 중복을 방지해줍니다.

또한, 확장자를 분리하여 확장자를 통하여 다른 경로에 저장되도록 설정해주었습니다.

BoardService

import lombok.RequiredArgsConstructor;
import minsub.multipartfile.BoardImage.domain.Board;
import minsub.multipartfile.BoardImage.domain.Image;
import minsub.multipartfile.BoardImage.repository.BoardRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;

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

@Service
@RequiredArgsConstructor
public class BoardService {
    private final BoardRepository boardRepository;
    private final ImageManager imageManager;

    @Transactional
    public void create(List<MultipartFile> files) {
        String title = "게시글 " + files.size();
        String writer = "작성자 " + files.size();
        String content = "내용 " + files.size();

        Board board = Board.builder()
                .title(title)
                .content(content)
                .writer(writer)
                .build();

        if(!files.isEmpty()) {
            try {
                List<Image> fileList = imageManager.saveImages(files, board);
                board.setImageList(fileList);
            } catch(IOException e) {
                throw new RuntimeException("게시글 생성 오류 발생");
            }
        }

        boardRepository.save(board);
    }

    @Transactional
    public void change(Long id, List<MultipartFile> files){
        Board board = boardRepository.findById(id).get();

        try {
            for(Image image : imageManager.saveImages(files, board)) {
                board.getImageList().add(image);
            }
        } catch(IOException e) {
            throw new RuntimeException("게시글 수정 오류 발생");
        }
    }
}

다음으로는 게시글의 생성, 수정을 담당하는 BoardService 입니다만...
어떻게 공부하다보니 이미지만을 관리하는 ImageService가 되어버렸습니다...
이 부분은 추후에 공부하면서 수정해가야할 것 같습니다.

Controller

import lombok.RequiredArgsConstructor;
import minsub.multipartfile.BoardImage.service.BoardService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.util.List;

@RestController
@RequiredArgsConstructor
public class BoardController {

    private final BoardService boardService;

    @PostMapping("/board")
    @ResponseStatus(HttpStatus.OK)
    public void create(@RequestParam("files") List<MultipartFile> files){
        boardService.create(files);
    }

    @PostMapping("/board/{id}")
    @ResponseStatus(HttpStatus.OK)
    public void change(@PathVariable Long id, @RequestParam("files") List<MultipartFile> files) {
        boardService.change(id, files);
    }
}

페이지로부터 이미지를 받고, 이를 처리하도록 하였습니다.

결과 화면

일단 저는 jpeg 파일을 저장하기 때문에, 해당 확장자 파일을 만들어줍니다.

일단 다음처럼 2개의 이미지 파일만을 저장해보겠습니다.

다음처럼 게시글이 생성되는 것을 확인할 수 있으며,

이미지 파일 또한 데이터베이스에 잘 저장된 것을 확인할 수 있습니다.

또한, 바탕화면에 위치한 파일에도 잘 저장된 것을 확인할 수 있습니다.

그럼 이번에는 수정 기능이 잘 작동하는지 확인해보겠습니다.

사실상 삭제는 아직 구현하지 못한 이미지 추가 기능에 불과하지만, 일단은 잘 동작하는지 확인해보겠습니다.

다음처럼 아까 추가하지 않았던 파일들을 전송해보겠습니다.

다음처럼 3번째, 4번째 이미지가 데이터베이스에 추가된 것을 확인할 수 있으며,

폴더에도 잘 저장되는 것을 볼 수 있습니다.

일단은 간단하게 어떻게 동작하고, 사용하는지만 알아보았는데, 아직 공부할 부분이 많다고 생각합니다.

특히, 수정하는 로직의 경우에는 기존에 존재하는 이미지는 모두 지우고 새로운 이미지만 추가하는 방식으로 사용하려고 하였으나, 제가 생각하는 것처럼 잘 되지 않아서 삽질하다가 일단은 추가하는 방식만 구현하였습니다.

현재 팀 프로젝트에서 게시글에 이미지를 첨부하는 기능을 구현해야해서 알아봤던 기능인데, 신경써서 공부해야할 것 같습니다.


Reference

https://workshop-6349.tistory.com/entry/Spring-Boot-파일이미지-업로드하기

0개의 댓글