DDD 값 객체와 마이크로서비스
이 글은 도메인 주도 설계Domain-Driven Design(이하 DDD)에서 말하는 값 객체Value Object가 무엇인지 알아보고 마이크로서비스 환경에서 값 객체를 활용하는 법을 다룬다.
값 객체란 무엇인가?
전자 상거래 사이트에서 상품을 받을 주소(배송지)를 입력하고 주문한다고 생각해 보자. 시스템에서 배송지를 임의로 수정한다면 상품은 정상적으로 배송되지 못할 것이다. 따라서 시스템은 배송지를 바뀌지 않게 다뤄야 한다.
배송지를 바뀌지 않게 클래스로 표현하면 아래와 같다. 바뀌지 않는다는 것은 생성 이후에는 변경되지 않음을 의미한다. 이를 구현하기 위해 생성자로만 객체를 생성할 수 있으며 속성을 변경하는 Setter가 없다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
public class ShippingAddress { private String zipCode; private String address; private String recipient; public ShippingAddress(String zipCode, String address, String recipient) { this.zipCode = zipCode; this.address = address; this.recipient = recipient; } public String getZipCode() { return zipCode; } public String getAddress() { return address; } public String getRecipient() { return recipient; } }
고객이 주문 배송지를 변경하는 경우가 있다. 이때 시스템은 개별 속성(우편번호, 주소, 수취인)을 변경하지 않고 새로운 배송지로 대체한다.
1
order.setShippingAddress(new ShippingAddress("12-334", "Seoul", "Yoo Young-mo"));
배송지를 바꿔야 하면 새로운 값으로 변경할 뿐, 굳이 주소 수정 이력을 모두 보관할 필요는 없다. DDD에서는 "개념적 식별성을 갖지 않으면서 도메인의 서술적 측면을 나타내는 객체"를 값 객체라고 정의한다.
ENTITY의 식별성을 관리하는 일은 매우 중요하지만 그 밖의 객체에 식별성을 추가하면서 시스템의 성능이 저하되고, 분석 작업이 별도로 필요하며, 모든 객체를 동일한 것으로 보이게 해서 모델이 혼란스러워질 수 있다. 소프트웨어 설계는 복잡성과의 끊임없는 전투다. 그러므로 우리는 특별하게 다뤄야 할 부분과 그렇지 않은 부분을 구분해야 한다. 하지만 이러한 범주에 속하는 객체를 단순히 식별성이 없는 것으로만 생각한다면 우리의 도구상자나 어휘에 추가할 게 그리 많지 않을 것이다. 사실 이 같은 객체는 자체적인 특징을 비롯해 모델에 중요한 의미를 지닌다. 이것들이 사물을 서술하는 객체다. 개념적 식별성을 갖지 않으면서 도메인의 서술적 측면을 나타내는 객체를 VALUE OBJECT라 한다. - 도메인 주도 설계 100 쪽
JPAJava Persistence API 값 타입Value Type을 사용하여 배송지를 값 객체로 구현할 수 있다. 결과적으로 아래 코드는 데이터베이스에 ShippingAddress 테이블이 아닌 Order 테이블의 컬럼으로 매핑된다.
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; @Embedded private ShippingAddress shippingAddress; //... } @Embeddable public class ShippingAddress { private String zipCode; private String recipient; private String address; private ShippingAddress() { } public ShippingAddress(String zipCode, String address, String recipient) { this.zipCode = zipCode; this.address = address; this.recipient = recipient; } public String getZipCode() { return zipCode; } public String getAddress() { return address; } public String getRecipient() { return recipient; } }
ShippingAddress를 엔터티가 아닌 값 객체로 설계함으로써 엔터티간 연관 관계나 임의의 복잡한 상태에 놓일 수 있는 것을 고민하지 않아도 된다. 따라서 설계가 단순해졌을 뿐만 아니라 데이터베이스 JOIN이 발생하지 않으므로 성능 측면에서도 더 낫다.
결국 DDD에서 말하는 것처럼 특별히 다루지 않아야 하는 것을 값 객체로 만듦으로써 복잡성을 줄일 수 있다.
그리고 마이크로서비스
멀티 판매 채널에서 주문 가능한 마이크로서비스가 있다고 가정해보자.
판매 채널에서는 OrderService로 주문을 요청하고 OrderService는 판매 채널에서 요청한 주문을 저장(구매 주문)하고 비동기 주문 이벤트를 발행한다. 주문 이벤트를 구독하고 있던 각 서비스는 판매 채널 정책에 따라 자신(서비스)의 역할을 수행한다.
주문 업무의 특징 중 하나는 주문 당시 데이터(상품, 쿠폰, 결제, 회원, 배송지 등 - 이하 스냅샷Snapshot)를 기준으로 주문 처리를 한다는 것이다. 이러한 스냅샷 데이터를 모두 엔터티로 설계한다면 도메인 모델은 매우 복잡해질 것이다.
스냅샷이라는 말 자체가 의미하는 것처럼 바뀌지 않는 값이다. 이것은 값 객체로 설계할 수 있음을 의미한다. 문제는 멀티 채널에서 주문하기 때문에 스냅샷 데이터 구조가 가변적이라는 것이다.
아래는 JPA로 구현한 코드이다. 주문의 가변적인 스냅샷 데이터를 다루기 위해 String으로 데이터 타입 사용하고 JSON 문자열로 저장한다.
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 33 34 35 36 37 38 39 40 41 42 43
@Entity public class PurchaseOrder { @Id @GeneratedValue private Long id; private Long orderId; private Long channelId; @Enumerated(EnumType.STRING) private PurchaseOrderStatus status; @OneToMany(mappedBy = "purchaseOrder", cascade = CascadeType.ALL) private List<OrderLineItem> orderLineItems = new ArrayList<>(); // 주문 결제 정보 @Lob private String payment; // 주문 당시 회원 정보 @Lob private String customer; // 쿠폰 @Lob private String coupons; // 주문 배송지 @Lob private String shippingAddress; // ... } @Entity public class OrderLineItem { @Id @GeneratedValue private Long id; private String productId; private OrderLineItemStatus status; // 주문 당시 상품 정보 @Lob private String product; // LineItem 쿠폰 @Lob private String coupons; @ManyToOne @JoinColumn(name = "po_id", referencedColumnName = "id") private PurchaseOrder purchaseOrder; // ... }