관리 메뉴

나구리의 개발공부기록

나머지 기능들, Specifications(명세), Query By Example, Projections, 네이티브 쿼리 본문

인프런 - 스프링부트와 JPA실무 로드맵/실전! 스프링 데이터 JPA

나머지 기능들, Specifications(명세), Query By Example, Projections, 네이티브 쿼리

소소한나구리 2024. 10. 28. 19:38

출처 : 인프런 - 실전! 스프링 데이터 JPA (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


** 참고

  • 해당 기능들은 실무에서 자주 사용하진 않지만 알고있으면 가끔 필요할때 유용하게 사용할 수 있음
  • 기능의 복잡도에 비해 실무에서 사용하기 애매한 이유에 대해서 설명의 초점을 둠

1. Specifications (명세)

1) 기능설명

  • 도메인 주도 설계(Domain Driven Design)이라는 책에서는 SPECIFICATION(명세)라는 개념을 소개하는데 스프링 데이터 JPA에서 JPA가 지원하는 Criteria를 활용해서 해당 개념을 사용할 수 있도록 지원함
  • 참 or 거짓으로 평가하거나 AND, OR 같은 연산자로 조합하여 다양한 검색조건을 쉽게 생성할 수 있음
  • 일단 JPA Criteria를 활용했다는것은 미래에는 모르겠으나 현 시점에서는 JPA Criteria는 한눈에 코드의 의미를 파악하기도 어렵기에 유지보수가 매우 힘들어서 사용을 권장하지 않음

(1) JpaSpecificationExecutor 인터페이스 상속

  • 적용하고자 하는 리포지토리 인터페이스에 스프링 데이터 JPA와 함께 상속
  • 내부 구조를 보면 findAll, findOne, count 등등의 메소드가 정의되어 있음
public interface MemberRepository extends JpaRepository<Member, Long>,
                                          JpaSpecificationExecutor<Member> {
}

 

(2) Specification 구현

  • 명세를 구현할 때 JPA Criteria를 사용하여 구현해야함
public class MemberSpec {
    public static Specification<Member> teamName(final String teamName) {
        return (Specification<Member>) (root, query, builder) -> {
            if (StringUtils.isEmpty(teamName)) {
                return null;
            }
            Join<Member, Team> t = root.join("team", JoinType.INNER);
            return builder.equal(t.get("name"), teamName);
        };
    }

    public static Specification<Member> username(final String username) {
        return (Specification<Member>) (root, query, builder) ->
                builder.equal(root.get("username"), username);
    }
}

 

(3) Test

  • 작성해둔 Spec으로 명세들을 조합한 결과로 리포지토리에 입력하여 조회할 수 있으며 테스트를해보면 join쿼리가 생성되며 정상적으로 테스트가 통과됨
  • Specification의 기능 자체는 괜찮지만 JPA Criteria 자체가 너무 복잡하기 때문에 사용하지 않는것을 권장하며 Querydsl로 사용자 정의를하면 훨씬더 쉽고 더 보기좋게 구현할 수 있음
// Specification 테스트
@Test
void specBasic() {
    // given
    Team teamA = new Team("teamA");
    em.persist(teamA);

    Member m1 = new Member("member1", 20, teamA);
    Member m2 = new Member("member2", 20, teamA);
    em.persist(m1);
    em.persist(m2);

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

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

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

2. Query By Example

1) 기능 설명

  • 객체 자체를 검색조건으로 지정하여 동적으로 검색조건을 입력해야할 때 편하게 할 수 있음
  • 공식문서

(1) Test 실행

  • 실제 필드값이 있는 도메인 객체를 생성하여 검색조건으로 입력하여 편하게 검색조건을 입력할 수 있음
  • ExampleMatcher를 이용하여 검색조건에서 제외할 필드나 null 값을 무시할 수 있으며 다양한 메소드로 상세 검색조건을 설정할 수 있기에 동적쿼리를 편하게 작성할 수 있음
  • 연관관계가 적용된 엔터티에도 정상 동작하며 실행해보면 join 쿼리가 전송되어 테스트가 정상동작하는 것을 확인할 수 있음
  • DB를 변경해도 코드의 변경이 없도록 추상화 되어있음

** 참고

  • 영상의 강의 버전에서는 상세정보에 age값을 무시하는 정보를 제공하지 않아도 동작했지만, 현재 스프링 데이터 JPA 3.X 이상부터는 검색조건의 필드가 정확히 일치하도록 입력해야함
  • 현재 기본 동작방식은 상세 조건을 입력하지않으면 모든 필드가 and 조건으로 연결됨
// QueryByExample 테스트
@Test
void queryByExample() {
    Team teamA = new Team("teamA");
    em.persist(teamA);

    // given
    Member m1 = new Member("member1", 20, teamA);
    Member m2 = new Member("member2", 20, teamA);
    em.persist(m1);
    em.persist(m2);

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

    // when
    // Probe 생성, 객체 자체를 검색조건으로 생성
    Member member = new Member("member1");
    Team team = new Team("teamA");
    member.setTeam(team);

    // 특정 필드를 일치시키는 상세한 정보를 제공
    ExampleMatcher matcher = ExampleMatcher.matching().withIgnorePaths("age");

    // 검색할 조건인 객체와 상세정보를 제공
    Example<Member> example = Example.of(member, matcher);
    List<Member> result = memberRepository.findAll(example);

    assertThat(result.get(0).getUsername()).isEqualTo("member1");
}

 

(2) 단점

  • 그러나 실무에서 자주 사용하는 left 조인이 불가능함(inner join으로만 동작)
  • 중첩 제약 조건이 안됨 ex) firstname = ?0 or (firstname = ?1 and lastname =?2) 안됨
  • 매칭 조건이 매우 단순함(문자는 start/contains/ends/regex를 지원하지만 다른 속성은 정확한 매칭인 = 만 가능)
  • 실무에서 사용하기에는 매칭 조건이 너무 단순하고 left조인이 되지 않기 때문에 실무에서는 Querydsl을 사용하는것을 권장함

3. Projections

1) 기능 설명

  • select절에 특정 필드의 값을 지정하여 조회하는 기능이라고 생각하면 됨
  • 엔터티 대신에 DTO를 편리하게 조회할 때 사용함
  • 예를들어 전체 엔터티가 아니라 회원 이름만 딱 조회하거나 할때 편리하게 사용할 수 있음
  • 공식문서

2) 인터페이스로 Projections 적용 및 테스트

(1) 인터페이스로 생성 - 클로즈 프로젝션

  • 별도의 인터페이스를 생성하여 반환하고자할 필드를 메소드로 입력
public interface UsernameOnly {
    String getUsername();
}

 

(2) 리포지토리에 적용

  • 스프링 데이터 JPA로 구현한 리포지토리 메소드에 반환타입을 생성한 인터페이스로 지정
// 스프링 데이터 JPA로 동작하는 리포지토리의 메소드에 생성한 인터페이스를 반환타입으로 지정
List<UsernameOnly> findProjectionsByUsername(@Param("username") String username);

 

(3) Test

  • 테스트 케이스를 생성하여 Projections를 적용한 메서드를 테스트해보면 위에서 지정한 인터페이스의 필드의 값만 정확히 반환되며 생성되는 쿼리도 해당 값만 조회함
  • 스프링 데이터 JPA가 알아서 구현체를 생성하여 반환타입을 인식하여 데이터를 반환하기에 간단한 도메인데이터에서 원하는 데이터만 반환할 때 편하게 사용할 수 있음
  • 스프링 데이터 JPA의 공통 메서드들이나 쿼리 메소드 기능을 사용할 때 유용함
// Projections 테스트 코드
@Test
void projections() {
    Team teamA = new Team("teamA");
    em.persist(teamA);

    // given
    Member m1 = new Member("member1", 20, teamA);
    Member m2 = new Member("member2", 20, teamA);
    em.persist(m1);
    em.persist(m2);

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

    // when
    List<UsernameOnly> result = memberRepository.findProjectionsByUsername("member1");

    for (UsernameOnly usernameOnly : result) {
        System.out.println("usernameOnly.getUsername() = " + usernameOnly.getUsername());
    }
}

 

 

(4) 오픈 프로젝션으로 적용

  • 프로젝션을 적용할 인터페이스의 필드에 @Value 애노테이션의 값에 SpEL문법을 적용하여 전체값을 가져오는 것을 오픈 프로젝션이라고함
  • 아래처럼 적용하게 되면 DB에서 엔터티의 필드를 다 조회한 다음에 애플리케이션에서 계산하기 때문에 JPQL select 절이 최적화가 안됨
public interface UsernameOnly {
    
    @Value("#{target.username + ' ' + target.age + ' ' + target.team.name}")
    String getUsername();
}

3) 클래스 기반 Projections 적용 및 테스트

(1) 클래스 생성

  • 원하는 클래스 명으로 반환하고자할 필드를 선언하고 생성자와 게터를 작성하면 생성자의 파라미터 이름으로 스프링 데이터 JPA가 알아서 매칭하여 Projections이 동작함
@Getter
public class UsernameOnlyDto {
    private final String username;

    public UsernameOnlyDto(String username) {
        this.username = username;
    }
}

 

(2) 리포지토리에 적용 및 테스트

  • 마찬가지로 스프링 데이터 JPA로 동작하는 리포지토리의 메서드에 반환타입으로 적용하고 동일한 테스트를 진행해보면 클래스에 지정한 필드의 값만 반환되고 쿼리도 동일하게 생성됨
// 프로젝션 - 클래스로 Dto 생성
List<UsernameOnlyDto> findProjectionsClassByUsername(@Param("username") String username);

4) 동적 Projections

(1) 적용

  • 인터페이스나 클래스로 반환필드를 정의하는 것이아니라 메소드의 두번째 파라미터로 오브젝트 타입을 넘겨서 동적에 따라 반환타입을 적용할 수 있음
// 프로젝션 - 제네릭 타입으로 동적 프로젝션을 적용할 수 있음
<T> List<T> findProjectionsGenericByUsername(@Param("username") String username, Class<T> type);

 

(2) 테스트

  • 동일한 메소드명으로 두번째 인수에 원하는 반환타입을 입력하여 상황에 따라 동적으로 프로젝션이 적용되도록 사용할 수 있음
// 동일한 메소드를 호출할 때 반환타입을 인수로 입력하여 동적으로 적용
List<UsernameOnlyDto> result1 = memberRepository
        .findProjectionsGenericByUsername("member1", UsernameOnlyDto.class);

List<UsernameOnly> result2 = memberRepository
        .findProjectionsGenericByUsername("member1", UsernameOnly.class);

5) 중첩 구조 처리

(1) 적용 및 테스트

  • 인터페이스 내부에 중첩 인터페이스를 만들어서 프로젝션을 적용한 뒤에 테스트를 실행해보면 select 절의 쿼리가 최적화가 되어있지 않음
  • 첫번째 인터페이스에 적용된 getUsername은 최적화되어 잘 가져오지만 team 정보를 보면 id와 이름을 같이 가져오는것을 확인할 수 있음
  • 즉, 중첩쿼리에서는 원하는 값만 정확하게 뽑아서 반환시킬 수 없는 단점이 존재함
public interface NestedClosedProjections {

    String getUsername();
    TeamInfo getTeam();

    interface TeamInfo {
        String getName();
    }
}

/* 발생된 쿼리
select
    m1_0.username,
    t1_0.team_id,
    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=?
*/

6) 정리

  • 프로젝션 대상이 root 엔터티면 JPQL SELECT 절이 최적화 가능함
  • 그러나 프로젝션 대상이 ROOT가 아니면 LEFT OUTER JOIN처리를 하고 모든 필드를 SELECT 해서 엔터티로 조회한 다음에 계산하게 되는 문제가 있음
  • 그래서 프로젝션 대상이 root 엔터티이면 유용하지만 root 엔터티를 넘어가면 select 최적화가 진행되지 않기 때문에 실무의 복잡한 쿼리를 해결하기에는 한계가 있음
  • 매우 단순하게 조회할 때에만 사용하고 복잡해지면 Querydsl을 사용해야함

4. 네이티브 쿼리

** 참고

  • JPA에서 네이티브 쿼리를 사용할 수 있도록 지원하긴하지만 네이티브 쿼리가 필요할 때는 훨씬 사용하기 편리하고 동적 쿼리도 쓸수있는 Mybatis나 JdbcTemplate을 사용하는 것을 권장
  • 페이징 기능이 지원된다고 해도 반환타입이 Object[], Tuple, DTO(Projections 지원)로 한정되어 있고 Sort 파라미터를 통한 정렬이 정상 동작하지 않을 수 있고 JPQL 처럼 애플리케이션 로딩 시점에 쿼리가 잘못되어있는지 문법 확인이 불가능하며 동적 쿼리도 불가함

1) 적용 및 테스트

(1) 적용

  • @Query 애노테이션으로 JPQL이 아닌 SQL을 작성하고 nativeQuery 옵션을 true로 적용하면 SQL쿼리가 그대로 적용됨
// 네이티브 쿼리
@Query(value = "select * from Member m where username = ?", nativeQuery = true)
Member findByNativeQuery(String username);

 

(2) 테스트 결과

  • 테스트 케이스를 만들어 실행해보면 SQL쿼리가 나가는 것을 확인할 수 있음
  • 네이티브 SQL은 JPQL과 다르게 위치 기반 파라미터를 0부터 시작함(JPQL은 1)
// 네이티브 쿼리 테스트
@Test
public void nativeQuery() {
    Team teamA = new Team("teamA");
    em.persist(teamA);

    // given
    Member m1 = new Member("member1", 20, teamA);
    Member m2 = new Member("member2", 20, teamA);
    em.persist(m1);
    em.persist(m2);

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

    Member member1 = memberRepository.findByNativeQuery("member1");
    assertThat(member1.getUsername()).isEqualTo("member1");
}

/* 실행된 쿼리 로그
select
    * 
from
    Member m 
where
    username = ?

select * from Member m where username = ?
select * from Member m where username = 'member1';
*/

2) Projections 활용 조회

  • 그나마 간혹 사용할만한 것은 정적 SQL쿼리를 써야하는 상황에서 간단한 DTO로 조회하려고 할 때 Projections를 활용하면 그나마 간편하게 조회할 수 있음

(1) 프로젝션을 반환타입으로 네이티브 쿼리 적용

  • 페이징 기능을 지원하기 때문에 반환타입을 Page로하여 옵션에 countQuery를 적용해 카운트 쿼리를 입력하여 전체 개수 정보를 함께 넘길 수 있음
  • 쿼리와 반환타입을 작성하여 테스트를 해보면 네이티브 SQL이 전송되면서 페이징도 실제 적용되는 것을 확인할 수 있음

 

// 네이티브 쿼리 - 프로젝션 활용
@Query(value = "select m.member_id as id, m.username, t.name as teamName " +
                "from member m left join team t",
                    countQuery = "select count(*) from member",
                    nativeQuery = true)
Page<MemberProjection> findByNativeProjection(Pageable pageable);

// 반환값을 정의한 별도의 프로젝션 인터페이스
public interface MemberProjection {
    Long getId();
    String getUsername();
    String getTeamName();
}

// 테스트 코드 일부
Page<MemberProjection> result = memberRepository.findByNativeProjection(PageRequest.of(0, 5));
List<MemberProjection> content = result.getContent();
for (MemberProjection memberProjection : content) {
    System.out.println("memberProjection.getUsername() = " + memberProjection.getUsername());
    System.out.println("memberProjection.getTeamName() = " + memberProjection.getTeamName());
}

/* 실행된 쿼리 및 실행 결과
select m.member_id as id, m.username, t.name as teamName from member m left join team t fetch first 5 rows only;
memberProjection.getUsername() = member1
memberProjection.getTeamName() = teamA
memberProjection.getUsername() = member2
memberProjection.getTeamName() = teamA
*/