[Spring Boot] 게시판 구현 5 - 게시글 수정 및 삭제, 다중 파일(이미지) 반환 및 조회 처리 MultipartFile

joyful·2021년 5월 26일
13

Java/Spring

목록 보기
14/28

들어가기 앞서

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


저번 시간에 이어 이번에는 게시글 조회 및 삭제 시 다중 파일을 처리하는 부분을 구현해 볼 것이다. 필자는 중고거래 관련 프로젝트를 진행중이므로, 게시글을 조회할 때 첨부파일을 다운로드하기보다는 첨부파일(이미지)을 바로 게시판에 출력할 것이다. 고려해야 하는 부분들은 다음과 같다.

🔎 구현해야 할 기능

✅ 게시글 목록 조회시

  • 이미지 파일이 존재할 경우, 썸네일을 같이 출력한다. 단, DB에 저장된 이미지가 존재하지 않을 경우 기본 썸네일을 제공한다.
  • 게시글 상세 조회시 DB에 저장된 첨부파일들을 모두 출력한다.

✅ 게시글 수정시

이 부분은 첨부파일을 저장하는 부분과 첨부파일을 삭제하는 부분으로 나눠서 생각해 볼 수 있다.
여기서 첨부파일을 수정하는 부분은 구현하지 않을것이다. 존재하는 첨부파일을 삭제하고 새로운 파일로 변경하는 것은 결국 파일 삭제파일 저장를 하는 것과 같기 때문이다.

그렇다면 이제 어떻게 구현해야 할지 생각해보자.

일단, 서버(DB) 및 프론트(form)에 어떠한 파일도 존재하지 않는 경우한 개라도 존재하는 경우로 크게 나눠서 생각해보자.

📖 서버 혹은 클라이언트 둘 중 한 쪽이 파일이 아예 존재하지 않는 경우

  • 서버에 어떠한 파일도 존재하지 않는데 클라이언트에서 전달되어온 파일이 하나라도 있다면, 전달되어온 파일들을 저장하기만 하면 된다.
  • 클라이언트에서 전달되어온 파일은 없는데 서버에 파일이 하나라도 존재한다면, 서버에 저장된 파일들을 삭제만 하면 된다.

📖 서버와 클라이언트 모두 파일이 한 개라도 존재하는 경우

전달되어온 파일DB에 존재 여부처리 방식
OOskip
OX저장
XO삭제
XXskip

위의 표를 참고하여 로직을 작성해보자면 다음과 같다.

  • 클라이언트에서 전달되어온 파일이 서버에 존재한다면 어떤 처리도 하지 않는다.
  • 클라이언트에서 전달되어온 파일이 서버에 존재하지 않는다면 새롭게 첨부하길 원하는 파일이므로 서버에 저장한다.
  • 서버 내에 클라이언트에서 전달되어온 파일들 말고 다른 파일이 존재한다면 삭제하길 원하는 파일이므로 삭제 한다.
  • 서버에도, 클라이언트에도 존재하지 않는 파일이라면 추가 및 삭제를 할 필요가 없으므로 역시 어떠한 처리도 하지 않는다.

✅ 게시글 삭제시

게시글을 삭제할 때 첨부파일들도 같이 삭제해야 한다.


📌 게시글 수정 & 삭제

📝 Entity

Board.java

@Getter
@NoArgsConstructor(access = AccessLevel.PUBLIC)
@Entity
@Table(name = "board")
public class Board extends BaseTimeEntity {

    ...
    
    @OneToMany(
    	   mappedBy = "board",
    	   cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
    	   orphanRemoval = true
    )
    private List<Photo> photo = new ArrayList<>();
    
    ...
}

저번 시간에 작성한 코드를 그대로 사용한다.
위의 코드를 보면 @OneToMany 어노테이션의 옵션 중 cascade라는 부분이 있다.

엔티티 Cascade는 엔티티의 상태 변화를 전파시키는 옵션입니다. 단방향 혹은 양방향으로 매핑되어 있는 엔티티에 대해 어느 한쪽 엔티티의 상태(생성 혹은 삭제)가 변경되었을 시 그에 따른 변화를 바인딩된 엔티티들에게 전파하는 것을 의미합니다.

더 간단하게 설명하자면, 부모 엔티티의 상태가 변할 때 자식 엔티티의 상태도 같이 변화할 수 있다는 것이다.

따라서 CascadeType.REMOVE 옵션을 설정해주어 게시글을 삭제할 때 첨부파일들도 같이 삭제되게끔 처리하도록 했다.


📝 Controller

앞서 첨부파일을 추가하는 부분과 삭제하는 부분으로 나눠서 로직을 구성했던 것처럼 코드를 수정해준다.

BoardController

@RequiredArgsConstructor
@RestController
public class BoardController {

    private final BoardService boardService;
    private final PhotoService fileService;
    private final MemberService memberService;
    
    ...
        
    @PutMapping("/board/{id}")
    public Long update(@PathVariable Long id, BoardFileVO boardFileVO) throws Exception {

        BoardUpdateRequestDto requestDto = 
        	BoardUpdateRequestDto.builder()
            			     .title(boardFileVO.getTitle())
            			     .content(boardFileVO.getContent())
            			     .build();
        
        // DB에 저장되어있는 파일 불러오기 
        List<Photo> dbPhotoList = fileService.findAllByBoard(id);
        // 전달되어온 파일들
        List<MultipartFile> multipartList = boardFileVO.getFiles();
        // 새롭게 전달되어온 파일들의 목록을 저장할 List 선언
        List<MultipartFile> addFileList = new ArrayList<>();

        if(CollectionUtils.isEmpty(dbPhotoList)) { // DB에 아예 존재 x
            if(!CollectionUtils.isEmpty(multipartList)) { // 전달되어온 파일이 하나라도 존재
                for (MultipartFile multipartFile : multipartList)
                    addFileList.add(multipartFile);	// 저장할 파일 목록에 추가
            }
        }
        else {  // DB에 한 장 이상 존재
            if(CollectionUtils.isEmpty(multipartList)) { // 전달되어온 파일 아예 x
             	// 파일 삭제
                for(Photo dbPhoto : dbPhotoList)
                    fileService.deletePhoto(dbPhoto.getId());
            }
            else {  // 전달되어온 파일 한 장 이상 존재
            
            	// DB에 저장되어있는 파일 원본명 목록
                List<String> dbOriginNameList = new ArrayList<>();

		// DB의 파일 원본명 추출
                for(Photo dbPhoto : dbPhotoList) {
                    // file id로 DB에 저장된 파일 정보 얻어오기
                    PhotoDto dbPhotoDto = fileService.findByFileId(dbPhoto.getId());
                    // DB의 파일 원본명 얻어오기
                    String dbOrigFileName = dbPhotoDto.getOrigFileName();

                   if(!multipartList.contains(dbOrigFileName))  // 서버에 저장된 파일들 중 전달되어온 파일이 존재하지 않는다면
                        fileService.deletePhoto(dbPhoto.getId());  // 파일 삭제
                    else  // 그것도 아니라면
                        dbOriginNameList.add(dbOrigFileName);	// DB에 저장할 파일 목록에 추가
                }

                for (MultipartFile multipartFile : multipartList) { // 전달되어온 파일 하나씩 검사
                    // 파일의 원본명 얻어오기
                    String multipartOrigName = multipartFile.getOriginalFilename();
                    if(!dbOriginNameList.contains(multipartOrigName)){   // DB에 없는 파일이면
                        addFileList.add(multipartFile); // DB에 저장할 파일 목록에 추가
                    }
                }
            }
        }

	// 각각 인자로 게시글의 id, 수정할 정보, DB에 저장할 파일 목록을 넘겨주기
        return boardService.update(id, requestDto, addFileList);
    }

    ...
    
}

📝 Service

전달되어온 파일들이 DB에 존재하는지 확인하기 위하여 parseFileInfo의 매개변수로 Board 객체를 전달받고, 존재 여부 확인 및 처리 코드를 작성한다.

FileHandler

@Component
public class FileHandler {

    ...

    public List<Photo> parseFileInfo(
            Board board,  // Board에 존재하는 파일인지 확인하기 위함
            List<MultipartFile> multipartFiles
    ) throws Exception {
    
        ...

                    // 파일 DTO 이용하여 Photo 엔티티 생성
                    Photo photo = new Photo(
                            photoDto.getOrigFileName(),
                            photoDto.getFilePath(),
                            photoDto.getFileSize()
                    );

                    // 게시글에 존재 x → 게시글에 사진 정보 저장
                    if(board.getId() != null)
                        photo.setItem(item);

                    // 생성 후 리스트에 추가
                    fileList.add(photo);

                    ...

            }

        }

        return fileList;
    }
}

FileHandler를 수정했다면 BoardService를 다음과 같이 수정하면 된다.

BoardService

@RequiredArgsConstructor
@Service
public class BoardService {

    private final BoardRepository BoardRepository;
    private final PhotoRepository photoRepository;
    private final FileHandler fileHandler;

    ...
    
    @Transactional
    public void update(
    	Long id,
    	BoardUpdateRequestDto requestDto,
        List<MultipartFile> files
    ) throws Exception {
        Board board = boardRepository.findById(id).orElseThrow(()
                -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다."));

        List<Photo> photoList = fileHandler.parseFileInfo(board, files);

        if(!photoList.isEmpty()){
            for(Photo photo : photoList) {
                photoRepository.save(photo);
            }
        }

        board.update(requestDto.getTitle(),
        	     requestDto.getContent());
    }

    ...
   
}

📌 게시글 조회

필자는 다음과 같은 로직으로 이미지 반환을 처리할 것이다.

  1. 클라이언트 → 서버 : 게시글 id 전달하여 첨부된 이미지 전체 조회 요청
  2. 서버 → 클라이언트 : 이미지 id를 리스트 형태로 반환
  3. 클라이언트 → 서버 : 이미지 id를 전달하여 이미지 개별 조회 요청
  4. 서버 → 클라이언트 : 이미지 반환
  5. 클라이언트 : 반환 받은 이미지를 <img> 태그를 이용하여 출력

서버에서 클라이언트로 이미지를 리스트 형태로 반환해도 될텐데 왜 굳이 2번에 걸쳐 처리하려고 하는걸까?

📝 Controller

우선, 이미지를 조회하는 메소드는 총 2가지를 생성한다.
기본적으로 게시글에 첨부되는 이미지를 조회하는 메소드와 게시글의 썸네일로 사용할 이미지를 조회하는 메소드를 작성한다. 이는 게시글에 첨부되는 이미지가 존재하지 않을 경우 기본 썸네일을 서버에서 클라이언트로 반환해주어야 하기 때문이다.

또한, 이미지는 Byte 배열 형태를 띄므로 Byte 값으로 반환해준다. 클라이언트에서 이미지임을 인식하게 하기 위해 @GetMapping 어노테이션의 속성으로 produces를 설정해준다.

PhotoController

@RequiredArgsConstructor
@RestController
public class PhotoController {

    private final PhotoService photoService;

    /**
     * 썸네일용 이미지 조회
     */
    @CrossOrigin
    @GetMapping(
            value = "/thumbnail/{id}",
            // 출력하고자 하는 데이터 포맷 정의
            produces = {MediaType.IMAGE_PNG_VALUE, MediaType.IMAGE_JPEG_VALUE}
    )
    public ResponseEntity<byte[]> getThumbnail(@PathVariable Long id) throws IOException {
    
    	// 이미지가 저장된 절대 경로 추출
        String absolutePath =
        	new File("").getAbsolutePath() + File.separator + File.separator;
        String path;

	
        if(id != 0) {  // 전달되어 온 이미지가 기본 썸네일이 아닐 경우
            Photo photoDto = photoService.findByFileId(id);
            path = photoDto.getFilePath();
        }
        else {  // 전달되어 온 이미지가 기본 썸네일일 경우
            path = "images" + File.separator + "thumbnail" + File.separator + "thumbnail.png";
        }
        
	// FileInputstream의 객체를 생성하여
    	// 이미지가 저장된 경로를 byte[] 형태의 값으로 encoding
        InputStream imageStream = new FileInputStream(absolutePath + path);
        byte[] imageByteArray = IOUtils.toByteArray(imageStream);
        imageStream.close();
        
        return new ResponseEntity<>(imageByteArray, HttpStatus.OK);
    }

    /**
     * 이미지 개별 조회
     */
    @CrossOrigin
    @GetMapping(
            value = "/image/{id}",
            produces = {MediaType.IMAGE_PNG_VALUE, MediaType.IMAGE_JPEG_VALUE}
    )
    public ResponseEntity<byte[]> getImage(@PathVariable Long id) throws IOException {
        PhotoDto photoDto = photoService.findByFileId(id);
        String absolutePath
        	= new File("").getAbsolutePath() + File.separator + File.separator;
        String path = photoDto.getFilePath();

        InputStream imageStream = new FileInputStream(absolutePath + path);
        byte[] imageByteArray = IOUtils.toByteArray(imageStream);
        imageStream.close();
        
        return new ResponseEntity<>(imageByteArray, HttpStatus.OK);
    }
    
}

위 코드에서 사용된 반환 타입인 ResponseEntity헤더바디, 상태 코드로 구성된 http 응답을 나타낼 때 사용하는 클래스이다. 여기서 2번에 걸쳐 이미지 조회를 처리하는 이유를 알 수 있는데, http 요청한 번에 하나만 처리 가능하므로 List 타입으로 반환하면 오류가 발생하기 때문이다.

PhotoDto

@Getter
@NoArgsConstructor
public class PhotoDto {
    private String origFileName;
    private String filePath;
    private Long fileSize;

    @Builder
    public PhotoDto(String origFileName, String filePath, Long fileSize){
        this.origFileName = origFileName;
        this.filePath = filePath;
        this.fileSize = fileSize;
    }
}

BoardController

@RequiredArgsConstructor
@RestController
public class BoardController {
    private final BoardService boardService;
    
    ...

    /**
     * 개별 조회
     */
    @GetMapping("/board/{id}")
    public BoardResponseDto searchById(@PathVariable Long id) {
    
    	// 게시글 id로 해당 게시글 첨부파일 전체 조회
    	List<PhotoResponseDto> photoResponseDtoList =
        	fileService.findAllByBoard(id);
        // 게시글 첨부파일 id 담을 List 객체 생성 
        List<Long> photoId = new ArrayList<>();
      	// 각 첨부파일 id 추가
        for(PhotoResponseDto photoResponseDto : photoResponseDtoList)
            photoId.add(photoResponseDto.getFileid());
        
        // 게시글 id와 첨부파일 id 목록 전달받아 결과 반환
        return boardService.searchById(id, photoId);
    }
    
    /**
     * 전체 조회(목록)
     */
    @GetMapping("/board")
    public List<BoardListResponseDto> searchAllDesc() {
    
    	// 게시글 전체 조회
    	List<Board> boardList = itemService.searchAllDesc();
        // 반환할 List<BoardListResponseDto> 생성
        List<BoardListResponseDto> responseDtoList = new ArrayList<>();

        for(Board board : boardList){
            // 전체 조회하여 획득한 각 게시글 객체를 이용하여 BoardListResponseDto 생성
            BoardListResponseDto responseDto = new BoardListResponseDto(board);
            responseDtoList.add(responseDto);
        }

        return responseDtoList;
    }

}

BoardResponseDto

@Getter
public class BoardResponseDto {
    private Long id;
    private String member;
    private String title;
    private String content;
    private List<Long> fileId;  // 첨부 파일 id 목록

    public BoardResponseDto(Board entity, List<Long> fileId) {
        this.id = entity.getId();
        this.member = entity.getMember().getName();
        this.title = entity.getTitle();
        this.content = entity.getContent();
        this.fileId = fileId;
    }
}

BoardListResponseDto

@Getter
public class BoardListResponseDto {
    private Long id;
    private String member;
    private String title;
    private Long thumbnailId;  // 썸네일 id

    public BoardListResponseDto(Board entity) {
        this.id = entity.getId();
        this.member = entity.getMember().getName();
        this.title = entity.getTitle();
        
        if(!entity.getPhoto().isEmpty())  // 첨부파일 존재 o
            this.thumbnailId = entity.getPhoto().get(0).getId();  // 첫번째 이미지 반환
        else // 첨부파일 존재 x
            this.thumbnailId = 0L;  // 서버에 저장된 기본 이미지 반환
    }
}

📝 Service

PhotoService

@RequiredArgsConstructor
@Service
public class PhotoService {
    private final PhotoRepository photoRepository;

    /**
     * 이미지 개별 조회
     */
    @Transactional(readOnly = true)
    public PhotoDto findByFileId(Long id){

        Photo entity = photoRepository.findById(id).orElseThrow(()
                -> new IllegalArgumentException("해당 파일이 존재하지 않습니다."));

        PhotoDto photoDto = PhotoDto.builder()
                .origFileName(entity.getOrigFileName())
                .filePath(entity.getFilePath())
                .fileSize(entity.getFileSize())
                .build();

        return photoDto;
    }

    /**
     * 이미지 전체 조회
     */
    @Transactional(readOnly = true)
    public List<PhotoResponseDto> findAllByBoard(Long boardId){

        List<Photo> photoList = photoRepository.findAllByBoardId(boardId);

        return photoList.stream()
                .map(PhotoResponseDto::new)
                .collect(Collectors.toList());
    }
    
}

PhotoResponseDto

@Getter
public class PhotoResponseDto {
    private Long fileId;  // 파일 id

    public PhotoResponseDto(Photo entity){
        this.fileId = entity.getId();
    }
}

BoardService

@RequiredArgsConstructor
@Service
public class BoardService {

    private final BoardRepository BoardRepository;
    private final PhotoRepository photoRepository;
    private final FileHandler fileHandler;

    ...
    
    /**
     * 개별 조회
     * */
    @Transactional(readOnly = true)
    public BoardResponseDto searchById(Long id, List<Long> fileId){
        Board entity = boardRepository.findById(id).orElseThrow(()
                -> new IllegalArgumentException("해당 게시글이 존재하지 않습니다."));

        return new BoardResponseDto(entity, fileId);
    }
    
    /**
     * 전체 조회
     * */
    @Transactional(readOnly = true)
    public List<Board> searchAllDesc() {
        return boardRepository.findAllDesc();
    }
   
}

📝 Repository

PhotoRepository

public interface PhotoRepository extends JpaRepository<Photo, Long> {
	List<Photo> findAllByBoardId(Long boardId);
}

전달받은 게시글 id를 이용하여 첨부파일 목록을 모두 조회한다.



📖 참고

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

5개의 댓글

comment-user-thumbnail
2021년 12월 27일

이미지를 static경로에 저장하도록 하고 이미지 요청시 해당 경로로 리다이렉트 하도록 하는건 어떤가요??
구현은 훨씬 쉽게 될거 같은데 부작용은 없을까요?

1개의 답글
comment-user-thumbnail
2022년 4월 12일

originalName을 기준으로 삭제여부를판단하시는데
그러면 만약 기존에 첨부되어있던 이미지를 삭제하고 그 삭제된 이미지와 동일한 이름의 이미지를 첨부하면,
어떻게 되나요?

1개의 답글