스프링 JPA 양방향 연관관계의 이해 - 영속성 전이 cascade

김상운(개발둥이)·2022년 3월 22일
1

들어가기


이전 포스팅에서는 복잡한 일대다(@OneToMany) 연관관계에 대해 알아보았다.

이번 포스팅에서는 복잡한 연관관계에서의 cascade 옵션의 이해와 쿼리에 대해 설명하고자 한다.

영속성전이(CASCADE)란 ?

부모 엔티티가 영속화될 때 자식 엔티티도 같이 영속화되고, 부모 엔티티가 삭제될 때 자식 엔티티도 삭제되는 등 특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 전이되는 것을 의미합니다.

이번 포스팅은 JPA 에 대해 어느정도 알고 있다 생각하고 작성하기 때문에 영속성 전이 cascade 가 무엇인지 모른다면 해당 블로그를 참고하자!
https://willseungh0.tistory.com/67


엔티티

ERD 예시

이번 포스팅에서 사용되는 엔티티 및 연관관계도 이전 포스팅 그대로이다.

Post

package couch.camping.domain.post.entity;

import couch.camping.domain.base.BaseEntity;
import couch.camping.domain.comment.entity.Comment;
import couch.camping.domain.member.entity.Member;
import couch.camping.domain.postimage.entity.PostImage;
import couch.camping.domain.postlike.entity.PostLike;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
public class Post extends BaseEntity {

    @Id @GeneratedValue
    @Column(name = "post_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

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

    @Builder.Default
    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL, orphanRemoval = true)
    private List<PostImage> postImageList = new ArrayList<>();

    @Builder.Default
    @OneToMany(mappedBy = "post", cascade = CascadeType.REMOVE, orphanRemoval = true)
    private List<PostLike> postLikeList = new ArrayList<>();

    @Lob
    private String content;

    private String postType;

    private int likeCnt;

    private int commentCnt;

    public void editPost(String content, String hashTag) {
        this.content = content;
        this.postType = hashTag;
    }

    public void increaseLikeCnt() {
        this.likeCnt = likeCnt+1;
    }

    public void decreaseLikeCnt() {
        this.likeCnt = likeCnt - 1;
    }
}

commentList, postImageList, postLikeList 와 같은 일대다 리스트를 보면 cascade 옵션이 달려있는 것을 확인할 수 있다.

  • commentList
    • comment(댓글) 의 경우 post(게시글) 가 삭제될 경우 post와 연관된 comment 가 연쇄적으로 삭제하기 위해 cascade = CascadeType.REMOVE 을 설정하였다.
  • postImageList
    • postImage(게시글 이미지)의 경우 생성, 삭제 등 라이프 사이클이 게시글에 종속적이기 때문에 cascade = CascadeType.ALL 로 설정하였다.
  • postLikeList
    • postLike(게시글 좋아요)의 경우 post(게시글)이 삭제된다면 post와 연관된 postLike 를 연쇄적으로 삭제하기 위해 cascade = CascadeType.REMOVE 을 설정하였다.

PostLike

package couch.camping.domain.postlike.entity;

import couch.camping.domain.member.entity.Member;
import couch.camping.domain.post.entity.Post;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PostLike {

    @Id @GeneratedValue
    @Column(name = "post_like_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}

PostImage

package couch.camping.domain.postimage.entity;

import couch.camping.domain.base.BaseEntity;
import couch.camping.domain.member.entity.Member;
import couch.camping.domain.post.entity.Post;
import lombok.*;

import javax.persistence.*;

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
public class PostImage extends BaseEntity {

    @Id @GeneratedValue
    @Column(name = "post_image_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    private String imgUrl;

    public PostImage(Member member, Post post, String imgUrl) {
        this.member = member;
        this.post = post;
        this.imgUrl = imgUrl;
    }
}

Comment

package couch.camping.domain.comment.entity;

import couch.camping.domain.base.BaseEntity;
import couch.camping.domain.commentlike.entity.CommentLike;
import couch.camping.domain.member.entity.Member;
import couch.camping.domain.post.entity.Post;
import lombok.*;

import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
public class Comment extends BaseEntity {

    @Id @GeneratedValue
    @Column(name = "comment_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    @Builder.Default
    @OneToMany(mappedBy = "comment", cascade = CascadeType.REMOVE, orphanRemoval = true)
    List<CommentLike> commentLikeList = new ArrayList<>();

    @Lob
    private String content;

    private int likeCnt;

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

    public void increaseLikeCnt() {
        this.likeCnt = likeCnt+1;
    }

    public void decreaseLikeCnt() {
        this.likeCnt = likeCnt - 1;
    }

}

comment 엔티티 필드에 commentLikeList @OneToMany 리스트를 보면 cascade 옵션이 달려있는 것을 확인할 수 있다.

  • commentLikeList
    • commentLike(댓글 좋아요) 엔티티의 경우 comment(댓글) 엔티티가 삭제된다면 comment 와 연관된 commentLike 를 연쇄적으로 삭제하기 위해 cascade = CascadeType.REMOVE 을 설정하였다.

CommentLike

package couch.camping.domain.commentlike.entity;

import couch.camping.domain.comment.entity.Comment;
import couch.camping.domain.member.entity.Member;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import javax.persistence.*;

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Getter
public class CommentLike {

    @Id
    @GeneratedValue
    @Column(name = "comment_like_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "comment_id")
    private Comment comment;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}

post 엔티티 저장


post 엔티티는 postImage 와 일대다 연관관계로 cascade 옵션이 CascadeType.ALL 이라는 것을 위에서 확인하였다.

그럼 post 엔티티를 영속화 할 때 따로 postImage 들도 하나하나 em.save() 를 해야할까?

아니다 save 는 한번만 호출해줘도 괜찮다.

예시

 Post post = Post.builder()
                .title(postWriteRequestDto.getTitle())
                .content(postWriteRequestDto.getContent())
                .postType(postWriteRequestDto.getPostType())
                .lastModifiedDate(LocalDateTime.now())
                .member(member)
                .build();

		// Post 엔티티 postImageList 필듸에 add() 하는 로직
        for (String imgUrl : postWriteRequestDto.getImgUrlList()) {
            PostImage postImage = new PostImage(member, post, imgUrl);
            post.getPostImageList().add(postImage);
        }

        Post savePost = postRepository.save(post);

위의 코드는 request DTO 를 받아 post 를 em.save() 하는 로직이다. 하지만 postImage 엔티티를 em.save() 해주는 코드를 찾을 수 없다. 그 이유는
cascade 옵션이 CascadeType.ALL 이기 때문에 post 엔티티의 postImageList 에 add() 하여 마지막에 post 엔티티를 save 하면 List 내부에 있는 postImage 들도 같이 저장된다.(위 코드 주석 참조)

쿼리

게시글(post)에 3개의 사진(postImage)을 작성 후 게시글을 저장하면 아래와 같은 쿼리가 발생한다.

그 이유는 위에서 설명했듯이 1 개의 Post를 저장 시 3개의 postImage 를 같이 저장 하기 때문이다.


insert 
    into
        post
        (comment_cnt, content, created_by, created_date, last_modified_date, like_cnt, member_id, post_type, title, post_id) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)

>>>>>>>>> 게시글 저장 쿼리


    insert 
    into
        post_image
        (created_date, last_modified_date, created_by, last_modified_by, img_url, member_id, post_id, post_image_id) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?)
        
>>>>>>>> 첫번째 게시글 이미지 저장 쿼리
        
    insert 
    into
        post_image
        (created_date, last_modified_date, created_by, last_modified_by, img_url, member_id, post_id, post_image_id) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?)

>>>>>>>>> 두번째 게시글 이미지 저장 쿼리
 
    insert 
    into
        post_image
        (created_date, last_modified_date, created_by, last_modified_by, img_url, member_id, post_id, post_image_id) 
    values
        (?, ?, ?, ?, ?, ?, ?, ?)
        
>>>>>>>>> 세번째 게시글 이미지 저장 쿼리

post 엔티티 삭제

중요!

이번 포스팅의 핵심이다.


그럼 post 엔티티를 삭제할 경우 무슨일이 발생할까?

post 엔티티는 총 3개의 엔티티와 연관관계를 맺고 있고(postImage, postLike, comment) 각 casacde 옵션이 (ALL, REMOVE, REMOVE) 이다.

즉 post 삭제 시 연쇄적으로 삭제하고자 하는 post 엔티티와 연관관계를 맺고있는 모든 엔티티가 삭제된다.

주의

그럼 post 와 직접적으로 연관관계를 맺고 있는데 postImage, postLike, comment 만 삭제가 될까?

아니다!

comment 엔티티 필드를 잘보면 commentLikeList 에 cascade = CascadeType.REMOVE 옵션이 설정되어 있는 것을 확인할 수 있다.


post 엔티티 삭제 시 프로세스를 살펴보자

  1. post 삭제 전 연관된 (postImage, postLike, comment) 삭제
  2. post 와 연관된 comment 삭제 전 commentLike 엔티티 삭제
  3. 최종적으로 삭제하고자 하는 Post 삭제

그런데 왜? post 부터가 아닌 자식 엔티티들 부터 삭제가 되는 것일까?

부모엔티티 먼저 삭제하면 자식엔티티의 부모엔티티를 참조할 수 없기 때문이다.

따라서 자식 엔티티들의 cascade 옵션을 REMOVE 또는 ALL 로 설정할 경우 삭제하고자 하는 엔티티의 연관관계의 끝에서 부터 삭제가 된다.

profile
공부한 것을 잊지 않기 위해, 고민했던 흔적을 남겨 성장하기 위해 글을 씁니다.

0개의 댓글