[인강노트 - querydsl] 5. 중급 문법

봄도둑·2023년 1월 2일
2

김영한님의 실전! querydsl 강의 내용을 정리한 노트입니다. 블로그에 있는 자료를 사용하실 때에는 꼭 김영한님 강의 링크를 남겨주세요!

1. 프로젝션과 결과 반환 -기본

  • 프로젝션 : select절에 무엇을 가져올지 지정해주는 것
  • 일단 select절에 들어가는 녀석의 단일 타입이 fetch()를 통해 리턴됨 → member의 username만 칼럼으로 뽑아와보자 → String 타입에 맞춘 List가 반환됨
List<String> username = queryFactory
                .select(member.username)
                .from(member)
                .fetch();
  • 프로젝션 대상이 하나면 위의 예시처럼 명확하게 지정할 수 있음
  • 프로젝션의 대상이 둘이 상이면 tuple 혹은 DTO로 조회
  • 튜플(tuple) : querydsl이 프로젝션이 여러 개일 때 조회를 위해 만들어 놓은 객체 → 한 번에 여러 개 담아서 막 꺼낼 수 있는 것을 튜플이라고 부름
@Test
public void tupleProjection() {
    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은 com.querydsl.core의 것임 → tuple을 레포지토리 계층에서 사용하는 것은 문제가 없지만, 그 이상의 레벨에서 사용할 경우 좋은 설계는 아님(controller 등등…)
  • 하부 구현 기술에 앞단에서 알면 좋지 않음 → 예를 들어 controller에서 우리 비즈니스 로직 구현을 JPA 혹은 Mybatis로 했다는 것을 알 필요가 없음 → 하부 기술을 다른 것으로 바꾸더라도 controller가 영향 받을 일이 없음 → 바깥 계층으로 던져줄 때는 tuple 말고 DTO로 던져주자

2. 프로젝션과 결과 반환 -DTO 조회

  • 먼저 데이터를 담을 DTO를 하나 만들자
@Data
@NoArgsConstructor //기본 생성자는 꼭 있어야 함
public class MemberDto {

    private String username;
    private int age;

    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
  • 먼저 순수 JPA에서 DTO 조회를 어떻게 하는지 간단히 보고 가자
@Test
public void findDtoByJPQL() {
    //new 해서 패키지명 다 적어줌 -> 마치 생성자를 호출하듯이 사용, JPQL에서 제공하는 new operation 문법
    //이렇게 단순한 쿼리문 하나 짜는데도 좀 별로라는 게 느껴짐
    List<MemberDto> resultList = em.createQuery("select new study.querydsl.dto.MemberDto(m.username, m.age) from Member m", MemberDto.class)
            .getResultList();
    
    for (MemberDto memberDto : resultList) {
        System.out.println("memberDto : " + memberDto);
    }
}
  • 순수 JPA에서 DTO를 조회할 때는 new 명령어를 사용

  • DTO의 package 이름을 다 적어줘야 해서 지저분함 + 생성자 처럼 사용하는 방식만 지원

    • setter를 이용하거나 필드에 값을 주입해주는 게 안됨
  • querydsl은 이러한 한계를 극복한 방법들을 제시함

    • 프로퍼티 접근 - setter
    • 필드 직접 접근
    • 생성자 사용
  • 프로퍼티 접근 - setter

    @Test
    public void findDtoBySetter() {
        //Projections.bean 안에 반환하고자 하는 타입, 프로젝션할 항목들을 쭉 써주면 됨
        List<MemberDto> result = queryFactory
                .select(Projections.bean(MemberDto.class,
                        member.username, member.age))
                .from(member)
                .fetch();
    
        for (MemberDto memberDto : result) {
            System.out.println("memberDto : " + memberDto);
        }
    }
    • 이 때 DTO 클래스에 기본 생성자가 없으면 다음과 같은 exception이 발생 → com.querydsl.core.types.ExpressionException: study.querydsl.dto.MemberDto
    • querydsl이 먼저 기본 생성자를 통해 dto를 만들고 값을 set 해주는데 기본 생성자가 없으면 querydsl이 데이터를 담을 dto를 만들지 못하기 때문에 exception 발생
    • setter의 이름이 일치해야 함
  • 필드 직접 접근

    @Test
    public void findDtoByField() {
        List<MemberDto> result = queryFactory
                .select(Projections.fields(MemberDto.class,
                        member.username, member.age))
                .from(member)
                .fetch();
    
        for (MemberDto memberDto : result) {
            System.out.println("memberDto : " + memberDto);
        }
    }
    • 필드 접근 시 getter, setter가 dto 내부에 정의되어 있지 않아도 사용 가능 → dto의 필드에 값을 바로 뿌려줌

    • 필드명이 일치해야 함 → 에러는 나지 않지만 빈 값이 들어올 수 있음 → UserDto의 name이 null로 들어오는 것을 볼 수 있음

      @Data
      @NoArgsConstructor
      public class UserDto {
      
          private String name;
          private int age;
      
          public UserDto(String name, int age) {
              this.name = name;
              this.age = age;
          }
      }
      
      @Test
      public void findUserDto() {
          //Projections.bean 안에 반환하고자 하는 타입, 프로젝션할 항목들을 쭉 써주면 됨
          List<UserDto> result = queryFactory
                  .select(Projections.fields(UserDto.class,
                          member.username, member.age))
                  .from(member)
                  .fetch();
      
          for (UserDto userDto : result) {
              System.out.println("userDto : " + userDto);
          }
      }

    • 만약 필드명이 다를 경우 어떻게 처리하면 될까? → as를 붙여줘서 dto의 속성명으로 입력해주면 됨

        List<UserDto> result = queryFactory
                      .select(Projections.fields(UserDto.class,
                              member.username.as("name"),
                              member.age))
                      .from(member)
                      .fetch();
  • 생성자 사용

    @Test
    public void findDtoByConstructor() {
        List<MemberDto> result = queryFactory
                .select(Projections.constructor(MemberDto.class,
                        member.username, member.age))
                .from(member)
                .fetch();
    
        for (MemberDto memberDto : result) {
            System.out.println("memberDto : " + memberDto);
        }
    }
    • 생성자를 사용할 경우 타입이 일치해야 함 → member.username과 MemberDto.username의 타입, member.age와 MemberDto.age의 타입이 일치해야 함
  • 결국 Projections의 뒷부분을 어떻게 쓰느냐임 → 쓰는 방법은 같지만 실제 dto를 생성해서 값을 넣는 과정이 조금씩 다르다는 것


3. 프로젝션과 결과 반환 - @QueryProjection

  • 이게 제일 깔끔한 해결책이긴 한데… 약간의 단점이 있음
@Data
@NoArgsConstructor //기본 생성자는 꼭 있어야 함
public class MemberDto {

    private String username;
    private int age;
    
    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
  • Dto의 생성자에 @QueryProjection 만 달아주면 됨 → gradle 컴파일을 해주면 QMember처럼 QMemberDto가 생긴 것을 볼 수 있음
@Test
public void findDtoByQueryProjection() {
    List<MemberDto> result = queryFactory
            .select(new QMemberDto(member.username, member.age))
            .from(member)
            .fetch();

    for (MemberDto memberDto : result) {
        System.out.println("memberDto : " + memberDto);
    }
}
  • 생성자를 사용해 QMemberDto를 만들어주기만 하면 됨
  • 그럼 constructor와 차이는 뭐야??
    • constructor는 컴파일 오류를 잡아내지 못함

      List<MemberDto> result = queryFactory
                      .select(Projections.constructor(MemberDto.class,
                              member.username,
                              member.age,
                              member.id))
                      .from(member)
                      .fetch();
    • 위의 예시처럼 member.id는 프로퍼티로도 없고 생성자로 받지도 않는데 실행이 정상적으로 됨 → 즉, 컴파일 시점에서 이 녀석이 잘못 되었다는 것을 잡아낼 수 없음 → 런타임 시점에서야 문제를 찾을 수 있음

    • 반면 @QueryProjection 은 잘못된 인자가 넘어오면 컴파일 시점에서 문제를 잡아냄

    • 실제 호출하더라도 생성자가 그대로 호출되는 것을 보장해줌

  • 단점은 Q 타입을 생성해야 한다는 것 + 아키텍처 관점(의존관계)에서 문제가 있음
    • Dto는 기존에는 querydsl을 아예 몰랐음 → @QueryProjection 어노테이션이 추가되면서 dto가 querydsl에 대해 의존성을 가지게 됨
    • 만약, querydsl을 더 이상 사용하지 않게 된다면 dto가 바뀌게 되는 참사가 일어날 수 있음
    • dto는 여러 레이어 레벨에 걸쳐 돌아다님(service에서도 쓰이고, controller에서도 쓰이고, api 전송 및 응답 시에도 사용되기도 함) → dto가 흘러 갈텐데 dto 안에 querydsl이 들어 있는 것 → dto 자체가 순수하지 않게 됨(querydsl에 대해 의존성을 가짐) ⇒ 이것이 queryProjection 사용에 따른 단점
  • 아키텍처 설계 방향에 따라 dto를 순수하게 가져가고자 한다면 @QueryProjection 을 사용하기 힘듦. 반면 편의성 및 의존성을 감안하고 쓴다면 @QueryProjection 은 꽤 매력적인 선택지

4. 동적 쿼리 - BooleanBuilder 사용

  • 동적 쿼리를 해결하는 두 가지 방식
    • BooleanBuilder
    • Where 다음 다중 파라미터 사용
  • 코드로 바로 보자!
@Test
public void dynamicQuery_BooleanBuilder() {
    String usernameParam = "member1";
    Integer ageParam = 10;

    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) {
        //usernameCond이 null이 아니면 builder 조건에 username이 usernameCond과 같은지 판별 조건 추가
        builder.and(member.username.eq(usernameCond));
    }

    if (ageCond != null) {
        //ageCond가 null이 아니면 builder에 조건 추가
        builder.and(member.age.eq(ageCond));
    }

    return queryFactory
            .selectFrom(member)
            .where(builder)
            .fetch();
}
  • 핵심은 searchMember1의 BooleanBuilder!
  • 우리가 원하는 조건에 따라 BooleanBuilder에 조건을 추가할 수 있고, 최종적으로 queryFactory로 날려줄 때 where에 파라미터로 BooleanBuilder만 넘겨주면 됨 → 세상 너무 편한데?????
  • 쿼리가 어떻게 날아갔는지 보자 → 파라미터 2개가 바인딩되어서 날아감 → 이거 사기인듯…???
  • ageParam을 null로 해서 넘겨보자 → Integer ageParam = null;
  • 만약 특정 조건이 반드시 들어가야 하는 필수 조건이라면 BooleanBuilder에 초기값을 세팅할 수 있음
BooleanBuilder builder = new BooleanBuilder(member.username.eq(usernameCond));
  • BooleanBuilder는 or로도 조립할 수 있음

5. 동적 쿼리 - Where 다중 파라미터 사용

  • 김영한 선생님이 실무에서 가장 좋아하는 방법 → BooleanBuilder도 꽤 괜찮았던 것 같은데…. ⇒ 그런데 예제 코드를 보니까 확실히 where 다중 파라미터가 가독성이 훨씬 좋음
  • 코드로 바로 보자!
private List<Member> searchMember2(String usernameCond, Integer ageCond) {
    // 만약 usernameEq가 null을 반환했다면 where절은 이렇게 되겠지?
    //where(null, age(ageCond))
    //그런데 기본 문법에서 살펴 봤듯이 where에 null이 넘어가면 null 조건은 무시해버림
    //null에 대해서는 아무것도 수행하지 않기 때문에 동적 쿼리가 만들어짐!
    return queryFactory
            .selectFrom(member)
            .where(usernameEq(usernameCond), ageEq(ageCond))
            .fetch();
}
private Predicate usernameEq(String usernameCond) {
    if (usernameCond == null) {
        return null;
    }

    return member.username.eq(usernameCond);
    //이렇게 간단한 조건은 삼항 연산자를 사용하면 편리함
//        return usernameCond != null ? member.username.eq(usernameCond) : null;
}

private Predicate ageEq(Integer ageCond) {
    return ageCond != null ? member.age.eq(ageCond) : null;
}
  • where절의 다중 파라미터 사용의 핵심은 where에 null이 인자로 넘어갔을 경우 아무 연산 처리리를 하지 않는다!
  • queryFactory의 where절에 들어가는 메소드명만 잘 읽으면 동적 쿼리 동작이 무엇을 하는지 유추할 수 있음 → 코드 가독성이 훨씬 더 올라감
  • booleanBuilder는 조건 분기 코드를 쭉 보다가 가장 마지막에 동적 쿼리의 동작이 나옴 → 어떤 동작을 수행하는지 직관적으로 알아보기 힘듦
  • 그런데 이건 개인적인 의문인데, 메소드가 null을 return 해주는 것이 과연 좋은 것일까..??? 라는 의문
  • usernameEq와 ageEq가 메소드로 빠짐 → 한 방에 조립해서 날려줄 수 있음 → 이 때 usernameEq와 ageEq의 리턴 타입은 BooleanExpression으로 해줘야 함
private List<Member> searchMember2(String usernameCond, Integer ageCond) {
    return queryFactory
            .selectFrom(member)
            .where(allEq(usernameCond, ageCond))
            .fetch();
}

private Predicate allEq(String usernameCond, Integer ageCond) {
		//이렇게 and로 묶어서 한 방에 조건을 쏠 수 있음
    return usernameEq(usernameCond).and(ageEq(ageCond));
}

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;
}
  • 메소드로 빼냈다는 건 조건을 우리가 원하는대로 조립을 할 수 있다는 것 → sql은 큰 개념 하나로 움직이기 때문에 조립을 한다는 개념이 없음 → 쿼리를 자바스럽게 날릴 수 있다는 것
  • 메소드로 빼냈기 때문에 다른 메소드에서 다른 녀석들이랑 결합해서 사용할 수 있음 → 코드의 재사용성이 올라감
  • 실무할 때는 이러한 형태로 빼놓으면 사용할 곳이 많음!
  • 물론 null 처리는 기본으로 하고!
  • 이러한 부분을 보면 BooleanBuilder보다 훨씬 괜찮은 듯?ㅋㅋㅋㅋ

6. 수정, 삭제 벌크 연산

  • 수정, 삭제를 한 번에 처리할 수 있는 벌크 연산에 대해 알아보자 → 쿼리 한 번으로 대량 데이터 수정
  • 변경 감지를 해서 수정이 이뤄지는 경우, 일반적인 JPA는 엔티티 개별로 인식되기 때문에 쿼리가 개별로 하나하나 다 나가게 됨
  • 한 번에 한 쿼리로 처리해야 하는 경우가 있음
  • 코드로 바로 보자!
@Test
@Commit
public void bulkUpdate() {
    //회원의 나이가 20살 이하면 회원 이름을 다 "비회원"으로 변경하기 -> member1, member2가 변경
    //처리한 결과로 리턴되는 long 타입(count)는 변경이 완료된 row의 수를 의미
    long count = queryFactory
            .update(member)
            .set(member.username, "비회원")
            .where(member.age.loe(20))
            .execute();
}
  • 결과는 이렇게 나옴 → DB에 값은 바뀜!
  • 이렇게 코드를 짤 때 조심해야할 것이 있음
  • jpa는 기본적으로 영속성 컨텍스트에 엔티티들이 다 올라가 있음 →member1, 2, 3, 4 모두 영속성 컨텍스트에 올라가 있음
  • 벌크 연산은 영속성 컨텍스트를 무시하고 DB에 쿼리가 나감 → DB의 상태와 영속성 컨텍스트의 상태가 달라지게 됨
    • 이 상태에서 queryFactory.select로 값을 가져오게 되면 어떻게 될까?

      //영속성 컨텍스트 username : DB username
      //meber1 : member1
      //meber2 : member2
      //meber3 : member3
      //meber4 : member4
      
      long count = queryFactory
                  .update(member)
                  .set(member.username, "비회원")
                  .where(member.age.loe(20))
                  .execute();
      
      //meber1 : 비회원
      //meber2 : 비회원
      //meber3 : member3
      //meber4 : member4
      
      //지금 영속성 컨텍스트와 DB의 상태가 일치하지 않는 상태에서 DB를 조회하게 된다면?
      List<Member> result = queryFactory
                      .selectFrom(member)
                      .fetch();
      
      for (Member member : result) {
          System.out.println("member : " + member);
      }
    • JPA는 기본적으로 DB에서 가져온 값을 영속성 컨텍스트에 다시 넣어줌 → 그런데 영속성 컨텍스트와 DB의 상태가 일치하지 않은 상태에서 가져온 DB의 값을 다시 영속성 컨텍스트에 넣으려고 함 → 그런데 영속성 컨텍스트가 이미 값을 들고 있기 때문에 JPA는 DB에서 select 해온 값들을 버림 → DB에 업데이트 친 값들은 버려지게 되고 영속성 컨텍스트에 이전에 들고 있던 값들이 남아있게 됨 → DB에서 select를 해와도 영속성 컨텍스트가 항상 우선권을 가짐 → 값을 DB에 있는 것으로 엎어치지 않는 참사가 발생

    • 결과를 보자! → DB에는 값을 업데이트 쳤지만 영속성 컨텍스트의 값으로 출력되는 것을 확인할 수 있음

  • 그럼 영속성 컨텍스트와 DB의 상태 충돌에 대한 문제를 해결해보자
long count = queryFactory
            .update(member)
            .set(member.username, "비회원")
            .where(member.age.loe(20))
            .execute();

//맘 편하게 영속성 컨텍스트를 비워주자
em.flush();
em.clear();

List<Member> result = queryFactory
                .selectFrom(member)
                .fetch();
  • 벌크 연산이 나가는 순간 상태는 이미 어긋나기 때문에 영속성 컨텍스트를 비워주자
  • 벌크 연산 시 DB에 있는 값들을 n만큼 증가시키거나 곱해야할 경우가 있음 → 코드로 보자!
@Test
public void bulkAdd() {
    // 모든 회원의 나이를 한 살씩 더하기
    long count = queryFactory
            .update(member)
            .set(member.age, member.age.add(1))
//          .set(member.age, member.age.multiply(2)) 이건 곱하기
            .execute();
}

7. SQL function 호출하기

  • SQL function은 JPA와 같이 Dialect에 등록된 내용만 호출할 수 있음
  • 코드로 보자!
@Test
public void sqlFunction() {
    //member 라는 단어를 M으로 바꿔서 조회할 예정
    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);
    }
}
  • 그런데…. 동작을 안함ㅠㅠㅠㅠ

  • 지금 내가 사용하고 있는 DB는 MariaDB이고 MariaDB의 Dialect에 replace가 없다는 것. 한 번 살펴보러 가자!

  • MariaDBDialect는 MySQL5Dialect를 상속 받고 있고 → MySQL5Dialect는 MySQLDialect를 상속 받고 있음

  • 그런데 MySQLDialect에는 replace 함수가 등록되어 있지 않음 → 그러니 replace를 아무리 불러도 사용할 수 없고 오류가 발생했던 것

    • 단, 모든 DB에서 사용할 수 있는 공용적인 함수들은 기본적으로 내장하고 있음 → 대문자를 소문자로 바꿔주는 lower 같은 것들
profile
Java Spring 백엔드 개발자입니다. java 외에도 다양하고 흥미로운 언어와 프레임워크를 학습하는 것을 좋아합니다.

0개의 댓글