이 글에서는 Spring Boot 초기 설정 및 FrontEnd 관련 내용은 다루지 않습니다.
또한 파일 중에서도 이미지 처리에 중점을 둔 점 참고해주시길 바랍니다.
저번 시간에 이어 이번에는 게시글 조회 및 삭제 시 다중 파일을 처리하는 부분을 구현해 볼 것이다. 필자는 중고거래 관련 프로젝트를 진행중이므로, 게시글을 조회할 때 첨부파일을 다운로드하기보다는 첨부파일(이미지)을 바로 게시판에 출력할 것이다. 고려해야 하는 부분들은 다음과 같다.
이 부분은 첨부파일을 저장하는 부분과 첨부파일을 삭제하는 부분으로 나눠서 생각해 볼 수 있다.
여기서 첨부파일을 수정하는 부분은 구현하지 않을것이다. 존재하는 첨부파일을 삭제하고 새로운 파일로 변경하는 것은 결국 파일 삭제 후 파일 저장를 하는 것과 같기 때문이다.
그렇다면 이제 어떻게 구현해야 할지 생각해보자.
일단, 서버(DB) 및 프론트(form)에 어떠한 파일도 존재하지 않는 경우와 한 개라도 존재하는 경우로 크게 나눠서 생각해보자.
전달되어온 파일 | DB에 존재 여부 | 처리 방식 |
---|---|---|
O | O | skip |
O | X | 저장 |
X | O | 삭제 |
X | X | skip |
위의 표를 참고하여 로직을 작성해보자면 다음과 같다.
게시글을 삭제할 때 첨부파일들도 같이 삭제해야 한다.
@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
옵션을 설정해주어 게시글을 삭제할 때 첨부파일들도 같이 삭제되게끔 처리하도록 했다.
앞서 첨부파일을 추가하는 부분과 삭제하는 부분으로 나눠서 로직을 구성했던 것처럼 코드를 수정해준다.
@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);
}
...
}
전달되어온 파일들이 DB에 존재하는지 확인하기 위하여 parseFileInfo
의 매개변수로 Board
객체를 전달받고, 존재 여부 확인 및 처리 코드를 작성한다.
@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
를 다음과 같이 수정하면 된다.
@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());
}
...
}
필자는 다음과 같은 로직으로 이미지 반환을 처리할 것이다.
<img>
태그를 이용하여 출력서버에서 클라이언트로 이미지를 리스트 형태로 반환해도 될텐데 왜 굳이 2번에 걸쳐 처리하려고 하는걸까?
우선, 이미지를 조회하는 메소드는 총 2가지를 생성한다.
기본적으로 게시글에 첨부되는 이미지를 조회하는 메소드와 게시글의 썸네일로 사용할 이미지를 조회하는 메소드를 작성한다. 이는 게시글에 첨부되는 이미지가 존재하지 않을 경우 기본 썸네일을 서버에서 클라이언트로 반환해주어야 하기 때문이다.
또한, 이미지는 Byte
배열 형태를 띄므로 Byte
값으로 반환해준다. 클라이언트에서 이미지임을 인식하게 하기 위해 @GetMapping
어노테이션의 속성으로 produces
를 설정해준다.
@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
타입으로 반환하면 오류가 발생하기 때문이다.
@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;
}
}
@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;
}
}
@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;
}
}
@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; // 서버에 저장된 기본 이미지 반환
}
}
@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());
}
}
@Getter
public class PhotoResponseDto {
private Long fileId; // 파일 id
public PhotoResponseDto(Photo entity){
this.fileId = entity.getId();
}
}
@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();
}
}
public interface PhotoRepository extends JpaRepository<Photo, Long> {
List<Photo> findAllByBoardId(Long boardId);
}
전달받은 게시글 id를 이용하여 첨부파일 목록을 모두 조회한다.
📖 참고
이미지를 static경로에 저장하도록 하고 이미지 요청시 해당 경로로 리다이렉트 하도록 하는건 어떤가요??
구현은 훨씬 쉽게 될거 같은데 부작용은 없을까요?