스프링 JPA 양방향 연관관계의 이해 - 일대다(@OneToMany)

김상운(개발둥이)·2022년 3월 16일
7
post-thumbnail

개요

camp-us 프로젝트를 진행하는 중에 개발 편의성을 위해 엔티티에 @OneToMany(일대다) 를 사용하여 양방향 연관관계를 맺는 일이 많아졌다. 양방향 연관관계를 사용하면 부모 엔티티 조회 시 연관된자식 엔티티까지 조회하므로 굉장히 편하게 사용할 수 있다. 하지만 연관관계가 복잡해질수록 예상치 못한 쿼리가 나가는 것을 확인할 수 있다. 또한 cascade 옵션까지 설정하게 되면 부모 엔티티 삭제 시 무수히 많은 삭제 쿼리를 경험할 수 있다.

이번 포스팅에서 작성자가 경험한, 양방향 연관관계가 맺어진 엔티티를 조회 시 만나는 쿼리에 대해 설명하겠다.

cascade 옵션은 다음 포스팅에서 다루도록 하겠다.

인프런 김영한님의 JPA 강의에서 양방향 연관관계는 '엔티티 설계시 모두 단방향으로 설계 후 필요할 경우 양방향을 사용하라고 하셨다'.

엔티티

ERD 예시

실제로는 더 복잡하지만 이번 포스팅에서 사용할 테이블만 표시하였다.(mysql 자동 erd 생성을 사용하였다.)


위와 같이 설계한 이유

Post(일)

PostImage(다): 하나의 Post(게시글) 작성 시 여러 사진을 올릴 수 있다.
PostLike(다): 하나의 Post(게시글) 에 다수의 회원이 좋아요를 누를 수 있다.
Comment(다): 하나의 Post(게시글) 에 여러 댓글을 작성할 수 있다.


굵은 글씨를 잘 보면 각 엔티티가 왜 Post 와 다대일로 맺어졌는지 확인할 수 있다.

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;
    }
}

이번 포스팅에 가장 핵심이 되는 엔티티 이다. post 엔티티는 comment, poistImage, postLike 와 위의 필드에서 확인할 수 있듯이 @OneToMany 일대다로 연관관계를 맺고 있다.

cascade 옵션은 다음 포스팅 참조

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 는 commentLike 와 일대다 이다.

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(게시글) 단건 조회하면 어떤 쿼리가 몇번 발생할까?

Post findPost = postRepository.findById(postId);
  1. post 단건 조회하는 select 쿼리 = 1 번
  2. post 단건 조회하는 select 쿼리 + postImageList, postLikeList, commentList 필드를 채워주기 위한 select 쿼리 = (1 + 3) 번

JPA 에 대해 어느정도 이해가 있다면 정답은 2 번이라는 것을 알 수 있다.


그 이유는 post 엔티티 내부에 각 list(postImageList, postLikeList, commentList) 값을 채워주기 위해서 JPA 가 추가적인 3 번의 쿼리가 발생하기 때문이다.

실제 쿼리

select
        post0_.post_id as post_id1_6_0_,
        post0_.created_date as created_2_6_0_,
        post0_.last_modified_date as last_mod3_6_0_,
        post0_.created_by as created_4_6_0_,
        post0_.last_modified_by as last_mod5_6_0_,
        post0_.comment_cnt as comment_6_6_0_,
        post0_.content as content7_6_0_,
        post0_.like_cnt as like_cnt8_6_0_,
        post0_.member_id as member_10_6_0_,
        post0_.post_type as post_typ9_6_0_ 
    from
        post post0_ 
    where
        post0_.post_id=?
        
> post 단건 조회 쿼리        

select
        postlike0_.post_like_id as post_lik1_8_,
        postlike0_.member_id as member_i2_8_,
        postlike0_.post_id as post_id3_8_ 
    from
        post_like postlike0_ 
    where
        postlike0_.member_id=? 
        and postlike0_.post_id=?
        
> post 부모 엔티티와 연관된 자식 엔티티 postLike 조회 쿼리

select
        commentlis0_.post_id as post_id9_2_1_,
        commentlis0_.comment_id as comment_1_2_1_,
        commentlis0_.comment_id as comment_1_2_0_,
        commentlis0_.created_date as created_2_2_0_,
        commentlis0_.last_modified_date as last_mod3_2_0_,
        commentlis0_.created_by as created_4_2_0_,
        commentlis0_.last_modified_by as last_mod5_2_0_,
        commentlis0_.content as content6_2_0_,
        commentlis0_.like_cnt as like_cnt7_2_0_,
        commentlis0_.member_id as member_i8_2_0_,
        commentlis0_.post_id as post_id9_2_0_ 
    from
        comment commentlis0_ 
    where
        commentlis0_.post_id=?
        
> post 부모 엔티티와 연관된 자식 엔티티 comment 조회 쿼리

select
        postimagel0_.post_id as post_id8_7_1_,
        postimagel0_.post_image_id as post_ima1_7_1_,
        postimagel0_.post_image_id as post_ima1_7_0_,
        postimagel0_.created_date as created_2_7_0_,
        postimagel0_.last_modified_date as last_mod3_7_0_,
        postimagel0_.created_by as created_4_7_0_,
        postimagel0_.last_modified_by as last_mod5_7_0_,
        postimagel0_.img_url as img_url6_7_0_,
        postimagel0_.member_id as member_i7_7_0_,
        postimagel0_.post_id as post_id8_7_0_ 
    from
        post_image postimagel0_ 
    where
        postimagel0_.post_id=?
> post 부모 엔티티와 연관된 자식 엔티티 postImage 조회 쿼리

실제로 네번의 쿼리가 나가는 것을 확인할 수 있다.

이번 예제에서는 네번이지만 camp-us 프로젝트에서는 더 많은 쿼리가 나간다.(spring-security 활용을 위한 filter 에서 인증을 위한 회원 엔티티 조회 쿼리 등..)

post(게시글) 전체 조회

이번에는 아래와 같은 post(부모) 엔티티를 전체를 조회하면 (postImageList, postLikeList, commentList)와 같은 자식 엔티티 조회 쿼리는 어떻게 나갈까?


가정) postList 의 사이즈는 10으로 가정

List<Post> postList = postRepository.findAll();

실제 쿼리

select
        post0_.post_id as post_id1_6_0_,
        member1_.member_id as member_i1_4_1_,
        post0_.created_date as created_2_6_0_,
        post0_.last_modified_date as last_mod3_6_0_,
        post0_.created_by as created_4_6_0_,
        post0_.last_modified_by as last_mod5_6_0_,
        post0_.comment_cnt as comment_6_6_0_,
        post0_.content as content7_6_0_,
        post0_.like_cnt as like_cnt8_6_0_,
        post0_.member_id as member_10_6_0_,
        post0_.post_type as post_typ9_6_0_
    from
        post post0_
        
>  post 전체 조회 쿼리 

    select
        postlikeli0_.post_id as post_id3_8_1_,
        postlikeli0_.post_like_id as post_lik1_8_1_,
        postlikeli0_.post_like_id as post_lik1_8_0_,
        postlikeli0_.member_id as member_i2_8_0_,
        postlikeli0_.post_id as post_id3_8_0_ 
    from
        post_like postlikeli0_ 
    where
        postlikeli0_.post_id in (
            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
        )

> post 부모 엔티티 전체와 연관된 자식 엔티티인 postLike 조회 쿼리

    select
        commentlis0_.post_id as post_id9_2_1_,
        commentlis0_.comment_id as comment_1_2_1_,
        commentlis0_.comment_id as comment_1_2_0_,
        commentlis0_.created_date as created_2_2_0_,
        commentlis0_.last_modified_date as last_mod3_2_0_,
        commentlis0_.created_by as created_4_2_0_,
        commentlis0_.last_modified_by as last_mod5_2_0_,
        commentlis0_.content as content6_2_0_,
        commentlis0_.like_cnt as like_cnt7_2_0_,
        commentlis0_.member_id as member_i8_2_0_,
        commentlis0_.post_id as post_id9_2_0_ 
    from
        comment commentlis0_ 
    where
        commentlis0_.post_id in (
            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
        )

> post 부모 엔티티 전체와 연관된 자식 엔티티인 comment 조회 쿼리

    select
        postimagel0_.post_id as post_id8_7_1_,
        postimagel0_.post_image_id as post_ima1_7_1_,
        postimagel0_.post_image_id as post_ima1_7_0_,
        postimagel0_.created_date as created_2_7_0_,
        postimagel0_.last_modified_date as last_mod3_7_0_,
        postimagel0_.created_by as created_4_7_0_,
        postimagel0_.last_modified_by as last_mod5_7_0_,
        postimagel0_.img_url as img_url6_7_0_,
        postimagel0_.member_id as member_i7_7_0_,
        postimagel0_.post_id as post_id8_7_0_ 
    from
        post_image postimagel0_ 
    where
        postimagel0_.post_id in (
            ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
        )
        
> post 부모 엔티티 전체와 연관된 자식 엔티티인 postImage 조회 쿼리

위의 쿼리의 수를 보면 단건 조회일때랑 마찬가지로 네번의 쿼리가 나간것을 확인할 수 있다. 하지만 쿼리의 모양은 자식 엔티티 조회에 in 절에 10개의 파라미터가 추가된 것을 확인할 수 있다.

왜그럴까?

이를 알기 위해서는 지연로딩default_batch_fetch_size의 작동원리에 대해서 알아야한다.

이번 포스팅은 JPA 에 대해 어느정도 알고 있다고 가정하에 글을 쓰기 때문에 지연로딩과 default_batch_fetch_size 에 대해 모른다면 링크를 타고 꼭 학습하자!

이유

먼저 10 개의 파라미터(?) 가 나간 이유는 postList 의 사이즈가 10 이기 때문이다.

default_batch_fetch_size 설정을 하게되면 부모 엔티티 전체를 조회할 경우 연관된 자식 엔티티 조회 쿼리 in 절에 부모 엔티티의 여러 PK 를 넣어 한번에 조회하기 때문이다. 따라서 영속성 컨텍스트에 저장하므로 N+1 문제가 발생하지 않는다.

중요!

이번 글의 작성 이유이다.

그럼 이렇게 생각할 수 있다.

'부모 post 조회 시 자식 comment 까지 조회가 되므로 연쇄적으로 자식 comment자식 엔티티인 commentLike 쿼리도 같이 나가야 하지 않을까?'

좋은 지적이다. 하지만, post 의 자식 엔티티는 post 엔티티에 일대다(@OneToMany) 필드로 매핑되어 있기 때문 바로 조회가 되어 select 쿼리가 나가지만, post 의 자식 comment 의 자식인 commentLike 는 바로 조회가 되지 않고 아래와 같이 직접적으로 comment 엔티티의 commentLikeList 에 접근 시 쿼리가 나간다.

List<Comment> commentList = findPost.getCommentList();
        for (Comment comment : commentList) {
            System.out.println("test : " + comment.getCommentLikeList());
        }

왜 그럴까?

그 이유는, post 엔티티를 조회하는 쿼리이기 때문에 post 에 일대다로 매핑되어있는 자식 엔티티들만 조회하기 때문이다.

정리

  • default_batch_fetch_size 옵션은 항상 켜놓자!
  • 복잡한 양방향 연관관계를 맺을 때 예상치 못한 쿼리를 만날 수 있으므로 JPA 에 대한 깊이 있는 이해를 하여 어디서 어떻게 왜 쿼리가 나갔는지 알 수 있어야 한다.

깃허브: https://github.com/Couch-Coders/6th-camp_us-be

참고 사이트

https://ict-nroo.tistory.com/132
https://velog.io/@jadenkim5179/Spring-defaultbatchfetchsize%EC%9D%98-%EC%9E%91%EB%8F%99%EC%9B%90%EB%A6%AC

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

0개의 댓글