본문 바로가기
Spring

[Spring] QueryDSL을 활용한 동적 쿼리(Dynamic SQL) 조회하기 (BooleanBuilder 활용)

by 도전하는 린치핀 2024. 5. 14.

1. Query DSL 이란?

Spring boot에서 Spring Data JPA는 개발자가 간단한 CRUD 메서드 및 쿼리 메서드를 사용할 수 있다.

하지만, 원하는 조건의 데이터를 검색해야 할 때 다양한 조건들이 있다면 매개변수는 점차 증가하게 되고 성능 및 가독성이 떨어진다.

이때, 개발자는 JPQL을 활용해서 직접 SQL을 작성하여 데이터를 조작할 수 있지만, 로직이 점점 복잡해지면 마찬가지로 SQL에서의 개행이 반복되다보면 해당 쿼리가 어디에 어떻게 쓰이는지 알 수 없을 때가 있다. 또한, JPQL의 경우 복잡한 SQL을 작성하다보면 나올 수 있는 오타/문법적인 오류에 대해서 컴파일 시점에 확인할 수 없고 런타임에서 발생한 에러를 추적해야 한다.

 

이러한 불편함을 해소해주는 요소 중 하나로 QueryDSL은 정적 타입을 이용해서 SQL 등의 쿼리를 생성해주는 매우 친절한 프레임 워크이다. QueryDSL의 장점은 아래와  같다.

  1. 문자(SQL)가 아닌 코드(querydsl)로 쿼리를 작성함으로써 컴파일 시점에서 문법적인 오류나 오타를 확인할 수 있다.
  2. 자동완성 등 다양한 편의를 위한 기능이 존재한다.
  3. 동적인 쿼리를 작성해야할 때 매우 편리하다.
  4. 쿼리 작성 시 제약 조건과 같은 조건들을 메서드로 추출하여 재사용의 부분에서도 편리하다.

나의 경우에 다양한 필터 조건을 통한 SELECT 쿼리를 생성할 때 해당 조건들이 없을 수도 있고(null) 있을 수도 있어 어떤 방법을 사용해야 하나 고민을 굉장히 오래했다.

하지만 Query DSL을 알게되어 매우 간단한 방법으로 원하는 요구 사항을 구현할 수 있었다.

Query DSL을 사용하면서 가장 어려웠던 부분은 아마 설정이었던 것 같다.

물론 이것도 다양한 포스팅들을 확인하면서 해결하였지만 Gradle에 대한 설정이나 Q 클래스 생성하고 사용하기 위한 경로를 지정하는 것이 꽤나 복잡하고 머리가 아팠다.

2. Query DSL 설정

위에서 말했듯이 설정 부분이 제일 많이 꼬이고 헷갈렸기 때문에 하나의 목차로 구성해서 정리해보려 한다.

Query DSL의 공식 문서은 Gradle에 대한 내용이 조금 모자르다고 느꼈고, 실제 사용할 때 QueryDSL 설정 방법은 Gradle 및 IntelliJ 버전에 따라 달라지는 것 같아 여러 포스팅을 확인하면서 따라갔던 것 같다.

/* build.gradle */
plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.2'
    id 'io.spring.dependency-management' version '1.1.2'
}

group = 'com.asac-digital-new-tech'
version = '0.6.2'

java {
    sourceCompatibility = '17'
}

jar {
    version = "${project.version}-" + new Date().format('yyyyMMddHHmmss')
}

repositories {  
    mavenCentral()
}

dependencies {
    implementation ('org.projectlombok:lombok')
    compileOnly ('org.projectlombok:lombok:1.18.30')
    annotationProcessor ('org.projectlombok:lombok:1.18.30')
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.session:spring-session-core'
    implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2'
    implementation 'org.apache.poi:poi:5.2.2'
    implementation 'org.apache.poi:poi-ooxml:5.2.2'

    /* database setting */
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
    /* Query DSL 부분 */
    implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
    annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"

}

tasks.named('test') {
    useJUnitPlatform()
}


/* Query DSL 부분 */
def querydslDir = "$buildDir/generated/querydsl"
sourceSets {
    main.java.srcDirs += [ querydslDir ]
}
clean.doLast {
    file(querydslDir).deleteDir()
}

 

  • 설명에 들어가기에 앞서 해당 설정이 정답이 아니기 때문에 각자 환경에 맞는 Gradle 설정을 해야 한다.
  • 기본적으로  Query DSL은 자동으로 프로젝트 내의 @Entitiy 어노테이션을 선언한 클래스를 탐색하여 JPAAnnotationProcessor를 통해 Q 클래스를 생성해준다.
  • query-apt가 @Entity, @id 와 같은 어노테이션을 알 수 있도록 jakarta.persistence과 jakarta.annotation을 annotationProcessor에 함께 추가한다.
  • annotationProcessor는 Java 컴파일러 플러그인으로 컴파일 단계에서 어노테이션을 분석 및 처리하여 추가적인 파일을 생성할 수 있다.
  • sourceSets 부분을 통해 생성된 Q 클래스들을 /build/generated/source/annotationProceesor 폴더 내부에 추가된다.
  • 이렇게 생성된 Q 클래스들은 직접 코드에서 사용할 수 있다.

이렇게 gradle 설정 및 빌드를 마친 뒤, JAVA 컴파일하면 Q클래스가 생성된다.

  • 위의 그림처럼 생성된 Q클래스들은 QueryDSL 쿼리를 작성할 때 사용하며 쿼리를 Type-Safe하게 작성할 수 있다고 한다.

3. Query DSL 사용 예시

내가 사용한 Query DSL은 아래와 같은 상황에서 사용했다.

 

  • 데이터 베이스에 저장되어 있는 티켓의 리스트를 가져와야 한다.
  • 각 티켓을 가져올 때 필터링 할 수 있는 다양하게 있다.
  • 만약 필터가 존재한다면 필터링을 하고, 없다면 없는 상태로 리스트를 가져와야 한다.

이처럼 여러가지 상황의 필터가 존재할수도/존재하지 않을 수도 있는 상황에서 query dsl을 사용하지 않고 Spring Data JPA만 사용해서데이터를 반환해야 한다면 Repository 내에 모든 필터 값을 파라미터로 리스트를 반환하는 메서드를 여러개 생성해야 할 것 같았다.(왜냐면 Repository 내 메서드를 활용하는 방식은 동적으로 불가능하다고 생각해서,,,)

 

하지만 Querydsl을 사용한다면 어떨까?

동적으로 필터 값을 프론트엔드에서 받아서(이때도 각 @Pathvariable을 사용하는 것이 아니라 @ModelAttribute를 처음 사용해봤다.) SQL 쿼리 내 where 절에 들어가야 하는 조건들을 동적으로 생성하기만 하면 된다.

 

직접 사용한 코드는 아래와 같다.

@Slf4j
@Repository
public class TicketRepositoryImpl implements TicketRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    public TicketRepositoryImpl(@Qualifier("firstJPAQueryFactory") JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }
	@Override
    @Transactional
    public Page<QTicketDto> searchTicketList(
            Pageable pageable,
            List<Long> userCompanyIds,
            TicketListSearchDto ticketListSearchDto
    ) {
        if (userCompanyIds == null || userCompanyIds.isEmpty()) {
            return Page.empty();
        }

        BooleanBuilder cond = new BooleanBuilder();
        
//        // 티켓 필터 조건 추가
//        if(ticketListSearchDto.getTicketNo() != null) {
//            String [] ticketList = ticketListSearchDto.getTicketNo().split(",");
//            for (String t : ticketList) {
//                cond.and(ticketNoEq(t));
//            }
//        }

        if(ticketListSearchDto.getCompanyCode() != null) {
            String [] companyList = ticketListSearchDto.getCompanyCode().split(",");
            for(String c : companyList) {
                cond.and(companyCodeEq(c));
            }
        }

        if(ticketListSearchDto.getRiskRank() != null) {
            List<RiskRank> riskList = ticketListSearchDto.getRiskRank();
            for(RiskRank r : riskList) {
                cond.and(riskRankEq(r));
            }
        }
        // 시나리오 이름 필터 조건 추가
        if(ticketListSearchDto.getScenarioName() != null) {
            String [] scenarioNameList = ticketListSearchDto.getScenarioName().split(",");
            for(String s : scenarioNameList) {
                cond.and(scenarioNameEq(s));
            }
        }

        // 이벤트 이름 필터 조건 추가
        if(ticketListSearchDto.getEventName() != null) {
            String[] eventNameList = ticketListSearchDto.getEventName().split(",");
            for(String e: eventNameList){
                cond.and(eventNameEq(e));
            }
        }

        // 출발지 IP 필터 조건 추가
        if(ticketListSearchDto.getSourceIp() != null) {
            String[] sourceIpList = ticketListSearchDto.getSourceIp().split(",");
            for(String ip: sourceIpList) {
                cond.and(sourceIpEq(ip));
            }
        }

        // 국가 필터 조건 추가
        if(ticketListSearchDto.getNation() != null) {
            String[] nationList = ticketListSearchDto.getNation().split(",");
            for(String n: nationList) {
                cond.and(nationEq(n));
            }
        }

        // 목적지 IP 필터 조건 추가
        if(ticketListSearchDto.getDestinationIP() != null) {
            String[] destIPList = ticketListSearchDto.getDestinationIP().split(",");
            for(String d: destIPList) {
                cond.and(destinationIpEq(d));
            }
        }

        // 목적지 포트 필터 조건 추가
        if(ticketListSearchDto.getDestinationPort() != null) {
            String[] destPortList = ticketListSearchDto.getDestinationPort().split(",");
            for(String d : destPortList){
                cond.and(destinationPortEq(d));
            }
        }

        cond.and(userCompanyIdsContain(userCompanyIds))
                .and(processStatusEq(ticketListSearchDto.getProcessStatus()))
                .and(keywordContain(ticketListSearchDto.getKeyword()))
                .and(sourcePortEq(ticketListSearchDto.getSourcePort()))
                .and(managerEq(ticketListSearchDto.getManager()))
                .and(isDetectedWithinDateRange(ticketListSearchDto.getDStartDate(), ticketListSearchDto.getDEndDate()))
                .and(firstResponseYNEq(ticketListSearchDto.getFirstResponseYN()));

        List<QTicketDto> content = createTicketDtoQuery()
                .where(cond)
                .orderBy(ticket.created.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                        .stream().toList();
        return new PageImpl<>(content, pageable, totalCount(cond));
    }
    }

 

4. BooleanBuilder / BooleanExpression

 

위의 코드에서 설명하지 않았지만 Query DSL을 사용할 때 동적으로 Where절을 작성하는 방법에서 BooleanBuilder를 사용했다.

 

4-1.  BooleanBuilder

BooleanBuilder란 아래와 같이 BooleanBuilder 생성자를 생성 후, 아래와 같이 if문을 통해서 각 데이터의 조건에 맞게 코드를 작성하는 방법이다.

 

아래 코드와 같이 데이터가 많지 않아 작성해야 할 if문이 많지 않다면 쿼리를 쉽게 알아볼 수 있지만,

3번의 예제와 같이 추가해야 할 많다면 쿼리문이 어떻게 쓰이고 어떤 필터링을 거치는지 쉽게 알 수 없다.

 

public List<MemberTeamDto> searchByBuilder(MemberSearchCondition condition) {

  BooleanBuilder builder = new BooleanBuilder(); 

  if (hasText(condition.getUserName())) { // hasText = 값이 있을 경우 True, 공백 또는 ""일 경우 False 리턴

    builder.and(member.username.eq(condition.getUserName()));

  }

  if (hasText(condition.getTeamName())) {

    builder.and(team.name.eq(condition.getTeamName()));

  }

  if (condition.getAgeGoe() != null) {

    builder.and(member.age.goe(condition.getAgeGoe()));

  }

  if (condition.getAgeLoe() != null) {

    builder.and(member.age.loe(condition.getAgeLoe()));

  }


  return queryFactory

      .select(new QMemberTeamDto(

          member.id.as("memberId"),

          member.username,

          member.age,

          team.id.as("teamId"),

          team.name.as("teamName")))

      .from(member)

      .leftJoin(member.team, team)

      .where(builder) //생성한 builder 객체를 where절에 작성해줌

      .fetch();

}

 

 

 

4-2.  BooleanExpression

BooleanExpression은 BooleanBuilder에서 필터 조건이 많아지면 쿼리를 분석하기 힘들다는 단점 개선 할 수 있는 방법이다. 

 

아래 코드는 위 4-1에서 작성한 예시 코드와 같은 동작을 하는 코드다.

기존 BooleanBuilder에서는 생성한 객체를 where절에 넣었다면, BooleanExpression은 where절에 다중 파라미터를 사용하는 방식이다.

 

참고로 여기서 where절에 null 값은 무시한다.

public List<MemberTeamDto> search(MemberSearchCondition condition) {

  return queryFactory

      .select(new QMemberTeamDto(

          member.id.as("memberId"),

          member.username,

          member.age,

          team.id.as("teamId"),

          team.name.as("teamName")))

      .from(member)

      .leftJoin(member.team, team)

      .where(

          userNameEq(condition.getUserName()),

          teamNameEq(condition.getTeamName()),

          ageGoe(condition.getAgeGoe()),

          ageLoe(condition.getAgeLoe())

      )

      .fetch();

}

private BooleanExpression userNameEq(

    String userName) { //BooleanExpression을 사용해야 메서드끼리 조합이 가능해짐, 재사용도 가능

  return hasText(userName) ? member.username.eq(userName) : null;

}

private BooleanExpression teamNameEq(String teamName) {

  return hasText(teamName) ? team.name.eq(teamName) : null;

}

private BooleanExpression ageGoe(Integer ageGoe) {

  return ageGoe != null ? member.age.goe(ageGoe) : null; //goe = 크거나 같다

}

private BooleanExpression ageLoe(Integer ageLoe) {

  return ageLoe != null ? member.age.loe(ageLoe) : null; //loe = 작거나 같다

}

 

BooleanExpression을 사용하면 생성해야 할 메서드가 많아지겠지만, where절 내에서 메서드명을 보고 손쉽게 쿼리를 파악할 수 있어 가독성이 높아진다는 장점이 있다.

 

하지만 모든 코드에 오답은 존재하지만 정답은 존재하지 않는다고 생각하기 때문에 3번에 사용한 예시 코드와 같이 BooleanBuilder와 BooleanExpression을 다양하게 사용했던 것 처럼 상황에 맞게 적절하게 코드를 작성하는 것이 좋을 것 같다.

 

 

더보기

'Spring' 카테고리의 다른 글

[Spring] Spring Facade Pattern  (0) 2024.05.18
[Spring] Spring MVC / 3-tier Layered Architecture  (0) 2024.04.02
[Spring] Spring / Spring Boot 비교  (0) 2024.03.27