복잡한뇌구조마냥

[Spring] JPA + QueryDSL 정리 본문

BE/Spring

[Spring] JPA + QueryDSL 정리

지금해냥 2025. 10. 2. 21:12

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 사용할 때 주의할 점

  1. Q클래스 빌드 이슈
    • Gradle 설정 안 맞으면 Q클래스 생성 안 돼서 IDE 에러 → 보통 Build → Rebuild / Gradle clean 으로 해결
  2. count 쿼리 최적화
    • 복잡한 조인 쿼리에서 그대로 count() 하면 성능 나쁠 수 있음 → 필요하면 count 쿼리는 단순하게 따로 설계
  3. fetchJoin + paging 주의
    • 컬렉션 fetchJoin + paging은 JPA 스펙상 위험 → 필요 시 별도 쿼리 분리하거나, id 리스트 페이징 후 in절로 재조회

🔚 마무리

  • MyBatis는 “SQL을 직접 다루는 맛”
  • QueryDSL은 “JPA 위에서 타입 안전하게 조회 쿼리를 다루는 맛”
LIST