Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
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
more
Archives
Today
Total
관리 메뉴

Kim ByeungHyun

QueryDSL 본문

Framework/Spring

QueryDSL

sandbackend 2023. 5. 21. 22:29

JPAQueryFactory는 Java Persistence API (JPA)를 사용하여 데이터베이스에서 쿼리를 실행하기 위한 유틸리티 클래스입니다. JPA는 자바 애플리케이션과 관계형 데이터베이스 간의 객체-관계 매핑을 제공하는 기술입니다. JPAQueryFactory는 JPA 쿼리를 작성하고 실행하기 위한 도구로 사용됩니다. JPAQueryFactory를 사용하면 JPA의 쿼리 기능을 더욱 간편하고 효율적으로 사용할 수 있습니다

 


[우아콘2020] 수십억 건에서 Querydsl 사용하기

**영상을 보고 정리한 내용입니다**

cross join 회피

  • cross join은 성능이 좋지 않기 때문에 지양하는 것이 좋다.
  • 실제로 조인을 하지 않아도 조건절에 조인에 대해서 직접 사용하게 되면 묵시적 join으로 cross join이 발생한다.
// Querydsl
public List<Customer> crossJoin() {
    return queryFactory
            .selectFrom(customer)
            .where(customer.customerNo.gt(customer.shop.shopNo))
            .fetch();
}

// JPQL
@Query("SELECT c FROM Customer c WHERE c.customerNo > c.shop.shopNo")
List<Customer> crossJoin();
  • 명시적 join을 하게 되면 Inner join이 발생하게 된다.
public List<Customer> crossJoin() {
    return queryFactory
            .selectFrom(customer)
            .innerJoin(customer.shop, shop)
            .where(customer.customerNo.gt(shop.shopNo))
            .fetch();
}

Entity 보다는 Dto 를 우선

  • Entity를 조회하게 되면 불필요한 컬럼 조회, OneToOne N+1 쿼리가 발생한다.
  • 단순히 조회인데도 성능 이슈가 될만한 문제들이 발생한다.
  • 어떤 상황에 써야 하나?
    • Entity : 실시간으로 Entity 변경이 필요한 경우
    • Dto : 고강도 성능 개선 or 대량의 데이터 조회가 필요한 경우
// Entity
queryFactory
    .selectFrom(book)
    .where(book.bookNo.eq(bookNo))
    .offset(pageNo)
    .limit(10)
    .fetch();

// Dto
queryFactory
    .select(Projections.fields(BookPageDto.class,
            book.name,
            book.bookNo,
            book.id
    ))
    .from(book)
    ...

조회컬럼 최소화하기

  • 이미 알고 있는 값은 조회할 필요가 없기에 as 표현 식으로 대체한다.
  • as 컬럼은 조회 컬럼에서 제외되기 때문에 성능 향상
// before
public List<BookPageDto> getBooks(int bookNo, int pageNo) {
    queryFactory
        .select(Projections.fields(BookPageDto.class,
                book.name,
                book.bookNo,
                book.id
        ))
        .from(book)
        ...
}

// after
public List<BookPageDto> getBooks(int bookNo, int pageNo) {
    queryFactory
        .select(Projections.fields(BookPageDto.class,
                book.name,
                Expressions.asNumber(bookNo).as("bookNo"),
                book.id
        ))
        .from(book)
        ...
}

Select 컬럼에 Entity 자제 - 불필요한 컬럼 조회

  • 관계에 있는 엔티티의 id만 필요한 상황이라고 가정
  • id를 얻기 위해 조회 컬럼에 entity를 조회하게 되면 id뿐만 아니라 불필요한 모든 데이터까지 전부 조회하게 된다.
queryFactory
    .select(Projections.fields(AdBond.class,
            ...
            adItem.customer
            )
    )

Select 컬럼에 Entity 자제 - N + 1

  • shop과 customer가 OneToOne 관계이기에 Shop이 매 건 조회된다.
  • 이유는 OneToOne는 기본적으로 EAGER 전략을 따르기 때문이다.

Select 컬럼에 Entity 자제 - distinct

  • select에 선언된 entity의 컬럼 전체가 distinct 대상이 되기 때문에 customer 모든 컬럼 또한 대상이 되어 성능 이슈가 발생한다.

Entity간 연관관계를 맺으려면 반대 Entity가 필요하지 않나?

  • Select 컬럼에 엔티티를 썼을 때 위에서 알아봤듯이 문제가 될 이슈들이 정말 많다.
  • 연관된 Entity의 save를 위해서는 반대편 Entity의 id만 있으면 된다.
queryFactory
    .select(Projections.fields(AdBond.class,
            adItem.txDate,
            ...
            adItem.customer.id.as("customerId")
            )
    )

public AdBond toEntity() {
    return AbBond.builder()
        .txDate(txDate)
        ...
        .customer(new Customer(cusomerId))
        .build();
}

Group By 최적화

  • Mysql에서 group by를 실행하면 인덱스를 타지 않았을 때 Filesort가 필수적으로 실행된다.
explain select 1
from ad_offset
group by customer_no;
  • order by null을 하게 되면 Filesort가 제거된다.
explain select 1
from ad_offset
group by customer_no
order by null asc;
  • 하지만 Querydsl은 order by null을 지원하지 않기 때문에 직접 클래스를 생성
public class OrderByNull extends OrderSpecifier {
    public static final OrderByNull DEFAULT = new OrderByNull();

    private OrderByNull() {
        super(Order.ASC, NullExpression.DEFAULT, Default);
    }
}

queryFactory
    .select(...)
    ...
    .orderBy(OrderByNull.DEFAULT)
    .fetch();
  • 정렬이 필요하더라도, 조회 결과가 100건 이하라면 애플리케이션에서 정렬한다.
  • WAS 자원이 DB 자원보다 저렴하기 때문이다.
  • 단 페이징일 경우, order by null을 사용하지 못한다.

커버링 인덱스

  • 쿼리를 충족시키는 데 필요한 모든 컬럼을 가진 인덱스
  • NoOffset 방식과 더불어 페이징 조회 성능을 향상시키는 가장 보편적인 방법
select *
from academy a
join (select id
      from academy
      order by id
      limit 10000, 10) as temp
on temp.id = a.id;
  • JPQL은 from절의 서브쿼리를 지원하지 않는다. 이 말은 즉 Querydsl도 지원하지 않는다는 말이다.
  • 그렇기 때문에 커버링 인덱스 조회는 나눠서 진행한다.
List<Long> ids = queryFactory
                    .select(book.id)
                    .from(book)
                    .where(book.name.like(name + "%"))
                    .orderBy(book.id.desc())
                    .limit(pageSize)
                    .offset(pageNo * pageSize)
                    .fetch();

if (CollectionsUtils.isEmpty(ids)) {
    return new ArrayList<>();
}

return queryFactory
            .select(...)
            .from(book)
            .where(book.id.in(ids))
            .orderBy(book.id.desc())
            .fetch();
  • 이렇게 하면 기존 커버링 인덱스와 거의 비슷한 성능을 보여준다.

성능개선 - Update/Insert


일괄 Update 최적화

  • dirty check를 이용하여 트랜잭션 내부에 자동으로 엔티티의 변경사항을 체킹해 변경을 유도하면 수 천 건의 데이터를 처리하면 성능 이슈가 발생
  • Querydsl.update를 이용하여 dirty check를 하지 말고 한 번에 처리하는 것이 성능적으로 우월
// DirtyChecking
List<Student> students = queryFactory
                            .selectFrom(student)
                            .where(student.id.loe(studentId))
                            .fetch();
for (Student student : students) {
    students.updateName(name);
}

// Querydsl.update
queryFactory.update(student)
    .where(student.id.loe(studentId))
    .set(student.name, name)
    .execute();
  • 하이버네이트 1차 캐시와 2차 캐시가 일괄 업데이트 시 캐시 갱신이 되지 않는다.
  • 업데이트 대상들에 대한 Cache Eviction이 필요하다.
  • 사용 시점
    • DirtyChecking : 실시간 비즈니스처리, 실시간 단 건 처리 시
    • Querydsl.update : 대량의 데이터를 일괄로 Update 처리 시

JPA로 Bulk Insert는 자제한다

  • JPA는 auto_increment일 때 Insert 합치기가 적용되지 않는다.
  • JdbcTemplate로 Bulk Insert를 할 수 있으나 컴파일 체크, 코드-테이블 간의 불일치 체크, Type Safe 개발이 어려워진다.

TypeSafe 한 방식으로 Bulk Insert를 처리할 순 없을까?

  • Querydsl != Querydsl-JPA
  • Querydsl
    • Querydsl-JPA -> JPQL
    • Querydsl-SQL -> Native SQL
    • Querydsl-MongoDB -> Mongo Query
    • Querydsl-ElasticSearch -> ES Query

QClass 기반으로 Native SQL을 사용할 수 있는 Querydsl-SQL?

  • 잘 사용하지 않은 이유는?
  • 어노테이션을 통해 QClass를 지원하지 않기 때문에 테이블 Scan을 해서 QClass를 생성해야 하므로 상당히 번거롭다.
  1. 로컬 PC에 DB를 설치하고 실행
  2. Gradle/Maven에 로컬 DB 정보를 등록해서 flyway로 테이블을 생성
  3. Querydsl-SQL 플러그인으로 테이블 Scan하면 QClass를 생성

JPA 어노테이션으로 Querydsl-SQL QClass를 생성할 순 없을까?

  • EntityQL를 이용
  • JPA Entity를 기반으로 Querydsl-SQL QClass를 생성해준다.
// 단일 Entity
SQLInsertClause insert = sqlQueryFactory.insert(qAcademy);

for (int j = 1; j <= 1000; j++) {
    insert.populate(new Academy("address", "name", EntityMapper.DEFAULT))
        .addBatch();
}

insert.execute();

// OneToMany
SQLInsertClause insert = sqlQueryFactory.insert(qAcademy);

for (int j = 1; j < 1000; j++) {
    Academy academy = academyRepository.save(new Academy("address", "name"));

    insert.populate(new Student("student", 1, academy), EntityMapper.DEFAULT).addBatch();
    insert.populate(new Student("student", 2, academy), EntityMapper.DEFAULT).addBatch();
}

insert.execute();

EntityQL - 단점

  • Gradle 5 이상 필요
  • 어노테이션 Column(name=””) 필수
  • primitive type 사용 X, 무조건 Wrapper 클래스
  • querydsl-sql이 개선되지 못해 불편한 설정
  • Embedded 미지원

결론


  • 상황에 따라 ORM/ 전통적 Query 방식을 골라 사용할 것
  • JPA / Querydsl로 발생하는 쿼리 한번 더 확인하기

Q. cross join이 성능이 안나오는 이유?

A.

Cross join은 두 개의 테이블 간의 카디션 프로덕트를 반환하는 연산입니다. 이 연산은 대규모 데이터베이스에서 성능 이슈를 일으킬 수 있습니다. 몇 가지 이유로 인해 cross join의 성능이 나오지 않을 수 있습니다.

첫째, cross join은 결과 집합의 크기가 입력 테이블의 크기에 비례하기 때문에, 테이블의 크기가 크면 결과 집합도 매우 커질 수 있습니다. 이는 메모리 사용량과 디스크 I/O에 부하를 주어 성능을 저하시킬 수 있습니다.

둘째, cross join은 모든 가능한 조합을 반환하기 때문에 계산량이 매우 많을 수 있습니다. 입력 테이블이 크고 복잡한 경우, cross join은 계산에 많은 시간이 소요될 수 있습니다.

셋째, cross join은 인덱스를 사용할 수 없습니다. 따라서 cross join을 사용하는 경우에는 성능을 향상시키기 위해 적절한 인덱스를 생성하는 등의 최적화 작업이 필요합니다.

따라서 cross join은 성능이 안나오는 이유가 여러 가지이며, 사용 시에는 주의해야 합니다. 가능하다면 다른 조인 연산을 사용하거나, 데이터베이스 시스템의 성능을 향상시킬 수 있는 방법을 고려하는 것이 좋습니다.

Q. 커버링 인덱스?

A.

커버링 인덱스는 쿼리가 필요로 하는 컬럼들을 인덱스에 추가로 포함하여 구성하는 방식입니다. 따라서 쿼리가 실행될 때 인덱스 자체에서 필요한 데이터를 가져올 수 있으므로 디스크 I/O 작업을 줄일 수 있습니다. 이를 통해 쿼리의 실행 속도를 향상시키고 성능을 개선할 수 있습니다.

Q. NoOffset 방식?

A.

No-Offset 방식은 조회 시작 부분을 인덱스로 빠르게 찾아서 매번 첫 페이지만 읽도록 하는 방식이다. 조건절에 들어가는 id는 클러스터링 인덱스이기 때문에 매우 빠르게 조회가 된다. 이 방식을 사용하면 모든 페이지를 첫 페이지를 조회하듯이 일정한 개수를 가지고 올 수 있다.

Q. Bulk Insert?

A.

bulk insert는 한번의 쿼리로 여러건의 데이터 row를 insert할 수 있는 insert 방법입니다. batch 시스템을 만들다보면 한건씩 수만, 수십만건의 데이터를 넣다보면 퍼포먼스적으로 오래걸리는걸 알 수 있고 이를 해결하기 위해서 주로 사용됩니다.

 

 

'Framework > Spring' 카테고리의 다른 글

IoC DI 강의  (1) 2022.10.17
예외처리 + 관심사 별로 분리  (1) 2022.10.11
AOP, 예외처리, Transaction  (0) 2022.10.11
JPA 심화  (0) 2022.10.10
Dto를 거쳐 Controller, Repository  (0) 2022.10.07