[Spring] 파일 업로드 처리

유승욱·2024년 2월 15일
1

현재 진행중인 프로젝트에 파일 업로드 기능을 추가하려한다.

파일 업로드를 위한 설정

기존에는 파일 업로드를 처리하려면 별도의 라이브러리들을 활용했지만, 서블릿 3이상이 되면서부터 API 자체에 파일 업로드를 처리할 수 있는 API를 제공하므로 추가적인 라이브러리가 필요하지 않는다고 한다.

먼저 application.properties 파일에 약간의 설정을 추가하는 것만으로 파일 업로드에 대한 기본 설정을 완료할 수 있다. 기존 application.properties 파일에 다음과 같은 설정을 추가해준다.

spring.servlet.multipart.enabled=true
spring.servlet.multipart.location=C:\\upload
spring.servlet.multipart.max-request-size=30MB
spring.servlet.multipart.max-file-size=10MB

org.zerock.upload.path=C:\\upload

각 줄의 설명은 다음과 같다.

spring.servlet.multipart.enabled=true: 이 줄은 스프링 애플리케이션에서 멀티파트 요청을 지원하도록 합니다. 멀티파트 요청은 일반적으로 파일을 업로드하거나 파일 입력이 있는 양식을 제출할 때 사용됩니다.
spring.servlet.multipart.location=C:\upload: 이는 서버에 업로드된 파일이 저장될 위치를 지정합니다. 이 경우에는 업로드된 파일이 C:\upload 디렉토리에 저장됩니다.
spring.servlet.multipart.max-request-size=30MB: 이는 멀티파트 요청의 최대 크기를 설정합니다. 요청이 이 크기를 초과하면 거부됩니다. 여기서는 최대 요청 크기를 30 메가바이트로 설정합니다.
spring.servlet.multipart.max-file-size=10MB: 이는 업로드되는 개별 파일의 최대 크기를 설정합니다. 파일이 이 크기를 초과하면 업로드가 거부됩니다. 여기서는 개별 파일의 최대 크기를 10 메가바이트로 설정합니다.

업로드 처리를 위한 DTO

파일 업로드는 MultipartFile이라는 API를 이용해서 처리한다. 이 때문에 컨트롤러에서는 파라미터를 MultipartFile로 지정해 주면 간단한 파일 업로드 처리는 가능하지만 Swagger UI와 같은 프레임워크로 테스트하기 불편하기 때문에 별도의 DTO를 선언해서 사용하는 것이 좋다.

컨트롤러와 Swagger UI 테스트

UpdownController

 @Value("${org.zerock.upload.path}")
    private String uploadPath;

    @Operation(summary = "Upload POST", description = "POST 방식으로 파일 등록")
    @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public List<UploadResultDTO> upload(
            @Parameter(
                    description = "Files to be uploaded",
                    content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)
            )
            UploadFileDTO uploadFileDTO) {

        log.info(uploadFileDTO);

        if (uploadFileDTO.getFiles() != null) {

            final List<UploadResultDTO> list = new ArrayList<>();

            uploadFileDTO.getFiles().forEach(multipartFile -> {

                String originalName = multipartFile.getOriginalFilename();
                log.info(originalName);

                String uuid = UUID.randomUUID().toString();

                Path savePath = Paths.get(uploadPath, uuid + "_" + originalName);

                boolean image = false;

                try {
                    multipartFile.transferTo(savePath); // 실제 파일 저장

                    // 이미지 파일의 종류라면
                    if (Files.probeContentType(savePath).startsWith("image")) {

                        image = true;

                        File thumbFile = new File(uploadPath, "s_" + uuid + "_" + originalName);

                        Thumbnailator.createThumbnail(savePath.toFile(), thumbFile, 200, 200);
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }

                list.add(UploadResultDTO.builder()
                        .uuid(uuid)
                        .fileName(originalName)
                        .img(image)
                        .build());
            }); // end each

            return list;
        } // end if

        return null;
    }

이제부터 위의 코드를 천천히 살펴보도록 하자.

@Value

먼저 실제 파일을 처리할 때는 파일의 저장 경로가 필요하므로 application.properties의 설정 정보는 @Value를 이용해서 처리해준다.
@Value는 application.properties 파일의 설정 정보를 읽어서 변수의 값으로 사용할 수 있게 해주는 어노테이션이다. uploadPath는 나중에 파일을 업로드하는 경로로 사용할 것이다.

첨부파일 저장

실제로 파일을 저장할 때는 같은 이름의 파일이 문제가 된다. 이 문제를 해결하고자 가장 많이 사용하는 방법은 java.util.UUID를 이용해 새로운 값을 만들어 내는 방법이다.(UUID는 중복될 가능성이 거의 없는 코드 값을 생성한다.)

Path는 저장 경로를 생성해준다. 이후 multipartFile.transferTo(savePath)를 해주게 되면 multipartFile에서 읽어온 파일 내용을 지정된 savePath에 있는 파일로 전송(복사)하게 된다. 이 메서드를 호출하면 업로드된 파일이 서버의 지정된 경로에 저장된다.

Thumbnail 파일 처리

첨부파일이 이미지일 때는 용량을 줄여서 작은 이미지(섬네일)를 생성하고 이를 나중에 사용하도록 구성하애한다. 이를 처리하기 위해서 Thumbnailator 라이브러리를 이용하도록 한다.
build.gralde에 Thunbnailator 라이브러리를 추가해준다.

implementation 'net.coobird:thumbnailator:0.4.16'

섬네일 이미지는 업로드하는 파일이 이미지일 때만 처리하도록 구성해야 하고, 파일 이름은 맨 앞에 's_'로 시작하도록 구성한다.

Files.probeContentType(savePath)는 주어진 파일의 MIME 유형을 결정하는 메서드이다. 따라서 Files.probeContentType(savePath).startsWith("image")는 지정된 경로의 파일이 이미지인지를 확인해준다.

Thumbnailator.createThumbnail()은 Thumbnailator 라이브러리를 사용하여 이미지의 썸네일을 생성하는 메서드입니다. 이 메서드는 원본 이미지 파일을 로드하고 지정된 크기의 썸네일 이미지를 생성합니다.

savePath.toFile(): 첫 번째 매개변수는 썸네일을 생성할 원본 이미지 파일을 나타냅니다. savePath는 이미지 파일의 경로를 가리키는 Path 객체이므로 .toFile()을 호출하여 File 객체로 변환합니다.

thumbFile: 두 번째 매개변수는 생성된 썸네일 이미지를 저장할 파일을 나타냅니다. 이 파일은 이미지의 경로와 파일명을 가지고 있습니다. 이전에 생성한 썸네일 파일 객체입니다.

이렇게 하면 Thumbnailator 라이브러리가 제공하는 기능을 사용하여 원본 이미지 파일에서 지정된 크기의 썸네일 이미지를 생성하고, 그것을 지정된 경로에 저장합니다.

참고: 확실하진 않지만 File과 Path는 둘 다 파일의 경로를 지정해주는 것 같다. 여기서 Path가 좀 더 최신버전인듯 하다. 따라서 위의 코드를
Path thumbPath = Paths.get(uploadPath, "s" + uuid + "" + originalName);로 바꿔쓸 수 있다. 이는 나중에 공부하도록 하겠다.

업로드 결과의 반환 처리

여러 개의 파일이 업로드되면 업로드 결과도 여러 개 발생하게 되고 여러 정보를 반환해야하므로 별도의 DTO를 구성해서 반환하도록 하겠다.

이때 getLink()는 JSON으로 처리될 때 link라는 속성으로 자동 처리된다.

첨부파일 조회


하나씩 살펴보자. 먼저 Resource라는 인터페이스가 나오는데 Resource는 Spring Framework에서 제공하는 추상화된 리소스 인터페이스로, 다양한 유형의 리소스를 표현할 수 있다. 여기서는 uploadPath + File.separator + filename 경로의 파일을 가져오는 역할을 한다. 여기서 File.separator라는게 나오는데 이는 '/'를 의미한다. 따라서 예를 들자면 Resource 객체를 이용해 'C:/upload/aaa.jpg' 경로의 파일을 읽어오는 것이다.
이후 HttpHeaders를 통해 해당 파일의 MIME유형을 추가해주면 된다. resource.getFile()로 파일 객체를 가져오고 toPath()로 파일의 경로를 찾아 MIME유형을 찾으면 된다.

첨부파일 삭제

@OneToMany

다양한 연관관계를 연습할 겸 @OneToMany를 적용하려 한다.
@OneToMany는 기본적으로 상위 엔티티(게시물)와 여러 개의 하위 엔티티들(첨부파일)의 구조로 이루어진다. @ManyToOne과 결정적으로 다른 점은 @ManyToOne은 다른 엔티티 객체의 참조로 FK를 가지는 쪽에서 하는 방식이고, @OneToMany는 PK를 가진 쪽에서 사용한다는 점이다.
@OneToMany를 사용하는 구조는 다음과 같은 특징을 가진다.

상위 엔티티에서 하위 엔티티들을 관리한다.
상위 엔티티 상태가 변경되면 하위 엔티티들의 상태들도 같이 처리해야 한다.
상위 엔티티 하나와 하위 엔티티 여러 개를 처리하는 경우 'N+1'문제가 발생할 수 있으므로 주의해야한다.

BoardImage 클래스의 생성

다음과 같이 BoardImage(첨부파일)와 Board(게시판)객체의 연관관계를 설정해주었다.


이때 mappedBy라는 속성을 이용하여 연관관계의 주인을 설정해주었는데, mappedBy 양방향 참조 상황에서 '어떤 엔티티의 속성으로 매핑되는지'를 정하는 역할을 한다. 여기서는 'board'를 연관관계의 주인으로 정해주었다.

영속성의 전이(cascade)

상위 엔티티(Board)와 하위 엔티티(BoardImage)의 연관 관계를 상위 엔티티에서 관리하는 경우 신경써야 하는 가장 중요한 점 중에 하나는 상위 엔티티 객체의 상태가 변경되었을 때 하위 엔티티 객체들 역시 같이 영향을 받는다는 점이다.
JPA에서는 '영속성의 전이(cascade)'라는 용어로 이를 표현하는데, 여기서는 Board 객체가 변경될 때 BoardImage도 같이 처리되고, BoardImage가 변경될 때도 Board 객체가 영향을 받게된다.
cascade.ALL을 사용하면 상위 엔티티의 모든 상태 변경이 하위 엔티티에 적용된다.

Board와 BoardImage의 insert 테스트

현재 구조에서 BoardImage는 Board가 저장될 때 같이 저장되어야 한다. 이를 테스트를 통해 확인해보자.
먼저 Board 엔티티에 하위 엔티티 객체들을 관리하는 기능을 추가한다.

public void addImage(String uuid, String fileName) {
        BoardImage boardImage = BoardImage.builder()
                .uuid(uuid)
                .fileName(fileName)
                .board(this)
                .ord(imageSet.size())
                .build();
        imageSet.add(boardImage);
    }

    public void clearImages() {
        imageSet.forEach(boardImage -> boardImage.changeBoard(null));

        this.imageSet.clear();
    }

테스트 코드

 @Test
    public void testInsertWithImages() {

        Board board = Board.builder()
                .title("Image Test")
                .content("첨부파일 테스트")
                .writer("tester")
                .build();

        for (int i = 0; i < 3; i++) {
            board.addImage(UUID.randomUUID().toString(), "file" + i + ".jpg");
        }

        boardRepository.save(board);
    }



실행을 하면 Board 객체의 상태 변화에 BoardImage 객체들 역시 같이 변경된 것을 확인할 수 있다.

Lazy로딩과 @EntityGraph

@OneToMany의 로딩 방식은 기본적으로 지연(Lazy)로딩이다. 게시물을 조회하는 경우 Board 객체와 BoardImage 객체들을 생성해야 하므로 2번의 select가 필요하게 된다. 테스트를 통해 이를 확인해보자.

테스트 코드

 @Test
    public void testReadWithImages() {

        // 반드시 존재하는 bno로 확인
        Optional<Board> result = boardRepository.findById(1L);

        Board board = result.orElseThrow();

        log.info(board);
        log.info("-------------------------");
        for (BoardImage boardImage : board.getImageSet()) {
            log.info(boardImage);
        }
    }


실행결과 에러가 발생하는 것을 볼 수 있는데 이는 Board의 출력이 끝난 후에 다시 select를 실행하려고 하는데 데이터베이스와 연결이 끝난 상태이므로 select를 할 수 없기 때문이다.

@EntityGraph와 조회 테스트

하위 엔티티를 상위 엔티티와 함께 로딩하기 위해 @EntityGraph를 이용해보도록 하겠다.

@EntityGraph에는 attributePaths라는 속성을 이용해서 같이 로딩해야 하는 속성을 명시할 수 있다. 테스트 코드를 통해 확인해보자.

테스트 코드

 @Test
    public void testReadWithImages() {

        // 반드시 존재하는 bno로 확인
        Optional<Board> result = boardRepository.findByIdWithImages(2L);

        Board board = result.orElseThrow();

        log.info(board);
        log.info("-------------------------");
        for (BoardImage boardImage : board.getImageSet()) {
            log.info(boardImage);
        }
    }



실행 결과를 보면 board 테이브과 board_image 테이블의 조인 처리가 된 상태로 select가 실행되어 Board와 BoardImage를 한 번에 처리하는 것을 볼 수 있다.

orphanRemoval 속성

테스트 코드를 통해 특정 게시물의 첨부파일을 다른 파일들로 수정해 보자.

테스트 코드

 @Test
    public void testModifyImages() {
        Optional<Board> result = boardRepository.findById(2L);

        Board board = result.orElseThrow();

        // 기존의 첨부파일들은 삭제
        board.clearImages();

        // 새로운 첨부파일들
        for (int i = 0; i < 2; i++) {
            board.addImage(UUID.randomUUID().toString(), "updatefile" + i +".jpg");
        }

        boardRepository.save(board);
    }

테스트 코드를 실행하면 예상과 조금 다른 결과를 보게 된다.

현재 cascade 속성이 ALL로 지정되었기 때문에 상위 엔티티(Board)의 상태 변화가 하위 엔티티(BoardImage)까지 영향을 주긴 했지만 삭제되지는 않았다. 만일 하위 엔티티의 참조가 더 이상 없는 상태가 되면 @OneToMany에 orphanRemoval 속성값을 true로 지정해 주어야만 실제 삭제가 이루어진다.

다시 테스트해보자.

제대로 변경 내용이 적용된 것을 확인할 수 있다.

'N+1' 문제와 @BatchSize

상위 엔티티에서 @OneToMany와 같은 연관 관계를 유지하는 경우 한번에 게시물과 첨부파일을 같이 처리할 수 있다는 장점도 있기는 하지만 목록을 처리할 때는 예상하지 못한 문제를 만들어내기 때문에 주의해야 한다.
목록 데이터를 처리하기 위해 BoardSearch 인터페이스에 새로운 메서드를 추가한다.

BoardSearchImpl 클래스에 이를 구현해준다.

@Override
    public Page<BoardListAllDTO> searchWithAll(String[] types, String keyword, Pageable pageable) {

        QBoard board = QBoard.board;
        QReply reply = QReply.reply;

        JPQLQuery<Board> boardJPQLQuery = from(board);
        boardJPQLQuery.leftJoin(reply).on(reply.board.eq(board)); //left join

        if( (types != null && types.length > 0) && keyword != null ){

            BooleanBuilder booleanBuilder = new BooleanBuilder(); // (

            for(String type: types){

                switch (type){
                    case "t":
                        booleanBuilder.or(board.title.contains(keyword));
                        break;
                    case "c":
                        booleanBuilder.or(board.content.contains(keyword));
                        break;
                    case "w":
                        booleanBuilder.or(board.writer.contains(keyword));
                        break;
                }
            }//end for
            boardJPQLQuery.where(booleanBuilder);
        }

        boardJPQLQuery.groupBy(board);

        getQuerydsl().applyPagination(pageable, boardJPQLQuery); //paging



        JPQLQuery<Tuple> tupleJPQLQuery = boardJPQLQuery.select(board, reply.countDistinct());

        List<Tuple> tupleList = tupleJPQLQuery.fetch();

        List<BoardListAllDTO> dtoList = tupleList.stream().map(tuple -> {

            Board board1 = (Board) tuple.get(board);
            long replyCount = tuple.get(1,Long.class);

            BoardListAllDTO dto = BoardListAllDTO.builder()
                    .bno(board1.getBno())
                    .title(board1.getTitle())
                    .writer(board1.getWriter())
                    .regDate(board1.getRegDate())
                    .replyCount(replyCount)
                    .build();

            //BoardImage를 BoardImageDTO 처리할 부분
            List<BoardImageDTO> imageDTOS = board1.getImageSet().stream().sorted()
                    .map(boardImage -> BoardImageDTO.builder()
                            .uuid(boardImage.getUuid())
                            .fileName(boardImage.getFileName())
                            .ord(boardImage.getOrd())
                            .build()
                    ).collect(Collectors.toList());

            dto.setBoardImages(imageDTOS);

            return dto;
        }).collect(Collectors.toList());

        long totalCount = boardJPQLQuery.fetchCount();


        return new PageImpl<>(dtoList, pageable, totalCount);
    }


실행되는 쿼리들을 살펴보면 목록을 가져오는 쿼리 한 번에 하나의 게시물마다 board_image에 대한 쿼리가 실행되는 상황을 볼 수 있는데 이것을 'N+1' 문제라고 한다.(N은 게시물 마다 각각 실행되는 쿼리, 1은 목록을 가져오는 쿼리)

@BatchSize

'N+1'로 실행되는 쿼리는 데이터베이스를 엄청나게 많이 사용하기 때문에 문제가 된다. 이 문제에 대한 가장 간단한 보완책은 @BatchSize를 이용하는 것이다. @BatchSize에는 size라는 속성을 지정하는데 이를 이용해서 'N 번'에 해당하는 쿼리를 모아서 한 번에 실행할 수 있다.
Board 클래스의 imageSet 부분에 다음과 같이 @BatchSize를 적용한다.

다시 테스트해보자.

@BatchSize의 size 속성값은 지정된 수 만큼 BoardImage를 조회할 때 한 번에 in 조건으로 조회할 수 있다. BoardImage들이 모두 조회되었으므로 나머지 목록을 처리할 때는 별도의 쿼리가 실행되지 않고 처리되는 것을 볼 수 있다.

댓글의 개수와 DTO 처리

이제 해당 결과에 댓글 개수를 처리하도록 수정해서 DTO를 구성해보자.


BoardService에 다음 메서드를 추가해준다.

0개의 댓글