JPA, JPQL, 삭제

고동현·2025년 2월 5일
0

"더 깊이, 더 넓게"

목록 보기
15/15

배경

Prove 프로젝트를 하던 중, 회원 탈퇴시 엔티티를 삭제하는 과정에서 문제를 만났다.

구글링을 했을때 여러가지 삭제 방법이 있었는데, 각각의 방식을 알아보고, 나의 생각을 드러내 보도록 하겠다.

CASCADE

CASCADE란?
어떤 엔티티와 다른 엔티티가 매우 밀접한 관계에 있을때, A라는 엔티티에 특정 작업을 수행했을때, B라는 엔티티에도 동일한 작업을 행하는 것입니다.

예를 들어, 계시판과 댓글은 아주 밀접한 관계에 있습니다.
왜냐하면, 계시판이라는 엔티티가 선행되지 않으면 댓글이라는 엔티티는 존재 의미가 없기 때문입니다.

@Entity
public class Post{
	@Id
    @GeneratedValue
    private Long id;
    
    @OneToMany(mappedBy = "post", casacade = CascadeType.REMOVE)
    private List<Comment> comments = new ArrayList<>();
    ...
}
@Entity
public class Comment {

    @Id
    @GeneratedValue
    private Long id;

    private String value;

    @ManyToOne
    @JoinColumn(name = "post_id")
    private Post post;
    ...
}

이러한 관계에서 JPA를 사용하여서 postrepository.deleteByID()이런식으로 호출하면, Post도 지우지만, 해당 엔티티와 연관된 Comment엔티티까지 Cascade속성이 전파되어 같이 삭제 된다.

장점으로는, 당연히 굳이 Comment 삭제 로직을 작성하지 않아도 되서 편할것입니다.

그러나, Cascade 옵션은 사용하지 않는 것이 좋은데 이유는 바로

  1. 양방향 연관관계 매핑시 충돌가능성
    일단 RDB는 방향 자체가 존재하지 않는다. 즉 양방향이라는 개념자체가 없다. 고로 DB와 비슷하게 가려면 양방향 연관관계를 사용하지 않는다.
    OneToMany를 사용하더라도 실질적으로 One쪽의 DB에 컬럼이 생기지 않는다.
    또한 양방향 연관관계를 설정하면 양쪽 모두에 참조 변수를 설정해야하는 불편함이 있다.

  2. N+1문제
    JPA를 사용해서 삭제를 하면 Post에 Comment가 10개 가 있으면 총 delete쿼리가 11개가 날라가는 심각한 문제가 발생한다.
    해당 내용은 여기를 참고하자

  3. 의도치 않은 삭제
    만약 User <-> Comment <-> Like와 같이 엔티티가 연관관계가 있는데
    User를 삭제하면 Comment도 자동 삭제 된다고 가정합시다. 그런데 Like는 Comment를 참조해야합니다. 이처럼 의도치않은 로직으로 삭제가 진행되는 경우가 있습니다.

일단 제일 큰 문제점은 JPA를 사용하면 쿼리를 N+1로 날린다는 점입니다.

OnDelete

그렇다면 양방향 연관관계를 하지 않으면서 N+1개의 쿼리를 날리지 않는 방법은 없을까요? 있습니다.
@Ondelete

@Entity
public class Comment {

    @Id
    @GeneratedValue
    private Long id;

    private String value;

    @ManyToOne
    @JoinColumn(name = "post_id")
    @OnDelete(action = OnDeleteAction.CASCADE)
    private Post post;
    ...
}

이렇게 하고 Post의 OneToMany를 삭제한다.
이렇게 실행하면
Hiberate에서 alter문이 발생하게 되는데, on delete cascade가 추가되게 됩니다.
고로 @OnDelete는 JPA가 해주는게 아니라 데이터베이스가 제약조건을 설정하여서 레코드를 연쇄적으로 제거합니다.

고로, DDL에 의해서 스키마 자체에 해당 cascade조건이 추가가 되고, JPA가 아닌 제약조건을 통해서 데이터베이스에서 참조하는 레코드를 연속적으로 제거해줍니다.

당연히 쿼리를 N+1개 날리는게아니라, delete쿼리를 Post 한개만 날리고, 해당 쿼리를 받은 데이터베이스가 스키마의 제약조건을 살펴보고 이와 연관된 Comment도 같이 지워주는 것입니다.

그러나 문제가 있다.
해당 연관된 연쇄작업은 데이터베이스가 하기 때문에, FK를 통해서 Post의 Id에 해당되는 Comment 레코드로 가서 일일히 삭제하기 때문에 FK가 반드시 필요하게 됩니다.

그러나, 실무에서는 FK를 지정하는 경우가 많다고 합니다.
이부분은 저도 실무를 접하지 않아서 정확히 알지 못하지만, FK제약조건으로 Lock이 걸리거나, 스키마 변경이 어려워서 사용하지 않는다고 합니다.

FK제약조건으로 인해 요구사항에 따라 변화할 수 있는 스키마 구조가 어렵고
다른 테이블의 pk를 fk로 지정하여 insert할때 pk가 진짜 있는지 부모테이블에 Lock을 걸고 확인하던가,
아니면 레코드를 삭제할때 해당 pk를 fk로 사용하는 레코드가 있는지 판별해야하는등 이슈가 있을 것입니다.

그래서 아마, JPA를 사용할때

@Entity
public class Comment {

    @Id
    @GeneratedValue
    private Long id;

    private String value;

    @ManyToOne
    @JoinColumn(name = "post_id", foreignKey = @ForeignKey(ConstrainMode.NO_CONSTRAINT))
    private Post post;
    ...
}

이런식으로 포링키를 사용하지 않는다는 조건을 넣는다고 알고 있습니다.
고로, FK를 지정하지 않는다면 @OnDelete방식도 수행하기 어렵습니다.

그래서 어떻게 해야하냐?

지금 현재 저의 프로젝트의 엔티티를 보면
Prove(계시판)


public class Prove {
    @ManyToOne(fetch = FetchType.LAZY)
    private UserEntity user;
	...
}

좋아요

public class Like {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private UserEntity user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "prove_id")
    private Prove prove;

}

댓글

public class Comment {
    @Id
    @GeneratedValue(strategy =  GenerationType.IDENTITY)
    private Long id;

    private String comment;

    @ManyToOne(fetch = FetchType.LAZY)
    UserEntity user;

이런식 등등으로 각 엔티티에 User가 있습니다.

그래서
UserService에서 delete메서드를 만들고

public ResponseEntity<?> deleteUser() {
        Long userId = userRepository.findByUsername(getUseranmeWithToken()).getId();
   
        // 순차적으로 삭제
        commentLikeRepository.deleteByUserId(userId);
        commentRepository.deleteByUserId(userId);
        likeRepository.deleteByUserId(userId);
        proveRepository.deleteByUserId(userId);
        userRepository.deleteUserById(userId);

        return new ResponseEntity<>("유저 삭제 완료",HttpStatus.OK);
    }

그냥 엔티티 별로 별도 쿼리를 날리면서 삭제를 해줍니다.

    @Query("DELETE FROM CommentLike cl WHERE cl.user.id = :userId")
    void deleteByUserId(@Param("userId") Long userId);
Query("DELETE FROM Prove p WHERE p.user.id = :userId")
    void deleteByUserId(@Param("userId") Long userId);

이렇게 되면 총 5개의 쿼리를 별도로 날립니다. JPQL을 사용하여서 쿼리는 N+1문제가 생기지 않습니다.

결론

FK를 걸지 않는다, 양방향 연관관계 매핑을 맺지 않는다, N+1문제를 발생시키지 않는다.

이러한 조건을 만족하기 위해서는 각 엔티티별로 별도로 delete쿼리를 날리는게 최선인것 같습니다. 혹시 다른 의견이나 방법이 있다면 알려주십쇼

profile
항상 Why?[왜썻는지] What?[이를 통해 무엇을 얻었는지 생각하겠습니다.]

0개의 댓글

관련 채용 정보