개념/스프링 개념

[Spring] JPA환경에서 검색 및 필터 기능 구현 2 - QueryDSL 방법 이용

삼록이 2025. 6. 30. 15:10

이전 게시물에서 Specification을 통해, 즉 CriteriaAPI를 통해 검색 및 필터 기능을 구현했다면 

이번에는 같은 기능을 QueryDSL을 통해 구현하도록 리팩토링 해보려한다. 

 

(이전 게시물)

 

[JPA] JPA환경에서 검색 및 필터 기능 구현 1 - Specification 이용

먼저 JPA에서 쿼리를 날릴 수 있는 방식은 크게 아래 네 가지로 요약할 수 있겠다.JPQL : JPA의 기본 쿼리 언어로 SQL과 유사한 문법이지만 엔티티 기준으로 작성된다. 테이블명 대신 클래스명, 컬럼

gotopm.tistory.com

 

 

QueryDSL은 쉽게 말하면 JPQL을 자바 코드로 작성하는 것이다.
문자가 아니라 진짜 자바 코드로 작성하기 때문에 단순히 JPQL 문자열을 썼을 때는 에러가 런타임 시점에서 일어난다면,

QueryDSL은 컴파일 시점에서 에러를 잡아주는 이점이 있다. 

JPA를 사용하다보면 N+1 문제 때문에 JPQL을 사용해야하는데 위와 같은 단점으로 인해 JPA와 QueryDSL을 혼합해서 사용하는 방식이 실무에서도 권장된다고 한다.

 

QueryDSL을 사용하는 방법에는 여러 방식이 있겠지만

여기서는 한 번 만들어 놓고 프로젝트 내 어디에서나 사용할 수 있도록

QueryDSLConfig 라는 클래스를 작성하고 Bean으로 만드는 방법으로 설명하겠다. 

 

우선 QueryDSL 관련 개념이다.

  • Q타입 클래스: 엔티티를 기반으로 자동 생성되는 QueryDSL 전용 클래스
  • JPAQueryFactory : QueryDSL로 JPA 기반 쿼리를 생성하고 실행하는 핵심 객체. 
  • BooleanBuilder : 동적으로 where 조건을 만들기 위한 빌더 객체

Q타입 클래스는 QueryDSL에서 사용하는 엔티티의 타입 세이프한 참조 클래스다.

쉽게 말해서 우리가 엔티티의 필드 이름을 문자열로 쓰는 대신 실제 자바 필드처럼 사용할 수 있도록 도와주는 클래스다.

이로 인해, 컴파일 시점에서 바로 에러가 잡힐 수 있는 것이다.

Entity 어노테이션이 붙은 클래스에 한해 Q타입 클래스가 자동 생성된다.

 


1.QueryDSL 설정

build.gradle에 단순 라이브러리만 추가하는 것이 아니라 관련된 설정도 따로 작성해주어야한다.

그래서 아래 코드에서 따로 주석으로(여기서부터)(여기까지)라고 달아놓았다. 그 부분만 복사해서 쓰면 될 것이다.

참고로 Spring boot 3.x 이상에 맞춘 설정이다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.4.6'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
       languageVersion = JavaLanguageVersion.of(17)
    }
}

configurations {
    compileOnly {
       extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'
    annotationProcessor 'org.projectlombok:lombok'
    implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-impl:0.11.5'
    implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
    //(여기서부터)QueryDSL 관련 설정 4가지
    implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
    annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta"
    annotationProcessor "jakarta.annotation:jakarta.annotation-api"
    annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}

// Q타입 Class가 생성될 디렉토리 경로를 설정해야한다. 이 경로는 깃허브에 올라가지않도록 .gitignore에도 추가하는 것을 권한다.
def queryDslSrcDir = 'src/main/generated/querydsl/'

tasks.withType(JavaCompile).configureEach {
    options.getGeneratedSourceOutputDirectory().set(file(queryDslSrcDir))
}

// 소스 코드로 인식할 디렉토리에 경로에 Q타입 Class 파일을 추가한다.
sourceSets {
    main.java.srcDirs += [queryDslSrcDir]
}

// clean Task를 수행할 때 자동 생성된 Q타입 클래스 디렉토리를 삭제하도록 하는 것이다. 즉 ./gradlew clean명령어 수행시 Q클래스가 함께 삭제되도록 하는설정
clean {
    delete file(queryDslSrcDir)
}

configurations {
    compileOnly {
       extendsFrom annotationProcessor
    }
    // QueryDSL과 관련된 라이브러리들이 컴파일 시점에만 필요하도록 설정합니다. 또한 QueryDSL 설정을 컴파일 클래스패스에 추가
    querydsl.extendsFrom compileClasspath
}

//(여기까지)



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

 

그런 다음 서버를 실행하면, 이런 식으로 @Entity를 붙였던 클래스에 한해 Q타입의 클래스가 자동생성되었다.

 

2.QueryDslConfig 클래스

JPAQueryFactory 를 Bean으로 등록해놓는 클래스를 만든다. 이로 인해, 프로젝트 전역에서 JPAQueryFactory를 주입받아 QueryDSL을 사용할 수 있다. 

* JPAQueryFactory는 QueryDSL 쿼리를 만드는 핵심 객체라고 앞서 설명했다. 내부적으로는 EntityManager를 기반으로 작동하며 JPQL을 대체할 수 있도록 도와주는 빌더 역할을 한다.

@Configuration
public class QueryDslConfig {
    //EntityManger를 빈으로 주입받기 위해 사용하는 어노테이션
    @PersistenceContext
    private EntityManager entityManager;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}

 

3. QueryDSL 기반 레포지토리 만들기

기존에 우리는 아래와 같은 JpaRepository를 활용하고 있었다. 그러나 JpaRepository는 인터페이스기 때문에 QueryDSL 코드를 구현할 수 없다. 

@Repository
public interface LogRepository extends JpaRepository<Log,Long> {
//  전체 로그 불러오기(검색/필터링)
    Page<Log> findAll(Specification<Log> specification, Pageable pageable);
}

그래서 아래와 같은 방식을 사용해야 한다.

 

  1. QueryDSL을 사용할, 즉 내가 커스텀할 동작을 정의한 인터페이스를 만든다.
  2. 그 인터페이스의 실제 로직을 구현하는 클래스를 만든다.(JPAQueryFactory로 QueryDSL 쿼리를 직접 작성하는 클래스란 말)
    이때 반드시 기존JpaRepository 이름에 Impl을 붙인 이름을 쓴다.
    (LogRepository에 Imple에 붙여 LogRepositoryImpl)
    왜냐하면, 그래야 Spring Data Jpa가 내부적으로 이 구현체를 자동으로 찾아 연결하기 때문
  3. 기존 Jpa Repository에 1번에서 만든 커스텀 인터페이스를 추가 상속한다
    (LogRepository가 extends 하는 것에 LogRepositoryCustom을 추가하란 말. 그러면 2번에서 말한 것처럼 Spring Data Jpa가 LogRepositoryCustom을 구현체를 LogRepository에 Impl이 붙여진 이름 클래스를 존재하는 지 확인해서 해당 구현체를 자동으로 연결한  )

 

3-1.  커스텀 인터페이스 만들기(LogRepositoryCustom)

public interface LogRepositoryCustom {
    Page<Log> bringLogs(LogSearchReqDto dto, Pageable pageable);
}

 

나는 검색과 필터링 기능을 QueryDsl을 사용하는 것으로 코드를 변경한다.

따라서 해당 기능을 가질 메서드를 명명한다.

 

3-2.구현 클래스 만들기(LogRepositoryImpl)

보면, 지난번 게시물의 서비스단에 있던 검색,필터링 관련 코드들이 여기에다 작성해놓았다.

여기서 중요한 것은 //QueryDSL 방식의 쿼리라고 주석을 달아놓은 부분이다.

저 부분이 QueryDSL 쿼리이며 이러한 방식으로 다른 쿼리로 응용하여 사용하면 될 것이다.

@Repository
public class LogRepositoryImpl implements LogRepositoryCustom{

    private final JPAQueryFactory jpaQueryFactory;

    public LogRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
        this.jpaQueryFactory = jpaQueryFactory;
    }


    @Override
    public Page<Log> bringLogs(LogSearchReqDto dto, Pageable pageable) {
        QLog log = QLog.log;

        BooleanBuilder builder = new BooleanBuilder();
        //메세지에 대한 검색조건
        if(dto.getMessage() != null){
            builder.and(log.message.containsIgnoreCase(dto.getMessage())); //containsIgnoreCase("ab")는 SQL의 LIKE %ab%를 의미. 완전 똑같은걸 찾는가면 .eq()사용
        }
        //유저아이디에 대한 검색조건
        if(dto.getUserId() != null){
            builder.and(log.message.containsIgnoreCase(dto.getUserId()));
        }
         //레벨에 대한 필터조건
        if(dto.getLevels() != null && !dto.getLevels().isEmpty()){
            List<Level> levelEnums = dto.getLevels().stream().map(lv->Level.valueOf(lv.toUpperCase())).toList();
            builder.and(log.level.in(levelEnums)); // 사용자가 여러개의 필터링을 걸 수 있도록 리스트로 받고 in절 사용
        }
        //서비스에 대한 필터조건
        if(dto.getServices() != null && !dto.getServices().isEmpty()){
            List<Service> serviceEnums = dto.getServices().stream().map(sv->Service.valueOf(sv.toUpperCase())).toList();
            builder.and(log.service.in(serviceEnums));
        }

        //QueryDsl방식의 쿼리
        List<Log> logs =jpaQueryFactory
                .selectFrom(log)   //SELECT * FROM log
                .where(builder)    //WHERE조건에 우리가 만든 동적조건인 builder적용
                .offset(pageable.getOffset()) //몇 번째 페이지부터 가져올지(페이징)
                .limit(pageable.getPageSize()) // 몇개 가져올지(페이징)
                .orderBy(log.timestamp.desc()) // ORDER BY 정렬
                .fetch(); //결과 리스트 반환(반환 값이 리스트임). 결과가 0개여도 리스트(빈 리스트)로 반환
        //전체 개수 쿼리(페이징 처리를 하기 위해 필수)
        long total = jpaQueryFactory
                .select(log.count())  // .count는 전체 조건에 해당하는 데이터 수
                .from(log)
                .where(builder)
                .fetchOne(); // fetchOne은 반환값이 단일결과. 결과가 2개이상이면 예외 발생

        return new PageImpl<>(logs,pageable,total); //페이지 객체로 감싸서 반환 logs는 실제 데이터, pageable은 요청받은 저보, total은 전체 데이터 개수(페이지 계산용)

    }
}

 

3-3. 이제 커스텀 인터페이스와 구현클래스가 준비완료되었으니 기존 JpaRepository에서 해당 인터페이스를 상속한다.

@Repository
public interface LogRepository extends JpaRepository<Log,Long>, LogRepositoryCustom {

//  전체 로그 불러오기(검색/필터링)
    Page<Log> findAll(Specification<Log> specification, Pageable pageable);
}

 

4.서비스단에서 호출하여 사용

이전 게시물의 bringSearchedLogs 메서드를 QueryDSL로 바꾼 것이다.

우리가 검색,필터 관련기능을 LogRepositoryImpl에서 구현해놓았고.

또 Jpa Repository가 해당 인터페이스를 추가 상속해놓았기 때문에

기존 서비스단 코드에서 그대로 JpaRepository 객체로 구현해놓은 메서드를 호출해 사용 할 수 있는 것이다.

@Service
@Transactional
public class LogService {
    private final LogRepository logRepository;
    private final AdminRepository adminRepository;

    public LogService(LogRepository logRepository, AdminRepository adminRepository) {
        this.logRepository = logRepository;
        this.adminRepository = adminRepository;
    }

	 public Page<LogsResDto> bringSearchedLogs(LogSearchReqDto dto, Pageable pageable){

		String loginId = SecurityContextHolder.getContext().getAuthentication().getName();
        adminRepository.findByLoginId(loginId).orElseThrow(()->new EntityNotFoundException("admin login is needed"));
        
        
        Page<Log> originalSearchedLogs = logRepository.bringLogs(dto,pageable);
        return originalSearchedLogs.map(e->e.toDtoFromEntity());
    }