JPA - QueryDSL 1

Agida·2025년 9월 20일

JPA

목록 보기
8/8
post-thumbnail

1. JPQL vs QueryDSL

JPQL의 한계

JPQL은 문자열 기반 쿼리로 다음과 같은 문제점이 있다.

// JPQL - 런타임 에러 발생 가능
String jpql = "SELECT m FROM Member m WHERE m.age > :age";
TypedQuery<Member> query = em.createQuery(jpql, Member.class);
  • 컴파일 시점에 문법 오류를 발견할 수 없다
  • IDE의 자동완성 기능을 활용할 수 없다
  • 리팩토링 시 문자열 쿼리는 변경되지 않는다

QueryDSL의 장점

QueryDSL은 타입 안전한 쿼리를 제공한다.

// QueryDSL - 컴파일 타임 에러 검증
QMember member = QMember.member;
List<Member> result = queryFactory
    .selectFrom(member)
    .where(member.age.gt(18))
    .fetch();
  • 컴파일 시점에 문법 오류를 발견할 수 있다
  • IDE의 자동완성 기능을 완벽하게 지원한다
  • 리팩토링에 안전하다
  • 동적 쿼리 작성이 용이하다

성능 비교

JPQL과 QueryDSL은 모두 최종적으로 같은 SQL을 생성하므로 성능상 차이는 없다. QueryDSL은 JPQL 생성기 역할을 한다.

2. Q-Type

Q-Type 생성

QueryDSL에서 엔티티에 대응하는 정적 타입을 Q-Type이라 한다.

@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String username;
    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}

위 엔티티에 대해 다음과 같은 Q-Type이 생성된다.

// 자동 생성되는 QMember 클래스
public class QMember extends EntityPathBase<Member> {
    public static final QMember member = new QMember("member");

    public final NumberPath<Long> id = createNumber("id", Long.class);
    public final StringPath username = createString("username");
    public final NumberPath<Integer> age = createNumber("age", Integer.class);
    public final QTeam team = new QTeam(forProperty("team"));
}

Q-Type 사용법

Q-Type은 두 가지 방식으로 사용할 수 있다.

// 1. 별칭 직접 지정
QMember qMember = new QMember("m");

// 2. 기본 인스턴스 사용 
QMember member = QMember.member;

기본 인스턴스 사용을 권장하며, 같은 테이블을 조인해야 하는 경우에만 별칭을 직접 지정한다.

// 셀프 조인 시 별칭 지정
QMember memberA = new QMember("memberA");
QMember memberB = new QMember("memberB");

3. 기본 사용법

기본 설정

QueryDSL 사용을 위한 기본 설정이다.

@Repository
public class MemberRepository {

    private final EntityManager em;
    private final JPAQueryFactory queryFactory;

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

기본 조회

public List<Member> findAll() {
    return queryFactory
        .selectFrom(member)
        .fetch();
}

public Member findOne(Long id) {
    return queryFactory
        .selectFrom(member)
        .where(member.id.eq(id))
        .fetchOne();
}

검색 조건

QueryDSL은 다양한 검색 조건을 제공한다.

public List<Member> search(String username, Integer age) {
    return queryFactory
        .selectFrom(member)
        .where(
            member.username.eq(username),          // username = ?
            member.age.gt(age),                    // age > ?
            member.age.goe(age),                   // age >= ?
            member.age.lt(age),                    // age < ?
            member.age.loe(age),                   // age <= ?
            member.age.ne(age),                    // age != ?
            member.age.between(10, 30),            // age between 10 and 30
            member.username.contains("member"),     // username like '%member%'
            member.username.startsWith("member"),   // username like 'member%'
            member.username.like("member%"),        // username like 'member%'
            member.username.in("member1", "member2"), // username in ('member1', 'member2')
            member.username.notIn("member1", "member2") // username not in ('member1', 'member2')
        )
        .fetch();
}

AND/OR 조건

// AND 조건 (기본)
.where(member.username.eq("member1"), member.age.eq(10))

// OR 조건
.where(member.username.eq("member1").or(member.age.eq(10)))

// 복합 조건
.where(
    member.username.eq("member1")
        .and(member.age.eq(10))
        .or(member.age.eq(20))
)

정렬

public List<Member> findAllOrderBy() {
    return queryFactory
        .selectFrom(member)
        .orderBy(
            member.age.desc(),
            member.username.asc().nullsLast()
        )
        .fetch();
}

페이징

public List<Member> findByPage(int offset, int limit) {
    return queryFactory
        .selectFrom(member)
        .orderBy(member.username.desc())
        .offset(offset)
        .limit(limit)
        .fetch();
}

// 페이징 정보와 함께 조회
public Page<Member> findByPageWithCount(Pageable pageable) {
    List<Member> content = queryFactory
        .selectFrom(member)
        .offset(pageable.getOffset())
        .limit(pageable.getPageSize())
        .fetch();

    long total = queryFactory
        .select(member.count())
        .from(member)
        .fetchOne();

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

집합 함수

public MemberStatDto getMemberStat() {
    return queryFactory
        .select(Projections.constructor(MemberStatDto.class,
            member.count(),
            member.age.sum(),
            member.age.avg(),
            member.age.max(),
            member.age.min()
        ))
        .from(member)
        .fetchOne();
}

그룹화

public List<TeamStatDto> getTeamStat() {
    return queryFactory
        .select(Projections.constructor(TeamStatDto.class,
            team.name,
            member.age.avg()
        ))
        .from(member)
        .join(member.team, team)
        .groupBy(team.name)
        .having(member.age.avg().gt(20))
        .fetch();
}

조인

// 내부 조인
public List<Member> innerJoin() {
    return queryFactory
        .selectFrom(member)
        .join(member.team, team)
        .where(team.name.eq("teamA"))
        .fetch();
}

// 외부 조인
public List<Member> leftJoin() {
    return queryFactory
        .selectFrom(member)
        .leftJoin(member.team, team)
        .fetch();
}

// 세타 조인 (연관관계가 없는 필드로 조인)
public List<Member> thetaJoin() {
    return queryFactory
        .select(member)
        .from(member, team)
        .where(member.username.eq(team.name))
        .fetch();
}

// ON절을 활용한 조인
public List<Tuple> joinOnFiltering() {
    return queryFactory
        .select(member, team)
        .from(member)
        .leftJoin(member.team, team)
        .on(team.name.eq("teamA"))
        .fetch();
}

// 연관관계 없는 엔티티 외부 조인
public List<Tuple> joinOnNoRelation() {
    return queryFactory
        .select(member, team)
        .from(member)
        .leftJoin(team)
        .on(member.username.eq(team.name))
        .fetch();
}

페치 조인

// 페치 조인 미적용
public List<Member> findMembersLazy() {
    return queryFactory
        .selectFrom(member)
        .fetch(); // N+1 문제 발생
}

// 페치 조인 적용
public List<Member> findMembersWithTeam() {
    return queryFactory
        .selectFrom(member)
        .join(member.team, team).fetchJoin()
        .fetch(); // N+1 문제 해결
}

서브쿼리

// 서브쿼리 eq 사용
public List<Member> findBySubQueryEq() {
    QMember memberSub = new QMember("memberSub");

    return queryFactory
        .selectFrom(member)
        .where(member.age.eq(
            JPAExpressions
                .select(memberSub.age.max())
                .from(memberSub)
        ))
        .fetch();
}

// 서브쿼리 goe 사용
public List<Member> findBySubQueryGoe() {
    QMember memberSub = new QMember("memberSub");

    return queryFactory
        .selectFrom(member)
        .where(member.age.goe(
            JPAExpressions
                .select(memberSub.age.avg())
                .from(memberSub)
        ))
        .fetch();
}

// 서브쿼리 in 사용
public List<Member> findBySubQueryIn() {
    QMember memberSub = new QMember("memberSub");

    return queryFactory
        .selectFrom(member)
        .where(member.age.in(
            JPAExpressions
                .select(memberSub.age)
                .from(memberSub)
                .where(memberSub.age.gt(10))
        ))
        .fetch();
}

// select 절에 서브쿼리
public List<Tuple> findBySelectSubQuery() {
    QMember memberSub = new QMember("memberSub");

    return queryFactory
        .select(member.username,
            JPAExpressions
                .select(memberSub.age.avg())
                .from(memberSub)
        )
        .from(member)
        .fetch();
}

JPA JPQL 서브쿼리의 한계

  • from 절의 서브쿼리는 지원하지 않는다
  • 해결방안: 조인으로 변경하거나, 애플리케이션에서 쿼리를 2번 분리해서 실행하거나, nativeSQL을 사용한다

4. 프로젝션과 동적 쿼리

프로젝션 결과 반환

프로젝션 대상이 하나면 타입을 명확하게 지정할 수 있다.

// 단일 대상 프로젝션
List<String> result = queryFactory
    .select(member.username)
    .from(member)
    .fetch();

프로젝션 대상이 둘 이상이면 튜플이나 DTO로 조회한다.

// 튜플 조회
List<Tuple> result = queryFactory
    .select(member.username, member.age)
    .from(member)
    .fetch();

for (Tuple tuple : result) {
    String username = tuple.get(member.username);
    Integer age = tuple.get(member.age);
}

DTO 조회

순수 JPA에서 DTO 조회시 new 명령어를 사용해야 한다.

// JPQL - new 명령어 사용
List<MemberDto> result = em.createQuery(
    "select new study.querydsl.dto.MemberDto(m.username, m.age) from Member m",
    MemberDto.class)
    .getResultList();

QueryDSL은 3가지 방법을 지원한다.

프로퍼티 접근 - Setter

// DTO에 기본 생성자와 setter가 필요
List<MemberDto> result = queryFactory
    .select(Projections.bean(MemberDto.class,
        member.username,
        member.age))
    .from(member)
    .fetch();

필드 직접 접근

// DTO에 기본 생성자만 필요, setter 불필요
List<MemberDto> result = queryFactory
    .select(Projections.fields(MemberDto.class,
        member.username,
        member.age))
    .from(member)
    .fetch();

생성자 사용

// DTO의 생성자와 타입이 맞아야 함
List<MemberDto> result = queryFactory
    .select(Projections.constructor(MemberDto.class,
        member.username,
        member.age))
    .from(member)
    .fetch();

별칭이 다를 때

// 프로퍼티나 필드 접근 생성 방식에서 이름이 다를 때 해결 방안
List<UserDto> result = queryFactory
    .select(Projections.fields(UserDto.class,
        member.username.as("name"),  // 별칭 적용
        ExpressionUtils.as(         // 서브쿼리에 별칭 적용
            JPAExpressions
                .select(memberSub.age.max())
                .from(memberSub), "age")
    ))
    .from(member)
    .fetch();

@QueryProjection

DTO 생성자에 @QueryProjection을 사용하는 방법이다.

@Data
public class MemberDto {
    private String username;
    private int age;

    @QueryProjection
    public MemberDto(String username, int age) {
        this.username = username;
        this.age = age;
    }
}
// 컴파일 시점에 타입을 체크할 수 있음
List<MemberDto> result = queryFactory
    .select(new QMemberDto(member.username, member.age))
    .from(member)
    .fetch();

@QueryProjection의 장단점

  • 장점: 컴파일러로 타입을 체크할 수 있다
  • 단점: DTO에 QueryDSL 어노테이션을 유지해야 하고, DTO까지 Q 파일을 생성해야 한다

동적 쿼리

동적 쿼리를 해결하는 두 가지 방식이 있다.

BooleanBuilder 사용

public List<Member> searchByBuilder(String usernameCond, Integer ageCond) {
    BooleanBuilder builder = new BooleanBuilder();

    if (usernameCond != null) {
        builder.and(member.username.eq(usernameCond));
    }

    if (ageCond != null) {
        builder.and(member.age.eq(ageCond));
    }

    return queryFactory
        .selectFrom(member)
        .where(builder)
        .fetch();
}

Where 다중 파라미터 사용

public List<Member> searchByWhere(String usernameCond, Integer ageCond) {
    return queryFactory
        .selectFrom(member)
        .where(usernameEq(usernameCond), ageEq(ageCond))
        .fetch();
}

private BooleanExpression usernameEq(String usernameCond) {
    return usernameCond != null ? member.username.eq(usernameCond) : null;
}

private BooleanExpression ageEq(Integer ageCond) {
    return ageCond != null ? member.age.eq(ageCond) : null;
}

// 조합 가능
private BooleanExpression allEq(String usernameCond, Integer ageCond) {
    return usernameEq(usernameCond).and(ageEq(ageCond));
}

Where 조건에 null 값은 무시된다. 메서드를 다른 쿼리에서도 재활용할 수 있고, 쿼리 자체의 가독성이 높아진다.

수정, 삭제 벌크 연산

쿼리 한번으로 대량 데이터를 수정하거나 삭제한다.

수정 벌크 연산

// 28살 미만인 회원의 이름을 비회원으로 변경
long count = queryFactory
    .update(member)
    .set(member.username, "비회원")
    .where(member.age.lt(28))
    .execute();

기존 숫자에 연산

// 모든 회원의 나이를 1살 증가
long count = queryFactory
    .update(member)
    .set(member.age, member.age.add(1))
    .execute();

삭제 벌크 연산

// 18살 미만인 회원 삭제
long count = queryFactory
    .delete(member)
    .where(member.age.lt(18))
    .execute();

주의사항
벌크 연산은 영속성 컨텍스트를 무시하고 실행되기 때문에, 벌크 연산을 실행하고 나면 영속성 컨텍스트를 초기화하는 것이 안전하다.

long count = queryFactory
    .update(member)
    .set(member.username, "비회원")
    .where(member.age.lt(28))
    .execute();

em.flush();
em.clear();

5. SQL Function 호출

SQL function은 JPA와 같이 Dialect에 등록된 내용만 호출할 수 있다.

replace 함수 사용

List<String> result = queryFactory
    .select(Expressions.stringTemplate(
        "function('replace', {0}, {1}, {2})",
        member.username, "member", "M"))
    .from(member)
    .fetch();

소문자로 변경하는 함수

List<String> result = queryFactory
    .select(member.username)
    .from(member)
    .where(member.username.eq(
        Expressions.stringTemplate("function('lower', {0})", member.username)))
    .fetch();

lower 같은 ansi 표준 함수들은 querydsl이 상당부분 내장하고 있다. 따라서 다음과 같이 처리해도 결과는 같다.

List<String> result = queryFactory
    .select(member.username)
    .from(member)
    .where(member.username.eq(member.username.lower()))
    .fetch();
profile
백엔드

0개의 댓글