프로젝션
프로젝션은 select절에 무엇을 가져올지 대상을 지정하는 것을 프로젝션이라고 한다.
프로젝션 대상이 하나인 경우
@Test
public void oneProjection(){
List<String> members = queryFactory
.select(member.username)
.from(member)
.fetch();
for (String s : members){
System.out.println("s = "+s);
}
}
이 경우에는 프로젝션 대상이 하나이기 때문에 타입을 명확하게 지정할 수 있다.
프로젝션 대상이 둘 이상이면? 이전에 봤던 Tuple이나 DTO로 조회해야 한다.
튜플로 조회
‘com.querydsl.core.Tuple’
@Test
public void tupleProjection(){
List<Tuple> members = queryFactory
.select(member.username, member.age)
.from(member)
.fetch();
for (Tuple tuple : members) {
String username = tuple.get(member.username);
Integer age = tuple.get(member.age);
System.out.println("username = " + username + ", age = "+ age);
}
}
프로젝션 대상이 둘 이상일 때 사용하는 방법이다.
데이터를 가져올 땐 각 튜플마다 tuple.get()을 사용하여 해당하는 속성을 지정하면 된다.
이 Tuple은 com.querydsl.core에 있는데 repository계층 안에서 사용하는 것은 괜찮지만 service, controller계층까지 넘어가서 사용하는 것은 좋지 않다고 한다. 하부 구현 기술 즉, JPA나 Querydsl을 사용한다는 것을 비즈니스 로직에서 알면 좋은 구조가 아닌 것처럼 서로 알지 못하게 의존성을 낮추는 것이 좋은 설계이기 때문이다. 그래서 데이터를 이동할 땐 DTO로 바꿔서 service나 controller에 넘겨주는 것이 좋다.
DTO 조회(Querydsl Bean 생성)
기존 방식
@Test
public void findDtoByJPQL(){
String jpqlString = "select new com.seungh1024.dto.MemberDto(m.username, m.age) from Member m ";
List<MemberDto> result = em.createQuery(jpqlString, MemberDto.class).getResultList();
for(MemberDto memberDto :result){
System.out.println("memberDto = " + memberDto);
}
}
JPQL의 경우 Member와 MemberDto타입이 맞지 않아 생성자로 생성하듯이 만들어줘야 한다. 그래서 new를 사용하는데 그 뒤에 패키지 경로까지 다 맞춰서 입력해야하는 번거로움이 있다. 생성자 방식만 지원하여 setter를 활용하는 방법은 사용하지 못한다.
하지만 DTO를 사용하면 다음과 같이 3가지 방법으로 조회가 가능하다.
1. setter로 조회하기
@Test
public void findDtoBySetter(){
List<MemberDto> members = queryFactory
.select(Projections.bean(MemberDto.class, member.username, member.age))
.from(member)
.fetch();
for(MemberDto memberDto : members){
System.out.println(memberDto);
}
}
이 방법의 주의할 점은 DTO에 기본 생성자가 있어야 한다. 이는 Querydsl이 우선 MemberDto객체를 만들고 값을 setter로 넣어주는 것이기 때문이다. com.querydsl.core.type.Proejctions를 사용하며 bean()이라는 것을 사용하는데 setter로 데이터를 넣어주는 것이다. 파라미터로는 첫 번째로 결과로 받을 타입을 지정해 줘야 하고 그 이후에는 꺼내올 값들을 나열해 주면 된다. 꺼내올 값들은 당연히 DTO에 해당하는 값이어야 한다.
2. 필드로 조회하기
@Test
public void findDtoByField(){
List<MemberDto> members = queryFactory
.select(Projections.fields(MemberDto.class, member.username, member.age))
.from(member)
.fetch();
for(MemberDto memberDto : members){
System.out.println(memberDto);
}
}
위의 코드와 다른 것은 Projections.bean() → Proejctions.fields()로 바뀐 것이다. 얘는 setter로 값을 넣는 것이 아니라서 DTO에 setter가 없어도 문제가 생기지 않는다. 필드 값에 바로 넣어버리는 형태이다. 이 방법도 마찬가지로 MemberDto를 생성하고 필드에 주입하는 것이기 때문에 기본 생성자는 필요하다.
3. 생성자로 조회하기
@Test
public void findDtoByConstructor(){
List<MemberDto> members = queryFactory
.select(Projections.constructor(MemberDto.class, member.username, member.age))
.from(member)
.fetch();
for(MemberDto memberDto : members){
System.out.println(memberDto);
}
}
마찬가지로 Projections.constructor()로 변경만 하면 된다. 얘는 생성자로 생성하기 때문에 각 파라미터의 타입이 입력으로 들어가는 타입과 일치해야한다. 그리고 생성자로 생성하기 때문에 기본 생성자, setter가 없어도 된다.
DTO 조회 사용시 발생할 수 있는 문제
DTO의 필드명과 Entity의 필드 명이 다르면 fields()로 조회 시 DTO에 값이 제대로 주입되지 않는다.
아래는 DTO의 필드명을 다르게 만든 UserDto를 가지고 테스트를 한 것이다.
@Test
public void findUserDto(){
List<UserDto> members = queryFactory
.select(Projections.fields(UserDto.class, member.username, member.age))
.from(member)
.fetch();
for(UserDto userDto : members){
System.out.println(userDto);
}
}
username대신 name이라는 필드명을 사용해서 일치하지 않아 값이 전부 null인 것을 볼 수 있다.
setter로 조회하는 것도 마찬가지이다. 하지만 생성자의 경우는 문제가 없었다. 이때 해결 방법은 별칭을 붙여서 이름을 맞춰주면 된다.
@Test
public void findUserDto(){
List<UserDto> members = queryFactory
.select(Projections.fields(UserDto.class, member.username.as("name"), member.age))
.from(member)
.fetch();
for(UserDto userDto : members){
System.out.println(userDto);
}
}
아래와 같이 필드값을 서브쿼리로 뽑아올 때도 비슷하게 별칭을 넣어서 사용할 수 있다.
@Test
public void findUserDto(){
QMember subMember = new QMember("subMember");
List<UserDto> members = queryFactory
.select(Projections.fields(UserDto.class,
member.username.as("name"),
ExpressionUtils.as(
JPAExpressions
.select(subMember.age.max())
.from(subMember),"age")
))
.from(member)
.fetch();
for(UserDto userDto : members){
System.out.println(userDto);
}
}
ExpressionsUtils.as()로 앞의 파라미터에 들어가는 어떤 값에 대해 별칭을 붙이는 것이다. 서브쿼리는 as를 사용하지 못하고 이렇게만 별칭을 사용할 수 있다.
@QueryProjection를 사용하여 조회
가장 많이 사용하는 방법이다. 하지만 단점도 존재한다.
public class MemberDto {
private String username;
private int age;
public MemberDto(){};
@QueryProjection
public MemberDto(String username, int age){
this.username = username;
this.age = age;
}
}
얘는 DTO에 @QueryProjection이라는 어노테이션을 달아주고 gradle → other → complieQuerydsl을 더블클릭하여 실행하면(안되면 바로 위의 cmoplieJava 실행) DTO도 Q-Type으로 생성해 준다.
@Test
public void findDtoByQueryProjection(){
List<MemberDto> members = queryFactory
.select(new QMemberDto(member.username, member.age))
.from(member)
.fetch();
for (MemberDto memberDto : members){
System.out.println(memberDto);
}
}
보면 selct()에 new QMemberDto로 Q-Type을 사용하여 생성해 주고 조회할 필드를 넣어준다. 이 방법의 장점은 필드의 타입이 맞지 않으면 아래와 같이 컴파일 타임에 오류를 발생해 준다.
이전 생성자, setter 등을 사용하면 런타임에 오류가 발생하는 문제가 있어 @QueryProjection을 사용하면 해결이 되는 것이다. 또한 new QMemberDto에 뭐가 들어갈지 모른다면 맥기준 command + p를 누르면 어떤 필드가 들어가야 하는지도 다 보여주는 편리함이 있다.
하지만 얘도 단점이 있다. Q-Type 파일을 생성해줘야 하는 단점이 있다. 또 하나의 문제는 아키텍처에 관련된 문제인데 DTO에 @QueryProjection을 사용하며 Querydsl에 의존성을 가지게 되는 것이다. 이 DTO는 repository, service, controller에서 전부 사용될 텐데 Querydsl에 의존성을 가지게 되는 것이 안 좋은 것이다. 설계 방식에 따라서 결정하는 방법이 다르다고 한다. 실용성을 중시하면 @QueryProejction을, 의존성 관리가 중요하다면 Projections방식을 조금 불편해도 사용한다고 한다.
동적 쿼리
동적 쿼리는 BooleanBuilder, Where 다중 파라미터를 사용하는 두 가지 방법이 있다.
BooleanBuilder
BooleanBuilder라는 것을 사용하여 쿼리에 들어갈 조건을 동적으로 생성하여 넣는 방법이다.
@Test
public void booleanBuilder(){
String usernameParam = "member1";
Integer ageParam = 10;
List<Member> members = searchMember1(usernameParam,ageParam);
Assertions.assertThat(members.size()).isEqualTo(1);
}
private List<Member> searchMember1(String usernameParam, Integer ageParam) {
BooleanBuilder builder = new BooleanBuilder();
if(usernameParam != null){
builder.and(member.username.eq(usernameParam));
}
if(ageParam != null){
builder.and(member.age.eq(ageParam));
}
return queryFactory
.selectFrom(member)
.where(builder)
.fetch();
}
BooleanBuilder 객체를 생성하고 각 파라미터가 null인지 체크하고 아니라면 builder.and()를 사용하여 조건들을 붙여나가는 것이다. 완성된 조건을 where절에 추가하면 member.username.eq(usernameParam).and(member.age.eq(ageParam)과 같은 형태이다. 그래서 builder.and()로 추가적인 조건을 더 붙여서 넣을 수도 있다.
만약 위의 ageParam과 같은 파라미터에 null을 넣는다면?
이렇게 builder에 조건이 추가되지 않아 ageParam 조건은 없이 쿼리가 나간 것을 볼 수 있다.
Where 다중 파라미터(코드가 깔끔하게 나온다)
@Test
public void whereParam(){
String usernameParam = "member1";
Integer ageParam = null;
List<Member> members = searchMember2(usernameParam,ageParam);
Assertions.assertThat(members.size()).isEqualTo(1);
}
private List<Member> searchMember2(String usernameParam, Integer ageParam) {
return queryFactory
.selectFrom(member)
.where(usernameEq(usernameParam), ageEq(ageParam))
.fetch();
}
private BooleanExpression usernameEq(String usernameParam) {
if(usernameParam == null){
return null;
}else{
return member.username.eq(usernameParam);
}
}
private BooleanExpression ageEq(Integer ageParam) {
return ageParam != null ? member.age.eq(ageParam) :null;
}
where() 절에 조건들을 넣는 것이다. 메서드를 생성하여 null체크를 하고 통과하면 필요한 조건들을 리턴해주는 방법이다. 얼핏 보면 코드가 더 길어지고 귀찮아 보이지만 다른 사람들이 보기에는 where() 절 안의 메서드들을 보고 어떤 기능인지 파악하기 때문에 코드를 이해하기에 더 편한 것이다. BooleanBuilder를 사용하면 위에서부터 복잡한 코드들을 계속 읽어가야 하기 때문에 다른 사람이 쓴 코드를 이해하기 힘들 수 있다.(내가 작성한 게 아니니까)
추가로 null체크를 통과 못하면 null을 리턴하는데 where() 절에 null이 들어가면 자동으로 무시한다.
결과를 보면 ageParam에 대한 조건은 없이 쿼리가 나간 것을 확인할 수 있다.
private List<Member> searchMember2(String usernameParam, Integer ageParam) {
return queryFactory
.selectFrom(member)
// .where(usernameEq(usernameParam), ageEq(ageParam))
.where(allEq(usernameParam,ageParam))
.fetch();
}
...
private BooleanExpression allEq(String usernameParam, Integer ageParam){
return usernameEq(usernameParam).and(ageEq(ageParam));
}
이 방법의 좋은 점은 메서드로 분리할 수 있기 때문에 이렇게 조립을 할 수 있다. 즉 메서드를 다른 쿼리에도 재활용할 수 있다. 또한 가독성도 확실히 좋아지는 것을 볼 수 있다.
자바를 사용했을 때의 이점을 확실히 챙겨가는 것이다.
수정, 삭제 배치 쿼리
쿼리 한 번으로 대용량 데이터 수정하기(벌크 연산이라고도 함)
하나만 업데이트할 때는 Entity를 수정하면 자동으로 업데이트 쿼리가 나간다. 이건 여러 데이터들을 공통적으로 수정할 때 사용하는 방법이다.
@Test
@Commit
public void bulkUpdate(){
long count = queryFactory
.update(member)
.set(member.username, "비회원")
.where(member.age.lt(20))
.execute();
}
이런 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 바로 쿼리를 날린다. 그래서 이게 데이터가 일치하지 않는 문제가 발생할 수 있다.
우선 위의 테스트를 실행하면 update() 쿼리가 날아가고 DB에 반영되는데
이렇게 20살 미만인 사용자 이름만 비회원으로 잘 바뀌었다.
하지만 이전에 7명의 사용자들을 생성하면서 영속성 컨텍스트에는 update()쿼리가 나가기 전의 상태인 member1으로 이름이 설정되어 있다. 벌크 연산의 경우 영속성 컨텍스트를 무시하니 DB와 영속성 컨텍스트의 상태가 다른 것이다.
그럼 벌크 연산 후에 조회 쿼리를 날리면 어떻게 될까?
@Test
@Commit
public void bulkUpdate(){
long count = queryFactory
.update(member)
.set(member.username, "비회원")
.where(member.age.lt(20))
.execute();
List<Member> result = queryFactory
.selectFrom(member)
.fetch();
for(Member member1 : result){
System.out.println("member1 = " + member1);
}
}
벌크 연산 후 DB에서 조회를 해도 JPA는 조회한 데이터가 이미 영속성 컨텍스트에 있으면 해당 조회 데이터는 버린다. 즉 영속성 컨텍스트가 우선권을 가진다. 그래서 위와 같이 조회 쿼리는 영속성 컨텍스트에서 가져와서 username = member1이지만, DB는 username = 비회원이 되는 것이다.
이 문제를 해결하는 방법은 간단하다. em.flush(), em.clear()로 영속성 컨텍스트를 DB에 반영하고 초기화를 하는 것이다.
업데이트할 때 전체 값을 얼만큼 더해주거나 빼주는 작업이 많이 일어난다. 그 경우 아래처럼 하면 된다.
@Test
public void bulkAdd(){
long count = queryFactory
.update(member)
.set(member.age, member.age.add(1))
.execute();
}
곱셈은 add() 대신 multiply()를 사용하면 된다.
쿼리 한 번으로 삭제하기(벌크 삭제)
@Test
@Commit
public void bulkDelete(){
long count = queryFactory
.delete(member)
.where(member.age.gt(18))
.execute();
List<Member> result = queryFactory
.selectFrom(member)
.fetch();
for(Member member1 : result){
System.out.println("member1 = " + member1);
}
}
얘도 마찬가지로 영속성 컨텍스트의 정보가 반영되지 않아 DB에만 삭제된다. 하지만 삭제의 경우 삭제 이후 쿼리문을 날려 조회하면 전체 데이터가 조회되지 않고 아래처럼 삭제되어 조회된다.
해당 이유는 DB에서 조회했을 때 member1에 해당하는 Entity는 영속성 컨텍스트에 존재한다. 그래서 DB정보 대신 영속성 컨텍스트 데이터를 사용하고 DB 조회 데이터는 버린다. 하지만 다른 데이터들은 DB에서 삭제되었기 때문에 해당 데이터에 대한 영속성 컨텍스트 데이터가 있는지 알 수 없다. 즉 DB에서 조회한 데이터가 영속성 컨텍스트와 비교할 수 없기 때문에 그대로 DB정보를 사용하는 것이고 비교할 수 있는 member1만 제대로 출력되는 것이다. 조회를 하여 비교를 할 수 있을 때 우선순위가 영속성 컨텍스트인 것이다.
SQL function 호출
SQL function은 JPA와 같이 Dialect에 등록된 내용만 호출할 수 있다.
@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);
}
}
@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);
}
}
@Test
public void sqlFunction3(){
List<String> result = queryFactory
.select(member.username)
.from(member)
.where(member.username.eq(member.username.lower()))
.fetch();
for (String s :result){
System.out.println("s = "+s);
}
}
sqlFunction2와 sqlFunction3는 같은 기능이다. 대부분의 SQL에서 공통적으로 사용되는 기능들은 내장되어 있는 것이 많다. 그래서 함수 호출을 하지 않고 lower()와 같이 미리 구현된 함수를 사용할 수 있다.
'Java > Querydsl' 카테고리의 다른 글
[Querydsl] Spring Data JPA + Querydsl , Pageable(페이징) (0) | 2023.07.09 |
---|---|
[Querydsl] 순수 JPA Repository + Querydsl (0) | 2023.07.09 |
[Querydsl] Querydsl 기본문법 (0) | 2023.07.08 |
[Querydsl] Querdydsl 설정하기 (0) | 2023.07.03 |