Specifications (명세)
결론 : 실무에서 안쓰인다. 그냥 QueryDSL을 쓰자.
Spring Data에서 Specification은 DB 쿼리의 조건을 Spec으로 작성해 Repository method에 적용하거나 몇가지 Spec을 조합해서 사용할 수 있게 도와준다.
술어(predicate)
- 참 또는 거짓으로 평가
- AND, OR 같은 연산자로 조합해 다양한 검색 조건을 쉽게 생성 (컴포지트 패턴)
- 스프링 데이터 JPA는 org.springframework.data.jpa.domain.Specification 클래스로 정의한다.
1. 명세 기능 사용 방법
🔍 JpaSpecificationExecutor 인터페이스 상속
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long>, JpaSpecificationExecutor<Member>{
}
📌 JpaSpecificationExecutor 인터페이스 살펴보기
public interface JpaSpecificationExecutor<T> {
/**
* Returns a single entity matching the given {@link Specification} or {@link Optional#empty()} if none found.
*
* @param spec can be {@literal null}.
* @return never {@literal null}.
* @throws org.springframework.dao.IncorrectResultSizeDataAccessException if more than one entity found.
*/
Optional<T> findOne(@Nullable Specification<T> spec);
/**
* Returns all entities matching the given {@link Specification}.
*
* @param spec can be {@literal null}.
* @return never {@literal null}.
*/
List<T> findAll(@Nullable Specification<T> spec);
/**
* Returns a {@link Page} of entities matching the given {@link Specification}.
*
* @param spec can be {@literal null}.
* @param pageable must not be {@literal null}.
* @return never {@literal null}.
*/
Page<T> findAll(@Nullable Specification<T> spec, Pageable pageable);
/**
* Returns all entities matching the given {@link Specification} and {@link Sort}.
*
* @param spec can be {@literal null}.
* @param sort must not be {@literal null}.
* @return never {@literal null}.
*/
List<T> findAll(@Nullable Specification<T> spec, Sort sort);
/**
* Returns the number of instances that the given {@link Specification} will return.
*
* @param spec the {@link Specification} to count instances for. Can be {@literal null}.
* @return the number of instances.
*/
long count(@Nullable Specification<T> spec);
}
모든 메서드들이 Specification을 파라미터로 받아서 검색 조건으로 사용한다.
📌 MemberSpec - 명세 정의 코드
package study.datajpa.repository;
public class MemberSpec {
public static Specification<Member> teamName(final String teamName) {
return (root, query, builder) -> {
if(ObjectUtils.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);
}
}
명세를 정의하려면 Specification 인터페이스를 구현해야 한다.
명세를 정의할 때는 toPredicate(...)메서드만 구현하면 되는데 JPA Criteria의 Root, CriteriaQuery, CriteriaBuilder 클래스를 파라미터로 제공한다.
📌 MemberRepositoryTest
@Test
void specBasic() {
// given
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);
Assertions.assertThat(result.size()).isEqualTo(1);
}
Specification을 구현하면 명세들을 조립할 수 있다. 예) where(), and(), or(), not() 메서드들을 제공한다.
findAll()을 보면 회원 이름 명세(username)와 팀 이름 명세(teamName)를 and로 조합해서 검색 조건으로 사용했다.
그러나 이런 방법은 복잡하기 때문에 실무에서 쓰지말고 QueryDSL을 사용할 것
Query By Example
결론 : 실무에서 안쓰인다. QueryDSL을 사용하자.
Query By Example이란, 도메인 객체를 그대로 검색 조건으로 만드는 것이다.
참고자료: https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#query-by-example
📌 JpaRepository
public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {
...
}
JpaRepository 인터페이스는 QueryByExampleExecutor 인터페이스를 상속받고 있다.
QueryByExampleExecutor 인터페이스를 살펴보자.
QueryByExampleExecutor 인터페이스를 보면 메서드들이 Exmaple 타입을 파라미터로 받고 있다. 그래서 JpaRepository 의 기본 메서드들에 Example 타입을 파라미터를 전달할 수 있다.
📌 MemberRepositoryTest
@Test
void queryByExample() {
// given
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");
Team team = new Team("teamA");
member.changeTeam(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("m1");
}
ExampleMatcher : 특정 필드를 일치시키는 상세한 정보를 제공한다. 재사용할 수 있다.
Example : Probe와 ExampleMatch로 구성되어 있다. 쿼리를 생성하는데 사용된다.
QueyrByExample 장점
동적 쿼리를 편리하게 처리할 수 있다.
도메인 객체를 그대로 사용
데이터 저장소를 RDB에서 NOSQL로 변경해도 코드 변경이 없게 추상화 되어 있다.
스프링 데이터 JPA의 JpaRepository 인터페이스에 이미 포함되어 잇다.
QueyrByExample 단점
내부 조인(inner join)만 가능하다. 외부 조인(left join)이 안된다.
매칭 조건이 매우 단순하다. 문자는 starts, contains, ends, regx를 지원하지만 다른 속성은 정확항 매칭(=)만 지원한다.
결론 : QueryDSL 쓰자.
Projections
결론 : 유용할 때가 있다.
Projection은 엔티티의 일부 데이터만 가져오는 것이다. select 쿼리에서 엔티티의 일부 필드 값만 조회하는 것이다.
전체 엔티티가 아닌 회원 이름만 조회하고 싶을 때 JPA를 이용하면 엔티티 통째로 조회한다. 그러나 Projection를 사용하면 엔티티 대신에 DTO로 필요한 값만 조회할 수 있다.
1. 인터페이스 기반 Closed Projections
프로퍼티 형식(getter)의 인터페이스를 제공하면, 구현체는 스프링 데이터 JPA가 프록시 객체로 제공하는 것이다.
📌 UsernameOnly
package study.datajpa.repository;
public interface UsernameOnly {
String getUsername();
}
조회할 엔티티의 필드를 getter 형식으로 지정하면 해당 필드만 선택해서 조회할 수 있다.
📌 MemberRepository
package study.datajpa.repository;
public interface MemberRepository extends JpaRepository<Member, Long> {
// Projections
List<UsernameOnly> findProjectionsByUsername(@Param("username") String username);
}
메서드는 이름은 자유롭게 할 수 있다. 반환 타입은 아까 사용자 정의로 만들어둔 UsernameOnly 인터페이스로 지정해야 한다.
반환타입을 인터페이스로 하면 스프링 데이터 JPA가 해당 인터페이스의 구현체를 프록시 객체로 채운다.
📌 MemberRepositoryTest
@Test
void projections() {
// given
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
List<UsernameOnly> result = memberRepository.findProjectionsByUsername("m1");
for (UsernameOnly usernameOnly : result) {
System.out.println("usernameOnly = " + usernameOnly.getUsername());
}
select
member0_.username as col_0_0_
from
member member0_
where
member0_.username=?
usernameOnly = m1
SQL 에서도 select 절에서 username만 조회(Projection) 하는 것을 확인할 수 있다.
인터페이스만 정의하면 실제 구현체는 스프링 데이터 jpa가 프록시 객체로 만들어서 데이터를 담아서 반환을 해준다.
2. 인터페이스 기반 Open Projections
@Value 애노테이션을 통해 스프링의 SpEL 문법도 사용할 수 있다.
📌 UsernameOnly 인터페이스
package study.datajpa.repository;
import org.springframework.beans.factory.annotation.Value;
public interface UsernameOnly {
@Value("#{target.username + ' ' + target.age}")
String getUsername();
}
이렇게 SpEL문법을 사용하면 DB에서 엔티티 필드를 다 조회해온 다음에 처리한다. 따라서 JPQL select절 최적화가 안된다. 즉, select를 통해 엔티티 통째로를 가져오고 지정된 값을 추출한다.
📌 MemberRepositoryTest
@Test
void interfaceByProjections() {
// given
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
List<UsernameOnly> result = memberRepository.findInterfaceProjectionsByUsername("m1");
for (UsernameOnly usernameOnly : result) {
System.out.println("usernameOnly = " + usernameOnly.getUsername());
}
}
select
member0_.member_id as member_i1_1_,
member0_.created_date as created_2_1_,
member0_.last_modified_date as last_mod3_1_,
member0_.created_by as created_4_1_,
member0_.last_modified_by as last_mod5_1_,
member0_.age as age6_1_,
member0_.team_id as team_id8_1_,
member0_.username as username7_1_
from
member member0_
where
member0_.username=?
출력 결과를 보면 Member 엔티티를 통쨰로 조회했다.
참고자료 : https://bcp0109.tistory.com/227
3. 클래스 기반 Projections
인터페이스가 아닌 구체적인 DTO 형식도 가능하다.
📌 UsernameOnlyDto 클래스
package study.datajpa.repository;
public class UsernameOnlyDto {
private final String username;
// 생성자의 파라미터 이름인 username으로 매칭을 시킨다.
public UsernameOnlyDto(String username) {
this.username = username;
}
public String getUsername() {
return username;
}
}
📌 MemberRepository
// 클래스 기반 Projections
List<UsernameOnlyDto> findClassProjectionsByUsername(@Param("username") String username);
📌 MemberRepositoryTest
// 클래스 기반 Projections
@Test
void classByProjections() {
// given
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
List<UsernameOnlyDto> result = memberRepository.findClassProjectionsByUsername("m1");
for (UsernameOnlyDto usernameOnly : result) {
System.out.println("usernameOnly = " + usernameOnly.getUsername());
}
}
select
member0_.username as col_0_0_
from
member member0_
where
member0_.username=?
usernameOnly = m1
프록시 객체가 아닌 실제 구현 클래스가 들어와있다.
4. 동적 Projections
Generic type을 주면 동적으로 프로젝션 데이터를 변경할 수 있다.
📌 MemberRepository
// 제네릭 기반 동적 Projections
<T>List<T> findGenericProjectionsByUsername(@Param("username") String username, Class<T> type);
📌 MemberRepositoryTest
// 제네릭 기반 동적 Projections
@Test
void genericByProjections() {
// given
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
List<UsernameOnlyDto> result = memberRepository.findGenericProjectionsByUsername("m1", UsernameOnlyDto.class);
for (UsernameOnlyDto usernameOnly : result) {
System.out.println("usernameOnly = " + usernameOnly.getUsername());
}
}
5. 중첩 구조
중첩 구조에서는 첫번째 조건은 최적화가 된다. 두번째 조건부터는 최적화가 안된다.
📌 NestedClosedProjection 인터페이스
package study.datajpa.repository;
public interface NestedClosedProjection {
String getUsername();
TeamInfo getTeam();
// 팀에 대한 정보
interface TeamInfo {
String getName(); // 팀에 대한 정보는 팀 이름만 가져올 것
}
}
📌 MemberRepositoryTest
// 중첩 구조
@Test
void NestedClosedByProjections() {
// given
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
List<NestedClosedProjection> result = memberRepository.findGenericProjectionsByUsername("m1", NestedClosedProjection.class);
for (NestedClosedProjection nestedClosedProjection : result) {
String username = nestedClosedProjection.getUsername();
System.out.println("username = " + username);
String teamName = nestedClosedProjection.getTeam().getName();
System.out.println("teamName = " + teamName);
}
}
select
member0_.username as col_0_0_,
team1_.team_id as col_1_0_,
team1_.team_id as team_id1_2_,
team1_.name as name2_2_
from
member member0_
left outer join
team team1_
on member0_.team_id=team1_.team_id
where
member0_.username=?
출력 결과를 보면 첫번째 조건인 member 엔티티에 대해서는 최적화가 이뤄졌다. 하지만 두번째 조건인 team 엔티티에 대해서는 최적화가 이뤄지지 않았다. team 엔티티는 통째로 전부 조회하고 있다.
left 조인으로 가져온다.
프로젝션 대상이 root 엔티티면, JPQL select절이 최적화가 이뤄진다.
프로젝션 대상이 root 엔티티가 아니면 left outer join으로 처리된다. 그리고 모든 필드를 select 해서 엔티티를 통째로 조회한 다음에 처리한다.
정리
프로젝션 대상이 root 엔티티면 유용하다.
프로젝션 대상이 root 엔티티를 넘어가면 JPQL select절이 최적화가 안된다.
실무의 복잡한 쿼리를 해결하기에는 한계가 있다.
실무에서는 단순할 때만 사용하고, 조금만 복잡해지면 QueryDSL을 사용하자.
네이티브 쿼리
가급적 네이티브 쿼리는 사용하지 않는게 좋다. 정말 어쩔 수 없을 때 사용한다.
최근에는 스프링 데이터 Projections를 활용하여 사용하는 방법이 있다.
스프링 데이터 JPA 기반 네이티브 쿼리
- 장점
- 페이징 지원
- 반환타입
- Object[]
- Tuple
- DTO (스프링 데이터 인터페이스 Projections 지원)
- 단점
- Sort 파라미터를 통한 정렬이 정상 동작하지 않을 수 있다. (믿지 말고 직접 처리할 것)
- JPQL 처럼 애플리케이션 로딩 시점에 문법 확인 불가
- 동적 쿼리 처리 불가
1. JPA 네이티브 SQL 지원
📌 MemberRepository
@Query(value = "select * from member where username = ?", nativeQuery = true)
Member findByNativeQuery(String username);
이렇게 사용하면 너무 복잡하다. 이 처럼 네이티브 SQL을 사용할거면 차라리 JDBC Template 또는 MyBatis를 사용할 것
📌 MemberRepositoryTest
@Test
void nativeQuery() {
// given
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
Member result = memberRepository.findByNativeQuery("m1");
System.out.println("result = " + result);
}
select
*
from
member
where
username = ?
엔티티를 통쨰로 가져온다.
네이티브 쿼리를 쓸바엔 JDBC Template나 MyBatis를 사용할 것
2. Proejctions 활용
스프링 데이터 JPA 네이티브 쿼리 + 인터페이스 기반 Projections 활용
📌 MemberProjection
package study.datajpa.repository;
public interface MemberProjection {
Long getId();
String getUsername();
String getTeamName();
}
📌 MemberRepository
@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);
📌 MemberRepositoryTest
@Test
void nativeQueryAndProjection() {
// given
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
Page<MemberProjection> result = memberRepository.findByNativeProjection(PageRequest.of(0, 10));
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 limit ?
👀 참고 자료
'[JPA] > 실전! 스프링 데이터 JPA' 카테고리의 다른 글
스프링 데이터 JPA 분석 (0) | 2022.04.21 |
---|---|
[JPA] 확장 가능 (0) | 2022.04.20 |
[JPA] 쿼리 메소드 기능 (0) | 2022.04.17 |
[JPA] 공통 인터페이스 기능 (0) | 2022.04.16 |
[JPA] 예제 도메인 모델 (0) | 2022.04.16 |