JPA 중급 1 - Querydsl
Querydsl
은 JPQL
빌더입니다.
이 포스팅의 대부분 내용은
김영한
님의 실전! Querydsl을 참고하여 작성됐으며, 간단한 사용법에 대한 예제일 뿐이니 더 깊은 내용이 궁금하신분은 강의를 보세요.
📕 Querydsl
Querydsl
은 JPQL 빌더
역할을 하는 비표준 오픈소스 프로젝트이다.
JPA 표준 빌더는 JPA Criteria
인데, 이것은 너무 장황하고 가독성이 좋지 않기 때문에 비록 표준은 아니지만 Querydsl
을 사용한다.
Querydsl
이 너무 훌륭하기 때문에 Spring Data
프로젝트에서도 많은 지원이 되고 있는데, 근시일내에 표준으로 자리잡지 않을까 조심스레 예상해본다.
Querydsl
을 왜 사용할까?
JPQL을 직접 사용하게 되면 문자열을 직접 타이핑해야 한다는 큰 단점이 존재한다.
Ibatis
나 Mybatis
를 사용해보신분은 알겠지만, 쿼리를 직접 타이핑한다는 것은 안정성이 굉장히 떨어지는 작업이라고 볼 수 있다.
다음 SQL
을 보자.
String query = " select \n" +
" m.id\n" +
" t.id\n" +
" m.create_at\n" +
" m.update_at\n" +
" m.age\n" +
" m.name\n" +
" m.team_id\n" +
" t.create_at\n" +
" t.update_at\n" +
" t.name\n" +
" from\n" +
" member m \n" +
" inner join\n" +
" team t \n" +
" on m.team_id=t.id";
눈썰미가 좋으신분은 알아차리셨을수도 있겠지만, 위 SQL
에는 버그가있다.
뭘까?
String query = " select \n" + // <-- select 뒤에 띄어쓰기가 있음
" m.id\n" +
" t.id\n" +
" m.create_at\n" +
" m.update_at\n" +
" m.age\n" +
" m.name\n" +
" m.team_id\n" +
" t.create_at\n" +
" t.update_at\n" +
" t.name\n" +
" from\n" +
" member m \n" +
" inner join\n" +
" team t \n" +
" on m.team_id=t.id";
위처럼 SQL
에 버그가 존재하는 상태로 어플리케이션이 실행이 된다는게 가장 큰 문제점이다. (컴파일 타임에 에러가 잡히질 않는다 ! 😱)
즉, 배포가 완료된 후 사용자가 어떤 기능을 사용하여 해당 SQL
이 포함된 메서드가 런타임에 호출되고 나서야 버그가 존재함을 알 수 있다는 것이다.
실무에서 이런 상황은 즉시 대처에 들어가야하는 핫픽스 작업이며(애초에 발생해서는 안된다 !), 서비스에 대한 사용자의 신뢰성이 떨어지는 결과까지도 야기할 수 있다.
심지어 원인이 오타라는, 누구나 쉽게 저지를 수 있는 실수이고 디버깅 또한 굉장히 힘들기 때문에 굉장히 질이 좋지 않은 버그라고 볼 수 있을 것 같다.
또한, 위와 비슷하지만 코드를 작성하는 개발자 관점에서 봤을 때 자바 컴파일러와 IDE의 도움을 받을 수 없다는 문제도 있다.
즉, 코드를 작성하다 잘 기억이 나지 않는다면 관련 문서를 계속해서 찾아야만 한다. 실수가 나올 가능성도 매우 높다.
그리고 가독성이 매우 떨어진다는 문제도 있다.
자바 13이후로 문자열 블록이라는 기능이 생겨서 다음과 같은 코드 작성이 가능하긴 한데, 이것도 가독성 측면에서 좋아진 것 뿐이지, 공백 유무에 의한 문제 발생 가능성은 여전히 존재한다.
아직은 우리나라에서 자바8이 가장 많이 사용되고 있고 이제야 자바 11로 넘어가고 있는 추세이기 때문에 먼 이야기다. (21.9월에 자바 17 LTS 발표인데…)
String query = """
select
m.id
t.id
m.create_at
m.update_at
m.age
m.name
m.team_id
t.create_at
t.update_at
t.name
from
member m
inner join
team t
on m.team_id=t.id";
""";
Querydsl
은 이러한 단점을 모두 보완해준다.
일단 위의 SQL
을 Querydsl
로 작성한 예를 보자.
queryFactory
.selectFrom(member)
.join(member.team, team)
.fetch();
보다시피 어떤 쿼리가 발생할지 한눈에 파악할 수 있다.
굉장히 가독성이 좋으며, 문자열 사용을 최소화고 빌더 패턴으로 작성되기 때문에 자바 컴파일러와 IDE의 도움을 모두 받을 수 있다.
즉, 쿼리에 문제가 있다면 아예 컴파일 에러가 발생하여 실행자체가 안됨과 동시에 어디에 어떤 문제가 있는지 에러 메시지를 받아볼 수 있으며, 다음과 같이 IDE 자동완성의 이점도 모두 누릴 수 있다.
이쯤하면 왜 Querydsl
을 사용하는지에 대한 납득이 되었을 것 같다.
하지만 Querydsl
은 단순히 JPQL 빌더
이기 때문에, JPQL로 안되는 것은 Querydsl에서도 안된다.
Querydsl
은 절대 만능이 아니며, 용빼는 재주가 있는게 아니다.
Querydsl
은 단순히 JPQL 빌더
라는 것을 명심해야 한다.
🚀 환경설정
빌드 스크립트에 대한 세부적인 내용이 궁금하신 분은 하기 포스팅을 참고해주세요.
우선 이 세팅은 2021.07.31 현재 Querydsl 최신 버전을 기준으로 하여 작성됨을 말씀드린다.
프로젝트에 적용된 Querydsl 버전
은 4.4.0
이며, gradle 버전
은 6.8.3
이다.
버전을 먼저 언급하는 이유가 있는데, Querydsl 3.x대 버전과 4.x대 버전 사이에 상당한 차이가 있기 때문이다.
또한, Querydsl
을 사용하기 위해서는 JPA Entity
를 QClass
라는 것으로 컴파일 해야 하는데, 여기서 annotationProcessor
와 gradle
의 도움이 필요하기 때문이다.
필수적인 설정은 다음과 같다.
ext {
querydslDir = "$buildDir/generated/querydsl"
}
dependencies {
annotationProcessor(
'org.springframework.boot:spring-boot-configuration-processor',
'jakarta.persistence:jakarta.persistence-api',
'jakarta.annotation:jakarta.annotation-api',
"com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa",
)
implementation(
'com.querydsl:querydsl-jpa',
)
}
이 포스팅을 작성하기 위해 구성한 프로젝트의 전체적인 빌드 스크립트는 다음과 같다.
plugins {
id 'java'
id 'org.springframework.boot' version '2.5.2'
id 'io.spring.dependency-management' version '1.0.11.RELEASE'
}
ext {
querydslDir = "$buildDir/generated/querydsl"
}
group = 'learn.jpa'
version = ''
sourceCompatibility = '11'
repositories {
mavenCentral()
}
configurations {
developmentOnly
runtimeClasspath {
extendsFrom developmentOnly
}
}
dependencies {
annotationProcessor(
'org.projectlombok:lombok',
'org.springframework.boot:spring-boot-configuration-processor',
'jakarta.persistence:jakarta.persistence-api',
'jakarta.annotation:jakarta.annotation-api',
"com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jpa",
)
implementation(
'org.springframework.boot:spring-boot-starter-web',
'org.springframework.boot:spring-boot-starter-data-jpa',
'org.springframework.boot:spring-boot-starter-validation',
'com.querydsl:querydsl-jpa',
)
testImplementation(
'org.springframework.boot:spring-boot-starter-test',
)
testImplementation("org.springframework.boot:spring-boot-starter-test") {
exclude group: "junit", module: "junit"
}
compileOnly(
'org.projectlombok:lombok'
)
runtimeOnly(
'com.h2database:h2'
)
}
test {
useJUnitPlatform()
}
clean {
delete file('src/main/generated')
}
task cleanGeneatedDir(type: Delete) {
delete file('src/main/generated')
}
위 코드가 뭔지 몰라도 일단 필수 설정만 똑같이 작성하면 Querydsl
을 사용하는데 큰 문제는 없을 것 같다.
만약 위 코드가 더 궁금하다면 groovy
와 gradle
에 대한 공부가 필요할 것이다.
그리고 Querydsl 4.x에서 JPAQueryFactory
가 핵심적인 역할을 하는데, 이 클래스를 스프링 빈으로 만들기 위해 다음과 같은 설정파일을 작성한다.
필자는 Querydsl이 JPA 도메인에 포함된다고 판단하여 JPA 설정파일에 이 설정을 집어넣는다.
@Configuration
@EnableJpaAuditing
public class JpaConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
참고로 이렇게 설정파일을 작성하는 이유가 두가지 존재한다.
Querydsl
은 비표준이기 때문에@DataJpaTest
의 도움을 받지 못한다@DataJpaTest
는 슬라이싱 테스트를 진행할 때 JPA관련 스프링 빈을 주입해주는데,JPAQueryFactory
빈을 주입해주지 않기 때문에Querydsl
테스트를 하지 못한다
JPAQueryFactory
를싱글톤
으로 활용하기 위함이다
이후 모든 Querydsl
테스트를 진행할 때 다음과 같이 진행하면 된다.
여기서 JUnit
은 5버전
이며, 생성자 주입을 받기 위해 src/test/resources/spring.properties
를 생성한 후 다음과 같은 설정을 추가해야 한다.
# file: 'src/test/resources/spring.properties'
spring.test.constructor.autowire.mode=ALL
@DataJpaTest
@Import(JpaConfig.class)
class QueryRepositoryTest {
private final JPAQueryFactory queryFactory;
private final TestEntityManager entityManager;
QueryRepositoryTest(JPAQueryFactory queryFactory, TestEntityManager entityManager) {
this.queryFactory = queryFactory;
this.entityManager = entityManager;
}
// test cases...
}
이렇게 설정하면 @SpringBootTest
를 사용하지 않는 슬라이싱 테스트를 진행하더라도 Querydsl
테스트를 진행할 수 있다.
명심해야 할 것은, Querydsl
을 사용하기 위해서는 QClass
가 반드시 필요하므로 항상 gradle
에서 build
태스크를 먼저 실행해줘야 QClass
가 컴파일되어 Querydsl
이 정상적으로 동작하게 된다는 것이다.
📜 사용방법
우선 결과를 조회하는 fetch()
관련 메서드에 대해 알면 좋을 것 같다.
자주 사용하는 fetch
는 다음과 같다.
- fetch()
- 검색 결과에 대한 모든 값을
Collection
에 담아 반환한다. 이 때
- 검색 결과에 대한 모든 값을
- fetchCount()
- 검색결과를 카운트로 반환한다. 반환 타입은
long
- 검색결과를 카운트로 반환한다. 반환 타입은
- fetchOne()
- 한개의 결과를 반환한다. 검색 결과가 반드시 한개인 경우에만 사용해야 한다
- JPQL의
getSingleResult
와 같다 - 결과가 둘 이상인 경우
NonUniqueResultException
을 던진다
- fetchFirst()
limit 1
과 같다.- 검색 중 처음으로 매치되는 것 하나만 반환하며, 즉시 쿼리가 종료된다
- fetchResults()
페이징 쿼리
를 작성할 때 사용된다- 콘텐츠, 페이지 개수, 페이지 사이즈, 토탈 카운트를 반환한다
- 콘텐츠를 찾는 쿼리와 카운트를 찾는 쿼리가 모두 발생한다 (총 2번)
검색
List<Member> members = queryFactory
.select(QMember.member)
.from(QMember.member)
.fetch();
List<Member> members = queryFactory
.selectFrom(QMember.member) // select절에 member를 전체 가져올 경우 selectFrom()으로 축약 가능
.fetch();
List<Member> members = queryFactory
.selectFrom(member) // QMember를 static import하면 이렇게 줄일 수 있다
.fetch();
조건 검색
// where절에 and()나 or()를 추가할 수 있으며, 아래 샘플 코드처럼 쉼표(,)로 구분하면 and()와 같이 동작한다.
List<Member> members = queryFactory
.selectFrom(member)
.where(
member.name.eq("siro"),
member.age.eq(10)
)
.fetch();
정렬
List<Member> members = queryFactory
.selectFrom(member)
.orderBy(member.name.desc())
.fetch();
페이징
QueryResults<Member> results = queryFactory
.selectFrom(member)
.orderBy(member.name.desc())
.offset(1)
.limit(2)
.fetchResults();
List<Member> content = results.getResults();
long totalCount = results.getTotal();
집합
// Tuple은 com.querydsl.core에 존재하는 클래스로, 결과가 하나의 엔티티가 아닌 여러가지 타입일 경우를 뜻한다
List<Tuple> tuples = queryFactory
.select(
member.count(),
member.age.sum(),
member.age.avg(),
member.age.max(),
member.age.min()
)
.from(member)
.fetch();
내부조인
// 내부조인 시 on()절은 생략해도 무방하다. where()절이 있다면 대체된다.
List<Member> members = queryFactory
.selectFrom(member)
.join(member.team, team) // innerJoin()과 join()은 같다
.fetch();
외부조인
// 외부조인 시에는 where()절보다 on()절을 사용해야만 한다.
List<Member> members = queryFactory
.selectFrom(member)
.leftJoin(member.team, team) // rightJoin()도 존재한다
.fetch();
세타조인(=크로스조인)
// 세타조인시 join 조건절 역할을 하는 where이 없거나 잘못된 경우 카테시안 곱이 발생하므로 주의한다
List<Member> members = queryFactory
.select(member)
.from(member, team)
.where(member.name.eq(team.name))
.fetch();
페치조인
페치조인은 fetchJoin()
만 추가하면 바로 적용된다.
List<Member> fetch = queryFactory
.selectFrom(member)
.join(member.team, team)
.fetchJoin()
.fetch();
스위치
// 간단한 스위치
List<String> members = queryFactory
.select(member.age
.when(10).then("열살")
.when(20).then("스무살")
.otherwise("기타")
)
.from(member)
.fetch();
// 복잡한 스위치
List<String> members = queryFactory
.select(new CaseBuilder() // 케이스빌더 사용
.when(member.name.startsWith("김")).then("김씨")
.when(member.name.startsWith("이")).then("이씨")
.otherwise("기타"))
.from(member)
.fetch();
문자열, 상수 이어붙이기
List<String> members = queryFactory
.select(member.name.concat("_").concat(member.age.stringValue())) // concat사용
.from(member)
.where(member.name.eq("siro"))
.fetch();
프로젝션
프로젝션을 사용할 경우 필드명이 다른 경우 as
를 사용해 필드명을 맞춰주면 된다.
예를 들자면, Member
엔티티의 값을 꼭 MemberDto
에 넣어야만 되는 것이 아니며, 다양한 DTO에 넣어줄 수 있다.
기본적으로 아래의 3가지 방식을 지원한다.
// 수정자(setter) 프로젝션
List<MemberDto> memberDtos = queryFactory
.select(Projections.bean(MemberDto.class, member.name, member.age))
.from(member)
.fetch();
// 필드 프로젝션
List<MemberDto> memberDtos = queryFactory
.select(Projections.fields(MemberDto.class, member.name, member.age))
.from(member)
.fetch();
// 생성자 프로젝션
List<MemberDto> memberDtos = queryFactory
.select(Projections.constructor(MemberDto.class, member.name, member.age))
.from(member)
.fetch();
추가로 @QueryProjection
을 생성자에 추가하면 DTO
도 QClass
로 컴파일되어 더 쉽게 사용할 수 있다.
@Getter
public class MemberDto {
private Long id;
private String name;
private Integer age;
public MemberDto() {}
@QueryProjection
public MemberDto(Long id, String name) {
this.id = id;
this.name = name;
}
@QueryProjection
public MemberDto(String name, Integer age) {
this.name = name;
this.age = age;
}
@QueryProjection
public MemberDto(Long id, String name, Integer age) {
this.id = id;
this.name = name;
this.age = age;
}
public static MemberDto createMemberDto(Long id, String name, Integer age) {
return new MemberDto(id, name, age);
}
public static MemberDto createMemberDto(Long id, String name) {
return new MemberDto(id, name);
}
public static MemberDto createMemberDto(String name, Integer age) {
return new MemberDto(name, age);
}
}
// @QueryProjection 프로젝션
List<MemberDto> memberDtos = queryFactory
.select(new QMemberDto(member.name, member.age))
.from(member)
.fetch();
@QueryProjection
과 생성자 프로젝션
은 서로 일장일단이 존재한다.
- @QueryProjection
- 장점 - 프로젝션시 발생하는 에러를 컴파일타임에 잡을 수 있다
- 장점 - 코드가 약간 더 직관적이다
- 단점 - DTO에 Querydsl 로직이 들어간다 (DTO가 Querydsl을 의존한다)
- 생성자 프로젝션
- 장점 - DTO가 Querydsl을 의존하지 않는다
- 단점 - 런타임 에러가 발생할 수 있는 가능성이 존재한다
동적쿼리
where절
에 null
을 반환하면 해당 검색조건이 무시되기 때문에 동적쿼리를 작성하기가 편리하다.
외에 BooleanBuilder
를 사용한 방법도 존재하는데 별로 선호하지 않으므로 패스.
List<Member> members = queryFactory
.selectFrom(member)
.where(allEq("siro", null))
.fetch();
// 계속해서 재사용할 수 있다는 큰 장점이 있다.
private BooleanExpression nameEq(String name) {
return Objects.isNull(name) ? null : member.name.eq(name);
}
private BooleanExpression ageEq(Integer age) {
return Objects.isNull(age) ? null : member.age.eq(age);
}
private BooleanExpression allEq(String name, Integer age) {
return nameEq(name).and(ageEq(age));
}
벌크연산
벌크연산의 경우 영속성 컨텍스트를 무시하고 DB에 직접 쿼리하기 때문에 영속성 컨텍스트와 실제 DB의 데이터 정합성이 깨질 수 있다.
따라서 벌크연산을 가장 먼저 실행하거나, 후순위에 실행된다면 영속성 컨텍스트를 개발자가 직접 관리해야한다.
벌크연산은 이 쿼리에 영향받은 row 개수
를 long
으로 반환한다.
혹은 쿼리 메서드에 @Modifying
을 추가하여 영속성 컨텍스트를 관리할 수 있다.
잦은 업데이트가 발생하는 경우 엔티티 클래스에 @DynamicUpdate
를 붙여 관리할 수도 있다.
다만 @Modifying
와 @DynamicUpdate
는 잘 알아보고 사용해야 한다.
queryFactory
.update(member)
.set(member.age, 100)
.where(member.age.lt(35))
.execute();
queryFactory
.delete(member)
.where(member.age.eq(29))
.execute();
DB 함수 사용
org.hibernate.dialect.Dialect
의 DB벤더 별 확장 클래스를 찾아서 (H2Dialect
같은 것) 사용하고자 하는 함수가 등록돼있는지 확인하고, 사용하려는 함수가 없는 경우(커스텀 함수 등) 해당 확장 클래스를 한번 더 확장하여 함수를 등록한 후 확장한 클래스를 설정파일에 등록해야한다.
queryFactory
.select(Expressions.stringTemplate("function('replace', {0}, {1}, {2})", member.name, "s", "S"))
.from(member)
.fetch();
모든 DB 벤더가 지원하는 ANSI 표준에 있는 기본적인 함수들은 이미 Hibernate
가 지원하므로, stringTemplate
을 직접 사용하지 않아도 된다.
queryFactory
.select(member.name)
.from(member)
.where(member.name.eq(member.name.lower()))
.fetch();