이 글에서는 Spring Boot 초기 설정 및 FrontEnd 관련 내용은 다루지 않습니다.
또한 파일 중에서도 이미지 처리에 중점을 둔 점 참고해주시길 바랍니다.
저장할 수 있는 파일의 최대 용량이 작으므로 조절해준다.
spring:
servlet:
multipart:
maxFileSize: 10MB
maxRequestSize: 20MB
옵션 | 설명 | 기본값 |
---|---|---|
enabled | 멀티파트 업로드 지원여부 | true |
fileSizeThreshold | 파일이 메모리에 기록되는 임계 값 | 0B |
location | 업로드된 파일의 임시 저장 공간 | |
maxFileSize | 파일의 최대 사이즈 | 1MB |
maxRequestSize | 요청한 최대 사이즈 | 10MB |
dependencies {
...
implementation 'commons-io:commons-io:2.6'
}
IOUtils
패키지는 대부분 static 메소드이므로 객체를 생성하지 않고 바로 사용 가능하다. 이 중 org.apache.commons.io.FileUtils
를 사용하여 파일 관련 처리를 진행한다.
파일의 정보를 저장할 Entity를 구현한 후, Board
Entity와 관계 매핑을 한다. 하나의 게시글이 여러 장의 파일을 가질 수 있으므로 Photo
와 Board
의 관계는 N:1(다대일)이 된다.
파일 엔티티의 경우 File을 클래스 명으로 단독 사용하지 않는다. 파일 처리를 위해서는
java.io.File
패키지를 사용해야 하는데, 패키지 명과 엔티티 명이 동일할 경우java.io.File
을 인식하지 못한다.
@Getter
@NoArgsConstructor(access = AccessLevel.PUBLIC)
@Entity
@Table(name = "file")
public class Photo extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "file_id")
private Long id;
@ManyToOne
@JoinColumn(name = "board_id")
private Board board;
@Column(nullable = false)
private String origFileName; // 파일 원본명
@Column(nullable = false)
private String filePath; // 파일 저장 경로
private Long fileSize;
@Builder
public Photo(String origFileName, String filePath, Long fileSize){
this.origFileName = origFileName;
this.filePath = filePath;
this.fileSize = fileSize;
}
// Board 정보 저장
public void setBoard(Board board){
this.board = board;
// 게시글에 현재 파일이 존재하지 않는다면
if(!board.getPhoto().contains(this))
// 파일 추가
board.getPhoto().add(this);
}
}
여기서 파일 원본명과 파일 저장 경로를 따로 지정한 이유는, 동일한 이름을 가진 파일이 업로드될 경우 오류가 발생하기 때문이다.
@Getter
@NoArgsConstructor(access = AccessLevel.PUBLIC)
@Entity
@Table(name = "board")
public class Board extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "board_id")
private Long id;
@ManyToOne(cascade = CascadeType.MERGE, targetEntity = Member.class)
@JoinColumn(name = "member_id", updatable = false)
@JsonBackReference
private Member member;
@Column(nullable = false)
private String title;
@Column(columnDefinition = "TEXT", nullable = false)
private String content;
@OneToMany(
mappedBy = "board",
cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
orphanRemoval = true
)
private List<Photo> photo = new ArrayList<>();
@Builder
public Board(Member member, String title, String content) {
this.member = member;
this.title = title;
this.content = content;
}
public void update(String title, String content) {
this.title = title;
this.content = content;
}
// Board에서 파일 처리 위함
public void addPhoto(Photo photo) {
this.photo.add(photo);
// 게시글에 파일이 저장되어있지 않은 경우
if(photo.getBoard() != this)
// 파일 저장
photo.setBoard(this);
}
}
public interface PhotoRepository extends JpaRepository<Photo, Long>{
}
기본적으로 @RequestBody
는 body로 전달받은 JSON형태의 데이터를 파싱해준다. 반면 Content-Type
이 multipart/form-data
로 전달되어 올 때는 Exception을 발생시킨다. 바로 이 점이 문제가 되는 부분인데, 파일 및 이미지의 경우는 multipart/form-data
로 요청하게 된다.
따라서 @RequestBody
가 아닌 다른 방식을 통하여 데이터를 전달받아야 한다. multipart/form-data
를 전달받는 방법에는 다음과 같은 방법들이 있다.
@PostMapping("")
@ResponseStatus(HttpStatus.CREATED)
public Long create(
@RequestPart(value="image", required=false) List<MultipartFile> files,
@RequestPart(value = "requestDto") BoardCreateRequestDto requestDto
) throws Exception {
return boardService.create(requestDto, files);
}
@PostMapping("")
@ResponseStatus(HttpStatus.CREATED)
public Long create(
@RequestParam(value="image", required=false) List<MultipartFile> files,
@RequestParam(value="id") String id,
@RequestParam(value="title") String title,
@RequestParam(value="content") String content
) throws Exception {
Member member = memberService.searchMemberById(
Long.parseLong(boardFileVO.getId()));
BoardCreateRequestDto requestDto =
BoardCreateRequestDto.builder()
.member(member)
.title(boardFileVO.getTitle())
.content(boardFileVO.getContent())
.build();
return boardService.create(requestDto, files);
}
위의 두 가지 방법으로도 해결 가능하다고 한다.
또한 실무에서는 @RequestPart
와 @RequestParam
을 혼합하여 사용하기도 하니, 원하는 방법을 택하여 사용하면 된다.
그러나 나는 게시글-파일 처리용 VO 클래스를 하나 선언하여 처리하기로 했다. 코드는 다음과 같다.
@RequiredArgsConstructor
@RestController
public class BoardController {
private final BoardService boardService;
private final PhotoService fileService;
private final MemberService memberService;
@PostMapping("/board")
@ResponseStatus(HttpStatus.CREATED)
public Long create(BoardFileVO boardFileVO) throws Exception {
// Member id로 조회하는 메소드 존재한다고 가정하에 진행
Member member = memberService.searchMemberById(
Long.parseLong(boardFileVO.getId()));
BoardCreateRequestDto requestDto =
BoardCreateRequestDto.builder()
.member(member)
.title(boardFileVO.getTitle())
.content(boardFileVO.getContent())
.build();
return boardService.create(requestDto, boardFileVO.getFiles());
}
...
}
@Data
public class BoardFileVO {
private String memberId;
private String title;
private String content;
private List<MultipartFile> files;
}
전달받을 데이터가 적을 경우는 @RequestPart
나 @RequestParam
을 사용해도 상관없으나, 전달받을 데이터가 많을 경우 코드가 지저분하게 보일 수 있다. 이때 VO로 설계하면 훨씬 코드를 깔끔하게 작성할 수 있다.
List<MultipartFile>
을 전달받아 파일을 저장한 후 관련 정보를 List<Photo>
로 변환하여 반환할 FileHandler
를 생성하고, 반환받은 파일 정보를 저장하기 위하여 BoardService
를 수정한다.
@Component
public class FileHandler {
private final PhotoService photoService;
public FileHandler(PhotoService photoService) {
this.photoService = photoService;
}
public List<Photo> parseFileInfo(
List<MultipartFile> multipartFiles
)throws Exception {
// 반환할 파일 리스트
List<Photo> fileList = new ArrayList<>();
// 전달되어 온 파일이 존재할 경우
if(!CollectionUtils.isEmpty(multipartFiles)) {
// 파일명을 업로드 한 날짜로 변환하여 저장
LocalDateTime now = LocalDateTime.now();
DateTimeFormatter dateTimeFormatter =
DateTimeFormatter.ofPattern("yyyyMMdd");
String current_date = now.format(dateTimeFormatter);
// 프로젝트 디렉터리 내의 저장을 위한 절대 경로 설정
// 경로 구분자 File.separator 사용
String absolutePath = new File("").getAbsolutePath() + File.separator + File.separator;
// 파일을 저장할 세부 경로 지정
String path = "images" + File.separator + current_date;
File file = new File(path);
// 디렉터리가 존재하지 않을 경우
if(!file.exists()) {
boolean wasSuccessful = file.mkdirs();
// 디렉터리 생성에 실패했을 경우
if(!wasSuccessful)
System.out.println("file: was not successful");
}
// 다중 파일 처리
for(MultipartFile multipartFile : multipartFiles) {
// 파일의 확장자 추출
String originalFileExtension;
String contentType = multipartFile.getContentType();
// 확장자명이 존재하지 않을 경우 처리 x
if(ObjectUtils.isEmpty(contentType)) {
break;
}
else { // 확장자가 jpeg, png인 파일들만 받아서 처리
if(contentType.contains("image/jpeg"))
originalFileExtension = ".jpg";
else if(contentType.contains("image/png"))
originalFileExtension = ".png";
else // 다른 확장자일 경우 처리 x
break;
}
// 파일명 중복 피하고자 나노초까지 얻어와 지정
String new_file_name = System.nanoTime() + originalFileExtension;
// 파일 DTO 생성
PhotoDto photoDto = PhotoDto.builder()
.origFileName(multipartFile.getOriginalFilename())
.filePath(path + File.separator + new_file_name)
.fileSize(multipartFile.getSize())
.build();
// 파일 DTO 이용하여 Photo 엔티티 생성
Photo photo = new Photo(
photoDto.getOrigFileName(),
photoDto.getFilePath(),
photoDto.getFileSize()
);
// 생성 후 리스트에 추가
fileList.add(photo);
// 업로드 한 파일 데이터를 지정한 파일에 저장
file = new File(absolutePath + path + File.separator + new_file_name);
multipartFile.transferTo(file);
// 파일 권한 설정(쓰기, 읽기)
file.setWritable(true);
file.setReadable(true);
}
}
return fileList;
}
}
Spring Boot 설정에서 server.tomcat.basedir
를 지정하지 않을 경우 기본적으로 temp 경로가 설정된다. 절대 경로를 구하지 않고 파일을 저장하려고 하면 오류가 발생하므로, 절대 경로를 구하여 파일을 저장해야 한다.
또한, 운영체제 간의 경로 구분자에는 차이가 있다.
운영체제 | 경로 구분자 | 설명 |
---|---|---|
Windows | \ | 역슬래쉬(\) |
Linux | / | 슬래쉬 |
만약 경로 구분자를 직접적으로 역슬래쉬나 슬래쉬를 사용한다면, 서로 다른 운영체제의 경우 파일을 처리할 때 경로를 읽어오는 부분에서 오류가 나게 된다.
그렇다면 경로 구분자를 어떻게 처리해줘야 할까?
java.io.File
에서 제공하는 OS 환경에 맞는 파일 구분자를 제공하는 API를 이용하면 된다.
String fileSeparator = File.separator;
위의 API를 이용하면 OS에 호환되는 파일 경로를 구성할 수 있다.
FileHandler
의 parseFileInfo
메소드에서는 매개변수로 MultipartFile
타입 데이터를 List 타입으로 전달받고 있다(List<MultipartFile>
).
public List<Photo> parseFileInfo(
List<MultipartFile> multipartFiles
) throws Exception {
...
}
전달받은 매개변수가 존재한다면 파일 처리를, 존재하지 않는다면 파일 처리를 skip 해야 한다. 전달받은 매개변수는 List 타입의 객체이므로, multipartFiles
의 빈 값 여부와 null 여부를 판별해주어야 한다.
if(!CollectionUtils.isEmpty(multipartFiles)) {
...
}
Apache Commons 라이브러리 중 Null과 빈 값 체크를 동시에 해주는 CollectionUtils.isEmpty(객체)
를 사용하면 간편하게 해결할 수 있다.
FileHandler
를 생성했다면 BoardService
를 다음과 같이 수정하면 된다.
@RequiredArgsConstructor
@Service
public class BoardService {
private final BoardRepository BoardRepository;
private final PhotoRepository photoRepository;
private final FileHandler fileHandler;
@Transactional
public Long create(
BoardCreateRequestDto requestDto,
List<MultipartFile> files
) throws Exception {
// 파일 처리를 위한 Board 객체 생성
Board board = new Board(
requestDto.getMember(),
requestDto.getTitle(),
requestDto.getContent()
);
List<Photo> photoList = fileHandler.parseFileInfo(files);
// 파일이 존재할 때에만 처리
if(!photoList.isEmpty()) {
for(Photo photo : photoList) {
// 파일을 DB에 저장
board.addPhoto(photoRepository.save(photo));
}
}
return boardRepository.save(board).getId();
}
...
}
📖 참고
좋은 포스팅 감사합니다. 정리가 깔끔하게 되어 있네요.
덕분에 많은 도움 얻고 가요.!