실무에서 가장 많이 사용되는 기능 중 하나는 동적 쿼리입니다. 사용자가 다양한 조건으로 데이터를 검색할 때, 조건에 따라 쿼리를 동적으로 생성해야 합니다. 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)
}
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
}
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);
}
}
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() |
- 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 분리를 통한 깔끔한 응답 구조
'개발 > BACK' 카테고리의 다른 글
| 통제망 환경에서 외부에서 내부망 API 호출하는 서버 아키텍처 설계 (0) | 2025.12.27 |
|---|---|
| Spring Boot + QueryDSL로 DB에서 영상 스트리밍 구현하기 (0) | 2025.12.27 |
| Spring 환경에서 RabbitMQ 설정하기 예제 (0) | 2025.12.25 |
| Springboot 스케줄러 개발 예제 @Scheduled (0) | 2025.12.24 |
| Spring 환경에서 DB 접근 최소화 방법 정리 (0) | 2025.12.24 |