영속성 전이(cascade)를 사용할 때 주의해야 할 점

34295 단어 JPASpringJPA

영속성 전이를 프로젝트에 적용하면서 꽤나 많은 우여곡절을 겪었는데, 여기에서 다시 한 번 배운 내용을 정리해보려 합니다.
본 게시글에 사용될 엔티티들의 코드는 다음과 같습니다.

Candidate Entity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Candidate {

    @Id @GeneratedValue
    @Column(name = "candidate_id")
    private Long id;

    private int number;

    @Column(name = "candidate_name")
    private String name;

    @Column(name = "candidate_likes")
    private int likes;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "city_id")
    private City city;

    @OneToMany(mappedBy = "candidate", cascade = CascadeType.ALL)
    private List<Sns> snsList = new ArrayList<>();

    @OneToMany(mappedBy = "candidate", cascade = CascadeType.ALL)
    private List<Youtube> youtubeList = new ArrayList<>();

    @Builder
    public Candidate(int number, String name, City city) {
        this.number = number;
        this.name = name;
        this.likes = 0;
        this.city = city;
    }

    //== 연관관계 편의 메서드 ==//
    public void addSns(Sns sns) {
        snsList.add(sns);
        sns.setCandidate(this);
    }
}

Sns Entity

@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "dtype")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Sns {

    @Id @GeneratedValue
    @Column(name = "sns_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "candidate_id")
    private Candidate candidate;

    @Column(columnDefinition = "Text")
    private String content;

    private String url;

    private LocalDateTime uploadDate;

    public Sns(String content, String url, LocalDateTime uploadDate) {
        this.content = content;
        this.url = url;
        this.uploadDate = uploadDate;
    }

    public void setCandidate(Candidate candidate) {
        this.candidate = candidate;
    }
}

Facebook Entity

@Entity
@DiscriminatorValue("F")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Facebook extends Sns{

    private int likes;
    private int comments;
    private int shares;

    @Builder
    public Facebook(String content, String url, LocalDateTime uploadDate, int likes, int comments, int shares) {
        super(content, url, uploadDate);
        this.likes = likes;
        this.comments = comments;
        this.shares = shares;
    }

    public void change(int likes, int comments, int shares) {
        this.likes = likes;
        this.comments = comments;
        this.shares = shares;
    }
}

당신의 엔티티, remove()를 수행해도 남아있지 않나요?

EntityManager = em 입니다.

    @Test
    public void 영속성전이_remove() throws Exception {
        //given
        Candidate candidate = createCandidate();
        Facebook facebook = new Facebook("content1", "url", LocalDateTime.now(), 1, 1, 1);
        candidate.addSns(facebook);
        candidateRepository.save(candidate);

        //when
        facebookRepository.remove(facebook); //em.remove(facebook); 과 같은 동작입니다.

        //then
        Assertions.assertTrue(em.contains(facebook));
    }
  • candidate 엔티티의 @OneToMany(mappedBy = "candidate", cascade = CascadeType.ALL) 어노테이션으로 인해, candidate가 persist 되는 시점에 facebook 또한 persist 되는 구조입니다.

  • Entity Manager에서 facebook을 remove() 해주었으니, 테스트는 성공하겠군요?!


성공했습니다!


그러나 쿼리를 날려보면...?

위 코드에서 em.flush()를 추가해서 살펴보겠습니다.

    @Test
    public void 영속성전이_remove() throws Exception {
        //given
        Candidate candidate = createCandidate();
        Facebook facebook = new Facebook("content1", "url", LocalDateTime.now(), 1, 1, 1);
        candidate.addSns(facebook);
        candidateRepository.save(candidate);

        //when
        facebookRepository.remove(facebook);
        em.flush();

        //then
        Assertions.assertTrue(em.contains(facebook));
    }

결과

띠용?! 같은 테스트 코드인데, flush()를 해줬다는 이유만으로 실패 테스트로 바뀌었습니다.
여기서 쿼리에 주목해야 하는데, insert 쿼리만 있고 delete 쿼리는 날아가지도 않았습니다.


이유

이유는 CascadeType.all, CascadeType.persist로 설정되어 있기 때문입니다.

EntityManager는 flush(), commit()될 때 영속 상태로 관리되고 있는 엔티티에 대해 쿼리를 날립니다.

  • 이 때 EntityManager에 아직 candidate 엔티티가 남아있는데, SnsList에 CascadeType.all 어노테이션이 달려있기 때문에 facebook까지 다시 영속성 컨텍스트에 등록되고, 또 insert 쿼리까지 날아가는 것입니다.

해결책1

candidate의 snsList에서도 facebook 객체를 지워주면 됩니다.

    @Test
    public void 영속성전이_remove() throws Exception {
        //given
        Candidate candidate = createCandidate();
        Facebook facebook = new Facebook("content1", "url", LocalDateTime.now(), 1, 1, 1);
        candidate.addSns(facebook);
        candidateRepository.save(candidate);

        //when
        em.remove(facebook);
        candidate.getSnsList().remove(0);//이 부분입니다
        em.flush();

        //then
        Assertions.assertFalse(em.contains(facebook));
    }

  • 영속성 컨텍스트에서 facebook 객체를 제거해주고
  • candidate의 snsList에서도 facebook 객체를 지워주면
  • em.flush()에서 쿼리가 나갈 때, 변경 감지 + CascadeType.all에 의해 영속성 컨텍스트에서 facebook 객체가 완전히 분리되면서 delete 쿼리가 나갑니다.

해결책2

candidate의 @OneToMany 어노테이션에 orphanRemoval = true 속성을 넣어주면 됩니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Candidate {

    @Id @GeneratedValue
    @Column(name = "candidate_id")
    private Long id;

    private int number;

    @Column(name = "candidate_name")
    private String name;

    @Column(name = "candidate_likes")
    private int likes;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "city_id")
    private City city;

    @OneToMany(mappedBy = "candidate", cascade = CascadeType.ALL, orphanRemoval = true) //바뀐 부분입니다.
    private List<Sns> snsList = new ArrayList<>();

    @OneToMany(mappedBy = "candidate", cascade = CascadeType.ALL)
    private List<Youtube> youtubeList = new ArrayList<>();

    @Builder
    public Candidate(int number, String name, City city) {
        this.number = number;
        this.name = name;
        this.likes = 0;
        this.city = city;
    }

    //== 연관관계 편의 메서드 ==//
    public void addSns(Sns sns) {
        snsList.add(sns);
        sns.setCandidate(this);
    }
}

좋은 웹페이지 즐겨찾기