[Spring Boot] 게시판 구현 4 - 다중 파일(이미지) 업로드 MultipartFile

joyful·2021년 4월 30일
27

Java/Spring

목록 보기
13/28

들어가기 앞서

이 글에서는 Spring Boot 초기 설정 및 FrontEnd 관련 내용은 다루지 않습니다.
또한 파일 중에서도 이미지 처리에 중점을 둔 점 참고해주시길 바랍니다.

🔎 구현해야 할 기능

  1. 게시글 작성 및 파일 업로드 동시 처리
  2. 다중 파일 업로드
  3. DB에는 파일 관련 정보만 저장하고 실제 파일은 서버 내의 지정 경로에 저장
    → DB에 파일 자체 저장은 여러 가지 문제점으로 인하여 권장하지 않음


📌 Setting

✅ 파일 용량 설정

저장할 수 있는 파일의 최대 용량이 작으므로 조절해준다.

application.yml

spring:
  servlet:
    multipart:
      maxFileSize: 10MB
      maxRequestSize: 20MB

🔧 옵션

옵션설명기본값
enabled멀티파트 업로드 지원여부true
fileSizeThreshold파일이 메모리에 기록되는 임계 값0B
location업로드된 파일의 임시 저장 공간
maxFileSize파일의 최대 사이즈1MB
maxRequestSize요청한 최대 사이즈10MB

✅ 파일 처리 관련 의존성 주입

build.gradle

dependencies {

	...
    
	implementation 'commons-io:commons-io:2.6'
}

IOUtils 패키지는 대부분 static 메소드이므로 객체를 생성하지 않고 바로 사용 가능하다. 이 중 org.apache.commons.io.FileUtils 를 사용하여 파일 관련 처리를 진행한다.


📝 Entity

파일의 정보를 저장할 Entity를 구현한 후, Board Entity와 관계 매핑을 한다. 하나의 게시글이 여러 장의 파일을 가질 수 있으므로 PhotoBoard의 관계는 N:1(다대일)이 된다.

파일 엔티티의 경우 File을 클래스 명으로 단독 사용하지 않는다. 파일 처리를 위해서는 java.io.File 패키지를 사용해야 하는데, 패키지 명과 엔티티 명이 동일할 경우 java.io.File을 인식하지 못한다.

Photo.java

@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);
    }
}

여기서 파일 원본명파일 저장 경로를 따로 지정한 이유는, 동일한 이름을 가진 파일이 업로드될 경우 오류가 발생하기 때문이다.

Board.java

@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);
    }
}

📝 Repository

PhotoRepository

public interface PhotoRepository extends JpaRepository<Photo, Long>{

}

📝 Controller

기본적으로 @RequestBody는 body로 전달받은 JSON형태의 데이터를 파싱해준다. 반면 Content-Typemultipart/form-data로 전달되어 올 때는 Exception을 발생시킨다. 바로 이 점이 문제가 되는 부분인데, 파일 및 이미지의 경우는 multipart/form-data로 요청하게 된다.

따라서 @RequestBody가 아닌 다른 방식을 통하여 데이터를 전달받아야 한다. multipart/form-data를 전달받는 방법에는 다음과 같은 방법들이 있다.


@RequestPart 이용

@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);
}

@RequestParam 이용

@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 클래스를 하나 선언하여 처리하기로 했다. 코드는 다음과 같다.


BoardController

@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());
    }

    ...
    
}

BoardFileVO

@Data
public class BoardFileVO {
    private String memberId;
    private String title;
    private String content;
    private List<MultipartFile> files;
}

전달받을 데이터가 적을 경우는 @RequestPart@RequestParam을 사용해도 상관없으나, 전달받을 데이터가 많을 경우 코드가 지저분하게 보일 수 있다. 이때 VO로 설계하면 훨씬 코드를 깔끔하게 작성할 수 있다.


📝 Service

List<MultipartFile> 을 전달받아 파일을 저장한 후 관련 정보를 List<Photo>로 변환하여 반환할 FileHandler를 생성하고, 반환받은 파일 정보를 저장하기 위하여 BoardService를 수정한다.

FileHandler

@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에 호환되는 파일 경로를 구성할 수 있다.

✅ List 객체의 null 체크 시

FileHandlerparseFileInfo 메소드에서는 매개변수로 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를 다음과 같이 수정하면 된다.

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();
    }

    ...
   
}




📖 참고

profile
기쁘게 코딩하고 싶은 백엔드 개발자

10개의 댓글

comment-user-thumbnail
2021년 11월 23일

좋은 포스팅 감사합니다. 정리가 깔끔하게 되어 있네요.
덕분에 많은 도움 얻고 가요.!

1개의 답글
comment-user-thumbnail
2021년 11월 27일

혹시 이미지를 출력하고 싶을때는 front쪽으로 어떤데이터를 넘겨줘야하나요??

1개의 답글
comment-user-thumbnail
2022년 2월 9일

포스팅으로 많은 도움 얻고 있습니다! 다만 궁금한 점이 BoardService의
parseFileInfo(board, files) 이 부분은 오타인가요? parseFileInfo 메서드는 List타입 하나만 받을 수 있는 거 아닌가요~?

1개의 답글
comment-user-thumbnail
2022년 5월 9일

윈도우에서 경로구분 \가 맞고 자바에서는 이스케이프 문자때문에 \ 2개쓰는게 아닌가요?

1개의 답글