이 글에서는 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
가 빈 객체인지 판별하기 위해서는 isEmpty()
메소드로 null 여부를 판별해주어야 한다.
if(multipartFiles.isEmpty())
return fileList;
if(객체 == null)
대신에 if(객체.isEmpty())
를 사용해야 하는 이유는 다음과 같다.
List가
null
인 경우에는 List의 변수가 그 어떤 주솟값도 참조하지 않은 상태를 의미한다. 다시 말하자면 인스턴스가 생성되어 있지 않은 상태(존재 x)를 의미한다.
isEmpty()
는 인스턴스는 생성된 상태, 혹은 주솟값을 참조하고는 있는 상태지만, 아무런 데이터가 적재되어 있지 않은 상태를 의미한다.
parseFileInfo
메소드의 매개변수로 List타입의 객체를 사용하고 있는데, 이때의 매개변수는 이미 인스턴스가 생성되어있는 상태라고 생각하면 된다. 따라서 isEmpty()
를 사용해야 한다.
원래라면 isEmpty()
를 사용했을 경우 코드가 잘 실행되어야 한다. 그러나 나 같은 경우는 isEmpty()
를 사용하게 되면 NullPointerException
에러가 발생했기에 다른 방법을 찾아야 했다.
if(!CollectionUtils.isEmpty(multipartFiles)) {
...
}
isEmpty()
를 사용할 경우 Null인 경우 오류가 발생할 수도 있다고 한다. 따라서 isEmpty()
보다는 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();
}
...
}
📖 참고
좋은 포스팅 감사합니다. 정리가 깔끔하게 되어 있네요.
덕분에 많은 도움 얻고 가요.!