ZOOOO

Logo

이것 저것 남겨보겠습니다.

View My GitHub Profile

4 March 2022

당황스러웠던 JPA의 양방향 매핑과 영속성 컨텍스트

by zoooo-hs

TL;DR


들어가기 앞서

JPA를 사용하고 배우는 사람이라면 영속성에 대해 들어봤을 것이다. JPA는 Entity를 영속성(Persistence) 컨텍스트를 통해 관리한다. JPA는 영속성 컨텍스트에서 영속화된 Entity를 전달해주거나, 사용자가 원하는 Entity를 영속화해서 영속성 컨텍스트에 넣기도 하고 DB에서 가져온 데이터를 알맞은 Entity에 매핑하여 영속화한 뒤 컨텍스트에 저장하고 사용자에게 반환하기도 한다. 이를 통해 캐싱의 효과를 낼 뿐만 아니라, 컨텍스트를 여러 개 두어 transaction 관리 효과를 낼 수도 있다.

이번 글에서 JPA의 영속성에 대한 심도 있는 내용을 정리하진 않을 것이다. 다만 이런 영속성 컨텍스트와 엔티티 관계 매핑을 제대로 이해하지 않았을 때 발생한 이슈에 대해 공유하고자 한다.

배경

Instagram Clone Backend 프로젝트에서 JPA Repository Test Case를 작성하다 일어난 일이 하나 있다. 댓글을 Repository에서 받아올 때 대댓글 순으로 정렬해서 받아야 하는 경우가 있어 테스트 코드를 작성했다. 데이터 세팅을 위해 게시글의 댓글 두 개를 생성하고, 각 댓글에 대댓글을 2개, 1개씩 생성했다. 예상대로라면 2개, 1개의 대댓글을 가진 순서로 댓글이 불러와져야 했지만, 테스트는 실패했다. 디버깅 중 대댓글 리스트가 잘 저장된걸 확인했으나, 각 댓글에 oneToMany로 연결된 대댓글은 보이지 않았다. 어떻게 된 일일까?

아래는 테스트 코드에 사용된 각 엔티티 코드 그리고 테스트 코드이다.

CommentEntity.java

@Getter
@Setter
@NoArgsConstructor
@Entity(name = "comment")
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "commentType")
@NamedEntityGraphs({
        @NamedEntityGraph(name = "comment-user", attributeNodes = {
                @NamedAttributeNode(value = "user"),
                @NamedAttributeNode(value = "likes"),
        }),
})
public abstract class CommentEntity extends BaseEntity {
    @Column(name = "content")
    private String content;

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

    @OneToMany(mappedBy = "comment", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
    private Set<CommentLikeEntity> likes = new HashSet<>();

    @OneToMany(mappedBy = "comment", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
    private Set<CommentCommentEntity> comments = new HashSet<>();

    public Long getLikeCount() {
        return (long) likes.size();
    }

    public Long getCommentCount() {
        return (long) comments.size();
    }

    public CommentEntity(String content, UserEntity user) {
        this.content = content;
        this.user = user;
    }
}

댓글은 게시글의 댓글과 대댓글(댓글의 댓글) 두 가지로 구성된다. 두 댓글의 엔티티를 구성하기 위해 공통된 필드를 Super Class인 CommentEntity에 담았다. 이번에 중요하게 봐야 할 부분은 대댓글과 oneToMany로 연결된 담아둔 commetns이다.

@OneToMany(mappedBy = "comment", fetch = FetchType.LAZY, cascade = CascadeType.REMOVE)
private Set<CommentCommentEntity> comments = new HashSet<>();

뒤에서 CommentCommentEntity에서 설명하겠지만, 대댓글은 모든 댓글에 댓글을 작성하는 것이기 때문에 CommentEntity와 ManyToOne 관계를 맺는다. 그리고 앞으로 설명할 Repository 테스트 코드에서 댓글 엔티티에서 대댓글 엔티티들을 참조하기 위해 CommentEntity에 CommentCommentEntity와 OneToMany 관계를 맺어 양방향 관계를 표현하였다.

PostCommentEntity

@Getter
@Setter
@NoArgsConstructor
@Entity(name = "post_comment")
@DiscriminatorValue("post_comment")
public class PostCommentEntity extends CommentEntity {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id", nullable = false)
    private PostEntity post;

    @Builder
    public PostCommentEntity(String content, PostEntity post, UserEntity user) {
        super(content, user);
        this.post = post;
    }
}

PostCommentEntity는 CommenEntity를 상속받고 특정 Post(게시글)에 속한 댓글이기 때문에 PostEntity와 ManyToOne 연관 관계를 맺는다.

CommentCommentEntity

@Getter
@Setter
@NoArgsConstructor
@Entity(name = "comment_like")
@DiscriminatorValue("comment_like")
public class CommentLikeEntity extends LikeEntity {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "comment_id", nullable = false)
    private CommentEntity comment;

    @Builder
    public CommentLikeEntity(CommentEntity comment, UserEntity user) {
        super(user);
        this.comment = comment;
    }
}

CommentCommentEntity는 대댓글 엔티티로 게시글의 댓글(PostCommentEntity) 혹은 다른 대댓글(CommentCommentEntity)와 ManyToOne 관계를 맺어야 한다. 그렇기에 두 댓글의 Super Class인 CommentEntity와 ManyToOne 관계를 맺었다.

PostCommentRepositoryTest.java

@DisplayName("대댓글 개수로 정렬해 받아오기")
@Test
public void orderByCommentCountTest() {
    for (int i = 0; i < 2; i++) {
        // 게시글 댓글 2개 생성
        PostCommentEntity comment = PostCommentEntity.builder()
                .content("content22").user(user).post(post).build();
        postCommentRepository.save(comment);

        for (int j = i; j < 2; j++) {
            // 순서대로 대댓글 2개, 1개 넣기
            CommentCommentEntity commentComment = CommentCommentEntity.builder()
                    .content("asdfasdf").user(user).comment(comment).build();
            commentRepository.save(commentComment);
        }
    }

    List<PostCommentEntity> actual = postCommentRepository.findPostCommentsOrderByCommentsSize(post.getId(), PageRequest.of(0, 10));

    assertEquals(2, actual.get(0).getCommentCount());
    assertEquals(1, actual.get(1).getCommentCount());
}

for loop를 통해 같은 게시글의 PostCommentEntitty를 두 개 생성하고, 각 PostCommentEntity의 CommentCommentEntity를 각각 2개, 1개씩 생성한다. for loop를 마치면 결과적으로 2개의 대댓글을 가진 게시글 댓글 하나, 1개의 대댓글을 가진 게시글 댓글 하나가 생성된다.

이후 postCommentRepository.findPostCommentsOrderByComm 를 통해 해당 게시글의 댓글 리스트를 받아오는데 이때 대댓글의 개수로 내림차순 정렬해 받아온다. 아래는 위 레포지토리 메소드의 코드이다.

@Query("SELECT c FROM post_comment c WHERE c.post.id = :id ORDER BY c.comments.size DESC")
List<PostCommentEntity> findPostCommentsOrderByCommentsSize(@Param("id") Long id, Pageable pageable);

이후 리스트에 있는 대댓글의 개수가 2개, 1개로 테스트를 통과할 줄 알았는 데 실패했다. actual 안에 있는 모든 PostCommentEntity에 comments가 빈 리스트이다.

comments의 size가 0이다.

comments의 size가 0이다.

CommentCommentEntity가 저장되지 않은 건가 싶어서 debugger Watch에 전체 comment를 받아오는 repository method를 호출해 봤는데, 정상적으로 잘 받아오는 걸 알 수 있었다.

그런데 CommentCommentEntity는 존재한다!!

그런데 CommentCommentEntity는 존재한다!!

당황스러웠다.


왜 그럴까?

PostCommentEntity를 저장하고나서 findPostCommentsOrderByComm를 호출할 때 JPA가 영속성 컨텍스트에 저장된 PostCommentEntity를 그대로 다시 가져온 것 같다. PostCommentEntity와 CommentCommentEntity들이 영속성 컨텍스트에 저장되어있다.

그러나 PostCommentEntity를 저장할 때

영속성 컨텍스트 내에 있는 PostCommentEntity의 comments가 비어있는 것이다.


그래서 어떻게?..

이런 경우에 두 가지 방법으로 문제를 해결할 수 있다.

  1. Setter 변경: CommentCommentEntity의 setComment를 수정하여 CommentEntity의 comments에 CommentCommentEntity를 add 한다.
  2. 영속성 컨텍스트 초기화: EntityManager.flush(), EntityManager.close()를 호출하여 Entity를 DB에 flush하고 영속성 컨텍스트를 종료한다.

1. Setter 변경

RDB에선 Table간 FK로 연결만 되어있다면 양방향 Join이 가능하다. 그러나 JPA의 Entity는 기본적으로 자바 객체이기 때문에 한쪽에서 JoinColumn에 해당하는 필드에 객체를 연결했다고 하더라도, 피 연결 객체는 그걸 알 수 없다. persist(save)를 한다고 해도, JPA가 자동으로 피 연결 객체의 mappedBy로 연결된 Collection에 자동으로 넣어주는 것도 아니다.

이런 문제 때문에 영속성 컨텍스트에서 바로 값을 찾아올 경우 Collection이 비어있는 것이다. 이런 문제를 해결하는 방법 중 하나는 관계의 주인을 가진 쪽에서 JoinColumn에 해당하는 필드의 setter를 변경하는 것이다. (보통 ManyToOne JoinColumn을 가진 객체. 여기선 CommentCommentEntity)

@Getter
@Setter
@NoArgsConstructor
@Entity(name = "comment_comment")
@DiscriminatorValue("comment_comment")
public class CommentCommentEntity extends CommentEntity {
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "comment_id", nullable = false)
    private CommentEntity comment;

    @Builder
    public CommentCommentEntity(String content, UserEntity user, CommentEntity comment) {
        super(content, user);
        this.comment = comment;
    }

    public void setComment(CommentEntity comment) {
		    this.comment = comment;
				if (comment != null) {
				    comment.getComments().add(this);
				}
    }
}
// 위 코드에서 추가된 부분
public void setComment(CommentEntity comment) {
    this.comment = comment;
		if (comment != null) {
		    comment.getComments().add(this);
		}
}

위와 같은 setter가 추가되었는데, 대댓글과 연결될 댓글의 comments에 자기 자신을 add 하는 코드가 추가되었다. 이런 방법을 통해 영속성 컨텍스트에서 원 댓글을 그대로 가져왔을 때 comments에 올바르게 값이 있는 것을 알 수 있다.

2. 영속성 컨텍스트 초기화

영속성 컨텍스트에 저장된 원 댓글에 임의로 대댓글 Collection을 넣어주지 않는 이상 올바른 값을 받아올 수 없다면, 영속성 컨텍스트에 해당 원 댓글을 다시 불러오면 된다. 영속성 컨텍스트에 저장된 Entity들을 DB로 flush하고 컨텍스트 내에 있는 Entity를 없앤 뒤 다시 select하여 새로운 Entity를 불러오는 것이다. 이때 원 댓글과 함께 대댓글을 Join 해서 가져오면 된다(혹은 lazy fetch로 받아오면 된다).

// for - loop 돌면서 entity 만드는 내용은 동일 ...

// entityManager는 Autowired로 TestClass 필드로 주입받아 사용했다.
entityManager.flush();
entityManager.clear();

List<PostCommentEntity> actual = postCommentRepository.findPostCommentsOrderByCommentsSize(post.getId(), PageRequest.of(0, 10));

방법이 두개씩이나?

두 방법 중에 뭘 선택해야 하지? 라는 생각이 들었는데, 일단 setter도 적용하고, EntityManager flush, close도 적용했다.


결론

JPA의 주요 내용인 영속성 컨텍스트 관리에 대한 이해가 부족하고 양방향 매핑 시 주의해야할 점에 대해서도 제대로 숙지하지 않아서 발생한 이슈였다. 제대로 알고 사용해야 원하는 결과를 낼 수 있겠다. 더 열심히 공부하자.

tags: JPA - Spring - Spring boot - Spring Data JPA - Java