Lined Notebook

[Spring Data JPA] 39. JPA 마무리 및 나머지기능

by ymkim

01. Specification(명세)

스프링 데이터 JPA에서 Specification은 DB 쿼리의 조건을 Spec으로 작성하여 Repository Method에 적용하거나 몇 가지 Spec을 조합해 사용할 수 있게 도와준다.

01-1. 명세 기능 사용 방법

public interface MemberRepository
    extends JpaRepository<Member, Long>,
            MemberRepositoryCustom,
            JpaSpecificationExecutor<Member> { // 이 부분 추가

    //...
}
  • Repository에 JpaSpecificationExecutor<T> 인터페이스 추가 상속
  • 웬만하면 Specification은 실무에서의 사용은 지양하는게 좋다고 한다
package study.datajpa.repository;

import org.springframework.data.jpa.domain.Specification;
import org.springframework.util.StringUtils;
import study.datajpa.entity.Member;
import study.datajpa.entity.Team;

import javax.persistence.criteria.*;

public class MemberSpec {

    public static Specification<Member> teamName(final String teamName) {
        return (root, query, 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 (root, query, criteriaBuilder) ->
            criteriaBuilder.equal(root.get("name"), userName);
    }
}

01-2. Specification 테스트 코드 작성

@Test
public void specBasic() {
    Team teamA = new Team("teamA");
    em.persist(teamA);

    Member m1 = new Member("m1", 0, teamA);
    Member m2 = new Member("m2", 0, teamA);
    em.persist(m1);
    em.persist(m2);

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

    //when
    Specification<Member> spec = MemberSpec.userName("m1").and(MemberSpec.teamName("teamA"));
    List<Member> result = memberRepository.findAll(spec);

    assertThat(result.size()).isEqualTo(1);
}
  • TeamA를 생성하고 두 명의 회원(m1, m2)를 저장
  • MemberSpec객체를 통해 Specification<Member> 결과를 얻는다
  • findAll의 인수 값으로 spec 변수를 넘기면 전체 조회가 가능하다

02. Query By Example

Query를 할 때 Example에 의해서 Query를 하는 기능

02-1. Query By Example

@Test
public void queryByExample() {
    Team teamA = new Team("teamA");
    em.persist(teamA);

    Member m1 = new Member("m1", 0, teamA);
    Member m2 = new Member("m2", 0, teamA);
    em.persist(m1);
    em.persist(m2);

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

    //when
    //Probe
    Member member = new Member("m1"); // 검색 조건이 Member 엔티티가 된다
    Team team = new Team("teamA");
    member.setTeam(team);

    //ExampleMatcher
    ExampleMatcher matcher = ExampleMatcher.matching().withIgnorePaths("age"); // primitive type 무시

    //Example
    Example<Member> example = Example.of(member, matcher); // 쿼리 생성

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

02-2. Query By Example 장점

  • 동적 쿼리를 편리하게 처리한다
  • 도메인 객체를 그대로 사용한다
  • 스프링 데이터 JPA JpaRepository 인터페이스에 이미 포함한다

02-3. Query By Example 단점

  • Inner join은 가능하지만 Outer join이 불가능
  • 중첩 제약 조건은 사용이 불가능
    • firstName = ?0 or (firstName = ?1 and lastName = ?2)

03. Projection

Projection 기능은 크게 2가지 (Open, Closed Projection)으로 구분이 된다.

스프링 데이터 JPA는 Projection라는 기능을 제공한다. Projection의 경우 엔티티 대신에 DTO를 편리하게 조회할 때 사용이 된다. 즉, 엔티티의 일부 데이터만 가지고 오고 싶은 경우 사용이 되는데 간단한 실습을 통해 Projection의 사용 방법을 숙지 해보자.

  • Nested 프로젝션 가능
  • Closed 프로젝션
    • 일부만 가져오기
    • 쿼리를 최적화 할 수 있다. 가져오려는 attribute가 뭔지 알기에
    • Java 8의 디폴트 메서드를 사용해서 연산을 할 수 있다
  • Open 프로젝션
    • 전부 다 가져와서 가공
    • @Value(SpEL)을 사용해서 연산 할 수 있다. 스프링 빈의 메서드 호출도 가능
    • 쿼리 촤적화 할 수 없다. SpEL을 엔티티 대상으로 사용하기 때문에

03-1. 인터페이스 기반의 Closed Projection 적용

Member 엔티티에서 특정 값만 가져오기위해 아래 인터페이스 생성

package study.datajpa.repository;

public interface UserNameOnly {

    String getUserName();

}
  • 인터페이스 안에 가져오고자 하는 필드명에 맞는 메서드를 생성한다

03-2. MemberRepository에 메서드 추가

package study.datajpa.repository;

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

    //..

    // Projections 기능 사용을 위해 반환 타입을 기존에 생성한 UserNameOnly 인터페이스로 지정
    List<UserNameOnly> findProjectionsByUserName(@Param("userName") String userName);

}
  • Projections 기능 사용을 위해 findProjectionsByUserName(…) 메서드를 선언
  • 여기서 봐야할 부분은 반환 타입이 엔티티, DTO가 아닌 인터페이스(UserNameOnly)라는 점

03-3. 인터페이스 기반의 Closed Projection 테스트코드 작성

@Test
public void projections() {
    Team teamA = new Team("teamA");
    em.persist(teamA);

    Member m1 = new Member("m1", 0, teamA);
    Member m2 = new Member("m2", 0, teamA);
    em.persist(m1);
    em.persist(m2);

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

    // memberRepository.findByUserName("m1");
    List<UserNameOnly> resultList = memberRepository.findProjectionsByUserName("m1");
    for (UserNameOnly result : resultList) {
        System.out.println("result = " + result);
    }
}

단건 조회를 하는 경우 해당 조건에 맞는 전체 필드가 아니라 특정 필드만 조회하고 싶은 상황이다.

2022-07-02 13:33:23.936 DEBUG 13140 --- [           main] org.hibernate.SQL                        :
    select
        member0_.user_name as col_0_0_ // 정확하게 user_name 필드만 가져오는것을 확인 가능
    from
        member member0_
    where
        member0_.user_name=?

JPQL을 사용하여 특정 필드를 조회한 경우가 아닌데 위와 같이 조건(member0_.username=?)에 맞는 필드(member0.user_name as col_0_0*)만 가져오는 것을 확인할 수 있다.

03-4. 디버깅 결과 확인

projectsions

즉, 결과적으로 인터페이스(UserNameOnly.java)를 정의하면 스프링 데이터 JPA에 의해 프록시 객체를 생성하여 결과를 반환 해준다.

03-5. 어노테이션 기반의 Open Projection 적용

package study.datajpa.repository;

import org.springframework.beans.factory.annotation.Value;

public interface UserNameOnly {

    // Spring SpEL 문법 지원
    @Value("#{target.userName + ' ' + target.age}")
    String getUserName();

}
  • Open Projection 적용
  • 테스트 코드를 돌린 결과는 동일하다

03-6. 클래스 기반의 Projection 적용

package study.datajpa.repository;

public class UserNameOnlyDto {

    private final String userName;

    public UserNameOnlyDto(String userName) {
        this.userName = userName;
    }

    public String getUserName() {
        return userName;
    }
}

우선 클래스 기반의 Projection 적용을 위해 위와 같이 UserNameOnlyDto 클래스를 생성 해주었다.

인터페이스 기반의 Projection과 다른 부분은 실제 클래스(DTO)를 생성하여 사용하기에 프록시 객체가 아닌 실제 객체를 반환해준다는 차이점이 존재한다.

public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom, JpaSpecificationExecutor<Member> {
    // 이전에 만든 인터페이스 메서드 반환 타입 수정

    // AS-IS
    // List<UserNameOnly> findProjectionsByUserName(@Param("userName") String userName); // 인터페이스 기반 Projection

    // TO-BE
    List<UserNameOnlyDto> findProjectionsByUserName(@Param("userName") String userName); // 클래스 기반 Projection
}

이전에 MemberRepository 인터페이스에 선언한 메서드의 반환 타입을 DTO로 수정. 후에 아래와 같이 이전에 만든 테스트 코드의 반환 타입만 DTO로 수정 해준다.

@Test
public void projections() {
    Team teamA = new Team("teamA");
    em.persist(teamA);

    Member m1 = new Member("m1", 0, teamA);
    Member m2 = new Member("m2", 0, teamA);
    em.persist(m1);
    em.persist(m2);

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

    /* AS-IS
    List<UserNameOnly> resultList = memberRepository.findProjectionsByUserName("m1");
    for (UserNameOnly result : resultList) {
        System.out.println("result = " + result);
    }*/

    // TO-BE: 클래스(DTO) 기반의 데이터 조회
    List<UserNameOnlyDto> resultList = memberRepository.findProjectionsByUserName("m1");
    for (UserNameOnlyDto result : resultList) {
        System.out.println("[>>>>>>>>>>>>>>>>] result = " + result.getUserName());
    }
}
2022-07-02 13:57:52.350 DEBUG 6968 --- [           main] org.hibernate.SQL                        :
    select
        member0_.user_name as col_0_0_
    from
        member member0_
    where
        member0_.user_name=?

Projection이 최적화된 것을 확인 할 수 있다.

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

    <T> List<T> findProjectionsByUserName(@Param("userName") String userName, Class<T> type); // 클래스 기반 Projection + 동적 Projection [제네릭 타입]

}

또한 위와 같이 제네릭 타입으로 선언하여 사용도 가능.

03-7. 클래스 기반의 Project 적용(중첩 구조)

이번에는 Member와 연관된 Team까지 가져오는 방식에 대해 알아보자

package study.datajpa.repository;

public interface NestedClosedProjection {

    String getUserName();
    TeamInfo getTeam();

    interface TeamInfo {
        String getName();
    }
}

중첩 Projection(NestedClosedProjection)을 만들기 위해 위와 같이 인터페이스르 생성 해준다. 다른 부분은 이전에 했던 것과 동일하지만 내부 인터페이스 TeamInfo가 추가 되었다.

@Test
public void projections() {
    Team teamA = new Team("teamA");
    em.persist(teamA);

    Member m1 = new Member("m1", 0, teamA);
    Member m2 = new Member("m2", 0, teamA);
    em.persist(m1);
    em.persist(m2);

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

    // 01. 인터페이스 기반의 Project 적용
    /*List<UserNameOnly> resultList = memberRepository.findProjectionsByUserName("m1");
    for (UserNameOnly result : resultList) {
        System.out.println("result = " + result);
    }*/

    // 02. 클래스 기반의 Projection 생성 + 동적 Projection -> 제네릭
    /*List<UserNameOnlyDto> resultList = memberRepository.findProjectionsByUserName("m1", UserNameOnlyDto.class);
    for (UserNameOnlyDto result : resultList) {
        System.out.println("[>>>>>>>>>>>>>>>>] result = " + result.getUserName());
    }*/

    // 03. 중첩 Projection - NestedClosedProjection
    List<NestedClosedProjection> resultList = memberRepository.findProjectionsByUserName("m1", NestedClosedProjection.class);
    for (NestedClosedProjection result : resultList) {
        System.out.println("[>>>>>>>>>>>>>>>>] result = " + result.getUserName());
    }
}

이전에 작성한 테스트코드에서 타입만 NestedClosedProjection 인터페이스로 변경 후 실행을 하면 어떤 결과가 나올지 한번 살펴보자.

2022-07-02 14:18:03.662 DEBUG 31484 --- [           main] org.hibernate.SQL                        :
    select
        member0_.user_name as col_0_0_,
        team1_.team_id as col_1_0_,
        team1_.team_id as team_id1_2_,
        team1_.created_date as created_2_2_,
        team1_.updated_date as updated_3_2_,
        team1_.name as name4_2_
    from
        member member0_
    left outer join
        team team1_
            on member0_.team_id=team1_.team_id
    where
        member0_.user_name=?

Member의 경우 정확하게 user_name 필드만 조회가 되었지만 Team의 경우 모든 필드가 조회가 된 것을 위 쿼리를 통해 확인할 수 있다.

왜 Team은 getName()가 있는데도 불구하고 모든 필드가 조회가 되었을까?

중첩 Projection 구조에서는 root에 있는 필드 -> String getUserName() 의 경우 정확하게 최적화가 된다. 하지만 두 번째 부터는 최적화가 되지 않으며 조인(Join)은 Left Join을 사용하여 데이터를 조회한다.

03-8. Project 기능 정리

  1. 프로젝션 대상이 root 엔티티면 유용하다
  2. 프로젝트 대상이 root 엔티티를 넘어가면 JPQL SELECT 최적화가 불가능하다
  3. 실무에서는 단순할 때만 사용하고, 복잡한 경우 QueryDSL을 사용하자

04. 네이티브 쿼리

가급적 네이티브 쿼리를 사용하지 않는 것이 좋다. 즉, 어쩔수없을 경우에만 네이티브 쿼리를 사용해야 한다.

04-1. 네이티브 쿼리 리포지토리 작성

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

    //..

    @Query(value = "select * from Member m where user_name = ?", nativeQuery = true)
    Member findByNativeQuery(String userName);

}
  • @Query 어노테이션의 value 값에 기본적인 SQL 구문 작성
  • Native Query임을 나타내기 위해 nativeQuery = true 지정

04-2. 네이티브 쿼리 테스트 코드 작성

@Test
public void nativeQuery() {
    Team teamA = new Team("teamA");
    em.persist(teamA);

    Member m1 = new Member("m1", 0, teamA);
    Member m2 = new Member("m2", 0, teamA);
    em.persist(m1);
    em.persist(m2);

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

    Member member = memberRepository.findByNativeQuery("m1");
    System.out.println("member = " + member);
}
  • 위에서 만든 nativeQuery 관련 메서드 호출
  • 해당 테스트 코드의 결과(SQL)는 아래와 같다
2022-07-02 15:59:14.063 DEBUG 14408 --- [           main] org.hibernate.SQL                        :
    select
        *
    from
        member
    where
        user_name = ?

04-3. 정리

  • Native Query를 실무에서 직접 사용할 일은 거의 없다
  • Native Query를 직접 사용해야 한다면 JdbcTemplate / Mybatis 사용 권장
  • 동적 네이티브 쿼리도 역시 외부 라이브러리 권장

04-4. Projections 활용

스프링 데이터 JPA 네이티브 쿼리 + 인터페이스 기반 Projections 활용

package study.datajpa.repository;

public interface MemberProjection {

    Long getId();
    String getUserName();
    String getTeamName();

}
  • Id, userName, teamName 관련 메서드를 선언해준다
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom, JpaSpecificationExecutor<Member> {

    //..

    @Query(value = "select m.member_id as id, m.user_name as userName, t.name as teamName " +
            "from member m left join team t ", // ANSI SQL 표준
            countQuery = "select count(*) from member " , // Native Query Count Query가 들어가야함
            nativeQuery = true)
    Page<MemberProjection> findByNativeProjection(Pageable pageable);
}
  • NativeQuery와 Projection 기능을 엮어서 사용
  • 반환 타입이 MemberProjection
    • 이전에는 반환 타입으로 Projection 지정이 불가능 하였음
    • 현재는 가능하여 종종 사용이 된다고 함

참고 자료

블로그의 정보

기록하고, 복기하고

ymkim

활동하기