Spring Data JPA가 기본적으로 제공해 주는 CRUD 메서드 및 쿼리 메서드 기능을 사용하더라도, 원하는 조건의 데이터를 수집하기 위해서는 필연적으로 JPQL을 작성하게 된다.
간단한 로직을 작성하는데 큰 문제는 없으나, 복잡한 로직의 경우 개행이 포함된 쿼리 문자열이 상당히 길어진다. JPQL 문자열에 오타 혹은 문법적인 오류가 존재하는 경우, 정적 쿼리라면 애플리케이션 로딩 시점에 이를 발견할 수 있으나 그 외는 런타임 시점에서 에러가 발생한다.
이러한 문제를 어느 정도 해소하는데 기여하는 프레임워크가 QueryDSL이다. QueryDSL은 정적 타입을 이용해서 SQL 등의 쿼리를 생성해주는 프레임워크이다.
QueryDSL의 장점
- 문자가 아닌 코드로 쿼리를 작성함으로써, 컴파일 시점에 문법 오류를 쉽게 확인할 수 있다.
- 자동 완성 등 IDE의 도움을 받을 수 있다.
- 동적인 쿼리 작성이 편리하다.
- 쿼리 작성 시 제약 조건 등을 메서드 추출을 통해 재사용할 수 있다.
QueryDSL 설정하기
plugins {
id 'java'
id 'org.springframework.boot' version '2.2.2.RELEASE'
id 'io.spring.dependency-management' version '1.0.8.RELEASE'
//querydsl 추가
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}
group = 'study'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
//querydsl 추가
implementation 'com.querydsl:querydsl-jpa'
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.8.1'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.h2database:h2'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
//querydsl 추가 시작
def querydslDir = "$buildDir/generated/querydsl"
querydsl {
jpa = true
querydslSourcesDir = querydslDir
}
sourceSets {
main.java.srcDir querydslDir
}
configurations {
querydsl.extendsFrom compileClasspath
}
compileQuerydsl {
options.annotationProcessorPath = configurations.querydsl
}
//querydsl 추가 끝
주석으로 querydsl 관련된 설정을 적어두었다.
기본적으로 QueryDSL은 프로젝트 내의 @Entity 어너테이션을 선언한 클래스를 탐색하고,
JPAAnnotationPRocessor를 사용해 Q클래스를 생성한다.
검증용 Q 타입 생성
Gradle -> Tasks -> other -> compileQuerydsl
Q 타입 생성 확인
build -> generated -> querydsl
Q클래스 인스턴스를 사용하는 2가지 방법
- new QMember("m") : 별칭(alias)을 직접 지정하는 방법이다.
- JPQL 실행 시 select m From Member m ~~ 식으로 작성된다.
- QMember.member : QueryDSL이 기본으로 생성해 주는 인스턴스를 사용하는 방법이다
- static-import를 사용하면 깔끔하게 사용하실 수 있습니다.
- 별칭을 직접 지정하는 방법은 같은 테이블을 join 하는 경우 혹은 서브쿼리를 사용하는 경우가 아니라면 잘 사용하지 않는다.
별칭 직접 지정
@Test
public void querydslTest(){
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
QMember m = new QMember("m");
Member findMember = queryFactory
.select(m)
.from(m)
.where(m.username.eq("member1"))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
테스트 작성시 JPAQueryFactory는 @BeforeEach에 선언하여 생략하는 방법을 사용하자
@PersistenceContext;
EntityManager em;
JPAQueryFactory queryFactory;
@BeforeEach
public void before(){
queryFactory = new JPAQueryFactory(em);
//~~
}
기본 인스턴스를 static import와 함께 사용
import static study.querydsl.entity.QMember.*;
@Test
public void QuerydTest() {
//member1을 찾아라.
Member findMember = queryFactory
.select(member)
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
JPAQueryFactory
QueryDSL을 사용하려면 JPAQueryFactory 객체를 사용해야 하며, 생성자로는 EntityManager를 넣어주어야 한다.
@PersistenceContext;
EntityManager em;
JPAQueryFactory queryFactory = new JPAQueryFactory(em);
JPAQueryFactory Bean으로 등록하여 사용하기
@SpringBootApplication
public class QuerydslStudyApplication {
public static void main(String[] args) {
SpringApplication.run(QuerydslStudyApplication.class, args);
}
@Bean
JPAQueryFactory jpaQueryFactory(EntityManager em){
return new JPAQueryFactory(em);
}
}
검색 조건 쿼리
QueryDSL의 기본 검색 조건 쿼리는 다음과 같이 사용 가능하다.
@Test
public void search() {
Member findMember = queryFactory
.selectFrom(member)
.where(member.username.eq("member1")
.and(member.age.eq(10)))
.fetchOne();
assertThat(findMember.getUsername()).isEqualTo("member1");
}
검색 조건은. and() , or()를 메서드 체인으로 연결 가능
select와 from을 합치면 selectFrom이 된다.
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()의 AND 조건을 , 를 사용해서 처리 가능하다.
@Test
public void querydslTest() {
List<Member> result1 = queryFactory
.selectFrom(member)
.where(member.username.eq("member1"),
member.age.eq(10))
.fetch();
assertThat(result1.size()).isEqualTo(1);
}
where()에 파라미터로 검색조건을 추가하면 AND 조건이 추가된다.
이때 해당 값이 null 인 경우 무시된다.
결과 조회 메소드
- fetch() : 리스트 조회, 데이터 없는 경우 빈 리스트 반환
- fetchOne() : 단 건 조회
- 결과가 없으면 null
- 결과가 둘 이상이면 NonUniqueResultException
- fetchFirst() : limit(1). fetchOne()과 동일하다.
다음은 QueryDSL 5.0 버전에서 Deprecate된 메서드이다.
- fetchResult() : 페이징 정보 포함, total count 쿼리 추가 실행
- fetchCount() : count 쿼리로 변경하여 count 수 조회
정렬
정렬은 orderBy()를 사용하여 수행할 수 있다.
- desc(), asc() : 내림차순, 오름차순
- nullsLast() : null 인 경우 가장 마지막에 위치한다.
- nullsFirst() : null 인 경우 가장 처음에 위치한다.
@Test
public void sort(){
em.persist(new Member(null,100));
em.persist(new Member("member5",100));
em.persist(new Member("member6",100));
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(100))
.orderBy(member.age.desc(), member.username.asc().nullsLast())
.fetch();
Member member5 = result.get(0);
Member member6 = result.get(1);
Member memberNull = result.get(2);
assertThat(member5.getUsername()).isEqualTo("member5");
assertThat(member6.getUsername()).isEqualTo("member6");
assertThat(memberNull.getUsername()).isNull();
}
페이징
@Test
public void paging() {
List<Member> result = queryFactory
.selectFrom(member)
.orderBy(member.username.desc())
.offset(1) //0부터 시작(zero index)
.limit(2) //최대 2건 조회
.fetch();
assertThat(result.size()).isEqualTo(2);
}
전체 조회수가 필요하다면?
@Test
public void paging2() {
QueryResults<Member> queryResults = queryFactory
.selectFrom(member)
.orderBy(member.username.desc())
.offset(1)
.limit(2)
.fetchResults();
assertThat(queryResults.getTotal()).isEqualTo(4);
assertThat(queryResults.getLimit()).isEqualTo(2);
assertThat(queryResults.getOffset()).isEqualTo(1);
assertThat(queryResults.getResults().size()).isEqualTo(2);
}
집계 (count, sum, avg, max, min)
집계 함수는 그룹 별로 그룹핑된 결과를 반환합니다.
- COUNT() : 총 개수
- SUM() : 합
- AVG() : 평균
- MAX() : 최댓값
- MIN() : 최솟값
@Test
public void querydslTest(){
List<Tuple> result = queryFactory
.select(member.count(),
member.age.sum(),
member.age.avg(),
member.age.max(),
member.age.min())
.from(member)
.fetch();
Tuple tuple = result.get(0);
assertThat(tuple.get(member.count())).isEqualTo(4);
assertThat(tuple.get(member.age.sum())).isEqualTo(100);
assertThat(tuple.get(member.age.avg())).isEqualTo(25);
assertThat(tuple.get(member.age.max())).isEqualTo(40);
assertThat(tuple.get(member.age.min())).isEqualTo(10);
}
Select 절에 위와 같이 여러 타입을 조회하는 경우 QueryDsl에서 제공하는 Tuple로 조회된다.
하나만 조회 시에는, List <Integer>와 같이 조회한 타입의 List로 조회된다.
Tuple은 get() 메서드의 인자로 SELECT 절에서 수행했던 Expression을 그대로 사용해서 값을 가져올 수 있다.
GroupBy
데이터를 그룹화하여 조회하기 위해 GroupBy를 사용한다.
having을 통해 그룹화된 결과를 제한할 수 있다.
@Test
public void group() throws Exception {
List<Tuple> result = queryFactory
.select(team.name, member.age.avg())
.from(member)
.join(member.team, team)
.groupBy(team.name)
.fetch();
Tuple teamA = result.get(0);
Tuple teamB = result.get(1);
assertThat(teamA.get(team.name)).isEqualTo("teamA");
assertThat(teamA.get(member.age.avg())).isEqualTo(15);
assertThat(teamB.get(team.name)).isEqualTo("teamB");
assertThat(teamB.get(member.age.avg())).isEqualTo(35);
}
(member1, 10, teamA), (member2, 20, teamA), (member3, 30, teamB), (member4, 40, teamB)
데이터가 디비에 저장되어 있다.
having() 예시
...
.groupBy(team.name)
.having(member.age.loe(10))
...
조인
조인의 사용 방법은 다음과 같다.
join(조인 대상, 별칭으로 사용할 Q타입)
기본 조인
@Test
public void join() throws Exception {
List<Member> result = queryFactory
.selectFrom(member)
.join(member.team, team)
.where(team.name.eq("teamA"))
.fetch();
assertThat(result)
.extracting("username")
.containsExactly("member1", "member2");
}
세타 조인
연관관계가 없는 필드로 조인
@Test
public void theta_join() {
List<Member> result = queryFactory
.select(member)
.from(member, team)
.where(member.username.eq(team.name))
.fetch();
assertThat(result)
.extracting("username")
.containsExactly("teamA", "teamB");
}
from 절에 여러 Q타입을 나열해서 세타 조인을 할 수 있다.
세타 조인 시에는 외부 조인이 불가능하고 오직 inner join만 가능하다.
만약 외부 조인이 필요하다면 on을 사용하여야 한다.
ON절
on이 제공하는 기능은 다음과 같다.
- 조인 대상 필터링
- ON절은 조인할 대상을 필터링하기 위해 사용합니다. where는 조인 후 필터링이다
- inner join에서 사용할 경우 where과 on의 결과가 동일하다. 이 경우 익숙한 where을 사용하는 것을 권장한다.
- 연관관계없는 엔티티 외부 조인 (외부 세타 조인)
조인 대상 필터링
@Test
public void join_on_filtering(){
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);
}
}
연관관계 없는 엔티티 외부 조인
@Test
public void join_on_no_relation(){
em.persist(new Member("teamA"));
em.persist(new Member("teamB"));
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("t=" + tuple);
} }
해당 경우 leftJoin()의 인자로 엔티티가 하나만 들어간다.
페치 조인
join() 이후 fetchJoin을 붙여주어 사용한다.
Member findMember = queryFactory
.selectFrom(member)
.join(member.team, team).fetchJoin()
.where(member.username.eq("member1"))
.fetchOne();
서브쿼리
JPAExpressions을 사용하여 처리한다.
JPQL과 마찬가지로 from절의 서브쿼리는 지원되지 않는다.
단 하이버네이트 구현체를 사용하면 select절의 서브쿼리를 사용할 수 있다.
주의 사항
서브쿼리와 메인쿼리의 별칭이 겹치면 안 되므로, 같은 엔티티를 사용한다면, QType을 생성할 때 Alias를 직접 지정해야 함
from절의 서브쿼리 해결방안
- 서브쿼리를 join으로 해결한다.(가능할 수도, 불가능할 수도 있습니다)
- 애플리케이션에서 쿼리를 2번 분리해서 실행합니다.
- nativeSQL을 실행합니다.
@Test
public void subQuery(){
QMember memberSub = new QMember("memberSub");
List<Member> result = queryFactory
.selectFrom(member)
.where(member.age.eq(JPAExpressions
.select(memberSub.age.max())
.from(memberSub))) .fetch();
assertThat(result).extracting("age")
.containsExactly(40);
}
CASE문
select, 조건절(where), orderBy에서 사용가능
단순한 조건
List<String> result = queryFactory
.select(member.age.when(10).then("열살")
.when(20).then("스무살") .otherwise("기타"))
.from(member)
.fetch();
복잡한 조건 CaseBuilder 사용
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();
CaseBuilder의 활용
다음과 같은 임의의 순서로 회원을 출력해 보자.
- 0 ~ 30살이 아닌 회원을 가장 먼저 출력
- 0 ~ 20살 회원 출력
- 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) {
String username = tuple.get(member.username);
Integer age = tuple.get(member.age);
Integer rank = tuple.get(rankPath);
System.out.println("username = " + username + " age = " + age + " rank = "
+ rank); }
상수
상수가 필요한 경우 Expressions.constant(상수)를 사용한다.
Tuple result = queryFactory
.select(member.username, Expressions.constant("A"))
.from(member)
.fetchFirst();
다음과 같은 결과가 나온다.
문자 더하기
결과에 문자열을 더하고 싶은 경우에 다음 메서드를 사용할 수 있다.
concat(): 문자열 뒤에 이어서 더한다.
prepend(): 문자열의 맨 앞에 더한다.
String result = queryFactory
.select(member.username.concat("_").concat(member.age.stringValue()))
.from(member)
.where(member.username.eq("member1"))
.fetchOne();
결과: member1_10
- member.age.stringValue() 부분이 중요하다.
- 문자가 아닌 다른 타입들은 stringValue() 로 문자로 변환할 수 있다.
- 이 방법은 ENUM을 처리할 때도 자주 사용한다.
프로젝션
프로젝션은 select절에 대상을 지정하는 것이다.
프로섹션 대상이 하나인 경우,
둘 이상인 경우,
DTO로 조회하는 경우에 대한 방법이 모두 다르기에 하나하나 예시를 동해 알아보자
그전에 우선 프로젝션을 사용할 때 가장 권장되는 방법인 @QueryProjection의 사용법부터 알아보자
@QueryProjection
@QueryProjection은 생성자를 통해 DTO를 조회하는 방법과 함께 사용됩니다.
DTO의 생성자에 @QueryProjection을 붙여주어야 한다.
@Data
@NoArgsConstructor
@ToString
public class MemberDto {
private String username;
private int age;
@QueryProjection
public MemberDto(String username, int age) {
this.username = username;
this.age = age;
}
}
이후 빌드 시 DTO가 Q파일로 생성된다.
Q타입은 다음과 같이 사용가능하다.
List<MemberDto> result = queryFactory
.select(new QMemberDto(member.username, member.age))
.from(member)
.fetch();
@QueryProjection을 사용하면 컴파일 시에 타입 체크를 할 수 있어 이후 소개할 여러 방법들보다 안전한 방법이다.
그러나 DTO에 QueryDSL 어노테이션을 유지해야 하는 점과(의존성의 추가), DTO까지 Q파일을 생성해야 하는 단점이 있다.
프로젝션 대상이 하나인 경우
대상이 하나라면 타입을 명확하게 지정하여 결과값으로 반환받을 수 있습니다.
List<String> result = queryFactory
.select(member.username)
.from(member)
.fetch();
member의 username은 String이므로 위와 같이 String 타입으로 바로 조회가능하다
프로젝션 대상이 둘 이상
프로젝션 대상이 둘 이상인 경우에는 Tuple을 사용하여 결과를 조회할 수 있습니다.
List<Tuple> result = queryFactory
.select(member.username, member.age)
.from(member)
.fetch();
for (Tuple tuple : result) {
String username = tuple.get(member.username);
Integer age = tuple.get(member.age);
System.out.println("username=" + username);
System.out.println("age=" + age);
}
Tuple을 사용하는 경우, 프로젝션 할 때 사용했던 Expression을 그대로 사용하여 get의 인자로 넘겨주어 사용할 수 있다.
DTO로 조회
Projection을 사용한다.
QueryDSL은 3가지의 방법을 통해 DTO로 조회할 수 있도록 지원한다.
- 프로퍼티 접근 (Setter) : setter와 기본 생성자 필수
- 필드 직접 접근 : 기본 생성자 필수
- 생성자 사용 : 타입이 일치하는 생성자 필수
1. 프로퍼티 접근법 - Setter 사용
Projection.bean()을 사용
List<MemberDto> result = queryFactory
.select(Projections.bean(MemberDto.class,
member.username,member.age))
.from(member)
.fetch();
2. 필드 직접 접근
Projections.fields()을 사용
List<MemberDto> result = queryFactory
.select(Projections.fields(MemberDto.class,
member.username, member.age))
.from(member)
.fetch();
DTO와 Entity의 필드명이 다른 경우
ExpressionUtils.as(source, alias):필드나 서브 쿼리에 별칭을 적용할 때 사용
username.as(alias): 필드에 별칭을 적용할 때 사용
List<UserDto> fetch = queryFactory
.select(Projections.fields(UserDto.class,
member.username.as("name"),
ExpressionUtils.as(JPAExpressions
.select(memberSub.age.max())
.from(memberSub), "age"))).from(member).fetch();
3. 생성자 사용
Projections.contructor()을 사용
List<MemberDto> result = queryFactory
.select(Projections.constructor(MemberDto.class,member.username,member.age))
.from(member)
.fetch();
동적 쿼리
QueryDSL에서 동적 쿼리를 사용하는 방법은 다음과 같다.
- BooleanBuiilder 사용
- Where 다중 파라미터 사용 (권장)
@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 절에 다중 파라미터 사용
where 조건에 전달되는 null 값은 무시되는 것을 사용하는 방법이다.
@Test
public void dynamicQuery_whereParam(){
String usernameParam = "member1";
Integer ageParam = 10;
List<Member> result = searchMember2(usernameParam, ageParam);
assertThat(result.size()).isEqualTo(1);
}
private List<Member> searchMember2(String usernameCond, Integer ageCond) {
return queryFactory
.selectFrom(member)
.where(usernameEq(usernameCond),ageEq(ageCond))
.fetch();
}
private BooleanExpression usernameEq(String usernameCond) {
return usernameCond != null ? member.username.eq(usernameCond) : null;
}
private BooleanExpression ageEq(Integer ageCond) {
return ageCond != null ? member.age.eq(ageCond) : null;
}
메서드를 다른 쿼리에서도 재활용할 수 있으며, 쿼리 자체의 가독성이 높아진다.
조합 가능
private BooleanExpression allEq(String usernameCond, Integer ageCond) {
return usernameEq(usernameCond).and(ageEq(ageCond));
}
null 체크는 주의해서 처리해야 한다.
수정, 삭제 벌크연산
쿼리 한 번으로 대량 데이터 수정하기
long count = queryFactory
.update(member)
.set(member.username, "비회원")
.where(member.age.lt(28)) .execute();
count는 적용된 수이다.
기존 숫자에 1 더하기
long count = queryFactory
.update(member)
.set(member.age, member.age.add(1))
.execute();
빼기의 경우 add(-1)을 해야 한다.
쿼리 한 번으로 대량 데이터 삭제
long count = queryFactory
.delete(member)
.where(member.age.gt(18))
.execute();
'JPA' 카테고리의 다른 글
OSIV(Open Session In View) (2) | 2023.10.02 |
---|---|
페치 조인 (2) | 2023.01.10 |
JPQL 사용법 (3) | 2023.01.09 |
JPA의 데이터 타입 (3) | 2023.01.07 |
프록시 & 지연로딩 (0) | 2023.01.07 |