본문 바로가기

개발/BACK

SpringBoot + QueryDSL 환경에서 동적 쿼리와 페이지네이션 구현하기 (Java 17)

728x90

실무에서 가장 많이 사용되는 기능 중 하나는 동적 쿼리입니다. 사용자가 다양한 조건으로 데이터를 검색할 때, 조건에 따라 쿼리를 동적으로 생성해야 합니다. SpringBoot와 QueryDSL을 활용하여 조회 조건을 객체로 받아 동적 쿼리를 생성하고, 페이지네이션을 구현하는 방법을 단계별로 알아보겠습니다.


1. QueryDSL 설정

먼저 SpringBoot 프로젝트에 QueryDSL을 설정해야 합니다. build.gradle 파일에 필요한 의존성을 추가합니다.

1.1 build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.1.0'
    id 'io.spring.dependency-management' version '1.1.0'
    id 'com.ewerk.gradle.plugins.querydsl' version '1.0.10'
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    
    // QueryDSL
    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'
    
    // Database
    runtimeOnly 'com.h2database:h2'
    
    // Lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

// QueryDSL 설정
def querydslDir = "$buildDir/generated/querydsl"

querydsl {
    jpa = true
    querydslSourcesDir = querydslDir
}

sourceSets {
    main.java.srcDirs += [ querydslDir ]
}

tasks.withType(JavaCompile) {
    options.annotationProcessorGeneratedSourcesDirectory = file(querydslDir)
}

clean {
    delete file(querydslDir)
}
💡 팁: QueryDSL은 컴파일 시점에 Q 클래스를 생성합니다. 프로젝트를 빌드하면 build/generated/querydsl 디렉토리에 Q 클래스가 생성됩니다.

2. Entity 클래스 작성

예제로 사용할 User 엔티티를 작성합니다. 번호(id), 이름(name), 생성날짜(createdAt), 수정날짜(updatedAt) 필드를 포함합니다.

2.1 User.java

package com.example.demo.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Entity
@Table(name = "users")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class User {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(nullable = false, length = 100)
    private String name;
    
    @Column(length = 200)
    private String email;
    
    @CreatedDate
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;
    
    @LastModifiedDate
    @Column(nullable = false)
    private LocalDateTime updatedAt;
    
    @Builder
    public User(String name, String email) {
        this.name = name;
        this.email = email;
    }
}

2.2 JPA Auditing 설정

@CreatedDate와 @LastModifiedDate를 사용하기 위해 JPA Auditing을 활성화해야 합니다.

package com.example.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}

3. 조회 조건 DTO 작성

클라이언트로부터 받을 조회 조건을 담는 DTO를 작성합니다. 모든 필드는 선택적(Optional)으로 받아 동적 쿼리를 구성합니다.

3.1 UserSearchRequest.java

package com.example.demo.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserSearchRequest {
    
    // 번호로 검색
    private Long id;
    
    // 이름으로 검색 (부분 일치)
    private String name;
    
    // 생성날짜 범위 검색
    private LocalDateTime createdAtStart;
    private LocalDateTime createdAtEnd;
    
    // 수정날짜 범위 검색
    private LocalDateTime updatedAtStart;
    private LocalDateTime updatedAtEnd;
    
    // 페이지네이션
    private Integer page = 0;      // 기본값: 0
    private Integer size = 10;     // 기본값: 10
}
📌 참고: 모든 필드가 null일 수 있도록 설계했습니다. null이 아닌 필드만 쿼리 조건에 추가하여 동적 쿼리를 구성합니다.

4. Repository 구현 (동적 쿼리)

QueryDSL을 사용하여 동적 쿼리를 구현합니다. BooleanBuilder를 활용하여 조건에 따라 쿼리를 동적으로 생성합니다.

4.1 UserRepository.java

package com.example.demo.repository;

import com.example.demo.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {
}

4.2 UserRepositoryCustom.java

package com.example.demo.repository;

import com.example.demo.dto.UserSearchRequest;
import com.example.demo.entity.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;

public interface UserRepositoryCustom {
    Page<User> searchUsers(UserSearchRequest searchRequest, Pageable pageable);
    long countUsers(UserSearchRequest searchRequest);
}

4.3 UserRepositoryImpl.java

package com.example.demo.repository.impl;

import com.example.demo.dto.UserSearchRequest;
import com.example.demo.entity.QUser;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepositoryCustom;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.support.PageableExecutionUtils;

import java.util.List;

@RequiredArgsConstructor
public class UserRepositoryImpl implements UserRepositoryCustom {
    
    private final JPAQueryFactory queryFactory;
    
    private static final QUser user = QUser.user;
    
    @Override
    public Page<User> searchUsers(UserSearchRequest searchRequest, Pageable pageable) {
        List<User> content = queryFactory
                .selectFrom(user)
                .where(buildSearchConditions(searchRequest))
                .orderBy(user.createdAt.desc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
        
        JPAQuery<Long> countQuery = queryFactory
                .select(user.count())
                .from(user)
                .where(buildSearchConditions(searchRequest));
        
        return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne);
    }
    
    @Override
    public long countUsers(UserSearchRequest searchRequest) {
        return queryFactory
                .select(user.count())
                .from(user)
                .where(buildSearchConditions(searchRequest))
                .fetchOne();
    }
    
    /**
     * 조회 조건에 따라 동적 쿼리 생성
     */
    private BooleanBuilder buildSearchConditions(UserSearchRequest searchRequest) {
        BooleanBuilder builder = new BooleanBuilder();
        
        // 번호로 검색
        if (searchRequest.getId() != null) {
            builder.and(user.id.eq(searchRequest.getId()));
        }
        
        // 이름으로 검색 (부분 일치)
        if (searchRequest.getName() != null && !searchRequest.getName().isEmpty()) {
            builder.and(user.name.containsIgnoreCase(searchRequest.getName()));
        }
        
        // 생성날짜 범위 검색
        if (searchRequest.getCreatedAtStart() != null) {
            builder.and(user.createdAt.goe(searchRequest.getCreatedAtStart()));
        }
        if (searchRequest.getCreatedAtEnd() != null) {
            builder.and(user.createdAt.loe(searchRequest.getCreatedAtEnd()));
        }
        
        // 수정날짜 범위 검색
        if (searchRequest.getUpdatedAtStart() != null) {
            builder.and(user.updatedAt.goe(searchRequest.getUpdatedAtStart()));
        }
        if (searchRequest.getUpdatedAtEnd() != null) {
            builder.and(user.updatedAt.loe(searchRequest.getUpdatedAtEnd()));
        }
        
        return builder;
    }
}

4.4 QueryDSL 설정 (JPAQueryFactory Bean 등록)

package com.example.demo.config;

import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class QueryDSLConfig {
    
    @PersistenceContext
    private EntityManager entityManager;
    
    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(entityManager);
    }
}
💡 핵심 포인트:
  • BooleanBuilder: 여러 조건을 AND/OR로 조합할 수 있는 QueryDSL의 유틸리티 클래스
  • 동적 조건: null 체크를 통해 조건이 있을 때만 쿼리에 추가
  • PageableExecutionUtils: count 쿼리를 최적화하여 불필요한 count 쿼리를 방지

5. Service 구현

Service 레이어에서 비즈니스 로직을 처리하고, Repository를 호출하여 데이터를 조회합니다.

5.1 UserService.java

package com.example.demo.service;

import com.example.demo.dto.UserSearchRequest;
import com.example.demo.dto.UserResponse;
import com.example.demo.entity.User;
import com.example.demo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.stream.Collectors;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {
    
    private final UserRepository userRepository;
    
    /**
     * 사용자 검색 (페이지네이션 포함)
     */
    public Page<UserResponse> searchUsers(UserSearchRequest searchRequest) {
        // 페이지네이션 설정
        Pageable pageable = PageRequest.of(
                searchRequest.getPage(),
                searchRequest.getSize(),
                Sort.by(Sort.Direction.DESC, "createdAt")
        );
        
        // 검색 실행
        Page<User> userPage = userRepository.searchUsers(searchRequest, pageable);
        
        // Entity를 DTO로 변환
        return userPage.map(UserResponse::from);
    }
    
    /**
     * 사용자 전체 조회 (페이지네이션 없음)
     */
    public List<UserResponse> getAllUsers() {
        return userRepository.findAll().stream()
                .map(UserResponse::from)
                .collect(Collectors.toList());
    }
}

5.2 UserResponse.java (응답 DTO)

package com.example.demo.dto;

import com.example.demo.entity.User;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserResponse {
    
    private Long id;
    private String name;
    private String email;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
    
    public static UserResponse from(User user) {
        return UserResponse.builder()
                .id(user.getId())
                .name(user.getName())
                .email(user.getEmail())
                .createdAt(user.getCreatedAt())
                .updatedAt(user.getUpdatedAt())
                .build();
    }
}

6. Controller 구현

REST API 엔드포인트를 제공하는 Controller를 작성합니다. 조회 조건을 파라미터로 받아 검색을 수행합니다.

6.1 UserController.java

package com.example.demo.controller;

import com.example.demo.dto.UserSearchRequest;
import com.example.demo.dto.UserResponse;
import com.example.demo.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.List;

@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
    
    private final UserService userService;
    
    /**
     * 사용자 검색 (동적 쿼리 + 페이지네이션)
     * 
     * GET /api/users/search?name=홍길동&createdAtStart=2024-01-01T00:00:00&page=0&size=10
     */
    @GetMapping("/search")
    public ResponseEntity<Page<UserResponse>> searchUsers(
            @RequestParam(required = false) Long id,
            @RequestParam(required = false) String name,
            @RequestParam(required = false) 
            @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime createdAtStart,
            @RequestParam(required = false) 
            @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime createdAtEnd,
            @RequestParam(required = false) 
            @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime updatedAtStart,
            @RequestParam(required = false) 
            @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime updatedAtEnd,
            @RequestParam(defaultValue = "0") Integer page,
            @RequestParam(defaultValue = "10") Integer size
    ) {
        UserSearchRequest searchRequest = UserSearchRequest.builder()
                .id(id)
                .name(name)
                .createdAtStart(createdAtStart)
                .createdAtEnd(createdAtEnd)
                .updatedAtStart(updatedAtStart)
                .updatedAtEnd(updatedAtEnd)
                .page(page)
                .size(size)
                .build();
        
        Page<UserResponse> result = userService.searchUsers(searchRequest);
        return ResponseEntity.ok(result);
    }
    
    /**
     * 사용자 전체 조회
     */
    @GetMapping
    public ResponseEntity<List<UserResponse>> getAllUsers() {
        List<UserResponse> users = userService.getAllUsers();
        return ResponseEntity.ok(users);
    }
}
📌 참고: @RequestParam의 required = false로 설정하여 모든 파라미터를 선택적으로 받습니다. 클라이언트는 필요한 조건만 전달하면 됩니다.

7. 페이지네이션 응답 DTO

Spring Data의 Page 객체를 그대로 사용할 수도 있지만, 필요에 따라 커스텀 응답 DTO를 만들어 사용할 수 있습니다.

7.1 PageResponse.java (선택사항)

package com.example.demo.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.domain.Page;

import java.util.List;

@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PageResponse<T> {
    
    private List<T> content;
    private int page;
    private int size;
    private long totalElements;
    private int totalPages;
    private boolean first;
    private boolean last;
    
    public static <T> PageResponse<T> of(Page<T> page) {
        return PageResponse.<T>builder()
                .content(page.getContent())
                .page(page.getNumber())
                .size(page.getSize())
                .totalElements(page.getTotalElements())
                .totalPages(page.getTotalPages())
                .first(page.isFirst())
                .last(page.isLast())
                .build();
    }
}

7.2 Controller에서 커스텀 응답 사용 (선택사항)

@GetMapping("/search-custom")
public ResponseEntity<PageResponse<UserResponse>> searchUsersCustom(
        @RequestParam(required = false) Long id,
        @RequestParam(required = false) String name,
        // ... 기타 파라미터
        @RequestParam(defaultValue = "0") Integer page,
        @RequestParam(defaultValue = "10") Integer size
) {
    UserSearchRequest searchRequest = UserSearchRequest.builder()
            .id(id)
            .name(name)
            .page(page)
            .size(size)
            .build();
    
    Page<UserResponse> pageResult = userService.searchUsers(searchRequest);
    PageResponse<UserResponse> response = PageResponse.of(pageResult);
    
    return ResponseEntity.ok(response);
}

8. 테스트 및 실행

8.1 API 테스트 예시

다양한 조건으로 API를 테스트해봅니다.

1) 이름으로 검색

GET /api/users/search?name=홍길동&page=0&size=10

2) 번호로 검색

GET /api/users/search?id=1

3) 생성날짜 범위로 검색

GET /api/users/search?createdAtStart=2024-01-01T00:00:00&createdAtEnd=2024-12-31T23:59:59&page=0&size=20

4) 복합 조건 검색

GET /api/users/search?name=홍&createdAtStart=2024-01-01T00:00:00&updatedAtStart=2024-06-01T00:00:00&page=0&size=10

8.2 응답 예시

{
  "content": [
    {
      "id": 1,
      "name": "홍길동",
      "email": "hong@example.com",
      "createdAt": "2024-01-15T10:30:00",
      "updatedAt": "2024-01-20T14:20:00"
    },
    {
      "id": 2,
      "name": "홍길순",
      "email": "hong2@example.com",
      "createdAt": "2024-02-10T09:15:00",
      "updatedAt": "2024-02-12T11:30:00"
    }
  ],
  "pageable": {
    "sort": {
      "sorted": true,
      "unsorted": false,
      "empty": false
    },
    "pageNumber": 0,
    "pageSize": 10
  },
  "totalElements": 25,
  "totalPages": 3,
  "last": false,
  "first": true,
  "size": 10,
  "number": 0,
  "numberOfElements": 10,
  "empty": false
}

8.3 주요 QueryDSL 메서드 정리

메서드 설명 예시
eq() 같음 (equals) user.id.eq(1L)
ne() 같지 않음 (not equals) user.id.ne(1L)
contains() 포함 (LIKE %value%) user.name.contains("홍")
containsIgnoreCase() 대소문자 무시 포함 user.name.containsIgnoreCase("hong")
startsWith() 시작 (LIKE value%) user.name.startsWith("홍")
endsWith() 끝 (LIKE %value) user.name.endsWith("동")
goe() 크거나 같음 (>=) user.createdAt.goe(startDate)
loe() 작거나 같음 (<=) user.createdAt.loe(endDate)
gt() 큼 (>) user.id.gt(10L)
lt() 작음 (<) user.id.lt(100L)
in() 포함 (IN) user.id.in(1L, 2L, 3L)
isNull() NULL 체크 user.email.isNull()
isNotNull() NOT NULL 체크 user.email.isNotNull()
💡 Best Practice:
  • BooleanBuilder 활용: 복잡한 동적 쿼리를 깔끔하게 작성할 수 있습니다.
  • null 체크: 모든 조건에 대해 null 체크를 수행하여 불필요한 조건을 제외합니다.
  • 인덱스 고려: 자주 사용되는 검색 조건에 대해서는 DB 인덱스를 고려해야 합니다.
  • 성능 최적화: PageableExecutionUtils를 사용하여 count 쿼리를 최적화합니다.

 

카카오톡 오픈채팅 링크

https://open.kakao.com/o/seCteX7h

 


마무리

SpringBoot와 QueryDSL을 활용하여 동적 쿼리와 페이지네이션을 구현하는 방법을 알아보았습니다. BooleanBuilder를 활용하면 복잡한 검색 조건도 깔끔하게 처리할 수 있으며, Spring Data의 Page 객체를 활용하면 페이지네이션도 간단하게 구현할 수 있습니다.

실무에서는 더 복잡한 검색 조건이나 정렬 조건이 필요할 수 있습니다. 이 예제를 기반으로 프로젝트에 맞게 확장하여 사용하시면 됩니다.

핵심 요약

  • QueryDSL의 BooleanBuilder를 활용하여 동적 쿼리 구성
  • null 체크를 통해 조건부로 쿼리 조건 추가
  • Spring Data의 Page 객체를 활용한 페이지네이션 구현
  • PageableExecutionUtils를 사용한 count 쿼리 최적화
  • Entity와 DTO 분리를 통한 깔끔한 응답 구조

 

728x90