QueryDSL 재사용성 극대화하기

게으른 사람·2022년 11월 15일
2
post-thumbnail

1. 개요

이 글은 QueryDSL의 이점 중 하나인 코드 재사용성을 활용하는 방법을 소개합니다.

com.querydsl:querydsl-jpa:5.0.0, org.springframework.boot:spring-boot-starter-data-jpa:2.6.6 버전에서 작성된 글입니다.

이 글의 목표는 QueryDSL을 사용하여 쿼리 작성 시 다양한 기법을 사용하여 코드 품질을 높이는 방법을 얻는 것입니다.


2. 기존 QueryDSL 사용법

예시를 위하여 많이 사용되는 사용자, 주문, 상품으로 구성된 주문 서비스의 간단한 모델을 사용합니다.

@Entity
public class User {
  @Id
  @GeneratedValue
  private Long userId;

  private String name;
   
   // getters and setters
}

@Entity
public class Product {
  @Id
  @GeneratedValue
  private Long productId;

  private String productName;
  
  private Integer price;
   
   // getters and setters
}

@Entity
public class Orders {
  @Id
  @GeneratedValue
  private Long orderId;
  
  private LocalDateTime orderDate;

  private Long productId;
  
  @ManyToOne
  private User user;
   
   // getters and setters
}

JPAQueryFactory를 사용하여 주문정보를 조회하는 쿼리를 작성합니다.

queryfactory.select(
    order.orderId,
    order.orderDate,
    product.productId,
    product.productName,
    user.userId,
    user.name)
  .from(order)
  .join(product).on(order.productId.eq(product.productId))
  .join(order.user, user)
  .fetch()

JPAQueryFactory정의된 메소드를 사용하여 Java코드로 쿼리를 작성할 수 있다. 어떻게 가능한걸까?

내부 코드를 파해쳐보자.

다음은 JPAQueryFactory 내부 코드 중 일부이다.

실제 구현 코드는 없고 JPAQuery 클래스의 빌더 역할만 하고 있다. JPAQuery를 살펴보자.

다음은 JPAQuery의 클래스 다이어그램이다.

관계가 매우 복잡한데 이때 제레릭 변수 Tselect로 반환되는 타입이고 Q는 현재 클래스의 구체적인 하위 타입이다. 예를 들면 JPAQueryBaseQ타입으로 그 하위 클래스들이 대상이 될 수 있다. (AbstractJPAQuery, JPAQuery)
빌더 패턴으로 작성한 쿼리를 누적시키기위해 메소드의 리턴 타입을 Q타입으로 설계를 한 것으로 보인다. 그럼 어떤 필드에서 쿼리가 누적되고 있을까? 한번 찾아보자.

JPAQueryselect()

JPAQueryBasefrom()join()

이 외에 쿼리를 작성하는 메소드에서 필드queryMixin의 메소드를 실행시켜 쿼리를 작성하는 것을 확인할 수 있다.
필드 queryMixin의 타입은 JPAQueryMixin인데 여기까지 파해치진 않겠다. 이미 우리는 JPAQuery 내부에서 JPAQueryMixin에 쿼리를 담아 생성한다는 것을 유추해냈다.

이와 같은 패턴을 봤을 때 우리도 빌더 패턴을 이용해 우리가 원하는 형식으로 빌더 클래스를 만들고 쿼리를 만들어낼 수 있을 것이다.


3. 개선이 가능하다면 개선하자

위에서 작성한 주문정보 조회 메소드를 다시 살펴보자.

queryfactory.select(
    order.orderId,
    order.orderDate,
    product.productId,
    product.productName,
    user.userId,
    user.name)
  .from(order)
  .join(product).on(order.productId.eq(product.productId))
  .join(order.user, user)
  .fetch()

여기서 다음 부분을 개선할 수 있다.

select 절

queryfactory.select(
    order.orderId,
    order.orderDate,
    product.productId,
    product.productName,
    user.userId,
    user.name)
    ...

비지니스 요구사항을 충족하기 위해 여러 도메인에서 필요한 컬럼을 추출해야한다. 하지만 때때로 비슷한 컬럼을 추출하는 경우도 있다. 예를들면 주문정보를 포함하는 데이터는 주문식별자나 주문일시가 필수라던지, 회원 데이터는 회원명이 필수라던지...
이런 경우 동일한 코드가 늘어날 것이고 갑자기 요구사항으로 '회원 데이터를 포함하는 API에 회원 가입일시를 모두 넣어주세요'라는 요청을 받으면 모든 API를 탐색해서 일일이 회원 가입일시를 넣어주어야 한다.

또 현재는 한 객체에 도메인 구분없이 필드를 나열하고 있다. 물론 개발하는데 지장은 없지만 도메인별로 관련 필드를 묶어주면 클라이언트가 도메인을 좀 더 명확하게 구분할 수 있으므로 서로간의 의사소통이 원활해질 수 있다.

위 개선사항들을 요약하면

  • 도메인별로 필수 노출 컬럼이 있을 수 있다.
  • 컬럼을 도메인별로 구분해야 한다.

그렇담 개선책은 도메인별 필수 컬럼을 포함하는 DTO를 생성하고 그 DTO를 노출시키면 개선사항을 만족할 수 있다.

다음과 같이 DTO를 생성해보자.


public class SimpleUser {
  private Long userId;
  private String name;
  // getters and setters
}

public class SimpleProduct {
  private Long productId;
  private String productName;
  // getters and setters
}

public class SimpleOrder {
  private Long orderId;
  private LocalDateTime orderDate;
  // getters and setters
}

각 도메인별 최소 필요 컬럼을 나열했다. 꼭 필수 컬럼의 관점이 아닌 다른 비지니스의 관점으로 바라보아 DTO를 작성해도 좋다.

DTO를 작성했으니 해당 DTO를 QueryDSL에 매핑해주어야한다. 여러번 재사용될 DTO이니 Util클래스를 작성해주자.

public class UserProjection { // 1
  public static QBean<?> simpleUser(QUser user) { // 2
    return Projection.bean(SimpleUser.class,
      user.userId,
      user.name
    );
  }
}
// ... 나머지 DTO도 동일하게 유틸 클래스를 작성한다.
  1. User 도메인의 프로젝션 유틸 클래스이다. 이제 프로젝션 DTO 사용 시 이 클래스에 해당 DTO를 QueryDSL에 매핑하는 작업을 수행해준다.
    이 유틸 클래스의 장점은 컬럼 표현식을 메소드로 분리해 내부에서 재사용할 수 있다는 점이다. 예를 들어 사용자 이름은 무조건 대문자로 표기해야한다는 요구사항이 있다면 다음과 같이 작성할 수 있다.
public class UserProjection {
  public static QBean<?> simpleUser(QUser user) {
    return Projection.bean(SimpleUser.class,
      user.userId,
      upperCaseName(user) 
    );
  }
  
  public static StringExpression upperCaseName(QUser user) {
    return user.name.toUpperCase();
  }
}

어디까지나 예시이며 이런식으로 표현식을 메소드로 분리해 재사용할 수 있다는 점만 이해해두자.

  1. 위에서 작성한 SimpleUserProjection을 활용하여 매핑해주었다. JpaQueryFactory.select()의 인자는 Expression이므로 무리없이 매핑될 것이다.

이런식으로 작성한다면 다음과 같이 개선된다.

queryfactory.select(Projection.bean(OrderResponse.class,
    OrderProjection.simpleOrder(order).as("order"), // alias를 지정해주지 않으면 내부에서 인식을 하지 못한다.
    ProductProjection.simpleProduct(product).as("product"),
    UserProjection.simpleUser(user).as("user")))
    ...

이렇게 공통 클래스로 묶으면 공통 클래스 수정 시 사용처 전체에 영향이 가므로 생성/수정을 신중히 해야한다. 물론 경우에 따라 컬럼 노출 개수가 민감하거나 그렇지 않은 경우도 있으니 이 점은 유지보수가 우선인지 성능이 우선인지 판단하여 사용하기 바란다.

from 절

...중략
  .from(order)
  .join(product).on(order.productId.eq(product.productId))
  .join(order.user, user)
  .fetch()

테이블간에 연관된 데이터를 조회하기 위해 조인은 굉장히 많이 사용한다.
단순 내부,외부 조인뿐 아니라 셀프조인, 자연조인 등 종류도 다양하며 테이블 설계에 따라 항시 조인해야하는 경우도 있다.
그리고 한 도메인에서 조인 대상이 되는 테이블은 대부분 정해져있다.
이럴 경우 매번 같은 조인문을 작성하는 것이 대부분이며 중복코드가 늘어난다.

이러한 중복코드를 줄이기 위해 우리는 미리 join문을 정의하여 재사용하도록 할 수 있다.
방법은 도메인별로 from절을 정의할 수 있는 팩토리 클래스를 만드는 것이다.

위에서 살펴본 JPAQueryFactory의 구현 방식을 참고하여 다음과 같이 팩토리 클래스를 작성해보자.

public abstract class AbstractQueryFactory { // 1

    protected final JPAQuery<?> queryMixin; // 2

    protected AbstractQueryFactory(JPAQuery<?> query) { // 3
        this.queryMixin = query;
    }

    public JPAQuery<?> build() { // 4
        return queryMixin;
    }
}
  1. 앞으로 작성할 도메인별 팩토리 클래스의 추상 클래스이다.
  2. 실제 쿼리는 JPAQuery로 인해 작성되니 final로 지정해 변동될 수 없도록 하고 하위 클래스에서 접근 가능하도록 한다.
  3. 이 클래스는 하위 클래스에서 생성이 가능하다.
  4. 쿼리를 모두 작성하면 build()를 호출해 JPAQuery를 반환하도록 한다.

이제 AbstractQueryFactory를 사용하여 각 도메인별 팩토리 클래스를 만들 수 있다.
주문 테이블의 팩토리 클래스를 만들어보자.

public class OrderQueryFactory extends AbstractQueryFactory { // 1

    protected OrderQueryFactory(JPAQuery<?> query) {
        super(query);
    }

    public static OrderQueryFactory query(JPAQueryFactory query) { // 2
        return new OrderQueryFactory(query.query());
    }
    
    public OrderQueryFactory from(QOrders order) { // 3
      queryMixin.from(order);
      return this;
    }
    
    public OrderQueryFactory join(QOrders order, QProduct product) { // 4
      queryMixin.join(product).on(order.productId.eq(product.productId));
      return this;
    }
    
    public OrderQueryFactory join(QOrders order, QUser user) {
      queryMixin.join(order.user, user);
      return this;
    }
 }
  1. AbstractQueryFactory를 상속하여 클래스를 정의해분다.
  2. 클래스를 생성할 정적 팩토리 메소드이다. JPAQueryFactoryquery() 메소드는 우리가 사용할 수 있는 JPAQuery를 생성해준다. 따라서 JPAQueryFactory를 인자로 받아 OrderQueryFactory를 생성해준다.
  3. 팩토리 클래스에서 기본적으로 구현되는 메소드의 패턴은 Q클래스를 인자로 받아 JPAQuery에 쿼리를 쌓아주고 메소드 체이닝으로 작성을 계속 이어나가도록 하는 것이다. 여기서는 from()을 정의하여 실제 구현을 감추었다.
  4. 인자로 Q클래스만 받고 실제 join문 작성은 메소드 내부에서 한다. 이러면 OrderQueryFactory를 사용할때 Q클래스만 넘기고 실제 구현은 OrderQueryFactory에 맡기면 된다.

이런직으로 작성하면 다음과 같이 개선된다.

OrderQueryFactory.query(queryFactory)
  .from(order)
  .join(order, product)
  .join(order, user)
  .build() // 1
  .select(...)
  1. OrderQueryFactory로 from절을 모두 작성한 후 build()를 호출하여 나머지 쿼리들을 JPAQuery를 이용해 작성하도록 한다.

이 방법의 장점은 다음과 같다.

  • from절 재사용이 가능하다.
  • 실제 join문의 구현을 감출 수 있어 코드 읽기가 용이하다. 혹여 on절이 특수한 경우라 작성자가 참고를 해야된다면 메소드명으로 나타내어 표시할 수 있다.
// OrderQueryFactory.java

// 예시로만 봐주세요.
public static OrderQueryFactory leftJoinIsNull(QOrders orders, QUser user) {
  queryMixin.leftJoin(user).on(orders.user.eq(user), orders.user.isNull());
  return this;
}
  • 팩토리 클래스 내부에서 on절의 조건들을 재사용할 수 있다.
  • 해당 도메인에서 조인 가능한 테이블의 범위를 정의하여 도메인 응집도를 향상 시킬 수 있다.
    예를 들어, 주문 도메인의 팩토리 클래스에선 다른 도메인과 연결될 수 있는 핵심 엔티티만 연결짓거나 아예 연결짓지 않는다. User까지만 연결하고 User로 부터 연결할 수 있는 엔티티는 연결하지 않는다.

4. 결론

이런 공통 클래스의 사용은 팀 내부에서 어떻게 사용할 것인지 상세한 논의를 거쳐 사용하는 것이 좋습니다. 작성법, 제약사항, 사용법에 대한 규칙을 정하여 활용 시 막강한 생산성을 가져다 줄 것입니다.
이 글에서 제시한 패턴에 대한 이해를 완벽히 했다면 다양한 방식으로도 응용할 수 있을 것 입니다.

profile
웹/앱 백앤드 개발자

4개의 댓글

comment-user-thumbnail
2022년 11월 16일

잘보고 가용~

답글 달기
comment-user-thumbnail
2022년 11월 27일

덕분에 한단계 더 성장한 느낌입니다. 감사합니다.

답글 달기
comment-user-thumbnail
2024년 1월 18일

오 이렇게 재사용성을 극대화 할 수도 있군요. 좋은 인사이트 얻고 갑니다. 🌹👍

답글 달기
comment-user-thumbnail
2024년 3월 11일

저도 좋은 인사이트 얻고갑니다~

답글 달기