개발/JAVA

[자바웹개발워크북] 7. 파일업로드 처리

독코더 2023. 2. 9. 00:24
반응형

/*

 *

 * 자바웹개발워크북의 내용을 정리하기 위한 포스팅입니다.

 */

 

첨부파일은 크게 브라우저에서 전송하는 데이터를 파일형태로 서버에 보관하는 처리와,

이에 해당하는 데이터의 부가적인 정보를 처리하는것으로 나누어 볼 수 있습니다.

 

1. 파일업로드를 위한 설정

스프링부트는 application.properties파일에 약간의 설정을 추가하는것만으로 파일업로드에 대한 기본설정은 완료할 수 있습니다.

... 생략 ...
spring.servlet.multipart.enabled=true
spring.servlet.multipart.location=/Users/rrumang/desktop/upload
spring.servlet.multipart.max-request-size=30MB
spring.servlet.multipart.max-file-size=10MB

org.zerock.upload.path=/Users/rrumang/desktop/upload

업로드처리를 위한 DTO

파일업로드는 MultipartFile이라는 API를 이용해서 처리합니다. 때문에 컨트롤러에서 파라미터를 MultipartFile로 지정해주면

간단한 파일업로드처리는 가능하지만 SwaggerUI와 같은 프레임워크로 테스트하기 불편하기 때문에 dto패키지에 별도의 DTO로

선언해서 사용하는것이 좋습니다. dto패키지에 upload하위패키지를 구성하고 UploadFileDTO를 추가합니다.

@Data
public class UploadFileDTO {
    
    private List<MultipartFile> files;
}

controller패키지에 UpDownController를 대략적으로 만들어 줍니다.

@RestController
@Log4j2
public class UpDownController {

    @Value("${org.zerock.upload.path}") // import시에 springframework로 시작하는 value
    private String uploadPath;

    @ApiOperation(value = "upload POST", notes = "POST 방식으로 파일등록")
    @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public String upload(UploadFileDTO uploadFileDTO) {

        log.info(uploadFileDTO);

        if (uploadFileDTO.getFiles() != null) {
            uploadFileDTO.getFiles().forEach(multipartFile -> {
                
                String originalName = multipartFile.getOriginalFilename();
                
                log.info(multipartFile.getOriginalFilename());
                
                String uuid = UUID.randomUUID().toString();

                Path savePath = Paths.get(uploadPath, uuid + "_" + originalName);
                
                try {
                    multipartFile.transferTo(savePath); // 실제 파일저장
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
        }
         
        return null;
    }

@Value는 application.properties의 설정정보를 읽어서 변수의 값으로 사용할 수 있습니다.(아직은 사용하지 않습니다.)

실제로 파일을 저장할때 같은 이름의 파일이 문제가 됩니다. java.util.UUID를 이용해서 새로운값을 만들어내서 해결해줍니다.

MultipartFile의 transfer()를 이용하면 간장하게 파일업로드가 완료됩니다. SwaggerUI로 UUID값이 붙어서 저장되는지 확인합니다.

 

Thumbnail파일처리

build.gradle에 Thumbnailator 라이브러리를 추가합니다.

dependencies {
    ... 생략 ...

    //Thumbnail
    implementation 'net.coobird:thumbnailator:0.4.16'
}

섬네일 이미지는 업로드 파일이 이미지일때만 처리해야하고, 파일이름은 맨앞에 's_'로 시작하도록 구성합니다. upload()를 수정합니다.

try {
    multipartFile.transferTo(savePath); // 실제 파일저장
    
    // 이미지 파일의 종류라면
    if (Files.probeContentType(savePath).startsWith("image")) {
        File thumbFile = new File(uploadPath, "s_" + uuid + "_" + originalName);

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

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

dto의 upload패키지에 UploadResultDTO클래스를 추가합니다.

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UploadResultDTO {
    
    private String uuid;
    
    private String fileName;
    
    private boolean img;
    
    public String getLink() {
        if (img) {
            return "s_" + uuid + "_" + fileName; // 이미지인 경우 썸네일
        } else {
            return uuid + "_" + fileName;
        }
    }
}

UploadResultDTO의 getLink()는 나중에 JSON으로 처리될때는 link라는 속성으로 자동 처리됩니다.

UpDownController의 upload()는 List<UploadResultDTO>를 반환하도록 수정합니다.

@ApiOperation(value = "upload POST", notes = "POST 방식으로 파일등록")
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public List<UploadResultDTO> upload(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(multipartFile.getOriginalFilename());

            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")) {
                    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;
}

 

첨부파일조회

첨부파일조회는 가능하면 GET방식으로 바로 가능하도록 설정합니다. 첨부파일은 나중에 보안문제가 발생하므로 코드를 통해서 접근여부를 허용하도록 컨트롤러를 이용하는것이 좋습니다.

첨부파일을 조회할때는 '/view/파일이름'으로 동작하도록 UpDownController에 viewFileGET()메소드를 추가합니다.

@ApiOperation(value = "view 파일", notes = "GET 방식으로 첨부파일 조회")
@GetMapping(value = "/view/{fileName}")
public ResponseEntity<Resource> viewFileGET(@PathVariable String fileName) {
    
    Resource resource = new FileSystemResource(uploadPath + File.separator + fileName);
    
    String resourceName = resource.getFilename();
    HttpHeaders headers = new HttpHeaders();
    
    try {
        headers.add("Content-Type", Files.probeContentType(resource.getFile().toPath()));
    } catch (Exception e) {
        return ResponseEntity.internalServerError().build();
    }
    return ResponseEntity.ok().headers(headers).body(resource);
}

 

첨부파일삭제

조회와 비슷한 DELETE방식의 호출하는 형태로 첨부파일 삭제를 구현할 수 있습니다.

첨부파일을 삭제할때 해당파일이 이미지라면 섬네일이 존재할 수 있으므로 같이 삭제되도록 구현합니다.

@ApiOperation(value = "remove 파일", notes = "DELETE 방식으로 첨부파일 삭제")
@GetMapping(value = "/remove/{fileName}")
public Map<String, Boolean> removeFile(@PathVariable String fileName) {
    
    Resource resource = new FileSystemResource(uploadPath+File.separator+fileName);
    
    String resourceName = resource.getFilename();
    
    Map<String, Boolean> resultMap = new HashMap<>();
    
    boolean removed = false;
    
    try {
        String contentType = Files.probeContentType(resource.getFile().toPath());
        removed = resource.getFile().delete();
        // 썸네일이 존재하면
        if (contentType.startsWith("image")){
            File thumbnailFile = new File(uploadPath+File.separator+"s+"+fileName);
            thumbnailFile.delete();
        }
    } catch (Exception e) {
        log.error(e.getMessage());
    }
    
    resultMap.put("result", removed);
    
    return resultMap;
}

 

첨부파일을 일반파일에도 사용하려면 이미지파일이 아닐때는 썸네일을 생성하지않도록 해야하고 GET방식으로 이미지를 전송하는 방식대신에 파일을 내려받도록 처리되어야 합니다.

 


2. @OneToMany

게시물과 첨부파일의 관계는 연관관계 중에서 @OneToMany를 이용해서 처리해보도록 합니다.

@ManyToOne을 이용하는것도 가능하지만 @OneToMany를 이용하는 경우 추가작업없이 여러종류의 엔티티객체처리가 가능합니다.

게시물과 댓글, 게시물과 첨부파일의 관계를 테이블의 구조로보면 같은구조지만, JPA에서는 게시글을 중심으로 해석하는지 첨부파일을 중심으로 해석하는지에따라서 다른 결과가 나올수있습니다.

@OneToMany는 기본적으로 상위 엔티티(게시물)와 여러개의 하위엔티티들(첨부파일)의 구조로 이루어집니다.

@ManyToOne과 결정적으로 다른점은 @ManyToOne은 다른 엔티티객체의 참조로 FK를 가지는쪽에서 하는 방식이고,

@OneToMany는 PK를 가진쪽에서 사용한다는 점입니다.

@OneToMany를 사용하는 구조는 다음과 같은 특징을 가집니다.

▶ 상위엔티티에서 하위엔티티들을 관리한다.

▶ JPA의 Repository를 상위엔티티 기준으로 생성한다. 하위엔티티에 대한 Repository의 생성이 잘못된것은 아니지만 하위엔티티들의 변경은 상위엔티티에도 반영되어야한다.

▶ 상위엔티티 상태가 변경되면 하위엔티티들의 상태들도 같이 처리해야한다.

▶ 상위엔티티 하나와 하위엔티티 여러개를 처리하는 경우 'N+1'문제가 발생할수 있으므로 주의해야한다.

 

domain패키지에 첨부파일을 의미하는 BoardImage엔티티클래스를 추가합니다.

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "board")
public class BoardImage implements Comparable<BoardImage> {

    @Id
    private String uuid;

    private String fileName;

    private int ord;

    @ManyToOne
    private Board board;

    @Override
    public int compareTo(BoardImage other) {
        return this.ord - other.ord;
    }
    
    public void changeBoard(Board board) {
        this.board = board;
    }
}

특이하게 Comparable인터페이스를 적용하는데 이능 @OneToMany처리에서 순번에 맞게 정렬하기 위함입니다.

changeBoard()를 이용해서 BoardImage객체의 참조도 변경하기 위해서 사용합니다.

Board클래스에 @OneToMany를 적용합니다.(양방향참조)

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "imageSet")
public class Board extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long bno;

    ... 생략 ...
    
    @OneToMany
    @Builder.Default
    private Set<BoardImage> imageSet = new HashSet<>();

}

@OneToMany는 기본적으로 각 엔티티에 해당하는 테이블을 독립적으로 생성하고 중간에 매핑해주는 테이블이 생성됩니다.

(이를 확인하기위해서 기존 데이터베이스에서 board테이블과 reply테이블을 삭제해 두도록합니다.)

엔티티테이블 사이에 생성되는 테이블을 흔히 '매핑 테이블'이라고 하는데 매핑테이블을 생성하지 않는 방법으로

첫번째는 단방향으로 @OneToMany를 이용하는경우 @JoinColumn을 이용하거나,

두번째로 mappedBy라는 속성을 이용하는 방법이 있습니다.

이중 mappedBy의 경우 Board와 BoardImage가 서로 참조를 유지하는 양방향 참조상황에서 사용하는데

mappedBy는 '어떤 엔티티의 속성으로 매핑되는지'를 의미합니다. mappedBy를 적용하기전에 기존의 테이블들을 삭제합니다.

public class Board extends BaseEntity {

   ... 생략 ...

    @OneToMany(mappedBy = "board")  // BoardImage의 board변수
    @Builder.Default
    private Set<BoardImage> imageSet = new HashSet<>();

}

 

영속성의 전이(cascade)

상위엔티티(Board)와 하위엔티티(BoardImage)의 연관관계를 상위엔티티에서 관리하는 경우 신경써야하는 가장 중요한점중 하나는

상위엔티티객체의 상태가 변경되었을때 하위엔티티객체들 역시 같이 영향을 받는다는점입니다.

JPA에서는 '영속성 전이(cascade)'라고하는데 지금하는 작업이 대표적인 예입니다.

예를들어 BoardImage객체가 JPA에 의해 관리되면 BoardImage를 참조하고있는 Board객체도 같이 처리되어야합니다.

반대로 Board객체가 변경될때 BoardImage객체들 역시 영향을 받을수 있습니다.

JPA에서는 이러한 경우 연관관계에 cascade속성을 부여해서 이를 제어하도록 합니다.

cascade속성값 설명
PERSIST
REMOVE
상위엔티티가 영속처리될때 하위엔티티들도 같이 영속처리
MERGE
REFRESH
DETACH
상위엔티티의 상태가 변경될때 하위엔티티들도 같이 상태변경
ALL 상위엔티티의 모든 상태변경이 하위엔티티에 적용

현재구조에서 BoardImage는 Board가 저장될때 같이 저장되어야하는 엔티티객체입니다.

이처럼 상위엔티티가 하위엔티티객체들을 관리하는 경우에는 별도의 JPARepository를 생성하지않고, Board엔티티에 하위엔티티객체들을 관리하는 기능을 추가해서 사용합니다.

public class Board extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long bno;

  ... 생략 ...

    // BoardImage의 board변수
    @OneToMany(mappedBy = "board",
            cascade = {CascadeType.ALL},
            fetch = FetchType.LAZY)
    @Builder.Default
    private Set<BoardImage> imageSet = new HashSet<>();
    
    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();
    }

@OneToMany의 cascade속성값으로는 CasecadeType.ALL을 지정해서 Board엔티티객체의 모든 상태변화에 BoardImage객체들 역시 같이 변경되도록 구성합니다. @OneToMany의 로딩방식은 기본적으로 지연(lazy)로딩입니다.

또한 Board객체자체에서 BoardImage객체들을 관리하도록 addImage()와 cleanImage()를 이용해서 Board내에서 BoardImage객체들을 모두 관리하도록 합니다.

addImage()는 내부적으로 BoardImage객체내부의 Board에대한 참조를 this를 이용해서 처리합니다.(양방향의 경우 참조관계가 서로 일치하도록 작성해야만 합니다.) clearImages()는 첨부파일들을 모두 삭제하므로 BoardImage객체의 Board참조를 null로 변경합니다.

(필수적이지는않지만 항상 상위엔티티의 상태와 하위엔티티의 상태를 맞추는것이 좋습니다.)

더보기
@Test
public void testImsertWithImages() {
    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);
}

 

@EntityGraph

하위엔티티를 로딩하는 방식으로 지연로딩을 하기에 @EntityGraph를 이용하도록 합니다.

지연로딩이라고해도 한번에 조인처리해서 select가 이루이지도록 하는 방법입니다.

BoardRepository에 findByIdWithImages()를 직접 정의하고 다음과 같이 @EntityGraph를 적용합니다.

public interface BoardRepository extends JpaRepository<Board, Long>, BoardSearch {
    
    @EntityGraph(attributePaths = {"imageSet"})
    @Query("select b from Board b where b.bno =: bno")
    Optional<Board> findByIdWithImages(long bno);
}

@EntityGraph는 attributePaths라는 속성을 이용해서 같이 로딩해야하는 속성을 명시할 수 있습니다.

@Test
public void testReadWithImages() {

    // 반드시 존재해야할 bno확인
    Optional<Board> result = boardRepository.findByIdWithImages(1L);

    Board board = result.orElseThrow();

    log.info(board);

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

}

Hibernate: 
    select
        board0_.bno as bno1_0_0_,
        imageset1_.uuid as uuid1_1_1_,
        board0_.moddate as moddate2_0_0_,
        board0_.regdate as regdate3_0_0_,
        board0_.content as content4_0_0_,
        board0_.title as title5_0_0_,
        board0_.writer as writer6_0_0_,
        imageset1_.board_bno as board_bn4_1_1_,
        imageset1_.file_name as file_nam2_1_1_,
        imageset1_.ord as ord3_1_1_,
        imageset1_.board_bno as board_bn4_1_0__,
        imageset1_.uuid as uuid1_1_0__ 
    from
        board board0_ 
    left outer join
        board_image imageset1_ 
            on board0_.bno=imageset1_.board_bno 
    where
        board0_.bno=?

 

테스트결과 board테이블과 board_image테이블의 조인처리가 된 상태로 select가 실행되면서 Board와 BoardImage를 한번에 처리할 수 있게된것을 확인할 수 있습니다. @OneToMany구조를 사용하는 경우에 얻을수있는 장점중 하나가 이러한 하위엔티티의 처리입니다.

 

게시물과 첨부파일 수정

게시물과 첨부파일 수정은 다른 엔티티들 간의 관계와는 조금 다른점이 있습니다. 실제 처리과정에서 첨부파일은 그자체가 변경되는것이 아니라 아예 기존의 모든 첨부파일들이 삭제되고 새로운 첨부파일들로 추가되기때문입니다.

Board에는 addImage()와 clearImage()를 이용해 Board를 통해서 BoardImage객체들을 처리하도록 설계합니다.

 

OrphanRemoval속성

@Test
public void testModifyImage() {
    Optional<Board> result = boardRepository.findByIdWithImages(1L);

    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로 지정해주어야만 실제 삭제가 이루어집니다. Board클래스의 @OneToMany를 조정합니다.

@OneToMany(mappedBy = "board",
        cascade = {CascadeType.ALL},
        fetch = FetchType.LAZY,
        orphanRemoval = true)
@Builder.Default
private Set<BoardImage> imageSet = new HashSet<>();

다시 테스트코드를 실행해보면 추가된 BoardImage가 insert되고, 기존의 BoardImage에 해당하는 데이터들은 delelte됩니다.

 

게시물과 첨부파일삭제

게시물삭제에는 게시물을 사용하는 댓글들을 먼저 삭제해야만 합니다. 다만 이 경우 다른 사용자가 만든 데이터를 삭제하는것은 문제가 될 수 있으므로 주의할 필요가 있습니다. 우선 ReplyRepository에 특정한 게시물에 해당하는 데이터들을 삭제할수있도록 쿼리메소드를 다음과 같이 추가할수 있습니다.

public interface ReplyRepository extends JpaRepository<Reply, Long> {

    @Query("select r from Reply r where r.board.bno = :bno")
    Page<Reply> listOfBoard(long bno, Pageable pageable);
    
    void deleteByBoard_Bno(Long bno);
}

 

'N+1'문제와 @BatchSize

상위엔티티에서 @OneToMany과 같은 연관관계를 유지하는경우 한번에 게시물과 첨부파일을 같이 처리할수있다는 장점도 있기는 하지만

목록을 처리할때는 예상하지못한 문제를 만들어내기 때문에 주의해야합니다.

목록데이터를 처리하기위해서 Querydsl을 이용하는 BoardSearch, BoardSearchImpl에 새로운 메소드를 추가합니다.

Page<BoardListReplyCountDTO> searchWithAll(String[] types, String keyword, Pageable pageable);
@Override
public Page<BoardListReplyCountDTO> 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

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

    List<Board> boardList = boardJPQLQuery.fetch();

    boardList.forEach(board1 -> {
        System.out.println(board1.getBno());
        System.out.println(board1.getImageSet());
        System.out.println("-----------------");
    });

    return null;
}

Board와 Reply를 left join으로 처리하고 쿼리를 실행해서 내용을 확인하는 것입니다.

이메소드를 실행하면 목록을 가져오는 쿼리한번과 하나의 게시물마다 board_image에 대한 쿼리가 실행되는 상황을 볼수있는데

이것을 'N+1'문제라고 합니다.(N은 게시물마다 각각 실행되는 쿼리, 1은 목록을 가져오는 쿼리)

1) Board에 대한 페이징처리가 실행되면서 limit로 처리

2) System.out.println()을 통해 Board의 bno값 출력

3) Board객체의 imageSet을 가져오기위해서 board_image테이블을 조회하는 쿼리실행

4) 2,3의 과정이 반복적으로 실행

 

@BatchSize

'N+1'로 실해되는 쿼리는 데이터베이스를 엄청나게 많이 사용하기때문에 문제가 됩니다.

이문제에 대한 가장 간단한 보완책은 @BatchSize를 이용하는것입니다. @BatchSize에는 size라는 속성을 지정하는데 이를 이용해서 'N번'에 해당하는 쿼리를 모아서 한번에 실행할 수 있습니다. Board클래스의 imageSet에 @BatchSize를 적용합니다.

@OneToMany(mappedBy = "board",
        cascade = {CascadeType.ALL},
        fetch = FetchType.LAZY,
        orphanRemoval = true)
@Builder.Default
@BatchSize(size = 20)
private Set<BoardImage> imageSet = new HashSet<>();

@BatchSize의 size속성값은 지정된 수만큼은 BoardImage를 조회할때 한번에 in조건으로 사용합니다.

 

댓글의 개수와 DTO처리

추가로 한번더 쿼리가 실행되기는하지만 Board와 BoardImage들을 한번에 처리할수 있다는 점은 분명히 장점이 될수있으므로 해당 결과에 댓글개수를 처리하도록 수정해서 최종적으로 DTO를 구성합니다.

엔티티객체를 DTO로 변환하는 방식은 ModelMapper를 이용하거나, Projections를 이용했지만 Board객체안에 Set과 같이 중첩된 구조를 처리할 경우에는 직접 튜플(Tuple)을 이용해서 DTO로 변환하는 방식을 사용하는것이 편리합니다.

 

BoardListAllDTO클래스

dto패키지에 Board와 BoardImage, 댓글개수를 모두 반영할수있는 BoardListAllDTO클래스와 BoardImage엔티티를 처리하기위한 BoardImageDTO클래스를 추가합니다.

@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class BoardImageDTO {
    
    private String uuid;
    
    private String fileName;
    
    private int ord;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class BoardListAllDTO {

    private Long bno;

    private String title;

    private String writer;

    private LocalDateTime regDate;

    private Long replyCount;

    private List<BoardImageDTO> boardImages;
}

 

BoardService변경

BoardService, BoardServiceImpl에 BoardListAllDTO를 이용할수있도록 새로운 listWithAll()메소드를 다음과 같이 추가합니다.

BoardServiceImpl은 메소드틀만 작성해두고 Querydsl의 처리가 끝난후에 수정하겠습니다.

// 게시글의 이미지와 댓글의 숫자까지 처리
PageResponseDTO<BoardListAllDTO> listWithAll(PageRequestDTO pageRequestDTO);
@Override
public PageResponseDTO<BoardListAllDTO> listWithAll(PageRequestDTO pageRequestDTO) {
    return null;
}

 

Querydsl의 튜플처리

Querydsl을 이용해서 동적쿼리를 처리하는 BoardSearch와 Impl클래스의 리턴타입은 BoardListAllDTO로 수정합니다.

이과정에서 임시로 데이터를 튜플타입으로 처리하도록 합니다.

Page<BoardListAllDTO> searchWithAll(String[] types, String keyword, Pageable pageable);
@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
    
    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 = 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 처리할 부분
        return dto;
    }).collect(Collectors.toList());
    
    long totalCount = boardJPQLQuery.fetchCount();
    
    return new PageImpl<>(dtoList, pageable, totalCount);
}

List<Tuple>을 이용하는 방식은 Projections를 이용하는 방식보다 번거롭기는 하지만, 코드를 통해서 마음대로 커스터마이징할수 있다는 장점이 있습니다. 위의코드에서는 LIst<Tuple>에서 List<BoardListALLDTO>로 변경하고있습니다.(아직 BoardImage에 대한처리 X)

 

Board에 대한 처리결과를 확인했다면 Board객체 내 BoardImage들을 추출해서 BoardImageDTO로 변환하는 코드를 추가합니다.

Querydsl을 이용해서 페이징처리하기 전에 검색조건과 키워드를 사용하는 부분의 코드를 추가해서 searchWithAll()을 완성합니다.

@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;
            }
        }
        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 = 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); // 처리된 BoardImageDTO들을 추가

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

    long totalCount = boardJPQLQuery.fetchCount();

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

 


3. 서비스계층과 DTO

첨부파일이 있는 게시물은 각 작업에 따라 엔티티설계와 다르게 처리될부분이 많습니다.

엔티티클래스와 달리 DTO클래스는 상황에따라 여러개의 클래스를 작성해서 처리하도록 합니다.

 

게시물등록처리

게시물등록시 첨부파일은 이미 업로드된 파일의 정보를 문자열로 받아서 처리합니다.

public class BoardDTO {

... 생략 ...
    
    // 첨부파일의 이름들
    private List<String> fileNames;
}

BoardDTO의 List<String> fileNames는 Board에서 set<BoardImage>타입으로 변환되어야만 합니다.

기존의 ModelMapper는 단순한 구조의 객체를 다른 타입의 객체로 만드는데는 편리하지만 다양한 처리가 필요한 경우에는 오히려 복잡하기 때문에 DTO객체를 엔티티객체로 변환하는 메소드를 작성하도록합니다.

BoardService인터페이스의 default메소드를 이용해서 처리합니다.

default Board dtoToEntity(BoardDTO boardDTO) {
    
    Board board = Board.builder()
            .bno(boardDTO.getBno())
            .title(boardDTO.getTitle())
            .content(boardDTO.getContent())
            .writer(boardDTO.getWriter())
            .build();
    
    if (boardDTO.getFileNames() != null) {
        boardDTO.getFileNames().forEach(fileName -> {
            String[] arr = fileName.split("_");
            board.addImage(arr[0], arr[1]);
        });
    }
    return board;
}
@Override
public long register(BoardDTO boardDTO) {
    Board board = dtoToEntity(boardDTO);

    long bno = boardRepository.save(board).getBno();

    return bno;
}

 

게시물 조회처리

Board엔티티객체를 BoardDTO타입으로 변환하는 처리 역시 BoardService인터페이스의 default메소드를 이용해서 처리합니다.

default BoardDTO entityToDTO(Board board) {
    
    BoardDTO boardDTO = BoardDTO.builder()
            .bno(board.getBno())
            .title(board.getTitle())
            .content(board.getContent())
            .writer(board.getWriter())
            .build();
    
    List<String> fileNames = board.getImageSet().stream().sorted().map(boardImage -> 
        boardImage.getUuid()+"_" + boardImage.getFileName()).collect(Collectors.toList());
    
    boardDTO.setFileNames(fileNames);
    
    return boardDTO;
}
@Override
public BoardDTO readOne(long bno) {
    
    // board_image까지 조인처리되는 findByWithImages()를 이용
    Optional<Board> result = boardRepository.findByIdWithImages(bno);

    Board board = result.orElseThrow();

    BoardDTO boardDTO = entityToDTO(board);

    return boardDTO;
}

 

게시물수정처리

게시물 수정시 Board의 clearImage()를 실행한 후 새로운파일들의 정보를 추가하도록 구성합니다.

@Override
public void modify(BoardDTO boardDTO) {
    Optional<Board> result = boardRepository.findById(boardDTO.getBno());

    Board board = result.orElseThrow();

    board.change(boardDTO.getTitle(), boardDTO.getContent());
    
    // 첨부파일의 처리
    board.clearImages();
    
    if (boardDTO.getFileNames() != null) {
        for (String fileName : boardDTO.getFileNames()) {
            String[] arr = fileName.split("_");
            board.addImage(arr[0], arr[1]);
        }
    }

    boardRepository.save(board);

}

 

게시물삭제처리

게시물의삭제처리는 '댓글'이 존재하지 않는 경우만을 고려해서 작성합니다. 만일 댓글이 있는 경우에도 삭제하려면 ReplyRepository를

BoardService에 주입하고 특정한 게시물의 모든 댓글을 삭제한 후에 게시물을 삭제하도록 작성해야합니다.

Board클래스에는 Cascade.Type.ALL과 orphanRemoval속성값이 true로 지정되어있으므로 게시물이 삭제되면 자동으로 해당 게시물의 BoardImage객체들도 같이 삭제되도록 구성되어있습니다.

 

게시물목록처리

BoardService의 마지막은 검색과 페이징처리가 필요한 목록을 처리하는 기능의 구현입니다.

@Override
public PageResponseDTO<BoardListAllDTO> listWithAll(PageRequestDTO pageRequestDTO) {
    String[] types = pageRequestDTO.getTypes();
    String keyword = pageRequestDTO.getKeyword();
    Pageable pageable = pageRequestDTO.getPageable("bno");
    
    Page<BoardListAllDTO> result = boardRepository.searchWithAll(types, keyword, pageable);
    
    return PageResponseDTO.<BoardListAllDTO>withAll()
            .pageRequestDTO(pageRequestDTO)
            .dtoList(result.getContent())
            .total((int) result.getTotalElements())
            .build();
}

 


 

4. 컨트롤러와 화면처리

첨부파일과 관련해서 가장많은 코드를 작성하게 되는 부분은 화면쪽의 자바스크립트코드입니다.

기능 설명
게시물 등록 모달창을 이용해서 첨부파일을 등록하고, 추가된 첨부파일은 섬네일로 표시
첨부파일의 삭제기능까지 적용, 첨부파일의 삭제시에는 업로드된 파일도 같이 삭제
게시물 목록 게시물과 함께 첨부파일을 목록상에서 출력
게시물 조회 해당 게시물에 속한 모든 첨부파일을 같이 출력
게시물 수정 게시물 조회 기능
첨부파일 삭제는 우선 화면에서만 안보이도록 처리하고 실제로 수정작업처리시 첨부파일도 같이 처리
게시물 삭제 해당 게시물 삭제 + 업로드된 모든 첨부파일 삭제

 

게시물과 첨부파일 등록 처리

게시물의 등록화면을 담당하는 register.html은 <form>의 sumbit()만으로 처리가 되었지만 Ajax를 이용하는 방식이 되면 자바스크립트를 이용하도록 수정해야합니다. register.html에는 <form>에 첨부파일을 추가할 수 있는 버튼을 추가합니다.

register.html의 <form>의 writer밑에 작성합니다.

<div class="input-group mb-3">
    <span class="input-group-text">이미지</span>
    <div class="float-end uploadHidden">
        <button type="button" class="btn btn-primary uploadFileBtn">파일추</button>
    </div>
</div>
<div class="my-4">
    <div class="float-end">
        <button type="submit" class="btn btn-primary submitBtn">등록</button>
        <button type="reset" class="btn btn-secondary">초기화</button>
    </div>
</div>

 

Axios처리를 위한 준비

register.html이 정상적으로 동작하기 위해서는 Axios를 이용해서 서버에 업로드처리가 되야하므로 별도의 자바스크립트파일을

upload.js파일로 작성해서 처리하도록 합니다.

async function uploadToServer(formObj) {
    console.log("upload to server...")
    console.log(formObj)
    
    const response = await axios({
        method: 'post',
        url: '/upload',
        data: formObj,
        headers: {
            'Content-Type': 'multipart/form-data'
        },
    });
    
    return response.data
}

async function removeFileToServer(uuid, fileName) {
    const response = await axios.delete('/remove/${uuid}_${fileName}')
    
    return response.data
}

register.html에는 Axios라이브러리와 upload.js파일을 이용하기위해서 <script>코드를 추가해야합니다.

            </form>
                </div><!--end card body-->
            </div><!--end card-->
        </div><!--end col-->
    </div><!--end row-->
    
    <!--첨부파일 섬네일을 보여줄부분-->
    <div class="row mt-3">
        <div class="col">
            <div class="container-fluid d-flex uploadResult" style="flex-wrap: wrap"></div>
        </div>
    </div>

    <!--첨부파일 추가를 위한 모달창-->
    <div class="modal uploadModal" tabindex="-1">
        <div class="modal-dialog">
            <div class="modal-content">
                <div class="modal-header">
                    <h5 class="modal-title">업로드파일</h5>
                    <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
                </div>
                <div class="modal-body">
                    <div class="input-group mb-3">
                        <input type="file" name="files" class="form-control" multiple>
                    </div>
                </div>
                <div class="modal-footer">
                    <button type="button" class="btn btn-primary uploadBtn">업로드</button>
                    <button type="button" class="btn btn-outline-dark closeUploadBtn">닫기</button>
                </div>
            </div>
        </div>
    </div><!--end registerModal-->

    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="/js/upload.js"></script>
</div>

 

기존의 <script>내에는 <form>태그의 '업로드파일'버튼을 눌렀을때 모달창이 보이도록 이벤트처리를 추가합니다.

<script layout:fragment="script" th:inline="javascript">

    ... 생략 ...
    
    // 업로드 모달
    const uploadModal = new bootstrap.Modal(document.querySelector(".uploadModal"))
    document.querySelector(".uploadFileBtn").addEventListener("click", function(e){
        e.stopPropagation()
        e.preventDefault()
        uploadModal.show()
    }, false)

</script>

 

모달창의 파일업로드와 섬네일출력

모달창에는 업로드라는 버튼이 있으므로 이를 이용해서 Axios로 파일업로드를 할수있습니다.

//모달창의 파일업로드와 섬네일 출력
document.querySelector(".uploadBtn").addEventListener("click", function(e){
    const formObj = new FormData();
    
    const fileInput = document.querySelector("input[name='files']")
    
    console.log(fileInput.files)
    
    const files = fileInput.files
    
    for (let i = 0; i < files.length; i++) {
        formObj.append("files", files[i]);
    }
    
    uploadToServer(formObj).then(result => {
        console.log(result)
        uploadModal.hide()
    }).catch(e => {
        uploadModal.hide()
    })
}, false)

업로드버튼에 대한 이벤트처리는 자바스크립트의 FormData객체를 이용해서 파일정보들을 추가하고 이를 upload.js에 정의한

uploadToServer()를 호출하는 방식으로 구성합니다.

브라우저를 실행하면 여러개의 파일을 한꺼번에 업로드하고 console.log()를 통해서 업로드결과를 JSON파일로 보여줍니다.

업로드가 성공적으로 처리되는것을 확인했다면, <div class="container-fluid d-flex uploadResult" style="flex-wrap: wrap">부분에 업로드된 결과를 부트스트랩 card를 만들어 출력해줍니다.

우선 Axios를 호출한 후에 결과는 다른 함수를 통해서 출력하도록 showUploadFile()을 호출하도록 수정합니다.

uploadToServer(formObj).then(result => {
    // console.log(result)
    for (const uploadResult of result) {
        showUploadFile(uploadResult)
    }
    uploadModal.hide()
}).catch(e => {
    uploadModal.hide()
})

showUploadFile()은 다음과 같이 구성합니다.

const uploadResult = document.querySelector(".uploadResult")
function showUploadFile({uuid, fileName, link}) {
    const str =
        `<div class="card col-4">
            <div class="card-header d-flex justify-content-center">
            ${fileName}
            <button class="btn-sm btn-danger" onclick="javascipt:removeFile('${uuid}','${fileName}', this)">X</button>
            </div>
            <div class="card-body">
                <img src="/view/${link}" data-src="${uuid + "_" + fileName}">
            </div>
         </div>`

    uploadResult.innerHTML += str
}

showUploadFile()이 동작하면 다음과 같은 섬네일이 보여지고 첨부파일을 삭제할수있는 버튼도 보이게됩니다.

 

첨부파일삭제

만일 잘못된 첨부파일을 추가했다면 화면에서 [X]버튼을 누르면 서버에서도 삭제하고 화면에서도 삭제할수 있어야합니다.

function removeFile(uuid, fileName, obj) {
    console.log(uuid)
    console.log(fileName)
    console.log(obj)
    
    const targetDiv = obj.closest(".card")
    
    removeFileToServer(uuid, fileName).then(data => {
        targetDiv.remove()
    })
}

브라우저에서 파일을 삭제하면 실제 저장된 폴더에서도 같이 삭제되는것을 확인할 수 있습니다.

 

게시물등록과정처리

최종적으로 <form>의 submit()동작시 업로드된 파일들의 정보를 <form>에 추가해서 같이 submit()하도록 수정합니다.

document.querySelector(".submitBtn").addEventListener("click", function(e){
    e.preventDefault()
    e.stopPropagation()
    
    const target = document.querySelector(".uploadHidden")
    const uploadFiles = uploadResult.querySelectorAll("img")
    
    let str = ''
    
    for (let i =0; i < uploadFiles.length; i++) {
        const uploadFile = uploadFiles[i]
        const imgLink = uploadFile.getAttribute("data-src")
        
        str += `<input type='hidden' name='fileNames' value="${imgLink}">`
    }
    
    target.innerHTML = str;
    // document.querySelector("form").submit();
}, false)

[Submit]버튼을 누르면 현재 남아있는 첨부파일들의 정보들을 읽어서 <input type='hidden'>을 생성해서 <form>에 추가하고

submit()을 수행하도록 작성했습니다. 마지막 submit()의 실행은 먼저 정상적으로 태그들이 생성되는지를 확인한 후에 주석해제합니다.

주석을 해제하면, BoardController에서 BoardDTO로 데이터를 수집/처리하게 됩니다.

등록에 대한 최종결과는 데이터베이스와 화면에서 확인할수있습니다.

 

게시물목록과 첨부파일처리

게시물목록은 게시물과 해당게시물의 첨부파일들을 같이 처리할수있으므로 화면목록에서 하나가 아닌 여러개의 파일들을 같이 보여줍니다.

현재 board_image테이블에는 테스트용으로 생성된 데이터들이 추가되어있으므로 등록작업이 완료되기전에 이전 첨부파일 데이터들을 삭제하고 등록화면을 통해서 만들어진 첨부파일데이터들만 남겨두도록 합니다.

 

BoardController는 BoardService의 listWithAll()메소드를 호출하도록 수정하고 BoardListAllDTO들을 반환하도록 수정합니다.

@GetMapping("/list")
public void list(PageRequestDTO pageRequestDTO, Model model) {

    // PageResponseDTO<BoardDTO> responseDTO = boardService.list(pageRequestDTO);
    PageResponseDTO<BoardListAllDTO> responseDTO = boardService.listWithAll(pageRequestDTO);

    model.addAttribute("responseDTO", responseDTO);
}

 

list.html수정

list.html에는 BoardListAllDTO객체의 boardImages를 이용해서 목록을 출력할때 첨부파일들을 보여주도록 수정합니다.

<tbody th:with="link = ${pageRequestDTO.getLink()}">
<tr th:each="dto:${responseDTO.dtoList}">
    <th scope="row">[[${dto.bno}]]</th>
    <td>
        <a th:href="|@{/board/read(bno =${dto.bno})}&${link}|" class="text-decoration-none">[[${dto.title}]]</a>
        <span class="badge progress-bar-success" style="background-color: #0a53be">[[${dto.replyCount}]]</span>
        <div th:if="${dto.boardImages != null && dto.boardImages.size() > 0}">
            <img style="width:100px" th:each="boardImage: ${dto.boardImages}" th:src="|/views/s_${boardImage.uuid}_${boardImage.fileName}|">
        </div>
    </td>
    <td>[[${dto.writer}]]</td>
    <td>[[${#temporals.format(dto.regDate, 'yyyy-MM-dd')}]]</td>
</tr>
</tbody>

 

게시물조회와 첨부파일

조회화면에는 게시물과 첨부파일들이 같이 BoardDTO타입의 객체로 전달되므로 이들을 출력하도록 구성합니다.

BoardDTO의 첨부파일에는 uuid와 fileName이 결합된 fileNames 리스트를 이용해서 화면에 원본이미지들을 보여주도록 수정합니다.

 <div class="my-4">
        <div class="float-end" th:with="link = ${pageRequestDTO.getLink()}">
            <a th:href="|@{/board/list}?${link}|" class="text-decoration-none">
                <button type="button" class="btn btn-primary">목록</button>
            </a>
            <a th:href="|@{/board/modify(bno=${dto.bno})}&${link}|" class="text-decoration-none">
                <button type="button" class="btn btn-secondary">수정</button>
            </a>
        </div>
    </div>
</div><!--end card body-->
<div class="col">
    <div class="card" th:if="${dto.fileNames != null && dto.fileNames.size() > 0}">
        <img class="card-img-top" th:each="fileName: ${dto.fileNames}" th:src="|/view/${fileName}|">
    </div>
</div>

 

게시물수정과 삭제

게시물수정과 삭제는 GET방식으로 동작하는 '/board/modify'에서 이루어집니다. 이때는 첨부파일의 섬네일 이미지만 보여줍니다.

modify.html에 첨부파일들을 보여주는 영역과 수정할때 사용하는 모달창을 추가해주도록 합니다.

</div><!--end card body-->
        </div>
    </div>
</div>

<!-- 첨부파일 썸네일을 보여줄 부분-->
<div class="row mt-3">
    <div class="col">
        <div class="container-fluid d-flex uploadResult" style="flex-wrap: wrap;">
            <th:block th:each="fileName: ${dto.fileNames}">
                <div class="card col-4" th:with="arr = ${fileName.split('_')}">
                    <div class="card-header d-flex justify-content-center">
                        [[${arr[1]}]]
                        <button class="btn-sm btn-danger" th:onclick="removeFile([[${arr[0]}]], [[${arr[1]}]], this)">
                            X
                        </button>
                    </div>
                    <div class="card-body">
                        <img th:src="|/view/s_${fileName}|" th:data-src="${fileName}">
                    </div>
                </div><!--end card-->
            </th:block>
        </div>
    </div>
</div>

<!-- 첨부파일추가를 위한 모달창-->
<div class="modal uploadModal" tabindex="-1">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">업로드파일</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
            </div>
            <div class="modal-body">
                <div class="input-group mb-3">
                    <input type="file" name="files" class="form-control" multiple>
                </div>
            </div>
            <div class="modal-footer">
                <button type="button" class="btn btn-primary uploadBtn">업로드</button>
                <button type="button" class="btn btn-outline-dark closeUploadBtn">닫기</button>
            </div>
        </div>
    </div>
</div><!--end register modal-->

 

첨부파일 삭제

첨부파일 삭제는 등록과 조금다르게 삭제시에는 화면에서 보이지 않도록만 처리하고, 최종적으로 [수정]버튼을 눌렀을때 서버에서 파일을 삭제하도록 구성합니다. 첨부파일의 [X]버튼을 누르면 동작하는 removeFile()함수를 추가합니다.

// 최종적으로 삭제될 파일들의 목록
const removeFileList = [];

function removeFile(uuid, fileName, obj) {
    if (!confirm("파일들을 삭제하시겠습니까?")) return
    console.log(uuid)
    console.log(fileName)
    console.log(obj)
    
    removeFileList.push({uuid, fileName})
    
    const targetDiv = obj.closest(".card")
    targetDiv.remove()
}

 

새로운 첨부파일추가

게시물을 수정할때는 새로운 첨부파일을 추가하는 경우도 있으므로 게시물등록과 마찬가지로 첨부파일을 추가할수있는 버튼과 업로드를 위한 upload.js파일이 필요합니다. 게시물의 작성자(writer)아래에 첨부파일추가를 위한 버튼을 추가합니다.

<div class="input-group mb-3">
    <span class="input-group-text">이미지</span>
    <div class="float-end uploadHidden">
        <button type="button" class="btn btn-primary uploadFileBtn">파일추가</button>
    </div>
</div>

axios와 upload.js를 추가합니다.

    </div><!--end register modal-->
    
    <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
    <script src="/js/upload.js"></script>
</div>

첨부파일을 추가하기위한 버튼의 이벤트처리와 첨부된파일을 출력하는 자바스크립트코드를 추가합니다.

// 업로드 모달
const uploadModal = new bootstrap.Modal(document.querySelector(".uploadModal"))

document.querySelector(".uploadFileBtn").addEventListener("click", function(e){
    e.stopPropagation()
    e.preventDefault()
    uploadModal.show()
}, false)

document.querySelector(".uploadBtn").addEventListener("click", function (e){
    const formObj = new FormData();
    const fileInput = document.querySelector("input[name='files']")
    console.log(fileInput.files)
    const files = fileInput.files
    for (let i =0; i <files.length; i++){
        formObj.append("files", files[i])
    }

    uploadToServer(formObj).then(result => {
        console.log("result :" + result)
        for (const uploadResult of result){
            showUploadFile(uploadResult)
        }
        uploadModal.hide()
    }).catch(e => {
        uploadModal.hide()
    }, false)

    const uploadResult = document.querySelector(".uploadResult")
    function showUploadFile({uuid, fileName, link}){
        const str =
            `<div class="card col-4">
                <div class="card-header d-flex justify-content-center">
                    ${fileName}
                    <button class="btn-sm btn-danger" onclick="javascript:removeFile('${uuid}', '${fileName}', this)">X</button>
                </div>
                <div class="card-body">
                    <img src="/view/${link}" data-src="${uuid}+"_"+${fileName}">
                </div>
            </div>`
        uploadResult.innerHTML += str
    }

}, false)

위의 코드가 적용되면 브라우저에서 새로운 첨부파일을 추가하거나 삭제하고싶은 첨부파일을 화면에서 사라지게 할수있게 됩니다.

 

게시물수정

게시물수정은 등록과 유사하지만 삭제하려고 했던 첨부파일을 삭제하도록 호출하는 과정이 하나 더 필요합니다.

기존 <form>의 submit()이벤트처리전에 현재의 첨부파일들을 <input type='hidden'..>으로 추가하는 appendFileData()함수와

삭제하기로 결정한 파일들을 Ajax로 호출하는 callRemoveFiles()를 추가적으로 작성합니다.

document.querySelector(".modBtn").addEventListener("click", function (e) {
    e.preventDefault()
    e.stopPropagation()

    formObj.action = `/board/modify?${link}`
    
    // 첨부파일을 <input type='hidden'..>으로 추가
    appendFileData()
    
    // 삭제대상 파일들의 삭제
    callRemoveFiles()
    
    formObj.method = 'post'
    formObj.submit()

}, false)

function appendFileData() {
    
    const target = document.querySelector(".uploadHidden")
    const uploadFiles = uploadResult.querySelector("img")
    
    let str = ''
    
    for (let i = 0; i < uploadFiles.length; i++) {
        const uploadFile = uploadFiles[i]
        const imgLink = uploadFile.getAttribute("data-src")
        
        str += `<input type='hidden' name='fileNames' value="${imgLink}">`
    }
    target.innerHTML += str;
}

function callRemoveFiles() {
    
    removeFileList.forEach(({uuid, fileName}) => {
        removeFileToServer({uuid, fileName})
    })
}

 

게시물삭제

게시물삭제는 우선 다른 사용자들이 추가한 댓글이 없다는 상황에서 이루어진다고 가정하고 작성하겠습니다.

주의할점은 DB상에서 게시글이나 첨부파일 데이터를 삭제해야하지만, 해당 게시물이 가진 첨부파일들도 삭제해 주어야 한다는 점입니다.

이를위해서 BoardController에 삭제를 요구할때 게시물의 번호(bno)만 전송하는 방식대신 현재화면에 있는 첨부파일들과

게시물수정과정에서 안보이게 처리된 파일들의 목록도 같이 전송해서 서버에서 게시물이 성공적으로 삭제된 후 첨부파일들을 삭제하도록 합니다.

화면에서 [삭제]버튼을 눌렀을때의 이벤트처리는 다음과 같이 수정합니다.

document.querySelector(".removeBtn").addEventListener("click", function (e) {
    e.preventDefault()
    e.stopPropagation()

    // 화면에 보이는 파일들을 form 태그에 추가
    appendFileData()
    // 화면에서 안 보이도록 처리된 파일들을 form태그에 추가
    appendNotShownData()

    formObj.action = `/board/remove`
    formObj.method = 'post'
    formObj.submit()
}, false)
function appendNotShownData() {
    if (removeFileList.length == 0) {
        return
    }
    const target = document.querySelector(".uploadHidden")
    let str = ''
    
    for (let i = 0; i < removeFileList.length; i++) {
        const {uuid, fileName} = removeFileList[i];
        
        str += `<input type='hidden' name='fileNames' value="${uuid}_${fileName}">`
    }
    target.innerHTML += str;
}

 

BoardController의 삭제처리

formObj.submit()이 실행되면 BoardController가 호출되는데 이때 기존과 달리 BoardDTO를 이용해서 게시물삭제 이후에

첨부파일들을 삭제하도록 구성합니다. BoardController에는 실제파일삭제도 이루어지므로 첨부파일 경로를 주입받습니다.

public class BoardController {
    
    @Value("${org.zerock.upload.path") // import시에 springframework로 시작하는 Value
    private String uploadPath;

    private final BoardService boardService;

기존에 bno파라미터만 수집하는 remove()메소드를 BoardDTO를 파라미터로 변경하고 BoardDTO에 수집된 fileNames를 이용해서

파일들을 삭제하는 메소드를 추가합니다.

@PostMapping("/remove")
public String remove(BoardDTO boardDTO, RedirectAttributes redirectAttributes) {

    long bno = boardDTO.getBno();
    log.info("remove post... " + bno);
    
    boardService.remove(bno);
    
    // 게시물이 데이터베이스상에서 삭제되었다면 첨부파일 삭제
    log.info(boardDTO.getFileNames());
    List<String> fileNames = boardDTO.getFileNames();
    if (fileNames != null && fileNames.size() > 0) {
        removeFiles(fileNames);
    }
    
    redirectAttributes.addFlashAttribute("result", "removed");

    return "redirect:/board/list";

}

public void removeFiles(List<String> files) {
    for (String fileName: files) {
        Resource resource = new FileSystemResource(uploadPath + File.separator + fileName);
        
        String resourceName = resource.getFilename();
        
        try {
            String contentType = Files.probeContentType(resource.getFile().toPath());
            resource.getFile().delete();
            
            // 썸네일이 존재하면
            if (contentType.startsWith("image")){
                File thumbnailFile = new File(uploadPath + File.separator + "s_" + fileName);
                thumbnailFile.delete();
            }
        } catch (Exception e) {
            log.error(e.getMessage());
        }
    }
}

게시물을 삭제할째는 fileNames라는 이름의 파라미터로 삭제해야하는 모든 파일들의 정보를 전달하고 BoardService에서 삭제가 성공적으로 이루어 진다면 BoardController에서는 업로드되어있는 파일들을 삭제합니다.

반응형