스프링에서 검색기능을 구현하는데에는 여러가지 방법이 있지만 나는 그 중 2가지 방법을 소개하려고 한다.
1. @query
필터링할 항목이 적을때는 간단하게 @query 어노테이션을 사용해서 처리할 수 있다.
예를 들어서 어떤 팀에서 해당 유저가 리더인지 확인해야 한다면
/**
* 해당 팀의 리더 여부 확인
* @param teamNo 팀번호
* @param userNo 유저번호
* @return Boolean
*/
@Query("SELECT tu.isLeader " +
"FROM TeamUser tu " +
"JOIN User u ON tu.user.no = u.no " +
"WHERE tu.team.no = :teamNo AND u.no = :userNo")
Boolean isLeader(@Param("teamNo") Long teamNo, @Param("userNo") Long userNo);
@Query로 sql 쿼리문을 작성해서 비교하고,
아래에는 해당 쿼리문에 들어갈 인자값을 @Param 으로 넘겨주고 결과를 반환한다.
여기 이 코드에서는 TeamUser테이블과 User 테이블을 유저 번호로 조인하고,
TeamUser에 있는 teamNo로 같은 팀을 찾고, userNo로 해당 유저의 정보를 조회한다.
그리고 그 테이블의 isLeader를 찾아 반환하는 코드이다.
이런식으로 조금더 상세하게 조회를 해야한다면 @Query 어노테이션을 사용하기도 한다.
하지만 조건이 더 복잡하고 인자값이 더 많다면 어떻게 될까?
/**
* 모든 팀 정보 조회
* @param userNo (필터) 유저번호
* @param pageable 페이징 크기
* @return TeamSearchDto
*/
@Query("SELECT new com.beyond.backend.domain.team.dto.TeamResponseDto" +
"(t.no, t.teamName, t.teamIntroduce, t.projectStatus) " +
"FROM Team t " +
"JOIN TeamUser tu ON t.no = tu.team.no " +
"WHERE (:userNo IS NULL OR tu.user.no = :userNo) " +
"AND (:teamName IS NULL OR t.teamName LIKE %:teamName%) " +
"AND (:teamIntroduce IS NULL OR t.teamIntroduce LIKE %:teamIntroduce%) " +
"AND (:projectStatus IS NULL OR t.projectStatus = :projectStatus) " +
"GROUP BY t.no")
Page<TeamResponseDto> findByUserNoForUserTeams(
@Param("userNo") Long userNo,
@Param("teamName") String teamName,
@Param("teamIntroduce") String teamIntroduce,
@Param("projectStatus") ProjectStatus projectStatus,
Pageable pageable);
보기만 해도 복잡한 이 코드는 모든 팀의 정보를 조회하는 코드이다.
Page를 사용하여 페이징 처리를 하였고, 인자값을 받아오되 만약 값이 Null 이라면 (빈 값이라면) 해당 조건은 건너띈다.
동작하는데에는 전혀 문제가 없지만 가독성도 떨어지고 많이 복잡하다.
이렇게 많은 양의 조건을 붙여야하는 복잡한 동적 쿼리문을 처리하기엔 비효율적이다.
2. Querydsl
Querydsl은 타입 안전한(Type-Safe) 쿼리를 생성할 수 있도록 도와주는 오픈 소스 프레임워크로서
자바 코드를 이용해서 데이터베이스 쿼리 (SQL, JPQL 등)를 작성할 수 있게 해주는 "빌더" 역할을 한다.
쉽게 말해, 우리가 보통 SQL이나 JPQL 쿼리를 문자열로 작성하는데,
이 문자열 쿼리는 오타가 나거나 필드명이 바뀌어도 컴파일 시점에는 오류를 알 수 없고 런타임에 가서야 오류가 발생한다.
Querydsl은 이런 문제점을 해결하기 위해 나왔다.
하지만 초기에 설정해야 하는 부분들이 조금 까다로워서 그 부분에서 약간 애를 먹었다.
Querydsl의 주요 특징 및 장점
1. 타입 안전성 (Type-Safety)
- 가장 큰 장점으로서 Querydsl은 컴파일 시점에 엔티티(Entity)를 기반으로 Q-Class라는 것을 자동으로 생성합니다. 이 Q-Class는 엔티티의 필드들을 정적인 타입으로 가지고 있어서, 쿼리를 작성할 때 오타나 잘못된 필드명을 사용하면 컴파일 에러가 발생합니다.
- 이는 런타임 오류를 줄여주고 개발 생산성을 크게 향상시킬 수 있다.
2. 직관적인 쿼리 작성
- 문자열 쿼리 대신 자바 코드로 쿼리를 작성하므로 IDE의 자동완성 기능을 활용 가능하다.
- SQL이나 JPQL과 유사한 문법으로 메서드 체이닝 방식을 사용하여 쿼리를 구성하기에 가독성이 좋다.
3 .동적 쿼리 (Dynamic Query) 생성 용이
- where() 절 등에 조건을 유연하게 추가하여 조건에 따라 쿼리 내용이 달라지는 동적 쿼리 작성이 매우 편리하다.
- 예를 들어서, 검색 조건이 있을 때만 특정 AND 조건을 추가하는 등의 로직을 깔끔하게 구현 가능 하다.
4 . 다양한 백엔드 지원
- JPA, JDBC, SQL, MongoDB 등 다양한 데이터 베이스 및 데이터 소스 지원
5. 프로젝션 (Projection) 기능
- 쿼리 결과를 특정 DTO로 바로 매핑하여 가져올 수 있는 기능을 지원한다.
- 이를 통해 엔티티 전체를 가져오지 않고 필요한 데이터만 선택적으로 가져올 수 있다.
Querydsl의 동작 방식 (JPA 기준)
1. Q-Class 생성
- Querydsl을 사용하기 위해 빌드 도구 (Gradle, Maven)에 설정을 추가하면, 컴파일 시점에 엔티티 클래스(예: Member.java)를 분석하여 해당 엔티티에 대한 Q-Class (예: QMember.java)를 자동으로 생성합니다.
이 Q-Class는 엔티티 필드에 해당하는 정적인 필드들을 가지고 있습니다. (예: QMember.member.name, QMember.member.age).
2. JPAQueryFactory를 이용한 쿼리 작성
- JPAQueryFactory라는 객체를 생성하여 Querydsl 쿼리를 시작합니다.
이 팩토리를 통해 Q-Class를 사용하여 select, from, where, join, orderBy 등 SQL과 유사한 메서드를 체인 방식으로 호출하여 쿼리를 구성합니다.
3. 쿼리 실행
- fetch(), fetchOne(), fetchResults() (deprecated), fetchCount() (deprecated) 등의 메서드를 호출하여 구성된 쿼리를 실행하고 결과를 반환받습니다.
간단한 예시 코드
예를 들어, 사용자가 주문한 상품만 보여주는 페이지에 검색기능과 관리자 여부를 판단해야 하는 코드가 있다고 생각해보자
그 경우 Querydsl은 이렇게 코드를 작성할 수 있다.
먼저 조건문을 이렇게 작성할 수 있다.
...
import static com.beyond.homs.company.entity.QCompany.company;
import static com.beyond.homs.order.entity.QOrder.order;
import static com.beyond.homs.product.entity.QProduct.product;
import static com.beyond.homs.order.entity.QOrderItem.orderItem;
import static com.beyond.homs.user.entity.QUser.user;
import static com.beyond.homs.wms.entity.QDeliveryAddress.deliveryAddress;
import static com.beyond.homs.order.entity.QClaim.claim;
...
@Repository
@RequiredArgsConstructor
public class OrderItemRepositoryImpl implements OrderItemRepositoryCustom {
// Querydsl 쿼리를 생성하는 핵심 클래스
// 내부적으로 EntityManager를 사용하여 데이터베이스에 접근
private final JPAQueryFactory queryFactory;
// 별칭 충돌을 피하기 위해 새로운 QCompany 인스턴스 생성
// deliveryAddress를 통한 company 조인에 사용할 별칭
private final QCompany deliveryCompany = new QCompany("deliveryCompany"); // 새로운 별칭
// 동적 검색 조건 메서드
private BooleanExpression searchOptions(String keyword, OrderSearchOption option) {
if (option == OrderSearchOption.ORDER_CODE){
return order.orderCode.contains(keyword); // 코드 검색
} else if (option == OrderSearchOption.COMPANY_NAME){
return company.companyName.contains(keyword); // 회사 검색
}
return null; // 일치하는 옵션 없으면 null
}
// 사용자 ID 기반 필터링 조건 추가
private BooleanExpression userEq(Long userId) {
if (userId == null) {
return null; // userId가 null이면 모든 주문 조회 (관리자 케이스)
}
// 주문을 생성한 user의 ID와 userId가 일치하는 경우
return order.user.userId.eq(userId);
}
// 관리자 역할에 따른 추가 필터링 조건
private BooleanExpression adminSpecificFilter(boolean isAdmin) {
if (isAdmin) {
// 관리자일 경우 deliveryName과 dueDate가 모두 null인 주문은 보지 못하게 필터링
return order.deliveryAddress.deliveryName.isNotNull().or(order.dueDate.isNotNull());
}
return null; // 관리자가 아니면 이 조건은 적용하지 않음
}
위에서 작성된 조건문을 아래의 쿼리문에서 사용하여 편리하게 필터링 하고 원하는 값만 가져올 수 있다.
@Override
public Page<OrderResponseDto> findOrders(OrderSearchOption option, String keyword, Long userId, boolean isAdmin, Pageable pageable) {
List<OrderResponseDto> content = queryFactory // JPAQueryFactory 사용
// select문 시작
.select(Projections.constructor(OrderResponseDto.class, // DTO 인트턴스 직접 생성
order.orderId,
order.orderCode,
company.companyName,
deliveryAddress.deliveryName,
order.orderDate,
order.dueDate,
order.approved,
order.parentOrder.orderId,
order.rejectReason,
order.orderStatus
))
.from(order)
.leftJoin(order.user,user)
.leftJoin(user.company,company)
.leftJoin(deliveryAddress.company,deliveryCompany)
.where(
searchOptions(keyword, option), // 동적 검색 조건
userEq(userId) , // 사용자 필터링 조건 추가
adminSpecificFilter(isAdmin) // 관리자 필터링 조건 추가
)
.orderBy(order.orderDate.desc()) // 정렬
.offset(pageable.getOffset()) // 페이징 시작 오프셋
.limit(pageable.getPageSize()) // 페이지 크기
.fetch(); // 실제 쿼리 실행 및 결과 리스트 반환
// 총 개수 쿼리
JPAQuery<Long> totalCount = queryFactory
.select(order.count()) // COUNT 쿼리
.from(order)
.leftJoin(order.user,user)
.leftJoin(user.company,company)
.leftJoin(deliveryAddress.company,deliveryCompany)
.where(
searchOptions(keyword, option),
userEq(userId),
adminSpecificFilter(isAdmin) // 관리자 필터링 조건 추가
);
// Spring Data JPA의 PageableExecutionUtils를 사용하여 Page 객체 생성
return PageableExecutionUtils.getPage(content,pageable,totalCount::fetchOne);
}
이렇게 작성하면 페이징이나 다른 조건문들도 쉽게 추가 가능하다.
코드가 더 길어보이지만 가독성이나 유지보수 측면에서도 더 효율적이라고 생각한다.
QuryDsl을 사용하기 전 설정해야 하는 것
Querydsl을 사용하기 위해서는 먼저 여러가지 설정을 해줘야하는데, 여기서는 Gradle 기준으로 설명하겠다.
먼저 Q-Class를 생성해야하는데 이것을 생성하기 위해서는 build.gradle에 아래의 코드를 추가해야한다.
- HOMS 프로젝트에서 내가 쓴 방법
// ...
ext {
querydslVersion = "5.0.0"
}
// ...
dependencies {
// ...
// --- Querydsl ---
implementation "com.querydsl:querydsl-jpa:${querydslVersion}:jakarta"
annotationProcessor "com.querydsl:querydsl-apt:${querydslVersion}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
// ...
}
- Gemini가 알려준 방법
plugins {
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10" // <-- 이 플러그인
}
// ...
querydsl { // <-- 이 블록 (플러그인에 의해 제공됨)
jpa = true
querydslSourcesDir = "$buildDir/generated/querydsl"
}
sourceSets {
main {
java {
srcDirs += [ "$buildDir/generated/querydsl" ] // <-- Q-Class 경로 추가
}
}
}
가장 큰 차이점은 Q-Class 생성을 위한 Gradle 플러그인의 유무이고 핵심은 annotationProcessor 이다.
Gradle의 annotationProcessor 설정은 컴파일 시점에 특정 어노테이션을 처리하고 코드를 생성하는 역할을 합니다.
Querydsl의 querydsl-apt 모듈은 바로 이러한 어노테이션 프로세서입니다.
내가 사용한 build.gradle에서는 com.ewerk.gradle.plugins.querydsl 플러그인을 명시적으로 사용하지 않았지만,
다음과 같이 직접 annotationProcessor에 Querydsl의 APT 모듈을 추가했다.
annotationProcessor "com.querydsl:querydsl-apt:${querydslVersion}:jakarta"
이것만으로도 Q-Class는 정상적으로 생성된다. 그 이유는 아래와 같다고 한다.
- querydsl-apt 모듈의 역할: 이 모듈 자체가 엔티티 클래스에 있는 @Entity 등의 어노테이션을 감지하고, 해당 엔티티에 대한 Q-Class를 자동으로 생성하는 로직을 포함하고 있습니다.
- Gradle의 annotationProcessor 기본 동작: Gradle은 annotationProcessor 설정에 포함된 라이브러리들을 빌드 시점에 어노테이션 프로세서로 실행합니다. 따라서 querydsl-apt가 실행되어 Q-Class를 생성하게 됩니다.
- 기본 출력 경로: querydsl-apt는 특정 Gradle 플러그인이 설정되어 있지 않으면, 기본적으로 Gradle의 빌드 경로(일반적으로 build/generated/sources/annotationProcessor/java/main 또는 build/generated/sources/annotationProcessor/java/main/<패키지 경로>) 아래에 Q-Class를 생성합니다.
그렇다면 플러그인을 사용하는 이유는 무엇일까
- 명시적인 Q-Class 경로 지정: querydslSourcesDir을 통해 Q-Class가 생성될 정확한 디렉토리를 개발자가 직접 지정할 수 있습니다. 이는 빌드 스크립트의 가독성을 높이고, Q-Class의 위치를 예측 가능하게 만듭니다.
- sourceSets 자동 추가: Q-Class가 생성될 경로를 sourceSets.main.java.srcDirs에 자동으로 추가해줍니다. 이 플러그인을 사용하지 않으면 이 부분을 수동으로 추가해야 IDE에서 Q-Class를 소스 파일로 인식합니다.
- clean 태스크 통합: clean 태스크에 Q-Class 생성 디렉토리를 포함시켜 빌드 부산물을 깔끔하게 제거할 수 있도록 도와줍니다.
- 명확한 의도: 빌드 스크립트만 봐도 "이 프로젝트는 Querydsl을 사용하고 Q-Class를 자동 생성한다"는 의도를 명확하게 알 수 있습니다.
앞으로는 나도 플러그인을 사용해서 설정을 하도록 해야겠다.
이렇게 설정을 하고나면 Q-Class가 자동으로 생성이되고 커스텀 리포지토리를 생성해서 사용하게 된다.
결론
간단한 검색 쿼리가 필요할때는 @Query 어노테이션을 사용하면 되고,
복잡한 검색 쿼리가 필요할때는 QueryDsl을 사용하는것을 고려해보는게 좋을것 같다.