연관관계 | 다대일 연관관계 | 일대다 연관관계 |
기준 | 다른 엔티티 객체의 참조로 FK가 속하는 쪽 기준 | PK를 가진 쪽 기준 |
어노테이션 | @ManyToOne | @OneToMany |
로딩 방식 기본값 | fetchType.EAGER | fetchType.LAZY |
특징 | 'N+1' 문제 발생 주의 간편한 하위 엔티티 관리 |
요구사항
- 일대다 연관관계
- @OneToMany를 이용해 일대다 연관관계 처리
- 영속성 전이를 이용해, 일대다 관계에 있는 엔티티 객체 삭제 처리
- 참조 방식
- 단방향 : @JoinColumn을 이용해 참조관계 형성
- 양방향
- 매핑 테이블 이용해 양방향 참조관계 형성
- mappedBy 속성을 이용해 양방향 참조관계 형성
일대다 연관관계 - 매핑 테이블 생성하는 양방향 참조관계
@ManyToOne : 댓글의 관점에서 게시물을 바라보는 관계
: 다른 엔티티 객체의 참조로 FK가 속하는 쪽 기준
@OneToMany : 게시물의 관점에서 첨부파일을 바라보는 관계
: PK를 가진 쪽 기준
- 상위 엔티티에서 하위 엔티티 관리
- 상위 엔티티 기준의 리포지토리로, 하위 엔티티 변경시 상위 엔티티에도 반영되어야 한다.
- 상위 엔티티 객체의 상태 변경시 하위 엔티티의 상태도 변경 -> 영속성 전이
- 상위 엔티티 1 : 하위 엔티티 N의 경우 'N+1' 문제 발생 주의
1. 하위 엔티티 클래스 작성
-@ManyToOne 어노테이션 지정해 하위 엔티티인 BoardImage 엔티티 클래스 작성
-첨부파일의 순번에 맞게 정렬하는 메서드
-영속성 전이를 위한 메서드 작성
- 엔티티 명 : BoardImage
- PK : uuid
- 상위 엔티티 : Board
- 상속하는 인터페이스 : Comparabe<BoardImage>
- 오버라이딩 메서드 : compareTo()
-상위 엔티티에서 하위엔티티를 순번에 맞게 정렬하기 위함 - Setter 메서드 : changeBoard()
-상위 엔티티 삭제시 하위 엔티티 객체의 참조 변경을 위함
package org.zerock.b01.domain;
import io.swagger.annotations.Api;
import lombok.*;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.ManyToOne;
@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 boardImage) {
return this.ord- boardImage.ord;
}
public void changeBoard(Board board){
this.board=board;
}
}
2. 상위 엔티티에 어노테이션 적용
-양방향 참조관계 형성을 위해, @OneToMany 어노테이션 적용해 일대다 연관관계 형성
변경 전, Board 엔티티
package org.zerock.b01.domain;
import lombok.*;
import javax.persistence.*;
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Board extends BaseEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
@Column(length = 500, nullable = false)
private String title;
@Column(length = 2000, nullable = false)
private String content;
@Column(length = 50, nullable = false)
private String writer;
public void change(String title, String content){
this.title=title;
this.content=content;
}
}
변경 후, Board 엔티티
-중복된 이미지를 추가하지 않도록 설정
package org.zerock.b01.domain;
import lombok.*;
import javax.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
//연관관계에 있는 필드는 제외
@ToString(exclude = "imageSet")
public class Board extends BaseEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
@Column(length = 500, nullable = false)
private String title;
@Column(length = 2000, nullable = false)
private String content;
@Column(length = 50, nullable = false)
private String writer;
//하위 엔티티 필드 생성
//: 중복된 이미지를 제거하기 위한 HashSet 자료구조 사용
@OneToMany
@Builder.Default
private Set<BoardImage> imageSet=new HashSet<>();
public void change(String title, String content){
this.title=title;
this.content=content;
}
}
3. 상위 엔티티와 하위 엔티티의 양방향 참조 관계 형성
-양방향 참조 관계는 DB의 테이블에서는 표현할 수 없어서, JPA는 매핑 테이블을 자동으로 생성한다.
-각 엔티티에 해당하는 테이블을 독립적으로 생성하고, 양방향 참조 관계를 위한 중간에 매핑해주는 테이블이 생성
- 엔티티에 해당하는 테이블 : board, board_image_set
- @OneToMany로 인한 양방향 참조 관계를 위한 매핑 테이블 : board_image_set
일대다 연관관계 - 매핑 테이블 생성하지 않는 양방향 참조관계
mappedBy를 이용해 양방향 참조관계 형성
mappedBy : 연관 관계의 주인을 명시하는 나타내는 개념으로, 하위엔티티인 첨부파일의 관점에서 게시물을 참조하는 형태
1. mappedBy 속성으로 연관관계의 주인을 명시하도록 변경
변경 전, Board
package org.zerock.b01.domain;
import lombok.*;
import javax.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
//연관관계에 있는 필드는 제외
@ToString(exclude = "imageSet")
public class Board extends BaseEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
@Column(length = 500, nullable = false)
private String title;
@Column(length = 2000, nullable = false)
private String content;
@Column(length = 50, nullable = false)
private String writer;
//하위 엔티티 필드 생성
//: 중복된 이미지를 제거하기 위한 HashSet 자료구조 사용
@OneToMany
@Builder.Default
private Set<BoardImage> imageSet=new HashSet<>();
public void change(String title, String content){
this.title=title;
this.content=content;
}
}
변경 후, Board
-mappedBy 속성으로, 상위 엔티티에서 하위 엔티티의 필드에 연관관계 주인 명시
-연관관계의 주인은 변경이 많은 방향이 단방향에서 기준이며, 양방향의 경우 상위 엔티티가 연관관계의 주인이다.
package org.zerock.b01.domain;
import lombok.*;
import javax.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
//연관관계에 있는 필드는 제외
@ToString(exclude = "imageSet")
public class Board extends BaseEntity{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long bno;
@Column(length = 500, nullable = false)
private String title;
@Column(length = 2000, nullable = false)
private String content;
@Column(length = 50, nullable = false)
private String writer;
//하위 엔티티 필드 생성
//: 중복된 이미지를 제거하기 위한 HashSet 자료구조 사용
//mappedBy 속성으로 연관관계의 주인 명시함으로써, 매핑테이블 생성하지 않고 양방향 참조관계 설정
@OneToMany(mappedBy = "board")
@Builder.Default
private Set<BoardImage> imageSet=new HashSet<>();
public void change(String title, String content){
this.title=title;
this.content=content;
}
}
영속성 전이
상위 엔티티 객체의 상태 변경시 하위 엔티티의 상태도 변경하도록 엔티티의 상태를 관리
-Board와 BoardImage의 참조관계에서 게시물이 삭제되었을 때 게시물에 해당하는 첨부파일도 함께 삭제
-외래키 제약조건 (cascade / CONSTRAINT)
JPA에서 제공하는 cascade 속성
cascade 속성값 | 설명 |
PERSIST REMOVE |
상위 엔티티 영속 처리시 하위 엔티티도 영속 처리 |
MERGE REFRESH DETACH |
상위 엔티티 상태 변경시 하위 엔티티도 상태 변경 |
ALL | 상위 엔티티의 모든 상태 변경이 하위 엔티티에 적용 |
하위 엔티티의 리포지토리 생성 여부
- 상위 엔티티가 하위 엔티티 객체를 관리하는 경우 : 하위 엔티티의 리포지토리를 생성하지 않는다.
- 상위 엔티티 저장시 하위 엔티티도 함께 저장하는 게시물과 첨부파일 관계
- 상위 엔티티가 하위 엔티티 객체를 관리하지 않는 경우 : 하위 엔티티의 리포지토리를 생성한다.
1. 영속성 전이 구현
-상위 엔티티에서 하위 엔티티 객체들을 관리하도록 변경
-상위 엔티티와 하위 엔티티의 상태를 동일하게 유지하는 것이 좋다.
-@OneToMany 속성 : mappedBy, cascade, fetch, orphanRemoval
(1) 하위 엔티티와 상위 엔티티의 관계 설정 : 연관관계 기준, 영속성 전이 단계, fetch 타입
//하위 엔티티 필드 생성
//: 중복된 이미지를 제거하기 위한 HashSet 자료구조 사용
//mappedBy 속성으로 연관관계의 주인 명시함으로써, 매핑테이블 생성하지 않고 양방향 참조관계 설정
//cascade 속성으로 영속성 전이 단계 설정
//fetch 속성으로 언제 fetch할지 설정
@OneToMany(mappedBy = "board", cascade = {CascadeType.ALL}, fetch = FetchType.LAZY)
@Builder.Default
private Set<BoardImage> imageSet=new HashSet<>();
(2) 상위 엔티티 저장시 하위 엔티티도 함께 저장하는 addImage() 메서드 작성
public void addImage(String uuid, String fileName){
BoardImage boardImage=BoardImage.builder()
.uuid(uuid)
.fileName(fileName)
.board(this)
//순서는 이미지가 추가될 때마다, HashSet의 크기가 늘어나므로 size가 순서를 결정
.ord(imageSet.size())
.build();
//Hashset에 BoardImage 객체 추가
imageSet.add(boardImage);
}
(3) 상위 엔티티 삭제시 하위 엔티티도 함께 삭제하는 clearImages() 메서드 작성
public void clearImages(){
//이미지들을 모두 null처리 후 HashSet 지우기
imageSet.forEach(boardImage -> boardImage.changeBoard(null));
this.imageSet.clear();
}
(4) 상위 엔티티에서 하위 엔티티 관리하는 테스트 코드 작성
-BoardRepositoryTests에 첨부파일을 등록하는 메서드 작성
@Test
public void testInsertWithImage(){
Board board=Board.builder()
.title("Image Insert Test")
.content("첨부파일 테스트")
.writer("tester")
.build();
for(int i=0;i<3;i++){
board.addImage(UUID.randomUUID().toString(), "file"+i+".jpg");
}
boardRepository.save(board);
}
board insert-> board_image insert-> board_image insert-> board_image insert
: fetchType이 LAZY이기 때문에 DB 테이블의 접근은 필요할때까지 가장 마지막까지 미룬다.
Hibernate: insert into board (moddate, regdate, content, title, writer) values (?, ?, ?, ?, ?) Hibernate: insert into board_image (board_bno, file_name, ord, uuid) values (?, ?, ?, ?) Hibernate: insert into board_image (board_bno, file_name, ord, uuid) values (?, ?, ?, ?) Hibernate: insert into board_image (board_bno, file_name, ord, uuid) values (?, ?, ?, ?) |
Lazy 로딩과 @EntityGraph
지연로딩의 경우, 일대다 연관관계에서 게시물 조회시 에러 발생
: Board 객체와 BoardImage 객체를 생성할 때 두 번의 select 발생하므로
성능저하 관계없이 문제 해결하는 방법
- fetchType.EAGER로 변경
- @Transational : 필요할 때마다 메소드 내에서 쿼리를 여러번 실행
@EntityGraph
지연 로딩 사용시 여러번의 성능 저하를 막기 위해 DB 접근을 최소화하기 위해
필요한 데이터들을 한번의 조인으로 select가 이루어지도록 한다.
(1) 상위 엔티티의 리포지토리인 BoardRepository에 게시물번호로 이미지를 찾는 메서드 작성
-@EntityGraph : attributePaths 속성으로 필요한 필드 설정
-JPQL을 이용해 findByIdWithImages() 메서드 작성
- 메서드 명 : findByIdWithImages
- 파라미터 : Long bno
- 리턴타입 : Optional<Board>
@EntityGraph(attributePaths = {"imageSet"})
@Query("select b from Board b where b.bno=:bno")
Optional<Board> findByIdWithImages(Long bno);
(2) 테스트 코드 작성
@Test
public void testReadWithImages(){
//존재하는 글번호
Optional<Board> result = boardRepository.findByIdWithImages(1L);
Board board=result.orElseThrow();
log.info(board);
log.info("---");
//HashSet에서 BoardImage를 하나씩 꺼낸다.
for(BoardImage boardImage: board.getImageSet()){
log.info(boardImage);
}
}
-> board 테이블과 board_image 테이블이 조인된 상태로 select
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=? |
-> 외부조인으로 게시물의 이미지가 존재하지 않더라도 null로 출력
일대다 연관관계에서의 하위 엔티티 수정 및 삭제
하위 엔티티 객체 하나의 수정이 아니라, 모든 하위 엔티티가 삭제되고 새롭게 추가된다.
즉, addImage()와 clearImages() 두 메서드만을 이용해 수정 처리
(1) 상위 엔티티의 리포지토리에서 첨부파일을 수정하는 테스트 코드 작성
//특정 게시물의 첨부파일을 다른 파일들로 수정
@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(), "updatefile"+i+".jpg");
}
boardRepository.save(board);
}
-> cascade 속성이 ALL인, 상위 엔티티의 모든 상태 변경이 하위 엔티티에 적용되는 상태에서는 하위 엔티티만 단독으로 삭제 불가능
->삭제는 되지 않았고, board_bno가 null인 잘못된 로우가 추가된다.
(2) 상위 엔티티인 Board 클래스의 @OneToMany의 orphanRemoval 속성 변경
-cascade 속성 상관없이 하위 엔티티를 삭제할 수 있는 속성을 true 설정
//하위 엔티티 필드 생성
//: 중복된 이미지를 제거하기 위한 HashSet 자료구조 사용
//mappedBy 속성으로 연관관계의 주인 명시함으로써, 매핑테이블 생성하지 않고 양방향 참조관계 설정
//cascade 속성으로 영속성 전이 단계 설정
//fetch 속성으로 언제 fetch할지 설정
@OneToMany(mappedBy = "board", cascade = {CascadeType.ALL}, fetch = FetchType.LAZY, orphanRemoval = true)
@Builder.Default
private Set<BoardImage> imageSet=new HashSet<>();
다대일 연관 관계에서 상위 엔티티 삭제
-연관관계의 주인인 상위 엔티티를 삭제하기 위해서는 주인 엔티티를 참조하는 엔티티가 없어야 삭제가 가능하다.
-하지만, 사용자의 데이터를 함부로 삭제하는 것은 주의해야한다.
-하위 엔티티 먼저 삭제 후, 상위 엔티티가 삭제된다.
(1) 상위 엔티티인 특정 게시물을 참조하는 엔티티인 Reply를 모두 삭제하는 쿼리 메서드 작성
쿼리 메서드 작성
-키워드와 칼럼을 결합하는 쿼리
-'findBy', 'get'으로 시작해 칼럼명과 키워드를 결합한다.
Page<Board> findByTitleContainingOrderByBnoDesc(String keyword, Pageable pageable);
void deleteByBoard_Bno(Long bno);
(2) BoardRepositoryTests에 해당 쿼리 메서드를 수행하기 위해서 ReplyRepository 의존성 주입
-@Transactional, @Commit : 다른 테이블에도 접근을 해야하고, 삭제가 실제로 이루어져야 하므로
-하위 엔티티인 Reply 엔티티 삭제 후 상위 엔티티인 Board 엔티티 삭제
//주인 엔티티를 지우기 위해 참조하는 엔티티 모두 삭제
@Test
@Transactional
@Commit
public void testRemoveAll(){
Long bno=1L;
replyRepository.deleteByBoard_Bno(bno);
boardRepository.deleteById(bno);
}
(3) 상위 엔티티 삭제 동작 순서
- 상위 엔티티를 참조하는 Reply가 존재하는 경우 Reply 삭제
- 하위 엔티티인 BoardImage 삭제
- 상위 엔티티인 Board 삭제
Hibernate: select reply0_.rno as rno1_2_, reply0_.moddate as moddate2_2_, reply0_.regdate as regdate3_2_, reply0_.board_bno as board_bn6_2_, reply0_.reply_text as reply_te4_2_, reply0_.replyer as replyer5_2_ from reply reply0_ left outer join board board1_ on reply0_.board_bno=board1_.bno where board1_.bno=? Hibernate: select board0_.bno as bno1_0_0_, 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_ from board board0_ where board0_.bno=? Hibernate: select imageset0_.board_bno as board_bn4_1_0_, imageset0_.uuid as uuid1_1_0_, imageset0_.uuid as uuid1_1_1_, imageset0_.board_bno as board_bn4_1_1_, imageset0_.file_name as file_nam2_1_1_, imageset0_.ord as ord3_1_1_ from board_image imageset0_ where imageset0_.board_bno=? Hibernate: delete from board_image where uuid=? Hibernate: delete from board_image where uuid=? Hibernate: delete from board where bno=? |
'Server Programming > Spring Boot Backend Programming' 카테고리의 다른 글
7장-4. 이전 프로젝트에 이미지 추가 (0) | 2022.12.10 |
---|---|
7장-3. 일대다 연관관계의 N+1 문제와 @BatchSize (0) | 2022.12.09 |
7장-1. 다중 파일 업로드 처리 (+MultipartFile) (0) | 2022.12.09 |
6장-3. 댓글의 자바스크립트 처리 (+Axios, @JsonFormat, @JsonIgnore) (0) | 2022.12.08 |
6장-2. 다대일 연관관계를 이용한 댓글 처리 (+ 인덱스, Querydsl, Optional<T>, orElseThrow(), @RestControllerAdvice) (0) | 2022.12.08 |