네 안녕하세요!
오랜만에 글을 작성하네요.
최근에 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로 고정되어있는데, 기능이 동작하는지만 확인하고자 이렇게 해놨습니다.
프로젝트 초기 설정이 끝났을 경우, 다음의 페이지를 확인할 수 있습니다.
먼저, 게시글을 위한 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;
}
각 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를 만들어줍니다.
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로 변경하여 중복을 방지해줍니다.
또한, 확장자를 분리하여 확장자를 통하여 다른 경로에 저장되도록 설정해주었습니다.
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가 되어버렸습니다...
이 부분은 추후에 공부하면서 수정해가야할 것 같습니다.
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번째 이미지가 데이터베이스에 추가된 것을 확인할 수 있으며,
폴더에도 잘 저장되는 것을 볼 수 있습니다.
일단은 간단하게 어떻게 동작하고, 사용하는지만 알아보았는데, 아직 공부할 부분이 많다고 생각합니다.
특히, 수정하는 로직의 경우에는 기존에 존재하는 이미지는 모두 지우고 새로운 이미지만 추가하는 방식으로 사용하려고 하였으나, 제가 생각하는 것처럼 잘 되지 않아서 삽질하다가 일단은 추가하는 방식만 구현하였습니다.
현재 팀 프로젝트에서 게시글에 이미지를 첨부하는 기능을 구현해야해서 알아봤던 기능인데, 신경써서 공부해야할 것 같습니다.
https://workshop-6349.tistory.com/entry/Spring-Boot-파일이미지-업로드하기