JPA

[JPA] 연관 관계를 가진 엔티티 저장 방식 개선 (불필요한 select문 제거)

제리 . 2021. 5. 30. 20:56

JPA를 사용하면서 연관 관계를 갖는 엔티티를 만들어 주기위해 연관된 엔티티를 조회해오는 경우가 있다. 

 

아래 게시글에 댓글을 의미하는 Comment엔티티가 있다.

@Getter
@NoArgsConstructor
@Entity
public class Comment {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @ManyToOne(fetch = FetchType.LAZY)
  private Post post;

  @Lob
  private String content;

  @Builder
  public Comment(Post post, String content) {
    this.post = post;
    this.content = content;
  }
}

해당 엔티티를 저장하기 위해서는 Post엔티티가 필요하다. 

 

웹상에서 게시글에 댓글을 작성한다고 생각해보자

 

 

댓글 작성을 위해 먼저 웹에서 보내는 요청을 받는 dto가 있을 것이다. 

@Getter
public class CommentRequestDto {

  private long postId;
  private String content;
  
}

 

코드를 단순화하면 dto에서는 게시글의 id값과 댓글 내용을 전달 받는다. 

public void saveComment(CommentRequestDto requestDto) {
    Post post = postRepository.findById(requestDto.getPostId()).get();
    Comment comment = Comment.builder()
        .post(post)
        .content(requestDto.getContent())
        .build();
    return commentRepository.save(comment);
 }

Comment를 저장하기 위해서는 Post엔티티를 조회해서 Comment 엔티티를 만드는데 사용하고 Comment 엔티티를 저장해야한다. 

그런데 Post 엔티티를 가져오는 과정에서 DB에서 조회가 이뤄진다. select + insert문이 실행되는 것이다. 잘 생각해보면 굳이 select해오는 과정이 필요한가 의문이 든다. 왜냐면 fk인 post_id는 이미 알고있고 Comment엔티티는 DB에 저장될 때 이 fk로 저장되기 때문이다. 만약 JPA가 아닌 JDBC를 사용한다면 쿼리 파라미터에 post_id를 바로 사용하면 이런 문제가 발생하지 않는다. 이번 포스트는 JPA에서 직접 연관 관계의 엔티티를 조회하지 않고 엔티티를 저장하는 방식에 대해 다룬다.

  

대안1. 임시 entity만들기

public void saveComment(CommentRequestDto requestDto) {
    Post post = Post.builder().id(requestDto.getPostId()).build(); //id값을 지닌 임시 엔티티 작성
    Comment comment = Comment.builder()
        .post(post)
        .content(requestDto.getContent())
        .build();
    return commentRepository.save(comment);  
}

직접 post를 조회하는 것 대신 dto에 담긴 post_id를 사용하여 엔티티를 만들어 주었다. 이런 경우 어떻게 될까?

select문이 실행되지 않고 insert문만 실행된다. 

 

그런데, 조금 찝찝하다. post엔티티에서 id값을 제외한 나머지 필드는 다 null값이 설정된다. 만약 save에 사용된 Comment엔티티에 접근해서 post엔티티를 사용한다고 했을 때, db에 저장된 값이 아닌 null값이 출력된다. 실제 db와 불일치하는 결과를 가져오는 문제가 있다. 

 

대안2. getOne()메서드 사용

 public Comment saveComment(CommentRequestDto requestDto) {
    Post post = postRepository.getOne(requestDto.getPostId());
    Comment comment = Comment.builder()
        .post(post)
        .content(requestDto.getContent())
        .build();
    return commentRepository.save(comment);
 }

JpaRepository에서 제공하는 getOne()을 사용한 방식이다. 결과는 아래와 같다.

select문이 추가적으로 발생하지 않았다. 그렇다면 getOne()메서드는 어떤 역할을 할까?

우리가 흔히 사용하던 findById()은 eager방식의 조회기법이라면 getOne()은 lazy방식으로 조회된다.

레퍼런스를 확인해보면 주어진 식별자를 가지고있는 엔티티의 참조를 반환한다. JPA에서 lazy loading을 사용할때 엔티티 매니저에서 getReference()를 사용하는데 getOne()도 내부적으로 이 메서드를 사용한다. 프록시를 사용하기 때문에 실제 필드에 접근할 때 db에서 조회해온다.  getOne()을 사용하고 필드에 접근하는 예시를 보자.

@Transactional
  public Comment saveComment(CommentRequestDto requestDto) {
    Post post = postRepository.getOne(requestDto.getPostId());
    System.out.println(post.getContent()); //필드 값 접근
    Comment comment = Comment.builder()
        .post(post)
        .content(requestDto.getContent())
        .build();
    return commentRepository.save(comment);
  }

이런 상황이라면 필드에 접근했으므로 아래와 같이 select문이 동작한다. 만약, 식별자가 db에 존재하지 않는다면 필드 접근 시점에서 예외가 발생한다.

fk로 사용되는 경우 제약조건이 걸려있다면 아래처럼 예외가 발생한다.

fk의 제약 조건을 다음과 같이 설정하지 않는다면, 식별자가 실제 존재하지 않아도 db에 저장된다.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(foreignKey = @ForeignKey(value = ConstraintMode.NO_CONSTRAINT))
private Post post;

 

결론

JPA는 엔티티를 저장할 때 객체와 연관관계를 만들어주는 측면이 조금 불편하다. 보통 findById()를 사용하면 데이터의 조회가 이뤄져서 연관관계의 엔티티를 조회하기 위한 select + insert쿼리가 수행된다. 이를 개선하기위해 엔티티의 id값만 설정한 임시 엔티티를 만들어주는 방식과 getOne()을 사용한 방식이 있다. 임시로 엔티티를 만들어서 연관 관계를 만드는 방법은 기존의 db데이터와 불일치 문제를 야기할 수 있다. 반면, getOne()을 사용할시 다른 필드에 접근했을 경우 db에 존재하지 않는다면 예외를 반환하고 존재하는 경우 lazy방식으로 조회하기 때문에 데이터의 불일치 문제를 해결할 수 있다. 따라서 연관 관계를 갖는 엔티티를 저장할 때, 연관된 엔티티 조회시 getOne()을 사용하는 것이 성능 개선에 도움이 된다.