본문 바로가기

JPA

[Spring Data JPA] 나머지 기능들

Specifications (명세)

책 도메인 주도 설계(Domain Driven Design)SPECIFICATION(명세)라는 개념을 소개

스프링 데이터 JPAJPA Criteria를 활용해서 이 개념을 사용할 수 있도록 지원

 

술어(predicate)

  • 참 또는 거짓으로 평가
  • AND OR 같은 연산자로 조합해서 다양한 검색조건을 쉽게 생성(컴포지트 패턴)
  • 예) 검색 조건 하나하나
  • 스프링 데이터 JPA는 org.springframework.data.jpa.domain.Specification 클래스로 정의

명세 기능 사용 방법

public interface MemberRepository extends JpaRepository<Member, Long>, JpaSpecificationExecutor<Member> {

}
  • JpaSpecificationExecutor 인터페이스 상속

JpaSpecificationExecutor 인터페이스

public interface JpaSpecificationExecutor<T> {
     Optional<T> findOne(@Nullable Specification<T> spec);
     List<T> findAll(Specification<T> spec);
     Page<T> findAll(Specification<T> spec, Pageable pageable);
     List<T> findAll(Specification<T> spec, Sort sort);
     long count(Specification<T> spec);
}
  • Specification을 파라미터로 받아서 검색 조건으로 사용

MemberSpec 명세 정의 코드

public class MemberSpec {
    public static Specification<Member> teamName(final String teamName) {
        return new Specification<Member>() {

            @Override
            public Predicate toPredicate(Root<Member> root, CriteriaQuery<?> query, CriteriaBuilder criteriaBuilder) {
                if (StringUtils.isEmpty(teamName)) {
                    return null;

                }
                Join<Member, Team> t = root.join("team", JoinType.INNER);// 회원과 조인
                return criteriaBuilder.equal(t.get("name"), teamName);
            }
        };
    }
    public static Specification<Member> username(final String username) {
        return (Specification<Member>) (root, query, criteriaBuilder) -> 
                criteriaBuilder.equal(root.get("username"), username);
    }
}
  • 명세를 정의하려면 `Specification` 인터페이스를 구현
  • 명세를 정의할 때는 `toPredicate(...)` 메서드만 구현하면 되는데 JPA Criteria의 Root , CriteriaQuery , CriteriaBuilder 클래스를 파라미터 제공 예제에서는 편의상 람다를 사용
  • 실무에서는 JPA Criteria를 거의 안쓴다! 대신에 QueryDSL을 사용하자.**

명세 사용 코드

@Test
public void specBasic() {
    // given
    Team team = new Team("TeamA");
    em.persist(team);

    Member member1 = new Member("member1", 10, team);
    Member member2 = new Member("member2", 10, team);
    em.persist(member1);
    em.persist(member2);

    em.flush();
    em.clear();

    // when
    Specification<Member> spec = MemberSpec.username("member1").and(MemberSpec.teamName("TeamA"));
    List<Member> result = memberRepository.findAll(spec);

    Assertions.assertThat(result.size()).isEqualTo(1);
}
  • `Specification` 을 구현하면 명세들을 조립할 수 있음. where(), and() , or() , not() 제공
  • `findAll` 을 보면 회원 이름 명세(username)와 팀 이름 명세(teamName)를 and로 조합해서 검색 조건으로 사용

Query By Example

@Test
public void specBasic() {
    // given
    Team team = new Team("TeamA");
    em.persist(team);

    Member member1 = new Member("member1", 10, team);
    Member member2 = new Member("member2", 10, team);
    em.persist(member1);
    em.persist(member2);

    em.flush();
    em.clear();

    // when
    Specification<Member> spec = MemberSpec.username("member1").and(MemberSpec.teamName("TeamA"));
    List<Member> result = memberRepository.findAll(spec);

    Assertions.assertThat(result.size()).isEqualTo(1);
}

@Test
public void queryByExample() {
    // given
    Team team = new Team("TeamA");
    em.persist(team);

    Member member1 = new Member("member1", 10, team);
    Member member2 = new Member("member2", 10, team);
    em.persist(member1);
    em.persist(member2);

    em.flush();
    em.clear();

    // when
    // Probe 생성
    Member member = new Member("member1");

    //ExampleMatcher 생성, age 프로퍼티는 무시
    ExampleMatcher matcher = ExampleMatcher.matching()
            .withIgnorePaths("age");
    Example<Member> example = Example.of(member, matcher);

    List<Member> result = memberRepository.findAll(example);
    
    // then
    assertThat(result.get(0).getUsername()).isEqualTo("member1");
}
  • Probe: 필드에 데이터가 있는 실제 도메인 객체
  • ExampleMatcher: 특정 필드를 일치시키는 상세한 정보 제공, 재사용 가능
  • Example: Probe와 ExampleMatcher로 구성, 쿼리를 생성하는데 사용

장점

  • 동적 쿼리를 편리하게 처리
  • 도메인 객체를 그대로 사용
  • 데이터 저장소를 RDB에서 NOSQL로 변경해도 코드 변경이 없게 추상화 되어 있음
  • 스프링 데이터 JPA `JpaRepository` 인터페이스에 이미 포함

단점

  • 조인은 가능하지만 내부 조인(INNER JOIN)만 가능함 외부 조인(LEFT JOIN) 안됨 다음과 같은 중첩 제약조건 안됨
    • firstname = ?0 or (firstname = ?1 and lastname = ?2)
  • 매칭 조건이 매우 단순함
    • 문자는 starts/contains/ends/regex
    • 다른 속성은 정확한매칭( = )만지원

정리

  • 실무에서 사용하기에는 매칭 조건이 너무 단순하고, LEFT 조인이 안됨
  • 실무에서는 QueryDSL을 사용하자

Projections

엔티티 대신에 DTO를 편리하게 조회할 때 사용
전체 엔티티가 아니라 만약 회원 이름만 딱 조회하고 싶으면
?

 

1. 인터페이스 기반 Closed Projections

public interface UsernameOnly {
     String getUsername();
}
  • 조회할 엔티티의 필드를 getter 형식으로 지정하면 해당 필드만 선택해서 조회(Projection)
  • 프로퍼티 형식(getter)의 인터페이스를 제공하면, 구현체는 스프링 데이터 JPA가 제공

public interface MemberRepository ... {
    List<UsernameOnly> findProjectionByUsername(String username);
}
  • 메서드 이름은 자유, 반환 타입으로 인지
@Test
public void projections() {
    // given
    Team team = new Team("TeamA");
    em.persist(team);

    Member member1 = new Member("member1", 10, team);
    Member member2 = new Member("member2", 10, team);
    em.persist(member1);
    em.persist(member2);

    em.flush();
    em.clear();

    // when
    List<UsernameOnly> result = memberRepository.findProjectionByUsername("member1");
    for (UsernameOnly usernameOnly : result) {
        System.out.println("usernameOnly = " + usernameOnly);
    }

    //then
    assertThat(result.size()).isEqualTo(1);
}
select m.username from member m
  where m.username=‘member1’;
  • SQL에서도 select절에서 username만 조회(Projection)하는 것을 확인
  • SELECT 최적화가 되었음(Member의 username만 조회).

2. 인터페이스 기반 Open Proejctions

public interface UsernameOnly {
    @Value("#{target.username + ' ' + target.age}")
    String getUsername();
}
  • 다음과 같이 스프링의 SpEL 문법도 지원
  • ! 이렇게 SpEL문법을 사용하면, DB에서 엔티티 필드를 다 조회해온 다음에 계산한다! 따라서 JPQL SELECT 절 최적화가 안된다.

3. 클래스 기반 Projection

public class UsernameOnlyDto {

    private final String username;

    // 생성자의 파라미터 이름으로 매칭을 시켜서 프로젝션
    public UsernameOnlyDto(String username) {
        this.username = username;
    }
    public String getUsername() {
        return username;
    }
}
  • 다음과 같이 인터페이스가 아닌 구체적인 DTO 형식도 가능
  • 생성자의 파라미터 이름으로 매칭
public interface MemberRepository ... {
    List<UsernameOnlyDto> findClassProjectionByUsername(String username);
}

 

4. 동적 Projections

<T> List<T> findClassProjectionByUsername(String username, Class<T> type);
  • 다음과 같이 Generic type을 주면, 동적으로 프로젝션 데이터 번경 가능

사용코드

List<UsernameOnlyDto> result = memberRepository.findClassProjectionByUsername("member1", UsernameOnlyDto.class);

 

5. 중첩 구조 처리

public interface NestedClosedProjections {

    String getUsername();
    TeamInfo getTeam();
    
    interface TeamInfo {
        String getName();
    }
}
@Test
public void classProjections() {
    // given
    Team team = new Team("TeamA");
    em.persist(team);

    Member member1 = new Member("member1", 10, team);
    Member member2 = new Member("member2", 10, team);
    em.persist(member1);
    em.persist(member2);

    em.flush();
    em.clear();

    // when
    List<NestedClosedProjections> result = memberRepository.findClassProjectionByUsername("member1", NestedClosedProjections.class);

    for (NestedClosedProjections nestedClosedProjections : result) {
        System.out.println("nestedClosedProjections = " + nestedClosedProjections.getUsername());
        System.out.println("nestedClosedProjections.getTeam().getName() = " + nestedClosedProjections.getTeam().getName());
    }

    //then
    assertThat(result.size()).isEqualTo(1);
}
select
        m1_0.username,
        t1_0.team_id,
        t1_0.create_by,
        t1_0.create_time,
        t1_0.last_modified_by,
        t1_0.last_modified_date,
        t1_0.name 
    from
        member m1_0 
    left join
        team t1_0 
            on t1_0.team_id=m1_0.team_id 
    where
        m1_0.username=?
  • 프로젝션 대상이 root 엔티티면, JPQL SELECT 절 최적화 가능
  • 프로젝션 대상이 ROOT가 아니면
    • LEFT OUTER JOIN 처리
    • 모든 필드를 SELECT해서 엔티티로 조회한 다음에 계산

정리

  • 프로젝션 대상이 root 엔티티면 유용하다
  • 프로젝션 대상이 root 엔티티를 넘어가면 JPQL SELECT 최적화가 안된다!
  • 실무의 복잡한 쿼리를 해결하기에는 한계가 있다.
  • 실무에서는 단순할 때만 사용하고, 조금만 복잡해지면 QueryDSL을 사용하자.

네이티브 쿼리

가급적 네이티브 쿼리는 사용하지 않는게 좋음, 정말 어쩔 수 없을 때 사용

최근에 나온 궁극의 방법 → 스프링 데이터 Projections 활용

 

스프링 데이터 JPA 기반 네이티브 쿼리

  • 페이징 지원
  • 반환 타입
    • Object[]
    • Tuple
    • DTO(스프링 데이터 인터페이스 Projections 지원)
  • 제약
    • Sort 파라미터를 통한 정렬이 정상 동작하지 않을 수 있음(믿지 말고 직접 처리)
    • JPQL처럼 애플리케이션 로딩 시점에 문법 확인 불가
    • 동적 쿼리 불가
public interface MemberRepository extends JpaRepository<Member, Long> {
     @Query(value = "select * from member where username = ?", nativeQuery = true)
     Member findByNativeQuery(String username);
 }
  • JPQL은 위치 기반 파리미터를 1부터 시작하지만 네이티브 SQL은 0부터
  • 시작 네이티브 SQL을 엔티티가 아닌 DTO로 변환은 하려면

Projections 활용

public interface MemberProjection {

    Long getId();
    String getUsername();
    String getTeamName();
}
@Query(value = "SELECT m.member_id as id, m.username, t.name as teamName " +
        "FROM member m left join team t ON m.team_id = t.team_id",
        countQuery = "SELECT count(*) from member",
        nativeQuery = true)
Page<MemberProjection>findByNativeProjection(Pageable pageable);
@Test
public void nativeQueryWithProjection() {
    // given
    Team team = new Team("TeamA");
    em.persist(team);

    Member member1 = new Member("member1", 10, team);
    Member member2 = new Member("member2", 10, team);
    em.persist(member1);
    em.persist(member2);

    em.flush();
    em.clear();

    // when
    Page<MemberProjection> result = memberRepository.findByNativeProjection(PageRequest.of(0, 10));
    List<MemberProjection> content = result.getContent();

    for (MemberProjection memberProjection : content) {
        System.out.println("memberProjection = " + memberProjection.getUsername());
        System.out.println("memberProjection.getTeamName() = " + memberProjection.getTeamName());
    }
}

 

동적 네이티브 쿼리

  • 하이버네이트를 직접 활용
  • 스프링 JdbcTemplate, myBatis, jooq같은 외부 라이브러리 사용
//given
 String sql = "동적 쿼리 작성";
 List<MemberDto> result = em.createNativeQuery(sql)
         .setFirstResult(0)
         .setMaxResults(10)
         .unwrap(NativeQuery.class)
         .addScalar("username")
         .setResultTransformer(Transformers.aliasToBean(MemberDto.class))
         .getResultList();
}