Cascade
옵션이 있다. 이 옵션을 이용해서 부모에 가해지는 변화를 자식에게 전파할지에 대해 설정할 수 있다.그런데, 이에 관련해서 찾아보면 부모에 가해지는 변화가 자식에게 전파되는 것에 대해서만 언급하고 있다(적어도 내가 본 글들은 그랬다). 그렇다면, 자식에 가해지는 변화에 대해서는 어떻게 동작할까? 문득 궁금해져서 테스트를 작성해서 확인해보기로 했다.
아래는 테스트에 사용할 Member, Team Entity이다.
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "teamId")
private Team team;
public Member(String name) {
this(null, name, null);
}
}
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@Entity
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Builder.Default // 이 녀석이 없으면 lombok의 builder로 객체를 생성할 때 List가 선언한대로 제대로 초기화되지 않는다.
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private List<Member> members = new ArrayList<>();
}
아래는 테스트를 진행할 코드이다.
@DataJpaTest
class TeamRepositoryTest {
@Autowired
private TeamRepository teamRepository;
@Autowired
private MemberRepository memberRepository;
@Test
void cascadeInTeamSaveTest() {
// given
Team team = Team.builder()
.name("team")
.build();
Member member = Member.builder()
.name("member")
.team(team)
.build();
team.getMembers().add(member); // 편의 메서드를 만들지 않고 그냥 직접 Member를 추가했다.
// when
teamRepository.save(team);
// then
assertThat(memberRepository.findAll()).hasSize(1);
}
@Test
void cascadeInTeamRemoveTest() {
// given
Team team = Team.builder()
.name("team")
.build();
Member member = Member.builder()
.name("member")
.team(team)
.build();
team.getMembers().add(member);
teamRepository.save(team);
// when
teamRepository.delete(team);
// then
assertThat(memberRepository.findAll().isEmpty()).isTrue();
}
}
위 사진은 cascadeInTeamSaveTest에서 실행되는 쿼리다. cascade.ALL(정확하게는 cascade.PERSIST)에 의해 team을 저장했지만 member에 대해서도 insert 쿼리를 날리는 모습을 볼 수 있다.
위 사진은 cascadeInTeamRemoveTest에서 실행되는 쿼리다. cascade.ALL(정확하게는 cascade.REMOVE)에 의해 team을 제거했지만 member에 대해서도 delete 쿼리를 날리는 모습을 볼 수 있다.
그렇다면 만약 cascade.ALL 옵션을 제거하고 orphanRemoval = true 로 설정하면 어떻게 될까?
@Builder.Default
@OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.PERSIST, orphanRemoval = true) // 편의상 PERSIST는 남겨뒀다.
private List<Member> members = new ArrayList<>();
Team Entity중 연관관계 설정을 위와 같이 변경하고 아래의 테스트를 실행해보면
@Test
void orphanRemovalInTeamTest() { // 이 테스트는 실패한다.
// given
Team team = Team.builder()
.name("team")
.build();
Member member = Member.builder()
.name("member")
.team(team)
.build();
team.getMembers().add(member);
teamRepository.save(team);
// when
team.getMembers().remove(0); // Member 객체와의 참조를 끊었다. 즉, 고아 객체로 만들었다.
// then
assertThat(memberRepository.findAll()).hasSize(0);
assertThat(teamRepository.findAll().isEmpty()).isFalse(); // 실패하는 단언문
}
위 테스트는 아래와 같은 에러를 뿜으며 실패한다.
아래와 같은 쿼리가 실행되는 것을 볼 수 있는데, 자세히 살펴보면 이상한 점이 있다.
member를 삭제하는 쿼리가 실행되는 것까지는 의도한 것이 맞는데, team을 삭제하는 쿼리도 실행되고 있다!(4번째 쿼리) 이는 반드시 orphanRemoval 옵션으로 member를 제거한 것이 아니더라도, member를 제거하기만 하면 team도 함께 삭제되는 현상이 발생한다. 왜 그런걸까?
다시 Member Entity를 살펴보자.
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
@Entity
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "teamId")
private Team team;
public Member(String name) {
this(null, name, null);
}
}
Member Entity에 선언된 Team과의 연관관계에서, cascade.ALL 옵션이 보이는가? 해당 옵션 때문에 Member Entity에 가해진 변화(삭제)가 Team까지 전파되었다. 즉, 영속성 전이는 부모에서 자식에게만 발생하는 것이 아닌, 참조하고 있는 Entity에 대해서 cascade 옵션이 활성화 되어 있다면 발생하는 것이다. 그래서 쌩뚱맞게도 의도와는 전혀 다르게 Team Entity가 삭제된 것이다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "teamId")
private Team team;
위와 같이 Member의 연관관계 옵션에서 cascade를 제거한 다음 다시 테스트를 돌리면 통과하는 것을 볼 수 있으며, 실제로 아래 사진과 같이 쿼리에서도 Team Entity를 삭제하는 부분이 사라진 것을 볼 수 있다.
cascade 옵션은 단순히 생각하면, 그냥 persist() 호출을 줄여줄 수 있기 때문, 유용해 보이지만, 반대로 생각하면 Order 엔티티를 저장할 때 연관된 어떤 엔티티들이 함께 저장될까? 를 계속 코드를 보며 추적해야 합니다.
따라서 어디까지는 cascade로 함께 저장하고, 어디까지는 함께 저장하면 안될까? 하는 명확한 기준이 필요하다. 그러므로 이런 기준을 잡기 애매한 경우에는 사실 사용하지 않는 것이 좋습니다.
통상적으로 권장하는 cascade 범위는, 완전히 개인 소유하는 엔티티일 때, 예를 들어서 게시판과 첨부파일이 있을 때 첨부파일은 게시판 엔티티만 참조하므로, 개인 소유 입니다. 이런 경우에는 사용해도 됩니다.
그럼 반대로 개인 소유하지 않는 엔티티는 무엇일까요? 예를 들어서, 회원, 상품 등등이 있습니다. 이 예제에서 Order -> OrderItem을 개인소유 하기 때문에 cascade를 사용했습니다. 그런데 Order 입장에서 Delivery는 좀 애매합니다.
여기서는 프로젝트 규모가 작기 때문에 매우 단순하게 표현했지만, 실무에서 프로젝트 규모가 커지면, Delivery로 여러곳에서 참조될 수 있습니다. 그러면 사용하면 안됩니다.
추가로 도메인 주도 설계(DDD)의 Aggregate Root 개념을 이해하고, 프로젝트 적용하면 여기에 맞추어 cascade 옵션을 더 잘 활용할 수 있습니다.정리하면
1. 완전 개인 소유인 경우에 사용할 수 있다.
2. DDD의 Aggregate Root와 어울린다.
3. 애매하면 사용하지 않는다.출처 : cascade 옵션 질문
- 우선은 설계할 때 전부 다 단방향으로 설계를 끝내버린다. 그리고 이후에 양방향이 필요한 경우에 반대쪽에 매핑을 추가해준다. DB에 영향을 주지 않기 때문에 코드 몇 줄 추가하면 끝난다.
- 이미 단방향 매핑만으로도 ORM 매핑이 끝난 것이나 다름없다. 양방향은 단순히 조회를 조금 더 편하게 하기 위해서 부가기능이 들어가는 것이라고 보면 된다.