catsridingCATSRIDING|OCEANWAVES
Dev

Querydsl 단일 테이블 여러 번 조인하기

jynn@catsriding.com
Dec 12, 2023
Published byJynn
999
Querydsl 단일 테이블 여러 번 조인하기

Join a Single Entity Multiple Times with Querydsl

Querydsl을 이용하여 쿼리를 작성하는 과정에서, 간혹 동일한 엔티티를 여러 번 조인해야 하는 상황에 직면합니다. 데이터베이스 관점으로는 단일 테이블을 여러 번 조인하는 작업에 해당하며, 이는 SQL 쿼리의 별칭(alias)을 활용해서 구현이 가능합니다.

Querydsl이 궁극적으로 SQL 쿼리를 작성하는 데 사용되는 도구인 것을 고려하면, 동일한 엔티티를 여러 번 조인하는 쿼리를 작성하는 것은 본질적으로 Querydsl에서 별칭(alias)을 어떻게 생성하고 사용하는지 이해하는 것과 동일하다는 것을 알 수 있습니다.

Prerequisite

데이터베이스 테이블에는 공통 코드 테이블 COMMON_CODES와 주문 정보 테이블 ORDERS가 있다고 가정하겠습니다.

create table COMMON_CODES
(
    id   bigint      not null primary key,
    code varchar(50) not null,
    constraint COMMON_CODES_pk
        unique (code)
);

create table ORDERS
(
    id                 bigint auto_increment primary key,
    dispatch_status_id bigint not null,
    payment_status_id  bigint not null,
    constraint ORDERS_COMMON_CODES_id_fk
        foreign key (dispatch_status_id) references COMMON_CODES(id),
    constraint ORDERS_COMMON_CODES_id_fk2
        foreign key (payment_status_id) references COMMON_CODES(id)
);

각 주문(ORDERS)은 배송 상태와 결제 상태를 가지고 있습니다. 이 두 상태는 공통 코드 테이블 COMMON_CODES에 저장되어 있습니다.

# ORDERS
+----+-------------------+------------------+
| id | dispatch_status_id| payment_status_id|
+----+-------------------+------------------+
|  1 |               101 |              201 |
|  2 |               102 |              202 |
|  3 |               103 |              203 |
+----+-------------------+------------------+

# COMMON_CODES
+-----+---------------- +
| id  |      code       |
+-----+-----------------+
| 101 | ORDER_RECEIVED  |
| 102 | PACKAGING       |
| 103 | SHIPPED         |
| 201 | PAYMENT_PENDING |
| 202 | PAYMENT_RECEIVED|
| 203 | REFUNDED        |
+-----+-----------------+

주문을 조회할 때, 주문 정보 뿐만 아니라 배송 상태와 결제 상태의 상세 정보도 함께 조회하고 싶습니다. 이 정보를 함께 가져오기 위해서는 ORDERS의 외래 키인 dispatch_status_idpayment_status_id 두 개를 조인해야 합니다. SQL 쿼리를 작성한다면 COMMON_CODES 테이블을 두 번 조인해야하고, 별칭(alias)을 사용하여 두 조인을 구분해야 합니다.

select
    *
from
    ORDERS orders
    inner join COMMON_CODES dispatch_status on orders.dispatch_status_id = dispatch_status.id
    inner join COMMON_CODES payment_status on orders.payment_status_id = payment_status.id;

Issue

위 두 테이블을 JPA Entity로 매핑하고 Querydsl을 이용하여 주문 배송 상태와 결제 상태에 대해 각각 조인한 쿼리를 작성 하는 과정을 순차적으로 진행해보면서, 어떤 상황에 직면하는지 살펴보도록 하겠습니다.

먼저 COMMON_CODES 테이블을 나타내는 CommonCode라는 이름의 클래스를 생성합니다. id 필드에는 @Id@GeneratedValue 어노테이션을 추가하여 이 필드가 테이블의 기본 키(Primary Key)라는 것을 나타냅니다. 또한 이 필드의 값은 자동으로 생성되어야 함을 나타냅니다.

@Entity
@Table(name = "COMMON_CODES")
public class CommonCode {

    @Id
    @Column(name = "id", nullable = false)
    private Long id;

    @Size(max = 50)
    @NotNull
    @Column(name = "code", nullable = false, length = 50)
    private String code;

}

다음으로 ORDERS 테이블을 나타내는 Order라는 이름의 클래스를 생성합니다. 여기서 두 개의 외래 키를 가진 CommonCode 엔티티에 대한 두 개의 연관관계를 설정합니다. 이는 @ManyToOne 어노테이션을 사용하여 CommonCode 엔티티에 매핑됩니다.

@Entity
@Table(name = "ORDERS")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private Long id;

    @NotNull
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "dispatch_status_id", nullable = false)
    private CommonCode dispatchStatus;

    @NotNull
    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "payment_status_id", nullable = false)
    private CommonCode paymentStatus;

}

엔티티 매핑이 완료되었다면 프로젝트를 빌드하여 Q-Type을 생성합니다.

$ gradle build

실제로 CommonCodeOrder 엔티티에 대해 작업하려면, 각 엔티티에 대한 Spring Data JPA Repository를 생성해야 합니다. 추가로, 복잡한 쿼리를 처리하기 위한 Querydsl에 특화된 Custom Repository도 작성합니다.

//  OrderRepository.java
public interface OrderRepository extends JpaRepository<Order, Long>, OrderRepositoryExtension {...}

//  OrderRepositoryExtension.java
public interface OrderRepositoryExtension {...}

//  OrderRepositoryImpl.java
public class OrderRepositoryImpl implements OrderRepositoryExtension {...}

이제 Querydsl의 JPAQueryFactory를 통해 연관관계에 있는 엔티티를 조인하여 필요한 데이터를 조회하는 쿼리를 작성합니다.

//  OrderRepositoryImpl.java

private final JPAQueryFactory queryFactory;

@Override
public List<Order> fetchAll() {
    return queryfactory
            .select(order)
            .from(order)
            .join(order.dispatchStatus, commonCode).fetchJoin()
            .join(order.paymentStatus, commonCode).fetchJoin()
            .fetch();
}

위 쿼리는 컴파일 오류도 발생하지 않고 특별한 문제가 없어 보입니다. 하지만, 쿼리를 실행해보면 AliasCollisionException 에러가 발생합니다. 이 에러는 SQL 쿼리의 별칭(alias) 충돌에 해당합니다. 별칭은 SQL에서 한 번 이상 사용되는 테이블을 구분해야 할 때 사용되며, 하나의 SQL 쿼리 내에서 각 별칭은 고유해야 합니다.

Caused by: org.hibernate.query.sqm.AliasCollisionException: Alias [commonCode] used for multiple from-clause elements : SqmSingularJoin(dev.catsriding.burning.entity.Order(order1).dispatchStatus(commonCode) : dispatchStatus), SqmSingularJoin(dev.catsriding.burning.entity.Order(order1).paymentStatus(commonCode) : paymentStatus)

오류 메시지에서 볼 수 있듯이, COMMON_CODES 테이블이 dispatchStatuspaymentStatus와 각각 조인되어 있지만, 두 조인 모두에 대해 같은 별칭 commonCode를 사용하고 있습니다. 이로 인해 두 개의 다른 조인에 대해 같은 별칭을 사용하려고 하여 별칭 충돌 오류가 발생한 것입니다.

Resolve

Querydsl에서 동일한 엔티티를 여러 번 조인하는 방법은 결국 별칭(alias)을 생성하고 사용하는 방법에 대해 이해하는 과정이라고 할 수 있습니다. 여기까지 오는 데에 많은 단계를 거쳤지만 문제를 해결하는 부분은 꽤나 간단합니다. Querydsl에서 별칭을 생성하는 방법은 새로운 Q-type 인스턴스를 생성하면 되기 때문입니다.

//  OrderRepositoryImpl.java

@Override
public List<Order> fetchAll() {
    QCommonCode dispatchStatus = new QCommonCode("dispatchStatus");
    QCommonCode paymentStatus = new QCommonCode("paymentStatus");
    
    return queryFactory
            .select(order)
            .from(order)
            .join(order.dispatchStatus, dispatchStatus).fetchJoin()
            .join(order.paymentStatus, paymentStatus).fetchJoin()
            .fetch();
}

Q-Type 인스턴스를 생성하면서 파라미터로 전달한 문자열을 별칭으로 사용합니다. 이 별칭(alias)은 Querydsl 쿼리 작성 과정에서의 '가명'일 뿐이며, 실제 SQL 쿼리로 변환되어 DBMS에 전송될 때는 아래와 같이 랜덤한 문자열로 교체됩니다.

select
    o1_0.id,
    o1_0.dispatch_status_id,
    ds1_0.id,
    ds1_0.code,
    o1_0.payment_status_id,
    ps1_0.id,
    ps1_0.code
from
    ORDERS o1_0
    join
        COMMON_CODES ds1_0
                on ds1_0.id=o1_0.dispatch_status_id
    join
        COMMON_CODES ps1_0
                on ps1_0.id=o1_0.payment_status_id
  • Spring
  • JPA
  • Querydsl