Develop

[Spring Boot] 게시판 - 10. QueryDSL로 동적 검색 기능 구현하기 본문

백엔드/게시판 만들기

[Spring Boot] 게시판 - 10. QueryDSL로 동적 검색 기능 구현하기

230801 2025. 12. 5. 16:15

안녕하세요 .ᐟ 

오늘은 게시판의 핵심 기능 중 하나인 검색 기능을 QueryDSL을 사용해서 구현해봤습니다.

"제목으로만 검색할까? 내용도 포함할까? 작성자 이름도?" 등등 .. 이런 동적 검색 조건을 깔끔하게 처리하는 방법을 공유합니다~

 


 

왜 QueryDSL을 사용할까?

 

JPA 의 @Query 로 검색 구현

  • 검색 조건이 늘어날수록 쿼리가 복잡해지고, 가독성이 떨어집니다.
@Query("SELECT p FROM Post p WHERE " +
       "(:title IS NULL OR p.title LIKE %:title%) AND " +
       "(:content IS NULL OR p.content LIKE %:content%) AND " +
       "(:author IS NULL OR p.user.username LIKE %:author%)")
Page<Post> searchPosts(String title, String content, String author, Pageable pageable);

 

QueryDSL 로 검색 구현

  • 자바 코드로 쿼리 작성 → 컴파일 시점에 오류 체크
  • 동적 쿼리를 깔끔하게 → null 조건 자동 처리
  • IDE 자동완성 지원 → 오타 방지
  • 타입 안전성 → 리팩토링 시 자동 반영

 

 


QueryDSL로 검색 기능 구현하기

 

1. QueryDSL 설정

build.gradle 의존성 추가

  • Gradle 빌드 후 build/generated/sources/annotationProcessor/java/main에 Q클래스(QPost, QUser 등)가 자동 생성됩니다.
dependencies {
    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"
}

 

 

QueryDslConfig (설정 클래스)

  • 역할
    • QueryDSL을 사용하기 위한 핵심 도구를 스프링 빈으로 등록
  • 용도
    • JPAQueryFactory 가 있어야 QueryDSL 쿼리 작성 가능
    • EntityManager 를 주입받아서 데이터베이스와 통신
@Configuration
public class QueryDslConfig {

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

 

 

PostSearchCondition (검색 조건 DTO)

  • 역할
    • 검색에 필요한 조건들을 묶어서 전달
  • 용도
    • 파라미터가 많을때 깔끔하게 관리
    • null 체크를 한 곳에서 처리
    • 나중에 조건을 추가하기 쉬움
@Getter
public class PostSearchCondition {
    private String title;
    private String content;
    private String author;
    private Long categoryId;
}

 

 


 

2. Custom Repository 패턴 적용

  • QueryDSL을 사용하려면 Custom Repository 패턴을 적용해야 합니다.
repository/
├── PostRepository.java              (메인)
├── PostRepositoryCustom.java        (인터페이스)
└── PostRepositoryImpl.java          (구현체)

 

  • 왜 이렇게 나눌까?
    • JpaRepository: 기본 CRUD 기능 제공
    • Custom Repository: 복잡한 동적 쿼리 전용

둘을 분리해서 관심사를 명확히 하고, 코드를 재사용 가능하게 만듭니다.

 

 


 

3. PostRepositoryCustom (인터페이스)

  • 역할
    • "이런 기능이 필요해요"라고 선언하는 계약서 역할입니다.
  • 용도
    • JpaRepository 가 제공하는 기본 메서드(findById, save.. 등) 외에 복잡한 동적 쿼리 기능을 추가하고 싶을 때 사용
public interface PostRepositoryCustom {
    Page<Post> searchPosts(PostSearchCondition condition, Pageable pageable);
}

 

 

 

PostRepositoryImpl (구현체)

  • 역할
    • QueryDSL로 쿼리를 작성하는 곳 입니다.
  • 레포지토리 이름 뒤에 Impl 을 붙이면 Spring Data JPA가 자동으로 인식해서 연결해줍니다.
@RequiredArgsConstructor
public class PostRepositoryImpl implements PostRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Page<Post> searchPosts(PostSearchCondition condition, Pageable pageable) {
        // 실제 QueryDSL 작성
    }
}

 

 

  • BooleanExpression과 null 처리
    • null 을 리턴하면 QueryDSL이 해당 조건을 자동으로 무시합니다!
private BooleanExpression titleContains(String title) {
    return hasText(title) ? post.title.contains(title) : null;
}

 

아래처럼 null 인 필드는 where 조건에서 제외하고 검색하게 됩니다.

  • title=Spring, content=null → WHERE title LIKE '%Spring%'
  • title=null, content=JPA → WHERE content LIKE '%JPA%'
  • 둘 다 null → WHERE 조건 없이 전체 조회

 

  • fetchJoin() 사용
    • 연관된 엔티티(User, Category)를 한 번에 조회합니다. → N+1 문제 예방
.leftJoin(post.user, user).fetchJoin()
.leftJoin(post.category, category).fetchJoin()

 

아래처럼 fetchJoin을 안 쓰면,  21번의 쿼리가 발생합니다!

fetchJoin을 쓰면 단 1번으로 해결 됩니다.

  1. 게시글 10개 조회 (쿼리 1번)
  2. 각 게시글의 작성자 조회 (쿼리 10번)
  3. 각 게시글의 카테고리 조회 (쿼리 10번)

 

  • count 쿼리를 따로 날리는 이유
    • 페이징을 위해 전체 개수가 필요하기 때문입니다. 
      • count 쿼리 최적화 가능 (JOIN 불필요)
      • 쿼리 의도가 명확해짐
// 데이터 조회
List<Post> content = queryFactory.select(...).fetch();

// 개수 조회 (별도)
Long total = queryFactory.select(post.count()).fetchOne();

 

 

 

 


 

4. PostRepository 연결 (main Repository)

  • 역할
    • 기본 CRUD + 커스텀 검색 기능 모두 사용이 가능합니다.
  • 나누는 이유는?
    • 관심사의 분리
    • 테스트 용이
    • 재사용 가능
public interface PostRepository 
    extends JpaRepository<Post, Long>, PostRepositoryCustom {
    // 기존 메서드들...
}
  • PostRepositoryCustom을 상속받으면 끝!

 

 


5. 테스트

1. 전체 조회

GET /posts/search

2. 제목으로 검색

GET /posts/search?title=Spring

3. 복합 검색

GET /posts/search?title=JPA&categoryId=1

4. 페이징 + 정렬

GET /posts/search?title=Spring&page=0&size=10&sort=createdAt,desc

 

 

검색 결과 예시

데이터가 있을 때

{
  "success": true,
  "data": {
    "content": [
      {
        "id": 1,
        "title": "Spring Boot 입문",
        "content": "Spring Boot를 시작하는 방법",
        "authorName": "초",
        "categoryName": "개발",
        "viewCount": 5,
        "createdAt": "2024-12-05T..."
      }
    ],
    "totalElements": 1,
    "totalPages": 1,
    "size": 10,
    "number": 0
  }
}

 

검색 결과가 없을 때

  • 빈 배열을 받으면 프론트엔드에서 "검색 결과가 없습니다" 메시지를 표시하면 됩니다.
{
  "success": true,
  "data": {
    "content": [],
    "totalElements": 0,
    "totalPages": 0
  }
}

 

 

 


 

느낀 점

  • QueryDSL 을 쓰면 컴파일 타임에 오류를 잡을 수 있다.(JPQL 은 오타가 나도 실행전까지는 모름)
  • 파일을 왜이렇게 많이 만들어야 되지? 했는데 Custom Repository 패턴을 적용해보고 나니 검색 조건을 추가하거나 수정할때 필요한 Condition 부분만 수정하면돼서 확장성과 편리함이 좋았다.
  • BooleanExpression 에서 null 리턴 시 조건이 무시되고 where 절을 만드는 부분이 신기했다.