우아콘 QueryDSL 사용기

김민우·2024년 2월 13일
0

잡동사니

목록 보기
19/22

시작하기 전
우아한형제들 이동욱님의 우아콘 영상을 정리한 글입니다.

QueryDSL의 경우 버전별 문법 차이가 존재합니다. 아래 QueryDSL 버전은 Querydsl-JPA 4.2.1 입니다.

버전이 5.X라면 아래 내용과 상이할 수 있습니다.

extends / implements 사용하지 않기


JPAQueryFactory 객체만 있으면 QueryDSL은 사용할 수 있다.
extends/implements 는 제거하자

Repository 클래스를 만들 때 상속 및 구현 계층을 만들지 말고 합성을 통해 JpaQueryFactory와 의존관계를 설정하자.

특정 엔티티가 필요하다면 Q클래스의 static field를 사용하면 된다.

MemberRepository.java

@Repository
public class MemberRepository {
    private final JPAQueryFactory queryFactory;

    public MemberRepository(final JPAQueryFactory queryFactory) {
        this.queryFactory = queryFactory;
    }

    public List<Member> findByName(final String name) {
        return queryFactory.selectFrom(member)
                .where(member.name.eq(name))
                .fetch();
    }
}

동적 쿼리는 BooleanExpression


BooleanBuilder 는 직관성이 떨어지고 메서드 추출을 어렵게 만든다. 이를 보완한 BooleanExpression을 사용하자.

다음과 같이 BooleanBuilder 를 사용하는 경우 직관성이 떨어져 어떤 쿼리인지 예상하기가 어렵다.

public List<Member> findDynamicQuery(final String email, final String name, final String phoneNumber) {
    final BooleanBuilder booleanBuilder = new BooleanBuilder();

    if (!StringUtils.isNullOrEmpty(name)) {
        booleanBuilder.and(member.name.eq(name));
    }

    if (!StringUtils.isNullOrEmpty(email)) {
        booleanBuilder.and(member.name.eq(email));
    }

    if (!StringUtils.isNullOrEmpty(phoneNumber)) {
        booleanBuilder.and(member.name.eq(phoneNumber));
    }

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

만약 비교 대상 컬럼이 많아진다면 if 문이 매우 많아질 것이다.

다음과 같이 BooleanExpression 을 사용하여 메서드 추출을 통해 코드의 가독성을 높이자.

    public List<Member> findDynamicQueryUsingBooleanExpression(final String email, final String name, final String phoneNumber) {
        return queryFactory.selectFrom(member)
                .where(eqEmail(email),
                        eqName(name),
                        eqPhoneNumber(phoneNumber))
                .fetch();
    }

    private BooleanExpression eqEmail(final String email) {
        if (StringUtils.isNullOrEmpty(email)) {
            return null;
        }

        return member.name.eq(email);
    }

    private BooleanExpression eqName(final String name) {
        if (StringUtils.isNullOrEmpty(name)) {
            return null;
        }

        return member.name.eq(name);
    }

    private BooleanExpression eqPhoneNumber(final String phoneNumber) {
        if (StringUtils.isNullOrEmpty(phoneNumber)) {
            return null;
        }

        return member.name.eq(phoneNumber);
    }

BooleanExpression의 경우 null 반환시 자동으로 조건절에 제거된다. 단, 모든 조건문이 null인 경우 큰 장애가 발생하니 주의하자.

exist 메서드 금지


QueryDSL의 exist 메서드를 사용하지 말고 fetchOne() 을 통해 직접 만들어 사용하자

데이터 약 2,500만건 기준으로 아래 쿼리를 비교해보자

SQL.exist

select exist(
	select 1
    from member
    where create_date > '2024-01-01')
  • 실행 시간 : 약 3초

SQL.count(1) > 0

select count(1)
from member
where create_date > '2024-01-01'
  • 실행 시간 : 약 5초

두 쿼리 모두 특정 조건을 만족하는 row가 존재하는지 여부를 조사한다. exist의 경우 첫 번째 값이 발견되는 순간 종료되지만 count(1) 의 경우 발견되도 끝까지 조회하므로 성능이 좋지 않다. 스캔 대상이 앞에 있을 수록 더 큰 성능 차이가 발생한다.

결과적으로 exist 함수가 적합하다는 걸 알 수 있다.

QueryDSL의 exists는 실제로 count() 으로 실행된다.

QuerydslJpaPredicateExecutor.class

이는 바꾸고 싶어도 바꿀 수 없다. QueryDSL은 결국 JPQL로 변환되어 실행되는데 JPQL은 from 없이 쿼리를 생성할 수 없다.

exists는 직접 구현하자

  • fetchFirst() : limit(1).fetchOne() 과 동일
  • 조회 결과가 0보다 큰지 작는지 가 아니라 null 여부로 판단해야 한다.

Cross Join 회피


묵시적 조인은 Cross Join을 유발한다. 명시적 조인을 사용하자

Cross Join은 나올 수 있는 모든 경우의 수에 대해 Join을 수행하므로 성능이 안좋을 수 밖에 없다. 물론 DB마다 이에 대한 최적화가 되있을 수 있긴 하지만 모든 DB 버전이 그러진 않으므로 왠만하면 Cross Join을 피해야 한다.

QueryDSL이나 JPA에서 묵시적 Join으로 인해 Cross Join이 발생할 수 있다. 아래 코드를 보자.

public List<Member> crossJoin(final String name) {
    return queryFactory
            .selectFrom(member)
            .where(member.bookmarks.any().studycafe.name.eq(name))
            .fetch();
}

별도의 Join 문이 없어 묵시적 조인이 발생한다. 이 때, Cross Join이 발생한다.

참고
이는 Hibernate 이슈라 Spring Data JPA에서도 동일하게 발생한다.
아래와 같이 작성해도 Cross Join이 발생한다.

@Query("SELECT m FROM Member m WHERE m.teamId = :teamId)
List<Member> crossJoin(final Long teamId);

명시적 Join을 사용하자

아래와 같이 join() + alias 를 통해 명시적으로 Join을 나타내자

public List<Member> notCrossJoin(final String name) {
    return queryFactory
            .selectFrom(member)
            .innerJoin(member.bookmarks, bookmark)
            .innerJoin(bookmark.studycafe, studycafe)
            .where(studycafe.name.eq(name))
            .fetch();
}

Entity 보다는 DTO를 우선


from 절에 엔티티를 포함하지 말고 DTO를 포함하자

많은 사람들이 Spring Data JPA를 사용할 때 엔티티를 반환해야 한다고 생각한다. (나도 그렇게 생각했다)

그러나, JPA는 엔티티와 동일 선상에 두는게 아니고 단순히 엔티티를 사용할 수 있게 해주는 도구일 뿐이다. 더군다나 엔티티를 조회한다면 여러 문제점들을 동반한다.

엔티티 조회 시 문제점

return queryFactory
            .selectFrom(member)...

Hibernate 1/2차 캐시, 불필요한 컬럼 조회, OneToOne에서 N+1 쿼리 등 단순 조회 기능에선 성능 이슈 요소가 많다.

일반적으로 엔티티의 모든 컬럼이 필요하지도 않을 뿐더러 필요없는 연관관계 컬럼을 조회한다면 불필요한 쿼리가 실행될 수 있다.

아래와 같이 Projections를 통해 DTO를 조회하자.

return queryFactory
            .selectFrom(Projections.fields(MemberDto.class,
                    member.email,
                    member.name,
                    member.phoneNumber))
            .innerJoin(member.bookmarks, bookmark)
            .innerJoin(bookmark.studycafe, studycafe)
            .where(studycafe.name.eq(name))
            .fetch();

상황마다 다르다

상황에 따라 엔티티를 조회해야 하는 경우도 존재한다. 요약하면 다음과 같다.

엔티티 조회

  • 실시간으로 엔티티 변경이 필요한 경우

DTO 조회

  • 고강도 성능 개선 or 대량의 데이터 조회가 필요한 경우

엔티티 대신 DTO를 사용하면서 추가적으로 개선할 점

조회 컬럼 최소화하기

public List<MemberDto> findByEmail(final String email) {
    return queryFactory
            .selectFrom(Projections.fields(MemberDto.class,
                    member.email,
                    member.name,
                    member.phoneNumber))
            .where(member.email.eq(email))
            .fetch();
}
  • 파라미터에 email 이 있으므로 select 절에서 굳이 email을 조회할 필요가 없다.
public List<MemberDto> findByEmail(final String email) {
    return queryFactory
            .selectFrom(Projections.fields(MemberDto.class,
                    Expressions.asString(email).as("email"),
                    member.name,
                    member.phoneNumber))
            .where(member.email.eq(email))
            .fetch();
}
  • Expressions.asXXX().as() 표현식으로 대체할 수 있다. 이러면 실제 SQL시 as 컬럼은 select 절에서 제외된다.

Select 컬럼에 엔티티 자제

public List<MemberDto> findByEmail(final String name) {
    return queryFactory
            .selectFrom(Projections.fields(MemberDto.class,
                    member.name,
                    member.phoneNumber,
                    member.team)) // team은 별도의 엔티티
            .where(member.name.eq(name))
            .fetch();
}
  • team 엔티티를 select 컬럼에 추가하면 QueryDSL로 조회된 결과를 신규 엔티티로 생성한다.
  • 이렇게 되면 실제 team 의 모든 컬럼이 조회되게 된다.
    • 사용되지 않는 컬럼들이 조회된다.
    • 추가로 team과 연관관계 엔티티가 조회될 수 있다. (N + 1 문제)
      (특히, OneToOne은 지연 로딩이 안되므로 반드시 N + 1 문제가 발생한다.)

만약, team 과 OneToOne 연관관계인 엔티티가 또 다른 엔티티와 OneToOne이라면? 기존 예상 쿼리의 100배, 1,000배 쿼리가 수행되는 것이다.

추가로 distinct 이슈도 있다. distinctselect 에 선언된 컬럼 전체가 distinct 대상이 되므로 더더욱 성능이 안좋아진다.

연관된 엔티티가 필요한 상황에선 해당 엔티티의 ID만 있으면 된다. 즉, Join Column에 들어갈 ID만 필요하다.

return queryFactory.selectFrom(Projections.fields(MemberDto.class,
                    member.name,
                    member.phoneNumber,
                    member.team.id.as("teamId"))
            .where(member.name.eq(name))
            .fetch();

이후 team 엔티티를 저장해야 한다면 아래와 같이 ID만 사용하면 된다.

public Member toEntity() {
	return Member.builder()
    			...
               .team(new Team(teamId))
               .build();

참고
아래와 같이 연관된 엔티티 ID만 조회하면 엄청난 성능 이득을 얻는다.

Group By 최적화


MySQL에서 group Byorder by null 문법을 제공하는 별도의 클래스를 만들어 사용하자.

MySQL의 경우 group by 실행 시 인덱스가 아닌 경우 fileSort 가 필수로 발생한다.

모든 group by가 인덱스를 사용한다는 보장이 없으므로 fileSort가 빈번하게 발생할 수 있다.

MySQL은 order by null 을 사용하면 fileSort를 제거하는 기능을 제공한다. 그러나, QuerySQL에서는 order by null 문법을 지원하지 않는다.

아래와 같은 조건 클래스를 생성해 적용하자.

OrderByNull.java

public class OrderByNull extends OrderSpecifier {
    public static final OrderByNull DEFAULT = new OrderByNull();
    
    private OrderByNull() {
        super(Order.ASC, NullExpression.DEFAULT, NullHandling.Default);
    }
}
return queryFactory.selectFrom(Projections.fields(MemberDto.class,
                    member.name,
                    member.phoneNumber,
                    member.team.id.as("teamId"))
            .where(member.name.eq(name))
            .groupBy(member.id)
            .orderBy(OrderByNull.DEFAULT)
            .fetch();

단, 페이징의 경우 order by null 문법을 사용할 수 없다.

참고
OrderByNull을 적용하면 아래와 같은 성능 이득을 볼 수 있다.

중요
일반적으로 DB 자원보다 WAS 자원이 더 저렴하다. (DB 서버는 3~4대를 두더라도 WAS는 몇 백개를 두는게 일반적이다.)

정렬이란 자원이 필요한 경우 WAS가 DB에 비해 자원이 여유롭다. 정렬이 필요하더라도 조회 결과가 100건 이하라면 어플리케이션에서 정렬하자.

result.sort(comparingLong(PointCaculateAmount::getPointAmount));

커버링 인덱스


PK를 커버링 인덱스로 조회하고 이를 통해 필요한 컬럼들을 후속 조회하자.

커버링 인덱스

쿼리를 충족시키는데 필요한 모든 컬럼을 갖고 있는 인덱스로 NoOffSet 방식과 더불어 페이징 조회 성능을 향상시키는 가장 보편적 방법

e.g.

select *
from academy a
join (select id
	from academy
    order by id
    limit 10000, 10) as temp
)
on temp.id = a.it;
  • select / where / order by / group by 등에서 사용되는 모든 컬럼이 인덱스에 포함된 상태이다.

JPQL은 from 절의 서브쿼리를 지원하지 않아 별도의 방법으로 커버링 인덱스를 구현해야 한다.

Cluster Key (PK)를 커버링 인덱스로 조회 후 select 컬럼을 후속 조회하자

 public List<MemberDto> findByEmail(final Long pageNo, final Long pageSize, final String email) {
 	// Cluster Key (PK)를 커버링 인덱스로 빠르게 조회
    List<Long> ids = queryFactory
            .select(member.id)
            .from(member)
            .where(member.email.eq(email))
            .orderBy(member.name.desc())
            .limit(pageSize)
            .offset(pageNo * pageSize)
            .fetch();


	// 앞서 PK를 통해 나머지 컬럼을 후속 조회
    return queryFactory
            .selectFrom(Projections.fields(MemberDto.class,
                    Expressions.asString(email).as("email"),
                    member.name,
                    member.phoneNumber))
            .where(member.id.in(ids))
            .fetch();
}
  • where/orderBy/limit 는 커버링 인덱스를 통해 매우 빠르게 조회된다.

참고 기존/QueryDSL/jdbc 간 페이징 성능 차이

  • 기존 커버링 인덱스와 거의 비슷한 성능을 보인다.

일괄 Update 최적화

특정 엔티티를 일괄 update 하는 경우 QueryDSL의 update를 사용하자.

보통 객체지향적인 설계를 핑계로 성능을 등한시하는 경우가 있는데 Dirty Checking의 경우가 대표적이다. 이 때, 수정을 위해 모든 엔티티를 조회하게 되므로 성능 이슈가 발생한다.

Dirty Checking

final List<Member> members = queryFactory
		.selectFrom(member)
        .where(member.id.eq(id))
        .fetch();

for (Member member : members) {
	member.updateName(name);
}

Querydsl.update

queryFactory.update(member)
		.where(member.id.eq(id)
        .set(member.name, name)
        .execute();

참고 Dirty Checking vs Querydsl.udpate 성능 차이

만능은 아니다

하이버네이트 캐시는 일괄 업데이트시 캐시 갱신이 안된다. 이런 경우엔 업데이트 대상들에 대한 Cache Eviction이 필요하다.

상황마다 다르다

상황에 따라 Dirty Checking을 해야되는 경우도 있다. 요약하면 아래와 같다.

Dirty checking

  • 실시간 비지니스 처리/단건 처리

Querydsl.update

  • 대량의 데이터를 일괄로 Update 처리

마치 캐시 즉시 쓰기/지연 쓰기와 비슷한 느낌이다. 데이터의 정합성이 중요하다면 Dirty checking을 우선적으로 사용하고 그외엔 Querydsl.update를 사용하자.

특히, 하이버네이트 캐시 갱신이 필요없는 일괄 update는 고민할 것도 없이 Querydsl.update를 사용하자.

Bulk Insert


Bulk Insert시 JPA보단 JdbcTemplate를 사용하자.

JPA의 merge(), persist() 는 Jdbc의 Batch에 비해 성능이 매우 안좋다. 이러한 경우엔 JPA를 이용하기 보단 JdbcTemplate을 사용하자.

JdbcTemplate을 사용한 bulk insert

final String sql = "INSERT INTO ...";
final SqlParameterSource[] batch = SqlParameterSourceUtils.createBatch(list.toArray());

parameterJdbcTemplate.batchUpdate(sql, batch);
  • JdbcTemplate으로 Bulk Insert는 처리되나 문자열로 SQL을 작성하므로 Type Safe한 개발이 어렵다.

TypeSafe한 방식으로 Bulk Insert를 처리하는 방법

앞서 언급한 Bulk Insert가 안되는 건 Querydsl-JPA이다. Querydsl-SQL은 Bulk Insert를 지원한다!

참고 QueryDSL != Querydsl-JPA
QueryDSL은 4가지 모듈로 나뉘며 각각은 또 다른 언어로 변환이 된다.

  • Querydsl-SQL은 JPQL이 아닌 Native SQL로 변환된다.

Querydsl-SQL의 단점

Querydsl-JPA는 어노테이션 프로세서를 통해 Q클래스를 만들어주지만, Querydsl-SQL은 테이블을 스캔해서 QClass를 만들어야 한다.

  • Gradle/Maven에 로컬 DB 정보를 등록하고 flyway로 테이블을 생성하고 QueryDSL-SQL 플러그인으로 테이블 스캔하여 Q클래스를 만들면 된다! 너무 복합하다

참고 EntityQL
Querydsl-JPA와 마찬가지로 어노테이션 기반으로 테이블을 생성할 수 있게 해준다. 그러나, 단점이 명확하여 실제 개발환경에 적용하기 어렵다.

0개의 댓글