JPA 연관 관계 조회 그리고 MSA

먼저 아래와 같은 객체 모델이 있다고 가정해 보자.

하나의 Order(주문)는 여러 개의 LineItem(주문 품목) 가지고 각 LineItem은 하나의 Product(상품)에 의존한다.

객체 모델

객체 모델

특정 상품이 포함된 주문 목록은 어떻게 조회할 수 있을까?

가장 쉬운 방법은 객체 연관관계를 사용하는 것이다. 객체 그래프 탐색이라고 부르는 방식인데 Order 목록을 가져와 반복문을 돌려 LineItem이 특정 상품인지 확인하는 것이다. 당연히 이 방식은 Order가 많으면 많을수록 성능이 문제가 된다.

이런 문제를 해결하기 위해 JPAJava Persistence API는 JPQLJava Persistence Query Language이라는 쿼리 언어를 지원한다. 복잡한 검색 조건을 사용해서 객체를 조회할 수 있다. JPQL은 SQL과 비슷한 것처럼 보이지만 다른 점이 많다. 왜냐하면, JPA 명세specification의 일부로 테이블을 대상으로 하는 SQL과 달리 JPA 엔터티를 대상으로 하는 객체지향 쿼리 언어이기 때문이다.

이 글에서는 JPQL을 사용한 두 가지 방법을 소개하며 글 말미에는 마이크로서비스 환경으로 확장될 때 문제와 해결 방법을 소개한다.

  • EXISTS
  • JOIN

JPA 엔터티

앞서 언급한 객체 모델은 아래와 같이 관계 모델로 표현할 수 있다.

관계 모델

관계 모델

그리고 JPA 엔터티로 아래와 같이 매핑할 수 있다.

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
32
@Entity
@Table(name = "orders")
public class Order {
  @Id
  @GeneratedValue
  private Long id;
  @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
  private List<LineItem> lineItems = new ArrayList<>();
  // ...
}
@Entity
@Table(name = "line_items")
public class LineItem {
  @Id
  @GeneratedValue
  private Long id;
  @ManyToOne
  @JoinColumn(name = "order_id", referencedColumnName = "id")
  private Order order;
  @ManyToOne
  @JoinColumn(name = "product_id", referencedColumnName = "id")
  private Product product;
  //...
}
@Entity
@Table(name = "products")
public class Product {
  @Id
  @GeneratedValue
  private Long id;
  // ...
}

이제부터 JPQL로 특정 상품이 포함된 주문 엔터티 목록을 조회해 보자.

JPQL - EXISTS

WHERE절에 EXISTS 하위 쿼리에 LineItem 엔터티를 기술하고 productId를 매개변수로 넘긴다.

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
EntityManager em = emf.createEntityManager();
// JPQL - EXISTS 사용
TypedQuery<Order> query =
em.createQuery("select o from Order o " +
        "where exists (select item from LineItem item where item.order = o and item.product.id = :productId)"
        , Order.class);
query.setParameter("productId", 2L);
List<Order> orders = query.getResultList();
/*
Hibernate:
    select
        order0_.id as id1_1_
    from
        orders order0_
    where
        exists (
            select
                lineitem1_.id
            from
                line_items lineitem1_
            where
                lineitem1_.order_id=order0_.id
                and lineitem1_.product_id=?
        )
*/

Spring Data JPA로 아래와 같이 할 수 있다.

1
2
3
4
public interface OrderRepository extends JpaRepository<Order, Long> {
  @Query("select o from Order o where exists (select item from LineItem item where item.order = o and item.product.id = :productId)")
  List<Order> findByProductId(@Param("productId") Long productId);
}

JPQL - JOIN

JOIN을 사용하여 연관 엔터티 콜렉션을 Alias로 조건을 걸어서 조회한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
EntityManager em = emf.createEntityManager();
// JPQL - JOIN 사용
TypedQuery<Order> query =
em.createQuery("select o from Order o join o.lineItems item " +
        "where item.product.id = :productId", Order.class);
query.setParameter("productId", 1L);
List<Order> orders = query.getResultList();
/*
Hibernate:
    select
        order0_.id as id1_1_
    from
        orders order0_
    inner join
        line_items lineitems1_
            on order0_.id=lineitems1_.order_id
    where
        lineitems1_.product_id=?
*/

역시 Spring Data JPA로 아래와 같이 할 수 있다.

1
2
3
4
public interface OrderRepository extends JpaRepository<Order, Long> {
  @Query("select o from Order o join o.lineItems item where item.product.id = :productId")
  List<Order> findByProductId(@Param("productId") Long productId);
}

마이크로서비스 확장

여기까지는 하나의 데이터베이스에 연관된 테이블이 모두 존재한다는 가정하에 작성해 보았다. 하지만 요즘 많이 시도하는 MSA 환경에서는 앞서 언급한 테이블은 주문(OrderService)과 상품(ProductService) 마이크로서비스로 나뉠 가능성이 높다.

image2019-8-23_9-19-1

물리적으로 분산된 환경에서 LineItem 엔터티의 Product 엔터티 매핑은 어떻게 변경해야 할까?

1
2
3
4
5
6
7
8
@Entity
@Table(name = "line_items")
public class LineItem {
  @ManyToOne
  @JoinColumn(name = "product_id", referencedColumnName = "id")
  private Product product;
  //...
}

Product 엔터티 대신 전역 고유 식별자 참조를 사용할 수 있다. 추론 객체 참조inferred object reference 라고도 하는데 LineItem 엔터티에서는 Product 엔터티의 전역 식별자 product_id를 참조하는 것이다.

1
2
3
4
5
6
@Entity
@Table(name = "line_items")
public class LineItem {
  private Long productId;
  //...
}

필자의 이전 글 ID로 다른 애그리게잇을 참조하라에서 자세한 예시를 확인할 수 있다.

데이터 복제

OrderService에서는 상품 정보가 필요할 때마다 ProductService의 API를 호출해야 할까? 분산된 환경에서 성능도 문제지만 마이크로 서비스 사이에 강한 결합이 발생한다. 강한 결합의 서비스의 유연성을 떨어뜨릴 뿐만 아니라 변경에 따른 영향도 역시 커진다.

필자가 경험으로 얻은 규칙은 개별 마이크로서비스 내에서는 DRYdon't repeat yourself를 위반하지 않아야 하지만 전체 서비스 간의 DRY 위반은 너무 걱정하지 않아도 된다는 것이다. 서비스 사이의 지나치게 강한 결합이 가져오는 해악이 코드 중복이 초래하는 문제보다 더 심각하기 때문이다. - 마이크로 서비스 아키텍처 구축, 101 쪽

이런 문제는 데이터 복제로 해결할 수 있다. 주문 당시에 상품 정보를 LineItem에 함께 기록하는 것이다. 주문에서 필요한 상품 정보를 LineItem 엔터티의 속성에 추가하거나 JSON 필드 하나로 추가하는 방법이 있다. 필자의 이전 글 DDD 값 객체와 마이크로서비스에서 예시를 확인할 수 있다.

주문 상품 데이터와 현재 상품 데이터를 함께 보고 싶다면...

관심사의 분리Separation of concerns라는 설계 원칙이 있다. 이 경우는 OrderService와 ProductService 둘 모두 관심사가 아니다.

이를 해결하는 방법으로는 먼저 이 기종 데이터베이스(이 글에서는 Order DB, Product DB) JOIN 할 수 있는 Presto 같은 것을 사용하여 보여 줄 수 있다. 김형준 님 글에 잘 나와있다.

두 번째로는 Kafka나 RabbitMQ 같은 메시징 시스템을 사용하는 것이다. OrderService는 주문이 생성 되거나 변경되면 이를 메시지로 발행한다. ProductService 역시 상품이 생성되거나 변경되면 메시지로 발행한다. AggregatorService[1]는 주문 정보와 상품 정보를 구독하여 이를 조합하여 보여준다.

image2019-8-23_10-34-58

주석

[1] Aggregator 라는 작명은 EIPEnterprise Integration Patterns에서 빌려 왔다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.