JPA에서 Cascade, orphanRemoval을 사용할 때 주의해야할 점

콜트·2021년 11월 8일
0
post-thumbnail

JPA 연관관계 옵션 - Cascade, orphanRemoval

  • JPA에는 Entity들 사이의 연관관계를 정의할 때 사용할 수 있는 영속성 전이라고 하는 Cascade 옵션이 있다. 이 옵션을 이용해서 부모에 가해지는 변화를 자식에게 전파할지에 대해 설정할 수 있다.
    • @OneToMany로 자식들을 갖고 있는 부모 객체만 저장/삭제 해도 자식 객체도 함께 저장/삭제 된다던지, 하는 효과를 누릴 수 있다.
  • JPA에는 Entity들 사이의 연관관계를 정의할 때 사용할 수 있는 옵션 중에 orphanRemoval 라는 것이 있다. 이 옵션을 이용하면 부모가 자식에 대한 참조를 끊을 때, 참조가 끊어진 자식 Entity(고아 객체)를 DB에서 삭제하도록 설정할 수 있다.

그런데, 이에 관련해서 찾아보면 부모에 가해지는 변화가 자식에게 전파되는 것에 대해서만 언급하고 있다(적어도 내가 본 글들은 그랬다). 그렇다면, 자식에 가해지는 변화에 대해서는 어떻게 동작할까? 문득 궁금해져서 테스트를 작성해서 확인해보기로 했다.

테스트 환경

아래는 테스트에 사용할 Member, Team Entity이다.

Member

@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);
    }
}

Team

@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<>();
}

아래는 테스트를 진행할 코드이다.

TeamRepositoryTest

@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

위 사진은 cascadeInTeamSaveTest에서 실행되는 쿼리다. cascade.ALL(정확하게는 cascade.PERSIST)에 의해 team을 저장했지만 member에 대해서도 insert 쿼리를 날리는 모습을 볼 수 있다.

cascadeInTeamRemoveTest

위 사진은 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에 있다.

다시 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를 삭제하는 부분이 사라진 것을 볼 수 있다.

결론

  • 이 외에도 여러 테스트를 작성해서 실험해본 결과, Entity에 생긴 변화는 cascade 옵션이 활성화되어 있다면 영속성에 생긴 변화는 무조건 전파된다(PERSIST, REMOVE에 대해서만 해보긴 했지만). 부모이건 자식이건, 단방향이건 양방향이건 상관없다.
  • 자식에 cascade가 걸려있는 상태로 부모에서 자식에게 해당 전파 행위를 하면 자식이 부모에게 역으로(?) 전파를 하는, 역류 현상을 맛볼 수 있다.
  • cascade 옵션은 객체의 입장에서, 연관관계를 맺고 있는 대상 엔티티에게 "야, 너도 같이 가야지?"하고 같이 끌고가도록(?) JPA에게 알려주는 것과 같다고 생각하면 될 것 같다.
  • 자식이 부모와만 연관관계가 있다면 다행이지만, 부모는 자식 외에도 다른 엔티티들과 연관관계를 맺을 확률이 높으므로 자식(반드시 자식이라고 하기엔 조금 그렇지만, 대상을 소유하고 있는 주체를 모두 아울러서)에게는 cascade 옵션을 쥐어주지 않는 것이 좋아보인다.
  • 실무에서는 delete 쿼리를 사용하지 않고 상태값으로 데이터를 관리하는 경우가 많다고 한다. 그러나 주의해서, 잘 알고 사용하도록 하자.

참고 자료

cascade 옵션 질문

cascade 옵션은 단순히 생각하면, 그냥 persist() 호출을 줄여줄 수 있기 때문, 유용해 보이지만, 반대로 생각하면 Order 엔티티를 저장할 때 연관된 어떤 엔티티들이 함께 저장될까? 를 계속 코드를 보며 추적해야 합니다.
따라서 어디까지는 cascade로 함께 저장하고, 어디까지는 함께 저장하면 안될까? 하는 명확한 기준이 필요하다. 그러므로 이런 기준을 잡기 애매한 경우에는 사실 사용하지 않는 것이 좋습니다.
통상적으로 권장하는 cascade 범위는, 완전히 개인 소유하는 엔티티일 때, 예를 들어서 게시판과 첨부파일이 있을 때 첨부파일은 게시판 엔티티만 참조하므로, 개인 소유 입니다. 이런 경우에는 사용해도 됩니다.
그럼 반대로 개인 소유하지 않는 엔티티는 무엇일까요? 예를 들어서, 회원, 상품 등등이 있습니다. 이 예제에서 Order -> OrderItem을 개인소유 하기 때문에 cascade를 사용했습니다. 그런데 Order 입장에서 Delivery는 좀 애매합니다.
여기서는 프로젝트 규모가 작기 때문에 매우 단순하게 표현했지만, 실무에서 프로젝트 규모가 커지면, Delivery로 여러곳에서 참조될 수 있습니다. 그러면 사용하면 안됩니다.
추가로 도메인 주도 설계(DDD)의 Aggregate Root 개념을 이해하고, 프로젝트 적용하면 여기에 맞추어 cascade 옵션을 더 잘 활용할 수 있습니다.

정리하면

1. 완전 개인 소유인 경우에 사용할 수 있다.
2. DDD의 Aggregate Root와 어울린다.
3. 애매하면 사용하지 않는다.

출처 : cascade 옵션 질문

JPA 연관관계 매핑 팁

  • 우선은 설계할 때 전부 다 단방향으로 설계를 끝내버린다. 그리고 이후에 양방향이 필요한 경우에 반대쪽에 매핑을 추가해준다. DB에 영향을 주지 않기 때문에 코드 몇 줄 추가하면 끝난다.
  • 이미 단방향 매핑만으로도 ORM 매핑이 끝난 것이나 다름없다. 양방향은 단순히 조회를 조금 더 편하게 하기 위해서 부가기능이 들어가는 것이라고 보면 된다.

출처 : [토크ON세미나] JPA 프로그래밍 기본기 다지기 5강 - 양방향 매핑 | T아카데미

profile
개발 블로그이지만 꼭 개발 이야기만 쓰라는 법은 없으니, 그냥 쓰고 싶은 내용이면 뭐든 쓰려고 합니다. 코드는 깃허브에다 작성할 수도 있으니까요.

0개의 댓글