관리 메뉴

나구리의 개발공부기록

중급 문법, 프로젝션과 결과 반환(기본/DTO 조회/@QueryProjection), 동적 쿼리(BooleanBuilder사용/Where다중 파라미터 사용), 수정 및 삭제 벌크 연산, SQL function 호출하기 본문

인프런 - 스프링부트와 JPA실무 로드맵/실전! Querydsl

중급 문법, 프로젝션과 결과 반환(기본/DTO 조회/@QueryProjection), 동적 쿼리(BooleanBuilder사용/Where다중 파라미터 사용), 수정 및 삭제 벌크 연산, SQL function 호출하기

소소한나구리 2024. 10. 30. 11:42

출처 : 인프런 - 실전! Querydsl (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


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

** 프로젝션: select에 대상을 지정하는 것

1) 프로젝션 대상이 하나

  • 프로젝션 대상이 하나면 타입을 명확하게 지정할 수 있음
  • String뿐만 아니라 반환타입에 객체타입을 명확하게 지정할 수 있음
// 프로젝션 대상이 하나
@Test
void simpleProjection() {
    List<String> result = queryFactory
            .select(member.username)
            .from(member)
            .fetch();

    for (String s : result) {
        System.out.println("s = " + s);
    }
}

2) 튜플 조회

  • 프로젝션 대상이 둘 이상일 때 사용하는 방법 중 하나(또 다른 방법은 DTO로 조회 - 실무에서 주로 사용)
  • tuple의 값을 꺼낼때는 get()으로 select절에 입력한 프로젝션을 그대로 입력해주면 됨
  • 튜플은 com.querydsl.core.Tuple에 있기때문에 repository계층에서 사용하는 것은 괜찮을 수 있으나 service나 화면계층으로 넘거아는 것은 좋지 않은 설계임
  • repository 계층의 코드가 외부의 계층에 종속적이면 안좋은 설계인 것과 동일한 개념으로 이해하면 됨
// 프로젝션 대상이 둘 - tuple
@Test
void tupleProjection() {
    List<Tuple> result = queryFactory
            .select(member.username, member.age)
            .from(member)
            .fetch();

    for (Tuple tuple : result) {
        System.out.println("username) = " + tuple.get(member.username));
        System.out.println("age = " + tuple.get(member.age));
    }
}

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

1) DTO 생성

  • Member의 전체 필드가아닌 username과 age만 담는 클래스 생성
@Data
@NoArgsConstructor
public class MemberDto {

    private String username;
    private int age;

    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

2) DTO 직접 조회

(1) 순수 JPA에서 DTO조회 - new 연산자를 활용해야함

  • 과거 강의에서 계속 해왔던 JPQL로 DTO를 직접 조회하려면 마치 생성자를 입력하듯이 new 연산자를 사용해서 DTO가 속해있는 패키지명을 전부 입력해주고 인자값으로 필드명을 입력해주어야함
  • 패키지명을 전부 입력해줘야하는 것이 상당히 번거로우며 생성자 방식만 지원하기때문에 생성자를 꼭 생성해줘야함
// 프로젝션 대상이 둘 - DTO(JPQL쿼리, new 연산자를 적용해야함)
@Test
void findDtoByJPQL() {
    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);
    }
}

 

(2) Querydsl로 DTO 조회

  • 순수 JPA에서 DTO로 조회했을 때의 문제를 전부 해결하고 Projections의 .bean, .fields, .constructor를 활용하여 프로퍼티 접근, 필드 직접 접근, 생성자 접근 모두 가능함
  • 세터, 생성자를 모두 만들어서 브레이크포인트로 디버그를 찍어보면 bean()은 setter, fields()는 필드로 직접, constructor()는 생성자로 접근하여 값을 입력하는 것을 알 수 있음
  • 인자로 조회할 DTO클래스.class로 타입을 입력해 주어야 함
  • 실무에서는 아래에서 배울 @QueryProjection방식이 아니면 생성자 방식을 가장 많이 사용함
// 프로젝션 대상이 둘 - DTO(Querydsl)
@Test
void findDtoByQuerydsl() {
    // setter를 이용
    List<MemberDto> resultSetter = queryFactory
            .select(Projections.bean(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();

    for (MemberDto memberDto : resultSetter) {
        System.out.println("memberDto = " + memberDto);
    }

    // field에 직접 접근 - Q클래스와 DTO의 필드명이 동일
    List<MemberDto> resultFields = queryFactory
            .select(Projections.fields(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();

    for (MemberDto memberDto : resultFields) {
        System.out.println("memberDto = " + memberDto);
    }

    // 생성자 방식
    List<MemberDto> resultConstructor = queryFactory
            .select(Projections.constructor(MemberDto.class,
                    member.username,
                    member.age))
            .from(member)
            .fetch();

    for (MemberDto memberDto : resultConstructor) {
        System.out.println("memberDto = " + memberDto);
    }
}

 

(3) Querydsl - Q클래스와 DTO의 필드명이 다를 때(별칭이 다를 때)

  • UserDto라는 클래스에는 username이아니라 필드명이 name이라 필드명이 다르면 SQL문법처럼 필드명에 .as()로 타입으로 지정한 DTO의 필드를 별칭으로 지정하면 정상적으로 조회가 됨
  • 잘 사용하진 않지만 필드는 물론 서브쿼리에 별칭을 적용할 때에는 ExpressionUtils.as()를 활용하면 됨
  • 그러나 필드명에 별칭을 사용해야한다면 굳이 ExpressionUtils.as()를 사용할 필요는 없이 필드명에 바로 .as()를 사용하는것이 훨씬 깔끔함
// field에 직접 접근 - Q클래스와 DTO의 필드명이 다를 때
List<UserDto> resultFields2 = queryFactory
        .select(Projections.fields(UserDto.class,
                member.username.as("name"),
                member.age))
        .from(member)
        .fetch();

for (UserDto userDto : resultFields2) {
    System.out.println("memberDto = " + userDto);
}

// field에 직접 접근 - 필드나, 서브쿼리에 별칭을 적용
QMember memberSub = new QMember("memberSub");

List<UserDto> resultFields3 = queryFactory
        .select(Projections.fields(UserDto.class,
                // 필드에 별칭
                member.username.as("name"),
                // 서브쿼리에 별칭
                ExpressionUtils.as(JPAExpressions
                        .select(memberSub.age.max())
                        .from(memberSub), "age")
        ))
        .from(member)
        .fetch();

for (UserDto userDto : resultFields3) {
    System.out.println("memberDto = " + userDto);
}

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

1) 예제 코드

(1) DTO의 생성자에 @QueryProjection 추가

  • DTO의 생성자에 @QueryProjection 애노테이션을 작성 후 gradle - clean, build를 하면 DTO클래스도 Q파일이 생성됨
public class MemberDto {
    // ... 기존코드 생략  
  
    @QueryProjection // 생성자에 애노테이션 작성
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}

 

(2) 적용

  • DTO에 Querydsl 애노테이션을 작성해야하고 DTO까지 Q파일을 생성해야한다는 단점이 존재하지만 Q클래스를 new 연산자를 사용하여 쿼리를 입력하면 타입체크가 되기 때문에 안전하게 작성할 수 있음
  • 생성자 방식으로 DTO를 직접 조회하는 방식은 런타임 시점에 오류를 확인할 수 있지만 @QueryProjection을 적용한 방식은 생성자 방식과 비슷하지만 컴파일 시점에 오류를 잡을 수 있기 때문에 개발하기가 수월함
  • 아키텍처 설계를 할 때 DTO를 순수하게 유지하고 설계하는 프로젝트의 경우 DTO가 Querydsl을 의존을 하기때문에 적용하기가 어려울 수 있음
// @QueryProjection 적용
@Test
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);
    }
}

 

** distinct

  • Querydsl의 distinct는 JPQL과 동일기능으로 동일하게 사용할 수 있음
  • select(member.username).distinct() ... 과 같이 사용하면됨

4. 동적 쿼리 - BooleanBuilder 사용

1) 예제 코드

  • Querydsl의 동적쿼리를 해결하는 방법 중 BooleanBuilder()를 생성하여 해결하는 방법
  • 동적쿼리는 파라미터에 따라 쿼리의 조건이 달라져야하므로 메서드로 별도로 빼서 작성하고 BooleanBuilder()를 생성하여 참조변수를 선언
  • 자유롭게 작성이 가능하지만 기본적으로 각 파라미터의 값이 null이 아닐 때 조건이 발생되도록 작성하는 경우가 많으므로 if문으로 null이 아니면 builder에 메서드 체인으로 적용하려는 조건을 작성
  • 각 파라미터마다 조건의 작성이 모두 끝나면 builder를 where문의 인자로 입력해주면 동적쿼리가 완성됨
  • 아래의 예제에서는 파라미터의 값이 모두 일치하는 값을 조회하는 예제를 작성하였고 실행해보면 쿼리결과가 정상적으로 where문의 두 조건이 입력되며 둘중 파라미터의 값이 하나라도 null이면 해당 조건은 빠짐
  • BooleanBuilder를 생성할 때 인수값으로 조건을 입력하여 필수로 적용되게 할 수도 있음(예외 처리 필요)
// 동적쿼리 - BooleanBuilder
@Test
void dynamicQuery_BooleanBuilder() {
    String usernameParam = "member1";
    Integer ageParam = 10;

    List<Member> result = searchMember1(usernameParam, ageParam);
    assertThat(result.get(0))
            .extracting("username", "age")
            .containsExactly(usernameParam, ageParam);
}

// 동적쿼리 메서드
private List<Member> searchMember1(String usernameCond, Integer ageCond) {
    BooleanBuilder builder = new BooleanBuilder();  // 생성시 인수에 값을 입력하여 초기값을 설정할 수 있음

    if (usernameCond != null) { // usernameCond가 null이 아니면 .and(조건)이 입력
        builder.and(member.username.eq(usernameCond));
    }

    if (ageCond != null) {
        builder.and(member.age.eq(ageCond));
    }
    return queryFactory
            .selectFrom(member)
            .where(builder) // where에 조건들이 입력된 builder를 입력
            .fetch();
}

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

1) 예제 코드

  • where문에 각 파라미터의 조건을 처리하는 메소드를 작성하여 입력하는 방식이며 완성된 메인 query의 구조가 일반적으로 자주 접하는 SQL과 흡사한 형태로 조건이 들어가있기 때문에 가독성이 좋음
  • .where조건에는 null의 값은 무시되기 때문에 편리하게 파라미터의 조건을 처리하는 메서드를 작성하기가 수월함
  • 또한 조건 자체가 메서드이기때문에 작성된 조건들을 원하는 대로 합치거나 분리하거나 부분적으로 적용하는등의 커스텀 메서드를 만들어서 where문에 간단히 적용할 수도있고 다른 query에서도 조건들을 재사용할 수 있는 장점이 있음
  • 실무에서 동적쿼리를 적용할때 해당 방식을 적용하는 것을 권장함
// 동적쿼리 - Where 다중 파라미터 사용(실무에서 사용하기 좋음)
@Test
void dynamicQuery_WhereParam() {
    String usernameParam = "member1";
    Integer ageParam = 10;

    List<Member> result = searchMember2(usernameParam, ageParam);
    assertThat(result.get(0))
            .extracting("username", "age")
            .containsExactly(usernameParam, ageParam);
}

// 동적쿼리 메서드 - BooleanBuilder보다 쿼리의 가독성이 SQL과 비슷하여 가독성이 좋음
private List<Member> searchMember2(String usernameCond, Integer ageCond) {
    return queryFactory
            .selectFrom(member)
            .where(usernameEq(usernameCond), ageEq(ageCond)) // where문에 null이 반환되면 그냥 무시됨
            .fetch();
}

// 직접 조건을 입력하는 메서드를 작성
// 메서드의 반환타입은 Predicate보다 BooleanExpression을 사용하는 것이 더 활용하기 좋음
private BooleanExpression usernameEq(String usernameCond) {
    // null 이면 null을 그냥 반환하고 null이 아니면 조건이 반환
    if (usernameCond != null) {
        return member.username.eq(usernameCond);
    } else {
        return null;
    }
}

// 간단한 조건은 3항연산자로 만드는 것을 추천
private BooleanExpression ageEq(Integer ageCond) {
    return ageCond != null ? member.age.eq(ageCond) : null;
}

// 여러가지의 조건을 조립하여 합치거나 분리하는 등의 원하는 조건끼리 분리하여 적용할 수 있음(null 체크는 주의해서 처리해야함)
private BooleanExpression allEq(String usernameCond, Integer ageCond) {
    return usernameEq(usernameCond).and(ageEq(ageCond));
}

//      return queryFactory
//                .selectFrom(member)
//                .where(allEq(usernameCond, ageCond)) 조립한 메서드를 where 문에 적용
//                .fetch();

6. 수정, 삭제 벌크 연산

1) 예제 코드

** 주의

  • 벌크연산은 DB에 직접 쿼리를 날리기 때문에 벌크연산을 단독으로 사용하여 트랜잭션을 완료하던지 다른 JPA코드들과 함께 사용하려면 벌크연산 후 강제로 초기화를 해주어야만 데이터 정합성에 문제가 발생하지 않음
  • 계속 반복해서 JPA에서 벌크연산을 사용할 때 주의점을 설명하는 이유는 매우 중요하기 때문임

(1) 쿼리 한번으로 대량 데이터 수정

  • query의 마지막에 execute()를 사용하면 벌크연산으로 수행됨
// 벌크연산 - 수정, 항상 벌크연산은 DB에 바로 쿼리를 날리기 때문에 주의해야함
@Test
void bulkUpdate() {

    // member1, member2 -> 잼민이들로 변경됨
    // member3, member4 -> 유지
    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();
    for (Member member1 : result) {
        System.out.println("member1 = " + member1);
       
    }
}

 

(2) 기존 숫자에 1 더하기, 곱하기

  • add() - 덧셈, 음수를 입력하면 뺄셈이 됨
  • multiply() - 곱셈
  • divide() - 나눗셈
  • subtract() - 뺄셈
  • 쿼리를 확인해보면 정상적으로 사칙연산이 적용되는 쿼리가 실행됨
// 별크연산 - 기존숫자에 사칙연산 수행
@Test
void bulkOperation() {
    // 덧셈
    queryFactory
            .update(member)
            .set(member.age, member.age.add(1))
            .where(member.age.loe(20))
            .execute();
    // 곱셈
    queryFactory
            .update(member)
            .set(member.age, member.age.multiply(2))
            .where(member.age.gt(20))
            .execute();

    // 나눗셈
    queryFactory
            .update(member)
            .set(member.age, member.age.divide(2))
            .where(member.age.mod(2).eq(0)) // 짝수일때 조건, 2로 나눈 나머지가 0이면
            .execute();

    // 뺄셈, add(음수)를 적용해도 됨 
    queryFactory
            .update(member)
            .set(member.age, member.age.subtract(1))
            .where(member.age.loe(15))
            .execute();
}

 

(3) 쿼리 한번으로 대량 데이터 삭제

  • 쿼리를 확인해보면 delete 쿼리가 정상적으로 수행됨
// 벌크연산 - 쿼리한번에 데이터 삭제
@Test
void bulkDelete() {
    queryFactory
            .delete(member)
            .where(member.age.gt(15))
            .execute();

}

7. SQL function 호출하기

1) 예제 코드

  • Expressions.stringTemplate()를 호출하여 문자로 함수의 기능을 입력한뒤, 두번째 파라미터로 적용 대상을 입력할 수 있음
  • 데이터베이스의 함수를 호출하여 사용할 수 있으며 기본적으로 JPA와 동일하게 Dialect(방언설정)에 등록된 내용만 호출할 수 있음
  • 등록되지 않은 함수를 사용하려면 직접 각DB의 Dialect 클래스를 상속받아서 함수를 등록할 수 있음(https://nagul2.tistory.com/338)
  • ANSI 표준 SQL의 함수들은 Querydsl에서도 메서드로 대부분 정의해두었기 때문에 where문에서 바로 필요한 함수를 호출하여 바로 사용할 수 있음
// SQL function 호출
@Test
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
void sqlFunction2() {
    List<String> result = queryFactory
            .select(member.username)
            .from(member)
//          .where(member.username.eq(Expressions.stringTemplate("function('lower', {0})",
//                 member.username)))
//          ANSI표준에서 제공하는 기능들은 이미 메서드로 제공되어있음
            .where(member.username.eq(member.username.lower())) 
            .fetch();

    for (String s : result) {
        System.out.println("s = " + s);
    }