[Spring/JPA] CascadeType.ALL 사용시 주의 해야 할 점

resilient

·

2023. 3. 15. 16:51

728x90
반응형

최근에 JPA를 본격적(?)으로 사용하면서 무릎을 탁! 쳤던 경험을 했습니다.

 

바로 연관관계 매핑, 영속성 전이, 연관관계 편의 메서드, 고아 객체를 이해하고 사용했던 경험인데요.

 

이번 시간에는 Cascade에 대해서 간단하게 살펴보고, Cascade를 사용할 시, 주의할 점에 대해서 알아보려고 합니다.

 

0. Cascade란?

 

먼저 Cascade는 무엇인지에 대해 간단하게 살펴보겠습니다.

 

특정 엔티티를 영속 상태로 만들 경우, 연관된 엔티티도 함께 영속 상태로 만들고 싶을 경우 영속성 전이를 사용하는데요. 

JPA에서는 영속성 전이를 Cascade옵션을 통해서 설정하고 관리할 수 있습니다. 쉽게 말해서 부모 엔티티를 다룰 경우, 자식 엔티티까지 다룰 수 있다는 뜻이죠.

 

Cascade는 6가지의 옵션을 가지고 있습니다. 이번 포스팅에서는 가장 자주 사용하는 ALL과 PERSIST와 REMOVE를 제외한 옵션들에 대해서는 다루지 않으려고 합니다.

  • ALL
  • PERSIST
  • MERGE
  • REMOVE
  • REFRESH
  • DETACH

 

1. CascadeType.PERSIST

 

먼저 PERSIST는 부모와 자식엔티티를 한 번에 영속화할 수 있습니다.

@Entity
@Table(name = "circle")
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class Circle extends BaseTimeEntity {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @OneToMany(mappedBy = "circle", cascade = CascadeType.PERSIST)
    @Builder.Default
    private List<UserCircle> userCircleList = new ArrayList<>();

}

 

CascadeType.PERSIST로 설정하고 부모인 circle을 영속화했을 경우, userCircleList에 담긴 UserCircle까지 함께 영속화가 되는 것이죠.

 

2. CascadeType.REMOVE

 

위에서 PERSIST로 함께 저장했던 부모와 자식의 엔티티를 모두 제거할 경우에 CascadeType.REMOVE를 사용합니다.

 

Circle circle = EntityManger.find(Circle.class,1L);
UserCircle userCircle1 = EntityManager.find(UserCircle.class,1L);
UserCircle userCircle2 = EntityManager.find(UserCircle.class,2L);

EntityManager.remove(userCircle1);
EntityManager.remove(userCircle2);
EntityManager.remove(circle);

 

원래대로라면 위와 같이 userCircle1, userCircle2를 먼저 제거해 준 뒤, 그 다음 부모객체인 circle을 제거해줘야 합니다. 

 

하지만 CascadeType.REMOVE옵션을 사용했을 경우에는 아래와 같이 부모객체를 삭제하면 연관되어 있는 자식 객체들이 줄줄이 사라지게 되죠.

Circle circle = EntityManger.find(Circle.class,1L);

EntityManager.remove(circle);

 

3. CascadeType.ALL 

 

위 1,2번 주제에서 살펴봤던 CascadeType.PERSIST 와 CascadeType.REMOVE의 기능을 모두 수행 해주는 옵션입니다. 

 

 

4. CascadeType.Remove와 orphanRemoval=true의 차이

 

 사실 CascadeType.REMOVE와 orphanRemoval은 아예 다른 사용법을 가지고 있습니다.

 

CascadeType.Remove를 이용해서 부모 엔티티를 삭제하면 자식 엔티티까지 삭제되는걸 위에서 확인했습니다.

 

그럼, 부모 엔티티에서 자식 엔티티와의 연관관계를 제거하는 경우는 어떨까? 아래와 같은 경우를 살펴보도록 하겠습니다.

 

@Override
public CircleDTO.CircleSimpleInfoResponse leaveCircle(Long userId, Long circleId) {
    UserCircle userCircle = userCircleRepository.findByUserIdAndCircleId(userId, circleId).orElseThrow();
    Circle circle = circleRepository.findById(circleId).orElseThrow();
    circle.removeUserCircle(userCircle);
}

 

circle에서 removeUserCircle을 했으면 연관관계가 끊어지게 되는데 연관관계가 끊어졌을 경우 CascadeType.REMOVE 옵션은 논리적으로 참조를 변경시켜서 무결성 오류를 안 나게 할 뿐, 데이터는 남게 됩니다.

 

이때 orphanRemoval=true 옵션이 등장하게 됩니다. 

 

circle.removeUserCircle(userCircle)을 실행하면, userCircle은 부모 객체인 circle과의 연관관계가 끊어지고 고아 객체가 됩니다.

 

고아 객체가 되었을 경우, 해당 고아 객체를 자동으로 삭제해 주는 옵션이 바로 orphanRemoval=true 인 것이죠.

 

정리해 보면

 

부모 엔티티를 삭제했을 경우는 CascadeType.REMOVE와 orphanRemoval=true 옵션이 동일하게 작동합니다. 부모 엔티티를 삭제하면 자식 엔티티도 삭제가 되죠.

 

하지만 부모 엔티티에서 자식 엔티티를 제거하면 차이가 발생합니다.

 

CascadeType.REMOVE만 사용했을 경우에는 부모객체가 삭제되어도 자식 객체가 그대로 남아있는 반면, orphanRemoval=true 옵션은 부모 엔티티와 자식 엔티티의 연관관계가 끊어졌을 경우, 자식 엔티티를 모두 제거를 해줍니다.

 

5. CascadeType 옵션 또는 orphanRemoval 옵션 사용 시 주의할 점

 

자 그러면 CascadeType 옵션이랑 orphanRemoval 옵션을 붙여두면 부모 객체가 자식 객체의 생명 주기를 모두 관리해 주고 삭제했을 경우 자동으로 삭제해 주니까 너무 좋다고 생각할 수 있는데요.

 

주의할 점에 대해서 알아보겠습니다.

 

5-1. CascadeType 옵션을 사용할 때 고려해야 할 점

 

먼저 아래 예시를 보겠습니다. 구조는 Circle과 Users 엔티티가 있고 중간에 조인엔티티로 UserCircle을 가지고 있는 양방향 단방향으로 ManyToMany를 풀어낸 구조입니다.

 

@Entity
class Circle{
    
    @OneToMany(mappedBy = "circle", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<UserCircle> userCircleList;
}

@Entity
class Users{

    @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<UserCircle> userCircleList;
}

@Entity
class UserCircle{

    @ManyToOne(fetch = FetchType.LAZY)
    private Circle circle;

    @ManyToOne(fetch = FetchType.LAZY)
    private Users users;
}

 

위와 같은 경우에는 Usercircle이라는 자식을 Circle 부모 객체와 Users 부모 객체가 알고 있는 상황입니다.

 

이러한 경우에는 주의해야 합니다. 자식 엔티티를 삭제할 상황이 아닌 경우 Circle이나 Users 중 어느 한쪽에서 부모 엔티티를 삭제하거나 부모와 한쪽의 연관관계를 제거했을 경우 자식인 UserCircle이 아예 삭제되는 대참사가 발생할 수도 있습니다.

 

여기서 중요한 점은 Cascade 옵션이 유용해 보이지만 어떤 연관관계를 맺은 엔티티들이 함께 저장되고 관리가 될지 계속해서 추적해 보고 생각해야 한다는 점입니다.

 

Cascade옵션을 사용할 경우 고려해야 할 점을 참고해보면 '통상적으로 권장하는 cascade 범위는, 완전히 개인 소유하는 엔티티일 때, 예를 들어서 게시판과 첨부파일이 있을 때 첨부파일은 게시판 엔티티만 참조하므로, 개인 소유하는 경우에는 사용 가능'이라고 합니다.

 

위 경우는 개인 소유가 아닙니다. UserCircle이 Circle과 Users에서 공동 소유를 하고 있기 때문이죠.

 

따라서 신중하게 판단하고 되도록이면 한쪽에서만 사용해야 합니다.

 

아니면 Circle에서 UserCircle을 삭제할 경우 Users가 알게 하고, Users에서 UserCircle을 삭제할 경우 Circle이 알게 해야 합니다.

 

5-2. CascadeType.ALL을 남발하면 안 되는 이유

 

CascadeType.ALL과 orphanRemoval=true 옵션을 모두 사용했을 경우, 부모 엔티티를 통해서 자식의 모든 생명 주기를 관리할 수 있다는 내용이 타 블로그에 많이 올라와있습니다.

 

하지만 제가 생각했을 때는 5-1에서 언급했듯이, 부모 엔티티가 자식의 모든 생명 주기를 관리하는 것이 무조건적으로 좋지만은 않습니다.

 

5-1의 ManyToMany 구조를 가진 세게의 엔티티를 참고해서 아래 예시를 보겠습니다.

 

Circle이 가지고 있는 UserCircle들의 User들이 가진 UserCircle의 size를 알아보는 함수입니다.

Circle circle = EntityManager.find(Circle.class, 1L);

List<UserCircle> userCircleList = circle.getUserCircleList();

for(UserCircle uc : userCircleList){
    Integer ucSize = uc.getUser().getUserCircle().size();
}

circle.getUserCircle().clear();

 

위에서 보면 size을 얻기 위해서 User가 lazy 로딩이 되었고 User들의 UserCircle이 lazy 로딩되었습니다.

 

for문이 종료된 뒤에 circle 속의 UserCircle들을 모두 지우려고 하는 상황이죠. 위에서 orphanRemoval을 걸어놨으니 clear를 하면 다 삭제가 되어야 할 텐데요.

 

결과는 삭제가 안됩니다.

 

Circle입장에서는 UserCircle 개수가 0이 되면 삭제를 시도합니다.

 

하지만 User와 User에서 UserCircle을 lazy 로딩시켰을 때, 영속성 컨텍스트에 올라가게 되죠.

 

User가 영속성 컨텍스트에 올라갔는데? UserCircle에는 CascadeType.ALL 속의 PERSIST가 있기 때문에 User의 UserCircle은 존재하게 되고 삭제에 실패하게 됩니다.

 

이렇게 PERSIST는 한쪽에서 빼주고 Users 엔티티에서 UserCircle OneToMany에 대해서 REMOVE만 설정해 주면 위와 같이 삭제가 실패하는 경우가 없어지게 됩니다.

 

정리해 보면, CascadeType.ALL을 사용하게 되면 PERSIST와 REMOVE를 모두 사용하게 되는데 완전히 개인 소유하는 엔티티일 때는 ALL을 사용해도 괜찮을 듯합니다. orphanRemoval=true 옵션도 마찬가지로요.

 

하지만 조인테이블을 사용한 ManyToMany 구현과 같이 엔티티끼리의 연관관계가 부모가 둘 이상이거나 완전히 개인 소유가 안 되는 경우는 REMOVE를 할 때, PERSIST가 있기 때문에 실패하는 경우가 발생하고, PERSIST만 해야 하는데 REMOVE가 생겨서 의도치 않은 삭제가 발생할 수도 있습니다.

 

무조건 적으로 CascadeType.ALL이 나쁘다! 는 아니지만 엔티티 구조와 방향에 따라서 잘 생각해서 사용하고 무조건적으로 ALL을 달아버리면 안 된다! 가 결론이 될 수 있겠습니다.

 

 

(2023.3.28추가)

오늘 흥미로운 글을 읽었습니다. CascadeType.ALL을 사용했을 경우, OneToMany로 묶여있는 entity를 삭제 할 경우,

DELETE 쿼리를 날릴 때 N+1 현상이 발생한다는 문제였는데요. 한번 살펴보도록 하겠습니다.

 

아래와 같이 Pin Entity와 Picture Entity가 양방향 관계를 맺고 있다고 가정해 보겠습니다.

 

@Entity
@Table(name = "pin")
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class Pin extends BaseTimeEntity {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    private Users user;

    @OneToMany(mappedBy = "pin", cascade = CascadeType.ALL, orphanRemoval = true)
    @Builder.Default
    private List<Picture> pictures = new ArrayList<>();

}


@Entity
@Table(name = "picture")
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class Picture extends BaseTimeEntity {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Id
    private Long id;

    @Column(name = "original_name")
    private String originalName;

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

    @ManyToOne(fetch = FetchType.LAZY)
    private Pin pin;
}

 

Pin에서 OneToMany 옵션을 보면 CascadeType.ALL이 걸려있는데요.

옵션을 적용한 이유는 위에도 설명되어 있지만, 자식인 PictureList의 생명주기를 관리하기 위함이죠.

 

여기서 Pin을 삭제하면 나머지 Picture들도 CascadeType.ALL에 의해서 자동으로 삭제되는 그림을 생각하고 적용했습니다.

 

@Test
    public void deletePin() {
        Users user = generateSimpleUser("test-email", "test-nickname");
        Circle circle = generateSimpleCircle("test-circle");
        userJoinCircle(user, circle);
        LocationDTO locationDTO = new LocationDTO("location", new PointDTO(123.123, 123.456));
        List<String> tagNames = generateTagNames("tag1", "tag2");
        PinDTO.PinCreateRequest request = PinDTO.PinCreateRequest.builder().location(locationDTO).tagNames(tagNames).build();
        List<MultipartFile> pictures = List.of(generateFile("p1", "p1"),generateFile("p2", "p2"),generateFile("p3", "p3"),generateFile("p4", "p4"));

        // When
        PinDTO.PinDetailResponse createdPin = pinService.createPin(user, circle.getId(), request, pictures);

        Pin pin = pinRepository.findById(createdPin.getId()).orElseThrow();
        System.out.println(pin.getPictures().size()+" 개의 pictures");
        System.out.println(pin);
        System.out.println("--delete 쿼리--");
        pinService.deletePin(user,pin.getId());
        System.out.println("--delete 쿼리 끝--");
    }

 

현재 soft delete를 사용하고 있기 때문에 delete쿼리가 아닌 update 쿼리가 나가긴 합니다만 아래를 보면 pictureList size 만큼 update 쿼리가 나가는 것을 볼 수 있습니다.

 

 

이러한 현상은 Cascade 옵션을 사용하면 연관관계를 반드시 조회하는 동작 때문에 발생합니다.

 

그러면 이 현상을 N+1이라고 부를 수 있을까요?

 

쿼리가 delete All 대신 각각의 picture마다 하나의 delete가 나가는 것이기 때문에 성능상으로는 큰 문제가 없지 않을까.. 라는 생각입니다.

 

N+1 는 조회를 할 경우 같은 데이터를 불러올 경우 불필요한 쿼리들을 추가적으로 실행하기 때문에 성능상 문제를 발생시키는 문제인데 Delete의 경우는 약간 다르다고 생각이 듭니다.

 

(혹시라도 잘못된 생각이라면 편하게 댓글 부탁드리겠습니다.)

 

6. 정리

 

이번 포스팅에서는 JPA를 사용하면 빠질 수 없는 Cascade에 대해서 여러 가지가지를 뻗어나가며 정리를 해봤습니다.

 

이전 게시물에서도 항상 언급했지만 옵션들을 사용할 때에는 무지성으로 사용하지 않고 이 상황에서 꼭 써야 하는 옵션인지 잘 찾아보고, 생각해 보고 사용해야 해당 옵션이 제공해 주는 기능을 완전히 사용할 수 있습니다.

 

앞으로도 JPA를 사용하면서 많은 옵션들을 사용할 텐데 옵션들을 공부하고 제대로 사용해 봐야겠습니다.

 

감사합니다.

반응형