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_id
와 payment_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
실제로 CommonCode
와 Order
엔티티에 대해 작업하려면, 각 엔티티에 대한 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
테이블이 dispatchStatus
와 paymentStatus
와 각각 조인되어 있지만, 두 조인 모두에 대해 같은 별칭 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