티스토리 뷰

 

 

무수히 많은 데이터를 전부 조회하기 위해서는 1차적으로 필요한 만큼 화면에 뿌리기 위해 페이징이 필요하다. 추가로 검색 조건, 조건 정렬 등의 검색 기능이 포함될 수 있다.

Page 인터페이스만 알고 있던 중 QueryDSL 함께 활용한 Search 구현 방법을 알게 되었다.

Search 를 구현하며 겪은 트러블 슈팅을 중심으로 구현 방법을 정리해 보았다.


짚고 넘어가기


 

JPA 의 Page<>

@GetMapping("/search")
public Page<User> getAllUsers(
    @RequestParam(required = false, defaultValue = "10") int size,
    @RequestParam(required = false, defaultValue = "10") int page
) {
    PageRequest pageRequest = PageRequest.of(page, size);
    return userRepository.findAll(pageRequest);
}


스프링 데이터 JPA는 쿼리 메서드에 페이징을 위한 Peageable 파라미터를 제공한다.

Pageable 을 사용하면 반환타입으로 List 나 Page 를 사용할 수 있다.

Page 내 메서드를 활용하여 전체 페이지 수, 현재 페이지, 현재 페이지에 나올 데이터 수, 조회된 데이터 정보 등을 쉽게 개발할 수 있다.

Page 응답 결과


필요한 매개변수를 전달하여 손쉽게 원하는 방식으로 구현할 수 있다.

하지만 Page 를 사용할 경우 필요 이상의 모든 pageale 데이터를 얻게 되고, 직렬화의 문제가 발생할 수 있다.


PageModel

이러한 문제를 해결하기 위해 PageModel을 사용할 수 있다.
PageModel 은 Page 의 안정적인 JSON 표현을 구축하기 위한 DTO 객체이다.

  • perplexity.ai 를 활용하여 PageModel 반환으로 수정한 코드
@GetMapping("/pagedmodel")
    public PagedModel<User> getAllUserModels(
        @RequestParam(required = false, defaultValue = "10") int size,
        @RequestParam(required = false, defaultValue = "0") int page
    ) {
        PageRequest pageRequest = PageRequest.of(page, size);
        Page<User> userPage = userRepository.findAll(pageRequest);

        PagedModel.PageMetadata metadata = new PagedModel.PageMetadata(
            userPage.getSize(),
            userPage.getNumber(),
            userPage.getTotalElements(),
            userPage.getTotalPages()
        );

        return PagedModel.of(userPage.getContent(), metadata);
    }

PagedModel 응답 결과


꼭 확인해야 할 사항!

Paging 처리 시에는 해당 서비스에 @Transactional( readonly = true ) 를 반드시 붙여주어야 한다.

 


그럼 이제 Search 만들어주세요. 


일단, 쿼리 종류를 확인하자.

JPA 에서 데이터 조회를 위해 쿼리를 생성하는 방법은 아래 세 가지가 있다.

  • Query Method : 메서드 이름으로 자동으로 쿼리를 생성하는 Spring Data JPA의 기능. findAll()
  • JPQL : 엔티티 객체를 대상으로 하는 객체지향 쿼리 언어로, SQL과 유사한 문법을 가짐. @Value
  • QueryDSL : 타입 안전한 방식으로 복잡한 쿼리를 자바 코드로 작성할 수 있게 해주는 프레임워크


QueryDSL 이란?

QueryDSL 은 오픈소스 프로젝트이다. 쿼리를 문자가 아닌 코드로 작성할 수 있으며, 쉽고 간결하다.

스프링에서 QueryDSL 을 공식으로 인정하지 않았다는 부분이 있어서 수동 설정이 필요하다.



Search 구현하기


User 앤티티를 조회하는 기능을 개발한다. ‘키워드 포함 검색’은 제외하고 진행한다.

Search 의 기본적인 틀이 되는 내용으로 상세한 요구사항 정의는 생략한다.


시작하자마자 첫 번째 트러블 슈팅

문제상황: QueryDSL 의존성으로 인한 빌드 실패 및 Q-Class 생성 실패

 

1. 라이브러리 추가하기 (문제 해결)

Spring 버전에 따라 QueryDSL 을 적용시키는 방법은 많이 달랐다.

해당 프로젝트에서는 나는 Spring Boot 3.4.0 버전을 사용하였다.

 

> 과정에서 발생한 에러 코드들

더보기
  `Execution failed for task ':compileQuerydsl'.`
          `Annotation processor '' not found`
Caused by: org.springframework.beans.factory.BeanCreationException at AbstractAutowireCapableBeanFactory.java:1802

 

> 처음 설정 (spring 2.x 이하 참고)

더보기

해당 의존성은 spring 2.x 이하에서 정상 동작하는 듯하다. 

## 플러그인 추가
plugins {
	id 'io.spring.dependency-management' version '1.1.6'
}
## 의존성 추가
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"
implementation 'org.jetbrains:annotations:24.1.0'


해당 의존성은 spring 2.x 이하에서 정상 동작하는 듯하다.

추가로 아래와 같이 QClass 생성 위치를 지정해 주어 설정할 수 있다.

## classpath 추가
def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile

querydsl {
	jpa = true
	querydslSourcesDir = querydslDir
}

sourceSets {
	main.java.srcDir querydslDir
}

compileQuerydsl {
	options.annotationProcessorPath = configurations.querydsl
}

configurations {
	compileOnly {
		extendsFrom annotationProcessor
	}
	querydsl.extendsFrom compileClasspath
}

## 재빌드시 오류 방지
compileQuerydsl.doFirst {
	if(file(querydslDir).exists() )
		delete(file(querydslDir))
}

 

참고로 클래스 경로에서 아래 사진과 같이 오류가 발생하는 경우가 있는데, 위 코드처럼 작성해 주면 된다.

 

  •  Spring Boot 3.4.0 기준
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"


정상적으로 빌드되면 아래와 같이 build > generated 하위에 Qclass 가 생성된다.


이전 방식으로 사용할 경우, Qclass 를 gitignore 처리해주어야 하지만,

build 패키지 하위는 보통 이미 처리한 경우가 많아 추가로 작성할 필요가 없다.

*QClass 란?

엔티티의 메타 정보를 담고 있는 자동 생성 클래스로, 데이터베이스 쿼리 작성 시 타입 안전성을 제공한다.

QClass를 사용하면 컴파일 시점에 쿼리 오류를 검출할 수 있어, 런타임 시 발생할 수 있는 데이터베이스 관련 오류를 미리 방지한다. 이는 데이터베이스 작업의 안정성을 높일 수 있다.


2. application.yml

N+1 문제를 방지하기 위해 아래와 같이 설정을 추가한다.

  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 500  
    open-in-view: false


3. Configuration 등록

QueryDSL 을 사용하기 위해 config 클래스를 생성하여 빈으로 등록한다.

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

@Configuration
@RequiredArgsConstructor
public class QueryDslConfig {

    private final EntityManager entityManager;

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

}

 

4. 레포지토리 연결

 

QuerydslPredicateExecutor<Entity> 을 레포지토리에 상속받는다.

QuerydslPredicateExecutor는 Predicate를 JpaRepository에서 추출할 수 있도록 해준다.

*Predicate 란?

직역하면 ‘서술어’라는 뜻을 가진다. Page의 Predicate는 Spring Data JPA와 QueryDSL을 함께 사용할 때 페이징 처리와 동적 쿼리 생성을 위해 사용한다. 쿼리의 where 절에 해당하는 조건을 나타낸다.

 

5. PagedModel 객체 생성

import lombok.Getter;
import lombok.ToString;
import org.mosaic.auth.domain.entity.user.User;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.web.PagedModel;

@Getter
@ToString
public class UserPage extends PagedModel<User> {

  public UserPage(Page<User> userPage) {
    super(
        new PageImpl<>(
          userPage.getContent(),
          userPage.getPageable(),
          userPage.getTotalElements()
        )
    );
  }
}

 

6. PagedModel 을 리스트로 담는 응답 객체 생성

@Getter
@Builder(access = AccessLevel.PRIVATE)
public class UserPageResponse {

  private UserPage userPage;

  public static UserPageResponse of(Page<User> userPage) {
    return UserPageResponse.builder()
        .userPage(new UserPage(userPage))
        .build();
  }

}

 

7. Controller 작성

@QuerydslPredicate(root = User.class)

: Querydsl 조건을 자동으로 생성하는 어노테이션으로, User 엔티티를 대상으로 한다. 요청 파라미터를 기반으로 Predicate 객체를 생성.

@PageableDefault(sort = "id", direction = Sort.Direction.DESC)

: 페이징 정보의 기본값을 설정. 여기서는 id 필드를 기준으로 내림차순 정렬을 기본값으로 지정한다.

@GetMapping
public ResponseEntity<UserPageResponse> findPageByQuerydsl(
    @QuerydslPredicate(root = User.class) Predicate predicate,
    @PageableDefault(sort = "id", direction = Sort.Direction.DESC) Pageable pageable
) {
    return ResponseEntity.ok(userService.findAllByQueryDslPaging(predicate, pageable));
}

 

7. Service

Qclass 를 활용하여 요청 조건으로 데이터를 조회하는 로직을 처리해 준다.

BooleanBuilder 를 활용하여 검색 조건을 추가하거나 제한한다.

여기서는 is_public 칼럼이 true 인 경우만 조회하도록 한다.

  • Qclass import

  • code
  @Transactional(readOnly = true)
  public UserPageResponse findAllByQueryDslPaging(Predicate predicate, Pageable pageable) {

    BooleanBuilder booleanBuilder = new BooleanBuilder(predicate);
    booleanBuilder.and(user.isPublic.eq(true));
    Page<User> userEntityPage = userRepository.findAll(booleanBuilder, pageable);
    return UserPageResponse.of(userEntityPage);

  }


BooleanBuilder & BooleanExpression

  • BooleanBuilder : 가변 객체로, 동적으로 조건을 추가한다.
  • BooleanExpression : 불변 객체로, 각 조건을 메서드로 표현한다.
  • BooleanExpression 코드
public List<Member> findMembers(String name, Integer age) {
    return queryFactory
        .selectFrom(member)
        .where(nameEq(name), ageGt(age))
        .fetch();
}

 

선택은 상황에 따라 다르지만,

간단한 쿼리나 재사용성이 중요한 경우 BooleanExpression을,

복잡한 동적 쿼리가 필요한 경우 BooleanBuilder를 사용하는 것이 좋다고 한다.

 

단, 대용량 처리의 경우 높은 가독성과 null 반환 시 조건이 무시되는 특징을 가진 BooleanExpression 사용이 더 좋을 수 있다.

 

조회 결과

  • 요청 url: http://localhost:19092/api/v1/admin/user?userId=2

이렇게 요청한 내용을 기준으로 검색(userId = 2)하고 페이징 처리되는 것을 확인할 수 있다.

Hibernate: 
    select
        u1_0.user_id,
        u1_0.created_at,
        u1_0.created_by,
        u1_0.deleted_at,
        u1_0.deleted_by,
        u1_0.is_deleted,
        u1_0.is_public,
        u1_0.password,
        u1_0.role,
        u1_0.slack_email,
        u1_0.updated_at,
        u1_0.updated_by,
        u1_0.user_uuid,
        u1_0.username 
    from
        p_users u1_0 
    where
        (
            u1_0.is_deleted = false
        ) 
        and u1_0.user_id=? 
    order by
        u1_0.user_id desc 
    offset
        ? rows 
    fetch
        first ? rows only

 

이제 두 번째 트러블 슈팅


문제상황: Company entity 의 OneToOne 연관관계로 인한 페이징 실패

  • 오류 코드
  • Resolved [org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Could not initialize proxy

연결 동작을 테스트하던 중 실패 메서드를 발견하였다.

Company 정보를 일괄 조회하는 메서드로 요청 시 아래와 같은 오류를 반환하며 응답값을 반환하지 못하였다.

오류 코드를 잘 읽어보면 Could not write JSON 이라는 로그를 발견할 수 있었다.


해결과정 1: 디버깅

반환 객체 직렬화의 문제점으로 인지하였고 현재 리턴 타입을 확인하기 위해 디버깅하였다.

반환 타입은 Page 를 CompanyResponseDTO 에 잘 담아 옮겨지고 있었다.

 

?? 그럼 어떤 문제인가?



해결과정 2: 구글링

아래 블로그에서 동일한 문제를 찾았다.

반환되는 객체는 Entity 가 아닌 반드시 DTO 타입으로 보내야 한다.

https://velog.io/@myway00/Could-not-write-JSON-could-not-initialize-proxy-com.fasterxml.jackson.databind.JsonMappingException

 

나는 CompanyResponseDTO 를 반환하도록 되어있는데 잘 지키고 있는 게 아닌가?

현재 보고 있는 오류


에러 코드를 다시 확인하면 User Entity 직렬화가 불가능하다는 오류 내용이다.

지금 호출하는 엔티티 정보는 Company인데?

문제를 정확히 찾은 것 같으니 따라가 보자

 

원인 분석 및 해결책 도출

사용하는 엔티티 정리 : Companies ↔ Users (One to One)


Company 내에는 User 를 가지고 있다.

즉, 아래와 같이 Company 를 바로 페이징 한다면, 내부에 User 엔티티 타입이 담기게 된다.

CompanyResponseDTO 로 변환하는 과정 내에서는 User 앤티티 타입을 수정하는 포인트가 없다는 뜻이다.

현재 컨트롤러의 문제 원인 코드

각설하고, Page에 저장하는 값으로 엔티티 타입이 아닌 CompanyDto를 담도록 하자.

 

문제해결


QueryDsl 을 처음 사용하는 상황이라, 쿼리문 자제를 수정하는 방식을 선택하고 싶지 않았다.

현재 코드에서 최대한 달라지지 않는 선에서 해결책을 찾아보았다.

  • 수정된 Service
public CompanyPageResponse findAllByQueryDslPaging(Predicate predicate, Pageable pageable) {

    BooleanBuilder booleanBuilder = new BooleanBuilder(predicate);
    Page<Company> companyEntityPage = companyRepository.findAll(booleanBuilder, pageable);
    Page<CompanyDto> companyDtoPage = companyEntityPage
        .map(company -> CompanyDto.create(
            company.getCompanyName(),
            company.getCompanyAddress(),
            company.getCompanyType(),
            company.getUser().getUserId(),
            company.getHubId()));

    return CompanyPageResponse.of(companyDtoPage);


Page 로 반환된 객체를 Dto 로 변환해 준다.

PageModel 에서 Company를 페이징 하는 내용을 Dto 로 변경해 주었다.

  • PageModel
public class CompanyPage extends PagedModel<CompanyDto> {

  public CompanyPage(Page<CompanyDto> companyPage) {
    super(
        new PageImpl<>(
            companyPage.getContent(),
            companyPage.getPageable(),
            companyPage.getTotalElements()
        )
    );
  }
}


코드 리팩토링

응답용 객체를 이용하여 코드 전체를 가독성 높게 수정하였다.

    Page<CompanyResponse> companyDtoPage = companyEntityPage
        .map(CompanyResponse::of);

 

결과 (성공)

predicator 도 잘 적용되어 원하는 키워드로 쉽게 검색할 수 있다.

 

쿼리문을 수정하여 필요한 정보만 추출해 내는 것이 성능적으로 좋겠지만, 개발 기간이 3일 남은 상황에서 추가적인 학습을 할 수는 없었다.

오직 PageMode 과 Predicate 를 활용하고 싶어 QueryDsl 을 사용한 것이기 때문에, 원하는 결과를 만들어 낸 듯하다.

QueryDSL 활용이 낯선, 나와 비슷한 사람들에게 모든 최대한 많은 해결책을 제공해 주길 바라며 내용을 정리해 보았다.

 


 

[참고사이트]

 

자바 ORM 표준 JPA 프로그래밍 - 예스24

자바 ORM 표준 JPA는 SQL 작성 없이 객체를 데이터베이스에 직접 저장할 수 있게 도와주고, 객체와 관계형 데이터베이스의 차이도 중간에서 해결해준다. 이 책은 JPA 기초 이론과 핵심 원리, 그리고

www.yes24.com

 

[배워보자 Spring Data JPA] JPA 에서 Pageable 을 이용한 페이징과 정렬

해당 글은 배워보자 Spring Data JPA 시리즈 입니다. 해당 시리즈의 내용이 이어지는 형태이므로 글의 내용 중에 생략되는 말들이 있을 수 있으니, 자세한 사항은 아래 링크를 참고해주세요! Spring Dat

wonit.tistory.com

 

PagedModel (Spring Data Core 3.4.1 API)

public class PagedModel extends Object Since: 3.3 Author: Oliver Drotbohm, Greg Turnquist Copyright © 2011–2024 Pivotal Software, Inc.. All rights reserved.

docs.spring.io

 

[Spring] QueryDsl gradle 설정 (Spring boot 3.x , 2.x ) (1)

querydsl단독으로 쓸 때는 아니고, spring data jpa와 같이 쓰는 경우이다.💻 개발 환경OS: Ubuntu20.04 (wsl)IDE: IntelliJJdk: openjdk 17버전Gradle언어: GroovyGradle 콘솔 사용법./gradlew c

velog.io

 

스프링3.0에서 querydsl 설정시 나는 빌드 에러 문의드려요... - 인프런 | 커뮤니티 질문&답변

누구나 함께하는 인프런 커뮤니티. 모르면 묻고, 해답을 찾아보세요.

www.inflearn.com

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/11   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30
글 보관함