자바 스프링 부트 개념 정리(QueryDSL)

제이 용·2025년 12월 16일

JPQL의 한계와 QueryDSL

JPQL이란?

  • JPQL(Java Persistence Query Language) 은 엔티티 객체를 대상으로 쿼리를 작성하는 문자열 기반 쿼리 언어이다.
String jpql = "SELECT u FROM User u WHERE u.age > 20";
List<User> result = em.createQuery(jpql, User.class).getResultList();

JPQL의 한계

  • JPQL은 간단한 쿼리에는 유용하지만, 실무에서 복잡한 조건이나 동적 쿼리를 작성할 때 여러 한계가 드러난다.

요약

  • 문자열 기반 : IDE 자동완성, 리팩토링 지원 불가
  • 컴파일 타임 검증 불가 : 오타, 필드명 변경 시 런타임 오류
  • 유지보수 어려움 : 동적 쿼리 작성 시 가독성 급격히 저하
  • 타입 안정성 없음 : 잘못된 타입 비교도 컴파일 통과

쿼리가 길어질수록 안정성과 유지보수성이 급격히 떨어짐


QueryDSL의 등장

  • QueryDSL은 JPQL을 타입 안전(Type-safe) 하게 작성하기 위한 쿼리 빌더 라이브러리이다.

핵심 개념

  • JPQL을 대체하는 것이 아니라 JPQL을 생성하는 DSL

  • SQL이 아닌 JPA 위에서 동작

  • Java 코드로 쿼리를 작성

즉, 문자열 대신 객체와 메서드로 쿼리를 작성

QueryDSL 장점 요약

  • IDE 자동완성 : 엔티티 필드 자동완성 지원
  • 컴파일 타임 검증 : 잘못된 필드 접근 시 컴파일 에러
  • 동적 쿼리 강력 : BooleanExpression, BooleanBuilder
  • 가독성 : 쿼리 구조가 코드 흐름으로 드러남
  • 유지보수성 : 리팩토링에 안전

QueryDSL 설정 (Spring Boot 3.x)

  • build.gradle 설정
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"
  • 중요

    • Spring Boot 3.x → jakarta.persistence 기반

    • 반드시 :jakarta suffix 필요

  • QuerydslConfig 설정

@Configuration
public class QuerydslConfig {

    @PersistenceContext
    private EntityManager em;

    @Bean
    public JPAQueryFactory jpaQueryFactory() {
        return new JPAQueryFactory(em);
    }
}

JPAQueryFactory를 Bean으로 등록해 Repository에서 사용


Q-Type 이해하기

  • QueryDSL은 엔티티를 기반으로 Q타입 클래스를 자동 생성한다.

  • 엔티티

@Entity
public class User {

    @Id @GeneratedValue
    private Long id;

    private String username;
    private int age;
}
  • 생성된 QUser
@Generated("com.querydsl.codegen.EntitySerializer")
public class QUser extends EntityPathBase<User> {

    public static final QUser user = new QUser("user");

    public final StringPath username = createString("username");
    public final NumberPath<Integer> age = createNumber("age", Integer.class);
}

Q클래스 위치

build → classes → java → main → 엔티티 패키지 → Q클래스

이 Q타입을 통해 타입 안전한 쿼리 작성 가능


기본 QueryDSL 쿼리 예제

  • Repository 구조
public interface PostRepository
    extends JpaRepository<Post, Long>, PostCustomRepository {

    @EntityGraph(attributePaths = {"user", "comments"})
    List<Post> findByUserUsername(String username);
}

public interface PostCustomRepository {
    List<PostSummaryDto> findPostSummary(String username);
}
  • Custom Repository 구현
public class PostCustomRepositoryImpl implements PostCustomRepository {

    private final JPAQueryFactory queryFactory;

    public PostCustomRepositoryImpl(EntityManager em) {
        this.queryFactory = new JPAQueryFactory(em);
    }

    @Override
    public List<PostSummaryDto> findPostSummary(String username) {
        return queryFactory
            .select(Projections.constructor(
                PostSummaryDto.class,
                post.content,
                comment.countDistinct().intValue()
            ))
            .from(post)
            .leftJoin(post.comments, comment)
            .where(post.user.username.eq(username))
            .groupBy(post.id)
            .fetch();
    }
}

JPQL → QueryDSL 리팩토링 비교

❌ JPQL 버전

@Query("""
    SELECT new org.example.plus.domain.post.model.dto.PostSummaryDto(
        p.content,
        SIZE(p.comments)
    )
    FROM Post p
    WHERE p.user.username = :username
""")
List<PostSummaryDto> findAllWithCommentsByUsername(
    @Param("username") String username
);
  • 문제점

    • 문자열 기반

    • 필드명 변경 시 런타임 오류

    • IDE 도움 거의 없음

✅ QueryDSL 버전

@Override
public List<PostSummaryDto> findAllWithCommentsByUsername(String username) {
    return queryFactory
        .select(Projections.constructor(
            PostSummaryDto.class,
            post.content,
            comment.countDistinct().intValue()
        ))
        .from(post)
        .leftJoin(post.comments, comment)
        .where(post.user.username.eq(username))
        .groupBy(post.id)
        .fetch();
}
  • 장점

    • 자동완성 지원

    • 컴파일 타임 검증

    • 쿼리 구조가 코드 흐름 그대로 드러남

요약

JPQL은 문자열 기반이라는 한계가 있으며,

QueryDSL은 이를 타입 안전하고 유지보수 가능한 방식으로 해결한다.

실무에서는 복잡한 조회 로직일수록 QueryDSL의 가치가 커진다.


QueryDSL 기본 개념 & 사용법 정리

QueryDSL 기본 구조 이해

  • QueryDSL의 기본 쿼리 구조는 아래 패턴을 따른다.
queryFactory
    .select(조회대상)
    .from(대상엔티티)
    .where(조건)
    .orderBy(정렬)
    .fetch();
  • SQL과 유사하지만 Java 코드 기반

  • 각 단계가 메서드 체이닝으로 명확하게 구분됨

  • 필요 없는 절은 생략 가능 (where, orderBy 등)


Q-Type(타입) 활용법

  • QueryDSL은 컴파일 시점에 엔티티 기반 Q타입 클래스를 자동 생성한다.

  • 생성된 QUser 예시

public class QUser extends EntityPathBase<User> {

    public static final QUser user = new QUser("user");

    public final StringPath username = createString("username");
    public final StringPath email = createString("email");
    public final EnumPath<UserRoleEnum> roleEnum =
        createEnum("roleEnum", UserRoleEnum.class);
}
  • QUser.user 를 통해 엔티티 필드에 접근

  • 문자열이 아닌 객체 + 메서드 기반

  • IDE 자동완성 지원

  • 필드명 변경 시 컴파일 에러로 즉시 확인 가능

런타임 오류 → 컴파일 타임 오류로 전환


기본 검색 쿼리 예제

  • NORMAL 사용자 중 gmail.com 이메일을 가진 사용자 조회
List<User> result = queryFactory
    .selectFrom(user)
    .where(
        user.roleEnum.eq(UserRoleEnum.NORMAL),
        user.email.endsWith("gmail.com")
    )
    .fetch();
  • 결과
    • 앨리스 (alice@gmail.com)
    • 찰리 (charlie@gmail.com)

자주 사용하는 조건 메서드

  • eq() : 같다

    • user.roleEnum.eq(ADMIN)
  • ne() : 다르다

    • user.username.ne("관리자")
  • gt() : 초과

    • user.id.gt(1)
  • goe() : 이상

    • user.id.goe(3)
  • contains() : 포함

    • user.email.contains("gmail")
  • endsWith() : 끝남

    • user.email.endsWith(".com")
  • fetch() → 리스트 반환

  • fetchOne() → 단건 (2개 이상이면 예외)

  • fetchFirst() → 첫 번째 결과만 반환


정렬 (Order By)

  • 사용자 이름 오름차순 + ID 내림차순으로 정렬 후 3명 조회
List<User> result = queryFactory
    .selectFrom(user)
    .orderBy(
        user.username.asc(),
        user.id.desc()
    )
    .limit(3)
    .fetch();
  • 결과

    • 관리자
    • 앨리스
  • offset() → 시작 위치

  • limit() → 조회 개수

  • Spring Data Pageable과 함께 사용 가능


문자열 검색 예제

  • “여행” 키워드가 포함된 게시글 조회
List<Post> result = queryFactory
    .selectFrom(post)
    .where(post.content.contains("여행"))
    .fetch();
  • 결과
    • 후쿠오카 여행 후기 (작성자: 앨리스)
  • contains() = SQL LIKE '%keyword%'

논리 조합 (AND / OR)

  • ADMIN 사용자 또는 이름에 “밥”이 포함된 사용자 조회
List<User> result = queryFactory
    .selectFrom(user)
    .where(
        user.roleEnum.eq(UserRoleEnum.ADMIN)
            .or(user.username.contains("밥"))
    )
    .fetch();
  • 결과
    • 관리자 (ADMIN)
    • 밥 (NORMAL)

조인(JOIN) 검색

  • “앨리스”가 작성한 게시글 조회
List<Post> result = queryFactory
    .selectFrom(post)
    .join(post.user, user)
    .where(user.username.eq("앨리스"))
    .fetch();
  • 결과

    • 후쿠오카 여행 후기
    • 조호바루 맛집 탐방
    • 싱가포르 출퇴근 일상
  • join(post.user, user)
    → SQL의 INNER JOIN과 동일

Fetch Join (N+1 문제 해결)

  • 게시글 + 작성자(User)를 한 번에 조회
List<Post> result = queryFactory
    .selectFrom(post)
    .join(post.user, user).fetchJoin()
    .fetch();
  • fetchJoin() 사용 시

    • 지연 로딩 무시

    • 한 번의 쿼리로 연관 엔티티 조회

    • N+1 문제 방지


댓글(Comment) 조회 예제

  • “리제로 3기 감상평” 게시글의 모든 댓글 조회
List<Comment> comments = queryFactory
    .selectFrom(comment)
    .join(comment.post, post)
    .where(post.content.eq("리제로 3기 감상평"))
    .fetch();
  • 결과
    • 리제로 명작이죠

페이징 처리

  • 게시글 5개씩 조회 (2페이지: 6~10번)
List<Post> page2 = queryFactory
    .selectFrom(post)
    .orderBy(post.id.asc())
    .offset(5)
    .limit(5)
    .fetch();
  • 결과
      1. QueryDSL 실무 적용기
      1. JPA 성능 튜닝 방법
      1. Docker로 배포 환경 만들기
      1. 리제로 3기 감상평
      1. 롤체 시즌10 덱 분석

요약

QueryDSL은 문자열 JPQL의 한계를 해결하고,

타입 안전성·가독성·유지보수성을 동시에 제공하는 JPA쿼리 도구이다.

기본 CRUD를 넘는 조회 로직에서는 사실상 필수에 가깝다.


동적 쿼리 (Dynamic Query)

  • 사용자가 입력한 조건에 따라 쿼리를 유연하게 생성하는 방식을 말한다.

    • 예를 들어 회원 검색 기능에서
이름만 입력 → 이름으로 검색

이메일만 입력 → 이메일로 검색

이름 + 이메일 → 두 조건 모두로 검색
  • 처럼 입력 조건이 매번 달라지는 경우에 사용한다.

  • QueryDSL에서는 주로 다음 두 가지 방식으로 동적 쿼리를 구현한다.

    • BooleanBuilder

    • BooleanExpression


동적 쿼리가 필요한 이유

  • ❌ Spring Data JPA 메서드 이름 방식의 한계
List<User> findByUsernameAndEmailContainsAndRoleEnum(
    String username, String email, UserRoleEnum roleEnum
);
  • 조건이 늘어날수록 메서드가 폭발적으로 증가한다.
findByUsername

findByEmailContains

findByUsernameAndRoleEnum

findByUsernameOrEmailContainsAndRoleEnum …

유지보수 불가능


  • ❌ if문 조합 방식의 한계
if (username != null && email != null) {
    return userRepository.findByUsernameAndEmail(username, email);
} else if (username != null) {
    return userRepository.findByUsername(username);
} else if (email != null) {
    return userRepository.findByEmail(email);
}
  • 조건이 늘어날수록 분기 지옥

  • 코드 중복 증가

  • 검색 조건 수정 시 로직 전체 수정 필요


동적 쿼리와 단일 책임 원칙(SRP)

  • 자주 나오는 질문

“동적 쿼리는 조건을 여러 개 처리하니까 SRP에 어긋나는 거 아닌가요?”

  • 올바른 해석

    • 하나의 클래스(또는 메서드)는 하나의 이유로만 변경되어야 한다.
  • 동적 쿼리는

    • ❌ 여러 비즈니스 로직을 섞는 것이 아니라
    • ✅ “회원 검색”이라는 하나의 책임 안에서
    • 다양한 입력 조건을 처리하는 것

SRP를 위반하지 않는다


BooleanBuilder vs BooleanExpression

  • QueryDSL에서 동적 쿼리를 작성하는 대표적인 두 방식

BooleanBuilder

  • 조건을 하나씩 추가하는 가변(Mutable) 컨테이너

  • 조건을 순차적으로 조립할 때 유용

BooleanBuilder builder = new BooleanBuilder();

if (username != null) {
    builder.and(user.username.contains(username));
}
if (email != null) {
    builder.and(user.email.contains(email));
}
if (role != null) {
    builder.and(user.roleEnum.eq(role));
}

List<User> result = queryFactory
    .selectFrom(user)
    .where(builder)
    .fetch();
  • 조건을 자유롭게 추가 / 제거 가능

  • if문으로 직접 null 체크 필요

  • 코드가 길어질 수 있음

BooleanExpression

  • 하나의 조건을 표현하는 불변(Immutable) 표현식

  • null을 반환하면 QueryDSL이 자동으로 무시

private BooleanExpression usernameContains(String username) {
    return username != null ? user.username.contains(username) : null;
}

private BooleanExpression emailContains(String email) {
    return email != null ? user.email.contains(email) : null;
}

private BooleanExpression roleEq(UserRoleEnum role) {
    return role != null ? user.roleEnum.eq(role) : null;
}

List<User> result = queryFactory
    .selectFrom(user)
    .where(
        usernameContains(username),
        emailContains(email),
        roleEq(role)
    )
    .fetch();
  • null 자동 무시 → 코드 깔끔

  • 메서드 단위 재사용 가능

  • 구조가 명확함


두 방식 비교 요약

구분BooleanBuilderBooleanExpression
개념조건 컨테이너단일 조건 표현식
객체 성격가변(Mutable)불변(Immutable)
null 처리직접 if문자동 무시
가독성상대적으로 복잡깔끔
재사용성낮음높음
추천 상황조건을 즉석에서 조립해야 할 때일반적인 검색 조건

실습 예제 (QueryDSL)

  • Repository 구조
public interface UserRepository 
    extends JpaRepository<User, Long>, UserCustomRepository {
}

public interface UserCustomRepository {

    List<UserSearchResponse> searchUserByMultiCondition(
        UserSearchRequest request, Pageable pageable);

    List<UserSearchResponse> searchUserByMultiConditionV2(
        UserSearchRequest request, Pageable pageable);

    Page<UserSearchResponse> searchUserByMultiConditionPage(
        UserSearchRequest request, Pageable pageable);
}
  • BooleanExpression 방식
@RequiredArgsConstructor
public class UserCustomRepositoryImpl implements UserCustomRepository {

    private final JPAQueryFactory queryFactory;

    @Override
    public List<UserSearchResponse> searchUserByMultiCondition(
            UserSearchRequest request, Pageable pageable) {

        return queryFactory
            .select(Projections.constructor(UserSearchResponse.class,
                user.username,
                user.email,
                user.roleEnum))
            .from(user)
            .where(
                usernameContains(request.getUsername()),
                emailContains(request.getEmail()),
                roleEq(request.getRole())
            )
            .orderBy(user.username.asc())
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .fetch();
    }
    private BooleanExpression usernameContains(String username) {
        return username != null ? user.username.contains(username) : null;
    }

    private BooleanExpression emailContains(String email) {
        return email != null ? user.email.contains(email) : null;
    }

    private BooleanExpression roleEq(UserRoleEnum role) {
        return role != null ? user.roleEnum.eq(role) : null;
    }
}
  • BooleanBuilder 방식
@Override
public List<UserSearchResponse> searchUserByMultiConditionV2(
        UserSearchRequest request, Pageable pageable) {

    BooleanBuilder builder = new BooleanBuilder();

    if (request.getUsername() != null && !request.getUsername().isBlank()) {
        builder.and(user.username.contains(request.getUsername()));
    }

    if (request.getEmail() != null && !request.getEmail().isBlank()) {
        builder.and(user.email.contains(request.getEmail()));
    }

    if (request.getRole() != null) {
        builder.and(user.roleEnum.eq(request.getRole()));
    }

    return queryFactory
        .select(Projections.constructor(UserSearchResponse.class,
            user.username,
            user.email,
            user.roleEnum))
        .from(user)
        .where(builder)
        .orderBy(user.username.asc())
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();
}
  • Page 객체 반환 방식
@Override
public Page<UserSearchResponse> searchUserByMultiConditionPage(
        UserSearchRequest request, Pageable pageable) {

    BooleanBuilder builder = new BooleanBuilder();

    if (request.getUsername() != null && !request.getUsername().isBlank()) {
        builder.and(user.username.contains(request.getUsername()));
    }

    if (request.getEmail() != null && !request.getEmail().isBlank()) {
        builder.and(user.email.contains(request.getEmail()));
    }

    if (request.getRole() != null) {
        builder.and(user.roleEnum.eq(request.getRole()));
    }

    // 데이터 조회
    List<UserSearchResponse> content = queryFactory
        .select(Projections.constructor(UserSearchResponse.class,
            user.username,
            user.email,
            user.roleEnum))
        .from(user)
        .where(builder)
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

    // 전체 카운트 조회
    Long total = queryFactory
        .select(user.count())
        .from(user)
        .where(builder)
        .fetchOne();

    if (total == null) total = 0L;

    return new PageImpl<>(content, pageable, total);
}

요약

동적 쿼리는 검색 조건이 유동적인 상황에서 필수이며,

QueryDSL의 BooleanExpression을 활용하면 가독성과 재사용성을 모두 잡을 수 있다.


JPA 연관관계를 사용하는 이유와 실무에서의 변화

JPA에서 연관관계를 사용하는 이유

JPA는 객체(Object)와 테이블(Table)을 매핑하는 ORM이다.
즉, 단순히 테이블을 다루는 것이 아니라 객체 그래프 탐색을 가능하게 하는 것이 핵심 목적이다.

연관관계 예시
@Entity
public class Post {

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;

}

  • 이렇게 매핑하면 아래 코드가 가능해진다.
post.getUser().getUsername();
  • 개발자가 SQL을 직접 작성하지 않아도

  • 객체 참조만으로 연관 데이터 접근 가능

    → JPA가 내부적으로 쿼리를 생성

“연관관계는 객체 그래프 탐색을 위해 존재한다.”

하지만 이 편리함이 JPA의 가장 큰 단점의 출발점이 된다.


JPA 연관관계가 만들어내는 문제들

  • 대표적인 문제 정리
문제설명
N+1 문제연관 엔티티 조회 시 쿼리가 N번 추가 실행
Lazy / Eager 충돌서비스마다 로딩 전략이 달라 일관성 붕괴
순환 참조양방향 매핑 시 JSON 직렬화 무한 루프
쿼리 제어 불가JPA가 어떤 SQL을 날리는지 예측 어려움
성능 튜닝 한계fetch join, batch size는 임시 해결책
  • N+1 문제 예시
List<Post> posts = postRepository.findAll();

for (Post post : posts) {
    System.out.println(post.getUser().getUsername());
}

실제 실행 SQL
SELECT * FROM post;
SELECT * FROM user WHERE id = 1;
SELECT * FROM user WHERE id = 2;
SELECT * FROM user WHERE id = 3;
...

한 번의 조회(1) + 연관 데이터 N개 → N+1 문제 발생


그럼에도 JPA 연관관계를 써야 했던 이유

“이렇게 문제가 많은데, 왜 연관관계를 쓸까?”

  • 이유 정리
    • 트랜잭션 관리 : Dirty Checking, Cascade 전파
    • 객체 지향 모델 : 엔티티 간 참조로 도메인 로직 표현
    • 일관된 상태 관리 : 영속성 컨텍스트가 연관 객체 동기화

JPA의 핵심 장점은 객체 그래프 관리이고,

이를 위해 연관관계라는 불편함을 감수해야 했다.


QueryDSL의 등장 — 쿼리를 다시 제어하다

  • QueryDSL은 타입 안전한 SQL(JPQL) 빌더이다.

  • JPA가 “자동으로 쿼리를 만들어주는 방식”이라면
    QueryDSL은 “개발자가 쿼리를 직접 조립하는 방식”이다.

List<Post> result = queryFactory
    .selectFrom(post)
    .join(post.user, user)
    .where(user.username.eq("김동현"))
    .fetch();
  • JPA 내부 동작에 의존 ❌

  • 개발자가 조인 시점과 조건을 명시적으로 제어 ⭕

  • ID 기반 설계 — 연관관계 없는 엔티티

  • QueryDSL이 등장하면서 연관관계를 꼭 유지할 필요가 없어졌다.

  • ❌ 기존 연관관계 방식

@Entity
public class Post {

    @ManyToOne(fetch = FetchType.LAZY)
    private User user;
}

 실무형 ID 기반 방식
@Entity
public class Post {

    private Long userId; // FK만 저장
}
  • 엔티티는 순수 데이터 구조

  • 관계는 쿼리에서만 조립


QueryDSL로 명시적 조인

  • 이제 관계는 엔티티가 아니라 쿼리에서 정의한다.
List<PostResponse> result = queryFactory
    .select(Projections.constructor(
        PostResponse.class,
        post.content,
        user.username))
    .from(post)
    .leftJoin(user)
    .on(post.userId.eq(user.id))
    .fetch();

“관계는 코드(@ManyToOne)가 아니라 쿼리(join)로 관리한다.”


설계 변경 후 달라지는 점

항목JPA 연관관계QueryDSL + ID
관계 표현@ManyToOne / @OneToManyLong userId
쿼리 생성JPA 자동개발자 직접
성능 예측어려움매우 쉬움
Lazy 로딩Proxy 필요불필요
순환 참조발생 가능완전 제거
유지보수성낮음✅ 매우 높음

실무 흐름의 변화 요약

[JPA 초기]
  객체 중심 설계 → 연관관계 사용
        ↓
[N+1, Lazy 문제 발생]
  fetch join, batch size로 임시 해결
        ↓
[QueryDSL 도입]
  쿼리 직접 제어 가능
        ↓
[현재 실무 트렌드]
  연관관계 제거
  ID 기반 설계 + 명시적 Join(QueryDSL)

“연관이 필요 없는 것은 JPA가,

조인이 필요한 것은 QueryDSL이 담당한다.”


요약

JPA는 객체 그래프 탐색을 위해 연관관계를 도입했지만,

QueryDSL의 등장으로 개발자가 직접 Join을 제어할 수 있게 되었고,

실무에서는 연관관계를 제거한

ID 기반 설계 + 명시적 Join(QueryDSL) 방식이 주류가 되었다.


연관관계 끊기 실습 요약

User
// BEFORE
@OneToMany(mappedBy = "user")
private List<Post> posts = new ArrayList<>();

// AFTER
// 연관관계 제거

Post
// BEFORE
@ManyToOne(fetch = FetchType.LAZY)
private User user;

// AFTER
private long userId;

Comment
// BEFORE
@ManyToOne(fetch = FetchType.LAZY)
private Post post;

// AFTER
private long postId;

요약

연관관계는 JPA의 철학에서 출발했지만,

QueryDSL은 실무의 통제권을 개발자에게 돌려주었다.

불편함이 쌓이면, 기술은 자연스럽게 진화한다.

2개의 댓글

comment-user-thumbnail
2025년 12월 17일

우와 한눈에 보기 좋네요 역시 J 용......

1개의 답글