sol 개발 블로그 로고
Published on

Spring JPA의 사실과 오해 (2)

Authors
  • avatar
    Name
    Chan Sol OH
    Twitter

목차

이번 포스팅은 Hibernate의 N+1 Select 문제에서 추가적으로 NHN Forward 2019에 발표된 Spring Data JPA의 사실과 오해 내용을 참고하고 실습한다.

이전 OneToMany 관계의 insert 쿼리 문제와 fetch join에 관련된 내용은 Spring JPA의 사실과 오해 (1)을 참고할 수 있다.

JPA Repository 메서드로 JOIN 쿼리 보내기

JPA는 데이터베이스 테이블 간의 관계를 Entity 클래스의 속성으로 모델링한다. JPA Repository 메서드는 _를 통해 내부 속성값을 접근할 수 있다.

ProductContent Entity 객체의 product 속성 값에 접근해서 N대1 관계인 ProductContentProduct 두 테이블을 Left Join해서 쿼리를 요청할 수 있다.

ProductContentRepository
public interface ProductContentRepository extends JpaRepository<ProductContent, Long> {

    List<ProductContent> findByProduct_ProductId(Long id);
    List<ProductContent> findByProduct_ProductName(String name);
}

ProductContentRepository에서 findByProduct_ProductId는 프록시 객체인 id만 참조하기 때문에 Left Join이 발생하지 않는다. 하지만, findByProduct_ProductId로 얻은 ProductContent 객체 내부의 Product 속성 중 id를 제외한 속성을 참조하면 N+1문제가 발생할 것이다.

findByProduct_ProductName을 사용하면 ProductContent Entity의 Product 속성 중 내부 속성인 name을 검색하기 때문에 SQL 쿼리를 작성할 때 Left Join이 적용되어서 DB에 요청된다. 실제 요청에 따른 쿼리는 아래와 같다.

select p1_0.content_id,p1_0.content_img_url,p1_0.product_id
  from product_content p1_0
  left join product p2_0
  on p2_0.product_id=p1_0.product_id
where p2_0.product_name=?

Page vs Slice

JPA Repository 메서드 중 PageSlice 자료형을 출력하는 메서드는 쿼리의 결과 중 특정 개수의 row data에 대해서만 응답하도록한다. 하지만, 아래 그림과 같이 PageSlice를 상속 받으며 전체 페이지와 전체 Element 개수를 검색하는 것을 확인할 수 있다.

page와 slice의 차이점

Page는 조건에 따른 전체 요소 개수를 세기위해서 count 쿼리를 한번 더 요청하는 문제가 있다. 그렇기 때문에 데이터가 급변하지 않는다면, 초기에 한번 count쿼리를 날려서 전체 개수를 얻고, Slice를 사용하여 page에 따른 요소를 얻는 것이 단순히 Page를 사용하는 것보다 적절할 것이다.

JPA Repository 메서드로 DTO Projection 하기

JPA Repository 메서드로 Entity를 DTO로 Projection하는 방법은 아래와 같이 3가지 방법이 있다.

  • Class 기반 Projection
  • Interface 기반 Projection
  • Dynamic Projuection

Class 기반 Projection

아래와 같이 DTO에 final로 선언된 필드를 JPA Repository의 출력 자료형으로 사용하면 final 필드와 동일한 컬럼만 쿼리의 결과로 나오게된다.

MemberDto.java
@Value
public class MemberDto{
  private final String name;
  private final LocalDate createDate;
}
MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long>{
  Collection<MemberDto> findByName(String name);
}

// name과 create_date 컬럼만 쿼리된다. -> SQL 자체적으로 DTO projection 가능!

Interface 기반 Projection

아래와 같이 Member Entity가 있을 때, Entity class 필드의 getter를 정의한 interface MemberNameOnly를 작성할 수 있다.

Memver.java
@Entity
@Table(name="Members")
public class Member{
  private String name;
}
MemberNameOnly.java
public interface MemberNameOnly {
  String getName();
}

위처럼 Entity와 Interface를 정의하고, JPA Repository의 출력 자료형Interface를 넣으면

MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long>{
  Collection<MemberNameOnly> findByCreateDateAfter(LocalDateTime createDate);
}

// select name from Members
// where create_dt > {createDate}

Spring Data JPA가 실제 구현 객체는 프록시로 만들어서 출력하게된다. 그리고 Interface 기반 Projection은 중첩 구조를 지원한다.

Memver.java
@Entity
public class Member{
  private String name;
  @OneToMany
  private List<MemberDetail> details;
}

@Entity
public class MemberDetail{
  @EmbeddedId
  private Pk pk;
  private String description;
  @EmbeddedId public static class Pk{
    private String type;
  }
}

예를 들어 MemberList<MemberDetail> 이라는 필드가 존재할 때 아래와 같이 Interface를 작성할 수 있다.

MemberDto.java
public interface MemverDto{
  String getName();
  List<MemberDetailDto> getDetails();

  interface MemberDetail{
    @Value("#{target.pk.type}")
    String getType();
    String getDescription();
  }
}

@Value를 통해서 타켓을 설정하고, 인터페이스의 중첩 구조를 통해 Spring Data JPA의 출력 DTO를 정의할 수 있다.

Dynamic Projection

Dynamic Projection은 기본 Projection 방법들과 다르게 제네릭을 이용해서 출력 자료형이 정해지지 않고 JPA Repository method로 쿼리의 조건만 정의해 놓아서 유연한 사용을 할 수 있다.

MemberRepository.java
public interface MemberRepository extends JpaRepository<Member, Long>{
  <T> Collection <T> findByCreateDateAfter(LocalDate createDate, Class<T> type);
}

위 메서드는 아래와 같이 사용할 수 있다.

example.java
Collection<MemberNameOnly> nameOnlies = memberRepository.findByCreateDateAfter(LocalDateTime.now(), MemberNameOnly.class)
Collection<MemberDto> nameOnlies = memberRepository.findByCreateDateAfter(LocalDateTime.now(), MemberDto.class)