임도현의 성장
[Spring-Boot] QueryDsl 적용해보기 본문
🤯기능 요구 사항
- 문제 리스트를 응답한다.
- 필터링 기능을 문제 리스트에 적용 한다.
- 문제 리스트는 페이징이 되어 응답한다.
- 필터링 목록
- 검색 조건
- 카테고리별 정렬 (Y or N 선택)
- 풀었는 문제 정렬
- 안 풀었는 문제 정렬
- 스터디원이 풀었는 문제이면 스터디원 프로필 이미지 가져오기
- 내가 풀었는 문제인지
이런식으로 하나의 API에 조건이 6개이며 동적인 조건은 4개이다. 이럴 때 사용하는 것이 QueryDsl 이다.
👩🚀QueryDsl 란?
자바 백엔드 기술은 Spring Boot와 Spring Data JPA를 함께 사용한다. 하지만 복잡한 쿼리, 동적 쿼리를 구현하는 데 있어 한계가 있다. 이러한 문제점을 해결할 수 있는 것이 QueryDSL이다. QueryDSL은 자바 코드로 SQL문을 작성할 수 있어 컴파일 시에 오류를 발생하여 잘못된 쿼리가 실행되는 것을 방지할 수 있다.
🐵QueryDsl 왜 사용 해야 해?
- 타입 안정성
- JPQL이나 @Query를 사용하면 문자열 기반이므로 컴파일 시 오류를 잡지 못하지만
- QueryDSL은 메서드 체이닝 방식을 사용하여, 컴파일 시점에 SQL 문법 오류를 체크할 수 있음.
- 동적 쿼리 작성이 쉬움
- 아래 코드에서 자세히 설명
- 복잡한 JOION이나 서브쿼리 등 복잡석 SQL 처리 가능
- JPQL은 JOIN이 복잡하고 서브쿼리는 불가능 한 경우가 있음
- QueryDSL은 간단한 코드로 쉽게 작성 가능
🦖QueryDsl 안 쓰면?
QueryDSL을 사용하지 않으면 아래 코드처럼 조건문이 많아집니다. 장점으로는 단순한 조건에서는 이해하기 쉽지만 조건이 많아지면 가독성과 유지보수성이 낮아지며, 아래 Repository만 봐도 중복 코드가 많으면 JPQL 쿼리가 복잡해 지고 새로운 조건을 추가할 때 수정해야 할 부분이 많아지는 문제가 발생합니다.
public ProblemListResponse getProblemList(String memberId, String studyId, String category, String title, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
List<ProblemListDto> results;
if (title != null && !title.isEmpty()) { // 1. 검색 조건이 있음
if ("Y".equals(category)) {
results = problemRepository.findAllTitleCategoryProblemListWithCategoryY(title, memberId, category, pageable); // 1-1 검색 O 풀었는 문제 순 O
} else if ("N".equals(category)) {
results = problemRepository.findAllTitleCategoryProblemListWithCategoryNull(title, memberId, category, pageable); // 1-2 검색 O 풀었는 문제 순 X
} else {
results = problemRepository.findAllTitleProblemList(title, memberId, pageable); // 1-3 검색 O 정답률 순 O
}
} else {
if ("Y".equals(category)) { // 2. 검색 조건이 있음
results = problemRepository.findAllCategoryProblemListWithCategoryY(memberId, category, pageable); // 2-1 검색 X 풀었는 문제 순 O
} else if ("N".equals(category)) {
results = problemRepository.findAllCategoryProblemListWithCategoryNull(memberId, category, pageable); // 2-2 검색 X 풀었는 문제 순 X
} else {
results = problemRepository.findAllProblemList(memberId, pageable); // 2-3 검색 X 정답률 순 O
}
}
List<Long> problemNos = results.stream()
.map(ProblemListDto::getProblemId)
.collect(Collectors.toList());
List<ProblemSolverDto> solvers = problemRepository.findSolversByProblemNos(problemNos, studyId);
Map<Long, List<String>> problemSolverMap = solvers.stream()
.collect(Collectors.groupingBy(ProblemSolverDto::getProblemNo,
Collectors.mapping(ProblemSolverDto::getImgUrl, Collectors.toList())));
return ProblemListResponse.problemDto(results, problemSolverMap);
}
@Repository
public interface ProblemRepository extends JpaRepository<ProblemEntity, Long>, ProblemRepositoryCustom{
@Query("SELECT new com.mildo.dev.api.problem.domain.dto.response.ProblemListDto(p.problemNo, p.problemId, p.problemTitle, p.problemLevel, p.problemLink, c.codeStatus)" +
"FROM ProblemEntity p left join CodeEntity c on p.problemId = c.problemId " +
"and c.memberEntity.memberId = :memberId " +
"ORDER BY p.problemNo ASC")
List<ProblemListDto> findAllProblemList(@Param("memberId") String memberId, Pageable pageable);
@Query("SELECT new com.mildo.dev.api.problem.domain.dto.response.ProblemListDto(p.problemNo, p.problemId, p.problemTitle, p.problemLevel, p.problemLink, c.codeStatus) " +
"FROM ProblemEntity p left join CodeEntity c on p.problemId = c.problemId " +
"and c.memberEntity.memberId = :memberId " +
"WHERE p.problemTitle LIKE %:title% " +
"ORDER BY p.problemNo ASC")
List<ProblemListDto> findAllTitleProblemList(@Param("title") String title, @Param("memberId") String memberId, Pageable pageable);
@Query("SELECT new com.mildo.dev.api.problem.domain.dto.response.ProblemListDto(p.problemNo, p.problemId, p.problemTitle, p.problemLevel, p.problemLink, c.codeStatus) " +
"FROM ProblemEntity p LEFT JOIN CodeEntity c ON p.problemId = c.problemId " +
"AND c.memberEntity.memberId = :memberId " +
"WHERE p.problemTitle LIKE %:title% " +
"ORDER BY CASE " +
"WHEN c.codeStatus = 'Y' THEN 1 " +
"ELSE 2 END, p.problemNo ASC")
List<ProblemListDto> findAllTitleCategoryProblemListWithCategoryY(@Param("title") String title,
@Param("memberId") String memberId,
@Param("category") String category,
Pageable pageable);
@Query("SELECT new com.mildo.dev.api.problem.domain.dto.response.ProblemListDto(p.problemNo, p.problemId, p.problemTitle, p.problemLevel, p.problemLink, c.codeStatus) " +
"FROM ProblemEntity p LEFT JOIN CodeEntity c ON p.problemId = c.problemEntity.problemId " +
"AND c.memberEntity.memberId = :memberId " +
"ORDER BY CASE " +
"WHEN c.codeStatus = 'Y' THEN 1 " +
"ELSE 2 END, p.problemNo ASC")
List<ProblemListDto> findAllCategoryProblemListWithCategoryY(@Param("memberId") String memberId,
@Param("category") String category,
Pageable pageable);
@Query("SELECT new com.mildo.dev.api.problem.domain.dto.response.ProblemListDto(p.problemNo, p.problemId, p.problemTitle, p.problemLevel, p.problemLink, c.codeStatus) " +
"FROM ProblemEntity p LEFT JOIN CodeEntity c ON p.problemId = c.problemId " +
"AND c.memberEntity.memberId = :memberId " +
"WHERE p.problemTitle LIKE %:title% " +
"ORDER BY CASE " +
"WHEN c.codeStatus IS NULL THEN 1 " +
"ELSE 2 END, p.problemNo ASC")
List<ProblemListDto> findAllTitleCategoryProblemListWithCategoryNull(@Param("title") String title,
@Param("memberId") String memberId,
@Param("category") String category,
Pageable pageable);
@Query("SELECT new com.mildo.dev.api.problem.domain.dto.response.ProblemListDto(p.problemNo, p.problemId, p.problemTitle, p.problemLevel, p.problemLink, c.codeStatus) " +
"FROM ProblemEntity p LEFT JOIN CodeEntity c ON p.problemId = c.problemId " +
"AND c.memberEntity.memberId = :memberId " +
"ORDER BY CASE " +
"WHEN c.codeStatus IS NULL THEN 1 " +
"ELSE 2 END, p.problemNo ASC")
List<ProblemListDto> findAllCategoryProblemListWithCategoryNull(@Param("memberId") String memberId,
@Param("category") String category,
}
🍅QueryDsl 적용
QueryDSL의 장점은 위에 설명한것들이 있지만 단점은 설정 할 것들이 많다. QueryDSL 은 별다른 설정이 없으면, 자동으로 QClass 를 만들어 build 디렉토리에 저장한다. 기본적인 자동 생성 경로를 그대로 사용할 경우 IntelliJ IDEA 에서 문제가 발생할 수 있습니다. 이러한 단점을 해결하기 위해 generated 디렉토리와 같은 별도의 경로 설정을 통해 문제를 방지할 수 있습니다.
// dependencyManagement에서 mavenBom을 가져올 때 사용됨
ext {
springCloudVersion = "2023.0.4"
}
dependencies {
implementation "com.querydsl:querydsl-jpa:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
implementation "com.querydsl:querydsl-core"
implementation "com.querydsl:querydsl-collections"
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
}
// BOM은 여러 개의 라이브러리 버전을 한꺼번에 관리할 수 있도록 해주는 설정.
dependencyManagement {
imports {
mavenBom "org.springframework.cloud:spring-cloud-dependencies:$springCloudVersion"
}
}
// QueryDSL이 생성하는 QClass 파일의 위치를 src/main/generated로 지정.
def generated = "src/main/generated"
/*
querydsl QClass 파일 생성 위치를 지정
JavaCompile 작업을 수행할 때, QClass 생성 경로를 src/main/generated로 설정
기본적으로 QueryDSL이 빌드 디렉토리에 QClass를 생성하는데, 이를 명확하게 지정해 IntelliJ IDEA 등에서 빌드 오류를 방지.
*/
tasks.withType(JavaCompile) {
options.getGeneratedSourceOutputDirectory().set(file(generated))
}
// java sourceSets 에 querydsl QClass 위치 추가
// 만약 이 설정이 없다면, QClass를 import할 때 "Cannot resolve symbol" 오류가 발생할 수 있음.
sourceSets {
main.java.srcDirs += [ generated ]
}
//'gradle clean' 시에 QClass 디렉토리 삭제
clean {
delete file(generated)
}
📚Q클래스 생성
Tasks => build => clean : clean을 실행하면 기존 Q클래스 디렉토리 삭제
Tasks => other => compileJava : compileJava을 실행하면 Java 코드가 컴파일되면서 Q 클래스가 자동 생성
Q클래스를 생성하면 아까 build.gradle에 설정한 경로 src/main/generated 에 Q클래스가 생성 됩니다. Q클래스는 QueryDSL 전용 엔티티 클래스라고 생각하면 됩니다.
😚Repository 생성
기본 Repository에 ProblemRepositoryCustom을 상속해서 QueryDSL을 사용할 수 있도록 연결
@Repository
public interface ProblemRepository extends JpaRepository<ProblemEntity, Long>, ProblemRepositoryCustom{
}
😑ProblemRepositoryCustom 생성
QueryDSL을 활용하려면 JpaRepository에 직접 추가할 수 없고 따로 QueryDSL을 적용할 메서드를 따로 정의하는 interface를 만들어야 한다.
public interface ProblemRepositoryCustom {
List<ProblemListDslDto> findFilteredProblemList(String title, String category, String memberId, Pageable pageable);
}
🤐ProblemRepositoryImpl 생성
public class ProblemRepositoryImpl implements ProblemRepositoryCustom{
private final JPAQueryFactory queryFactory;
public ProblemRepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(JPQLTemplates.DEFAULT, em);
}
public List<ProblemListDslDto> findFilteredProblemList(String title, String category, String memberId, Pageable pageable) {
// Q클래스 선언
QProblemEntity problem = QProblemEntity.problemEntity;
QCodeEntity code = QCodeEntity.codeEntity;
// QueryDSL에서 동적 조건을 쉽게 추가할 수 있는 객체
BooleanBuilder whereClause = new BooleanBuilder();
// 문제 제목 검색 조건
if (title != null && !title.isEmpty()) {
whereClause.and(problem.problemTitle.contains(title));
}
// memberId 조건이 있을 때만 JOIN (불필요한 조인을 방지)
BooleanBuilder joinCondition = new BooleanBuilder();
if (memberId != null && !memberId.isEmpty()) {
joinCondition.and(code.memberEntity.memberId.eq(memberId));
}
// 정렬 조건 설정
List<OrderSpecifier<?>> orderSpecifiers = new ArrayList<>();
if ("Y".equals(category)) {
// status가 'Y'인 것을 먼저 정렬
orderSpecifiers.add(new CaseBuilder()
.when(code.codeStatus.eq("Y")).then(1)
.otherwise(2).asc());
} else if ("N".equals(category)) {
// status가 NULL인 것을 먼저 정렬
orderSpecifiers.add(new CaseBuilder()
.when(code.codeStatus.isNull()).then(1)
.otherwise(2).asc());
}
// 항상 problemNo 순으로 정렬
orderSpecifiers.add(problem.problemNo.asc());
// QueryDSL로 쿼리 작성
return queryFactory
.select(new QProblemListDslDto(
problem.problemNo,
problem.problemId,
problem.problemTitle,
problem.problemLevel,
problem.problemLink,
code.codeStatus
))
.from(problem)
.leftJoin(code).on(problem.problemId.eq(code.problemEntity.problemId).and(joinCondition)) // 동적 JOIN 조건 추가
.where(whereClause)
.orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) // 동적 정렬 적용
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
}
🙊ProblemRepositoryImpl 코드 설명
QueryDSL의 JPAQueryFactory는 JPQL을 생성하고 실행하는 핵심 객체이다. 이를 통해 select, from, where 등 SQL과 유사한 방식으로 쿼리를 작성할 수 있음. JPAQueryFactory를 초기화 해서 EntityManager를 인자로 받는다. JPQLTemplates.DEFAULT는 QueryDSL이 JPQL을 생성할 때 기본 템플릿을 사용하도록 설정 이렇게 하면 JPQL 기반의 동적 쿼리를 수행할 수 있습니다.
private final JPAQueryFactory queryFactory;
public ProblemRepositoryImpl(EntityManager em) {
this.queryFactory = new JPAQueryFactory(JPQLTemplates.DEFAULT, em);
}
BooleanBuilder는 QueryDSL에서 동적 조건을 쉽게 추가할 수 있는 객체이며 필요할 때마다 and()를 이용해서 조건을 추가할 수 있습니다. whereClause에는 검색 조건만 담고 joinCondition에는 memberId만 있을때 추가
// QueryDSL에서 테이블을 표현하는 클래스
QProblemEntity problem = QProblemEntity.problemEntity;
QCodeEntity code = QCodeEntity.codeEntity;
BooleanBuilder whereClause = new BooleanBuilder();
// title이 있으면 조건 추가
if (title != null && !title.isEmpty()) {
whereClause.and(problem.problemTitle.contains(title));
}
BooleanBuilder joinCondition = new BooleanBuilder();
if (memberId != null && !memberId.isEmpty()) {
joinCondition.and(code.memberEntity.memberId.eq(memberId));
}
QueryDSL의 orderBy()는 동적 정렬 조건을 여러 개 받을 수 있습니다. List<OrderSpecifier<?>>를 만들어 정렬 조건을 orderSpecifiers에 저장할 리스트를 생성합니다. orderBy조건을 추가 하는 곳을 보면 CaseBuilder() 객체가 보인다. CaseBuilder를 사용하여 Case When 을 사용 할 수 있다. 사용법은 when 절은 조건문이다. then은 when절이 true 인 경우 실행 otherwise는 when절이 false일때 실행 then절은 기술 안 해도 되지만 otherwise절은 무조건 기술해야 합니다.
List<OrderSpecifier<?>> orderSpecifiers = new ArrayList<>();
if ("Y".equals(category)) {
// status가 'Y'인 것을 먼저 정렬
orderSpecifiers.add(new CaseBuilder()
.when(code.codeStatus.eq("Y")).then(1)
.otherwise(2).asc());
} else if ("N".equals(category)) {
// status가 NULL인 것을 먼저 정렬
orderSpecifiers.add(new CaseBuilder()
.when(code.codeStatus.isNull()).then(1)
.otherwise(2).asc());
}
// 항상 problemNo 순으로 정렬
orderSpecifiers.add(problem.problemNo.asc());
select()를 사용하여 DTO(ProblemListDslDto)로 데이터를 매핑 코드를 보면 QProblemListDslDto로 매핑하는 이유는 Q클래스 DTO이다. QProblemListDslDto는 QueryDSL이 DTO 매핑을 지원하는 방법 중 하나이다. 아래 DTO를 생성하고 @QueryProjection 사용하면 QueryDSL에서 DTO 객체를 생성자로 직접 매핑할 수 있도록 도와 줍니다.
return queryFactory
.select(new QProblemListDslDto(
problem.problemNo,
problem.problemId,
problem.problemTitle,
problem.problemLevel,
problem.problemLink,
code.codeStatus
))
@Getter
@NoArgsConstructor
public class ProblemListDslDto {
private Long problemNo;
private Long problemId;
private String problemTitle;
private String problemLevel;
private String problemLink;
private String status;
@QueryProjection
public ProblemListDslDto(Long problemNo, Long problemId, String problemTitle, String problemLevel, String problemLink, String status) {
this.problemNo = problemNo;
this.problemId = problemId;
this.problemTitle = problemTitle;
this.problemLevel = problemLevel;
this.problemLink = problemLink;
this.status = status;
}
}
leftJoin을 보면 problem.problemId 와 code.problemEntity.problemId 가 같으면 joinCondition을 추가하여 JOIN을 실행합니다. orderBy를 보면 orderSpecifiers 정렬 조건을 toArray(new OrderSpecifier[0])로 변환하여 orderBy()에 적용하여 쿼리를 실행하고 결과를 리스트로 반환합니다.
.from(problem)
.leftJoin(code).on(problem.problemId.eq(code.problemEntity.problemId).and(joinCondition)) // 동적 JOIN 조건 추가
.where(whereClause)
.orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) // 동적 정렬 적용
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
🎅QueryDSL 연산자 목록
QueryDSL 메서드 | SQL 연산자 | 설명 |
eq | = | 값이 같은지 비교 |
ne | != | 값이 다른지 비교 |
like("abc") | LIKE 'abc' | SQL LIKE 와 동일 (완전 일치) |
contains("abc") | LIKE '%abc%' | 특정 문자열 포함 여부 검사 |
startsWith("abc") | LIKE 'abc%' | 특정 문자열로 시작하는지 검사 |
endsWith("abc") | LIKE '%abc' | 특정 문자열로 끝나는지 검사 |
lt | < | 미만 비교 |
loe | <= | 이하 비교 |
gt | > | 초과 비교 |
goe | >= | 이상 비교 |
isNull() | IS NULL | 값이 NULL인지 검사 |
isNotNull() | IS NOT NULL | 값이 NULL이 아닌지 검사 |
👾최종 코드
QueryDSL을 도입하면서 Service와 Repository 코드가 훨씬 짧아지고 불필요한 if문이 제거되어 유지보수성과 확장성이 향상 되었으며, BooleanBuilder를 사용해 동적 조건을 유연하게 추가할 수 있고 QueryDSL 을 통해 JPQL을 직접 다룰 필요 없이 객체 지향적으로 쿼리를 작성할 수 있게 되었습니다.
public ProblemListResponse getProblemList(String memberId, String studyId, String category, String title, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
// 1. 문제 가져오기
List<ProblemListDslDto> results = problemRepository.findFilteredProblemList(title, category, memberId, pageable);
// 2. 문제 번호만 뽑아 오기
List<Long> problemNos = results.stream()
.map(ProblemListDslDto::getProblemId)
.collect(Collectors.toList());
// 3. 문제 번호들로 우리 스터디원이 풀었으면 프로필 이미지 가져오기
List<ProblemSolverDto> solvers = problemRepository.findSolversByProblemNos(problemNos, studyId);
// 4. 문제들과 풀었는 문제들 맞춰 넣어 주기
Map<Long, List<String>> problemSolverMap = solvers.stream()
.collect(Collectors.groupingBy(ProblemSolverDto::getProblemNo,
Collectors.mapping(ProblemSolverDto::getImgUrl, Collectors.toList())));
return ProblemListResponse.problemDto(results, problemSolverMap);
}
'Spring Boot' 카테고리의 다른 글
[Spring-Boot] Promtail + Loki + Logback 모니터링 (0) | 2025.03.09 |
---|---|
[Spring-Boot] 객체 지향 설계 원칙 SOLID (0) | 2025.02.25 |
[Spring-Boot] Prometheus + Grafana 모니터링 구축 (1) | 2025.02.09 |
[Spring-Boot] 스프링 데이터 JPA (0) | 2025.02.04 |
[Spring-Boot] Exception 예외처리 (0) | 2025.01.14 |