| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 | 31 |
Tags
- 10px
- TypeScript
- 타입스크립트
- 클론코딩
- 당근마켓
- angular
- github
- font-size
- npm
- 으
- 전역변수
- 문서번호
- ZOOM
- jwt
- Props
- Strict
- 0.5px border
- 데이터베이스 #try #이중
- es6
- literal
- 1px border
- ES5
- 0.25px border
- &연산
- 0.75px border
- 컴포넌튼
- entity
- 서버리스 #
- TS
- Websocket
Archives
- Today
- Total
복잡한뇌구조마냥
[Spring] JPA + QueryDSL 정리 본문
Spring Data JPA만으로도 웬만한 CRUD는 해결되지만,
현실적인 서비스에서는 점점 이런 생각이 든다.
“조건이 자꾸 늘어나는데… 이거 진짜 findByAAndBOrCAndD... 로 버티는 게 맞나?”
JPA + QueryDSL 조합으로 어떻게 조회 쿼리를 깔끔하게 관리할 수 있는지 프로젝트 기준으로 정리해본다.
1. QueryDSL이 뭐고, 왜 쓰는가?
1) 한 줄 정의
QueryDSL = 타입 안전한 쿼리 빌더
- JPQL/HQL을 문자열로 쓰는 게 아니라,
코드로 쿼리를 조합할 수 있게 해주는 라이브러리다. - 컴파일 타임에 문법을 체크할 수 있어서, 런타임에 "쿼리 오타"로 깨지는 상황을 줄여준다.
2) 장점 요약
- ✅ 타입 안전: 컬럼 이름, 엔티티 명을 문자열로 안 쓰고, Q클래스로 참조
- ✅ 동적 쿼리에 강함: BooleanExpression 조합해서 조건을 붙였다 뗐다 쉽게 가능
- ✅ 재사용 가능: where 절 조각 메서드를 만들어서 재사용
2. QueryDSL 기본 설정 (Spring Boot + Gradle 기준)
1) Gradle 의존성
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")
}
- 스프링 부트 3 / JPA 3는 jakarta 패키지 사용이라,
:jakarta classifier 붙여줘야 함.
2) Q클래스 생성 경로 설정 (선택)
보통 build/generated 아래에 QMember, QPost 이런 클래스들이 만들어진다.
IntelliJ에서 자동으로 잡히면 상관없지만, 안 잡히면 sourceSets 설정을 추가해도 됨.
val querydslSrcDir = "src/main/generated"
tasks.clean {
delete(file(querydslSrcDir))
}
tasks.withType<JavaCompile>().configureEach {
options.generatedSourceOutputDirectory.set(file(querydslSrcDir))
}
3. Q클래스와 JPAQueryFactory
1) Q 클래스란?
예를 들어 Member 엔티티가 있으면, 빌드 시 자동으로 QMember라는 클래스가 생성된다.
QMember member = QMember.member;
이 객체를 통해 컬럼에 접근한다.
member.id
member.email
member.nickname
2) JPAQueryFactory 빈 등록
보통 공통 설정으로 하나 만들어 둔다.
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager em;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(em);
}
}
이제 Repository에서 JPAQueryFactory를 주입받아 쿼리를 날릴 수 있다.
4. 커스텀 Repository + QueryDSL 패턴
보통 구조는 아래처럼 나눠 쓴다.
MemberRepository (extends JpaRepository)
└─ MemberQueryRepository (인터페이스)
└─ MemberQueryRepositoryImpl (QueryDSL 구현체)
1) 인터페이스 정의
public interface MemberQueryRepository {
Page<Member> searchMembers(MemberSearchCondition condition, Pageable pageable);
}
2) 구현체에서 QueryDSL 사용
@Repository
@RequiredArgsConstructor
public class MemberQueryRepositoryImpl implements MemberQueryRepository {
private final JPAQueryFactory queryFactory;
@Override
public Page<Member> searchMembers(MemberSearchCondition condition, Pageable pageable) {
QMember member = QMember.member;
List<Member> content = queryFactory
.selectFrom(member)
.where(
emailEq(condition.getEmail()),
nicknameContains(condition.getNickname()),
statusEq(condition.getStatus())
)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = queryFactory
.select(member.count())
.from(member)
.where(
emailEq(condition.getEmail()),
nicknameContains(condition.getNickname()),
statusEq(condition.getStatus())
)
.fetchOne();
return new PageImpl<>(content, pageable, total);
}
private BooleanExpression emailEq(String email) {
return email != null ? QMember.member.email.eq(email) : null;
}
private BooleanExpression nicknameContains(String nickname) {
return nickname != null ? QMember.member.nickname.contains(nickname) : null;
}
private BooleanExpression statusEq(MemberStatus status) {
return status != null ? QMember.member.status.eq(status) : null;
}
}
⭐ 포인트
- where() 안에 null이 들어가면 자동으로 무시된다 → 동적 쿼리에 매우 유용
- 조건 메서드를 따로 빼서 재사용할 수 있다
- count 쿼리는 되도록 content 와 동일한 조건을 사용해야 함
5. 동적 쿼리 패턴 — BooleanBuilder vs BooleanExpression
동적 조건이 많아질수록 아래 두 가지 패턴 중 하나를 쓰게 된다.
1) BooleanExpression 조각 재사용 (추천)
위에 쓴 것처럼:
private BooleanExpression emailEq(String email) { ... }
private BooleanExpression statusEq(MemberStatus status) { ... }
사용:
.where(
emailEq(condition.getEmail()),
statusEq(condition.getStatus())
)
→ 조건이 늘어나도 깔끔하게 유지 가능.
2) BooleanBuilder 사용
BooleanBuilder builder = new BooleanBuilder();
if (condition.getEmail() != null) {
builder.and(member.email.eq(condition.getEmail()));
}
if (condition.getStatus() != null) {
builder.and(member.status.eq(condition.getStatus()));
}
queryFactory
.selectFrom(member)
.where(builder)
...
- if가 많아지면 길어지긴 하지만, 로직 분기가 아주 복잡한 경우에는 여전히 쓸 만하다.
6. fetchJoin으로 N+1 문제 해결
JPA에서 연관관계를 LAZY로 걸어두면, 조회 시 N+1 문제가 발생한다.
QueryDSL에서는 fetchJoin()으로 이를 해결할 수 있다.
public Optional<Post> findPostWithImages(Long postId) {
QPost post = QPost.post;
QPostImage image = QPostImage.postImage;
Post result = queryFactory
.selectFrom(post)
.leftJoin(post.images, image).fetchJoin()
.where(post.id.eq(postId))
.fetchOne();
return Optional.ofNullable(result);
}
- leftJoin(...).fetchJoin() → 연관된 컬렉션/엔티티를 한 번에 가져온다.
- @Transactional(readOnly = true)와 함께 쓰면 조회용 쿼리 튜닝에 좋음.
7. 공통 페이징 유틸 (applyPagination 같은 패턴)
너가 지금 프로젝트에서 쓰는 것처럼,
applyPagination(pageable, contentQuery, countQuery) 패턴 하나 만들어두면 재사용성이 크게 올라간다.
예를 들어:
public abstract class QuerydslRepositorySupporter {
protected <T> Page<T> applyPagination(
Pageable pageable,
Function<JPAQueryFactory, JPAQuery<T>> contentQuery,
Function<JPAQueryFactory, JPAQuery<Long>> countQuery
) {
JPAQuery<T> content = contentQuery.apply(queryFactory)
.offset(pageable.getOffset())
.limit(pageable.getPageSize());
List<T> results = content.fetch();
Long total = countQuery.apply(queryFactory).fetchOne();
return new PageImpl<>(results, pageable, total);
}
}
각 Repository에서:
public Page<PostDto> findPosts(PostSearchCondition condition, Pageable pageable) {
return applyPagination(pageable,
factory -> factory
.select(new QPostDto(
post.id,
post.title,
post.author.nickname
))
.from(post)
.join(post.author, member)
.where(
titleContains(condition.getTitle()),
categoryEq(condition.getCategory())
),
factory -> factory
.select(post.count())
.from(post)
.where(
titleContains(condition.getTitle()),
categoryEq(condition.getCategory())
)
);
}
8. QueryDSL 사용할 때 주의할 점
- Q클래스 빌드 이슈
- Gradle 설정 안 맞으면 Q클래스 생성 안 돼서 IDE 에러 → 보통 Build → Rebuild / Gradle clean 으로 해결
- count 쿼리 최적화
- 복잡한 조인 쿼리에서 그대로 count() 하면 성능 나쁠 수 있음 → 필요하면 count 쿼리는 단순하게 따로 설계
- fetchJoin + paging 주의
- 컬렉션 fetchJoin + paging은 JPA 스펙상 위험 → 필요 시 별도 쿼리 분리하거나, id 리스트 페이징 후 in절로 재조회
🔚 마무리
- MyBatis는 “SQL을 직접 다루는 맛”
- QueryDSL은 “JPA 위에서 타입 안전하게 조회 쿼리를 다루는 맛”
LIST
'BE > Spring' 카테고리의 다른 글
| [Spring] Spring AI ( OpenAI + Rag + MariaDB Vector ) (0) | 2025.11.18 |
|---|---|
| [Spring] Spring Security + OAuth2 로그인 (0) | 2025.10.15 |
| [Spring] MyBatis 구조 정리 (0) | 2025.08.29 |
| [Spring] Spring Security + JWT 인증 구조 (0) | 2025.08.28 |
| [Spring] 스프링 부트 생성 (1) | 2025.06.18 |