게시글 하나 삭제했는데 DELETE가 N번?

2026. 2. 2. 16:01Backend

이 글은 JPA의 @OneToMany 연관관계에서 사용되는 cascade, orphanRemoval 옵션에 의한 고아객체 삭제가 성능에 어떤 영향을 미치는지를 다룹니다. JPA가 제공하는 편리한 연관관계 관리 기능이 의도치 않은 쿼리 폭증(row-by-row delete)으로 이어질 수 있음을 실제 사례를 통해 살펴보고, 성능 이슈가 발생하기 전에 어떤 기준으로 최적화 방안을 선택할 수 있는지 정리합니다.

 

게시글 1개 삭제, 삭제 쿼리는 N개?

문제 상황

SNS 서비스를 개발하며 게시글 삭제 기능을 구현하고 있었습니다. 게시글은 사진, 댓글, 좋아요, 태그 등 여러 연관 엔티티를 가져야 합니다.

게시글 엔티티는 다음과 같이 정의되어 있습니다.

class Post {
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<PostTag> postTags = new LinkedHashSet<>();

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private Set<PostLike> postLikes = new LinkedHashSet<>();

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Comment> commentList = new ArrayList<>();
}

부모 객체인 Post가 삭제되면, 연관 엔티티들이 연쇄 삭제되도록 구현했습니다.

따라서 게시글 삭제 로직은 다음과 같이 간편하게 구현할 수 있습니다.

@Transactional
public void delete(Long memberId, Long postId) {
    Post post = postFinder.findByIdOrThrow(postId);
    postValidator.validateOwner(post, memberId);
    postRepository.delete(post);
}

서비스 정책상 게시글은 하드 삭제되며, 연관된 댓글·좋아요·태그 역시 함께 연쇄 삭제되어야 합니다.

 

예상과 다른 결과

처음 예상했던 쿼리 흐름은 다음과 같습니다.

  1. 게시글 조회 (유효성·작성자 검증)
  2. 게시글 이미지 삭제(연관 엔티티 삭제)
  3. 게시글 댓글 삭제(연관 엔티티 삭제)
  4. 게시글 좋아요 삭제(연관 엔티티 삭제)
  5. 게시글 삭제

비교적 적은 수의 쿼리로 게시글 삭제가 끝날 것으로 예상했습니다.

하지만 실제 로그는 달랐습니다.

[Hibernate] select ... from posts p1_0 where p1_0.id=?

[Hibernate] select ... from post_comments cl1_0 where cl1_0.post_id=?
[Hibernate] select ... from post_likes pl1_0 where pl1_0.post_id=?
[Hibernate] select ... from post_tags pt1_0 where pt1_0.post_id=?

[Hibernate] delete from post_images where post_id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_likes where id=?
[Hibernate] delete from post_tags where id=?
[Hibernate] delete from post_tags where id=?
[Hibernate] delete from post_tags where id=?
[Hibernate] delete from post_tags where id=?
[Hibernate] delete from post_tags where id=?
[Hibernate] delete from posts where id=?

게시글 하나를 삭제했을 뿐인데,

  • 연관 엔티티를 조회하는 SELECT
  • 연관 엔티티 개수만큼 DELETE

즉 삭제할 연관 엔티티를 조회하고, 연관 엔티티의 수 만큼 삭제하는 쿼리가 발생했습니다.


되짚어보기

왜 JPA는 다음과 같은 쿼리를 바로 실행하지 않을까?

DELETE FROM post_comments WHERE post_id=?

이를 이해하려면 @OneToMany, cascade, orphanRemoval의 역할과 책임을 구분해서 볼 필요가 있습니다.

 

@OneToMany

@OneToMany는 단순히 테이블 간 관계를 표현하는 어노테이션이 아닌, 엔티티 그래프와 생명주기 관리 방식을 정의합니다.

특히 mappedBy가 설정된 경우,

  • 부모 엔티티의 컬렉션은 연관관계의 주인이 아니며,
  • 자식 엔티티가 FK(post_id)를 관리합니다.

이 구조에서 JPA는 “DB 레벨의 FK 연쇄 삭제”가 아니라 엔티티 단위의 생명주기 관리를 기본 전략으로 삼습니다.

cascade, orphanRemoval

  • cascade = ALL: 부모 엔티티에 대한 persist/remove 등의 작업을 자식에게 전파
  • orphanRemoval = true: 부모 컬렉션에서 제거된 자식 엔티티를 고아(orphan)로 보고 remove 적용

즉 이 옵션들은 DB에 DELETE를 직접 위임하는 기능이 아니라, JPA가 엔티티 단위로 remove를 전파하도록 만드는 기능 입니다.

따라서 삭제 과정은 다음과 같이 진행됩니다.

  1. 연관 컬렉션을 조회하여 삭제 대상을 식별
  2. 각 엔티티에 대해 EntityManager.remove() 호출
  3. DELETE ... WHERE id=?가 반복 실행

명시적으로 연관 엔티티 벌크 삭제하기

연관 엔티티의 연쇄 삭제만으로는 벌크 삭제가 불가능했습니다.

이를 해결하기 위해 여러 선택지를 검토했습니다.

DB에 CASCADE DELETE 제약걸기

DB FK에 ON DELETE CASCADE를 설정하면 연쇄 삭제가 가능합니다.

ALTER TABLE post_comments
ADD CONSTRAINT fk_comment_post
FOREIGN KEY (post_id)
REFERENCES posts(id)
ON DELETE CASCADE;

하지만 애플리케이션 레벨에서 삭제 흐름이 드러나지 않고, DB 의존성이 커져 제외했습니다.

 

게시글 소프트 삭제하기

물리적인 삭제 비용은 없지만, 서비스 요구사항(하드 삭제)과 맞지 않았습니다.

 

@SQLDelete

Hibernate 전용 기능으로 SQL을 직접 제어할 수 있지만, Hibernate 의존성이 생기고 더 단순한 대안이 있었습니다.

@SQLDelete(sql = "DELETE FROM post_comments WHERE post_id = ?")

 

JPA에서 명시적으로 삭제하기

연관 엔티티를 명시적으로 먼저 삭제하는 방식을 선택했습니다.

@Transactional
public void delete(Long memberId, Long postId) {
    Post post = postFinder.findByIdOrThrow(postId);
    postValidator.validateOwner(post, memberId);

    commentRepository.deleteByPostId(postId);
    postLikeRepository.deleteByPostId(postId);
    postTagRepository.deleteByPostId(postId);

    postRepository.delete(post);
}

하지만 결과는 오히려 악화되었습니다.

[Hibernate] select ... from posts p1_0 where p1_0.id=?

[Hibernate] select ... from post_comments c1_0 join posts p1_0 on p1_0.id=c1_0.post_id where p1_0.id=?
[Hibernate] select ... from post_likes pl1_0 join posts p1_0 on p1_0.id=pl1_0.post_id where p1_0.id=?
[Hibernate] select ... from post_tags pt1_0 join posts p1_0 on p1_0.id=pt1_0.post_id where p1_0.id=?

[Hibernate] delete from post_images where post_id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_comments where id=?
[Hibernate] delete from post_likes where id=?
[Hibernate] delete from post_tags where id=?
[Hibernate] delete from post_tags where id=?
[Hibernate] delete from post_tags where id=?
[Hibernate] delete from post_tags where id=?
[Hibernate] delete from post_tags where id=?
[Hibernate] delete from posts where id=?

deleteByPostId 는 JOIN 조회 후 동일하게 개별 삭제 방식으로 동작하고 있었기 때문입니다.


왜 deleteByPostId를 써도 N개의 쿼리가 발생했을까?

deleteByPostId라는 이름만 보면 곧바로 다음과 같은 SQL이 실행될 것으로 예상했습니다.

DELETE FROM post_comments WHERE post_id = ?

하지만 Spring Data JPA의 파생 delete 메서드는 엔티티 단위 정합성 보장을 위해 다음과 같이 동작합니다.

  1. 삭제 대상 엔티티를 먼저 조회
  2. 조회된 엔티티 각각에 대해 EntityManager.remove() 호출

이 과정에서 조건은 comment.post.id와 같은 객체 그래프 접근으로 해석되고, 묵시적 연관 조인(implicit association join)이 발생합니다.

결과적으로 JOIN으로 인한 더 많은 SELECT 비용과, 여전히 엔티티의 수 만큼 DELETE가 발생합니다.

 

연관 엔티티의 조회가 필요한가?

우선 이번 게시글 삭제는 다음 조건을 만족합니다.

  • 삭제 대상은 이미 post_id로 명확히 한정
  • 엔티티 라이프사이클 이벤트 불필요
  • 연관 엔티티의 상태 관리 불필요
  • 쿼리 수와 트래픽 최소화가 최우선

이런 상황에서는 엔티티 단위 삭제가 제공하는 장점보다 비용이 훨씬 큽니다.

특히 댓글·좋아요가 많은 인기 게시글이 삭제될 경우, 일반적인 N+1 조회 문제보다도 더 큰 트래픽을 유발할 수 있습니다.


해결: 엔티티를 거치지 않는 벌크 삭제

의도를 명확히 드러내기 위해 @Modifying + JPQL delete 방식으로 전환했습니다.

@Modifying
@Query("delete from Comment c where c.post.id = :postId")
void deleteByPostId(Long postId)

이제 게시글 삭제 시 발생하는 쿼리는 다음과 같이 단순해졌습니다.

[Hibernate] select ... from posts p1_0 where p1_0.id=?
[Hibernate] delete c1_0 from post_comments c1_0 where c1_0.post_id=?
[Hibernate] delete pl1_0 from post_likes pl1_0 where pl1_0.post_id=?
[Hibernate] delete pt1_0 from post_tags pt1_0 where pt1_0.post_id=?
[Hibernate] delete from post_images where post_id=?
[Hibernate] delete from posts where id=?

연관 엔티티 개수와 무관하게 각 테이블당 DELETE 1회 만 발생하는 것을 확인할 수 있습니다.


정리

  • cascade = ALL, orphanRemoval = true는 편리하지만 숨은 비용이 존재.
  • JPA의 기본 삭제 전략은 엔티티 단위 remove
  • 파생 delete 메서드는 벌크 삭제를 보장하지 않는다
  • 대량의 연관 엔티티 삭제가 예상되는 경우 @Modifying + JPQL delete를 명시적으로 사용하는 것이 안전하다