Open seungriyou opened 8 months ago
// test lombok 사용
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
other → compileJava 를 실행하면 Q클래스가 정상적으로 생성된다.
혹은 그냥 main()
을 실행만 하면 Q 파일이 자동으로 만들어진다.
[!caution] Q 파일은 컴파일 시점에 자동 생성되므로 git에 포함하면 안 된다. 수업에서는 gradle
build/
디렉토리 밑에 넣어서 자연스럽게 해결되도록 했다. (주로build
폴더를gitignore
)
다음과 같이 JPAQueryFactory
를 필드로 사용하면 동시성 문제가 발생하지는 않을까?
@SpringBootTest
@Transactional
public class QuerydslBasicTest {
@Autowired
EntityManager em;
JPAQueryFactory queryFactory;
// 테스트 케이스 추가
@BeforeEach
public void before() {
queryFactory = new JPAQueryFactory(em);
동시성 문제는 JPAQueryFactory를 생성할 때 제공하는 EntityManager
(em
)에 달려있는데, 스프링 프레임워크는 여러 스레드에서 동시에 같은 EntityManager
에 접근해도, 트랜잭션마다 별도의 영속성 컨텍스트를 제공한다.
따라서 멀티 스레드 환경에서의 동시성 문제는 걱정하지 않아도 된다.
JPQL의 별칭 직접 지정
같은 테이블을 조인해야 하는 경우에만, 이름이 같으면 안 되기 때문에 alias를 직접 지정하는 방법을 사용하는 것을 권장한다.
QMember qMember = new QMember("m");
기본 인스턴스 사용
QMember qMember = QMember.member;
static import로 사용하면 더 편리하다. (→ 권장 ✅)
import static study.querydsl.entity.QMember.member;
Member findMember = queryFactory
.select(member)
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
[!note] Querydsl은 결국 JPQL의 빌더이다.
→ 생성되는 JPQL을 보고 싶다면,
application.yml
에 다음을 추가하자.spring.jpa.properties.hibernate.use_sql_comments: true
select
,from
을selectFrom
으로 합칠 수 있다.
.and()
,.or()
를 메서드 체인으로 연결할 수 있다.- 검색 조건들
```java
member.username.eq("member1") // username = 'member1'
member.username.ne("member1") //username != 'member1'
member.username.eq("member1").not() // username != 'member1'
member.username.isNotNull() //이름이 is not null
member.age.in(10, 20) // age in (10,20)
member.age.notIn(10, 20) // age not in (10, 20)
member.age.between(10,30) //between 10, 30
member.age.goe(30) // age >= 30
member.age.gt(30) // age > 30
member.age.loe(30) // age <= 30
member.age.lt(30) // age < 30
member.username.like("member%") //like 검색
member.username.contains("member") // like ‘%member%’ 검색
member.username.startsWith("member") //like ‘member%’ 검색
...
```
다음과 같이 where
에 조건들을 나열해서 넣을 수도 있다.
Member findMember = queryFactory
.selectFrom(member)
.where(
member.username.eq("member1"),
member.age.eq(10)
)
.fetchOne();
where()
에 파라미터로 검색 조건을 추가하면 AND
조건이 추가된다.null
값은 무시되므로, 메서드 추출을 통해 동적 쿼리를 깔끔하게 만들 수 있다!fetch()
: 리스트 조회
데이터 없으면 빈 리스트 반환
fetchOne()
: 단 건 조회
null
com.querydsl.core.NonUniqueResultException
fetchFirst()
: limit(1).fetchOne()
fetchResults()
: 페이징 정보 포함
total count 쿼리 추가 실행
deprecated →
fetch()
사용 권장 (https://querydsl.com/static/querydsl/5.0.0/apidocs/com/querydsl/jpa/impl/AbstractJPAQuery.html#fetch--)→ count 동작도 따로!
fetchCount()
: count 쿼리로 변경해서 count 수 조회
deprecated → 다음과 같이 작성해야 한다.
Long totalCount = queryFactory
// .select(Wildcard.count) // select count(*)
.select(member.count()) // select count(member.id)
.from(member)
.fetchOne();
[!note] JPQL에서 엔티티를 직접 지정하면 id로 바뀐다!
desc()
, asc()
: 일반 정렬nullsLast()
, nullsFirst()
: null 데이터 순서 부여List<Member> result = queryFactory
.selectFrom(member)
.orderBy(member.username.desc())
.offset(1)
.limit(2)
.fetch();
groupBy
, having
도 사용 가능하다.
join(조인 대상, 별칭으로 사용할 Q타입)
join()
, innerJoin()
: 내부 ㅈ인leftJoin()
: left outer joinrightJoin()
: right outer joinon
과 성능 최적화를 위한 fetch
조인 제공 (→ 다음 절)연관관계가 없어도 수행하는 조인
join(조인 대상 엔티티1, 조인 대상 엔티티2...)
→ 외부 조인이 불가능하다! 하지만 조인 on
을 사용하면 외부 조인이 가능하다.
on
절JPA 2.1 부터 지원
- 조인 대상 필터링
```java
@Test
public void join_on_filtering() {
/**
* 회원과 팀을 조회하면서,
* 팀 이름이 teamA인 팀만 조인, 회원은 모두 조회
* JPQL: select m, t from Member m left join m.team t on t.name = 'teamA'
*/
List<Tuple> result = queryFactory
.select(member, team)
.from(member)
.leftJoin(member.team, team).on(team.name.eq("teamA"))
.fetch();
for (Tuple tuple : result) {
System.out.println("tuple = " + tuple);
}
}
```
```java
tuple = [Member(id=1, username=member1, age=10), Team(id=1, name=teamA)]
tuple = [Member(id=2, username=member2, age=20), Team(id=1, name=teamA)]
tuple = [Member(id=3, username=member3, age=30), null]
tuple = [Member(id=4, username=member4, age=40), null]
```
on
절을 외부 조인(ex.leftJoin()
)이 아닌 내부 조인(join()
)과 함께 사용한다면,where
절에서 필터링 하는 것과 기능이 동일하다. 따라서 내부 조인이면 더 가독성이 좋은where
절로 해결하고, 정말 필요한 경우에만on
을 사용하자.
연관관계 없는 엔티티 외부 조인
→ 세타 조인은 내부 조인만 가능했다!
@Test
public void join_on_no_relation() {
/**
* 회원의 이름이 팀 이름과 같은 대상 외부 조인
*/
em.persist(new Member("teamA"));
em.persist(new Member("teamB"));
em.persist(new Member("teamC"));
List<Tuple> result = queryFactory
.select(member, team)
.from(member) // 그냥 세타 조인에서는 외부 조인 불가!
.leftJoin(team).on(member.username.eq(team.name))
.fetch();
for (Tuple tuple : result) {
System.out.println("tuple = " + tuple);
}
}
tuple = [Member(id=1, username=member1, age=10), null]
tuple = [Member(id=2, username=member2, age=20), null]
tuple = [Member(id=3, username=member3, age=30), null]
tuple = [Member(id=4, username=member4, age=40), null]
tuple = [Member(id=5, username=teamA, age=0), Team(id=1, name=teamA)]
tuple = [Member(id=6, username=teamB, age=0), Team(id=2, name=teamB)]
tuple = [Member(id=7, username=teamC, age=0), null]
[!caution]
leftJoin(member.team, team)
과leftJoin(team).on(...)
의 차이점
leftJoin(member.team, team)
: 조인할 때, 동일id
조건이 추가된다. (SQL에서 동일 id 찾는 로직 추가)내부적으로 PK - FK를 사용한다.
leftJoin(team).on(...)
:on
절의 조건으로만 조인된다.
on
절을 사용해서 서로 관계 없는 필드로 외부 조인하는 경우에는,leftJoin()
부분에 일반 조인과는 다르게 엔티티 하나만 들어간다!
조인 뒤에 fetchJoin()
만 추가하면 된다.
@PersistenceUnit
EntityManagerFactory emf;
// (1) fetch join이 없을 때
@Test
public void fetchJoinNo() {
em.flush();
em.clear();
Member findMember = queryFactory
.selectFrom(member)
.where(member.username.eq("member1"))
.fetchOne();
// 초기화 되었는지 되지 않았는지 확인
boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
assertThat(loaded).as("페치 조인 미적용").isFalse();
}
// (2) fetch join 적용
@Test
public void fetchJoinYes() {
em.flush();
em.clear();
Member findMember = queryFactory
.selectFrom(member)
.join(member.team, team).fetchJoin() // 뒤에 fetchJoin()만 붙여주면 된다!
.where(member.username.eq("member1"))
.fetchOne();
// 초기화 되었는지 되지 않았는지 확인
boolean loaded = emf.getPersistenceUnitUtil().isLoaded(findMember.getTeam());
assertThat(loaded).as("페치 조인 적용").isTrue();
}
/* select
member1
from
Member member1
inner join
fetch
member1.team as team
where
member1.username = ?1 */
select
m1_0.member_id,
m1_0.age,
t1_0.id,
t1_0.name,
m1_0.username
from
member m1_0
join
team t1_0
on t1_0.id=m1_0.team_id
where
m1_0.username=?
com.querydsl.jpa.JPAExpressions
를 사용한다.
static import를 하면 더 편리하다.
eq
```java
@Test
public void subQuery() {
/**
* 나이가 가장 많은 회원 조회
*/
// alias를 다르게 부여하기 위해서 (충돌 방지)
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(
select(memberSub.age.max())
.from(memberSub)
))
.fetch();
assertThat(result)
.extracting("age")
.containsExactly(40);
}
```
goe
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.goe(
select(memberSub.age.avg())
.from(memberSub)
))
.fetch();
in
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.in(
select(memberSub.age)
.from(memberSub)
.where(memberSub.age.gt(10))
))
.fetch();
select
절에 서브쿼리
List<Tuple> result = queryFactory
.select(member.username,
select(memberSub.age.avg())
.from(memberSub))
.from(member)
.fetch();
JPQL에서는 from
절의 서브쿼리(= 인라인 뷰)는 지원하지 않으므로, Querydsl도 지원하지 않는다. 하이버네이트 구현체를 사용하면 select
절의 서브쿼리는 지원한다.
from
절의 서브쿼리 해결 방안 세 가지
select
, 조건절(where
), order by
에서 사용 가능하다.
단순한 조건
List<String> result = queryFactory
.select(member.age
.when(10).then("열살")
.when(20).then("스무살")
.otherwise("기타"))
.from(member)
.fetch();
복잡한 조건
List<String> result = queryFactory
.select(new CaseBuilder()
.when(member.age.between(0, 20)).then("0~20살")
.when(member.age.between(21, 30)).then("21~30살")
.otherwise("기타"))
.from(member)
.fetch();
ex) 다음과 같은 임의의 순서로 회원 출력하기
@Test
public void complexCaseSort() {
/**
* 다음과 같은 임의의 순서로 회원 출력하기
* 1. 0~30살이 아닌 회원
* 2. 0~20살 회원
* 3. 21~30살 회원
*/
NumberExpression<Integer> rankPath = new CaseBuilder()
.when(member.age.between(0, 20)).then(2)
.when(member.age.between(21, 30)).then(1)
.otherwise(3);
List<Tuple> result = queryFactory
.select(member.username, member.age, rankPath)
.from(member)
.orderBy(rankPath.desc())
.fetch();
for (Tuple tuple : result) {
System.out.println("tuple = " + tuple);
}
}
tuple = [member4, 40, 3]
tuple = [member1, 10, 2]
tuple = [member2, 20, 2]
tuple = [member3, 30, 1]
[!note] 꼭 필요한지 생각해봐야 한다. 이런 문제들은 보통 DB에서 처리하지 않는다. DB에서는 최소한의 필터링, 그루핑 등 데이터를 줄이는 일을 해야 하며, 데이터를 변환하는 동작은 어플리케이션/프레젠테이션 레이어에서 로직을 작성해야 한다.
상수가 필요하면 Expressions.constant()
사용
List<Tuple> result = queryFactory
.select(member.username, Expressions.constant("A"))
.from(member)
.fetch();
문자 더하기: concat()
📌 문자가 아닌 타입은
.stringValue()
로 변환해야 한다. 특히,ENUM
을 처리할 때 자주 사용된다.
// {username}_{age}
List<String> result = queryFactory
.select(member.username.concat("_").concat(member.age.stringValue()))
.from(member)
.where(member.username.eq("member1"))
.fetch();
프로젝션:
select
대상 지정→ 원하는 필드만 찍어서 가져오는 SQL 최적화!
프로젝션 대상이 하나이면 타입을 명확하게 지정할 수 있다.
List<String> result = queryFactory
.select(member.username)
.from(member)
.fetch();
하지만 프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회해야 한다.
List<Tuple> result = queryFactory
.select(member.username, member.age)
.from(member)
.fetch();
for (Tuple tuple : result) {
String username = tuple.get(member.username);
System.out.println("username = " + username);
Integer age = tuple.get(member.age);
System.out.println("age = " + age);
}
[!caution] 하지만 리포지토리 계층을 넘어서서 서비스 계층, 컨트롤러까지 넘어가는 것은 좋은 설계가 아니다! 하부 구현 기술이 해당 계층까지 넘어가면 안 된다. (튜플은 querydsl에 종속적!)
다른 계층에 나갈 때는 DTO 권장!
패키지명 전체를 다 적어야 하고 new
명령어로 생성한다.
생성자 방식만 지원한다.
List<MemberDto> result = em.createQuery("select new study.querydsl.dto.MemberDto(m.username, m.age) from Member m", MemberDto.class)
.getResultList();
결과를 DTO로 반환하는 세 가지 방법
- 프로퍼티 접근
- 필드 직접 접근
- 생성자 사용
[!note] DTO 클래스에 default 생성자를 만들어주거나,
**@NoArgsConstructor**
를 달아주어야 한다.→ 일반 DTO를 생성하고 set을 해야하므로!
프로퍼티 접근 (setter)
List<MemberDto> result = queryFactory
.select(Projections.bean(MemberDto.class,
member.username,
member.age))
.from(member)
.fetch();
필드 직접 접근 (getter/setter 무시하고 바로 필드에)
List<MemberDto> result = queryFactory
.select(Projections.fields(MemberDto.class,
member.username,
member.age))
.from(member)
.fetch();
생성자 사용 (생성자의 파라미터 순서와 동일하게 들어가야 함)
List<MemberDto> result = queryFactory
.select(Projections.constructor(MemberDto.class,
member.username,
member.age))
.from(member)
.fetch();
QMember memberSub = new QMember("memberSub");
List<UserDto> result = queryFactory
.select(Projections.fields(UserDto.class,
// 필드
member.username.as("name"),
// 서브 쿼리
ExpressionUtils.as(JPAExpressions
.select(memberSub.age.max())
.from(memberSub),
"age")))
.from(member)
.fetch();
필드에 별칭 적용: .as("...")
DTO의 필드 이름이 다른 경우
UserDto
→username
대신name
필드
```java
@Data
public class UserDto {
private String name;
private int age;
}
```
DTO와 엔티티의 필드 이름이 맞지 않으면 다음과 같이 null
로 들어간다.
userDto = UserDto(name=null, age=10)
userDto = UserDto(name=null, age=20)
userDto = UserDto(name=null, age=30)
따라서, 해당 필드에 as()
를 이용하여 필드에 별칭을 지정해주어야 한다.
ExpressionUtils.as(source, alias)
@QueryProjection
DTO: 생성자 + @QueryProjection
컴파일 하여 Q 파일을 생성해야 한다.
@Data
@NoArgsConstructor
public class MemberDto {
...
@QueryProjection
public MemberDto(String username, int age) {
this.username = username;
this.age = age;
}
}
테스트 코드
List<MemberDto> result = queryFactory
.select(new QMemberDto(member.username, member.age))
.from(member)
.fetch();
장점
단점
DTO에 Querydsl 어노테이션을 유지해야 한다.
이렇게 하면 DTO가 querydsl을 의존하게 된다. DTO는 보통 리포지토리에서 조회해서 서비스, 컨트롤러에서 사용하고 API에서 반환되기도 하는 등 여러 레이어에서 사용되므로 DTO를 깔끔하게 가져가기 어려울 수 있다.
→ 실용적인 관점 vs. DTO 깔끔하게 유지
List<String> result = queryFactory
.select(member.username).distinct()
.from(member)
.fetch();
동적 쿼리 해결하는 두 가지 방법
BooleanBuilder
where
다중 파라미터 사용
BooleanBuilder
@Test
public void dynamicQuery_BooleanBuilder() {
String usernameParam = "member1";
Integer ageParam = null;
List<Member> result = searchMember1(usernameParam, ageParam);
assertThat(result.size()).isEqualTo(1);
}
private List<Member> searchMember1(String usernameCond, Integer ageCond) {
BooleanBuilder builder = new BooleanBuilder();
if (usernameCond != null) {
builder.and(member.username.eq(usernameCond));
}
if (ageCond != null) {
builder.and(member.age.eq(ageCond));
}
return queryFactory
.selectFrom(member)
.where(builder)
.fetch();
}
where
다중 파라미터 사용 (✅ 권장!)private List<Member> searchMember2(String usernameCond, Integer ageCond) {
return queryFactory
.selectFrom(member)
.where(usernameEq(usernameCond), ageEq(ageCond))
.fetch();
}
private BooleanExpression ageEq(Integer ageCond) {
return ageCond != null ? member.age.eq(ageCond) : null;
}
private BooleanExpression usernameEq(String usernameCond) {
return usernameCond != null ? member.username.eq(usernameCond) : null;
}
where
절의 파라미터가 null
이면 무시된다.조립을 할 수 있다! 여러 쿼리에서 이를 깔끔하게 사용할 수 있다.
null
체크는 주의해야 한다.
private BooleanExpression allEq(String usernameCond, Integer ageCond) {
// 조립 가능
return usernameEq(usernameCond).and(ageEq(ageCond));
}
변경 감지는 한 건씩이다. 벌크 연산은 어떻게 할까?
[!caution] 벌크 연산을 실행하고 나면 영속성 컨텍스트를 초기화해주는 것을 권장한다.
쿼리 한 번으로 대량 데이터 수정
long count = queryFactory
.update(member)
.set(member.username, "비회원")
.where(member.age.lt(28))
.execute(); // 영향을 받은 row 수
// 영속성 컨텍스트 초기화
em.flush();
em.clear();
기존 숫자에 1 더하기
long count = queryFactory
.update(member)
.set(member.age, member.age.add(1))
.execute();
쿼리 한 번으로 대량 데이터 삭제
long count = queryFactory
.delete(member)
.where(member.age.gt(18))
.execute();
반환값은 영향을 받은 row의 수이다.
dialect에 등록된 내용만 호출할 수 있다.
- member → M으로 변경하는
replace
함수
```java
@Test
public void sqlFunction() {
List<String> result = queryFactory
.select(Expressions.stringTemplate(
"function('replace', {0}, {1}, {2})",
member.username, "member", "M"
))
.from(member)
.fetch();
for (String s : result) {
System.out.println("s = " + s);
}
}
```
소문자로 변경해서 비교하는 동작 (lower
함수)
@Test
public void sqlFunction2() {
List<String> result = queryFactory
.select(member.username)
.from(member)
.where(member.username.eq(
Expressions.stringTemplate("function('lower', {0})", member.username)))
.fetch();
for (String s : result) {
System.out.println("s = " + s);
}
}
lower
같은 ansi 표준 함수들은 querydsl에 상당 부분 내장되어 있다. 따라서 where
절을 다음과 같이 사용해도 된다.
.where(member.username.eq(member.username.lower()))
JPAQueryFactory
사용하는 두 가지 방법**선호에 따라 선택
- 리포지토리의 생성자에서 생성하기 (
new JPAQueryFactory(em)
)주입 받는 것이 하나라서 테스트 코드 작성 시 편리하다.
```java
@Repository
public class MemberJpaRepository {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
public MemberJpaRepository(EntityManager em) {
this.em = em;
this.queryFactory = new JPAQueryFactory(em);
}
```
스프링 빈으로 등록해서 주입 받아 사용하기
코드를 줄일 수 있으나, 테스트 코드 작성 시 주입 받아야 할 것이 두 개라 번거롭다.
QuerydslApplication.java
```java
@SpringBootApplication
public class QuerydslApplication {
public static void main(String[] args) {
SpringApplication.run(QuerydslApplication.class, args);
}
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager em) {
return new JPAQueryFactory(em);
}
}
```
리포지토리
@Repository
public class MemberJpaRepository {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
public MemberJpaRepository(EntityManager em, JPAQueryFactory queryFactory) {
this.em = em;
this.queryFactory = queryFactory;
}
다음과 같이 생성자 생략하고 @RequiredArgsConstructor
를 사용해도 된다.
@Repository
@RequiredArgsConstructor
public class MemberJpaRepository {
private final EntityManager em;
private final JPAQueryFactory queryFactory;
[!note] 스프링 빈으로 등록해서 사용하면 동시성 문제가 발생하지는 않을까?
발생하지 않는다. 스프링이 주입해주는 엔티티 매니저는 실제 동작 시점에 진짜 엔티티 매니저를 찾아주는 프록시용 가짜 엔티티 매니저이다.
이는 실제 사용 시점에 트랜잭션 단위로 실제 엔티티 매니저(→ 영속성 컨텍스트)를 할당해준다.
public List<Member> findAll_Querydsl() {
return queryFactory
.selectFrom(member)
.fetch();
}
public List<Member> findByUsername_Querydsl(String username) {
return queryFactory
.selectFrom(member)
.where(member.username.eq(username))
.fetch();
}
[!note] 성능 최적화 = 한 번에 DTO로 딱 조회하기
다음 예제에서는
@QueryProjection
을 사용하는데, 이렇게 하면 DTO가 Querydsl을 의존하게 된다. 이를 방지하려면Projection.bean()
,fields()
,constructor()
를 사용하면 된다.
MemberTeamDto
@Data
public class MemberTeamDto {
private Long memberId;
private String username;
private int age;
private Long teamId;
private String teamName;
// @QueryProjection을 사용하면 dto가 querydsl에 의존하게 된다. 따라서 다른 방식을 사용해도 된다.
@QueryProjection
public MemberTeamDto(Long memberId, String username, int age, Long teamId, String teamName) {
this.memberId = memberId;
this.username = username;
this.age = age;
this.teamId = teamId;
this.teamName = teamName;
}
}
MemberSearchCondition
(검색 조건) (MemberCond
등의 이름도 ok)
@Data
public class MemberSearchCondition {
// 회원명, 팀명, 나이(ageGoe, ageLoe)
private String username;
private String teamName;
private Integer ageGoe;
private Integer ageLoe;
}
[!tip]
StringUtils.hasText()
:null
도,""
도 아닌지 확인한다.[!caution] builder 조건이
null
이게 되면 전체 데이터를 불러오므로, 웬만하면 기본 조건이 있거나 리미트가 있을 때 이러한 동적 쿼리를 작성하는 것을 권장한다.
리포지토리
public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition) {
BooleanBuilder builder = new BooleanBuilder();
// StringUtils.hasText(): null도, ""도 아닌지 확인
if (hasText(condition.getUsername())) {
builder.and(member.username.eq(condition.getUsername()));
}
if (hasText(condition.getTeamName())) {
builder.and(team.name.eq(condition.getTeamName()));
}
if (condition.getAgeGoe() != null) {
builder.and(member.age.goe(condition.getAgeGoe()));
}
if (condition.getAgeLoe() != null) {
builder.and(member.age.loe(condition.getAgeLoe()));
}
return queryFactory
.select(new QMemberTeamDto(
member.id, // 생성자를 사용하므로, 필드 이름 맞출 필요 X
member.username,
member.age,
team.id.as("teamId"),
team.name.as("teamName")
))
.from(member)
.leftJoin(member.team, team)
.where(builder) // builder 추가!
.fetch();
}
[!warning]
Predicate
보다BooleanExpression
타입을 사용하도록 하자.
public List<MemberTeamDto> search(MemberSearchCondition condition) {
return queryFactory
.select(new QMemberTeamDto(
member.id, // 생성자를 사용하므로, 필드 이름 맞출 필요 X
member.username,
member.age,
team.id.as("teamId"),
team.name.as("teamName")
))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
) // 파라미터로 넘기기
.fetch();
}
private BooleanExpression ageLoe(Integer ageLoe) {
return ageLoe != null ? member.age.loe(ageLoe) : null;
}
private BooleanExpression ageGoe(Integer ageGoe) {
return ageGoe != null ? member.age.goe(ageGoe) : null;
}
private BooleanExpression teamNameEq(String teamName) {
return hasText(teamName) ? team.name.eq(teamName) : null;
}
private BooleanExpression usernameEq(String username) {
return hasText(username) ? member.username.eq(username) : null;
}
재사용이 가능하다.
ex) select projection이 DTO를 뽑는 것에서 엔티티로 뽑는 것으로 달라져도
null만 조심하면 조합이 가능하다. (메서드 이름도 잘 나타나게 작성 가능)
private BooleanExpression ageBetween(int ageLoe, int ageGoe) {
// null 처리 필요
return ageLoe(ageLoe).and(ageGoe(ageGoe));
}
리포지토리에 컨트롤러 추가 개발
샘플 데이터가 테스트 코드에 실행 시에는 추가되지 않고, 어플리케이션 실행 시에만 추가되도록 프로파일을 나눈다.
- 프로파일 설정 (
main/resources/application.yml
)주로 로컬 개발 환경은
local
, 개발 서버는dev
/develop
, 운영은real
로 둔다.
```yaml
spring:
profiles:
active: local
```
동일한 파일을 test/resources
디렉토리를 만들어서 복사 붙여넣기 하고, 프로파일 부분을 test로 변경한다.
spring:
profiles:
active: test
컨트롤러 패키지에 다음과 같은 코드를 작성한다.
InitMemberService.init()
메서드 안에 있는 내용을 곧바로init()
에 넣으면 안 되는 이유는, 스프링 라이프 사이클로 인해@PostConstruct
와@Transactional
을 같이 적용할 수 없기 때문이다. 해당 부분을 분리해야 한다.
@Profile("local")
@Component
@RequiredArgsConstructor
public class InitMember {
private final InitMemberService initMemberService;
@PostConstruct
public void init() {
initMemberService.init();
}
@Component
static class InitMemberService {
@PersistenceContext
private EntityManager em;
@Transactional
public void init() {
Team teamA = new Team("teamA");
Team teamB = new Team("teamB");
em.persist(teamA);
em.persist(teamB);
for (int i = 0; i < 100; i++) {
Team selectedTeam = i % 2 == 0 ? teamA : teamB;
em.persist(new Member("member" + i, i, selectedTeam));
}
}
}
}
@RestController
@RequiredArgsConstructor
public class MemberController {
private final MemberJpaRepository memberJpaRepository;
@GetMapping("/v1/members")
public List<MemberTeamDto> searchMemberV1(MemberSearchCondition condition) {
return memberJpaRepository.search(condition);
}
}
포스트맨에서 다음과 같이 url 작성하면 확인 가능
http://localhost:8080/v1/members?teamName=teamB&ageGoe=31&ageLoe=35&username=member31
인터페이스 추가!
public interface MemberRepository extends JpaRepository<Member, Long> {
// select m from Member m where m.username = ?
List<Member> findByUsername(String username);
}
스프링 데이터 JPA는 인터페이스로 동작하므로, Querydsl을 함께 사용하려면 사용자 정의 리포지토리를 만들어야 한다.
사용자 정의 인터페이스 작성
public interface MemberRepositoryCustom {
List<MemberTeamDto> search(MemberSearchCondition condition);
}
사용자 정의 인터페이스 구현
이름: 스프링 데이터 인터페이스 이름 +
Impl
public class MemberRepositoryImpl implements MemberRepositoryCustom {
// for Querydsl
private final JPAQueryFactory queryFactory;
public MemberRepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
@Override
public List<MemberTeamDto> search(MemberSearchCondition condition) {
return queryFactory
.select(new QMemberTeamDto(
member.id, // 생성자를 사용하므로, 필드 이름 맞출 필요 X
member.username,
member.age,
team.id.as("teamId"),
team.name.as("teamName")
))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
) // 파라미터로 넘기기
.fetch();
}
private BooleanExpression ageLoe(Integer ageLoe) {
return ageLoe != null ? member.age.loe(ageLoe) : null;
}
private BooleanExpression ageGoe(Integer ageGoe) {
return ageGoe != null ? member.age.goe(ageGoe) : null;
}
private BooleanExpression teamNameEq(String teamName) {
return hasText(teamName) ? team.name.eq(teamName) : null;
}
private BooleanExpression usernameEq(String username) {
return hasText(username) ? member.username.eq(username) : null;
}
}
스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속
public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom {
// select m from Member m where m.username = ?
List<Member> findByUsername(String username);
}
[!tip] 기본은 커스텀 리포지토리이지만, 더 유연한 설계를 원한다면 분리해도 좋다!
즉, 반드시 사용자 정의 리포지토리에 매몰될 필요는 없다. 모든 것을 커스텀에 넣는 것도 좋은 설계는 아니다!
특정 화면이나 조회 API 등에 특화된 기능이라면, 그냥 리포지토리를 분리(ex.
MemberQueryRepository
)하는 편이 좋을 수도 있다.기존 리포지토리에서는 엔티티를 많이 검색하고 재사용이 많이 되는 기능이 존재한다면, 이처럼 특화된 기능들은 수정 라이프 사이클이 화면이나 API에 맞춰지기 때문이다.
필요한 곳에서 해당 리포지토리를 주입 받아 사용하면 된다.
Page
, Pageable
) (v2)단순한 버전: fetchResults()
스프링 부트 2.6 이상, Querydsl 5.0 이상에서는 deprecated 이므로 다루지 X
복잡한 버전: content 쿼리(w/ fetch()
)와 count 쿼리 분리
📌 count 쿼리는 최적화(ex. 조인이 필요없으면 제외)할 수 있으면 해야 한다. 하지만 의미 없는 데에 노력할 필요는 없다. (데이터가 정말 많을 때에만)
커스텀 리포지토리 인터페이스
public interface MemberRepositoryCustom {
...
// count 쿼리를 별도로 (-> fetchResults(), fetchCount() deprecated 되었으므로)
Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable);
}
커스텀 리포지토리 구현 클래스
count 쿼리 최적화 (
return
부분): count 쿼리가 생략될 수 있는 경우, 생략해서 처리해준다. 필요할 때만!
- 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때
- 마지막 페이지일 때 (offset + 컨텐츠 사이즈를 더해 전체 사이즈를 구함)
public class MemberRepositoryImpl implements MemberRepositoryCustom {
// for Querydsl
private final JPAQueryFactory queryFactory;
public MemberRepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}
...
@Override
public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) {
// content 쿼리
List<MemberTeamDto> content = queryFactory
.select(new QMemberTeamDto(
member.id.as("memberId"),
member.username,
member.age,
team.id.as("teamId"),
team.name.as("teamName")
))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// count 쿼리
JPAQuery<Long> countQuery = queryFactory
// .select(Wildcard.count) // select count(*)
.select(member.count()) // select count(member.id)
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
);
return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
}
}
@GetMapping("/v2/members")
public Page<MemberTeamDto> searchMemberV2(MemberSearchCondition condition, Pageable pageable) {
// Pageable 인터페이스에 데이터가 바인딩되어서 넘어온다.
return memberRepository.searchPageComplex(condition, pageable);
}
스프링 데이터 JPA 정렬 (
Sort
) → Querydsl 정렬 (OrderSpecifier
)
변환하는 방법이 있으나, 조건이 조금만 복잡해져도 Pageable
의 Sort
기능을 사용하기 어려우므로, 동적 정렬 기능이 필요하면 파라미터를 직접 받아 처리하는 것을 권장한다.
여기에서 소개하는 기능은 제약이 커서 복잡한 실무 환경에서 사용하기에는 부족하다.
QuerydslPredicateExecutor
public interface MemberRepository extends JpaRepository<Member, Long>, QuerydslPredicateExecutor<Member> {
QMember member = QMember.member;
// 곧바로 querydsl 조건을 넣을 수 있다.
Iterable<Member> result = memberRepository.findAll(
member.age.between(10, 40)
.and(member.username.eq("member1"))
);
단순한 경우(ex. 테이블 하나)에는 사용할만 하지만, 결국엔 DTO나 파라미터를 넘겨서 해결하게 될 것이다.
쓰지 말자…
QuerydslRepositorySupport
getQuerydsl().applyPagination()
스프링 데이터가 제공하는 페이징을 Querydsl로 편리하게 변환 가능(단! Sort는 오류발생)from()
으로 시작 가능( 최근에는 QueryFactory를 사용해서 select()
로 시작하는 것이 더 명시적)QueryFactory
를 제공하지 않음
QuerydslRepositorySupport
의 한계를 극복하기 위해 직접 만들어보기
select()
, selectFrom()
으로 시작 가능EntityManager
, QueryFactory
제공QuerydslRepositorySupport
(추상 클래스)@Repository
public abstract class QuerydslRepositorySupport {
private final Class domainClass;
private Querydsl querydsl;
private EntityManager entityManager;
private JPAQueryFactory queryFactory;
public QuerydslRepositorySupport(Class<?> domainClass) {
Assert.notNull(domainClass, "Domain class must not be null!");
this.domainClass = domainClass;
}
@Autowired
public void setEntityManager(EntityManager entityManager) {
Assert.notNull(entityManager, "EntityManager must not be null!");
JpaEntityInformation entityInformation =
JpaEntityInformationSupport.getEntityInformation(domainClass, entityManager);
SimpleEntityPathResolver resolver = SimpleEntityPathResolver.INSTANCE;
EntityPath path = resolver.createPath(entityInformation.getJavaType());
this.entityManager = entityManager;
this.querydsl = new Querydsl(entityManager, new
PathBuilder<>(path.getType(), path.getMetadata()));
this.queryFactory = new JPAQueryFactory(entityManager);
}
@PostConstruct
public void validate() {
Assert.notNull(entityManager, "EntityManager must not be null!");
Assert.notNull(querydsl, "Querydsl must not be null!");
Assert.notNull(queryFactory, "QueryFactory must not be null!");
}
protected JPAQueryFactory getQueryFactory() {
return queryFactory;
}
protected Querydsl getQuerydsl() {
return querydsl;
}
protected EntityManager getEntityManager() {
return entityManager;
}
protected <T> JPAQuery<T> select(Expression<T> expr) {
return getQueryFactory().select(expr);
}
protected <T> JPAQuery<T> selectFrom(EntityPath<T> from) {
return getQueryFactory().selectFrom(from);
}
protected <T> Page<T> applyPagination(Pageable pageable, Function<JPAQueryFactory, JPAQuery> contentQuery, Function<JPAQueryFactory, JPAQuery> countQuery) {
JPAQuery jpaContentQuery = contentQuery.apply(getQueryFactory());
List<T> content = getQuerydsl().applyPagination(pageable, jpaContentQuery).fetch();
JPAQuery<Long> countResult = countQuery.apply(getQueryFactory());
return PageableExecutionUtils.getPage(content, pageable, countResult::fetchOne);
}
}
MemberTestRepository
(실제 리포지토리)[!note]
applyPagination
주목!→ content 쿼리와 count 쿼리를 따로 작성하는 방법으로만 진행했다. (deprecated 된 것 때문)
→ v2 코드를 더욱 간단하게 작성했다.
@Repository
public class MemberTestRepository extends QuerydslRepositorySupport {
public MemberTestRepository() {
super(Member.class);
}
public List<Member> basicSelect() {
return select(member)
.from(member)
.fetch();
}
public List<Member> basicSelectFrom() {
return selectFrom(member)
.fetch();
}
public Page<MemberTeamDto> applyPagination(MemberSearchCondition condition, Pageable pageable) {
// content 쿼리와 count 쿼리를 나누어서 실행 (w/ 람다)
return applyPagination(pageable, contentQuery -> contentQuery
.select(new QMemberTeamDto(
member.id.as("memberId"),
member.username,
member.age,
team.id.as("teamId"),
team.name.as("teamName")))
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
), countQuery -> countQuery
.select(member.count())
.from(member)
.leftJoin(member.team, team)
.where(
usernameEq(condition.getUsername()),
teamNameEq(condition.getTeamName()),
ageGoe(condition.getAgeGoe()),
ageLoe(condition.getAgeLoe())
));
}
private BooleanExpression ageLoe(Integer ageLoe) {
return ageLoe != null ? member.age.loe(ageLoe) : null;
}
private BooleanExpression ageGoe(Integer ageGoe) {
return ageGoe != null ? member.age.goe(ageGoe) : null;
}
private BooleanExpression teamNameEq(String teamName) {
return hasText(teamName) ? team.name.eq(teamName) : null;
}
private BooleanExpression usernameEq(String username) {
return hasText(username) ? member.username.eq(username) : null;
}
}
Contents
H2 URL