7-1 첨부파일과 @OneToMany
업로드 처리를 위한 DTO
파일 업로드는 MulipartFile이라는 API를 이용해서 처리합니다.
@Data
public class UploadFileDTO {
private List<MultipartFile> files;
}
@RestController
@Log4j2
public class UpDownController {
@ApiOperation(value="Upload POST", notes="POST 방식으로 파일 등록")
@PostMapping(value="/upload", consumes = MediaType.MULTIPART_FROM_DATA_VALUE)
public String upload(UploadFileDTO uploadFileDTO) {
log.info(uploadFileDTO);
return null;
}
}
UpDownController은 파일 업로드와 파일을 보여주는 기능을 처리할 것입니다. @RestController로 설정하고 파일의 업로드를 처리하기 위해 위와 같이 작성합니다.
실제 파일을 처리할 때는 파일의 저장 경로가 필요하므로 application.properties의 설정 정보는 @Value를 이용해서 처리합니다.
@RestController
@Log4j2
public class UpDownController {
@Value("${org.zerock.upload.path")
private String uploadPath;
@ApiOperation(value="Upload POST", notes="POST 방식으로 파일 등록")
@PostMapping(value="/upload", consumes = MediaType.MULTIPART_FROM_DATA_VALUE)
public String upload(UploadFileDTO uploadFileDTO) {
log.info(uploadFileDTO);
if(uploadFileDTO.getFiles()!=null){
uploadFileDTO.getFiles().forEach(multipartFile->{
log.info(multipartFile.getOriginalFilename());
});
}
return null;
}
}
@Value는 application.properties 파일의 설정 정보를 읽어서 변수의 값으로 사용할 수 있습니다. uploadPath는 나중에 파일을 업로드하는 경로로 사용합니다.
첨부파일 저장
파일을 저장할 떄 같은 이름의 파일이 문제가 됩니다. 해결하기 위해 가장 많이 사용되는 방법은 java.util.UUID를 이용해 새로운 값을 만들어내는 방법입니다.
@ApiOperation(value="Upload POST", notes="POST 방식으로 파일 등록")
@PostMapping(value="/upload", consumes = MediaType.MULTIPART_FROM_DATA_VALUE)
public String upload(UploadFileDTO uploadFileDTO) {
log.info(uploadFileDTO);
if(uploadFileDTO.getFiles()!=null){
uploadFileDTO.getFiles().forEach(multipartFile->{
String originalName=multipartFile.getOriginalFilename();
log.info(originalName);
String uuid= UUID.randomUUID().toString();
Path savePath=Paths.get(uploadPath, uuid+"-"+originalName);
try{
multipartFile.transferTo(savePath);//실제 파일 저장
}catch(IOException e){
e.printStackTrace();
}
});
}
return null;
}
Thumnail 파일 처리
첨부파일이 이미지일 때는 용량을 줄여서 작은 이미지를 만들고 나중에 사용하도록 구성해야합니다. 이를 위해 Thumnailator라이브러리를 이용하도록 합니다.
섬네일 이미지는 업로드하는 파일이 이미지일 때만 처리하도록 구성해야 하고 파일 이름은 맨 앞에 's_'로 시작하도록 구성합니다.
multipartFile.transferTo(savePath);
if(Files.probeContentType(saveParh).startsWith("image")){
File thumbFile = new File(uploadPath, "s_"+uuid+"_"+originalName);
Thumbnailator.createThumbnail(savePath.toFile(), thumbFile, 200,200);
해당 파일이 이미지 파일이라면 섬네일을 생성하도록 합니다.
업로드 결과의 반환 처리
여러 개의 파일이 업로드 되면 업로드 결과도 여러 개 발생하게 되고 여러 정보를 반환해야 하므로 별도의 DTO를 구성해야 합니다.
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UploadResultDTO {
private String uuid;
private String fileNaem;
private boolean img;
private String getLink(){
if(img){
return "s_"+uuid+"_"+fileNaem;
}else{
return uuid+"_"+fileNaem;
}
}
}
업로드 된 파일의 uuid값과 파일 이름, 이미지 여부를 객체로 구성하고 getLink()를 통해서 첨부파일의 경로 처리에 사용합니다.
첨부파일 조회
첨부파일 조회는 가능하면 GET방식으로 가능하도록 설정합니다. 나중에 보안 문제가 발생하므로 코드를 통해서 접근 여부를 허용하도록 컨트롤러를 이용하는 것이 좋습니다.
첨부파일 삭제
조회와 비슷한 DELETE 방식의 호출하는 형태로 구현할 수 있습니다. 이미지라면 섬네일이 존재할 수 있으므로 같이 삭제하도록 구현합니다.
7-2 @OneToMany
@OneToMany 적용
@OneToMany는 기본적으로 상위 엔티티와 여러 개의 하위 엔티티들의 구조로 이루어집니다. @ManyToOne과 결정적으로 다른 점은 @ManyToOne은 다른 엔티티 객체의 참조로 FK를 가지는 쪽에서 하는 방식이고 @OneToMany는 PK를 가진 쪽에서 사용한다는 점입니다. @OneToMany를 사용하는 구조는 다음과 같은 특징을 갖습니다.
- 상위 엔티티에서 하위 엔티티들을 관리한다.
- JPA의 Repository를 상위 엔티티 기준으로 생성한다. 하위 엔티티에 대한 Repositoy의 생성이 잘못된 것은 아니지만 하위 엔티티들의 변경은 상위 엔티티에도 반영되어야 한다.
- 상위 엔티티 상태가 변경되면 하위 엔티티들의 상태들도 같이 처리해야 한다.
- 상위 엔티티 하나와 하나의 엔티티 여러 개를 처리한느 경우 'N+1' 문제가 발생할 수 있으므로 주의한다.
BoardImage 클래스 생성
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "board")
public class BoardImage implements Comparable<BoardImage>{
@Id
private String uuid;
private String filenName;
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;
}
}
고유한 uuid 값, 파일의 이름, 순번(ord)를 지정하고 @ManyToOne으로 Board객체를 지정합니다.
Comparable 인터페이스를 적용하는데 이는 @OneToMany 처리에서 순번에 맞게 정렬하기 위함입니다.
Board 클래스에 @OneToMany 적용
테이블 생성 확인과 mappedBy
엔티티 테이블 사이에 생성되는 테이블을 매핑 테이블이라고 부르는데 매핑 테이블을 생성하지 않는 방법은
1. 단방향으로 @OneTomMany를 이용하는 경우 @JoinColumn을 이용한다.
2. mappedBy라는 속성을 이용한다.
mappedBy의 경우 Board와 BoardImage가 서로 참조를 유지하는 양방향 참조 상황에서 사용되는데 mappedBy는 어떤 엔티티의 속성으로 매핑되는지를 의미합니다.
영속성의 전이(cascade)
상위 엔티티와 하위 엔티티의 연관 관계를 상위 엔티티에서 관리하는 경우 가장 신경써야 하는 가장 중요한 점 중에 하나는 상위 엔티티 객체의 상태가 변경되었을 때 하위 엔티티 객체들 역시 같이 영향을 받는다는 점입니다.
JPA에서는 영속성의 전이라는 용어로 표현하는데 가장 대표적인 것이 Board와 BoardImage의 저장입니다.
JPA에서는 연관 관계에 cascade 속성을 부여해서 이를 제어합니다.
cascade 속성값 | 설명 |
PERSIST REMOVE |
상위 엔티티가 영속 처리될 때 하위 엔티티들도 같이 영속 처리 |
MERGE REFRESH DETACH |
상위 엔티티의 상태가 변경될 때 하위 엔티티들도 같이 상태 변경 |
ALL | 상위 엔티티의 모든 상태 변경이 하위 엔티티에 적용 |
Board와 BoardImage의 insert
@OneToMany(mappedBy = "board",
cascade = {CascadeType.ALL},
fetch = FetchType.LAZY)
@Builder.Default
private Set<BoardImage> images = new HashSet<>();
public void addImage(String uuid, String fileName){
BoardImgae boardImgae = BoardImage.builder()
.uuid(uuid)
.filenName(fileName)
.board(this)
.ord(imageSet.size())
.build();
imageSet.add(boardImage);
}
public void clearImages(){
imageSet.forEach(boardImage->boardImage.changeBoard(null));
this.imageSet.clear();
}
cascade 속성값으로 CascadeType.ALL을 지정해서 Board 엔티티 객체의 모든 상태 변화에 BoardImage 객체들도 같이 변경되도록 구성했습니다. Board객체 자체에서 BoardImage 객체들을 관리하도록 addImage()와 clearImages()를 이용해 관리합니다.
@Test
public void testInsertWithImages() {
Board board = Board.builder()
.title("Image Test")
.content("첨부파일 테스트")
.writer("testr")
.build();
for (int i = 0; i < 3; i++) {
board.addImage(UUID.randomUUID().toString(), "file" + i + ".jpg");
}
boardRepository.save(board);
}
Lazy로딩과 @EntityGraph
@OneToMany의 로딩 방식은 기본적으로 지연(lazy) 로딩입니다. 게시물을 조회하는 경우 Board객체와 BoardImage 객체들을 생성해야 하므로 2번의 select가 필요합니다.
@EntityGraph와 조회 테스트
하위 엔티티를 로딩하는 가장 간단한 방법은 즉시 로딩을 적용하는 것이지만 지연로딩을 이용하는 것이 기본이므로 @EntityGraph를 이용해보겠습니다. 지연로딩이더라도 한번에 조인처리해서 select가 이루어지도록 하는 방법을 사용하겠습니다.
@Test
public void testReadWithImages() {
Optional<Board> result=boardRepository.finbyIdWithImages(1L);
Board board=result.orElseThrow();
log.info(board);
for(BoardImage boardImage : board.getImageSet()){
log.info(boardImage)
}
}
실행 결과를 보면 board 테이블과 board_image 테이블의 조인 처리가 된 상태로 select가 실행되면서 한번에 처리할 수 있게됩니다.
게시물과 첨부파일 수정
게시물과 첨부파일 수정은 다른 엔티티들 간의 관계와는 조금 다른점이 있습니다. 첨부파일은 그 자체가 변경되는 것이 아니라 아예 기존의 모든 첨부파일들이 삭제되고 첨부파일들로 추가되기 때문입니다.
orphanRemoval 속성
@Transactional
@Commit
@Test
public void testModifyImages(){
Optional<Board> result = boardRepository.findByIdWithImages(1L);
Board board = result.orElseThrow();
//기존 첨부파일들 삭제
board.clearImages();
//새로운 첨부파일
for(int i=0;i<2;i++){
board.addImage(UUID.randomUUID().toString(), "file" + i + ".jpg");
}
boardRepository.save(board);
}
실행 결과를 예상과 다른 결과를 확인할 수 있습니다.
현재 cascade 속성이 ALL로 지정되어 있어 상위 엔티티의 변화가 하위 엔티티까지 영향을 주긴했지만 삭제되지는 않았습니다. 만일 하위 엔티티의 참조가 더 이상 없는 상태가 되면 @OneToMany에 orphanRemoval 속성값을 true로 지정해주어야 삭제가 이루어집니다.
@OneToMany(mappedBy = "board",
cascade = {CascadeType.ALL},
fetch = FetchType.LAZY,
orphanRemoval = true)
@Builder.Default
private Set<BoardImage> images = new HashSet<>();
게시물과 첨부파일 삭제
게시물 삭제에는 게시물을 사용하는 댓글들을 먼저 삭제해야 합니다. 다른 사용자가 만든 데이터를 삭제하는 것은 문제가 될 수 있으므로 주의합니다.
N+1 문제와 @BatchSize
상위 엔티티에서 @OneToMany과 같은 연관 관계를 유지하는 경우 한번에 게시물과 첨부 파일을 같이 처리할 수 있다는 장점이 있기는 하지만 목록을 처리할 때는 예상하지 못한 문제를 만들어낼 수 있습니다.
N+1로 실행되는 쿼리는 데이터베이스를 엄청나게 많이 사용하므로 문제가 됩니다. 가장 간단한 보안책은 @BatchSize를 이용하는 것입니다. size라는 속성을 지정하는 데 이를 이용해 N번에 해당하는 쿼리를 모아서 한 번에 실행합니다.
서비스 계층과 DTO
게시물 등록 처리
게시물 등록 시에 첨부파일은 이미 업로드된 파일의 정보를 문자열로 받아서 처리할 것이므로 등록에 사용할 BoardDTO에는 파일 이름을 리스트로 처리합니다.
DTO를 엔티티로 변환하기
기존의 ModelMapper은 단순한 구조의 객체를 다른 타입의 객체로 만드는데는 편리하지만 다양한 처리가 필요한 경우에은 더 복잡하기 때문에 DTO객체를 엔티티로 변환하는 메소드를 작성할 것입니다.
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.getFileName().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;
}
게시물 조회 처리
default BoardDTO entityToDTO(Board borad){
BoardDTO boardDTO=BoardDTO.builder()
.bno(boardDTO.getBno())
.title(boardDTO.getTitle())
.content(boardDTO.getContent())
.writer(boardDTO.getWriter())
.regDate(board.getRegDate())
.modDate(board.getModDate())
.build();
Lifst<String> fileNames=
board.getImageSet().stream().sorted().map(boardImage ->
boardImage.getUuid()+"_"+boardImage.getFileName()).collect(Collectors.toList());
boardDTO.setFileNames(fileNames);
return boardDTO;
}
'자바웹개발 워크북' 카테고리의 다른 글
자바웹개발 워크북(8) (0) | 2025.01.26 |
---|---|
자바웹개발 워크북(6) (1) | 2025.01.20 |
자바웹개발 워크북(5) (0) | 2025.01.16 |
자바웹개발 워크북(4) (0) | 2025.01.05 |
자바웹개발 워크북(3) (0) | 2025.01.02 |