본문 바로가기
Back-End/JPA

JPA) deleteById 호출 시 연관객체 SQL 줄이기

by 어렵다어려웡 2022. 4. 1.

Todo.class (부모 클래스)

@Entity
public class Todo extends DateEntity {

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "TODO_ID")
    private Long id;

    @Column(nullable = false) // text
    private String content; // 내용

    @Column(name = "MEMBER_ID", nullable = false)
    private String writer; // 작성자 -> Member

    @Convert(converter = TodoCheckedConverter.class)
    private boolean checked;

    @OneToMany(mappedBy = "todo", fetch = FetchType.LAZY,
 cascade = CascadeType.ALL, orphanRemoval=true )
    @Builder.Default
    private List<Comment> comments = new ArrayList<>();

    public void addComments(Comment comment) {
        this.comments.add(comment);
    }

    public void changeContent(String content) {
        this.content = content;
    }

    public void changeChecked() {
        this.checked = !checked;
    }
}

Comment.class (자식 클래스)

@Entity
public class Comment extends DateEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "COMMENT_ID")
    private Long id;

    @Column(name = "CONTENT", nullable = false)
    private String content;

    @Column(nullable = false)
    private String writer; // 작성자 -> Member

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "TODO_ID")
    private Todo todo;

    public void changeContent(String content) {
        this.content = content;
    }

}

삭제 관련 쿼리를 진행할 때, 보통 deleteById 를 사용한다.

하지만 deleteById 의 경우 delete SQL을 여러번 전송한다.

 

예제로 하나의 Todo 에 5개씩 Comment 를 달았다고 가정한다.

@BeforeEach
void BEFORE_CREATE_ENTITY() {
    for(int j=0; j<3; j++) {
        Todo entity = Todo.builder()
                .content("todo"+j)
                .writer("writer"+j)
                .checked(false)
                .build();
        for(int i=0; i<=5; i++) {
            entity.addComments(Comment.builder().content("안녕하세요."+i).writer("작성자"+i).todo(entity).build());
        }
        todoRepository.save(entity);
    }
}

@DisplayName("todo Delete Test")
@Test
@Order(4)
void DELETE_TEST() {
  todoRepository.deleteById(id);

  assertThat(todoRepository.findById(id)).isEmpty();
}

결과 쿼리

Hibernate: 
    delete 
    from
        comment 
    where
        comment_id=?
Hibernate: 
    delete 
    from
        comment 
    where
        comment_id=?
Hibernate: 
    delete 
    from
        comment 
    where
        comment_id=?
Hibernate: 
    delete 
    from
        comment 
    where
        comment_id=?
Hibernate: 
    delete 
    from
        comment 
    where
        comment_id=?
Hibernate: 
    delete 
    from
        comment 
    where
        comment_id=?
Hibernate: 
    delete 
    from
        todo 
    where
        todo_id=?

Comment 를 5번 삭제하고, Todo 를 1번 삭제하는 쿼리가 전송된다.

만약 수억개의 답글이 달려있다면? 수억개의 delete SQL이 전송된다.

 

따라서 나는 연관관계 객체의 데이터를 같이 삭제해야 하는 위와 같은 상황인 경우

벌크성 삭제를 이용해서 JPQL 을 만들어 호출한다.

 

대신 로직이 추가될 것이다.

public interface CommentRepository extends JpaRepository<Comment, Long> {
    @Modifying
    @Query("delete from Comment c where c.todo.id = :todoId")
    public void deleteByTodoId(@Param("todoId") Long todoId);
}

Spring Data JPA 에서는 @Modifying 어노테이션을 설정해서 해당 JPQL 을 벌크성으로 변환시켜준다.

void DELETE_TEST() {
  commentRepository.deleteByTodoId(id);
  todoRepository.deleteById(id);

  assertThat(todoRepository.findById(id)).isEmpty();
}

이제 추가적으로 delete 메서드를 호출해서 1번의 Delete SQL 을 해본다.

Hibernate: 
    delete 
    from
        comment 
    where
        todo_id=?
Hibernate: 
    delete 
    from
        comment 
    where
        comment_id=?
-> org.springframework.orm.ObjectOptimisticLockingFailureException: 
Batch update returned unexpected row count from update [0]; 
actual row count: 0; expected: 1; 
statement executed: delete from comment where comment_id=?; 

에러가 발생한다. 그 이유는,,?

@OneToMany(mappedBy = "todo", fetch = FetchType.LAZY, 
cascade = CascadeType.ALL, orphanRemoval = true)
@Builder.Default
private List<Comment> comments = new ArrayList<>();

CascadeorphanRemoval 속성으로 인한 동작방식을 알아야 할것 같다.

두 속성을 선언할 경우 부모 EntityTodo 클래스에서 Comment 클래스의 영속성 생명주기를 관리하게 될 것이다.

 

Cascade.ALLPersistRemove 속성을 선언한 것과 같다.

Remove 는 부모 클래스를 삭제하면 자식 엔티티도 삭제하는 SQL 을 날린다.

 

orphanRemoval=true 또한 부모 클래스를 자식 엔티티도 삭제하려 한다.

두 설정을 같이 하는 이유는 TodoComment 컬렉션 중 하나를 삭제 할 때 Delete SQL 을 날리기 위함이다.

 

원했던 동작방식은 다음과 같다.

  1. 여러 개일 수 있는 Comment 를 한번의 SQL 로 삭제하기 위해서 벌크성의 JPQL 을 만들어서 호출했다.
  2. 이후 부모 EntityTodo 를 삭제한다.

하지만, 나타난 SQLTododeleteById 로 인해서 자식 Entity를 삭제하려는 SQL 을 날렸다.

Hibernate: 
    delete 
    from
        comment 
    where
        comment_id=?

해결을 하는 방법은 다음과 같다.

 

Cascade 속성을 Persist 로 변경하고, orphanRemoval 을 제거한다.

@OneToMany(mappedBy = "todo", fetch = FetchType.LAZY, 
cascade = CascadeType.PERSIST)
@Builder.Default
private List<Comment> comments = new ArrayList<>();

// 호출결과
Hibernate: 
    delete 
    from
        comment 
    where
        todo_id=?
Hibernate: 
    delete 
    from
        todo 
    where
        todo_id=?

해결은 할 수 있지만 이렇게 할 경우 부모클래스에서 컬렉션 중 하나의 값을 지우더라도 Delete SQL 을 날리지 않는다.

 

여기서 트레이드 오프가 필요하다고 보인다.

부모 EntityTodo 에 답글이 많이 달릴 것이라고 가정하고 설계한다면

위 해결방식이 좀 더 성능적으로 가까울 것이라고 본다.

 

물론 이렇게 한다면 하나의 ROW 를 삭제하는 비즈니스 로직이 추가될 거라고 생각한다.

반면에, 답글이 많이 달리지 않을 것이라고 한다면, 그냥 deleteById 를 하나만 호출함으로써 자식 Entity 인 답글의 DB를 하나씩 삭제하게끔 비즈니스 로직을 추가하지 않고 자동으로 SQL 을 전달하는 것도 맞을것이라고 본다.