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

joyful·2021년 4월 30일
29

Java/Spring

목록 보기
13/29

들어가기 앞서

이 글에서는 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빈 값 여부와 null 여부를 판별해주어야 한다.

if(!CollectionUtils.isEmpty(multipartFiles)) {
	...
}

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개의 답글