Spring Data

QueryDSL Subquery

myeongil 2023. 10. 15. 22:39

QueryDSL SubQuery 한계

우리가 흔히 이야기하는 QueryDSL은 querydsl-jpa를 의미합니다. querydsl-jpa를 사용하는 코드를 보면 다음과 같이 JpaQueryFactory를 사용합니다.

 

@Repository
public class AlbumRepositoryImpl implements AlbumRepository {

  private final JPAQueryFactory jpaQueryFactory;

  public AlbumRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
    this.jpaQueryFactory = jpaQueryFactory;
  }

  public Optional<Album> findById(Long id) {
    return Optional.ofNullable(
        jpaQueryFactory
            .selectFrom(album)
            .where(album.id.eq(id), album.deletedAt.isNull())
            .fetchOne());
  }
}

 

JPAQueryFactory는 querydsl-jpa에서 제공하는 QueryFactory로 JPQL 생성 -> Native SQL로 변환 -> 쿼리 수행의 과정을 거쳐 결과를 반환합니다. 즉 내부적으로는 JPQL을 사용하기 때문에, JPQL에서 지원하지 않는 방법은 사용할 수 없습니다.

 

하지만 조회 쿼리를 작성하다 보면 여러 테이블을 조인하고 그룹핑하는 등 복잡한 쿼리를 작성할 때가 있습니다.

 

@Repository
public class SlotPictureRepository implements SlotRepository {

  private final JPAQueryFactory jpaQueryFactory;

  public AlbumRepositoryImpl(JPAQueryFactory jpaQueryFactory) {
    this.jpaQueryFactory = jpaQueryFactory;
  }

  public List<SlotPictureResult> findPicturesBySlotInAlbum(Long albumId) {
    return query
        .select(
            Projections.constructor(
                SlotPictureResult.class,
                slot.id,
                slot.layout,
                slot.location,
                slot.page,
                picture.id,
                picture.path,
                picture.content,
                picture.picturedAt,
                stringTemplate("GROUP_CONCAT({0})", pictureTag.name).as("tags")))
        .from(slot)
        .leftJoin(picture)
        .on(slot.pictureId.eq(picture.id))
        .leftJoin(pictureTagRelation)
        .on(pictureTagRelation.pictureId.eq(picture.id), pictureTagRelation.deletedAt.isNull())
        .leftJoin(pictureTag)
        .on(pictureTagRelation.tagId.eq(pictureTag.id), pictureTag.deletedAt.isNull())
        .where(slot.albumId.eq(albumId), slot.deletedAt.isNull())
        .groupBy(slot.id)
        .orderBy(slot.page.asc())
        .fetch();
  }
}

 

위 쿼리는 작성해보면 정상적으로 동작하는 것처럼 보이지만 실제로 수행해 보면 쿼리가 정상적으로 수행되지 않습니다. ( 정확하게는 groupBy 처리 방식의 차이로 인해 MySQL에서는 쿼리가 잘 수행되지만 PostgreSQL에서는 쿼리 오류가 발생합니다. )

테이블 간 관계도

Postgres에서는 아래와 같은 오류가 발생합니다.

 

ERROR: column "p1_0.id" must appear in the GROUP BY clause or be used in an aggregate function

 

groupBy를 slot.id로 하고 있는데, 조인한 결과를 집계함수 없이 가져오려고 하기 때문인데요. 실제로 값은 pictureId를 slot이 가지고 있기 때문에 값은 하나이지만 쿼리는 정상적으로 수행되지 않습니다.

 

그럼 Picture, PictureTagRelation, PictureTag를 먼저 조인하고 그룹핑한 결과와 Slot을 조인하는 방식으로 조인한다면 가능할 것으로 보이는데요. 여기서 문제가 발생합니다.

 

우리가 흔히 사용하는 querydsl-jpa 에서는 join문 내부에 subQuery를 사용할 수 없다는 것입니다. querydsl-jpa는 위에서 설명한 것처럼 JPQL로 변환 -> Native SQL로 변환 과정을 거치는데, JPQL에서는 subQuery를 where, having, select clause 에서만 지원하기 떄문입니다.

 

그럼 어떻게 수정해야 할까?

찾아보니 4가지 정도 방법이 있는 것 같았습니다.

  1. Native SQL을 사용한다.
  2. 쿼리를 2개로 나누어 수행 후, 애플리케이션 로직으로 결과를 합친다.
  3. Hibernate가 제공하는 @SubSelect를 사용한다.
  4. JPASQLQuery를 사용한다.

Natvie SQL 사용하기

직접 쿼리를 작성해 조회한 후, 결과를 적절하게 맵핑하여 반환한다.

 

public List<SlotPictureResult> findPicturesBySlotInAlbum(Long albumId) {
    Stream<Object[]> results =
        em.createNativeQuery(
                """
                ...query
                """.trim())
            .setParameter("albumId", albumId)
            .getResultStream();

    return results
        .map(
            objects ->
                new SlotPictureResult(...mapping))
        .toList();
}

쿼리를 나눈다.

Slot 목록을 먼저 조회하고, 가지고 있는 Picture들에 대한 Picture - PictureTagRelation - PictureTag를 그룹핑하여 조회하도록 쿼리를 둘로 나누어 조회한다. 이후, 결과를 애플리케이션 로직을 통해 합쳐 결과를 반환하도록 합니다.

@Subselect

Hibernate에는 @Subselect라는 어노테이션이 제공되는데, 이를 사용하면 디비에 있는 view와 비슷하게 쿼리 조회 결과를 엔티티로 정의할 수 있습니다.

 

@Entity
@Subselect(
    "SELECT "
        + " picture.id, "
        + " picture.content, "
        + " picture.path, "
        + " picture.pictured_at, "
        + " GROUP_CONCAT(picture_tag.name) AS tags"
        + " FROM picture"
        + " LEFT JOIN picture_tag_relation ON picture.id = picture_tag_relation.picture_id AND picture_tag_relation.deleted_at IS NULL"
        + " LEFT JOIN picture_tag ON picture_tag_relation.tag_id = picture_tag.id AND picture_tag.deleted_at IS NULL"
        + " WHERE picture.deleted_at IS NULL"
        + " GROUP BY picture.id")
@Immutable
@Synchronize({"picture", "picture_tag", "picture_tag_relation"})
@NoArgsConstructor(access = AccessLevel.PROTECTED, force = true)
public class PictureDetail {
  @Id private Long id;
  private String content;
  private String path;

  @Column(name = "pictured_at")
  private LocalDateTime picturedAt;

  private String tags;
}

 

이를 활용해 정의한 엔티티로 Qclass를 만들어 사용하면 subquery 동일하게 동작할 수 있습니다. SubSelect를 사용할 때는 보통 Immutable, Synchronize와 함께 사용합니다.

  • Immutable: 엔티티가 변경되더라도 이를 무시하여 업데이트 쿼리를 수행하지 않도록 하는 어노테이션입니다.
  • Synchronize: Subselect와 함께 사용할 경우 쿼리를 수행하기 전, 쓰기 지연 저장소에 명시한 테이블과 관련된 쿼리들을 flush 한 후 쿼리를 수행합니다. 즉, PictureDetail을 사용한 JPQL 사용 시, picture, picture_tag, picture_tag_relation과 관련되어 지연된 쿼리들이 flush 된다.

이렇게 정의한 PictureDetail을 활용해 다음과 같이 join을 수행해 subquery join이라는 목표를 달성할 수 있습니다.

 

  public List<SlotPictureResult> findPicturesBySlotInAlbum(Long albumId) {
    return query
        .select(
            Projections.constructor(
                SlotPictureResult.class,
                slot.id,
                slot.layout,
                slot.location,
                slot.page,
                pictureDetail.id,
                pictureDetail.path,
                pictureDetail.content,
                pictureDetail.picturedAt,
                pictureDetail.tags))
        .from(slot)
        .leftJoin(pictureDetail)
        .on(slot.pictureId.eq(pictureDetail.id))
        .where(slot.albumId.eq(albumId), slot.deletedAt.isNull())
        .fetch();
}

JPASQLQuery

JPASQLQuery는 기존 방식과 유사하게 쿼리를 작성하며 JPQL로 변환과정 없이 바로 Native SQL로 변환하는 방법입니다. 사용하기 위해선 querydsl-sql이 필요합니다.

기존 querydsl 설정에 querydsl-sql을 추가해야 합니다. SpringBoot 3.1.2, querydsl 5.0.0을 사용했습니다.

 

dependencies {
    ...

    implementation("com.querydsl:querydsl-jpa:5.0.0:jakarta")
    implementation("com.querydsl:querydsl-sql:5.0.0")
    annotationProcessor("com.querydsl:querydsl-apt:5.0.0:jakarta")
    annotationProcessor("jakarta.persistence:jakarta.persistence-api")
    annotationProcessor("jakarta.annotation:jakarta.annotation-api")

    ...
}

 

의존성 설치가 끝나면 아래와 같이 사용하는 DB에 맞는 SqlTemplates를 빈으로 등록해 줍니다.

 

@Bean
public SQLTemplates sqlTemplates() {
  return MySQLTemplates.builder().build();
}

 

이후 SqlTemplates를 주입받아, JPASQLQuery를 생성해 조회합니다.

 

@Repository
@RequiredArgsConstructor
public class PictureRepositoryImpl implements PictureRepository {

  private final EntityManager em;
  private final SQLTemplates sqlTemplates;

  public List<SlotPictureResult> findPicturesBySlotInAlbum(Long albumId) {
    JPASQLQuery<SlotPictureResult> query = new JPASQLQuery<>(em, sqlTemplates);

    StringPath pictureAlias = Expressions.stringPath("pictureAlias"); // subquery alias 설정

    // 조회를 위한 Path 설정 (LocalDateTime이 되지 않아 java.sql.TimeStamp를 사용했습니다) 
    StringPath path = Expressions.stringPath(pictureAlias, "path");
    StringPath content = Expressions.stringPath(pictureAlias, "content");
    DateTimePath<TimeStamp> picturedAt =
        Expressions.dateTimePath(TimeStamp.class, pictureAlias, "pictured_at");
    StringPath tags = Expressions.stringPath(pictureAlias, "tags");

    return query
        .select(
            Projections.constructor(
                SlotPictureResult.class,
                slot.id,
                slot.layout,
                slot.location,
                slot.page,
                slot.pictureId,
                path,
                content,
                picturedAt,
                tags))
        .from(slot)
        .leftJoin(
            JPAExpressions.select(
                    picture.id,
                    picture.path,
                    picture.content,
                    picture.picturedAt,
                    stringTemplate("GROUP_CONCAT({0})", pictureTag.name).as("tags"))
                .from(picture)
                .leftJoin(pictureTagRelation)
                .on(
                    picture.id.eq(pictureTagRelation.pictureId),
                    pictureTagRelation.deletedAt.isNull())
                .leftJoin(pictureTag)
                .on(pictureTagRelation.tagId.eq(pictureTag.id), pictureTag.deletedAt.isNull())
                .where(picture.deletedAt.isNull())
                .groupBy(picture.id),
            pictureAlias)
        .on(slot.pictureId.eq(Expressions.numberPath(Long.class, pictureAlias, "id")))
        .where(slot.albumId.eq(albumId), slot.deletedAt.isNull())
        .groupBy(slot.id)
        .orderBy(slot.page.asc())
        .fetch();
  }
}

 

하지만 약간의 문제들이 있었는데요.

  1. 카멜케이스로 선언된 칼럼들에 대해 name을 명시적으로 적어주어야 했습니다. 필드를 picturedAt으로 선언하면 자동으로 pictured_at으로 인식되었으나 querydsl-sql사용 시 명시적으로 지정해주어야 했습니다.
  2. picturedAtDateTimePath <LocalDateTime>으로 선언했었으나, 실제로 조회된 결과는 TimeStamp로 조회되어 LocalDateTime으로 변환해주어야 하는 문제가 있었습니다.

결론

개인적으로 대부분의 경우에 있어서 쿼리를 분리하여 애플리케이션 수준에서 합치는 방법이 좋다고 생각합니다. 하지만 도저히 성능상 쿼리를 쪼갤 수 없는 경우라면, Subselect 나 JPASQLQuery를 사용하는 방법도 적절하다고 생각됩니다.

 

위에서 언급한 방법 이외에, querydsl-sql의 SqlQueryFactory를 사용한 방법도 존재하는데요. 더 자세한 내용은 http://querydsl.com/static/querydsl/latest/reference/html/ch02s03.html 에서 확인할 수 있습니다.