Chapter1-도메인 모델 시작
도메인
- 온라인 서점(쇼핑몰) 소프트웨어는 온라인으로 책을 판매하는데 필요한 상품조회, 구매, 결제, 배송 추적 등의 기능을 제공해야 한다.
- 이때 ‘온라인 서점’은 소프트웨어로 해결하고자 하는 문제 영역, 즉 도메인에 해당한다.
- 한 도메인은 다시 하위 도메인으로 나눌 수 있다.
[그림1.1] 도메인은 여러 하위 도메인으로 구성된다.
- 카탈로그 하위 도메인은 고객에게 구매할 수 있는 상품 목록을 제공하고, 주문 하위 도메인은 고객의 주문을 처리한다.
- 한 하위 도메인은 다른 하위 도메인과 연동하여 완전한 기능을 제공한다.
- 예를 들어, 고객이 물건을 구매하면 주문, 결제, 배송, 혜택 하위 도메인의 기능이 엮이게 된다.
- 도메인마다 고정된 하위 도메인이 존재하는 것은 아니다.
- 모든 온라인 쇼핑몰이 고객 혜택을 제공하는 것은 아니며 규모가 크지 않은 소규모 업체는 엑셀과 같은 도구를 이용해 수작업으로 정산을 처리할 수도 있다.
- 하위 도메인을 어떻게 구성하지 여부는 상황에 따라 달라진다.
- 예를 들어 기업 고객을 대상으로 대형 장비를 판매하는 곳은 온라인으로 카탈로그를 제공하고 주문서를 받는 정도만 필요할 것이다.
도메인 모델
- 기본적으로 도메인 모델은 특정 도메인을 개념적으로 표현한 것이다.
- 예를 들어, 주문 도메인 모델을 살펴보자.
[그림1.3] 객체 기반 주문 도메인 모델
- [그림1.3]의 모델은 도메인의 모든 내용을 담고 있진 않지만 이 모델을 보면 주문(Order)은 주문번호(OrderNumber)와 지불할 총금액(totalAmounts)이 있고, 배송정보(Shipping)를 변경할 수 있음을 알 수 있다.
- 즉, 도메인 모델을 사용하면 여러 관계자들이 동일한 모습으로 도메인을 이해하고 도메인 지식을 공유하는데 도움이 된다.
- 도메인을 이해하려면 도메인이 제공하는 기능과 도메인의 주요 데이터 구성을 파악해야 하는데, 이런 면에서 기능과 데이터를 함께 보여주는 객체 모델은 도메인을 모델링하기 적합하다.
- 도메인 모델을 객체로만 모델링할 수 있는 것은 아니고 상태 다이어그램 등 다양한 표현 방법을 사용할 수 있다.
- 즉, 관계가 중요한 도메인이라면 그래프를 이용해서 도메인을 모델링할 수 도 있는 것처럼 적절하게 사용하는게 좋다.
- 도메인 모델은 기본적으로 도메인 자체를 이해하기 위한 개념 모델이다.
- 개념 모델과 구현 모델은 서로 다르지만 구현 모델이 개념 모델을 최대한 따르도록 할 수는 있다.
하위 모데인과 모델: 모델의 각 구성 요소는 특정 도메인을 한정할 때 비로소 의미가 완전해지기 떄문에, 각 하위 도메인마다 별도로 모델을 만들어야 한다. 이는 카탈로그 하위 도메인 모델과 배송하위 도메인 모델을 따로 만들어야 한다는 것을 뜻한다.
도메인 모델 패턴
[그림1.5] 아키텍처 구성
- 일반적인 애플리케이션 아키텍처는 위 이미지와 같이 네 개의 층으로 구성된다.
- 각 계층의 역할은 다음과 같다.
- 표현(UI) 계층: 사용자의 요청을 처리하고 사용자에게 정보를 보여준다. 여기서 사용자는 소프트웨어를 사용하는 사람 뿐 아니라 외부 시스템도 사용자가 될 수도 있다.
- 응용(Application) 계층: 사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
- 도메인 계층: 시스템이 제공할 도메인의 규칙을 구현한다.
- 인프라스트럭처 계층: DB나 메시징 시스템과 같은 외뷔 시스템과의 연동을 처리한다.
- 도메인 계층은 도메인의 핵심 규칙을 구현한다.
- 주문 도메인의 경우 ‘출고 전에 배송지를 변경할 수 있다’는 규칙과 ‘주문 취소는 배송 전에만 할 수 있다’는 규칙을 구현한 코드가 도메인 계층에 위치하게 된다.
- 이런 도메인 규칙을 객체 지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public class Order {
private OrderState state;
private ShippingInfo shippingInfo;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
if (!state.isShippingChangeable()) {
throw new IllegalStateException("can't change shipping in " + state);
}
this.shippingInfo = newShippingInfo;
}
public void changeShipped() {
// 로직 검사
this.state = OrderState.SHIPPED;
}
...
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public enum OrderState {
PAYMENT_WAITING {
public boolean isShippingChangeable() {
return true;
}
},
PREPARING {
public boolean isShippingChangeable() {
return true;
}
},
SHIPPED, DELIVERING, DELIVERY_COMPLETED;
public boolean isShippingChangeable() {
return false;
}
}
|
- 위 코드는 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 것이다.
- 주문 상태를 표현하는 OrderState는 배송지를 변경할 수 있는지 여부를 검사할 수 있는 isShippingChangeable() 메서드를 제공하고 있다.
- 코드를 보면 주문 대기 중(PAYMENT_WAITING) 상태와 상품 준비 중(PREPARING) 상태만 배송지를 변경할 수 있딴 것을 알 수 있다. 즉, OrderState는 주문 대기 중 이거나 상품 준비 중에는 배송지를 변경할 수 있따는 도메인 규칙을 구현하고 있다.
- 위 코드에선 배송지 변경 가능 여부를 판단할 규칙이 OrderState 쪽에 구현되어 있지만 Order 클래스에서 이를 구현할 수 도 있다.
- 하지만어디에 있뜬 중요한 점은 주문과 관련된 중요 업무 규칙을 주문 도메인 모델인 Order나 OrderState에서 구현하다는 점이다.
- 핵심 규칙을 구현한 코드는 도메인 모델에만 위치하기 때문에 규칙이 바뀌거나 규칙을 확장해야 할 때 다른 코드에 영향을 덜 주고 변경 내역을 모델에 반영할 수 있게 된다.
Note: ‘도메인 모델’이란 용어는 도메인 자체를 표현하는 개념적인 모델을 의미하지만, 도메인 계층을 구현할 때 사용하는 객체 모델을 언급할 때에도 ‘도메인 모델’이란 용어를 사용한다.
개념 모델과 구현 모델: 처음부터 완벽한 개념 모델을 만들기보단 전반적인 개요를 알 수 있는 수준으로 개념 모델을 작성해야 한다. 프로젝트 초기에는 개요 수준의 개념 모델로 도메인에 대한 전체 윤곽을 이해하는데 집중하고, 구현하는 과정에서 개념 모델을 구현 모델로 점진적으로 발전시켜 나가야 한다.
도메인 모델 도출
- 기획서, 유스 케이스, 사용자 스토리와 같은 요구사항과 관련자와의 대화를 통해 도메인을 이해하고 이를 바탕으로 도메인 모델 초안을 만들어야 비로소 코드를 작성할 수 있다.
- 도메인을 모델링할 떄 기본이 되는 작업은 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다.
- 주문 도메인과 관련된 몇가지 요구사항을 보자
- 최소 한 종류 이상의 상품을 주문해야 한다.
- 한 상품을 한 개 이상 주문할 수 있다.
- 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
- 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
- 주문할 때 배송지 정보를 반드시 지정해야 한다.
- 배송지 정보는 받는 사람 이름, 전화번호, 주소로 구성된다.
- 출고를 하면 배송지 정보를 변경할 수 없다.
- 출고 전에 주문을 취소할 수 있다.
- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
- 위 요구사항을 통해 아직 상세 구현까지 할 수 있는 수준은 아니지만 Order에 관련 기능을 메서드로 추가할 수 있다.
1
2
3
4
5
6
| public class Order {
public void changeShipped() { ... }
public void changeShippingInfo(ShippingInfo newShipping) { ... }
public void cancel() { ... }
public void completePayment() { ... }
}
|
- 다음 요구사항은 주문 항목이 어떤 데이터로 구성되는지 알려준다.
- 한 상품을 한 개 이상 주문할 수 있다.
- 각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
- 두 요구사항에 따르면 주문 항목을 표현하는 OrderLine은 적어도 주문할 상품, 상품의 가격, 구매 개수를 포함하고 있어야 한다. 이를 OrderLine으로 구현하면 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public class OrderLine {
private Product product;
private int price;
private int quantity;
private int amount;
public OrderLine(Product product, int price, int quantity) {
...
}
private int calculateAmounts() {
return price * quantity;
}
public int getAmounts() { ... }
...
}
|
- 다음 요구사항은 Order와 OrderLine과의 관계를 알려준다.
- 최소 한 종류 이상의 상품을 주문해야 한다.
- 총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
- 한 종류 이상의 상품을 주문할 수 있으므로 Order는 최소 한 개이상의 OrderLine을 포함해야 한다.
- 또한, OrderLine으로부터 총 주문 금액을 구할 수 있다. 이를 코드에 반영하면 다음과 같다.
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
| public class Order {
private List<OrderLine> orderLines;
private int totalAmounts;
public Order(list<OrderLine> orderLines) {
setOrderLines(orderLines);
}
private void setOrderLines(List<OrderLine> orderLines) {
verityAtLeastOneOrMoreOrderLines(orderLines);
this.orderLines = orderLines;
calculateTotalAmounts();
}
private void verifyAtLeastOneOrMoeOrderLines(List<OrderLine> orderLines) {
if (orderLines == null || orderLines.isEmpty()) {
throw new IllegalArgumentException("no OrderLine");
}
}
private void calculateTotalAmounts() {
this.totalAmounts == new Money(orderLines.stream()
.mapToInt(x -> x.getAmounts().getValue()).sum());
}
...
}
|
- 요구사항에 따르면 최소 한 종류 이상의 상품을 주문해야 하므로 setOrderLines() 메서드는 verityAtLeastOneOrMoteOrderLines() 메서드를 이용하여 OrderLine이 한 개이상 존재하는지 검사한다.
- 또한, calcuateTotalAmounts() 메서드를 이용해 총 주문 금액을 계산한다.
- 배송지 정보는 이름, 전화번호, 주소 데이터를 가지므로 ShippingInfo 클래스를 다음과 같이 정의하였다.
1
2
3
4
5
6
7
8
9
| public class ShipingInfo {
private String receiverName;
private String receiverPhoneNumber;
private String shipingAddress1;
private String shipingAddress2;
private String shipingZipcode;
... 생성자, getter
}
|
- 앞서 요구사항 중에 ‘주문할 때 배송지 정보를 반드시 지정해야 한다’는 내용이 있는데 이는 Order 생성시 생성자에 OrderLine의 목록 뿐만 아니라 ShippingInfo도 함께 전달해야 함을 의미한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public class Order {
private List<OrderLine> orderLines;
private ShippingInfo shippingInfo;
...
public Order(List<OrderLine> orderLines, ShippingInfo shippingInfo) {
setOrderLines(orderLines);
setShippingInfo(shippingInfo);
}
private void setShippingInfo(ShippingInfo shippingInfo) {
if(shippingInfo == null) {
throw new IllegalArgumentException("no ShippingInfo");
}
this.shippingInfo = shippingInfo;
}
...
}
|
- 생성자에서 호출하는 setShippingInfo() 메서드는 ShippingInfo가 null이면 Exception을 발생하는데, 이렇게 함으로써 배송지 정보 필수라는 도메인 규칙을 구현한다.
- 도메인을 구현하다 보면 특정 조건이나 상태에 따라 제약이나 규칙이 달리 적용되는 경우가 많다.
- 출고를 하면 배송지 정보를 변경할 수 없다.
- 출고 전에 주문을 취소할 수 있다.
- 고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
- 이는 요구사항이 추가로 존재할 수 있는 상태를 분석한 뒤, 다음과 같이 열거 타입을 이용해서 상태 정보를 표현할 수 있다.
1
2
3
4
5
6
7
8
| public enum OrderState {
PAYMENT_WAITING,
PREPARING,
SHIPPED,
DELIVERING,
DELIVERY_COMPLETED,
CANCELED
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public class Order{
private OrderState state;
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
}
public void cancel(){
verifyNotYetShipped();
this.state = OrderState.CANCELED;
}
private void verifyNotYetShipped(){
if(state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING)
throw new IllegalStateException("already shippped");
}
...
}
|
문서화: 문서화를 하는 주된 이유는 지식을 공유하기 위함이다. 코드를 이용해 전체 소프트웨어를 분석하려면 많은 시간이 소요되지만 전반적인 기능 목록이나 모듈 구조, 빌드 과정은 코드를 보고 직접 이해하는 것보다 상위 수준에서 정리한 문서를 참조하는 것이 소프트웨어 전반을 빨르게 이해하는데 도움이 된다. 그리고 코드가 도메인을 잘 표현해야 비로소 코드의 가독성이 높아지며 문서로서 코드가 의미를 갖는다.
엔티티와 벨류
엔티티
- 엔티티는 식별자를 갖는다.
- ex) 주문 도메인 -> 식별자: ‘주문 번호’
- Order는 엔티티로서 orderNumber를 식별자로 갖는다.
- 엔티티의 식별자는 바뀌지 않고 고유하기 떄문에 두 엔티티 객체의 식별자가 가틍면 두 엔티티를 같다고 판단할 수 있따.
- 엔티티를 구현한 클래스는 다음과 같이 식별자를 이용해서 equals() 메서드와 hashcode() 메서드를 구현할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public class Order {
public String orderNumber;
@Override
public boolean equals(Object obj){
if (this == obj) return true;
if (obj == null) return false;
if (obj.getClass() != Order.class) return false;
Order other = (Order)obj;
if (this.orderNumber == null) return false;
return this.orderNumber.equals(other.orderNumber);
}
@Override
public int hashCode(){
final int prime = 31;
int result = 1;
result = prime * result + ((orderNumber == null) ? 0 : orderNumber.hashCode());
return result;
}
}
|
엔티티의 식별자 생성 방식
- 1)특정 규칙에 따라 생성한다 - 주문번호,운송장번호 등 특정 규칙에 따라 생성
- 2)UUID 사용
UUID uuid = UUID.randomUUID();
// 615fsdf34-c342-5scd-d33d-123145sadfa 와 같은 문자열
- 3)값을 직접 입력 - 회원 아이디, 이메일
- 4)일렬번호 사용 (시퀀스나 DB의 자동 증가 칼럼(auto_increment) 사용)
벨류 타입
1
2
3
4
5
6
7
8
9
10
| public class ShippingInfo {
private String receiverName; // 받는 사람
private String receiverPhoneNumber; // 받는 사람
private String shippingAddress1; // 주소
private String shippingAddress2; // 주소
private String shippingAddressZipcode; // 주소
...
}
|
- ShippingInfo 클래스의 receiverName 필드와 receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만 두 필드는 개념적으로 받는 사람을 의미한다.
- 즉, 두 필드는 실제로 한 개의 개념을 표현하고 있다.
- 비슷하게 shippingAddress1, shippingAddress2, shippingAddressZipcode 는 주소라는 하나의 개념을 표현한다.
- 벨류 타입은 개념적으로 완전한 하나를 표현할 떄 사용한다.
- 예를 들어, 받는 사람을 위한 벨류 타입인 Receiver와 주소 관련 데이터를 표현하는 Address 를 다음과 같은 벨류 타입으로 작성할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public class Receiver {
private String name;
private String phoneNumber;
public Receiver(String name, String phoneNumber){
this.name = name;
this.phoneNumber = phoneNumber;
}
public String getName(){
return name;
}
public String getPhoneNumber(){
return phoneNumber;
}
}
|
1
2
3
4
5
6
7
| public class Address{
private String address1;
private String address2;
private String zipcode;
...
}
|
- 벨류 타입을 이용한 ShippingInfo 클래스는 다음과 같이 변경될 것이다.
1
2
3
4
5
6
| public class ShippingInfo{
private Receiver receiver;
private Address address;
...
}
|
- 벨류 타입이 꼭 두 개 이상의 데이터를 가져야 하는 것은 아니다.
- 의미를 명확히 표현하기 위해 사용하는 경우도 있다.
- 이를 위한 좋은 예가 OrderLine이다.
1
2
3
4
5
6
7
| public class OrderLine {
private Product product;
private int price;
private int quantity;
private int amounts;
...
}
|
- price와 amounts는 int 타입의 숫자를 사용하고 있지만, 의미하는 값은 ‘돈’ 이다.
- 따라서 ‘돈’을 의미하는 Money 타입을 만들어 사용하면 코드를 이해하는데 도움 된다.
1
2
3
4
5
6
7
8
9
10
11
| public class Money{
private int value;
public Money(int value){
this.money = money;
}
public int getValue() {
return this.value;
}
}
|
1
2
3
4
5
6
7
| public class OrderLine{
private Product product;
private Money price; // Money 타입 덕에 price가 금액을 의미한다는 것을 쉽게 유추할 수 있음
private int quantity;
private Money amounts; // Money 타입 덕에 price가 금액을 의미한다는 것을 쉽게 유추할 수 있음
...
}
|
- 벨류 타입을 사용할 때의 또 다른 장점은 벨류 타입을 위한 기능을 추가할 수 있다는 것이다.
- 예를 들어, Money 타입은 다음과 같이 돈 계산을 위한 기능을 추가할 수 있다.
- 또한 이를 통해 벨류 타입은 코드의 의미를 더 잘 이해할 수 있도록 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| public class Money{
private int value;
public Money(int value){
this.money = money;
}
public int getValue() {
return this.value;
}
//새로운 기능 추가 가능
public Money add(Money money){
return new Money(this.value + money.value);
}
public Money multiply(int multiplier) {
return new Money(this.value * multiplier);
}
}
|
참조 투명성과 관련된 문제
- 벨류 객체의 데이터 변경시 기존 데이터를 변경하기보단 변경한 데이터를 갖는 새로운 객체를 생성하는 방식이 좋다.
- 예를 들어, 앞서 Money 클래스의 add 메서드를 보면 Money를 새로 생성하고 있다.
- Money처럼 데이터 변경 기능을 제공하지 않는 타입을 불변(immutable)이라 표현한다.
- 벨류 타입을 불변으로 구현하는 이유는 여러가지가 있는데 가장 중요한 이유는 보다 안전한 코드를 작성할 수 있다는 것이다.
- 만약 Money 가 setValue와 같은 메서드를 제공하여 값을 변경할 수 있다면 아래 이미지처럼 OrderLine의 price값이 잘못 반영되는 상황이 발생하게 된다.
1
2
3
| Money price = new Money(1000);
OrderLine line = new OrderLine(product, price, 2) // -> [price=1000, quantity=2, amounts=2000]
price.setValue(2000); // -> [price=2000, quantity=2, amounts=2000]
|
- 이런 문제를 방지하려면 OrderLine 생성자는 다음과 같이 새로운 Money 객체를 생성하도록 코드를 작성해야 한다.
- 하지만, Money가 불변이라면 이런 코드를 작성할 필요가 없다.
- 그렇지만 개인적인 생각은 추후 어떠한 변경사항이 생길지 모르기 떄문에 안전하게 아래와 같이 처리하는게 좋은것 같다,.
1
2
3
4
5
6
7
8
9
10
11
12
| public class OrderLine {
...
private Money price;
public OrderLine(Product product, Money price, int quantity) {
this.product = product;
// Money 가 불변 객체가 아니라면,
// price 파라미터가 변경될 떄 발생하는 문제를 방지하기 위해
// 데이터를 복사한 새로운 객체를 생성해야 한다.
this.price = new Money(price.getValue());
}
}
|
- 두 벨류 객체가 같은지 비교할 땐 모든 속성이 같은지 비교해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| public class Receiver{
private String name;
private String phoneNumber;
public boolean equals(Object other){
if (other == null) return false;
if (this == other) return true;
if (! (other instanceof Receiver)) return false;
Receiver that = (Receiver) other;
return this.name.equals(that.name) && this.phoneNumber.equals(that.phoneNumber);
}
}
|
엔티티 식별자와 벨류 타입
- 식별자가 어떤 도메인의 식별자인지를 분명히 나타내기 위해서 밸류 타입을 사용할 수 있다.
- 밸류 타입을 통해 식별자의 의미를 분명히 드러낼 수 있다.
1
2
3
4
5
| public class Order{
//private String id;
private OrderNo id;
...
}
|
도메인 모델에 set 메서드 넣지 않기
- 도메인 모델에 get/set 메서드를 무조건 추가하는것은 좋지 않은 버릇이다.
- set메서드는 도메인의 핵심 개념이나 의도를 코드에서 사라지게 한다.
- 앞서 changeShippingInfo()가 배송지 정보를 새로 변경하다는 의미를 가졌다면 setShippingInfo() 메서드는 단순히 배송지 값을 설정한다는 것을 뜻한다.
- completePayment()는 결제 완료와 관련된 처리 코드를 함께 구현하기 때문에 결제 완료와 관련된 도메인 지식을 코드로 구현하는 것이 자연스럽다.
- 하지만 setOrderState는 단순히 상태값만 변경할지 아니면 상태 값에 따라 다른 처리를 위한 코드를 함꼐 구현할지 애매하다.
- set 메서드의 또 다른 문제점은 도메인 객체를 생성시 완전한 상태가 아닐 수도 있다는 것이다.
1
2
3
4
5
6
7
8
9
10
| // set 메서드로 데이터를 전달하도록 구현하면
// 처음 Order를 생성하는 시점에 order는 완전하지 않다.
Order order = new Order();
// set 메서드로 필요한 모든 값을 전달해야 한다.
order.setOrderLine(lines);
order.setShippingInfo(shipingInfo);
// 주문자(Orderer)를 설정하지 않은 상태에서 주문 완료 처리 -> 문제 발생!!!
order.setState(OrderState.PREPARING);
|
- 도메인 객체가 불완전한 상태로 사용되는 것을 막으려면 생성 시점에 필요한 것을 전달해 주어야 한다.
1
| Order order = new Order(orderer, lines, shippingInfo, OrderState.PREPARING);
|
- 생성자로 필요한 것을 모두 받으므로 다음처럼 생성자를 호출하는 시점에 필요한 데이터가 올바른지 검사할 수 있다.
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
| public class Order {
public Order(Orderer orderer, List<OrderLine> orderLines, ShippingInfo shippingInfo, OrderState orderState) {
setOrderer(orderer);
setOrderLines(orderLines);
... // 다른 값 설정
}
private void setOrderer(Orderer orderer) {
if (orderer == null) {
throw new IllegalArgumentException("no orderer");
}
this.orderer = this.orderer;
}
private void setOrderLines(List<OrderLine> orderLines) {
vverifyAtLeastOneOrMoeOrderLines(orderLines);
this.orderLines = orderLines;
calculateTotalAmounts();
}
private void verifyAtLeastOneOrMoeOrderLines(List<OrderLine> orderLines) {
if (orderLines == null || orderLines.isEmpty()) {
throw new IllegalArgumentException("no OrderLine");
}
}
private void calculateTotalAmounts() {
this.totalAmounts == new Money(orderLines.stream()
.mapToInt(x -> x.getAmounts().getValue()).sum());
}
}
|
- 이 코드의 set 메서드는 접근 범위가 private 이다.
- 즉, 클래스 내부에서ㅔ 데이터를 변경할 목적으로 사용되며 외부에선 데이터를 호출할 수 없다.
- 불변 밸류 타입을 사용하면 자연스럽게 밸류 타입엔 set 메서드를 구현하지 않도록 하여 불변 타입의 장점을 살리도록 한다.
DTO의 get/set 메서드: DTO가 도메인 로직을 담고 있지는 않기에 get/set 메서드를 제공해도 도메인 객체의 데이터 일관성에 영향을 줄 가능성이 높지 않다.
도메인 용어
- 코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다. 도메인에서 사용하는 용어를 코드에 반영하지 않으면 그 코드는 개발자에게 코드의 의미를 해석해야 하는 부담을 준다.
1
2
3
| public OrderState {
STEP1, STEP2, STEP3, STEP4, STEP5, STEP6
}
|
1
2
3
4
5
6
7
| public class Order {
public void changeShippingInfo(ShippingInfo shippingInfo) {
verifySetp1OrStep2();
...
}
public void verityStep1OrStep2() { ... }
}
|
- 기획자나 온라인 쇼핑 도메인 전문가가 개발자와의 업무 회의에서 ‘출고 전’이라는 단어를 사용하면 개발자는 머릿속으로 ‘출고 전은 STEP1과 STEP2’라고 도메인 지식을 코드로 해석해야 한다.
- 도메인 용어는 좋은 코드를 만드는 데 매우 중요하지만 국내 개발자에게 불리한 면이 있다. 바로 영어 때문이다.
- 분야의 특성상 알파벡과 숫자를 사용해서 클래스, 필드, 메서드 등의 이름을 작성하게 되는데 이는 도메인 용어를 영어로 해석하는 노력이 필요함을 뜻한다.
- 알맞은 영어 단어를 찾는 것은 쉽지 않은 일이지만 시간을 들여 찾는 노력을 해야 한다. 적절한 단어를 사용하려는 노력하지 않는다면 코드는 도메인과 점점 멀어지게 된다.
Chapter2-아키텍처 개요
네 개의 영역
표현 영역
- 표현 영역(UI영역)은 사용자의 요청을 받아 응용 영역에 전달하고 응용 영역의 처리 결과를 다시 사용자에게 보여주는 역할을 한다.
- 표현 영역의 사용자는 웹 브라우저를 사용하는 사람일 수 도 있고, REST API 를 호출하는 외부 시스템일 수도 있다.
- 표현 영역을 통해 사용자의 요청을 전달받는 응용 영역은 시스템이 사용자에게 제공해야 할 기능을 제공한다.
- 예를 들어, ‘주문 등록’, ‘주문 취소’, ‘상품 상세조회’ 와 같은 기능을 구현한다.
응용 영역
- 응용 영역은 기능을 구현하기 위해 도메인 영역의 도메인 도메인 모델을 사용한다.
- 응용 서비스는 로직을 직접 수행하기보단 도메인 모델에 로직 수행을 위임한다. 마치 아래 예제 코드처럼 Order 도메인 모델에 취소 처리를 위임하는 것처럼.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| @Service
public class CancelOrderService {
...
@Transactional
public void cancel(String orderId) {
Order order = findOrderById(orderId);
if (order == null) {
throw new OrderNotFoundException(orderId);
}
order.cancel();
}
...
}
|
도메인 영역
- 도메인 영역은 도메인 모델을 구현한다.
- 도메인 모델은 도메인의 핵심 로직을 구현한다. (ex. 주문 도메인의 배송지 변경, 결제 완료, 주문 총액 계산과 같은)
인프라스트럭처 영역
- 구현 기술에 대한 것을 다룬다.
- 예를 들어, RDBMS, MessageQueue, 몽고 DB, HBase 와의 연동을 처리한다.
- 추가적으로 SMTP를 이용한 메일 발송 기능을 구현하거나 HTTP 클라이언트를 이용해서 REST API 를 호출하는 것도 처리한다.
도메인, 응용, 표현 영역은 실제 구현 기술을 사용한 코드를 직접 만들지 않는다. 대신 인프라스트럭처 영역에서 제공하는 기능을 ㅅ용해서 필요한 기능을 개발한다. 예를 들어, 응용 영역에서 DB에 보관된 데이터가 필요하면 인프라스트럭처 영역의 DB 모듈을 사용해서 데이터를 읽어온다. 비슷하게 외부에 메일을 발송하려면 인프라스트럭처가 제공하는 SMTP 연동 모듈을 이용해 메일을 발송한다.
계층 구조 아키텍처
- 네 영역을 구성할 때 많이 사용하는 아키텍처가 [그림2.4]와 같은 계층 구조이다.
- 도메인의 복잡도에 따라 응용과 도메인을 분리하기도 하고 합치기도 한다.
- 계층 구조는 단방향(하위 계층)으로만 의존하게 한다.
- 계층 구조를 엄격하게 적용하면 상위 계층은 바로 아래 계층에만 의존을 가져야 하지만, 구현의 편리함을 위해 계층 구조를 유옇나게 적용한다.
- 예를 들어, 응용 계층에서 외부 시스템과의 연동을 위해 더 아래 계층인 인프라 계층에 의존하기도 한다.
- 계층 구조에 따르면 도메인과 응용 계층은 룰 엔진과 DB 연동을 위해 아래 이밎와 같이 인프라 모듈에 의존하게 된다.
- 여기서 짚고 넘어가야 할 것은 표현, 응용, 도메인 계층이 상세한 구현 기술을 다루는 인프라 계층에 종속된다는 점이다.
- 이때 문제가 생길 수 있는 부분이 있다.
- 도메인의 가격 계산 규칙을 정할 때 할인 금액 계산 로직이 복잡해지면 객체 지향으로 로직을 구현하는 것 보단 룰 엔진을 사용하는 것이 더 알맞을 때가 있다.
Drools
라는 룰 엔진을 사용해서 로직을 수행할 수 있는 인프라 스트럭처 코드이다. (Drools는 무시해도 된다.)- 핵심은 evalute() 메서드에 값을 주면 별도 파일로 작성한 규칙을 이용해서 연산을 수행하는 코드라는 것 정도로만 이해하면 된다.
1
2
3
4
5
6
7
| // 인프라스트럭처 영역
public class DroolsRuleEngine {
...
public void evalute(String sessionName, List<?> facts) {
...
}
}
|
응용 영역은 가격 계산을 위해 인프라스트럭처 영역의 DroolsRuleEngine을 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // 응용 영역
public class CalculateDiscountService {
private DroolsRuleEngine ruleEngine;
public CalculateDiscountService() {
ruleEngine = new DroolsRuleEngine();
}
public Money calculateDiscount(OrderLine orderLines, String customerId) {
Customer customer = findCusotmer(customerId);
MutableMoney money = new MutableMoney(0);
List<?> facts = Arrays.asList(customer, money);
facts.addAll(orderLines);
ruleEngine.evalute("discountCalculation", facts);
return money.toImmutableMoney();
}
...
}
|
- 위 응용 영역에 위치한 서비스 코드는 두 가지 문제가 존재한다.
- 1)CalculateDiscountService 만 테스트하기 어렵다. RuleEnigne이 완벽하게 동작해야만 하며 이에 대한 설정 파일도 필요하게 된다.
- 2)구현 방식을 변경하기 어렵다는 점이다.
discountCalculation
문자열은 Drools
의 세션 이름이다. Drools의 세션 이름을 변경시 CalculateDiscountService의 코드도 같이 변경되어야한다.
- 이처럼 인프라스트럭처 계층에 의존하면 테스트의 어려움과 기능 확장의 어려움 이라는 두 가지 문제가 생긴다. 이를 해결하기 위해 SOLID 원칙 중 하나인 DIP 를 적용하면 된다.
DIP
- 가격 할인 계산을 하려면 아래 이미지의 왼쪽과 같이 고객 정보를 구해야 하고, 구현할 고객 정보와 주문 정보를 이용해서 룰을 실행해야 한다.
1
2
3
| public interface RuleDiscounter {
publis Money applyRules(Customer customer, List<OrderLine> orderLines);
}
|
이제 CalCulateDiscountService 가 RuleDiscounter 를 이용하도록 바꿔보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public class CalculateDiscountService {
private RuleDiscounter ruleDiscounter;
private CustomerRepository customerRepository;
public CalculateDiscountService(RuleDiscounter ruleDiscounter, CustomerRepository customerRepository) {
this.ruleDiscounter = ruleDiscounter;
this.customerRepository = customerRepository
}
public Money calculateDiscount(OrderLine orderLines, String customerId) {
Customer customer = customerRepository.findCusotmer(customerId);
return ruleDiscounter.applyRules(customer, orderLines);
}
private Customer findCustomer(String customerId) {
Customer customer = customerRepository.findById(customerId);
if (customer == null) throw new NoCustomerException();
return customer;
}
...
}
|
- CalculateDiscountService 는 Drools 에 의존하는 코드를 포함하고 있지 않다.
- 단지 RuleDiscounter가 룰을 적용한다는 것만 알 뿐이다.
- 룰 적용을 구현한 클래스는 RuleDiscounter 인터페이스를 상속받아 구현하다.
1
2
3
4
5
6
7
8
9
| public class DroolsRuleDiscounter implements RuleDiscounter {
...
@Override
public Money applyRule(Customer customer, List<OrderLine> orderLines) {
// 실제 구현 코드 생략
return money;
}
}
|
- 아래 이미지는 RuleDiscounter가 출현하면서 바뀐 구조를 보여주고 있다.
- 위 이미지 [그림 2.8]의 구조를 보면 CalculateDiscountService는 더 이상 구현 기술인 Drools에 의존하지 않는다.
- ‘룰을 이용한 할인 금액 계산’을 추상화한 RuleDiscounter 인터페이스에 의존할 뿐이다.
- ‘룰을 이용한 할인 금액 계산’은 고수준 모듈의 개념이므로 RuleDiscounter 인터페이스는 고수준 모듈에 속한다.
- DroolsRuleDiscounter는 고수준의 하위 기능인 RuleDiscounter를 구현한 것이므로 저수준 모듈에 속한다.
- DIP를 적용하면 위 이미지와 같이 저수준 모듈이 고수준 모듈에 의존하게 된다.
- 고수준 모듈이 저수준 모듈을 사용하려면 고수준 모듈이 저수준 모듈에 의존해야 하는데, 반대로 저수준 모듈이 고수준 모듈에 의존한다고 해서 이를 DIP(Dependency Inversion Principle, 의존 역전 원칙) 라고 부른다.
- 이처럼 DIP를 적용하면 앞서 다른 영역이 인프라스트럭처 영역에 의존할 때 발생했떤 두 가지 문제인 구현 교체가 어렵다는 문제와 테스트가 어려운 문제를 해소할 수 있다.
1) 구현체 교체의 어려움 해결
- 고수준 모듈은 더 이상 저수준 모듈에 의존하지 않고 구현을 추상화한 인터페이스에 의존한다.
- 실제 사용할 저수준 구현 객체는 아래 코드처럼 의존 주입을 이용해 전달받을 수 있다.
1
2
3
4
5
| // 사용할 저수준 객체 생성
RuleDiscounter ruleDiscounter = new DroolsRuleDiscounter();
// 생성자 방식으로 주입
CalculateDiscountService calculateDiscountService = new CalculateDiscountService(ruleDiscounter);
|
- 만약 구현 기술을 변경하더라도 CalculateDicountService를 수정할 필요가 없다.
- 아래 코드처럼 사용할 저수준 구현 객체를 생성하는 부분의 코드만 변경하면 되기 때문이다.
1
2
3
4
5
| // 사용할 저수준 구현 객체 변경
RuleDiscounter ruleDiscounter = new SimpleRuleDiscounter();
// 사용할 저수준 모듈을 변경해도 고수준 모듈을 수정할 필요가 없다.
CalculateDiscountService calculateDiscountService = new CalculateDiscountService(ruleDiscounter);
|
- 의존 주입을 지원하는 스프링과 같은 프레임워크를 사용하면 설정 코드를 수정해서 쉽게 구현체를 변경할 수 있다.
1) 테스트 어려움 해결
- CalculateDiscountService가 제대로 동작하는지 테스트하려면 CustomerRepository와 RuleDiscounter를 구현한 객체가 필요하다.
- 하지만 둘 다 인터페이스로 추상화 했기 때문에 대용 객체를 사용해서 테스트를 진행할 수 있다.
- 다음은 대용 객체를 사용해서 Customer가 존재하지 않는 경우 익셉션이 발생하는지 검증하는 테스트 코드인데, 실제 구현 클래스 없이 테스트 가능함을 보여준다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public class CalculateDiscountServiceTest {
@Test(expected = NoCustomerException.class);
public void noCustomer_thenExceptionShouldBeThrown() {
// 테스트 목적의 대용 객체
CustomerRepository stubRepo = mock(CustomerRepository.class);
when(stubRepo.findById("noCustId")).thenReturn(null);
RuleDiscounter stubRule = (cust, lines) -> null;
// 대용 객체를 주입받아 테스트 진행
CalculateDiscountService calcDisSvc = new CalculateDiscountService(stubRepo, stubRule);
calcDisSvc.calculateDiscount(someLines, "noCustId");
}
}
|
- 이렇게 실제 구현 없이 테스트 할 수 있는 이유는 DIP를 적용해서 고수준 모듈이이 저수준 모듈에 의존하지 않도록 했기 때문이다.
- 고수준 모듈인 CalculateDiscountService는 저수준 모듈에 직접 의존하지 않기 때문에 실제 저수준 구현 클래스 없이도 테스트 대용 객체를 이용해서 거의 모든 기능을 테스트할 수 있는 것이다.
DIP 주의사항
- DIP의 핵심은 고수준 모듈이 저수준 모듈에 의존하지 않도록 하기 위함이다.
- 하지만 아래 이미지와 같이 저수준 모듈에서 인터페이스를 추출하는 경우가 있다.
- 위 이미지는 잘못된 구조이다.
- 이 구조에서 도메인 영역은 이프라 스트러거 영역에 의존하고 있다.
- 즉, 여전히 고수준 모듈이 저수준 모듈에 의존하고 있는 것이다.
- RuleEngine 인터페이스는 고수준 모듈인 도메인 관점이 아니라 룰 엔진이라는 저수준 모듈 관점에서 도출한 것이다.
- DIP를 적용할 때 하위 기능을 추상화한 인터페이스는 고수준 모듈 관점(도메인 영역)에서 도출한다.
- CalculateDiscountService 입장에서 봤을 때 할인 금액을 구하기 위해 룰 엔진을 사용하는지, 직접 연산하는지 여부는 중요치 않다.
- 단지 규칙에 따라 할인 금액을 계산한다는 것이 중요할 뿐이다.
- 즉, ‘할인 금액 계산’을 추상화한 인터페이스는 저수준 모듈이 아닌 고수준 모듈에 위치한다.(아래 이미지 참고)
DIP와 아키텍처
- 인프라스트럭처 영역은 구현 기술을 다루는 저수준 모듈이고 응용 영여과 도메인 영역은 고수준 모듈이다.
- 인프라스트럭처 계층의 가장 하단에 위치하는 계층형 구조와 달리 아키텍처에 DIP를 적용하면 [그림 2.12]와 같이 인프라스트럭처 영역이 응용 영역과 도메인 영역에 의존(상속)하는 구조가 된다.
- 인프라스트럭처에 위치한 클래스가 도메인이나 응용 영역에 정의한 인터페이스를 상속받아 구현하는 구조가 되므로 도메인과 응용 영역에 대한 영향을 주지 않거나 최소화하면서 구현 기술을 변경하는 것이 가능하다.
- 위 이미지에서 인프라스트럭처 영역의 EmailNotifier 클래스는 응용 영역의 Notifier 인터페이스를 상속 받고 있다.
- 주문 시 통지 방식에 SMS를 추가해야 한다는 요구사항이 들어왔을땐 응용 영역의 OrderService를 변경할 필요가 없다.
- [그림 2.14]와 같이 두 통지 방식을 함께 제공하는 Notifier 구현 클래스를 인프라 스트럭처 영역에 추가하면 된다.
- 비슷하게 MyBatis 대신 JPA를 구현 기술로 사용하고 싶다면 JPA를 이용한 OrderRepository 구현 클래스를 인프라스트럭처 영역에 추가하면 된다.
도메인 영역의 주요 구성요소
- 엔티티:
- 고유의 식별자를 갖는 객체로 자신의 라이프 사이클을 가진다.
- 주문(Order), 회원(Member), 상품(Product)과 같이 도메인의 고유한 개념을 표현한다.
- 도메인 모델의 데이터를 포함하며 해당 데이터와 관련된 기능을 함께 제공한다.
- 밸류:
- 고유의 식별자를 갖지 않는 개체로 주로 개념적으로 하나인 도메인 객체의 속성을 표현할 때 사용된다.
- 주소(Address), 금액(Money)와 같은 타입이 밸류 타입이다.
- 엔티티의 속성으로 사용될 뿐 아니라 다른 밸류 타입의 속성으로도 사용될 수 있다.
- 애그리거트(Aggregate):
- 관련된 엔티티와 밸류 객체를 개념적으로 하나로 묶은 것이다.
- 예를 들어, 주문과 관련된 Order 엔티티, OrderLine 밸류, Orderer 밸류 객체를 ‘주문’ 애그리거트로 묶을 수 있다.
- 리포지토리(Repository):
- 도메인 서비스(Domain Service):
- 특정 엥ㄴ티티에 속하지 않은 도메인 로직을 제공한다.
- ‘할인 금액 계산’은 상품, 쿠폰, 회원 등급, 구매 금액 등 다양한 조건을 이용해서 구현하게 되는데, 이렇게 도메인 로직이 여러 엔티티와 밸류를 필요로 할 경우 도메인 서비스에서 로직을 구현한다.
엔티티와 밸류
- 실제 도메인 모델의 엔티티와 DB 관계형 모델의 엔티티는 같은 것이라 볼 수 없다.
- 위 두 모델의 가장 큰 차이점은 도메인 모델의 엔티티는 데이터와 도메인 기능을 함께 제공한다는 점이다.
1
2
3
4
5
6
7
8
9
10
11
| public class Order {
// 주문 도메인 모델의 데이터
private OrderNo number;
private Orderer orderer;
private ShippingInfo shippingInfo;
// 도메인 모델 엔티티는 모데인 기능도 함께 제공
public void changeShippingInfo(ShippingInfo newShippingInfo) {
...
}
}
|
- 또 다른 차이점은 모데인 모델의 엔티티는 두 개 이상의 데이터가 개념적으로 하나인 경우 밸류 타입을 이용해서 표현할 수 있다는 것ㅇ이다.
- 위 코드에서 주문자를 푠현하는 Orderer 는 밸류 타입으로 다음과 같이 주문자이름과 이메일 데이터를 포함할 수 있다.
1
2
3
4
5
| public class Orderer {
private String name;
private String email;
...
}
|
- 이를 DB 테이블로 표현하려면 아래 이미지와 같이 두 가지 방식을 적용할 수 있다.
- 왼쪽 테이블의 경우 주문자(Orderer)라는 개념이 드러나지 않고 주문자의 개별 데이터만 드러난다.
- 오른쪽 테이블의 경우 주문자 데이터를 별도 테이블에 저장했찌만 이는 테이블의 엔티티에 가깝지 밸류 타입의 의미가 드러나진 않는다.
- 반면 도메인 모델의 Orderer 는 주문자라는 개념을 잘 반영하므로 도메인을 보다 잘 이해할 수 있도록 돕는다.
- 그리고 앞서 설명했던 것처럼 밸류는 불변으로 구현하는 것을 권장한다.
- 이는 엔티티의 밸류 타입 데이터를 변경시 객체 자체를 완전히 새로 교체한다는 것을 의미한다.
Chapter3-애그리거트
애그리거트
애그리거트의 필요성
- 도메인이 커질수록 개발할 도메인 모델도 커지면서 많은 엔티티와 밸류가 출현한다.
- 엔티티와 밸류 개수가 많아지면 많아질수록 모델은 점점 더 복잡해진다.
- 도메인 모델이 복잡해지면 개발자가 전체 구조가 아닌 한 개 엔티티와 밸류에만 집중하게 되는 경우가 발생한다.
- 이떄 상위 수준에서 모델을 관리하기보다 개별 요소에만 초점을 맞추다 보면 큰 수준에서 모델을 이해하지 못해 큰 틀에서 모델을 관리할 수 없는 상항에 빠질 수 있다.
- 도메인 모델도 개별 객체뿐만 아니라 상위 수준에서 모델을 볼 수 있어야 전체 모델의 관계와 개별 모델을 이해하는데 도움이 된다.
- 도메인 모델에서 전체 구조를 이해하는데 도움이 되는 것이 바로 애그리거트(Aggregate) 이다.
애그리거트의 정의
- 애그리거트는 관련 객체를 하나로 묶은 군집이다.
- 대표적인 예가 주문이다.
- 주문이라는 도메인 개념은 ‘주문’, ‘배송지 정보’, ‘주문자’, ‘주문목록’, ‘총결제 금액’ 의 하위 모델로 구성되는데 이때 이 하위 개념을 표현한 모델을 하나로 묶어서 ‘주문’이라는 상위 개념으로 표현할 수 있다.
- 위 이미지는 주문 애그리거트를 보여주고 있다.
- 애그리거트 루트인 Order는 주문 도메인 로직에 맞게 애그리거트의 상태를 관리한다.
- 예를 들어, Order의 배송지 정보 변경 기능은 배송지를 변경할 수 있는지 확인한 뒤에 배송지 정보를 변경한다.
1
2
3
4
5
6
7
8
9
10
11
| public class Order {
...
public void changeShippingInfo(ShippingInfo shippinginfo) {
checkShippingInfoChangeable(); // 배송지 변경 가능 여부 확인
this.shippingInfo = newInfo;
}
private Boolean checkShippingInfoChangeable() {
... 배송지 정보를 변경할 수 있는지 여부를 확인하는 도메인 규칙 구현
}
}
|
- checkShippingInfoChangeable() 메서드는 도메인 규칙에 따라 배송지를 변경할 수 있는지 확인할 것이다.
- 예를 들어, 이미 배송이 시작된 경우 익셉션을 발생하는 식으로 도메인 규칙을 구현할 것이다.
- 주문 애그리거트는 Order 를 통하지 않고 ShippingInfo를 변경할 수 있는 방법을 제공하지 않는다.
- 즉, 배송지를 변경하려면 루트 엔티티인 Order 를 사용해야 하므로 배송지 정보를 변경시엔 Order 가 구현한 로직을 항상 따르게 된다.
- 애그리거트를 구현할 떈 고려할 것이 많다.
- 애그리거트를 어떻게 구성했느냐에 따라 구현이 복잡해지기도 하고 트랜잭션 범위가 달라지기도 한다.
- 또한 선택한 구현 기술에 따라 애그리거트 구현에 제약이 생기기도 한다.
- 애그리거트의 구현에 대한 내용은 3장에서 자세히 다룬다.
리포지터리
- 도메인 객체를 지속적으로 사용하기 위해서는 RDBMS, NoSQL, 로컬 파일과 같은 물리적인 저장소에 도메인 객체를 보관해야한다.
- 이를 위한 도메인 리포지터리이다.
- 리포지터리는 애그리게이트 단위로 도메인 객체를 저장하고 조회하는 기능을 정의한다.
1
2
3
4
5
| public interface OrderRepository {
public Order findByNumber(OrderNumber number);
public void save(Order order);
public void delete(Order order);
}
|
- 도메인 모델을 사용해야 하는 코드(주로 응용 서비스 영역의 코드일 것이다) 는 리포지토리를 통해 도메인 객체를 구한 뒤 도메인 객체의 기능을 실행하게 된다.
1
2
3
4
5
6
7
8
9
| public class CancelOrderService {
private OrderRepository orderRepository; // DI
public void cancel(OrderNumber number) {
Order order = orderRepository.findByNumber(number);
if (order == null) throw new NoOrderException(number);
order.cancel();
}
}
|
- 도메인 모델 관점에서 OrderRepository 는 도메인 객체를 영속화하는데 필요한 기능을 추상화한 것으로 고수준 모델에 속한다.
- 기반 기술을 이용해 OrderRepository를 구현한 클래스는 저수준 모듈로 인프라스트럭처 영역에 속한다.
- 즉, 모듈 구조는 아래 이미지와 같을 것이다.
요청 처리 흐름
- 표현 영역은 사용자가 전송한 데이터 형식을 올바른지 검사하고 문제가 없다면 데이터를 이용해서 으용 서비스에 기능 실행을 위임한다.
- 이떄 표현 영역은 사용자가 전송한 데이터를 응용 서비스가 요구하는 형식으로 변환해서 전달한다.
- RQ 모델을 그대로 응용 서비스에 전달하는게 아닌 validation 체크 후 도메인 모델로 컨버팅해서 전달하는 방식이 될 것이다.
- 웹 브라우저를 이용해서 기능 실행을 요청할 경우, 아래 이미지처럼 표현 영역에 해당하는 컨트롤러는 과정1.1처럼 HTTP 요청 파라미터를 응용 서비스가 필요로 하는 데이터로 변환 후 응용 서비스를 실행할 떄 파라미터로 전달한다.
- 응용 서비스는 도메인 모델을 이용해 기능을 구현한다.
- 기능 구현에 필요한 도메인 객체를 리포지토리에서 가져와 실행하거나 신규 도메인 객체를 생성해서 리포지터리에 쩌장한다.
- 두 개 이상의 도메인 객체를 사용해서 구현하기도 한다.
- 응용 서비스의 역할 중 하나는 아래 코드와 같이 트랜잭션을 관리하는 것이다.
1
2
3
4
5
6
7
8
9
10
| public class CancelOrderService {
private OrderRepository orderRepository; // DI
@Transactional // 응용 서비스는 트랜잭션을 관리한다.
public void cancel(OrderNumber number) {
Order order = orderRepository.findByNumber(number);
if (order == null) throw new NoOrderException(number);
order.cancel();
}
}
|
인프라스트럭처 개요
1
2
3
4
5
6
7
8
| // 구현의 편리함을 위해ㅣ 인프라스트럭처에 대한 의존을 일부 도메인에 넣은 코드
// JPA의 @Table 애노테이션을 이용해서 엔티티를 저장할 테이블 이름을 지정했다.
// XML 설정 보다 편리하게 테이블 이름을 지정 가능하다.
@Entity
@Table(name = "TBL_ORDER")
public class Order {
}
|
- 구현의 편리함은 DIP가 주는 다른 장점(변경의 유연함, 테스트가 쉬움) 만큼 중요하기 떄문에 DIP의 장점을 해치지 않는 범위에서 응용 영역과 도메인 영역에서 구현 기술에 대한 의존을 가져가는 것이 현명하다.
- 응용 영역과 도메인 영역이 인프라스트럭처에 대한 의존을 완전히 갖지 않도록 시도하는 것은 자칫 구현을 더 복잡하고 어렵게 만들 수 있다.
- 예를 들어, 스프링의
@Transaction
어노테이션을 사용하면 한 줄로 트랜잭션을 처리할 수 있는데, 코드에서 스프링에 대한 의존을 없애려면 복잡한 스프링 설정을 사용해야 한다.- 의존은 없애지만 특별히 테스트를 더 쉽게 할 수있다거나 유연함을 증가 시켜주지 못한다. 단지 설정만 복잡해지고 개발 시간만 늘어날 뿐이다…
모듈 구성
- 아키텍처의 각 영역은 패키지에 위치한다.
- 패키지 구성 규칙에 한 개의 정답만 존재하는 것은 아니지만 아래와 같이 영역별로 모듈이 위치할 때 패키지를 구성할 수 있을 것이다.
- 도메인이 크면 아래 이미지의 [그림 2.22]와 같이 하위 도메인으로 나누고 각 하위 도메인마다 별도 패키지를 구성한다.
- domain 모듈은 도메인에 속한 애그리거트를 기준으로 다시 패키지를 구성한다.
- 예를 들어, 카탈로그 하위 도메인을 위한 도메인은 상품 애그리거트와 카테고리 애그리거트로 구성된다고 할 경우, [그림 2.23] 과 같이 domain을 두 개의 하위 패키지로 구성해볼 수 있다.
- 각 애그리거트와 모델과 리포지토리는 같은 패키지에 위치시킨다.
- 예를 들어, 주문고 ㅏ관련된 Order, OrderLine, OrderRepository 등은 com.myshop.order.domain 패키지에 위치시킨다.
- 도메인이 복잡하면 도메인 모델과 도메인 서비스를 다음과 같이 별도 패키지에 위치시킬 수도 있다.
1
2
| com.myshop.order.domain.order: 애그리거트 위치
com.myshop.order.domain.servicee: 도메인 서비스 위치
|
응용 서비스도 다음과 같이 도메인 별로 패키지를 구분할 수 있다.
1
2
| com.myshop.catalog.application.product
com.myshop.catalog.application.category
|
Note: 모듈 구조를 얼마나 세분화 할지에 대해서는 정해진 규칙은 없다. 한 패키지에 너무 많은 타입이 몰려서 코드를 찾을 때 불편한 정도만 아니면 된다. 저자는 한 패키지에 가능하면 10개 미만으로 타입 개수를 유지하려고 노력한다고 한다. 이 개수가 넘어가면 모듈을 분리하는 시도를 해보자.
애그리거트
- 백 개이상의 테이블을 한장의 ERD에 모두 표시하면 개별 테이블 간의 관계를 파악하느라 큰 틀에서 데이터 구조를 이해하는데 어려움을 겪게 되는 것처럼, 도메인 객체 모델이 복잡해지면 개별 구성요소 위주로 모델을 이해하게 되고 전반적인 구조나 큰 수준에서 도메인 간의 관계를 파악하기 어려워진다.
- 주요 도메인 개념 간의 관계를 파악하기 어렵다는 것은 곧 코드를 변경하고 확장하는 것이 어려줘진다는 것을 의미한다.
- 상위 수준에서 모델이 어떻게 엮여 있는지 알아야 전체 모델을 망가뜨리지 않으면서 추가 요구사항을 모델에 반영할 수 있는데 세부적인 모델만 이해한 상태론 코드를 수정하기가 두렵기 때문에 코드 변경을 최대한 회피하는 쪽으로 요구사항을 협의하게 된다.
- 꼼수를 부려 당장 돌아가는 코드를 추가할 순 있지만 이는 장기적인 관점에서 코드를 더 수정하기 어렵게 만들기도 한다.
- 복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만들려면 상위 수준에서 모델을 조망할 수 있는 방법이 필요한데, 그 방법이 애그리거트이다.
- 앞선 쳅터에서 언급한 것처럼 애그리거트는 관련된 객체를 하나의 군으로 묶어준다.
애그리거트가 가지는 책임
- 위 이미지에서 본 것처럼 애그리거트는 경계를 갖는다.
- 한 애그리거트에 속한 객체는 다른 애그리거트에 속하지 않는다. 즉 독립된 객체 군이며, 각 애그리거트는 자기 자신을 관리할 뿐 다른 애그리거트를 관리하지 않는다.
- 예를 들어, 주문 애그리거트는 배송지를 변경하거나 주문 상품 개수를 변경하는 등 자기 자신을 관리하지만, 회원의 비밀번호를 변경하거나 상품의 가격을 변경하진 않는다.
애그리거트의 경계를 어떻게 나누느냐
- 경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다.
- 도메인 규칙에 따라 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높다.
- 예를 들어, 주문할 상품 개수, 배송지 정보, 주문자 정보는 주문 시점에 함께 생성되므로 이들은 한 애그리거트에 속한다.
- 또한, OrderLine의 주문 상품 개수를 변경시 도메인 규칙에 따라 Order의 총 주문 금액도 새로 계산해야 한다.
- 사용자 요구사항에 따라 주문 상품 개수와 배송지를 함께 변경하기도 한다.
- 이렇게 하면 함께 변경되는 비녿가 높은 객체는 한 애그리거트에 속할 가능성이 높다.
- 흔히 ‘A가 B를 갖는다’ 로 설계할 수 있는 요구사항이 있다면 A와 B를 한 애그리거트로 묶어서 생각하기 쉽다.
- 주문의 경우 Order가 ShippingInfo와 Orderer를 가지므로 이는 어느 정도 타당해 보인다.
- 하지만 ‘A가 B를 갖는다’로 해석할 수 있는 요구사항이 있따하더라도 이것이 반드시 A와 B가 한 애그리거트에 속한다는 것을 의미하는 것은 아니다.
- 좋은 예가 상품과 리뷰다. 상품 상세 페이지에 들어가면 상품 상세 정보와 함꼐 리뷰 내용을 보여줘야 한다는 요구사항이 있다면 Product 엔티티와 Review엔티티가 한 애그리거트에 속한다 생각할 수 있지만 이 둘은 함께 생성되지 않고 함께 변경되지 않는다.
- 게다가 Product 를 변경하는 주체가 상품 담당자라면 Review를 생성하고 변경하는 주체는 고객이다.
- Review의 변경이 Product에 영향을 주지 않고 반대로 Product의 변경이 Review에 영향을 주지 않기 떄문에 이 둘은 한 애그리거트에 속한다기보다는 [그림 3.3]에 표시한 것처럼 서로 다른 애그리거트에 속한다.
- 처음 도메인 모델을 만들기 시작하면 큰 애그리거트로 보이는 것들이 많지만 도메인에 대한 경험이 생기고 도메인 규칙을 제대로 이해할수록 실제 애그리거트의 크기는 줄어들게 된다.
- 저자의 경험을 비추어보면 다수의 애그리거트가 한 개의 엔티티 객체만 갖는 경우가 많으며 두 개 이상의 엔티티로 구성되는 애그리거트는 드물게 존재한다고 한다.
애그리거트 루트
- 애그리거트는 여러 객체로 구성되기 때문에 반드시 모든 객체들의 상태가 정상이어야 한다.
- 예를 들어, 개별 구매 상품의 개수인 quantity와 금액 price를 가지는 OrderLine과 Order 객체의 총 주문 금액 totalAmounts 는 정합성이 맞아들어야 한다.
- 애그리거트에 속한 모든 객체가 일관된 상태를 유지하려면 애그리거트 전체를 관리할 주체가 필요한데 이 책임을 지는 것이 바로 애그리거트의 루트 엔티티다.
- 위 이미지에선 Order 가 애그리거트 루트 역할을 한다 볼 수 있다.
도메인 규칙과 일관성
- 애그리거트 루트의 핵심 역할은 애그리거트의 일관성이 깨지지 않도록 하는 것이다.
- 이를 위해 애그리거트 루트는 애그리거트가 제공해야 할 도메인 기능을 구현한다.
- 예를 들어, 주문 애그리거트는 배송지 변경, 상품 변경과 같은 기능을 제공하는데 Order가 이 기능을 구현한 메서드를 제공한다.
- 애그리거트 루트가 제공하는 메서드는 도메인 규칙에 따라 애그리거트에 속한 객체의 일관성이 깨지지 않도록 구현해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public class Order{
// 애그리거트 루트는 도메인 규칙을 구현한 기능을 제공한다.
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped(); // 출고 전에만 배송지 변경을 할 수 있다는 규칙을 구현
setShippingInfo(newShippingInfo);
}
private void verifyNotYetShipped(){
if(state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING)
throw new IllegalStateException("already shippped");
}
...
}
|
- 애그리거트 루트가 아닌 다른 객체가 애그리거트에 속한 객체를 직접 변경하면 안된다.
- getter를 무분별하게 만들었을 때 발생하는 문제점 중 하나이다.
- 이는 애그리거트 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨는 원인이 된다.
1
2
| ShippingInfo si = order.getShippingInfo();
si.setAddress(newAddress);
|
- 위 코드는 주문 상태와 관계 없이 배송지 주소를 변경하게 되는데 논리적인 데이터 일관성이 꺠지게 된다.
- 일관성을 지키기 위해 상태 확인 로직을 응용 서비스에서 구현할 수 도 있지만, 이렇게 되면 동일한 검사 로직을 여러 응용 서비스에서 중복 구현할 가능성이 높아져 상황을 더 악화시킬 수 있다.
- 유지보수성이 떨어지게되고 막 예시를 들어 주소 변경 도메인 규칙이 추가되거나 변경될 경우 일일이 다 찾아서 처리를 해줘야 될 것이다..
1
2
3
4
5
6
7
| ShippingInfo si = order.getShippingInfo();
// 주요 도메인 로직이 중복되는 문제
if(state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING)
throw new IllegalStateException("already shippped");
si.setAddress(newAddress);
|
불필요한 중복을 피하고 애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들기 위한 두 가지 습관
1. 단순히 필드를 변경하는 set메서드를 공개 (public) 범위로 만들지 않는다.
1
2
3
4
| // 도메인 모델에서 공개 set 메서드는 가급적 피해야 한다.
public void setName(String name) {
this.name = name;
}
|
- 공개 set 메서드는 중요 도메인의 의미나 의도를 표현하지 못하고 도메인 로직이 도메인 객체가 아닌 응용 표현 영역으로 분산되게 만드는 원인이 된다.
- 도메인 로직이 한 곳에 응집되어 있지 않게 되므로 코드를 유지보수할 때에도 분석하고 수정하는데 더 많은 시간을 들이게 된다.
- 도메인 모델의 엔티티나 밸류에 공개 set 메서드만 넣지 않아도 이로간성이 깨질 가능성이 줄어든다.
- 공개 set 메서드를 사용하지 않게 되면 의미가 드러나는 메서드를 사용해서 구현할 가능성이 높아진다.
- 예를 들어, set 형식의 이름을 갖는 공개 메서드를 사용하지 않으면 자연스럽게 cancel이나 changePassword 처럼 의미가 더 잘 드러나는 이름을 사용하는 빈도가 높아진다.
2. 벨류 타입은 불변으로 구현한다.
- 밸류 객체의 값을 변경할 수 없으면 애그리거트 루트에서 밸류 객체를 구해도 값을 변경할 수 없기에 애그리거트 외부에서 밸류 객체의 상태를 변경할 수 없게 된다.
1
2
3
| ShippingInfo si = order.getShippingInfo();
si.setAddress(newAddress); // ShippingInfo 벨류 객체가 불변이면 컴파일 에러 발생!
|
- 애그리거트 외부에서 내부 상태를 함부로 바꾸지 못하므로 애그리거트의 이로간성이 꺠질 가능성이 줄어든다.
- 즉, 다음과 같이 애그리거트 루트가 제공하는 메서드에 새로운 벨류 객체를 적달해서 값을 변경하는 방법 밖에 없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public class Order{
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
}
// set 메서드의 접근 허용 범위는 private이다.
private void setShippingInfo(ShippingInfo shippingInfo) {
// 벨류가 불변이면, 새로운 객체를 할당해서 값을 변경해야 한다.
// 불변이므로 this.shippingInfo.setAddress(newShippingInfo.getAddress())와 같은 코드를 사용할 수 없다.
this.shippingInfo = shippingInfo;
}
}
|
애그리거트 루트의 기능 구현
- 애그리거트 루트는 애그리거트 내부의 다른 객체를 조합해서 기능을 완성한다.
- 예를 들어, Order는 총 주문 금액을 구하기 위해 OrderLine 목록을 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
| public class Order {
private Money money;
private List<OrderLine> orderLines;
private void calculateTotalAmounts() {
int sum = orderLine.stream()
.mapToInt(o1 -> o1.getPrice() * o1.quantity())
.sum();
this.totalAmounts = new Money(sum);
}
}
|
- 또한 기능 실행을 위임하기도 한다.
- Order의 changeOrderLines() 메서드는 내부의 orderLines 필드에 상태변경을 위임하는 방식으로 구현한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| public class Order {
private OrderLines orderLines;
private void changeOrderLines(List<OrderLine> newOrderLines) {
orderLines.changeOrderLines(newOrderLines);
this.totalAmounts = orderLines.getTotalAmounts;
}
}
public class OrderLines {
private List<OrderLine> lines;
public Money getTotalAmounts() {
...
};
private void changeOrderLines(List<OrderLine> newLines) {
this.lines = newLines;
}
}
|
- 만약 Order가 getOrderLines()와 같이 OrderLine를 구할 수 있는 메서드를 제공하면 애그리거트 외부에서 OrderLines의 기능을 실행할 수 있게 된다.
1
2
3
4
5
| OrderLines lines = order.getOrderLines();
// 외부에서 애그리거트 내부 상태 변경!
// order의 totalAmounts가 값이 OrderLines가 일치하지 않게 됨
lines.changeOrderLines(newOrderLines);
|
- 이 코드는 주문의 OrderLine 목록이 바뀌는데 총합은 계산하지 않는 버그를 만든다.
- 이런 버그를 생기지 않도록 하려면 애초에 애그리거트 외부에서 OrderLine 목록을 변경할 수 없도록 OrderLines를 불변으로 구현하면 된다.
- 팀 표준이나 구현 기술의 제약으로 OrderLines를 불변으로 구현할 수 없다면 OrderLines의 변경 기능을 패키지나 protected 범위로 한정해서 외부에서 실행할 수 없도록 제한하는 방법이 있다.
- 보통 한 애그리거트에 속하는 모델은 한 패키지에 속하기 때문에 패키지나 protected 범위를 사용하면 애그리거트 외부에서 상태 변경 기능을 실행하는 것을 방지 할 수 있다.
트랜잭션 범위
- 트랜잭션의 범위는 작으면 작을 수록 좋다.
- DB 테이블을 기준으로 한 트랜잭션이 한개의 테이블을 수정하는 것과 세 개의 테이블을 수정하는 것은 성능에서 차이가 발생한다.
- 한 개의 테이블을 수정할 땐 트랜잭션 충돌을 막기 위해 잠그는 대상이 한 개 테이블의 한 행으로 한정되지만, 세 개의 테이블을 수정하면 잠금 대상이 더 많아진다.
- 잠금 대상이 많아진다는 것은 그만큼 동시에 처리할 수 있는 트랜잭션 개수가 줄어든다는 것을 뜻하고 전체적인 성능(처리량)을 떨어뜨린다.
- 동일하게 한 트랜잭션에선 한 개의 애그리거트만 수정해야 한다.
- 한 트랜잭션에서 두 개 이상의 애그리거트를 수정하면 트랜잭션 충돌이 발생할 가능성이 더 높아지기 때문에 한 번에 수정하는 애그리거트 개수가 많아질수록 전체 처리량이 떨어지게 된다.
- 한 트랜잭션에서 한 애그리거트만 수정한다는 것은 애그리거트에서 다른 애그리거트를 변경하지 않는다는 것을 뜻한다.
- 한 애그리거트에서 다른 애그리거트를 수정하면 결과적으로 두 개의 애그리거트를 한 트랜잭션에서 수정하게 되므로 한 애그리거트 내부에서 다른 애그리거트의 상태를 변경하는 기능을 실행하면 안된다.
- 예를 들어, 배송지 정보를 변경하면서 동시에 배송지 정보를 회원의 주소로 설정하는 기능이 있따고 해보자.
- 이 경우 주문 애그리거트는 다음과 같이 회원 애그리거트의 정보를 변경해선 안된다.
1
2
3
4
5
6
7
8
9
10
11
12
| public class Order {
private Orderer orderer;
public void shipTo(ShippingInfo shippingInfo, boolean useNewShippingAddrAsMemberAddr) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
if (useNewShippingAddrAsMemberAddr) {
// 다른 애그리거트의 상태를 변경하면 안됨!
order.getOrderer().getCustomer().changeAddress(newShippingInfo.getAddress());
}
}
}
|
- 이는 애그리거트가 자신의 책임 범위를 넘어 다른 애그리거트의 상태까지 관리하는 꼴이 된다.
- 애그리거트는 서로 최대한 독립적이어야 하는데 한 애그리거트가 다른 애그리거트의 기능에 의존하기 시작하면 애그리거트 간의 결합도가 높아지게 된다.
- 결합도가 높아지면 높아질수록 향후 수정 비용이 증가하므로 애거리트에서 다른 애그리거트의 상태를 변경하지 말아야 한다.
- 만약 한 트랜잭션에서 두 개 이상의 애그리거트를 수정해야한다면 아래와 같이 응용 서비스에서 두 애그리거트를 수정하도록 하자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public class ChangeOrderService {
@Transactional
public void changeShippingInfo(OrderId id,
ShippingInfo newShippingInfo,
boolean useNewShippingAddrAsMemberAddr) {
Order order = orderRepository.findbyId(id);
if (order == null) throw new OrderNotFoundException();
order.shipTo(newShippingInfo);
if (useNewshippingAddrAsMemberAddr) {
order.getOrderer()
.getCustomer().changeAddress(newShippingInfo.getAddress());
}
}
...
}
|
도메인 이벤트를 사용하면 한 트랜잭션에서 한 개의 애그리거트를 수정하면서도 동기나 비동기로 다른 애그리거트의 상태를 변경할 수 있다. 관련된 내용은 10장에서 살펴보자.
기본적으로 한 트랜잭션에서 하나의 애그리거트를 수정하는 것을 권장하지만, 다음의 경우에는 두 개 이상의 애그리거트를 변경하는 것을 고려해볼 수 있다.
- 1)팀 표준 : 조직의 표준에 따라 사용자 유스케이스와 관련된 응용 서비스의 기능을 한 트랜잭션으로 실행해야 하는 경우
- 2)기술 제약 : 한 트랜잭션에서 두 개 이상의 애그리거트를 수정하는 대신 도메인 이벤트와 비동기를 사용하는 방식을 사용하는데, 기술적으로 이벤트 방식을 도입할 수 없는 경우 한 트랜잭션에서 다수의 애그리거트를 수정해서 일관성을 처리해야 한다.
- 3)UI 구현의 편리 : 운영자의 편리함을 위해 주문 목록 화면에서 여러 주문의 상태를 한 번에 변경하고 싶을 경우
리포지터리와 애그리거트
- 리포지터리는 애그리거트 단위로 존재한다,
- Order와 OrderLine을 물리적으로 각각 별도의 DB테이블에 저장한다고 해서 Order 와 OrderLine을 위한 리포지터리를 각각 만들지 않는다. Order가 애그리거트 루트이고 OrderLine인 애그리거트에 속하는 구성요소이므로 Order 를 위한 리포지토리만 존재한다.
- ORM 기술 중의 하나인 JPA/Hibernate 를 사용하면 DB관계형 모델에 객체 도메인 모델을 맞춰야 하는 경우도 있다.
- 특히 레거시 DB를 사용해야 하거나 팀 내 DB 설계 표준을 따라야 한다면 DB 테이블 구조에 맞게 모델을 변경해야 한다.
- 이 경우 밸류 타입인 도메인 모델을(JPA에서 밸류 타입을 매핑할 떄 사용하는) @Component 가 아닌 (엔티티를 매핑할 때 사용하는) @Entity를 이용해야 할 수도 있다.
- 애그리거트는 개념적으로 하나이므로 리포지터리는 애그리거트 전체를 저장소에 영속화해야 한다.
- 예를 들어, Order 애그리거트와 관련된 테이블이 세 개라면 리포지터리를 통해서 Order 애그리거트를 저장할 떄 애그리거트 루트와 매핑되는 테이블뿐만 아니라 애그리거트에 속한 모든 구성요소를 위한 테이블에 데이터를 저장해야 한다.
1
2
| // 리포지토리에 애그리거트를 저장하면 애그리거트 전체를 영속화해야 한다.
orderRepository.save(order);
|
- 위와 동일하게 애그리거트를 구하는 리포지터리 메서드는 완전한 애그리거트를 제공해야 한다.
- 즉 다음 코드를 실행시 order 애그리거트는 OrderLine, Orderer 등 모든 구성요소를 포함하고 있어야 한다.
- 그렇지 않을 경우 NPE 가 발생하게 된다.
1
2
3
4
5
6
| // 리포지토리는 완전한 order를 제공해야 한다.
Order order = orderRepository.findById(orderId);
// order가 온전한 애그리거트가 아니면
// 기능 실행 도중 NPE 가 발생한다.
order.cancel();
|
- 애그리거트를 영속화할 저장소로 무엇을 사용하든지 간에(RDBMS, Mongo DB, HBase) 애그리거트의 상태가 변경되면 모든 변경을 원자적으로 저장소에 반영해야 한다. 데이터 일관성을 보장하는 것이 중요하다.
- RDBMS와 JPA를 이용한 리포지토리와 애그리거트의 구현에 대한 내용은 4장에서 살펴볼 예정이다.
ID를 이용한 애그리거트 참조
- 애그리거트는 다른 애그리거트를 참조한다.
- 애그리거트의 관리 주체가 애그리거트 루트이므로 애그리거트에서 다른 애그리거트를 참조한다는 것은 애그리거트의 루트를 참조한다는 것과 같다.
다른 애그리거트 필드 참조
- 예를 들어, 주문 애그리거트에 속해 있는 Orderer 는 [그림 3.6]처럼 주문한 회원을 참조하기 위해 회원 애그리거트 루트인 Member를 필드로 참조할 수 있다.
- 필드를 이용해서 다른 애그리거트를 직접 참조하는 것은 개발자에게 구현의 편리함을 제공한다.
- 예를 들어, 주문 정보 조회 화면에서 회원 아이디를 이용해서 링크를 제공해야 한다고 해보자.
- 이 경우, 다음과 같이 Order로부터 시작해서 회원 아이디를 구할 수 있다.
1
| order.getOrderer().getMember().getId();
|
- JPA를 사용하면
@ManyToOne
, @OneToOne
과 같은 어노테이션을 이용해서 연관된 객체를 로딩하는 기능을 제공하고 있으므로 필드를 이용해서 다른 애그리거트를 쉽게 참조할 수 있다.
다른 애그리거트 필드 참조의 문제점
1) 편한 탐색 오용
- 가장 큰 문제점이다.
- 한 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다른 애그리거트의 상태를 쉽게 변경할 수 있게 된다.
- 트랜잭션 범위에서 언급한 것처럼 한 애그리거트가 관리하는 범위는 자기 자신으로 한정해야 한다.
- 그런데, 애그리거트 내부에서 다른 애그리거트 객체에 접근할 수 있으면 다음 코드처럼 구현의 편리함 때문에 다른 애그리거트를 수정하고자 하는 유혹에 빠지기 쉽다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public class Order {
private Orderer orderer;
public void changeShippingInfo(ShippingInfo newShippingInfo,
boolean useNewShippingAddrAsMemberAddr) {
...
if(useNewShippingAddrAsMemberAddr) {
// 한 애그리거트 내부에서 다른 애그리거트에 접근할 수 있으면
// 다른 애그리거트의 상태를 변경하는 유혹에 빠지기 쉽다.
orderer.getCustomer().changeAddress(newShippingInfo.getAddress());
}
...
}
}
|
- 트랜잭션 범위에서 말한 것철검, 한 애그리거트에서 다른 애그리거트의 상태를 변경하는 것은 애그리거트 간의 의존 결합도를 높여서 결과적으로 애그리거트의 변경을 어렵게 만든다.
2) 성능에 대한 고민
- JPA를 사용할 경우 참조한 객체를 지연(Lazy)로딩과 즉시(Eager) 로딩의 두 가지 방식으로 로딩할 수 있다.
- 두 로딩 방식 중 무엇을 사용할지 여부는 애그리거트의 어떤 기능을 사용하느냐에 따라 달라진다.
- 단순히 연관된 객체의 데이터를 함께 보여주어야 하면 즉시 로딩이 조회성능에 유리하지만, 애그리거트의 상태를 변경하는 기능을 실행하는 경우엔 불필요한 객체를 함꼐 로딩할 필요가 없으므로 지연 로딩이 유리하다.
- 이런 다양한 경우의 수를 고려해서 연관 매핑과 JPQL/Criteria 쿼리의 로딩 전략을 결정해야 한다.
3) 확장 어려움
- 시스템 초기엔 단일 서버에 단일 DBMS로 서비스를 제공하는 것이 가능하다.
- 문제는 사용자가 몰리기 시작하면서 발생한다.
- 사용자가 늘고 트래픽이 증가하면 자연스럽게 부하를 분산하기 위해 하위 도메인 별로 시스템을 분리하기 시작한다.
- 이 과정에서 하위 도메인마다 서로 다른 DBMS를 사용할 가능성이 높아진다.
- 심지어 하위 도메인마다 다른 종류의 데이터 저장소를 사용하기도 한다. 한 하위 도메인은 마리아DB를 사용하고 다른 하위 도메인은 몽고DB를 사용하는 식으로 말이다.
- 이는 더 이상 다른 애그리거트 루트를 참조하기 위해 JPA와 같은 단일 기술을 사용할 수 없음을 의미한다.
이러한 세 가지 문제를 완화할 때 사용할 수 있는 것이 ID를 이용해서 다른 애그리거트를 참조하는 것이다.
ID를 이용해서 다른 애그리거트를 참조할 경우의 장점
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| public class ChangeOrderService {
@Transactional
public void changeShippingInfo(OrderId id,
ShippingInfo newShippingInfo,
boolean useNewShippingAddrAsMemberAddr) {
Order order = orderRepository.findbyId(id);
if (order == null) throw new OrderNotFoundException();
order.changeShippingInfo(newShippingInfo);
if (useNewshippingAddrAsMemberAddr) {
// ID를 이용해서 참조하는 애그리거트를 구한다.
Customer customer = customerRepository.findById(order.getOrderer().getCustomerId());
customer.changeAddress(newShippingInfo.getAddress();)
}
}
...
}
|
- 응용 서비스에서 필요한 애그리거트를 로딩하므로 애그리거트 수준에서 지연 로딩을 하는 것과 동일한 결과를 만든다.
- ID를 이용한 참조 방식을 사용하면 복잡도를 낮추는 것과 함께 한 애그리거트에서 다른 애그리거트를 수정하는 문제를 원천적으로 방지할 수 있다.
- 외부 애그리거트를 직접 참조하지 않기에 애초에 한 애그리거트에서 다른 애그리거트의 상태를 변경할 수 없는 것이다.
- 애그리거트별로 다른 구현 기술을 사용하는 것도 가능해진다.
- 중요한 데이터인 주문 애그리거트는 RDBMS에 저장하고 조회 성능이 중요한 상품 애그리거트는 NoSQL에 저장할 수 있다.
- 또한, 각 도메인을 별도 프로세스로 서비스하도록 구현할 수도 있다.
[그림3.8] 아이디로 애그리거트를 참조하면 리포지터리마다 다른 저장소를 사용하도록 구현할 떄 확장이 용이하다.
ID를 이용한 참조와 조회 성능
- 다른 애그리거트를 ID로 참조하면 참조하는 여러 애그리거트를 읽어야 할 때 조회 속도가 문제 될 수 있다.
- 예를 들어, 주문 목록을 보여주려면 상품 애그리거트와 회원 애그리거트를 함께 읽어야 하는데, 이를 처리할 때 다음과 같이 각 주문마다 상품과 회원 애그리거트를 읽어온다고 해보자.
- 한 DBMS에 데이터가 있다면 조인을 이용해서 한 번에 가져올 수 있음에도 주문마다 상품 저옵를 읽어오는 쿼리를 실행하게 된다.
1
2
3
4
5
6
7
8
9
| Customer customer = customerRepository.findById(ordererId);
List<Order> orders = orderRepository.findByOrderer(ordererId);
List<OrderView> dtos = orders.stream()
.map(order -> {
ProductId prodId = order.getOrderLines().get(0).getProductId();
// 각 주문마다 첫 번째 주문 상품 정보 로딩 위한 쿼리 실행
Product product = productRepository.findById(prodId);
return new OrderView(order, customer, product);
}).collect(toList());
|
- 위 코드를 보면 Order 조회 쿼리 한 번 그리고 주문에 엮여있는 상품 조회 쿼리 N번이 발생하게 된다.
- 이는 지연 로딩과 관련된 대표적인 문제 N+1 문제와 비슷한 문제가 발생한다.
- N+1 조회 문제는 더 많은 쿼리를 실행해서 전체 조회 속도가 느려지는 원인이다.
- 이 문제가 발생하지 않도록 하려면 조인을 사용하도록 해야하는데 조인을 사용하는 가장 쉬운 방법은 ID 참조 방식을 객체 참조 방식으로 바꾸고 즉시 로딩을 사용하도록 매핑 설정을 바꾸는 것이다.
- 하지만, 이 방식은 애그리거트 간 참조를 ID 참조 방식에서 객체 참조 방식으로 다시 되돌리는 것이다.
- ID 참조 방식을 사용하면서 N+1 조회와 같은 문제가 발생하지 않도록 하려면
전용 조회 쿼리
를 사용하면 된다.- 예를 들어, 데이터 조회를 위한 별도 DAO를 만들고 조회 메서드에서 세타 조인을 이용해서 한 번의 쿼리로 필요한 데이터를 로딩하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| @Repository
public class JpaOrderViewDao implements OrderViewDao {
@PersistenceContext
private EntityManager em;
@Override
public List<OrderView> selectByOrder(String ordererId) {
String selectQuery =
"select new com.myshop.order.application.dto.OrderView(o, m, p) " +
"from Order o join o.orderLines ol, Member m, Product p " +
"where o.orderer.memberId.id = :ordererId " +
"and o.orderer.memberId = m.id " +
"and ol.productId = p.id " +
"order by o.number.number desc";
TypedQuery<OrderView> query =
em.createQuery(selectQuery, OrderView.class);
query.setParameter("ordererId", ordererId);
return query.getResultList();
}
}
|
- 이 JPQL은 Order 애그리거트와 Member 애그리거트, 그리고 Product 애그리거트를 세타 조인으로 조회해서 한 번의 쿼리로 로딩한다.
- 즉시 로딩이나 지연로딩과 같은 로딩 전략을 고민할 필요 없이 조회 화면에서 필요한 애그리거트 데이터를 한 번의 쿼리로 로딩할 수 있다.
- 쿼리가 복잡하거나 SQL에 특화된 기능을 사용해야 한다면 조회를 위한 부분만 MyBatis 와 같은 기술을 이용해서 실행할 수도 있다.
Note: JPA를 사용하면 각 객체 간 모든 연관을 지연/즉시로딩으로 어떻게든 처리하고 싶은 욕구가 생길텐데 이는 실용적이지 않다. ID를 이용해서 애그리거트를 참조해도 한 번의 쿼리로 필요한 데이터를 로딩하는 것이 가능하다.
- 애그리거트마다 서로 다른 저장소를 사용하는 경우엔 한 번의 쿼리로 관련 애그리거트를 조회할 수 없다.
- 이런 경우 조회 성능을 높이기 위해
캐시
를 적용하거나 조회 전용 저장소
를 따로 구성한다.- 이 방법은 코드가 복잡해지는 단점이 있지만, 시스템의 처리량을 높일 수 있는 장점이 있다.
- 특히 한 대의 DB 장비로 대응할 수 없는 수준의 트래픽이 발생하는 경우
캐시
나 조회 전용 저장소
는 필수로 선택해야 한다.
애그리거트 간 집합 연관
1:N
- 한 카테고리에 여러 상품이 속할 수 있으니 1:N 관계이다.
1
2
3
4
5
6
7
8
| public class Category {
private Set<Product> products; // 다른 애그리거트에 대한 1:N 연관
//...
public List<Product> getProducts(int page, int size) {
List<Product> sortedProducts = sortById(products);
return sortedProducts.subList((page - 1) * size, page * size);
}
}
|
- 하지만 위처럼 도메인 객체 내에 연관을 맺게 되면 해당 객체가 불릴때마다 Category에 속한 모든 Product를 조회하게 되면서 성능에 심각한 문제를 야기시킨다.
- Product 의 갯수가 수백에서 수만 개정도로 많다면…
- 따라서 개념적으로는 애그리거트 간에 1:N 연관이 있다고 하더라도 성능상 문제로 인해 애그리거트 간의 1:N 연관을 실제 구현에 반영하는 경우는 드물다.
- 이에 대한 해결책으로 상품 입장에서 자신이 속한 카테고리를 N:1 로 연관지어 구하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| public class Product {
// ...
private CateogryId category;
//...
}
public class ProductListService {
public Page<Product> getProductOfCategory(Long categoryId, int page, int size) {
Category category = categoryRepository.findById(categoryId);
checkCategory(category);
List<Product> products = productRepository.findByCategoryId(category.getId(), page, size);
int totalCount = productRepository.countByCategoryId(category.getId());
return new Page(page, size, totalCount, products);
}
}
|
- 카테고리에 속한 상품 목록을 제공하는 응용 서비스는 다음과 같이 ProductRepository를 이용해 CategoryId가 지정한 카테고리 식별자인 Product 목록을 구한다.
M:N
- 상품이 여러 카테고리에 속할 수 있다고 가정하면 카테고리와 상품은 M:N 연관을 맺는다.
- M:N 연관은
개념적으로
양쪽 애그리거트에 컬랙션으로 연관을 만든다.- 하지만 앞선 1:N 처럼 요구사항을 고려해서 M:N 연관을 구현에 포함시킬지 여부를 결정해야 한다.
- 일반적으로 상품 페이지를 보여줄 때 각 상품 별 모든 카테고리 정보를 다 보여주진 않는다.
- 상품 상세 화면에서 주로 카테고리 정보를 보여주게 된다.
- 이 요구사항을 고려하면 카테고리 -> 상품의 연관은 필요하지 않다. 상품 -> 카테고리 연관만 구현하면 된다./b>
- 즉, 개념적으로 상품과 카테고리의 양방향 M:N 연관이 존재하지만 실제 구현에서는 상품 -> 카테고리의 단방향 M:N 연관만 적용하면 된다.
- RDBMS를 이용해 M:N 연관을 구현하려면 조인 테이블을 사용한다.
- JPA 를 이용하면 다음과 같은 매핑 설정을 사용해서 ID 참조를 이용한 M:N 단방향 연관을 구현할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| @Entity
@Table(name = "product")
public class Product {
@EmbeddedId
private ProductId id;
@ElementCollection
@CollectionTable(name = "product_category",
joinColumns = @JoinColumn(name = "product_id))
private Set<CategoryId> categoryIds;
...
}
|
- 이 매핑은 카테고리 ID 목록을 보관하기 위해 밸류 타입에 대한 컬렉션 매핑을 이용했다.
- 이 매핑을 사용하면 아래 코드와 같이
JPQL
의 member of
연산자를 이용해서 특정 Category에 속한 Product 목록을 구하는 기능을 구현할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| @Repository
public class JpaProductRepository implements ProductRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public List<Product> findByCategoryId(CategoryId categoryid, int page, int size) {
TypedQuery<Product> query = entityManager.createQuery(
"select p from Product p " +
"where :catId member of p.categoryIds order by p.id.id desc", Product.class);
query.setParameter("catId", categoryId);
query.setFirstResult((page - 1) * size);
query.setMaxResults(size);
return query.getResultList();
}
}
|
애그리거트를 팩토리로 사용하기
- 예를 들어, 특정 상점에서 더 이상 상품을 등록을 할 수 없도록 차단된 상태라고 할 때 상품 등록 기능을 아래와 같이 응용 서비스 로직에 구현할 수 있을 것이다. (예제 코드에 오타가 있는 듯하여 임의로 수정했다.)
1
2
3
4
5
6
7
8
9
10
11
12
13
| public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest req) {
Store store = accountRepository.findStoreById(req.getStoreId());
checkNull(store);
if (store.isBlocked()) {
throw new StoreBlockedException();
}
ProductId id = productRepository.nextId();
Product product = accout.createProduct(id, store.getId(), ...);
productRepository.save(product);
return id;
}
}
|
- 코드가 나빠조이진 않지만 중요한 도메인 로직 처리가 응용 서비스에 노출되었다.
- Store가 Product 를 생성할 수 있는지 여부를 판단하고 Product를 생성하는 것은 논리적으로 하나의 도메인 기능인데 이 도메인 기능을 응용 서비스에서 구현하고 있는 것이다.
- 이 도메인 기능을 넣기 위한 별도의 도메인서비스나 팩토리 클래스를 만들수도 있지만 이 기능을 구현하기에 더 좋은 장소는 Store 애그리거트이다.
- Product를 생성하는 기능을 Store 애그리거트에 다음과 같이 옮겨보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public class Store extends Member {
public Product createProduct(ProductId newProductId, /*...*/) {
if (isBlocked()) throw new StoreBlockedException();
return new Product(newProductId, getId(), /*...*/);
}
}
public class RegisterProductService {
public ProductId registerNewProduct(NewProductRequest req) {
Store account = accountRepository.findStoreById(req.getStoreId());
checkNull(account);
ProductId id = productRepository.nextId();
Product product = accout.createProduct(id, /*...*/);
productRepository.save(product);
return id;
}
}
|
- Store 애그리거트의 createProduct() 는 Product 애그리거트를 생성하는 패고틸 역할을 한다.
- 앞선 코드와 차이점이라면 응용 서비스에서 더 이상 Store의 상태를 확인하지 않는 다는 것이다.
- 이렇게 함으로써 도메인 응집도도 높아지게 된다.
밸류 컬렉션을 @Entity로 매핑하기
Reference
Chapter4-리포지터리와 모델구현(JPA 중심)
JPA를 이용한 리포지토리 기능 구현
모듈 구현
- 2장에서 언급한 것처럼 리포지토리 인터페이스는 애그리거트와 같이 도메인 영역에 속하고, 리포지터리를 구현한 클래스는 인프라스트럭처 영역에 속한다.
팀 표준에 따라 리포지터리 구현 클래스를 domain.impl과 같은 패키지에 위치시키는 경우도 있다. 이는 리포지터리 인터페이스와 구현체를 분리하기 위한 타협안 같은 것이지 좋은 설계 원칙을 따르는 것은 아니다. 가능하면 리포지터리 구현 클래스를 인프라스트럭처 영역에 위치시켜서 인프라스트럭처에 대한 의존을 낮춰야 한다.
리포지터리 기본 기능 구현
삭제 기능: 삭제 요구사항이 있더라도 여러 이유로 데이터를 실제로 삭제하는 경우는 많지 않다. 관리자 기능에서 삭제한 데이터까지 조회해야 하는 경우도 있고 데이터 원복을 위해 일정 기간 동안 보관해야 할 때도 있기 때문이다. 이런 이유로 사용자가 삭제 기능을 실행시 데이터를 바로 삭제하기 보단 삭제 플래그를 사용해서 화면에 보여줄지 여부를 결정하는 방식으로 구현한다.
매핑 구현
엔티티와 밸류 기본 매핑 구현
1
2
3
4
5
6
7
8
9
10
11
| @Embeddable
public class Orderer {
// MemberId에 정의된 칼럼 이름을 변경하기 위해
// @AttributeOverride 애노테이션 사용
@Embedded
@AttributeOverrides(@AttributeOverride(name = "id", column = @Column(name = "orderer_id")))
private MemberId memberId;
@Column(name = "orderer_name")
private String name;
}
|
@AttributeOverrides
어노테이션을 이용해서 매핑할 칼럼 이름을 변경한다.
기본 생성자
- JPA의
@Entity
와 @Embeddable
로 클래스를 매핑하려면 기본 생성자를 제공해야 한다. - 하이버네이트와 같은 JPA 프로바이더는 DB에서 데이터를 읽어와 매핑된 객체를 생성시 기본 생성자를 사용해서 객체를 생성한다.
- 이런 기술적 제약으로 Receiver와 같은 불변 타입은 기본 생성자가 필요 없음에도 불구하고 다음과 같이 기본 생성자를 추가해야 한다.
- 이때 다른 코드에서 기본 생성자를 사용하지 못하도록 protected 로 선언하는 것이 좋다.
1
2
3
4
| @Embeddable
public class Receiver {
protected Receiver() {}
}
|
Note: 하이버네이트는 클래스를 상속한 프록시 객체를 이용해서 지연 로딩을 구현한다. 이 경우 프록시 클래스에서 상위 클래스의 기본 생성자를 호출할 수 있어야 하므로 지연 로딩 대상이 되는 @Entity 와 @Embeddable의 기본 생성자는 private 이 아닌 protected로 지정해야 한다.
필드 접근 방식 사용
- JPA는 필드와 메서드(get/set)의 두 가지 방식으로 매핑을 처리할 수 있다.
- 엔티티를 객체가 제공할 기능 중심으로 구현하도록 유도하려면 JPA 매핑 처리를 프로퍼티 방식이 아닌 필드 방식으로 선택해서 불필요한 get/set/ 메서드를 구현하지 말아야 한다.
1
2
3
4
5
6
7
8
9
| @Entity
// @Access(AccessType.PROPERTY)
@Access(AccessType.FIELD)
public class Order {
@EmbeddableId
private OrderNo number;
...
}
|
Note: JPA 구현체인 하이버네이트는 @Access를 이용해 명시적으로 접근 방식을 지정하지 않으면 @Id나 @EmbeddId가 어디에 위치했느냐에 따라 접근 방식을 결정한다. 즉, 필드에 위치하면 필드 접근 방식을, get 메서드에 위치하면 메서드 접근 방식을 선택한다.
AttributeConverter를 이용한 밸류 매핑 처리
- JPA 2.0 버전에선 두 개 이상의 프로퍼티를 가진 밸류 타입을 한개 DB칼럼에 매핑하기 위해 칼럼과 매핑하기 위한 프로퍼티를 따로 추가하고 get/set 메서드에서 실제 밸류 타입과 변환 처리를 해야 했다.
1
2
3
4
5
6
7
8
9
10
11
12
| public class Product {
@Column(name = "WIDTH")
private String width;
public Length getWidth() {
return new Length(width); // DB 칼럼 값을 실제 프로퍼티 타입으로 변환
}
void setWidth(Length width) {
this.width = width.toString(); // 실제 프로퍼티 타입을 DB 칼럼 값으로 변환
}
}
|
- JPA 2.1에선
AttributeConverter
를 사용해서 밸류 타입과 칼럼 데이터 간의 변화 처리를 위한 기능을 사용할 수 있다. autoApply
가 true일 경우, 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해 MoneyConverter를 자동으로 적용한다.- false 인 경우 프로퍼티 값을 변환할 때 사용할 컨버터를 직접 지정할 수 있다.
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
| public interface AttributeConverter<X, Y> {
Y convertToDatabaseColumn(X var1);
X convertToEntityAttribute(Y var1);
}
// autoApply가 true일 경우, 모델에 출현하는 모든 Money 타입의 프로퍼티에 대해 MoneyConverter를 자동으로 적용한다.
// false인 경우, 프로퍼티 값을 변환할 때 사용할 컨버터를 직접 지정할 수 있다.
@Converter(autoApply = true)
public class MoneyConverter implements AttributeConverter<Money, Integer> {
@Override
public Integer convertToDatabaseColumn(Money money) {
if (money == null) {
return null;
} else {
return money.getValue();
}
}
@Override
public Money convertToEntityAttribute(Integer value) {
if (value == null) return null;
else return new Money(value);
}
}
public class Order {
...
@Column(name = "total_amounts")
@Convert(converter = MoneyConverter.class)
private Money toalAmounts;
}
|
밸류 컬렉션: 별도 테이블 매핑
- 밸류 컬렉션을 저장하는 ORDER_LINE 테이블은 외부키를 이용해서 엔티티에 해당하는 PURCHASE_ORDER 테이블을 참조한다.
- 이 외부키는 컬렉션이 속할 엔티티를 의미한다.
- List 타입의 컬렉션은 인덱스 값이 필요하므로 ORDER_LINE 테이블엔 인덱스 값을 저장하기 위한 칼럼(line_idx)도 존재한다.
- 밸류 컬렉션을 별도 테이블로 매핑할 땐
@ElementCollection
과 @CollectionTable
을 함께 사용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| @Entity
@Table(name = "purchase_order")
public class Order {
...
@ElementCollection
@CollectionTable(name = "order_line", joinColumns = @JoinColumn(name = "order_number"))
@OrderColumn(name = "line_idx")
private List<OrderLine> orderLines;
...
}
@Embeddable
public class OrderLine {
@Embedded
private ProductId productId;
@Column(name = "price")
private Money price;
...
}
|
@OrderColumn
어노테이션으로 지정한 칼럼에 리스트의 인덱스 값을 지정한다.@CollectionTable
은 밸류를 저장할 테이블을 지정할 때 사용한다.
밸류 컬렉션: 한 개 칼럼 매핑
- 밸류 컬렉션을 별도 테이블이 아닌 한 개 칼럼에 저장해야 할 때가 있다.
AttributeConverter
를 사용하면 쉽게 해결할 수 있는데 밸류 컬렉션을 표현하는 새로운 밸류 타입을 추가해야 한다.(일급 컬렉션)
1
2
3
4
5
6
7
8
9
10
11
12
| public class EmailSet {
private Set<Email> emails = new HashSet<>();
private EmailSet(){}
private EmailSet(Set<Email> emails) {
this.emails.addAll(emails);
}
public Set<Email> getEmails() {
return Collections.unmodifiableSet(emails);
}
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| @Converter
public class EmailSetConverter implements AttributeConverter<EmailSet, String> {
@Override
public String convertToDatabaseColumn(EmailSet attribute) {
if (attribute == null) return null;
return attribute.getEmails().stream()
.map(Email::toString)
.collect(Collectors.joining(","));
}
@Override
public EmailSet convertToEntityAttribute(String dbData) {
if (dbData == null) return null;
String emails = dbData.split(",");
Set<Email> emailSet = Arrays.stream(emails)
.map(value -> new Email(value))
.collect(toSet());
return new EmailSet(emailSet);
}
}
|
1
2
3
| @Column(name = "emails")
@Convert(converter = EmailSetConverter.class)
private EmailSet emailSet;
|
밸류를 이용한 아이디 매핑
- 밸류 타입을 식별자로 매핑하면
@Id
대신 @EmbeddedId
어노테이션이 필요하다. - JPA에서 식별자 타입은 Serializable 타입이어야 하므로 Serializable 인터페이스를 상속받아야 한다.
- 밸류 타입의 식별자를 구현할 때 얻을 수 있는 장점은 식별자에 기능을 추가할 수 있다. (is2ndGeneration 메서드 참조)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| @Entity
@Table(name = "purchase_order")
public class Order {
@EmbeddedId
private OrderNo orderId;
...
}
@Embeddable
public class OrderNo implements Serializable {
@Column(name = "order_number")
private String number;
// 1세대 시스템의 주문 번호와 2세대 시스템의 주문 번호가 있다고 가정할 때 이를 구분하기 위한 메서드 구현
public boolean is2ndGeneration() {
return number.startWith("N");
}
}
|
별도 테이블에 저장하는 밸류 매핑
- 애그리거트에 속한 객체가 밸류인지 엔티티인지 구분하는 방법은 고유 식별자를 갖는지 여부를 확인하는 것이다.
- 하지만, 식별자를 찾을 때 매핑되는 테이블의 식별자(PK)를 애그리거트 구성요소의 식별자와 동일한 것으로 착각해선 안된다.
- 별도 테이블로 저장되고 테이블에 PK가 있다 해서 테이블과 매핑되는 애그리거트 구성요소가 고유 식별자를 갖는 것은 아니다.
- 위 이미지에서 ARTICLE_CONTENT 테이블의 ID 칼럼이 식별자이므로 ARTICLE_CONTENT와 매핑되는 AriticleContent를 엔티티로 생각할 수 있는데, 이것 때문에 Article 과 ArticleContent를 두 엔티티 간의 일대일 연관으로 매핑하는 실수를 할 수 있다.
- ArticleContent를 엔티티로 생각할 수 있지만 ArticleContent 는 Article 의 내용을 담고 있는 밸류로 생각하는 것이 맞다.
- ARTICLE_CONTENT의 ID 는 식별자이기는 하지만 이 식별자를 사용하는 이유는 ARTICLE 테이블의 데이터와 연결하기 위함이지 ARTICLE_CONTENT를 위한 별도 식별자가 필요하기 때문은 아니다.
- 이때
@SecondaryTable
과 @AttributeOverride
를 사용하여 밸류를 매핑한 테이블을 지정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| @Entity
@Table(name = "article")
@SecondaryTable(
name = "article_content",
pkJoinColumns = @PrimaryKeyJoinColumn(name = "id")
)
public class Article {
@Id
private Long id;
@AttributeOverrides({
@AttributeOverride(name = "content", column = @Column(table = "article_content")),
@AttributeOverride(name = "contentType", column = @Column(table = "article_content"))
})
private ArticleContent content;
...
}
|
@SecondaryTable
의 name 속성은 밸류를 저장할 테이블을 지정한다.- pkJoinColumns 속성은 밸류 테이블에서 엔티티 테이블로 조인할 때 사용할 칼럼을 지정한다.
- content 필드에
@AttributeOverride
를 사용하여 해당 밸류 데이터가 저장된 테이블 이름을 지정한다.
@SecondaryTable
을 이용하면 아래 코드를 실행할 때 두 테이블을 조인해서 데이터를 조회한다
1
| Article article = entityManager.find(Article.class, 1L);
|
- 게시글 목록을 보여주는 화면은 article 테이블의 데이터만 필요하지, article_content 테이블의 데이터는 필요하지 않다.
- 그런데,
@SecondaryTable
을 사용하면 목록 화면에 보여줄 Article
을 조회할 때 article_content 테이블까지 조인해서 데이터를 읽어오게 되는 문제가 발생한다. - 이 문제를 해결하기 위해 ArticleContent 를 엔티티로 매핑하고 이를 지연 로딩으로 설정하여 해결할 수도 있다. (둘 다 필요할때만 페치 조인으로 가져오는 방식으로)
- 하지만 이 방식은 엔티티가 아닌 모델을 엔티티로 만드는 것이므로 좋은 방법은 아니다.
- 대신 조회 전용 기능을 구현하는 방법을 사용하는 것이 좋다.
- JPA에서 조회 전용 쿼리를 실행하는 방법은 5장에서 살펴본다.
밸류 컬렉션을 @Entity 로 매핑하기
- 개념적으로 밸류인데 구현 기술 한계나 팀 표준으로 @Entity를 사용해야 할 때가 있다.
- 예를 들어, 이미지 업로드 방식에 따라 이미지 경로와 썸네일 이미지 제공 여부가 달라진다고 했을 때 아래와 같은 계층 구조로 설계할 수 있다.
- JPA는
@Embeddable
타입의 클래스 상속 매핑을 지원하지 않는다. - 따라서 상속 구조를 갖는 밸류 타입을 사용하려면
@Embeddable
대신 @Entity
를 이용한 상속 매핑으로 처리해야 한다. - Image는 엔티티가 아니라 밸류이므로 상태를 변경하는 기능은 추가하지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| @Entity
@Inheritance(startegy = InheritanceType.SINGLE_TABLE)
@Descriminator(name = "image_type")
@Table(name = "image")
public abstract class Image {
...
// 밸류 타입이므로 상태 변경 기능이 있어선 안된다
}
@Entity
@DiscriminatorValue("II")
public class InternalImage extends Image {
...
}
@Entity
@DiscrimnatorValue("EI")
public class ExternalImage extends Image {
...
}
|
- Image는 밸류이므로 독자적인 라이프사이클을 갖지 않고 Product에 완전히 의존한다.
- 따라서
cascade
속성을 이용해서 Product 를 저장시 함께 저장되고, Product 삭제시 함께 삭제되도록 설정한다. - 리스트에서 Image 객체를 제거하면 DB에서 함께 삭제되도록 orphanRemoval 을 true로 설정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| @Entity
@Table(name = "product")
public class Product {
...
@OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE}, orphanRemoval = true)
@JoinColumn(name = "product_id")
@OrderColumn(name = "list_idx")
private List<Image> images = new ArrayList<>();
...
public void changeImages(List<Image> newImages) {
images.clear();
images.addAll(newImages);
}
}
|
- 하이버네이트에선 위처럼 엔티티를 위한 컬렉션 객체(images)의 clear 메서드를 호출시 select 쿼리로 대상 엔티티를 로딩 후 각 개별 엔티티에 대해 delete 쿼리를 수행한다.
- 변경 빈도가 낮으면 괜찮지만 빈도가 높으면 전체 서비스 성능에 문제가 될 수 도 있다.
- 하이버네이트는 위와 반대로
@Embeddable
타입에 대한 컬렉션의 clear() 메서드를 호출하면 컬렉션에 속한 객체를 로딩하지 않고 한 번의 delete 쿼리로 삭제 처리를 수행한다,,- 이 경우엔 타입에 따라 다른 기능을 구현하려면 다음과 같이 if-else를 써야 하는 단점이 발생한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| @Embeddable
public class Image {
@Column(name = "image_type")
private String imageType;
@Column(name = "image_path")
private String path;
...
public boolean hasThumbnail() {
// 성능을 위해 다형을 포기하고 if-else로 구현
if (imageType.equals("II")) {
return true;
}
return false;
}
}
|
- 코드 유지보수와 성능의 두 가지 측면을 고려해서 구현방식을 적절히 선택해야 한다.
ID 참조와 조인 테이블을 이용한 단방향 M:N 매핑
- 앞서 3장에서 애그리거트 간 집합 연관은 성능상의 이유로 피해야 한다고 했다.
- 그럼에도 불구하고 필요하다면 ID 참조를 이용한 단방향 집합 연관을 적용해 볼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
| @Entity
@Table(name = "product")
public class Product {
@EmbeddedId
private ProductId id;
@ElementCollection
@CollectionTable(name = "product_category", joinColumns = @JoinColumn(name = "product_id"))
private Set<CategoryId> categoryIds;
...
}
|
- ID 참조를 이용한 애그리거트 간 단방향 M:N 연관은 밸류 컬렉션 매핑과 동일한 방식으로 설정한 것을 알 수 있다.
@ElementCollection
을 이용하기 때문에 Product 를 삭제할 때 매핑에 사용한 조인 테이블의 데이터도 함께 삭제된다.- 애그리거트를 직접 참조하는 방식을 사용했다면 영속성 전파나 로딩 전략을 고민해야 하는데 ID 참조방식을 사용함으로써 이런 고민을 할 필요가 사라지게 된다.
애그리거트 로딩 전략
- JPA 매핑을 설정할 때 항상 기억해야 할 점은 애그리거트에 속한 객체가 모두 모여야 완전한 하나가 된다는 것이다.
- 즉, 다음과 같이 애그리거트 루트를 로딩시 루트에 속한 모든 객체가 완전한 상태여야 함을 의미한다.
1
2
| // product 는 완전한 하나여야 한다.
Prodcut product = productRepository.findById(id);
|
- 엔티티에 대한 매핑의 fetch 속성을 즉시 로딩(FetchType,EAGER)로 설정하면 find() 메서드로 애그리거트 루트를 구할 때 연관된 구성요소를 DB에서 함께 읽어온다.
- 하지만 컬렉션에 대해 로딩 전략을 EAGER 로 설정시엔 오히려 즉시 로딩 방식이 문제가 될 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| @Entity
public class Product {
...
@OneToMany(
cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
orphanRemoval = true,
fetch = FetchType.Eager)
@JoineColumn(name = "product_id")
@OrderColumn(name = "list_idx")
private List<Image> images = new ArrayList<>();
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "product_option", joinColumns = @JoinColumn(name = "product_id"))
@OrderColumn(name = "list_idx")
private List<Option> options = new ArrayList<>();
...
}
|
- 위와 같이 images 와 options 모두 EAGER 로 지정되어있을때 카타시안 조인을 사용해 불러오는데 이는 쿼리 결과에 중복을 발생한다.
- Proudct의 image 가 2개이고 option이 2개이면 쿼리 결과로 구해지는 행 개수는 4개일 것이다.
- 만약 데이터가 많다고하면 성능 문제가 될 수도 있다.
- 애그리거트는 개념적으로 하나여야 하지만, 루트 엔티티를 로딩하는 시점에 애그리거트에 속한 객체를 모두 로딩해야 하는 것은 아니다.
- 애그리거트가 완전해야 하는 이유는 두 가지다.
- 1)상태를 변경하는 기능 실행시 애그리거트 상태가 완전해야 하기 때문에
- 2)표현 영역에서 애그리거트의 상태 정보를 보여줄 때 필요하기 때문에
- 두 번째 이유는 별도의 조회 전용 기능을 구현하는 방식을 사용하는 것이 유리할 때가 많기에 애그리거트의 완전한 로딩과 관련된 문제는 상태 변경과 더 관련이 있다.
- 하지만 상태 변경 기능을 실행하기 위해 조회 시점에 즉시 로딩을 이용해서 애그리거트를 완전한 상태로 로딩할 필욘 없다.
- JPA는 트랜잭션 범위 내에서 지연 로딩을 허용하기 때문에 다음 코드처럼 실제로 상태를 변경하는 시점에 필요한 구성요소만 로딩해도 문제가 되지 않는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| @Transactional
public void removeOptions(ProductId id, int optIdxToBeDeleted) {
// Product 를 로딩. 컬렉션은 지연 로딩으로 설정했다면, Option은 로딩하지 않음
Prodcut product = productRepository.findById(id);
// 트랜잭션 범위이므로 지연 로딩으로 설정한 연관 로딩 가능
product.removeOption(optIdxToBeDeleted);
}
@Entity
public class Product {
@ElementCollection(fetch = FetchType.LAZY)
@CollectionTable(name = "product_option", joinColumns = @JoinColumn(name = "product_id"))
@OrderColumn(name = "list_idx")
private List<Option> options = new ArrayList<>();
public void removeOption(int optIdx) {
// 실제 컬렉션에 접근할 때 로딩
this.options.remove(optIdx);
}
}
|
- 상태를 변경하는 기능을 실행하는 빈도보다 조회하는 기능을 실행하는 빈도가 훨씬 높다.
- 그러므로 상태 변경을 위해 지연 로딩을 사용할 때 발생하는 추가 쿼리로 인한 실행 속도 저하는 문제 되지 않는다.
- 위와 같은 이유로 애그리거트 내의 모든 연관을 즉시 로딩으로 설정할 필욘 없다. 애그리거트에 맞게 즉시 로딩과 지연 로딩을 적절히 선택해야 한다.
애그리거트의 영속성 전파
- 애그리거트를 저장하거나 삭제할땐 애그리거트에 속한 모든 객체를 함께 저장하거나, 함께 삭제해야 한다.
@Embeddable
매핑 타입의 경우엔 함께 저장되고 삭제되므로 cascade 속성을 추가로 설정하지 않아도 된다.- 반면에 @Entity 타입에 대한 매핑은 cascade 속성을 사용해서 저장과 삭제시 함께 처리되도록 설정해야 한다.
@OneToOne
, @OneToMany
는 cascade 속성의 기본값이 없으므로 다음 코드처럼 cascade 속성 값으로 CascadeType.PERSIST, CascadeType.REMOVE 를 설정해야 한다.
1
2
3
4
5
| @OneToMany(cascade = {CascadeType.PERSIST, CascadeType.REMOVE},
orphanRemoval = true)
@JoinColumn(name = "product_id")
@OrderColumn(name = "list_idx")
private List<Image> images = new ArrayList<>();
|
식별자 생성 기능
- 식별자는 아래 세 가지 방식 중 하나로 생성한다.
- 1)사용자가 직접 생성
- 2)도메인 로직으로 생성
- 3)DB 를 이용한 일련번호 생성
1) 사용자가 직접 생성
- 식별자 생성주체가 사용자이므로 도메인 영역에 식별자 생성 기능을 구현할 필요 없다.
2) 도메인 로직으로 생성
- 별도 서비스로 식별자 생성 기능을 분리해야 한다.
- 식별자 생성 규칙은 도메인 규칙이므로 도메인 영역에 식별자 생성 기능을 위치시켜야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| public class OrderIdService {
public OrderId createId(UserId userId) {
if (userId == null) {
throw new IllegalArgumentException("invalid userid: " + userId);
return new OrderId(userId.toString() + "-" + timestamp());
}
}
private String timestamp() {
return Long.toString(System.currentTimeMillis());
}
}
|
- 또한 식별자 생성 규칙을 규현하기에 적합한 또 다른 위치는 리포지터리이다.
1
2
3
4
5
| public interface ProductRepository {
...
ProductId nexxtId();
...
}
|
3) DB 를 이용한 일련번호 생성
- JPA는 저장 시점에 생성한 식별자를 @Id로 매핑한 프로퍼티/필드에 할당한다.
- 실제 저장(영속화)된 후에 할당된 식별자를 사용할 수 있다.
Reference
Chapter5-스프링 데이터 JPA를 이용한 조회기능
시작에 앞서
- CQRS 는 명령(COMMAND) 모델과 조회(Query) 모델을 분리하는 패턴이다.
- 명령 모델은 상태(데이터)를 변경하는 기능을 구현할 때 사용된다(ex. 회원 가입, 암호 변경, 주문 취소)
- 조회 모델은 데이터를 조회하는 기능을 구현시 사용된다.(ex. 주문 목록, 주문 상세)
- 주문 취소, 배송지 변경 기능을 포함하여 앞에서 살펴본 것 처럼 도메인 모델은 명령 모델로 주로 사용된다.
- 반면에, 이 장에서 설명할 정렬, 페이징, 검색 조건 지정과 같은 기능은 주문 목록, 상품 상세와 같은 조회기능에서 사용된다. 즉 이장에서 살펴볼 구현 방법은 조회 모델을 구현시 주로 사용한다.
Note: 모든 DB연동 코드를 JPA만 사용해서 구현해야 한다고 생각하진 말자. MyBatis, JdbcTemplate 등 다양한 기술을 사용해서 조회 모델을 구현할 수 있다.
검색을 위한 스펙
- 검색 조건이 고정되어 있다면 특정 조건으로 조회하는 기능을 만들면 되지만, 만약 검색 조건을 다양하게 조합해야 할 때 사용할 수 있는 것이 스펙(Specification)이다.
- 스펙은 애그리거트가 특정 조건을 충족하는지를 검사할 때 사용하는 인터페이스다.
1
2
3
| public interface Specification<T> {
boolean isSatisfiedBy(T agg);
}
|
- agg 파라미터는 검사 대상이 되는 객체이며 리포지터리에서 사용하면 애그리거트 루트가 되고 스펙을 DAO 에 사용하면 검색 결과로 리턴할 데이터 객체가 된다.
- isSatisfiedBy() 메서드는 검사 대상 객체가 조건을 충족하면 true를 리턴하고, 그렇지 않으면 false 를 리턴한다.
- 예를 들어, Order 애그리거트 객체가 특정 고객의 주문인지 확인하는 스펙은 다음과 같이 구현 가능하다.
1
2
3
4
5
6
7
8
9
10
11
| public class OrdererSpec implements Specification<Order> {
private String orderId;
public OrdererSpec(String ordererId) {
this.ordererId = ordererId;
}
public boolean isSatisfiedBy(Order agg) {
return agg.getOrdererId().getMemberId().getId().equasl(orererId);
}
}
|
- 리포지터리나 DAO는 검색 대상을 걸러내는 용도로 스펙을 사용한다.
- 만약 리포지토리가 메모리에 모든 애그리거트를 보관하고 있다면 다음과 같이 스펙을 사용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public class MemberOrderRepository implements OrderRepository {
public List<Order> findAll(Specification<Order> spec) {
List<Order> allOrders = findAll();
return allOrders.stream()
.filter(order -> spec.isSatisfiedBy(order))
.toList();
}
...
}
// 검색 조건을 표현하는 스펙을 생성
SPecification<Order> ordererSpec = new OrdererSpec("madvirus");
// 리포지터리에 전달
List<Order> orders = orderRepository.findAll(ordererSpec);
|
- 하지만 실제 스펙은 이렇게 구현하지 않는다.
- 모든 애그리거트 객체를 메모리에 보관핟기도 어렵고 설사 메모리에 다 보관할 수 있다하더라도 조회 성능에 심각한 문제가 발생하기 때문이다.
- 실제 스펙은 사용하는 기술에 맞춰 구현하면 되는데 스프링 데이터 JPA를 이용한 스펙 구현에 대해 알아볼 것이다.
스프링 데이터 JPA를 이용한 스펙 구현
- 스프링 데이터 JPA 는 검색 조건을 표현하기 위한 인터페이스인 Specification 을 제공한다.
1
2
3
4
5
6
7
8
| public interface Specification<T> extends Serializable {
// not, where, and, or 메서드 생략
@Nullable
Predicate toPredicate(Root<T> root,
CriteriaQuery query,
CriteriaBuilder cb);
}
|
- 제네릭 타입 파라미터 T는 JPA엔티티 타입을 의미하며, toPredicate() 메서드는 JPA Criteria API에서 조건을 표현하는 Predicate 를 생성한다.
- 이를 활용한 예시는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public class OrdererIdSpec implements Specification<OrderSummary> {
private String ordererId;
public OrdererIdSpec(String ordererId) {
this.ordererId = ordererId;
}
@Override
public Predicate toPredicate(Root<T> root,
CriteriaQuery query,
CriteriaBuilder cb) {
return cb.equals(root.get(OrderSummary_.ordererId), ordererId);
}
}
|
- OrdererSummary 엔티티에 대한 검색 조건을 표현한다.
- ordererId 프로퍼티 값이 생성자로 전달받은 값과 동일한지 비교하는 Predicate 를 생성한다.
- 스펙 구현 클래스를 개별적으로 만들지 않고 별도 클래스에 스펙 생성 기능을 모아도 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| public class OrderSummarySpec {
public static Specification<OrderSummary> ordererId(String ordererId) {
return (Root<OrderSummary> root, CriteriaQuery<?> query, CriteriaBuilder cb) ->
cb.equals(root.<String>get("ordererId"), ordererId);
}
public static Specification<OrderSummary> orderDateBetween(LocalDateTime from, LocalDateTime to) {
return (Root<OrderSummary> root, CriteriaQuery<?> query, CriteriaBuilder cb) ->
cb.equals(root.get(OrderSummary_.orderDate), from, to);
}
}
// 스펙 생성이 필요한 코드는 스펙 생성 기능을 제공하는 클래스를 이용해서 조금 더 간결하게 스펙을 생성 가능하다.
Specification<OrderSummary> betweenSpec = OrderSummarySpecs.orderDateBetween(from, to);
|
JPA 정적 메타 모델
- 위 예제 코드에서 OrderSummary_.ordererId 로 사용되는 부분이 있는데, OrderSummary_ 클래스는 JPA정적 메타 모델을 정의한 코드이다.
- 정적 메타 모델 클래스는 다음과 같이 구현 가능하다.
1
2
3
4
5
6
7
8
| @StaticMetamoidel(OrderSummary.class)
public class OrderSummary_ {
public static volatile SingularAttribute<OrderSummary, String> number;
public static volatile SingularAttribute<OrderSummary, Long> version;
public static volatile SingularAttribute<OrderSummary, String> ordererId;
public static volatile SingularAttribute<OrderSummary, String> ordererName;
... 생략
}
|
- 정적 메타 모델은
@StaticMetamodel
애너테이션을 이용해서 관련 모델을 지정한다. - 메타 모델 클래스는 모델 클래스의 이름 뒤에 ‘_‘을 붙인 이름을 갖는다.
- 정적 메타 모델 클래스는 대상 모델의 각 프로퍼티와 동일한 이름을 갖는 정적 필드를 정의한다.
- 이 정적 필드는 프로퍼티에 대한 메타 모델로서 프로퍼티 타입에 따라 SingularAttribute, ListAttribute 등의 타입을 사용해서 메타 모델을 정의한다.
- 정적 메타 모델을 사용하는 대신 문자열로 프로퍼티를 지정할 수도 있다.
1
| cb.equals(root.<String>get("ordererId"), ordererId);
|
- 하지만 문자열은 오타 가능성이 있고, 실행전까진 오타가 있다는 것을 놓치기 쉽다.
- 게다가 IDE의 코드 자동 완성 기능을 사용할 수 없어 입력할 코드도 많아진다.
- 이런 이유로 Criteria를 사용할땐 정적 메타 모델 클래스를 사용하는 것이 코드 안정성이나 생산성 측면에서 유리하다.
- 정적 메타 모델 클래스를 직접 작성할 수 있지만 하이버네이트와 같은 JPA 프로바이더는 정적 메타 모델을 생성하는 도구를 제공하고 있으므로 이들 도구를 사용하면 편리하다.
리포지터리/DAO에서 스펙 사용하기
- 스펙을 충족하는 엔티티를 검색하고 싶다면 findAll() 메서드를 사용하면 된다.
- 스프링 데이터 JPA 사용시 JpaSpecificationExecutor 를 상속받아서 사용 가능하다.
1
2
3
4
5
6
7
| public interface JpaSpecificationExecutor<T> {
// 생략...
List<T> findAll(@Nullable Specification<T> spec);
// 생략...
}
|
스펙 조합
- 스프링 데이터 JPA가 제공하는 스펙 인터페잇느느 스펙을 조합할 수 있는 두 메서드
and()
, or()
를 제공하다.- and(): 두 스펙을 모두 충족하는 조건을 표현하는 스펙 생성
- or(): 두 스펙 중 하나 이상 충족하는 조건을 표현하는 스펙을 생성
1
2
3
4
5
6
7
8
9
10
| public interface Specification<T> extends Serializable {
static <T> Specification<T> not(@Nullable Specification<T> spec) { ... }
static <T> Specification<T> where(@Nullable Specification<T> spec) { ... }
default Specification<T> and(@Nullable Specification<T> other) { ... }
default Specification<T> or(@Nullable Specification<T> other) { ... }
@Nullable
Predicate toPredicate(Root<T> root, CriteriaQuery query, CriteriaBuilder cb);
}
|
- 아래 코드는 spec1.and(spec2) 는 spec1 과 spec2 를 모두 충족하는 조건을 표현하는 spec3를 생성한다.
1
2
3
4
5
| Specification<OrderSummary> spec1 = OrderSummarySpecs.ordererId("user1");
Specification<OrderSummary> spec2 = OrderSummarySpecs.orderDateBetween(LocalDateTime.of(2022, 1, 1, 0, 0, 0),
LocalDateTime.of(2022, 1, 2, 0, 0, 0));
Specification<OrderSummary> spec3 = spec1.and(spec2);
|
- 아래와 같은 체이닝 기법으로 사용가능하여 불필요한 변수를 선언하지 않아도 된다.
1
2
| Specification<OrderSummary> spec = OrderSummarySpecs.ordererId("user1")
.and(OrderSummarySpecs.orderDateBetween(from, to));
|
- not() 메서드는 정적 메서드로 조건을 반대로 적용할때 사용한다.
1
| Specification<OrderSummary> spec1 = Specification.not(OrderSummarySpecs.ordererId("user1"));
|
- null 가능성이 있는 스펙 객체와 다른 스펙을 조합해야 할 때 Null 체크를 매번 하려면 다소 귀찮다.
1
2
3
4
| Specification<OrderSummary> nullableSpec = createNullableSpec(); // null일 수 있음
Specification<OrderSummary> oterSpec = createOtherSpec();
Specification<OrderSummary> spec = nullable == null ? otherSpec : nullableSpec.and(otherSpec);
|
- where() 메서드를 사용면 이런 귀찮음을 줄일 수 있다.
- null을 전달하면 아무 조건도 생성하지 않는 스펙 객체를 리턴하고 null이 아니면 인자로 받은 스펙 객체를 그대로 리턴하다.
1
| Specification<OrderSummary> spec = Specification.where(createNulalbleSpec()).and(createOtherSpec());
|
정렬 지정하기
- 스프링 데이터 JPA는 두 가지 방법을 사용해서 정렬을 지정 가능하다.
1) 메서드 이름에 OrderBy를 사용해서 정렬 기준 지정
1
2
3
4
5
| public interface OrderSummaryRepository extends JpaRepository<OrderSummary, Integer> {
// 메서드 이름
List<OrderSummary> findAllByOrderByOrderDateDescNumberAsc(String ordererId); // OrderDate 프로퍼티 기준으로 내림차순 정렬 후 Number 프로퍼티 기준으로 오름차순으로 정렬
}
|
- 위 방법의 단점은 정렬 조건이 많아질수록 메서드명이 길어진다는 것과 메서드 일므으로 정렬 순서가 정해지기 때문에 상황에 따라 정렬순서를 변경할 수도 없다.
- 이땐 아래 Sort 타입을 사용하면 된다.
2) Sort를 인자로 전달
1
2
3
4
5
| public interface OrderSummaryRepository extends JpaRepository<OrderSummary, Integer> {
// Sort
List<OrderSummary> findAllByOrdererId(String ordererId, Sort sort);
}
|
- Sort 객체를 생성해서 인자로 넘겨주면 스프링 데이터 JPA 내부적으로 알아서 정렬 쿼리를 적용한다.
1
2
3
4
5
6
7
8
9
10
11
| Sort sort = Sort.by("number").ascending();
List<OrderSummary> results = orderSummaryDao.findByOrdererId("user1", sort);
// 두 개 이상의 정렬 순서를 지정하고 싶다면 Sort#and() 메서드를 사용해서 두 sort 객체를 연결하면 된다.
Sort sort1 = Sort.by("number").ascending();
Sort sort2 = Sort.by("orderDate").descending();
Sort sort sort1.and(sort2);
// 메서드 체이닝도 가능하다.
Sort sort = Sort.by("number").ascending().and(Sort.by("orderDate").descending());
|
페이징 처리하기
- 스프링 데이터 JPA 는 페이징 처리를 위해 Pageable 인터페이스 타입을 이용한다.
- Sort 타입과 마찬가지로 find 메서드에 Pageable 타입 파라미터를 사용하면 페이징을 자동으로 처리해준다.
1
2
3
4
5
6
7
8
| public interface OrderSummaryRepository extends JpaRepository<OrderSummary, Integer> {
List<OrderSummary> findByOrderByNumberDesc(String ordererId, Pagable pagable);
// 목록뿐 아니라 조건에 해당하는 전체 개수 및 페이징 처리에 필요한 데이터도 함께 제공
Page<OrderSummary> findByOrderByNumberDesc(String ordererId, Pagable pagable);
}
|
- 이를 실제 호출할땐 Pageable 의 구현체인 PageRequest 객체를 생성해야 한다.
1
2
| PageRequest pageReq = PageRequest.of(1, 10); // 페이지 번호, 한 페이지의 개수 (페이지 번호는 0부터 시작)
List<MemberData> user = memberDataDao.findbyNameLike("사용자%", pageReq);
|
- PageRequest 와 Sort 를 사용하면 정렬 순서도 지정가능하다.
1
2
3
| Sort sort = Sort.by("name").descending();
PageRequest pageReq = PageRequest.of(1, 2, sort);
List<MemberData> user = memberDataDao.findbyNameLike("사용자%", pageReq);
|
- Pageable 을 사용하는 메서드의 리턴타입이 Page 일 경우 스프링 데이터 JPA는 목록 조회 쿼리와 함께 COUNT 쿼리도 실행해서 조건에 해당하는 데이터 갯수를 구한다.
- Page 는 전체 개수, 페이지 개수 등 페이징 처리에 필요한 데이터도 함께 제공한다.
- 다음은 Page 가 제공하는 메서드의 일부를 보여준다.
1
2
3
4
5
6
7
8
9
| PageRequest pageReq = PageRequest.of(2, 3);
Page<MemberData> user = memberDataDao.findbyBlocked(false, pageReq);
List<MemberData> content = page.getContent(); // 조회 결과 목록
long totalElements = page.getTotalElements(); // 조건에 해당하는 전체 개수
int totalPages = page.getTotalPages(); // 전체 페이지 번호
int number = page.getNumber(); // 현재 페이지 번호
int numberOfElements = page.getNumberOfElements(); // 조회 결과 개수
int size = page.getSize(); // 페이지 크기
|
- 스펙을 사용하는 findAll() 메서드도 Pageable 을 사용할 수 있다.
1
2
3
| public interface MemberDataDao extends Repository<MemberData, String> {
Page<MemberData> findAll(Specification<MemberData> spec, Pageable pageable);
}
|
Note: 프로퍼티를 비교하는 findBy프로퍼티 형식의 메서드는 Pageable 타입을 사용하더라도 리턴타입이 List 면 COUNT 쿼리를 실행하지 않는다. 반면 스펙을 사용한 findAll 메서드에 Pageable 타입을 사용하면 리턴타입이 Page 가 아니어도 COUNT 쿼리를 실행한다. 만약 스펙을 사용하고 페이징 처리를 하면서 COUNT 쿼리는 실행하고 싶지 않다면 스프링 데이터 JPA가 제공하는 커스텀 리포지터리 기능을 이용해서 직접 구현해야 한다. 구현 방법이 궁금하면 https://javacan.tistory.com/entry/spring-data-jpa-range-query 문서를 참고하면 좋다.
- 쳐음부터 N개의 데이터가 필요하다면 Pageable 을 사용하지 않고 findFirstN형식의 메서드를 사용할 수도 있다.
- 예를 들어 다음 메서드는 name 프로퍼티 기준으로 like 검색한 결과를 name 프로퍼티 기준으로 오름차순 정렬해서 처음 3개를 조회한다. First 대신 Top을 사용해도 된다.
- 만약 First나 Top 뒤에 숫자가 없으면 한 개 결과만 리턴한다.
1
2
3
4
| List<MemberData> findFirst3ByNameLikeOrderByName(String name);
List<MemberData> findTop3ByNameLikeOrderByName(String name);
MemberData findFirstByBlockedOrderById(boolean blocked);
|
스펙 조합을 위한 스펙 빌더 클래스
- 스펙을 생성하다보면 다음 코드처럼 조건에 따라 스펙을 조합해야 할 때가 있다.
1
2
3
4
5
6
7
8
9
10
11
| Specification<MemberData> spec = Specification.where(null);
if (searchReq.isOnleyNotBlocked()) {
spec = spec.and(MemberDataSpecs.nonBlocked());
}
if (StringUtils.hasText(searchRequest.getName())) {
spec = spec.and(MemberDataSpecs.nameLike(searchRequest.getName()));
}
List<MemberData> results = memberDataDao.findAll(Spec, PageRequest.of(0, 5));
|
- 위 코드는 if와 각 스펙을 조합하는 코드가 섞여 있어 실수하기 좋고 복잡한 구조를 갖는다.
- 이 점을 보완하기 위해 필자는 스펙 빌더를 만들어 사용한다.
1
2
3
4
5
6
7
8
| Specification<MemberData> spec = SpecBuilder.builder(MemberData.class)
.ifTrue(searchRequest.isOnlyNotBlocked(),
() -> MemberDataSpecs.nonBlocked())
.ifHasText(searchReq.getName(),
name -> MemberDataSpecs.nameLike(searchReq.getName()))
.toSpec();
List<MemberData> result = memberDataDao.findAll(spec, PageRequest.of(0, 5));
|
- if 블록을 사용할 때와 비교하면 코드양은 비슷하지만 메서드를 사용해서 조건을 표현하고 메서드 호출 체인으로 연속된 변수할당을 줄여 코드 가독성을 높이고 구조가 단순해진다.
- 스펙 빌더 코드는 아래와 같다. and(), ifHasText(), ifTrue() 메서드 외에는 직접 추가해서 사용하면 된다.
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
44
45
46
47
48
49
50
51
52
| public class SpecBuilder {
public static <T> Builder<T> builder(Class<T> type) {
return new Builder<T>();
}
public static class Builder<T> {
private List<Specification<T>> specs = new ArrayList<>();
private void addSpec(Specification<T> spec) {
if (spec != null) {
specs.add(spec);
}
}
public Builder<T> and(Specification<T> spec) {
addSpec(spec);
return this;
}
public Builder<T> ifHasText(String str,
Function<String, Specification<T>> specSupplier) {
if (StringUtils.hasText(str)) {
addSpec(specSupplier.apply(str));
}
return this;
}
public Builder<T> ifTrue(Boolean cond,
Supplier<Specification<T>> specSupplier) {
if (cond != null && cond.booleanValue()) {
addSpec(specSupplier.get());
}
return this;
}
public <V> Builder<T> ifNotNull(V value,
Function<V, Specification<T>> specSupplier) {
if (value != null) {
addSpec(specSupplier.apply(value));
}
return this;
}
public Specification<T> toSpec() {
Specification<T> spec = Specification.where(null);
for (Specification<T> s : specs) {
spec = spec.and(s);
}
return spec;
}
}
}
|
동적 인스턴스 생성
- JPA 는 쿼리 결과에서 임의의 객체를 동적으로 생성할 수 있는 기능을 제공하고 있다.
- 조회 전용 모델을 만드는 이유는 표현 영역을 통해 사용자에게 데이터를 보여주기 위함이다.
- 동적 인스턴스의 장점은 JPQL 을 그대로 사용하므로 객체 기준 쿼리를 사용하면서도 지연/즉시 로딩과 같은 고민이 필요없이 데이터를 조회할 수 있다는 점이다.
1
2
3
4
5
6
7
8
9
| public interface OrderSummaryDao extends Repository<OrderSummary,String>{
@Query("select new com.myship.order.query.dto.OrderView(o.number, o.state, m.name, m.id, p.name)
from Order o join o.orderLines ol, Member m, Product p
where o.orderer.memberId.id = m.id
and index(ol) = 0
and ol.productId.id = p.id
order by o.number.number desc")
List<OrderView> findOrderView(String ordererId);
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public class OrderView {
private final String number;
private final OrderState state;
private final String memberName;
private final String memberId;
private final String productName;
public OrderView(OrderNo number,
OrderState state,
String memberName,
MemberId memberId,
String productName) {
this.number = number.getNumeber();
this.state = state;
this.memberName = memberName;
this.memberId = memberId.getId();
this.productName = productName;
}
... // get 메서드
}
|
하이버네이트 @Subselect 사용
- 하이버네이트는 JPA 확장 기능으로
@Subselect
를 제공한다. @Subselect
는 쿼리 결과를 @Entity로 매핑할 수 있는 유용한 기능이다.
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
| import org.hibernate.annotations.Immutable;
import org.hibernate.annotations.Subselect;
import org.hibernate.annotations.Synchronize;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
@Entity
@Immutable
@Subselect(
''''
select o.order_number as number,
o.version, o.orderer_id, o.orderer_name,
o.total_amounts, o.receiver_name, o.state, o.order_date, p.product_id, p.name as product_name
from purchase_order o inner join order_line ol
on o.order_number = ol.order_number
cross join product p
where ol.line_idx = 0 and ol.product_id = p.product_id
''''
)
@Synchronize({"purchase_order", "order_line", "product"})
public class OrderSummary {
@Id
private String number;
private long version;
@Column(name="orderer_id")
private String ordererId;
@Column(name="orderer_name")
private String ordererName;
protected OrderSummary(){
}
}
|
@Immutable
, @Subelect
, @Synchronize
는 하이버네이트 전용 애너테이션인데 이 애너테이션을 사용하면 테이블이 아닌 쿼리 결과를 @Entity로 매핑할 수 있다.@Subselect
는 조회 쿼리를 값으로 갖는다. 하이버네이트는 이 select 쿼리의 결과를 매핑할 테이블처럼 사용한다.- DBMS가 여러 테이블을 조인ㅇ해서 조회한 결과를 한 테이블처럼 보여주기 위한 용도로 뷰를 사용하는 것처럼
@Subselect
를 사용하면 쿼리 실행 결과를 매핑할 테이블처럼 사용한다. - 뷰를 수정할 수 없듯이
@Subselect
로 조회한 @Entity
역시 수정할 수 없다. - 실수로
@Subselect
를 이용한 @Entity
의 매핑 필드를 수정하면 하이버네이트는 변경 내역을 반영하는 update 쿼리를 실행할텐데 매핑 한 테이블이 없으므로 에러가 발생한다. - 이런 문제를 방지하기 위해
@Immutable
을 사용하는데 하이버네이트는 해당 엔티티의 매핑 필드/프로퍼티가 변경되도 DB에 반영하지 않고 무시한다.
1
2
3
4
5
6
| // purchase_order 테이블에서 조회
Order order = orderRepository.findById(orderNumber);
order.changeShippingInfo(newInfo); // 상태 변경
// 변경 내역이 DB에 반영되지 않았는데 purchase_order 테이블에서 조회
List<OrderSummary> summaries = orderSummaryRepository.findByOrdererId(userId);
|
- 위 코드는 Order의 상태를 변경한 뒤에 OrderSummary 를 조회하고 있다.
- 특별한 이유가 없으면 하이버네이트는 트랜잭션을 커밋하는 시점에 변경사항을 DB에 반영하므로, Order의 변경 내역을 아직 purchase_order 테이블에 반영하지 않은 상태에서 purchase_order 테이블을 사용하는 OrderSummary 를 조회하게 된다. 즉, OrderSummary 에는 최신 값이 아닌 이전 값이 담기게 된다.
- 이런 문제를 해소하기 위한 용도로 사용한 것이
@Synchronize
이다.- 해당 엔티티와 관련된 테이블 목록을 명시한다.
- 하이버네이트는 엔티티를 로딩전에 지정한 테이블과 관련된 변경사항이 발생하면 플러시를 먼저 한다.
- OrderSummary 의
@Synchronize
는 ‘purchase_order’ 테이블을 지정하고 있으므로 OrderSummary 를 로딩하기 전에 purchase_order 테이블에 변경이 발생하면 관련 내역을 먼저 플러시 한다. - 따라서 OrderSummary를 로딩하는 시점엔 변경 내역이 반영된다.
@Subselect
를 사용해도 일반 Entity 와 같기 때문에 EntityManager#find(), JPQL, Criteria 를 사용해서 조회할 수 있는 것이 @Subselect 의 장점이다. 이것은 초반에 설명한 스펙을 사용할 수 있따는 것도 포함된다.
1
2
3
4
| // @Subselect 를 적용한 @Entity 는 일반 @Entity 와 동일한 방법으로 조회할 수 있다.
Specification<OrderSummary> spec =orderDateBetween(from, to);
Pageable pageable = PageRequest.of(1, 10);
List<OrderSummary> results = orderSummaryDao.findAll(spec, pageable);
|
@Subselect
는 이름처럼 @Subselect 의 값으로 지정한 쿼리를 from절의 서브 쿼리로 사용한다. 즉, 실행하는 쿼리는 당므과 같은 형식을 갖는다.
1
2
3
4
5
6
7
8
9
10
11
| select osm.number as number1_0_, ... 생략
from (
select o.order_number as number,
o.version, o.orderer_id, o.orderer_name,
o.total_amounts, o.receiver_name, o.state, o.order_date, p.product_id, p.name as product_name
from purchase_order o inner join order_line ol
on o.order_number = ol.order_number
cross join product p
where ol.line_idx = 0 and ol.product_id = p.product_id
) osm
where osm.orderer_id = ? order by osm.number desc
|
@Subselect
를 사용할 때는 쿼리가 이러한 형태를 갖는다는 점을 유념해야 한다.- 서브 쿼리를 사용하고 싶지 않다면 네이티브 SQL 쿼리를 사용하거나 마이바티스와 같은 별도 매퍼를 사용해서 조회 기능을 구현해야 한다.
Reference
Chapter6-응용 서비스와 표현 영역
표현 영역과 응용 영역
- 응용 영역과 표현 영역은 사용자와 도메인을 연결해주는 매개체 역할을 한다.
응용 영역의 책임
- 응용 영역은 실제 사용자가 원하는 기능을 제공한다.
표현 영역의 책임
- 응용 서비스가 요구하는 형식으로 사용자 요청을 변환
- 응용 서비스를 실행 한 후 실행 결과를 사용자에 알맞은 형식으로 응답
사용자와의 상호작용은 표현 영역이 처리하기 때문에 응용 서비스는 표현 영역에 의존하지 않는다. 응용 영역은 사용자가 웹 브라우저를 사용하는지, REST API를 호출하는지, TCP 소켓을 사용하는지 여부를 알 필요가 없다. 단지, 응용 영역은 기능 실해에 필요한 입력값을 전달받고 실행 결과만 리턴하면 될 뿐이다.
응용 서비스의 역할
1. 도메인 객체 간의 실행 흐름 제어
- 사용자(클라이언트)가 요청한 기능을 실행한다.
- 사용자의 요청을 처리하기 위해 리포지터리로부터 도메인 객체를 구하고, 도메인 객체를 사용한다.
- 도메인 객체 간의 흐름을 제어하기 때문에 아래와 같이 단순한 형태를 가진다.
1
2
3
4
5
6
7
8
9
10
11
| public Result doSomeFunc(SomeReq req) {
// 1. 리포지터리에서 애그리거트를 구한다.
SomeAgg agg = someAggRepository.findById(req.getId());
checkNull(agg);
// 2. 애그리거트의 도메인 기능을 실행한다.
agg.doFunc(req.getValue());
// 3. 결과를 리턴한다.
return createSuccessResult(agg);
}
|
- 응용 서비스가 이것보다 복잡하다면 응용 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 높다.
2. 트랜잭션 처리
- 데이터 일관성을 보장하기 위한 트랜잭션 처리를 한다.
위 두 가지 이외에도 접근 제어와 이벤트 처리가 있는데 뒤에서 살펴본다.
도메인 로직 넣지 않기
- 아래 예제 코드와 같이 패스워드 일치 여부를 검사하는 도메인 로직은 도메인 영역에서 제공해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| public class Member {
public void changePassword(String oldPw, String newPw) {
if (!matchPassword(oldPw)) throw new BadPasswordException();
setPassword(newPw);
}
// 현재 암호와 일치하는지 검사하는 도메인 로직
public boolean matchPassword(String pwd) {
return passwordEncoder.matchs(pwd);
}
private void setPassword(String newPw) {
if (isEmpty(newPw)) throw new IllegalArgumentException("no new password");
this.password = newPw;
}
}
|
- 도메인 로직을 도메인 영역과 응용 서비스에서 분산해서 구현하면 코드 품질에 두 가지 문제가 발생한다.
1. 코드의 응집성 떨어진다.
- 도메인 데이터와 그 데이터를 조작하는 도메인 로직이 한 영역에 위치하지 않고 서로 다른 영역에 위치한다는 것은 도메인 로직을 파악하기 위해 여러 영역을 분석해야 한다는 것을 뜻한다.
2. 여러 응용 서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다는 것이다.
1
2
3
4
5
6
7
8
9
10
11
| public class DeactivationService {
public void deactivate(String memberId, String pwd) {
Member member = memberRepository.findById(memberId);
if (!passwordEncoder.matches(oldPw, member.getPassword())) {
throw new BadPasswordException();
}
member.deactivate();
}
}
|
- 위처럼 도메인 로직을 여러 응용 서비스에서 구현하게 되면 중복 코드가 발생하게 된다.
- 만약 패스워도 변경시에도 패스워드 일치 여부를 확인하는 로직이 필요하다면 중복해서 코드를 작성하게 될 것이다.
- 이는 결과적으로 코드 변경을 어렵게 만든다.
Note: 소프트웨어의 중요한 경쟁 요소 중 하나는 변경의 용이성인데, 변경이 어렵게 된다는 것은 그만큼 소프트웨어의 가치가 떨어진다는 것을 뜻한다. 소프트웨어의 가치를 높이려면 도메인 로직을 도메인 영역에 모아서 코드 중복이 발생하지 않도록 하고 응집도를 높여야 한다.
응용 서비스의 구현
- 응용 서비스는 표현 영역과 도메인 영역을 연결하는 매개체 역할을 하는데 이는 디자인 패턴에서 파사트(facade)와 같은 역할을 한다.
- 응용 서비스 자체는 복잡한 로직을 수행하지 않기 때문에 응용 서비스의 구현은 어렵지 않다.
- 응용 서비스 구현시 몇 가지 고려할 사항과 트랜잭션과 같은 구현 기술의 연동에 대해 살펴보자.
응용 서비스의 크기
1)응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기
- 장점: 한 도메인과 관련된 기능을 구현한 코드가 한 클래스에 위치하므로 각 기능에서 동일 로직에 대한 코드 중복을 제거할 수 있다.
- 단점: 한 서비스 클래스의 크기가 커진다. 연관성이 적은 코드가 한클래스에 함께 위치할 가능성이 높아짐을 의미하는데, 결과적으로 관련 없는 코드가 뒤섞여서 코드를 이해하는데 방해가 될 수 있다.
- 예를 들어, 암호 초기화 기능을 구현한 initializePassword() 메서드는 암호 초기화 후 신규 암호를 사용자에게 통지하기 위해 Notfier 를 사용하는데, changePassword() 메서드에선 필요치 않은 기능이다.
- 하지만 Notifier 가 필드로 존재하므로 이 Notifier 가 어떤 기능 때문에 필요한지 확인하려면 각 기능을 구현한 코드를 뒤져야만 한다.
- 그리고 엄연히 분리하는 것이 좋은 상황임에도 습관적으로 기존에 존재하는 클래스에 억지로 끼워 넣게 된다. 이는 코드를 점점 얽히게 만들어 코드 품질을 낮추는 결과를 초래한다.
2)구분되는 기능별로 응용 서비스 클래스를 따로 구현하기
- 장점: 코드 품질을 일정 수준으로 유지하는데 도움이 된다. 또한, 각 클래스별로 필요한 의존 객체만 포함하므로 다른 기능을 구현한 코드에 영향을 받지 않는다.
- 만약, 각 기능마다 동일한 로직을 구현할 경우 여러 클래스에 중복해서 동일한 코드를 구현할 가능성이 있는데, 이를 방지하기 위해 다음과 같이 별도 클래스에 로직을 구현해서 코드가 중복되는 것을 방지할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| // 각 응용 서비스에서 공통되는 로직을 별도 클래스로 구현
public final class MemberServiceHelper {
public static Member findExistingMember(MemberRepository repo, String memberId) {
Member member = memberRepository.findById(memberId);
if(member == null) {
throw new NoMemberException(memberId);
}
return member;
}
}
// 공통 로직을 제공하는 메서드를 응용 서비스에서 사용
import static com.myshop.member.application.MemberServiceHelper.*;
public class ChangePasswordService {
private MemberRepository memberRepository;
public void changePassword(String memberId, String oldPw, String newPw) {
Member member = findExistingMember(memberRepository, memberId);
member.setPassword(newPw);
}
...
}
|
Note: 저자는 한 클래스가 여러 역할을 갖는 것보다(전자) 각 클래스마다 구분되는 역할을 갖는 것을 선호한다고 한다. 즉, 한 도메인과 관련된 기능을 하나의 응용 서비스 클래스에서 모두 구현하는 방식보단 구분되는 기능을 별도의 서비스 클래스로 구현하는 방식을 사용한다고 한다.
응용 서비스의 인터페이스와 클래스
- 인터페이스를 유용하게 사용할 수 있는 경우는 다음과 같다.
- 1)구현 클래스가 다수 존재할때
- 2)런타임에 구현 객체를 교체해야 할 경우
- 하지만 응용 서비스는 보통 런타임에 이를 교체할 경우가 거의 없을 뿐만 아니라 한 응용 서비스의 구현 클래스가 두 개인 경우도 매우 드물다.
- 인터페이스가 명확하게 필요하기 전까지는 으용 서비스에 대한 인터페이스를 작성하는 것이 좋은 설계라 볼 수 없다.
- 표현 영역의 단위 테스트를 할 때 Mockito 와 같은 테스트 도구를 통해 테스트용 가짜 객체를 만들 수 있기 때문에 응용 서비스에 대한 인터페이스가 없어도 문제 없다.
메서드 파라미터와 값 리턴
- 응용 서비스에서 애그리거트 자체를 리턴하면 코딩은 편할 수 잇찌만 도메인의 로직 실행을 응용 서비스와 표현 영역 두 곳에서 할 수 있게 된다.
- 이는 기능 실행 로직을 응용 서비스와 표현 영역에 분산시켜 코드의 응집도를 낮추는 원인이 된다.
- 물론 팀 내 컨벤션으로 응용 서비스가 애그리거트를 리턴할 경우 해당 애그리거트의 기능을 컨트롤러나 뷰 코드에서 실행하지 않도록 규정할 수도 있다.
- 하지만, 그보다는 응용 서비스는 표현 영역에서 필요한 데이터만 리턴하는 것이 기능 실행 로직의 응집도를 높이는 확실한 방법이다.
표현 영역에 의존하지 않기
- 응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 영역과 관련된 타입을 사용하면 안된다는 점이다.
- ex.
HttpServletRequest
, HttpSession
등
- 표현 영역에 의존하지 않기 이렇게 했을 때 아래와 같은 문제점이 발생한다.
1) 응용 서비스만 단독으로 테스트하기 어려워짐
2) 표현 영역의 구현 변경시 응용 서비스의 구현도 함께 변경해줘야됨
3) 응용 서비스가 표현 영역의 역할까지 대신하는 상황이 벌어질 수도 있다.
- 예를 들어, 응용 서비스에 파라미터로 HttpServletRequest를 전달했는데 응용 서비스에 HttpSession 을 생성 후 세션에 인증 관련 정보를 담는다 해보자.
1
2
3
4
5
6
7
8
9
10
11
12
| public class AuthenticationService {
public void authenticate(HttpServletRequest request) {
String id = request.getParamter("id");
String password = request.getParameter("password");
if (checkIdPasswordMatching(id, password)) {
// 응용 서비스에서 표현 영역의 상태 처리
HttpSession session = request.getSession();
session.setAttribute("auth", new Authentication(id));
}
}
...
}
|
- HttpSession 이나 쿠키는 표현 영역의 상태에 해당하는데 이 상태를 응용 서비스에서 변경해 버리면 표현 영역의 코드만으로 표현 영역의 상태가 어떻게 변경되는지 이해하기 어려워진다.
- 즉, 표현 영역의 응집도가 깨지는 것이다. 이는 결과적으로 코드 유지보수 비용을 증가시키는 원인이 된다.
- 앞서 언급한 문제가 발생되지 않도록 하는 가장 쉬운 방법은 서비스 메서드의 파라미터와 리턴 타입으로 표현 영역의 구현 기술을 사용하지 않는 것이다.
트랜잭션 처리
- 프레임워크가 제공하는 트랜잭션 기능을 적극 사용하는 것이 좋다. 스프링의
@Transactional
은 RuntimeException 이 발생하면 롤백시켜버리고 그렇지 않으면 커밋을 자동으로 하게 된다.
도메인 이벤트 처리
- 응용 서비스의 역할 중 하나는 도메인 영역에서 발생한 이벤트를 처리하는 것이다.
- 여기서 이벤트는 도메인에서 발생한 상태 변경을 의미하며 ‘암호 변경됨’, ‘주문 취소함’ 과 같은 것이 이벤트가 될 수 있다.
1
2
3
4
5
6
7
8
9
| public class Member {
private Password password;
public void initializePassword() {
String newPassword = generateRandomPassword();
this.password = new Password(newPassword);
Events.raise(new PasswordChangedEvent(this.id, password); // 도메인 이벤트 발생!!
}
}
|
- 도메인에서 이벤트를 발생시키면 그 이벤트를 받아서 처리할 코드가 필요한데, 그 역할을 하는 것이 바로 응용 서비스다.
- 암호 초기화의 경우 암호 초기화됨 이벤트가 발생하면 변경한 암호를 이메일로 발송하는 이벤트 핸들러를 등록할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| public class InitPasswordService {
@Transactional
public void initializePassword(String memberId) {
Events.handle((PasswordChangeEvent evt) -> {
// evt.getid()에 해당하는 회원에게 이메일 발송하는 기능 구현
});
Member member = memberRepository.findById(memberId);
checkMemberExists(member);
member.initializePassword();
}
}
|
- 위 이벤트 처리 코드를 보고 왜 다음과 같이 코드를 작성해도 되는데 다소 복잡해 보이는 이벤트를 사용했는지 궁금할 수 있다.
1
2
3
4
5
6
7
| @Transactional
public void initializePassword(String memberId) {
Member member = memberRepository.findById(memberId);
checkMemberExists(member);
member.initializePassword(); // 이벤트 발생하지 않음
sendNewpasswordMailToMember(member); // 실행 안돼야 함
}
|
- 이벤트를 사용하면 코드가 다소 복잡해지는 대신 도메인 간의 의존성이나 외부 시스템에 대한 의존을 낮춰주는 장점을 얻을 수 있다.
- 또한 시스템을 확장하는 데에 이벤트가 핵심 역할을 수행하게 된다.
- 이런 이유로 이벤트를 사용하는데, 이에 대한 자세한 내용은 이벤트를 다루는 10장과 CQRS 에 대해 다루는 11장에서 살펴볼 예정이다.
표현 영역
- 표현 영역의 책임은 크게 다음과 같다.
- 1)사용자가 시스템을 사용할 수 있는 (화면)흐름을 제공하고 제어한다.
- 2)사용자의 요청에 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다.
- 3)사용자의 세션을 관리 (웹의 경우 쿠키나 서버 세션을 이용해 사용자의 연결 상태를 관리)
값 검증
- 값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행 가능하다.
- 원칙적으로 모든 값에 대한 검증은 응용 서비스에서 처리한다.
- 예를 들어, 회원 가입을 처리하는 응용 서비스는 다음과 같이 파라미터로 전달받은 값이 올바른지 검사해야 한다.
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
| public class JoinService {
@Transactional
public void join(JoinRequest joinReq) {
checkEmpty(joinRequest.getId(), "id");
checkEmpty(joinRequest.getName(), "name");
checkPassword(joinRequest.getPassword(), "password");
if (joinReq.getPassword().equals(joinReq.getConfirmPassword())) {
throw new InvalidPropertyException("confirmPassword");
}
// 로직 검사
checkDuplicatedId(joinReq.getId());
...
}
private void checkEmpty(String value, String propertyName) {
if(value == null || value.isEmpty()) {
throw new EmptyPropertyException(propertyName);
}
}
private void checkDuplicateId(String id) {
int count = memberRepository.countsById(id);
if (count > 0) throw new DuplicatedIdexception();
}
}
|
- 그런데 표현 영역은 잘못된 값이 존재하면 이를 사용자에게 알려주고 값을 다시 입력받아야 한다.
- 스프링 MVC의 경우 폼에 입력한 값이 잘못된 경우 에러 메시지를 보여주기 위한 용도로 Errors 나 BindingResult를 사용하는데, 스프링 MVC의 컨트롤러에서 위와 같은 응용 서비스를 사용하면 폼에 에러 메시지를 보여주기 위해 다음과 같이 다소 번잡한 코드를 작성해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| @Controller
public class Controller {
@RequestMapping
public String join(JoinReuqest joinRequest, Errors erros) {
try {
joinService.join(joinRequest);
return successView;
} catch(EmptyPropertyException ex) {
// 표현 영역은 잘못 입력한 값이 존재하면 이를 사용자에게 알려주고
// 폼을 다시 입력할 수 있도록 하려면, 관련 기능을 사용해야 한다.
errors.rejectValue(ex.getPropertyName(), "empty");
return formView;
} catch(InvalidPropertyException ex) {
errors.rejectValue(ex.getPropertyName(), "invalid");
return formView;
} catch(DuplicateIdException ex) {
errors.rejectValue(ex.getPropertyName(), "duplicate");
return formView;
}
}
}
|
- 응용 서비스에서 각 값이 존재하는지 형식이 올바른지 확인할 목적으로 익셉션을 사용할 때의 문제점은 사용자에게 좋지 않은 경험을 제공한다는 것이다.
- 사용자는 폼에 값을 입력 후 전송했는데 입력한 값이 잘못되어 다시 폼에 입력해야 할 때 한 개 항목이 아닌 모든 항목에 대해 잘못된 값이 존재하는지 알고 싶을 것이다.
- 그래야 한 번에 잘못된 값을 제대로 입력할 수 있기 때문이다.
- 그런데, 응용 서비스에서 값을 검사하는 시점에 첫 번째 값이 올바르지 않아 익셉션을 발생시키면 나머지 항목에 대해서는 값을 검사하지 않게 된다.
- 이러면 사용자는 첫번쨰 값에 대한 에러 메시지만 보게 되고 나머지 항목에 대해선 값이 올바른지 여부를 알 수 없게 된다. 이는 사용자가 같은 폼에 값을 여러 번 입력하게 만든다.
- 이런 사용자 불편을 해소하려면 다음과 같이 응용 서비스에 값을 전달하기 전에 표현 영역에서 값을 검사하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| @Controller
public class Controller {
@RequestMapping
public String join(JoinRequest joinRequest, Errors errors) {
checkEmpty(joinRequest.getId(), "id", errors);
checkEmpty(joinRequest.getName(), "name", errors);
... // 나머지 값 검증
// 모든 값의 형식을 검증한 뒤, 에러가 존재하면 다시 폼을 보여줌
if(errors.hasErrors()) return formView;
try {
joinService.join(joinRequest);
return successView;
} catch(DuplicateIdException ex) {
erros.rejectValue(ex.getPropertyName(), "duplicate");
return formView;
}
}
private void checkEmpty(String value, String property, Errors errors) {
if(isEmpty(value)) erros.rejectValue(property, "empty");
}
}
|
- 스프링에서 제공하는 Validator 인터페이스를 구현한 검증기를 따로 구현하면 위 코드를 더 간결하게 줄일 수 있다.
- 표현 영역에서 필수 값과 값의 형식을 검사하면 실질적으로 응용 서비스는 논리적 오류만 검사하면 된다.
- 표현 영역: 필수 값과 값의 형식, 범위 등을 검증
- 응용 서비스: 데이터의 존재 유무와 같은 논리적 오류를 검증
- 엄격하게 두 영역에서 모두 값검사를 하고 싶다면 동일한 검증기를 사용해서 검증 코드를 줄일 수 있다.
Note: 저자의 경험상 응용 서비스를 실행하는 주체가 표현 영역이면 응용 서비스는 논리적 오류 위주로 값을 검증해도 문제가 없었지만 응용 서비스를 실행하는 주체가 다양하면 응용 서비스에서 반드시 파라미터로 전달받은 값이 올바른지 검사를 해야 한다.
권한 검사
- 권한을 검사하기 위해 스프링 시큐리티나 아파치 Shiro 같은 프레임워크는 유연하고 확장 가능한 구조를 가진다.
- 보안 프레임워크의 복잡도를 떠나 보통 다음 세 곳에서 권한 검사를 수행할 수 있다.
- 예를 들어, 회원 정보 변경을 처리하는 URL에 대해 표현 영역에서 다음과 같이 접근 제어를 할 수 있다.
- 이 URL을 처리하는 컨트롤러에 웹 요청을 전달하기 전에 인증 여불르 검사해서 인증된 사용자의 요청만 컨트롤러에 전달한다.
- 인증된 사용자가 아닐 경우 로그인 화면으로 리다이렉트 시킨다.
- 이런 접근 제어를 하기 좋은 위치가 서블릿 필터이다.
- 서블릿 필터에서 사용자의 인증 정보를 생성 후 인증 여부를 검사하는 것이다.
- URL만으로 접근제어를 할 수 없는 경우 응용 서비스의 메서드 단위로 권한 검사를 수행해야 한다.
- 스프링 시큐리티는 AOP를 활용해서 다음과 같이 어노테이션으로 서비스 메서드에 대한 권한 검사를 할 수 있는 기능을 제공한다.
1
2
3
4
5
6
7
8
9
10
11
| public class BlockMemberService {
private MemberRepository memberRepository;
@PreAuthorize("hasRole('ADMIN')")
public void block(String memberId) {
Member member = memberRepository.findById(memberId);
if (member == null) throw new NoMemberException();
member.block();
...
}
}
|
- 개별 도메인 단위로 권한 검사를 해야 하는 경우는 응용 서비스의 메서드 수준에서 권한 검사를 할 수 없기 때문에 다음과 같이 직접 권한 검사 로직을 구현해줘야 한다.
1
2
3
4
5
6
7
8
9
10
11
| public class DeleteArticleService {
public void delete(String userId, Long articleId) {
Article article = articleRepository.findById(articleId);
checkArticleExistence(article);
permissionService.checkDeletePermission(userId, article);
article.markDeleted();
}
...
}
|
- 스프링 시큐리티와 같은 보안 프레임워크를 확장해서 개별 도메인 객체 수준의 권한 검사 기능을 프레임워크에 통합 할 수도 있을 것이다.
- 하지만, 이해가 높지 않아 프레임워크 확장을 우너하는 수준으로 할 수 없다면 프레임워크를 사용하는 대신 도메인에 맞는 권한 검사 기능을 구현하는 것이 코드 유지보수에 유리할 수 있다.
조회 전용 기능과 응용 서비스
- 서비스 코드가 다음과 같이 단순한 조회 전용 기능을 호출하는 것으로 끝나는 경우가 많다.
1
2
3
4
5
| public class OrderListService {
public List<OrderView> getOrderList(String ordererId) {
return orderViewDao.selectByOrderer(ordererId);
}
}
|
- 이런 경우라면 굳이 서비스를 만들 필요 없이 표현 영역에서 바로 조회 전용 기능을 사용해도 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| public class OrderController {
private OrderViewDao orderViewDao;
@RequestMapping("/myorders")
public String list(ModelMap model) {
String ordererId = SecurityContext.getAuthentication().getId();
List<OrderView> orders = orderViewDao.selectByOrderer(ordererId);
model.addAttribute("orders", orders);
return "order/list";
}
...
}
|
Reference
Chapter7-도메인 서비스
여러 애그리거트가 필요한 기능(도메인 서비스가 필요한 이유)
- 도메인 영여그이 코드를 작성하다보면 한 애그리거트로 기능을 구현할 수 없을 때가 있다.
- 대표적인 예가, 결제 금액 계산 로직인데 아래와 같은 사항들을 고려볼 수 있다.
상품 애그리거트
: 구매하는 상품의 가격. 또는 상품에 따라 추가되는 배송비주문 애그리거트
: 상품별 구매 개수할인 쿠폰 애그리거트
: 쿠폰별 지정된 할인 금액, 비율과 중복 사용 여부 등의 조건회원 애그리거트
: 회원 등급에 따른 추가 할인
- 이런 경우 실제 결제 금액을 계산해야 하는 주체는 어떤 애그리거트일까?
- 총 주문 금액을 계산하는 것은 주문 애그리거트가 할 수 있지만 실제 결제 금액은 이야기가 다르다. 총 주문 금액에서 할인 금액을 계산해야 한다.
- 그렇다고 할인 쿠폰 규칙을 갖고 있는 할인 쿠폰 애그리거트에서 계산한다면 할인 쿠폰을 두 개 이상 적용할 때 단일 할인 쿠폰 애그리거트로는 총 결제 금액을 계산할 수 없다.
- 생각해 볼 수 있는 방법은 주문 애그리거트가 필요한 애그리거트나 필요 데이터를 모두 가지도록 한 뒤 할인 금액 계산 책임을 주문 애그리거트에 할당하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public class Order {
...
private Orderer orderer;
private List<OrderLine> orderLines;
private List<Coupon> usedCoupons;
private Money calculatePayAmounts() {
// 총 지불 금액 계산 로직
...
}
private Money calculateDiscount(Coupon coupon) {
// 쿠폰에 따른 할인 금액 계산 로직
...
}
private Money calculateDiscount(MemberGrade grade) {
// 회원 등급에 따른 할인 금액 계산 로직
...
}
}
|
- 여기서 고민거리는 결제 금액 계산 로직이 주문 애그리거트의 책임이 맞느냐에 대한 것이다.
- 예를 들어, 특별 감사 세일로 전 품목에 한 달간 5% 추가 할인을 하기로 했을 때, 이 할인 정책은 주문 애그리거트의 구성요소와는 관련이 없음에도 불구하고 결제 금액 계산 책임이 주문 애그리거트에 있다는 이유로 주문 애그리거트 코드를 수정해야 한다.
- 이렇게 한 애그리거트에 넣기 애매한 도메인 기능을 특정 애그리거트에서 억지로 구현하면 안된다.
- 이 경우 애그리거트는 자신의 책임 범위를 넘어서는 기능을 구현하기 때문에 코드가 길어지고 외부에 대한 의존이 높아지게 된다.
- 이는 코드를 복잡하게 만들어 수정을 어렵게 만드는 요인이 되며 게다가 애그리거트의 범위를 넘어서는 도메인 개념이 애그리거트에 숨어들어서 명시적으로 드러나지 않게 된다.
- 이때 가장 쉬운 해결책이
도메인 서비스
를별도로 구현하는 것이다.
도메인 서비스
- 응용 서비스가 응용 로직을 다룬다면 도메인 서비스는 도메인 로직을 다룬다.
- 도메인 서비스가 도메인 영역의 애그리거트나 밸류와 같은 다른 구성요소와 비교할 때 다른 점이 있다면 상태 없이 로직만 구현한다는 점이다.
할인 금액 계산
로직을 위한 도메인 서비스는 다음과 같이 도메인의 의미가 드러나는 용어를 타입과 메서드 이름으로 짓는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| public class DiscountCalculationService {
public Money calculateDiscountAmounts(
List<OrderLIne> orderLines,
List<Coupon> coupons,
MemberGrade grade) {
Money couponDiscount = coupons.stream()
.map(coupon -> calculateDiscount(coupon))
.reduce(Money(0), (v1, v2) -> v1.add(v2));
Money membershipDiscount = calculateDiscount(orderer.getMember().getGrade());
return couponDiscount.add(membershipDiscount);
}
private Money calculateDiscount(Coupon coupon) {
...
}
private Money calculateDiscount(MemberGrade grade) {
...
}
}
|
- 할인 계산 서비스를 사용하는 주체는 애그리거트가 될 수도 있고 응용 서비스가 될 수도 있다.
- 위 도메인 서비스를 주문 애그리거트에 전달하면 다음과 같은 형태가 된다. 이 경우 사용하는 주체는 애그리거트가 된다.
1
2
3
4
5
6
7
8
| public class Order {
public void calculateAmounts(
DiscountCalculationService disCalSvc, MemberGrade grade) {
Money totalAmounts = getTotalAmounts();
Money discountAmounts = disCalSvc.calculateDiscountAmounts(this.orderLInes, this.coupons, greade);
this.paymentAmounts = totalAmounts.minus(discountAmounts);
}
...
|
- 애그리거트 객체에 도메인 서비스를 전달하는 것은 응용 서비스의 책임이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| public class OrderService {
private DiscountCalculationService discountCalculationService;
@Transactional
public OrderNo placeOrder(OrderRequest orderRequest) {
OrderNo orderno = orderRepository.nextId();
Order order = createOrder(orderNo, orderRequest);
orderRepository.save(order);
// 응용 서비스 실행 후 표현 영역에서 필요한 값 리턴
return orderNo;
}
private Order createOrder(OrderNo orderNo, OrderRequest orderReq) {
Member member =findMember(orderReq.getOrdererId());
Order order = new Order(orderNo, orderReq.gerOrderLines(),
orderReq.getCoupons(), createOrderer(member),
orderReq.getShippingInfo());
order.calculateAmounts(this.discountCalculationService, member.getGrade());
return order;
}
...
}
|
- 위와 반대로 도메인 서비스의 기능을 실행할 떄 애그리거트를 전달하기도 한다.
- 이런식으로 동작하는 것중 하나가
계좌 이체 기능
이다. - 계좌 이체의 경우 두 계좌 애그리거트가 관여하는데 한 애그리거트는 금액을 출금하고 한 애그리거트는 금액을 입금한다.
- 이를 위한 도메인 서비스는 다음과 같이 구현할 수 있을 것이다.
1
2
3
4
5
6
7
| public class TransferService {
public void transfer(Account fromAcc, Account toAcc, Money amounts) {
fromAcc.withdraw(amounts);
toAcc.credit(amounts);
}
...
}
|
- 응용 서비스는 두 Account 애그리거트를 구한 뒤에 해당 도메인 영역의 Transfer-Service를 이용해 계좌 이체 도메인의 기능을 실행할 것이다.
- 도메인 서비스는 도메인 로직을 수행하지 응용 로직을 수행하지는 않는다.
- 트랜잭션 처리와 같은 로직은 응용로직이므로 도메인 서비스가 아닌 응용 서비스에서 처리한다.
도메인 서비스 객체를 애그리거트에 주입하지 않기(저자 개인 생각)
- 애그리거트의 메서드를 실행할 때 도메인 서비스 객체를 파라미터로 전달한다는 것은 애그리거트가 도메인 서비스에 의존한다는 것을 뜻한다.
- 스프링의 DI 와 AOP를 공부하다 보면 애그리거트가 의존하는 도메인 서비스를 의존 주입으로 처리하고 싶어질 수 있는데 이는 저자 개인적으로 좋은 방법이 아니라 생각한다.
1
2
3
4
5
6
| public class Order {
@Autowired
private DiscountCalculationService discountCalculationService;
...
}
|
- 도메인 모델의 데이터를 담는 필드는 모델에서 중요한 구성요소이다.
- 그런데, discountCalculationSErvice 필드는 데이터 자체와는 관련이 없으며 Order 객체를 DB 에 보관할 때 다른 필드와는 달리 저장 대상도 아니다.
- 또 Order가 제공하는 모든 기능에서 discountCalculationService 를 필요로 하는 것도 아니다. 일부 기능만 필요로 한다.
- 일부 기능을 위해 굳이 도메인 서비스 객체를 애그리거트에 의존 주입할 이유는 없다.
- 이는 프레임워크의 기능을 사용하고 싶은 개발자의 욕심을 채우는 것에 불과하다.
Note: 특정 기능이 응용 서비스인지 도메인 서비스인지 감을 잡기 어려울땐 해당 로직이 애그리거트의 상태를 변경하거나 애그리거트의 상태 값을 계산하는지 검사해보면 된다. 예를 들어, 계좌 이체 로직은 계좌 애그리거트의 상태를 변경한다. 결제 금액 로직은 주문 애그리거트의 주문 금액을 계산한다. 이 두 로직은 각각 애그리거트를 변경하고 애그리거트의 값을 계산하는 도메인 로직이다. 도메인 로직이면서 한 애그리거트에 넣기 적합하지 않으므로 이 두 로직은 도메인 서비스로 구현하게 된다.
도메인 서비스의 패키지 위치
- 도메인 서비스는 도메인 로직을 실행하므로 도메인 서비스의 위치는 다른 도메인 구성요소와 동일한 패키지에 위치한다.
- 예를 들어, 주문 금액 계산을 위한 도메인 서비스는 아래 이미지와 같이 주문 애그리거트와 동일 패키지에 위치한다.
- 도메인 서비스의 개수가 많거나 엔티티나 밸류와 같은 다른 구성요소와 명시적으로 구분하고 싶다면 domain 패키지 밑에 domain.moel, domain.service, domain.repository 와 같이 하위 패키지를 구분해서 위치시켜도 된다.
도메인 서비스의 인터페이스와 클래스
- 도메인 서비스의 로직이 고정되어 있지 않은 경우 도메인 서비스 자체를 인터페이스로 구현하고 이를 구현한 클래스를 둘 수도 있따.
- 특히 도메인 로직을 외부 시스템이나 별도 엔진을 이용해서 구현해야 할 경우에 인터페이스와 클래스를 분리하게 된다.
- 예를 들어, 할인 금액 계산 로직을 룰 엔진을 이용해서 구현한다면 아래 이미지와 같이 도메인 영역에는 도메인 서비스 인터페이스가 위치하고 실제 구현은 인프라스트럭처 영역에 위치시킬 수 있다.
- 위 이미지와 같이 도메인 서비스의 구현이 특정 구현 기술에 의존적이거나 외부 시스템의 API를 실행한다면 도메인 영역의 도메인 서비스는 인터페이스로 추상화해야 한다.
- 이를 통해 도메인 영역이 특정 구현에 종속되는 것을 방지할 수 있고 도메인 영역에 대한 테스트가 수월해진다.
Reference
Chapter8-애그리거트 트랜잭션 관리
애그리거트와 트랜잭션
- 한 주문 애그리거트에 대해 운영자는 배송 상태로 변경할 때 사용자는 배송지 주소를 변경하면 어떻게 될까?
- 아래 이미지는 발생할 수 있는 다양한 경우 중 한 가지를 시간 순서로 표시한 것이다.
- 운영자 스레드와 고객 스레드는 개념적으로 동일한 애그리거트지만 물리적으로 서로 다른 애그리거트 객체를 사용한다.
- 때문에 운영자 스레드가 주문 애그리거트 객체를 배송 상태로 변경하더라도 고객 스레드가 사용하는 주문 애그리거트 객체엔 영향을 주지 않는다.
- 고객 스레드 입장에서 주문 애그리거트 객체는 아직 배송 상태 전이므로 배송지 정보를 변경 가능하다.
- 그렇기 때문에 애그리거트의 일관성이 깨지게 되는 것이다.
- 이런 문제가 발생하지 않도록 하려면 다음의 두 가지 중 하나를 해야 한다.
- 1)운영자가 배송지 정보를 조회하고 상태를 변경하는 동안 고객이 애그리거트를 수정하지 못하게 막는다.
- 2)운영자가 배송지 정보를 조회한 이후에 고객이 정보를 변경하면 운영자가 애그리거트를 다시 조회한 뒤 수정하도록 한다.
- 위 두 가지는 애그리거트 자체의 트랜잭션과 관련이 있다.
- DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다.
- 애그리거트에 대해 사용할 수 있는 대표적인 트랜잭션 처리 방식에는 선점(Pessimistic) 잠금과 비선점(Optimistic) 자금의 두 가지 방식이 있는데 이어서 살펴보자.
Note: Pessimistic Lock 과 Optimistic Lock을 비관적 잠금과 낙관적 잠금이라 많이 표현하는데 의미가 조금 더 가까운 선점 잠금과 비선점 잠금이란 용어를 저자는 사용하였다.
선점 잠금(Pessimistic Lock)
- 선점 잠금은 애그리거트를 먼저 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하는 것을 막는 방식이다.
- 스레드1이 선점 잠금 방식으로 애그리거트를 구한 후 스레드2는 스레드1인 애그리거트에 대한 잠금을 해제할 때 까지 블로킹된다.
- 이러한 선점 잠금을 사용하면, 한 스레드가 애그리거트를 구하고 수정하는 동안 다른 스레드가 수정할 수 없으므로 동시에 애그리거트를 수정할 때 발생하는 데이터 충돌 문제를 해소할 수 있다.
- 앞서 배송지 정보 수정과 배송 상태 변경을 동시에 하는 문제에 선점 잠금을 적용하면 아래 이미지와 같이 동작한다.
- 운영자 스레드가 먼저 선점 잠금 방식으로 주문 애그리거트를 구한 경우 운영자 스레드가 잠금을 해제할 때까지 고객 스레드는 대기 상태가 된다.
- 운영자 스레드가 배송 상태로 변경한 뒤 트랜잭션을 커밋하면 잠금을 해제한다.
- 잠금이 해제된 시점에 고객스레드가 구하는 주문 애그리거트는 운영자 스레드가 수정한 배송 상태의 주문 애그리거트이다.
- 배송 상태이므로 배송지 변경시 에러를 발생하고 트랜잭션은 실패하게 된다.
- 이 시점에 고객은 ‘이미 배송이 시작되어 배송지를 변경할 수 없습니다’ 와 같은 안내 문구를 보게 될 것이다.
- 선점 잠금은 보통 DBMS가 제공하는 행 단위 잠금을 사용해서 구현한다.
- 오라클을 비롯한 다수 DBMS가 for update 와 같은 쿼리를 사용해서 특정 레코드에 한 사용자만 접근할 수 있는 잠금 장치를 제공한다.
- JPA의 EntityManager 는 LockModeType 을 인자로 받는 find() 메서드를 제공하는데, LockModeType.PESSIMISTIC_WRITE를 값으로 전달하면 해당 엔티티와 매핑된 테이블을 이용해서 선점 잠금 방식을 적용할 수 있다.
1
| Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE;
|
- JPA 프로바이더와 DBMS에 따라 잠금 모드의 구현이 다른데, 하이버네이트의 경우
PESSIMISTIC_WRITE
를 잠금 모드로 사용하면 for update
쿼리를 사용해서 선점 잠금을 구현한다. - 스프링 데이터 JPA는
@Lock
어노테이션을 통해 잠금 모드를 지정한다.
1
2
3
4
5
6
7
8
9
| import org.springframework.data.jpa.repository.Lock;
import javax.persistence.LockModeType;
public interface MemberRepository extends Repository<Member, MemberId> {
@Lock(LockModeType.PESSIMITIC_WRITE
@Query("select m from Member m where m.id = :id")
Optional<Member> findByIdForUpdate(@Param("id") MemberId memberId);
}
|
선점 잠금과 교착 상태
- 선점 잠금 기능을 사용할 땐 잠금 순서에 따른 교착 상태(deadlock)가 발생하지 않도록 주의해야 한다.
- 예를 들어, 다음과 같은 수서로 두 스레드가 잠금 시도를 한다 해보자.
1
2
3
4
| 1. 스레드1: A 애그리거트에 대한 선점 잠금 구함
2. 스레드2: B 애그리거트에 대한 선점 잠금 구함
3. 스레드1: B 애그리거트에 대한 선점 잠금 시도
4. 스레드2: A 애그리거트에 대한 선점 잠금 시도
|
- 위 순서에 따르면 스레드1, 2 모두 영원히 B, A 애그리거트에 대한 선점 잠금을 구할 수 없다.
- 두 스레드는 상대방 스레드가 먼저 선점한 잠금을 구할수 없어 더 이상 다음 단계를 진행하지 못하게 되어 교착상태에 빠지게 된다.
- 선점 잠금에 따른 교착 상태는 상대적으로 사용자 수가 많을 때 발생할 가능성이 높고, 사용자수가 많아지면 교착 상태에 빠지는 스레드는 더 빠르게 증가한다.
- 이런 문제가 발생하지 않도록 하려면 잠금을 구할 때 최대 대기 시간을 지정해야 한다. JPA에서 선점 잠금을 시도할 떄 최대 대기 시간을 지정하려면 다음과 같이 힌트를 사용한다.
1
2
3
| Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.lock.timeout", 2000);
Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, hints);
|
- ‘javax.persistence.lock.timeout’ 힌트는 잠금을 구하는 대기 시간을 밀리초 단위로 지정한다.
- 지정한 시간 이내에 잠금을 구하지 못하면 익셉션을 발생시킨다.
- 이 힌트를 사용할 때 주의할 점은 DBMS에 따라 힌트가 적용되지 않을 수도 있따는 걳이다.
- 힌트를 이용할 땐 사용중인 DBMS가 관련 기능을 지원하는지 확인해야 한다.
- 스프링 데이터 JPA 는
@QueryHints
어노테이션을 사용해서 쿼리 힌트를 지정할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
| import org.sprintframework.data.jpa.repository.QueryHints;
import javax.persistence.QueryHint;
public interface MemberRepository extends Repository<Member, MemberId> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
@QueryHint(name = "javax.persistence.lock.timeout", value = "2000")
})
@Query("select m from Member m where m.id = :id")
Optional<Member> findByIdForUpdate(@Param("id") MemberId memberId);
}
|
Note: DBMS에 따라 교착 상태인 커넥션을 처리하는 방식이 다르니(쿼리 /커넥션별 대기 시간 지정) 선점 잠금 사용시 사용하는 DBMS에 대해 JPA가 어떤 식으로 대기 시간을 처리하는지 반드시 확인해야 한다.
비선점 잠금
- 선점 잠금이 강력해9 보이긴 하지만 모든 트랜잭션 충돌 문제가 해결되는 것은 아니다.
1
2
3
4
| 1. 운영자는 배송을 위해 주문 정보를 조회한다. 시스템은 정보를 제공한다.
2. 고객이 배송지 변경을 위해 변경 폼을 요청한다. 시스템은 변경 폼을 제공한다.
3. 고객이 새로운 배송지를 입력 후 폼을 전송하여 배송지를 변경한다.
4. 운영자가 1번에서 조회한 주문 정보를 기준으로 배송지를 정하고 배송 상태 변경을 요청한다.
|
- 여기서 문제는 운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경한다는 것이다.
- 운영자는 고객이 변경하기 전 배송지 정보를 이용하여 배송 준비를 한 뒤에 배송 상태로 변경하데 된다.
- 즉, 배송 상태 변경 전에 배송지를 한 번 더 확인하지 않으면 운영자는 다른 배송지로 물건을 발송하게 되고, 고객은 배송지를 변경했음에도 불구하고 엉뚱한 곳으로 주문한 물건을 받는 상황이 발생한다.
- 이 문제는 선점 잠금 방식으로는 해결할 수 없다. 이때 필요한 것이
비선점 잠금
이다.- 비선점 잠금은 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능여부를 확인하는 방식이다.
- 비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다.
- 애그리거트를 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가하는데 이떄 다음과 같은 쿼리를 사용한다.
1
2
| UPDATE aggtable SET version = version + 1, colx = ?, coly = ?
WHERE aggid = ? and version = 현재 버젼
|
- 이 쿼리는 수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정한다.
- 그리고 수정에 성공하면 버전 값을 1 증가시킨다.
- 다른 트랜잭션이 먼저 데이터를 수정해서 버전 값이 바뀌면 데이터 수정에 실패하게 된다.
- 스레드1과 2는 같은 버전을 갖는 애그리거트를 읽어와 수정하는데 스레드1이 먼저 수정 및 커밋 후 스레드2가 수정을 하게 되면 버전이 달라지기 때문에 데이터 수정에 실패하게 된다.
- JPA는 버전을 이용한 비선점 잠금 기능을 지원한다.
- 다음과 같이 버전으로 사용할 필드에
@Version
어노테이션을 붙이고 매핑되는 에티읍ㄹ에 버전을 젖아할 칼럼을 추가하면 된다.
1
2
3
4
5
6
7
8
9
10
11
12
| @Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
@EmbeddedId
private OrderNo number;
@Version
private long version;
...
}
|
- JPA는 엔티티가 변경되어 update 쿼리를 날릴 때
@Version
에 명시한 필드를 이용해서 비선점 잠금 쿼리를 실행한다. - 즉, 애그리거트 객체의 버전이 10일 경우 update 쿼리를 실행할 때 아래와 같은 쿼리를 사용해서 버전이 일치하는 경우에만 데이터를 수정한다.
1
2
| UPDATE purchagse_order SET ...생략, version = version + 1
WHERE number = ? and version = 10
|
- 응용 서비스는 버전에 대해 알 필요가 없다.
- 기능 실행 과정에서 애그리거트 데이터가 변경되면 JPA는 트랜잭션 종료 시점에 비선점 잠금을 위한 쿼리를 실행한다.
- 비선점 잠금을 위한 쿼리를 실행할 때 쿼리 실행 결과로 수정된 행의 개수가 0이면 이미 누군가 앞서 데이터를 수정한 것이다.
- 이는 트랜잭션이 충돌한 것이므로 트랜잭션 종료 시점에 익셉션이 발생한다. (OptimisticLockingFailureException)
- 표현 영역의 코드는 이 익셉션의 발생 여부에 따라 트랜잭션 충돌이 일어났는지 확인할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| @Controller
public class OrderController {
...
@RequestMapping(value = "/changeShipping", method = RequestMethod.POST)
public String changeShipping(ChangeShippingRequest changeReq) {
try {
changeShippingService.changeShipping(changeReq);
return "changeShippingSuccess";
} catch(optimisticLockingFailureException ex) {
// 누군가 먼저 같은 주문 애그리거트를 수정했으므로,
// 트랜잭션 충돌이 일어났다는 메시지를 보여준다.
return "changeShippingExConflic";
}
}
|
- 시스템은 사용자에게 수정 폼을 제공시에 애그리거트 버전을 함께 제공하고, 폼을 서버에 전송시 이 버전을 함께 전송한다.
- 이때 사용자가 전송한 버전과 애그리거트 버전이 동일한 경우에만 수정 기능을 실행하도록 하여 트랜잭션 충돌문제를 해소할 수 있다.
- 비선점 잠금 방식을 여러 트랜잭션으로 확장하려면 애그리거트 정보를 뷰로 보여줄 때 버전 정보도 함께 사용자 화면에 전달해야 한다.
- HTML 폼을 생성하는 경우 버전 값을 갖는 hidden 타입
<input>
태그를 생성해서 폼 전송시 버전 값이 서버에 함께 전달되도록 한다.
1
2
3
4
5
6
7
| <!- 애그리거트 정보를 보여줄 땐 뷰 코드는 버전 값을 함꼐 전송한다. ->
<form th:action="@{startShipping}" method="post">
<input type="hidden" name="version" th:value="${orderDto.version}">
<input type="hidden" name="orderNumber" th:value="${orderDto.orderNumber}">
...
<input type="submit" value="배송 상태로 변경하기">
</form>
|
- 응용 서비스에 전달할 요청 데이터는 사용자가 전송한 버전값을 포함한다. 예를 들어, 배송 상태 변경을 처리하는 응용 서비스가 전달받는 데이터는 다음과 같이 주문 번호와 함께 해당 주문을 조회한 시점의 버전 값을 포함해야 한다.
1
2
3
4
5
6
7
| public class StartShippingRequest {
private String orderNumber;
private long version;
...생성자, getter
}
|
- 응용 서비스는 전달받은 버전 값을 이용해서 애그리거트의 버전과 일치하는지 확인하고 일치하는 경우에만 요청한 기능을 수행한다.
1
2
3
4
5
6
7
8
9
10
11
12
| public class StartShippingService {
@PreAuthorize("hasRole('ADMIN')")
@Transactional
public void startShipping(StartShippingRequest req) {
Order order = orderRepository.findById(new OrderNo(req.getOrderNumber()));
checkOrder(order);
if (!order.matchVersion(req.getVersion())) {
throw new VersionConfilictException();
}
order.startShipping();
}
}
|
- matchVersion 메서드는 현재 애그리거트 버전과 인자로 전달받은 버전의 결과에 따라 분기를 태운다.
- 만약 다를 경우 버전이 충돌했다는 익셉션을 발생시켜 표현 계층에 이를 알린다.
- 표현 계층은 버전 충돌 익셉션이 발생하면 버전 충돌을 사용자에게 알려주고 사용자가 알맞은 후속 처리를 할 수 있도록 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| @Controller
public class OrderAdminController {
private StartShippingService startShippingService;
@RequestMapping(value = "/startShipping", method = RequestMethod.POST)
public String startShipping(StartShippingRequest startReq) {
try {
startShippingService.startShipping(startReq);
return "shippingStarted";
} catch(OptimisticLockingFailureException | VersionConflicException ex) {
// 트랜잭션 충돌
return "startShippingTxConflict";
}
}
...
|
- 위 코드는 비선점 잠금과 관련해서 발생하는 두 개의 익셉션을 처리하고 있다.
- 하나는 스프링 프레임워크가 발생시키는
OptimisticLockingFailureException
이고 다른 하나는 으용 서비스 코드에서 발생시키는 VersionConfilctException
이다. - 이 두 익셉션은 개발자 입장에선 트랜잭션 충돌이 발생한 신점을 명확히 구분한다.
VersionConfilctException
은 이미 누군가 애그리거트를 수정했다는 것을 의미OptimisticLockingFailureException
은 누군가가 거의 동시에 애그리거트를 수정했따는 것을 의미
- 버전 충돌 상황에 대한 구분이 명시적으로 필요없다면 응용 서비스에서 프레임워크용 익셉션을 발생시키는 것도 고려 할 수 있다.
1
2
3
4
5
6
7
8
9
| public void startShipping(StartShippingRequest req) {
Order order = orderRepository.findById(new OrderNo(req.getOrderNumber()));
checkOrder(order);
if (!order.matchVersion(req.getVersion())) {
// 프레임워크가 제공하는 비선점 트랜잭션 충돌 관련 익셉션 사용
throw new OptimisticLockingFailureException("version conflict);
}
order.startShipping();
}
|
강제 버전 증가
- 애그리거트 루트 외에 다른 엔티티의 값만 변경된다면 이 경우 JPA는 루트 엔티티 버전값을 증가 시키지 않는다.
- 애그리거트 관점에서 보면 애그리거트가 바뀐 것이기 때문에 버전 값을 증가시켜야 비선점 잠금이 올바르게 동작한다.
- JPA는
LockModeType.OPTIMISTIC_FORCE_INCREMENT
를 사용해 엔티티를 구할때 강제로 버전 값을 증가시키는 잠금 모드를 지원하고 있다.- 이를 통해 해당 엔티티의 상태 변경 여부와 상관 없이 트랜잭션 종료 시점에 버전 값 증가처리를 강제 할 수 있다.
- 이 잠금 모들르 사용시 애그리거트 루트 엔티티가 아닌 다른 엔티티나 밸류가 변경되더라도 버전 값을 증가시킬 수 있으므로 비선점 잠금 기능을 안전하게 적용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
| @Repository
public class JpaOrderRepository implements OrderRepository {
@PersistenceContext
private EntityMangager entityManager;
@Override
public Order findbyIdOptimisticLockMode(OrderNo id) {
return entityManager.find(Order.class, id
LockModeType.OPTIMISTTIC_FORCE_INCREMENT);
}
...
|
- 스프링 데이터 JPA를 사용하면 앞서 살펴본 @Lock 어노테이션을 이용해서 지정하면 된다.
오프라인 선점 잠금
- 오프라인 선점 잠금을 사용하면 위 이미지의 과정1처럼 폼 요청 과정에서 잠금을 선점하고, 과정3처럼 수정 과정에서 잠금을 해제한다.
- 이미 잠금을 선점한 상태에서 다른 사용자가 폼을 요청하면 과정2처럼 잠금을 구할 수 없어 에러 화면을 보게 된다.
잠금 유효 시간의 필요성
- 만약 사용자A가 과정3의 수정 요청을 수행하지 않고 프로그램을 종료하면 어떻게 될까?
- 이 경우 잠금을 해제하지 않으므로 다른 사용자는 영원히 잠금을 구할 수 없는 상황이 발생한다.
- 이런 사태를 방지하기 위해 오프라인 선점 방식은 잠금 유효시간을 가져야 한다.
- 유효시간이 지나면 자동으로 잠금을 해제해서 다른 사용자가 잠금을 일정 시간 후에 다시 구할수 있도록 해야 한다.
- 사용자A가 잠금 유효시간이 지난 후 1초 뒤에 3번 과정을 수행했다고 가정하자. 잠금이 해제되어 사용자 A는 수정에 실패하게 된다.
- 이런 상황을 만들지 않으려면 일정 주기로 유효 시간을 증가시키는 방식이 필요하다.
- 예를 들어, 수정 폼에서 1분 단위로 Ajax 호출을 해서 잠금 유효 시간을 1분씩 증가시키는 방법이 있다.
오프라인 선점 잠금을 위한 LockManager 인터페이스와 관련 클래스
- 오프라인 선점 잠금은 크게
잠금 선점 시도
, 잠금 확인
, 잠금 해제
, 잠금 유효시간 연장
의 네 가지 기능이 필요하다. - 이 기능을 위한 LockManager 인터페이스는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
| // LockManager 인터페이스
public interface LockManager {
LockId tryLock(String type, String id) throws LockException;
void checkLock(LockId lockId) throws LockException;
void releaseLock(LockId lockId) throws LockException;
void extendLockExpiration(LockId lockId, long inc) throws LockException;
}
|
- tryLock 메서드는 rtype과 id 를 파라미터로 갖는다. 이 두 파라미터엔 각각 잠글 대상 타입과 식별자를 값으로 전달하며 된다.
- 예를 들어 식별자가 10인 Article 에 대한 잠금을 구하고자 한다면 tryLock 메서드를 실행시 ‘domain.Article’을 type으로 주고 ‘10’을 id값으로 주면 된다.
- 잠금을 식별할 때 사용하는 LockId는 아래와 같다.
1
2
3
4
5
6
7
8
9
10
11
12
| // LockId 클래스
public class LockId {
private String value;
public LockId(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
|
- 컨트롤러가 오프라인 선점 잠금 기능을 이용해서 데이터 수정 폼에 동시에 접근하는 것을 제어하는 코드는 아래와 같다.
- 수정 폼에서 데이터를 전송할 때 LockId를 전송할 수 있도록 LockId를 모델에 추가했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // 서비스: 서비스는 잠금 ID를 리턴한다.
public DataAndLockId getDataWithLock(Long id) {
// 1. 오프라인 선점 잠금 시도
LockId locakId = locakManager.tryLock("data", id);
// 2. 기능 실행
Data data = someDao.select(id);
return new DataAndLocakId(data, lockId);
}
// 컨트롤러: 서비스가 리턴한 잠금ID를 모델로 뷰에 전달
@RequestMapping("/some/edit/{id}")
public String editForm(@PathVariable("id") Long id, ModelMap model) {
DataAndLockId dl = dataService.getDataWithLock(id);
model.addAttribute("data", dl.getData());
// 3. 잠금 해제에 사용할 LockId를 모델에 추가
model.addAttribute("lockId", dl.getLockId());
return "editForm"
}
|
- 잠금을 선점하는데 실패하면 LockException이 발생한다.
- 이때 다른 사용자가 데이터를 수정 중이니 나중에 다시 시도하라는 안내 화면을 보여주면 된다.
- 수정 폼은 LockId 를 다시 전송해서 잠금을 해제할 수 있도록 한다.
1
2
3
4
5
| <form th:action="@{/some/edit/{id}(id=${data.id})}" method="post">
...
<input type="hidden" name="lid" th:value="${locakId.value}">
...
</form>
|
- 잠금을 해제하는 코드는 다음과 같이 전달받은 LockId를 이용한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // 서비스: 잠금을 해제한다.
public void edit(EditRequest editReq, LockId lockId) {
// 1. 잠금 선점 확인
lockManager.checkLock(lockId);
// 2. 기능 실행
...
// 3. 잠금 해제
lockManager.releaseLock(lockId);
}
@RequestMapping(value = "/some/edit/{id}", method = RequestMethod.POST)
public String edit(@PathVariable("id") Long id,
@ModelAttribute("editReq") EditRequest editReq,
@RequestMapping("lid") String lockIdValue) {
editReq.setId(id);
someEditService.edit(editReq, new LockId(lockIdValue));
model.addAttribute("data", data);
return "editSuccess";
}
|
- 서비스 코드를 보면 checkLock 메서드를 가장 먼저 실행하는데, 잠금을 선점한 이후에 실행하는 기능은 다음과 같은 상황을 고려하여 반드시 주어진 LockId를 갖는 잠금이 유효한지 확인해야 한다.
- 잠금 유효시간이 지났으면 이미 다른 사용자가 잠금을 선점한다.
- 잠금을 선점하지 않은 사용자가 기능을 실행했따면 기능 실행을 막아야 한다.
DB 를 이용한 LockManager 구현
- 잠금 정보를 저장하기 위한 테이블 생성
- 타입과 아이디를 기본키로 지정해 동시에 두 사용자가 특정 타입 데이터 잠금 구하는 것을 방지
- 각 잠금마다 새로운 LockId를 사용하므로 lockId 컬럼은 유니크 인덱스로 설정
1
2
3
4
5
6
7
8
9
| create table locks (
`type` varchar(255),
id varchar(255),
lockid varchar(255),
expiration_time datetime,
primary key (`type`, id)
) character set utf8;
create unique index locks_idx ON locks (lockid);
|
- Order 타입의 1번 식별자를 갖는 애그리거트에 대한 잠금을 구하고 싶다면 다음의 insert 쿼리로 locks 테이블에 데이터 삽입
1
| insert into locks values ('Order', '1', '생성한lockid', '2016-03-28 09:10:00');
|
- locks 테이블의 데이터를 담을 LockData 클래스 생성
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
| public class LockData {
private String type;
private String id;
private String lockId;
private long timestamp;
public LockData(String type, String id, String lockId, long timestamp) {
this.type = type;
this.id = id;
this.lockId = lockId;
this.timestamp = timestamp;
}
public String getType() {
return type;
}
public String getId() {
return id;
}
public String getLockId() {
return lockId;
}
public long getTimestamp() {
return timestamp;
}
// 유효 시간이 지났는지를 판단할 때 사용
public boolean isExpired() {
return timestamp < System.currentTimeMillis();
}
}
|
- locks 테이블을 이용해서 LockManager를 구현
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
| @Component
public class SpringLockManager implements LockManager {
private int lockTimeout = 5 * 60 * 1000;
private JdbcTemplate jdbcTemplate;
private RowMapper<LockData> lockDataRowMapper = (rs, rowNum) ->
new LockData(rs.getString(1), rs.getString(2),
rs.getString(3), rs.getTimestamp(4).getTime());
public SpringLockManager(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
/**
* type과 id에 대한 잠금 시도
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public LockId tryLock(String type, String id) throws LockException {
checkAlreadyLocked(type, id);
LockId lockId = new LockId(UUID.randomUUID().toString());
locking(type, id, lockId);
return lockId;
}
/**
* type과 id에 대한 잠금이 존재하는지 검사
*/
private void checkAlreadyLocked(String type, String id) {
List<LockData> locks = jdbcTemplate.query(
"select * from locks where type = ? and id = ?",
lockDataRowMapper, type, id);
Optional<LockData> lockData = handleExpiration(locks);
if (lockData.isPresent()) throw new AlreadyLockedException();
}
/**
* 유효시간이 지난 락인지 아닌지 판단 후 유효시간 내 락이면 리턴
*/
private Optional<LockData> handleExpiration(List<LockData> locks) {
if (locks.isEmpty()) return Optional.empty();
LockData lockData = locks.get(0);
if (lockData.isExpired()) {
jdbcTemplate.update(
"delete from locks where type = ? and id = ?",
lockData.getType(), lockData.getId());
return Optional.empty();
} else {
return Optional.of(lockData);
}
}
/**
* locks 테이블에 잠금 데이터를 삽입
* 데이터 삽입 결과가 없거나 DuplicateKeyException이 발생하면 LockingFailException을 익셉션을 발생시킨다
*/
private void locking(String type, String id, LockId lockId) {
try {
int updatedCount = jdbcTemplate.update(
"insert into locks values (?, ?, ?, ?)",
type, id, lockId.getValue(), new Timestamp(getExpirationTime()));
if (updatedCount == 0) throw new LockingFailException();
} catch (DuplicateKeyException e) {
throw new LockingFailException(e);
}
}
/**
* 잠금 유효 시간 생성
*/
private long getExpirationTime() {
return System.currentTimeMillis() + lockTimeout;
}
/**
* 잠금이 유효한지 검사
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public void checkLock(LockId lockId) throws LockException {
Optional<LockData> lockData = getLockData(lockId);
if (!lockData.isPresent()) throw new NoLockException();
}
/**
* lockId에 해당하는 LockData 조회
*/
private Optional<LockData> getLockData(LockId lockId) {
List<LockData> locks = jdbcTemplate.query(
"select * from locks where lockid = ?",
lockDataRowMapper, lockId.getValue());
return handleExpiration(locks);
}
/**
* lockId에 해당하는 잠금 유효 시간을 inc 만큼 연장
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public void extendLockExpiration(LockId lockId, long inc) throws LockException {
Optional<LockData> lockDataOpt = getLockData(lockId);
LockData lockData =
lockDataOpt.orElseThrow(() -> new NoLockException());
jdbcTemplate.update(
"update locks set expiration_time = ? where type = ? AND id = ?",
new Timestamp(lockData.getTimestamp() + inc),
lockData.getType(), lockData.getId());
}
/**
* lockId에 해당하는 잠금 데이터를 locks 테이블에서 삭제
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Override
public void releaseLock(LockId lockId) throws LockException {
jdbcTemplate.update("delete from locks where lockid = ?", lockId.getValue());
}
public void setLockTimeout(int lockTimeout) {
this.lockTimeout = lockTimeout;
}
}
|
Reference
Chapter9-도메인 모델과 바운디드 컨텍스트
도메인 모델과 경계
이렇게 하위 도메인마다 같은 용어라도 의미가 다르고 같은 대상이라도 지칭하는 용어가 다를수 있기 떄문에 한개의 모델로 모든 하위 도메인을 표현하려는 시도는 올바른 방법이 아니며 표현할 수도 없다.
- 하위 도메인마다 사용하는 용어가 다르기 떄문에 올바른 도메인 모델을 개발하기 위해서라면 하위 도메인마다 모델을 만들어야 한다.
- 각 모델은 명시적으로 구분되는 경계를 가져서 섞이지 않도록 해야 한다.
여러 하위도메인의 모델이 섞이기 시작하면 모델의 의미가 약해질뿐 아니라 여러 도메인의 모델이 서로 얽히기 떄문에 각 하위 도메인 별로 다르게 발전하는 요구사항을 모델에 반영하기 어려워 진다.
- 모델은 특정한 컨텍스트(문맥) 하에 완전한 의미를 갖는다.
- 같은 제품이라도 카탈로그 컨텍스트와 재고 컨텍스트에서 의미가 서로 다르다.
- 이렇게 구분되는 경계를 갖는 컨텍스트를 DDD 에선 바운디드 컨텍스트 라고 부른다.
바운디드 컨텍스트
- 규모가 작은 기업은 전체 시스템을 한 개 팀에서 구현할 때도 있다.
- 예를 들어 소규모 쇼핑몰을 운영할 경우 한개의 웹 애플리케이션으로 온라인 쇼핑을 서비스한다.
- 이 경우 하나의 시스템에서 회원, 카탈로그, 재고, 구매, 결제와 관련된 기능을 제공 한다.
- 즉 여러 하위도메인을 한 개의 바운디드 컨텍스트에서 구현한다
- 여러 하위 도메인을 하나의 바운디드 컨텍스트에서 개발할때 주의할 점은 하위 도메인의 모델이 섞이지 않도록 하는것이다.
- 한 프로젝트에 각 하위 도메인의 모델이 위치하면 아무래도 전체 하위 도메인을 위한 단일 모델을 만들고 싶은 유혹에 빠지기 쉽다.
- 이런 유혹에 걸려들면 결과적으로 도메인 모델이 개별 하위 도메인을 제대로 반영하지 못해서 하위 도메인별 기능 확장이 어렵게 되고 이는 서비스의 경쟁력을 떨어뜨리는 원인이 된다.
- 따라서 비록 한개의 바운디드 컨텍스트에서 여러 하위 도메인을 포함하더라도 하위 도메인마다 구분되는 패키지를 갖도록 구현해야 하위 도메인을 위한 모델이 서로 뒤섞이지 않아서 하위 도메인마다 바운디드 컨텍스트를 갖는 효과를 낼 수 있다.
- 바운디드 컨텍스트는 도메인 모델을 구분하는 경계가 되기 때문에 바운디드 컨텍스트는 구현하는 하위 도메인에 알맞은 모델을 포함한다.
- 같은 사용자라 하더라도 주문 바운디드 컨텍스트와 회원 바운디드 컨텍스트가 갖는 모델이 달라진다.
- 또한 같은 상품이라도 카탈로그 바운디드 컨텍스트의 Product와 재고 바운디드 컨텍스트의 Product는 각 컨텍스트에 맞는 모델을 갖는다.
- 따라서 회원의 Member는 애그리것 루트이지만 주문의 Orderer는 밸류가 되고 카탈로그의 Product는 상품이 속할 Catergory와 연관을 갖지만 재고의 Product는 카탈로그의 Category와 연관을 맺지 않는다.
바운디드 컨텍스트 구현
- 바운디드 컨텍스트가 도메인 모델만 포함하는 것은 아니다.
- 도메인 기능을 사용자에게 제공하기 위해 표현 영역, 응용 서비스, 인프라 영역 등을 모두 포함한다.
- 도메인 모델의 데이터 구조가 바뀌면 DB 테이블 스키마도 함께 변경해야 하므로 해당 테이블도 바운디드 컨텍스트에 포함된다.
- 표현 영역은 HTML 페이지를 생성할 수도 있고, 다른 바운디드 컨텍스트를 위해 REST API 를 제공할 수도 있다.
- 모든 바운디드 컨텍스트를 반드시 도메인 주도로 개발할 필요는 없다. 상품의 리뷰는 복잡한 도메인 로직을 갖지 않기 때문에 CRUD 방식으로 구현해도 된다.
- 즉, DAO와 데이터 중심의 밸류 객체를 이용해서 리뷰 기능을 구현해도 기능을 유지보수하는데 큰 문제가 없다.
- 한 바운디드 컨텍스트에서 두 방식을 혼합해서 사용할 수도 있다.
- 대표적인 예가 CQRS 패턴이다.
- CQRS 는 Command Query Responsibility Segregation 의 약자로 상태를 변경하는 명령 기능과 내용을 조회하는 쿼리 기능을 위한 모델을 구분하는 패턴이다.
- 이 패턴을 단일 바운디드 컨텍스트에 적용하면 아래 이미지와 같이 상태 변경과 관련된 기능은 도메인 모델 기반으로 구현하고, 조회 기능은 서비스-DAO 를 이용해서 구현할 수 있다.
- 각 바운디드 컨텍스트는 서로 다른 구현 기술을 사용할 수도 있다.
- 예를 들어, 리포지터리 구현 기술을 JPA/하이버네이트&RDB 를 사용하는 바운디드 컨텍스트가 있을 수 있고, MyBatis&NoSQL 를 사용하는 바운디드 컨텍스트가 있을 수 있고..
- 바운디드 컨텍스트가 반드시 사용자에게 보여지는 UI 를 가지고 있어야 하는 것은 아니다.
- 웹 브라우저는 카탈로그 바운디드 컨텍스트를 통해 상세 정보를 읽어온 뒤, 리뷰 바운디드 컨텍스트의 REST API를 직접 호출해서 로딩한 JSON 데이터를 알맞게 가공해서 리뷰 목록을 보여줄 수도 있다.
- 또한 UI 를 처리하는 서버(BFF 와 같은 프록시 서버)를 두고 UI서버에서 바운디드 컨텍스트와 통신해서 사용자 요청을 처리하는 방법도 있다.
- 위 같은 구조에서 UI 서버는 각 바운디드 컨텍스트를 위한 파사드 역할을 수행한다. 브라우저가 UI서버에 요청을 보내면 UI 서버는 카탈로그와 리뷰 바운디드 컨텍스트로부터 필요한 정보를 읽어와 조합한 뒤 브라우저에 응답한다.
바운디드 컨텍스트 간 통합
- 온라인 쇼핑 사이트에서 매출 증대를 위해 카탈로그 하위 도메인에 개인화 추천 기능을 도입하기로 했다고 하자.
- 기존 카탈로그 시스템을 개발하던 팀과 별도로 추천 시스템을 담당하는 팀이 새로 생겨서 이 팀에서 주도적으로 추천 시스템을 만들기로 했다.
- 이렇게 되면 카탈로그 하위 도메인에는 기존 카탈로그를 위한 바운디드 컨텍스트와 추천 기능을 위한 바운디드 컨텍스트가 생긴다.
- 두 팀이 관련된 바운디드 컨텍스트를 개발하면 자연스럽게 두 바운디드 컨텍스트 간 통합이 발생한다.
- 카탈로그와 추천 바운디드 컨텍스트 간 통합이 필요한 기능은 다음과 같다.
사용자가 제품 상세 페이지를 볼 때, 보고 있는 상품과 유사한 상품 목록을 하단에 보여준다.
- 사용자가 카탈로그 바운디드 컨텍스트에 추천 제품 목록을 요청하면 카탈로그 바운디드 컨텍스트는 추천 바운디드 컨텍스트로부터 추천 정보를 읽어와 추천 제품 목록을 제공한다.
- 이때 카탈로그 컨텍스트와 추천 컨텍스트의 모델은 서로 다르다.
- 카탈로그는 제품을 중심으로 도메인 모델을 구현하지만 추천은 추천 연산을 위한 모델을 구현한다.
- 예를 들어 추천 시스템은 상품의 상세 정보를 포함하지 않으며 상품 번호 대신 아이템 ID라는 용어를 사용해서 식별자를 표현하고 추천 순위와 같은 데이터를 담게 된다.
- 카탈로그 시스템은 추천 시스템으로부터 추천 데이터를 받아오지만, 카탈로그 시스템에선 추천의 도메인 모델을 사용하기보단 카탈로그 도메인 모델을 사용해서 추천 상품을 표현해야 한다.
- 즉, 다음과 같이 카탈로그의 모델을 기반으로 하는
도메인 서비스
를 이용해서 상품 추천 기능을 표현해야 한다.
1
2
3
4
5
6
| /**
* 상품 추천 기능을 표현하는 도메인 서비스
*/
public interface ProductRecommendationService {
List<Product> getRecommendationOf(ProductId id);
}
|
- 도메인 서비스를 구현한 클래스는 인프라 스트럭처 영역에 위치한다.
- 이 클래스는 외부 시스템과의 연동을 처리하고 외부 시스템의 모델과 현재 도메인 모델 간의 변환을 책임진다.
- RecSystemClient는 외부 추천 시스템이 제공하는 REST API를 이용해서 특정 상품을 위한 추천 상품 목록을 로딩한다.
- 이 REST API가 제공하는 데이터는 추천 시스템의 모델을 기반으로 하기에 카탈로그 도메인 모델과 일치하지 않을 수 있다.
1
2
3
4
| {
{itemID: 'PROD-1000', type: 'PRODUCT', rank: 100},
{itemID: 'PROD-1001', type: 'PRODUCT', rank: 54}
}
|
- RecSystemClient가 REST API를 통해 데이터를 읽어와 카탈로그에 맞는 상품 모델로 변환한다. 아래는 해당 코드를 나타낸 것이다.
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
| public class RecSystemClient implements ProductRecommendationService {
private ProductRepository productRepository;
@Override
public List<Product> getRecommendationOf(ProductId id) {
List<Recommendation> items = getRecItems(id.getValue());
return toProducts(items);
}
private List<RecommendationItem> getRecItems(String itemId) {
// externalRecClient는 외부 추천 시스템을 위한 클라이언트라고 가정
return externalRecClient.getRecs(itemId);
}
private List<Product> toProducts(List<RecommendationItem> items) {
return items.stream()
.map(item -> toProductId(item.getItemId())
.map(prodId -> productRepository.findById(prodId))
.collect(toList());
}
private ProductId toProductId(String itemId) {
return new ProductId(itemId);
}
}
|
- getRecItems() 메서드에서 사용하는 externalRecClient는 외부 추천 시스템에 연결할 때 사용하는 클라이언트로서 팀에서 배포한 추천 시스템을 관리하는 모듈이라 가정하자.
- 이 모듈이 제공하는 RecommendationItem 은 추천 시스템의 모델을 따를텐데 RecSystemClient 는 추천 시스템의 모델을 받아와 toProducts() 메서드를 이용해서 카탈로그 도메인의 Product 모델로 변환하는 작업을 처리한다.
- 두 모델 간의 변환 과정이 복잡하면 변환 처리를 위한 별도 클래스를 만들고 이 클래스에서 변환을 처리해도 된다.
- Rest API를 호출하는 것은 두 바운디드 컨텍스트를 직접 통합하는 방법이다.
- 직접 통합하는 대신 간접적으로 통합하는 방법도 있다.
- 대표적인것이 메시지 큐(ex. kafka, rabiit mq)를 사용하는 것이다.
- 추천 시스템은 사용자가 조회한 상품 이력이나 구매 이력과 같은 사용자 활동 이력을 필요로 하는데 이 내역을 전달할 때 메시지 큐를 사용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| public class ViewLog {
private Long memberId;
private Long productId;
private ZonedDateTime timeStamp;
}
public class OrderLog {
private Long memberId;
private OrderNumber orderNumber;
private List<OrderLineLog> orderLineLogs;
private ZonedDateTime timeStamp;
private int totalAmounts;
}
public class OrderLineLog {
private Long memberId;
private Long productId;
private ZonedDateTime timeStamp;
}
|
- 만약 추천 바운디드 컨텍스트 관점에서 접근한다면 아래와 같이 메시지 데이터 구조를 잡을 수 있다.
1
2
3
4
5
6
| public class ActiveLog {
private Long itemId;
private Long userId;
private ActiveType activeType;
private ZonedDateTime actionDate;
}
|
- 어떤 도메인 관점에서 모델을 사용하느냐에 따라 두 바운디드 컨텍스트의 구현 코드가 달라지게 된다.
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
44
| // 1) 카탈로그 관점에서의 모델을 사용할 때의 구현 코드
// 상품 조회 관련 로그 기록 코드
public class ViewLogService {
private MessageClient mesasgeClient;
public void appendViewLog(String memberId, String productId, Date time) {
messageClient.send(new ViewLog(memberId, productId, time));
}
...
}
// messageClient
public class RabbitMQClient implements MessageClient {
private RabiitTemplate rabbitTemplate;
@Override
public void send(ViewLog viewLog) {
// 카탈로그 기준으로 작성한 데이터를 큐에 그대로 보관
rabbitTemplate.convertAndSend(logQueueName, viewLog);
}
}
// 2) 추천 시스템 관점에서의 모델을 사용할 때의 구현 코드
// 상품 조회 관련 로그 기록 코드
public class ViewLogService {
private MessageClient mesasgeClient;
public void appendViewLog(String memberId, String productId, Date time) {
messageClient.send(new ActivityLog(productId, memberId, ActivityType.VIEW, time));
}
...
}
// messageClient
public class RabbitMQClient implements MessageClient {
private RabiitTemplate rabbitTemplate;
@Override
public void send(ActivityLog activityLog) {
rabbitTemplate.convertAndSend(logQueueName, activityLog);
}
}
|
- 두 바운디드 컨텍스트를 개발하는 팀은 메시징 큐에 담을 데이터 구조를 협의하게 되는데 그 큐를 누가 제공하느냐에 따라 데이터 구조가 결정된다.
- 예를 들어, 카탈로그 시스템에서 큐를 제공한다면 큐에 담기는 내용은 카탈로그 도메인을 따른다.
- 카탈로그 도메인은 메시징 큐에 카탈로그 관련 메시지를 저장하게 되고 다른 바운디드 컨텍스트는 이 큐로부터 필요한 메시지를 수신하는 방식을 사용한다.
- 즉, 이 방식은 한쪽에서 메시지를 출한고 다른 쪽에서 메시지를 구독하는 출판/구독 모델을 따른다.(pub/sub 모델)
- 큐를 추천시스템에서 제공할 경우 큐를 통해 메시지를 추천 시스템에 전달하는 방식이 된다.
- 이 경우 큐로 인해 비동기로 추천 시스템에 데이터를 전달하는 것을 제외하면 추천 시스템이 제공하는 REST API를 사용해서 데이터를 전달하는 것과 차이가 없다.
마이크로서비스와 바운디드 컨텍스트: 마이크로서비스는 애플리케이션을 작은 서비스로 나누어 개발하는 아키텍처 스타일이다. 개별 서비스를 독립된 프로세스로 실행하고 각 서비스가 REST API나 메시징을 이용해서 통신하는 구조를 갖는다. 바운디드 컨텍스트를 마이크로서비스로 구현하면 자연스럽게 컨텍스트별로 모델이 분리된다.
마이크로서비스마다 프로젝트를 생성하므로 바운디드 컨텍스트마다 프로젝트를 만들게 된다. 이것은 코드 수준에서 모델을 분리하여 두 바운디드 컨텍스트의 모델이 섞이지 않도록 해준다.
바운디드 컨텍스트 간 관계
- 바운디드 컨텍스트는 어떤 식으로든 연결되기 떄문에 두 바운디드 컨텍스트는 다양한 방식으로 관계를 맺는다.
1) 한 쪽에서 API를 제공하고 다른 쪽에서 API를 호출하는 관계(가장 흔한 관계)
- REST API가 대표적이다.
- 이 관계에서 API를 사용하는 바운디드 컨텍스트는 API를 제공하는 바운디드 컨텍스트에 의존하게 된다.
- 위 이미지에서 하류 컴포넌트인 카탈로그 컨텍스트는 상류 컴포넌트인 추천 컨텍스트가 제공하는 데이터와 기능에 의존한다.
- 카탈로그는 추천 상품을 보여주기 위해 추천 바운디드 컨텍스트가 제공하는 REST API를 호출한다.
- 추천 시스템이 제공하는 REST API의 인터페이스가 바뀌면 카탈로그 시스템의 코드도 바뀌게 된다.
- 상류 컴포넌트는 일종의 서비스 공급자 역할을 하며 하류 컴포넌트는 그 서비스를 사용하는 고객 역할을 한다.
- 고객과 공급자 관계에 있는 두 팀은 상호 협력이 필수적이다.
- 공급자를 하는 상류팀이 마음대로 API를 변경하면 하류 팀은 변경된 API에 맞추느라 우선수위가 높은 다른 기능을 개발 못할 수 도 있다.
- 반대로 상류 팀이 무언가를 변경할때마다 하류 팀으로부터 여러 절차를 거쳐 승낙을 받아야 한다면 상류팀은 새로운 개발 시도 자체를 하지 않을 수도 있다.
- 따라서 상류 팀과 하류 팀은 개발 계획을 서로 공유하고 일정을 합의해야 한다.
- 상류팀의 고객인 하류 팀이 다수 존재하면 상류 팀은 여러 하류 팀의 요구사항을 수용할 수 있는 API를 만들고 이를 서비스 형태로 공개해서 서비스의 일관성을 유지할 수 있다.
- 이런 서비스를 가리켜
공개 호스트 서비스(OPEN HOST SERVICE)
라 한다. - 공개 호스트 서비스의 대표적인 예가 검색이다.
- 블로그, 카페, 게시판과 같은 서비스를 제공하는 포털은 각 서비스별로 검색 기능을 구현하기보단 검색을 위한 전용 시스템을 구축하고 검색 시스템과 각 서비스를 통합한다.
- 이때 검색 시스템은 상류 컴포넌트가 되고 블로그, 카페, 게시판은 하류 컴포넌트가 된다.
- 상류 팀은 각 하류 컴포넌트의 요구사항을 수용하는 단일 API를 만들어 이를 공개하고 각 하류 팀은 공개된 API를 사용해서 검색 기능을 구현한다.
- 상류 컴포넌트의 서비스는 상류 바운디드 컨텍스트의 도메인 모델을 따른다.
- 따라서 하류 컴포넌트는 상류 서비스의 모델이 자신의 도메인 모델에 영향을 주지 않도록 보호해주는 안충 지대를 만들어야 한다.
- 위 이미지에서 RecSystemClient 는 외부 시스템과의 연동을 처리하는데 외부 시스템의 도메인 모델이 내 도메인 모델을 침범하지 않도록 막아주는 역할을 한다.
- 즉, 내 모델이 깨지는 것을 막아주는 안티코럽션 계층이 된다.
- 이 계층에선 두 바운디드 컨텍스트 간의 모델 변환을 처리해주기 때문에 다른 바운디드 컨텍스트의 모델에 영향을 받지 않고 내 도메인 모델을 유지할 수 있다.
- 두 바운디드 컨텍스트가 같은 모델을 공유하는 경우도 있다.
- 예를 들어, 운영자를 위한 주문 관리 도구를 개발하는 팀과 고객을 위한 주문 서비스를 개발하는 팀..
- 두 팀이 공유하는 모델을
공유 커널
이라 한다. - 공유 커널의 장점은 중복을 줄여준다는 것이다.
- 공유 커널의 단점은 한 팀에서 임의로 모델을 변경시 다른 팀에 영향이 가기 때문에 두 팀이 밀접한 관계를 유지해야 한다.
- 두 팀이 밀접한 관계를 형성할 수 없다면 공유 커널을 사용할 때의 장점보다 공유 커널로 인해 개발이 지연되고 정체되는 문제가 더 커진다.
2) 독립 방식(SEPARATE WAY)
- 그냥 서로 통합하지 않는 방식으로 서로 독립적으로 모델을 발전시킨다.
- 두 바운디드 컨텍스트 간의 통합은 수동으로 이루어진다.
- 예를 들어 온라인 쇼핑몰 솔루션과 외부 ERP 서비스를 사용한다 했을때 이 둘은 연동을 지원하지 않으므로 온라인 쇼핑몰에서 판매가 발생하면 쇼핑몰 운영자는 쇼핑몰 시스템에서 판매 정보를 보고 ERP에 수동으로 입력해야 한다.
- 수동으로 통합하는 방식이 나쁜 것은 아니지만 규모가 커질수록 한계가 있으므로 규모가 커지기 시작하면 두 바운디드 컨텍스트를 통합해야 한다.
- 이때 외부에서 구매한 솔루션과 ERP를 완전히 대체할 수 없다면 두 바운디드 컨텍스트를 통합해주는 별도의 시스템을 만들어야 할 수도 있다.
컨텍스트 맵
- 개별 바운디드 컨텍스트에 매몰되면 전체를 보지 못할 때가 있다.
- 전체 비즈니스를 조망할 수 있는 지도가 필요한데 그것이 바로 컨텍스트 맵이다.
Note: 컨텍스트 맵은 전체 시스템의 이해 수준을 보여준다. 즉, 시스템을 더 잘 이해하거나 시간이 지나면서 컨텍스트간 관계가 바뀌면 컨텍스트 맵도 함께 바뀐다.
Reference
Chapter10-이벤트
10.1 시스템 간 강결합 문제
- 쇼핑몰에서 구매를 취소하면 환불을 처리해야 한다.
- 이때 환불 기능을 실행하는 주체는 주문 도메인 엔티티가 될 수 있따.
- 도메인 객체에서 환불 기능을 실행하려면 다음 코드처럼 환불 기능을 제공하는 도메인 서비스를 파라미터로 전달바독 취소 도메인 기능에서 도메인 서비스를 실행하게 된다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| public class Order {
...
// 외부 서비스를 실행하기 위해 도메인 서비스를 파라미터로 전달받음
public void cancel(RefundService refundService) {
verifyNotYetShipped();
this.state = OrderState.CANCELED;
this.refundStatus = State.REFUND_STARTED;
try {
refundService.refund(getPaymentId());
this.fefundStatus = State>REFUND_COMPLETED;
} catch (Exception ex) {
??
}
}
...
}
|
- 혹은 응용 서비스에서 환불 기능을 실행할 수도 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| public class CancelOrderService {
private RefundService refundService;
@Transactional
public void cancel(OrderNo orderNo) {
Order order = findOrder(orderNo);
order.cancel();
order.refundStarted();
try {
refundService.refund(order.getPaymentId());
order.refundCompleted();
} catch (Exception ex) {
???
}
}
...
}
|
- 보통 결제 시스템은 외부에 존재하므로 RefundService는 외부 결제 시스템이 제공하는 환불 서비스를 호출한다.
- 이때 세 가지 문제가 발생할 수 있다.
1) 트랜잭션 처리 범위 문제
- 외부 서비스가 정상이 아닐 경우 트랜잭션 처리를 어떻게 해야할지 애매하다는 것이다.
- 환불 기능을 실행하는 과정에서 익셉션이 발생하면 롤백해야 할까? 일단 커밋?
- 외부의 환불 서비스를 실행하는 과정에서 익셉션이 발생하면 환불에 실패했으므로 주문 취소 트랜잭션을 롤백하는 것이 맞아 보인다.
- 하지만 반드시 롤백 해야 하는 것은 아니다. 주문은 취소 상태로 변경하고 환불만 나중에 다시 시도하는 방식으로 처리할 수도 있다.
2) 성능 문제
- 환불을 처리하는 외부 시스템의 응답 시간이 길어지면 그만큼 대기 시간도 길어진다.
- 예를 들어, 환불 처리 기능이 30초 걸리면 주문 취소 기능은 30초만큼 대기시간이 증가한다.
- 즉, 외부 서비스 성능에 직접적인 영향을 받게 된다.
3) 도메인 객체에 서비스를 전달함으로써 추가적인 설계 문제
- 우선 주문 로직과 결제 로직이 섞이는 문제가 있을 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| public class Order {
...
// 외부 서비스를 실행하기 위해 도메인 서비스를 파라미터로 전달받음
public void cancel(RefundService refundService) {
verifyNotYetShipped();
this.state = OrderState.CANCELED;
// 여기까지 주문 로직
this.refundStatus = State.REFUND_STARTED;
try {
refundService.refund(getPaymentId());
this.fefundStatus = State>REFUND_COMPLETED;
} catch (Exception ex) {
??
}
// 여기까지 결제 로직
}
...
}
|
- 그리고 기존 기능에 새로운 기능을 추가할 때 발생한다.
- 만약 주문을 취소한 뒤 환불뿐만 아니라 취소했다는 내용을 통지해야 한다면?
- 환불 도메인 서비스와 동일하게 파라미터로 통지 서비스를 받도록 해야할 것이고 앞서 언급한 로직이 섞인는 문제가 더 커지고 트랜잭션 처리가 더 복잡해지게 될 것이다.
- 게다가 영향 주는 외부 서비스가 두개로 증가한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| public class Order {
// 기능을 추가할 떄마다 파라미터가 함께 추가되면
// 다른 로직이 더 많이 섞이고, 트랜잭션 처리가 더 복잡해진다.
public void cancel(RefundService refundService, NotiService notiSvc) {
verifyNotYetShipped();
this.state = OrderState.CANCELED;
...
// 주문 + 결제 + 통지 로직이 섞임
// refundService 는 성공하고, notiSvc는 실패하면?
// refundService와 notiSvc 중 무엇을 먼저 처리하나?
}
}
|
- 위에서 언급된 문제들은 주문 바운디드 컨텍스트와 결제 바운디드 컨텍스트간의 강결합(high coupling) 때문이다.
- 주문이 결제와 강하게 결합되어 있어서 주문 바운디드 컨텍스트가 결제 바운디드 컨텍스트에 영향을 받게 되는 것이다.
- 이런 강결합을 없앨 수 있는 방법이 이벤트를 사용하는 것이다.
- 특히 비동기 이벤트를 사용하면 두 시스템 간의 결합도를 크게 낮출 수 있는데, 한 번 익숙해지면 모든 연동을 이벤트와 비동기로 처리하고 싶을 정도로 강력하고 매력적인 것이 이벤트다.
10.2 이벤트 개요
1
2
3
| $("#myBtn").click(function (evt) {
alert("경고");
});
|
- 도메인 모델에서도 UI 컴포넌트와 유사하게 도메인의 상태 변경을 이벤트로 표현할 수 있다.
- 보통 ‘~할 때’, ‘~가 발생하면’, ‘만약 ~하면’과 같은 요구사항은 도메인의 상태 변경과 관련된 경우가 많고 이런 요구사항을 이벤트를 이용해서 구현할 수 있다.
- 예를 들어 ‘주문을 취소할 때 이메일을 보낸다’ 라는 요구사항에서 주문을 취소할 때’ 는 주문이 취소 상태로 바뀌는 것을 의미하므로 ‘주문 취소됨 이벤트’를 활용해서 구현할 수 있다.
10.2.1 이벤트 관련 구성 요소
- 도메인 모델에 이벤트를 도입하려면 아래 이미지와 같이 네 개의 구성요소인
이벤트
, 이벤트 생성 주체
, 이벤트 디스패처(퍼블리셔)
, 이벤트 핸들러(구독자)
를 구현해야 한다.
- 이벤트 생성 주체
- 도메인 모델에서 이벤트 생성 주체는 엔티티, 밸류, 도메인 서비스와 같은 도메인 객체이다.
- 이들 도메인 객체는 도메인 로직을 실행해서 상태가 바뀌면 관련 이벤트를 발생시킨다.
- 이벤트 핸들러
- 이벤트 생성 주체가 발생한 이벤트에 반응한다.
- 생성 주체가 발생한 이벤트를 전달받아 이벤트에 담긴 데이터를 이용해서 원하는 기능을 실행한다.
- 예를 들어, ‘주문 취소됨 이벤트’를 받는 이벤트 핸들러는 해당 주문의 주문자에게 SMS로 주문 취소 사실을 통지할 수 있다.
- 이벤트 디스패처
- 이벤트 생성 주체와 이벤트 핸들러를 연결해준다.
- 이벤트 생성 주체는 이벤트를 생성해서 디스패처에 이벤트를 전달한다.
- 이벤트를 전달받은 디스패처는 해당 이벤트를 처리할 수 있는 핸들러에 이벤트를 전파한다.
- 이벤트 디스패처의 구현 방식에 따라 이벤트 생성과 처리를 동기나 비동기로 실행하게 된다.
10.2.2 이벤트의 구성
- 이벤트는 발생한 이벤트에 대한 정보를 담는다.
- 이 정보는 다음을 포함한다.
- 1)이벤트 종류: 클래스 이름으로 이벤트 종류를 표현
- 2)이벤트 발생시간
- 3)추가 데이터: 주문번호, 신규 배송지 정보 등 이벤트와 관련된 정보
- 배송지를 변경시 발생하는 이벤트는 아래와 같이 작성할 수 있다.
1
2
3
4
5
6
7
| public class ShippingInfoChangedEvent {
private String orderNumber;
private long timestamp;
private ShippingInfo newShippingInfo;
// 생성자, getter
}
|
1
2
3
4
5
6
7
8
| public class Order {
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
Events.raise(new ShippingInfoChangedEvent(number, newShippingInfo));
}
...
}
|
- ShippingInfoChangedEvent 를 처리하는 핸들러는 디스패처로부터 이벤트를 전달받아 필요한 작업을 수행한다.
- 예를 들어, 변경된 배송지 정보를 물류 서비스에 전송하는 핸들러는 다음과 같이 구현할 수 있다.
1
2
3
4
5
6
7
8
9
| public class ShippingInfoChangedHandler {
@EventListener(ShippingInfoChangedEvent.class)
public void handle(ShippingInfoChangedEvent evt) {
shippingInfoSynchronizer.sync(
evt.getOrderNumber(),
evt.getNewShippingInfo()
);
}
}
|
- 이벤트는 이벤트 핸들러가 작업을 수행하는데 필요한 데이터를 담아야 한다.
- 이 데이터가 부족하면 핸들러는 필요한 데이터를 읽기 위해 관련 API를 호출하거나 DB에서 데이터를 직접 읽어와야 한다.
- 예를 들어, ShppingInfoChangedEvent 가 바뀐 배송지 정보를 포함하고 있지 않다고 가정해보자.
- 이 핸들러가 같은 VM에서 동작하고 있따면 다음과 같이 주문 데이터를 로딩해서 배송지 정보를 추출해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| public class ShippingInfoChangedHandler {
@EventListener(ShippingInfoChangedEvent.class)
public void handle(ShippingInfoChangedEvent evt) {
// 이벤트가 필요한 데이터를 담고 있지 않으면,
// 이벤트 핸들러는 리포지터리, 조회 API, 직접 DB 접근 등의
// 방식을 통해 필요한 데이터를 조회해야 한다.
Order order = orderRepository.findById(evt.getOrderNo());
shippingInfoSynchronizer.sync(
order.getOrderNumber(),
order.getNewShippingInfo()
);
}
...
}
|
- 이벤트는 데이터를 담아야 하지만 그렇다고 이벤트 자체와 관련 없는 데이터를 포함할 필요는 없다.
- 배송지 정보를 변경해서 발생시킨 ShippingInfoChangedEvent 가 이벤트 발생과 직접 관련된 바뀐 배송지 정보를 포함하는 것은 맞지만, 배송지 정보 변경과 전혀 관련 없는 주문 상품번호와 개수를 담을 필요는 없다.
10.2.3 이벤트 용도
1) 트리거
- 도메인의 상태가 바뀔때 다른 후처리가 필요하면 후처리를 실행하기 위한 트리거로 이벤트를 사용할 수 있다.
- 주문에선 주문 취소 이벤트를 트리거로 사용할 수 있다.
- 주문을 취소하면 환불을 처리해야 하는데 이때 환불 처리를 위한 트리거로 주문 취소 이벤트를 사용할 수 있다.
- 예매 결과를 SMS로 통지할때도 이벤트를 트리거로 사용할 수도 있다. 예매 도메인은 ‘예매 완료’ 이벤트를 발생시키고 이 이벤트 핸들러에선 SMS를 발송하는 방식으로 구현할 수 있다.
2) 서로 다른 시스템 간의 데이터 동기화
- 배송지를 변경하면 외부 배송 서비스에 바뀐 배송지 정보를 전송해야 한다.
- 주문 도메인은 배송지 변경 이벤트를 발생시키고 이벤트 핸들러는 외부 배송 서비스와 배송지 정보를 동기화할 수 있다.
10.2.4 이벤트 장점
1) 서로 다른 도메인 로직이 섞이는 것을 방지
이미지 출처: https://velog.io/@csh0034/%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9C-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0-10.-%EC%9D%B4%EB%B2%A4%ED%8A%B8
- 위 이미지를 보면 구매 취소 로직에 이벤트를 적용함으로써 환불 로직이 없어지고 환불 서비스를 실행하기 위한 파라미터도 없어진 것을 볼 수 있다.
- 환불 실행 로직은 주문 취소 이벤트를 받는 이벤트 핸들러로 이동하게 된다.
- 이벤트를 사용하여 주문 도메인에서 결제(환불) 도메인으로의 의존을 제거했다.
2) 기능 확장이 용이
- 구매 취소시 환불과 함께 이메일로 취소 내용을 보내고 싶다면 이메일 발송을 처리하는 핸들러를 구현하면 된다.
- 기능을 확장해도 구매 취소 로직은 수정할 필요가 없다.
10.3 이벤트, 핸들러, 디스패처 구현
- 이벤트와 관련된 코드는 다음과 같다.
- 이벤트 클래스: 이벤트를 표현한다.
- 디스패처: 스프링이 제공하는 ApplicationEventPublisher 를 이용한다.
- Events: 이벤트를 발행한다. 이벤트 발행을 위해 ApplicationEventPublisher 를 사용한다.
- 이벤트 핸들러: 이벤트를 수신해서 처리한다. 스프링이 제공하는 기능을 사용한다.
Note: 이벤트 디스패처를 직접 구현할 수도 있지만 이 책에서는 스프링이 제공하는 이벤트 관련 기능을 사용해서 이벤트 발생과 처리를 구현한다.
10.3.1 이벤트 클래스
- 이벤트 자체를 위한 상위 타입은 존재하지 않는다. 원하는 클래스를 이벤트로 사용하면 된다.
- OrderCanceledEvent 와 같이 클래스 이름 뒤에 접미사로 Event 를 사용해서 이벤트로 사용하는 클래스라는 것을 명시적으로 표현할 수도 있고 OrderCanceled 처럼 간결함을 위해 과거 시제만 사용 할수도 있다.
- 이벤트 클래스는 이벤트를 처리하는데 필요한 최소한의 데이터를 포함해야 한다.
- 예를 들어 주문 취소됨 이벤트는 적어도 주문번호를 포함해야 관련 핸들러에서 후속처리를 할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
| public class OrderCanceledEvent {
// 이벤트는 핸들러에서 이벤트를 처리하는데 필요한 데이터를 포함한다.
private String orderNumber;
public OrderCanceledEvent(String number) {
this.orderNumber = number;
}
public String getOrderNumber() {
return orderNumber;
}
}
|
- 모든 이벤트가 공통으로 갖는 프로퍼티가 존재한다면 관련 상위 클래스를 만들 수도 있다.
- 예를 들어 모든 이벤트가 발생 시간을 갖도록 하려면 다음과 같은 상위클래스를 만들고 각 이벤트 클래스가 이를 상속받도록 하면 된다.
1
2
3
4
5
6
7
8
9
10
| // 공통 추상 클래스
package com.myshop.common.event;
public abstract class Event {
private long timestamp;
public Event() {
this.timestamp = System.currentTimeMillis();
}
}
|
1
2
3
4
5
6
7
8
9
10
| // 발생 시간이 필요한 각 이벤트 클래스는 Event 클래스를 상속받아 구현한다.
public class OrderCanceledEvent extends Event {
private String orderNumber;
public OrderCanceledEvent(String number) {
super();
this.orderNumber = number;
}
}
|
10.3.2 Events 클래스와 ApplicationEventPublisher
- 이벤트 발생과 출판을 위해 스프링이 제공하는 ApplicationEventPublisher 를 사용한다.
- 스프링 컨테이너는 ApplicationEventPublisher도 된다.
- 스프링 컨테이너는 ApplicationEventPublisher 를 상속받는 구조로 되어 있기 때문이다.
- Events 클래스는 ApplicationEventPublisher 를 사용해서 이벤트를 발생시키도록 구현할 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| public class Events {
private static ApplicationEventPublisher publisher;
static void setPublisher(ApplicationEventPublisher publisher) {
Events.publisher = publisher;
}
public static void raise(Object event) {
if (publisher != null) {
publisher.publishEvent(event);
}
}
}
|
- Events 클래스의 raise() 메서드는 ApplicationEventPublisher 가 제공하는 publishEvent() 메서드를 이용해서 이벤트를 발생시킨다.
- Events 클래스가 사용할 ApplicationEventPublisher 객체는 setPublisher() 메서드를 통해서 전달받는다.
- Events#setPublisher() 메서드에 이벤트 퍼블리셔를 전달하기 위해 스프링 설정 클래스를 아래와 같이 작성한다.
1
2
3
4
5
6
7
8
9
10
| @Configuration
public class EventsConfiguration {
@Autowired
private ApplicationContext applicationContext;
@Bean
public InitializingBean eventsInitializer() {
return () -> Events.setPublisher(applicationContext);
}
}
|
- eventsInitializer() 메서드는 InitializingBean 타입 객체를 빈으로 설정한다.
- 이 타입은 스프링 빈 객체를 초기화할 때 사용하는 인터페이스로, 이 기능을 사용해서 Events 클래스를 초기화했다.
- 위에서 언급한것처럼 ApplicationContext 는 ApplicationEventPublisher 를 상속하고 있으므로 Events 클래스를 초기화할 때 ApplicationContext 를 전달했다.
10.3.3 이벤트 발생과 이벤트 핸들러
- 이벤트를 발생시킬 코드는 Events.raise() 메서드를 사용한다.
- 예를 들어 Order#cancel() 메서드는 다음과 같이 구매 취소 로직을 수행한뒤 Events.raise() 를 이용해서 관련 이벤트를 발생시킨다.
1
2
3
4
5
6
7
8
| public class Order {
public void cancel() {
verifyNotYetShipped();
this.state = OrderState.CANCELED;
Events.raise(new OrderCanceledEvent(number.getNumber()));
}
...
}
|
- 이벤트를 처리할 핸들러는 스프링이 제공하는
@EventListener
어노테이션을 사용해서 구현한다. - 다음은 OrderCanceldedEvent 를 처리하기 위한 핸들러를 구현한 코드의 예다
1
2
3
4
5
6
7
8
9
10
11
12
13
| @Service
public class OrderCanceledEventHandler {
private RefundService refundService;
public OrderCanceledEventHandler(RefundService refundService) {
this.refundService = refundService;
}
@EventListener(OrderCanceledEvent.class)
public void handle(OrderCanceledEvent event) {
refundService.refund(event.getOrderNumber());
}
}
|
- ApplicationEventPublisher#publishEvent() 메서드를 실행할 때 OrderCanceledEvent 타입객체를 전달하면, OrderCanceledEvent.class 값을 갖는 @EventListener 어노테이션이 붙은 메서드를 찾아 실행한다.
10.3.4 흐름 정리
- 아래와 같이 방식으로 처리된다.
- 1)도메인 기능을 실행한다.
- 2)도메인 기능은 Events.raise() 메서들르 이용해서 이벤트를 발생시킨다.
- 3)Events.raise() 는 스프링이 제공하는 ApplicationEventPublisher 를 이용해서 이벤트를 출판한다.
- 4)ApplicationEventPublisher 는 @EventListener(이벤트타입.class) 어노테이션이 붙은 메서드를 찾아 실행한다.
- 코드 흐름을 보면 응용 서비스와 동일한 트랜잭션 범위에서 이벤트 핸들러를 실행하고 있다.
- 즉, 도메인 상태 변경과 이벤트 핸들러는 같은 트랜잭션 범위에서 실행된다.
10.4 동기 이벤트 처리 문제
- 이벤트를 사용해서 강결합 문제는 해결했지만, 외부 서비스에 영향을 받는 문제가 남아있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // 1. 응용 서비스 코드
@Transactional // 외부 연동 과정에서 익셉션이 발생하면 트랜잭션 처리는?
public void cancel(OrderNo orderNo) {
Order order = findOrder(orderNo);
order.cancel(); // OrderCanceledEvent 발생
}
// 2. 이벤트를 처리하는 코드
@Service
public class OrderCanceledEventHandler {
...
@EventListener(OrderCanceledEvent.class)
public void handle(OrderCanceledevent event) {
// refundService.refund()가 느려지거나 익셉션이 발생하면?
refundService.refund(event.getOrderNumber());
}
}
|
- 위 코드에서 refundService.refund() 가 외부 환불 서비스와 연동한다고 가정해 보자.
- 만약 외부 환불 기능이 갑자기 느려지면 cancel() 메서드도 함께 느려진다.
- 이것은 외부 서비스의 성능 저하가 바로 내 시스템의 성능 저하로 연결된다는 것을 의미한다.
- 성능 저하뿐만 아니라 트랜잭션도 문제가 된다.
- refundService.refund() 에서 익셉션이 발생하면 cancel() 메서드의 트랜잭션을 롤백해야 할까? 트랜잭션을 롤백하면 구매 취소 기능을 롤백하는 것이므로 구매 취소가 실패하는 것과 같다.
- 생각해볼만한 것은 외부 환불 서비스 실행에 실패했다고 반드시 트랜잭션을 롤백해야 하는지에 대한 문제다.
- 일단 구매 취소 자체는 처리하고 환불만 재처리하거나 수동으로 처리할 수도 있다.
- 외부 시스템과의 연동을 동기로 처리할때 발생하는 성능과 트랜잭션 범위 문제를 해소하는 방법은 이벤트를 비동기로 처리하거나 이벤트 와 트랜잭션을 연계하는 것이다.
- 두 방법중 먼저 비동기 이벤트 처리에 대해 알아보자.
10.5 비동기 이벤트 처리
- 회원 가입 신청을 하면 검증을 위해 이메일을 보내는 서비스가 많다.
- 회원 가입 신청을 하자마자 바로 내 메일함에 검증 이메일이 도착할 필요는 없다.
- 이메일이 몇 초뒤에 도착해도 문제 되지 않는다.
- 10초 ~ 20초 후에 이메일이 도착해도 되고, 심지어 이메일을 받지 못하면 다시 받을 수 있는 기능을 이용하면 된다.
- 비슷하게 주문을 취소하자마자 바로 결제를 취소하지 않아도 된다.
- 수십초 내에 결제 취소가 이루어지면 된다. 몇칠 뒤에 결제가 확실하게 취소되면 문제없을 때도 있다.
- 이렇게 ‘A 하면 이어서 B하라’ 는 내용을 담고 있는 요구사항은 실제로 ‘A 하면 최대 언제까지 B 하라’ 인 경우가 많다.
- 즉, 일정 시간 안에서만 후속 조치를 처리하면 되는 경우가 적지 않다.
- 게다가 ‘A 하면 이어서 B 하라’ 는 요구사항에서 B를 하는데 실패하면 일정 간격으로 재시도를 하거나 수동 처리를 해도 상관없는 경우가 있다.
- 앞의 이메일 인증 예가 이에 해당한다.
- 회원 가입 신청 시점에서 이메일 발송을 실패하더라도 사용자는 이메일 재전송 요청을 이용하여 수동으로 인증 이메일을 다시 받아볼 수 있다.
- ‘A 하면 일정 시간 안에 B하라’ 는 요구사항에서 ‘A 하면은’ 이벤트로 볼 수도 있다.
- ‘회원 가입 신청을 하면 인증 이메일을 보내라’ 는 요구사항에서 ‘회원 가입 신청을 하면’ 은 ‘회원 가입 신청함 이벤트로’ 볼 수 있다.
- 따라서 ‘인증 이메일을 보내라’ 기능은 ‘회원 가입 신청함 이벤트’를 처리하는 핸들러에서 보낼 수 있다.
- 앞서 말했듯 ‘A 하면 이어서 B 하라’는 요구사항 중에서 ‘A 하면 최대 언제까지 B 하라’로 바꿀 수 있는 요구사항은 이벤트를 비동기로 처리하는 방식으로 구현할 수 있다.
- 다시 말해서 ‘A 이벤트가 발생하면 별도 스레드로 B를 수행하는 핸들러를 실행하는 방식으로 요구사항을 구현할 수 있다.
- 이벤트를 비동기로 구현하는 방법은 다양한데, 여기선 다음 네 가지 방식으로 비동기 이벤트 처리를 구현하는 방법에 대해 알아보자.
- 1)로컬 핸드러를 비동기로 실행하기
- 2)메시지 큐를 사용하기
- 3)이벤트 저장소와 이벤트 포워더 사용하기
- 4)이벤트 저장소와 이벤트 제공 API 사용하기
- 네 가지 방식은 각자 구현하는 방식도 다르고 그에 따른 장단점이 있다. 각 방식에 대해 차례대로 살펴보자.
10.5.1 로컬 핸들러 비동기 실행
- 이벤트 핸들러를 별도 스레드로 실행시키는 방법이다.
- 스프링이 제공하는
@Async
어노테이션을 사용하면 손쉽게 구현 가능하다.
1
2
3
4
5
6
7
8
| @SpringBootApplication
@EnableAsync // 기능 활성화
public class ShopApplication {
public static void main(String[] args) {
SpringApplication.run(ShopApplication.class, args);
}
}
|
1
2
3
4
5
6
7
8
9
10
| // 핸들러
@Service
public class OrderCanceledEventHandler {
@Async // @Async 에너테이션 사용
@EventListener(OrderCanceledEvent.class)
public void handle(OrderCanceledEvent event) {
refundService.refund(event.getOrderNumber());
}
}
|
10.5.2 메시징 시스템을 이용한 비동기 구현
- 카프카나 래빗MQ 와 같은 메시징 시스템을 사용하는 것이다.
- 처리 프로세스는 다음과 같다.
- 1)이벤트가 발생하면 이벤트 디스패처는 아래 이미지와 같이 이벤트를 메시지 큐에 저장한다.
- 2)메시지 큐는 이벤트를 메시지 리스너에 전달한다.
- 3)메시지 리스너는 알맞은 이벤트 핸들러를 통해 이벤트를 처리한다.
- 이때 이벤트를 메시지 큐에 저장하는 과정과 메시지 큐에서 이벤트를 읽어와 처리하는 과정은 별도 스레드나 프로세스로 처리된다.
- 필요하다면 이벤트를 발생시키는 도메인 기능과 메시지 큐에 이벤트를 저장하는 절차를 한 트랜잭션으로 묶어야 한다.
- 도메인 기능을 실행한 결과를 DB에 반영하고 이 과정에서 발생한 이벤트를 메시지 큐에 저장하는 것을 같은 트랜잭션 범위에서 실행하려면 글로벌 트랜잭션이 필요하다.
글로벌 트랜잭션
을 사용하면 안전하게 이벤트를 메시지 큐에 전달할 수 있는 장점이 있지만 반대로 전체 성능이 떨어지는 단점도 있다.- 글로벌 트랜잭션을 지원하지 않는 메시징 시스템도 있다.
- 메시지 큐를 사용하면 보통 이벤트를 발생하는 주체와 이벤트 핸들러가 별도 프로세스에서 동작한다.
- 이것은 이벤트 발생 JVM과 이벤트 처리 JVM이 다르다는 것을 의미한다.
- 물론 한 JVM에서 이벤트 발생 주체와 이벤트 핸들러가 메시지 큐를 이용해서 이벤트를 주고받을 수 있지만, 이는 시스템을 복잡하게 만들 뿐이다.
- 래빗MQ 처럼 많이 사용되는 메시징 시스템은 글로벌 트랜잭션 지원과 함께 클러스터와 고가용성을 지원하기 때문에 안정적으로 메시지를 전달할 수 있는 장점이 있다.
- 또한 다양한 개발 언어와 통신 프로토콜을 지원하고 있다.
- 메시지를 전달하기 위해 많이 사용되는 것 중 또 하나가 카프카인데 글로벌 트랜잭션을 지원하지 않지만 다른 메시징 시스템에 비해 높은 성능을 보여준다.
- 여기를 참고하면 카프카에서 제공하는 ChainedKafkaTransactionManager 를 통해 DB 트랜잭션과 카프카 트랜잭션을 묶어서 관리할 수 있다.
10.5.3 이벤트 저장소를 이용한 비동기 처리
- 이벤트를 일단 DB에 저장 후 별도 프로그램을 이용해서 이벤트 핸들러에 전달하는 방법이다.
1) 이벤트 포워더를 이용한 방식
2) 이벤트를 외부에 제공하는 API 를 사용하는 방식
- 포워더 방식과의 차이점은 이벤트를 전달하는 방식에 있다.
- 포워더 방식은 포워더를 이용해서 이벤트를 외부에 전달한다면,
- API 방식은 외부 핸들러가 API 서버를 통해 이벤트 목록을 가져간다.
- 포워더 방식은 이벤트를 어디까지 처리했는지 추적하는 역할이 포워더에 있다면 API 방식에선 이벤트 목록을 요구하는 외부 핸들러가 자신이 어디까지 이벤트를 처리했는지 기억해야 한다.
이벤트 저장소 구현
- 포워더 방식과 API 방식 모두 이벤트 저장소를 사용하므로 이벤트를 저장할 저장소가 필요하다.
- 각 구성요소는 아래와 같다.
EventEntry
: 이벤트 저장소에 보관할 데이터이다.EventStore
: 이벤트를 저장하고 조회하는 인터페이스를 제공한다.JdbcEventStore
: 이벤트를 저장하고 조회하는 인터페이스를 제공한다.EventApi
: REST API를 이용해서 이벤트 목록을 제공하는 컨트롤러이다.
실제 구현 코드
- EventEntry 클래스를 실제 구현한 코드는 아래와 같다.
1
2
3
4
5
6
7
8
9
| public class EventEntry {
private Long id;
private String type;
private String contentType; // application.json
private String payload; // 실제 이벤트 페이로드
private long timestampe;
// constructor, getter
}
|
- EventStore 는 이벤트 객체를 직렬화해서 payload 에 저장한다.
- 이때 JSON으로 직렬화했다면 contentType 값으로 ‘application/json’ 을 갖는다.
1
2
3
4
| public interface EventStore {
void save(Object event);
List<EventEntry> get(long offset, long limit);
}
|
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
44
45
46
47
48
49
50
51
52
53
| @Component
public class JdbcEventStore implements EventStore {
private ObjectMapper objectMapper;
private JdbcTemplate jdbcTemplate;
@Autowired
public void setJdbcTemplate(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Autowired
public void setObjectMapper(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void save(Object event) {
EventEntry entry = new EventEntry(event.getClass().getName(),
"application/json", toJson(event));
jdbcTemplate.update(
"insert into evententry (type, content_type, payload, timestamp) values (?, ?, ?, ?)",
ps -> {
ps.setString(1, entry.getType());
ps.setString(2, entry.getContentType());
ps.setString(3, entry.getPayload());
ps.setTimestamp(4, new Timestamp(entry.getTimestamp()));
});
}
private String toJson(Object event) {
try {
return objectMapper.writeValueAsString(event);
} catch (JsonProcessingException e) {
throw new PayloadConvertException(e);
}
}
@Override
public List<EventEntry> get(long offset, long limit) {
return jdbcTemplate.query("select * from evententry order by id asc limit ?, ?",
ps -> {
ps.setLong(1, offset);
ps.setLong(2, limit);
},
(rs, rowNum) -> {
return new EventEntry(
rs.getLong("id"), rs.getString("type"),
rs.getString("content_type"), rs.getString("payload"),
rs.getTimestamp("timestamp").getTime());
});
}
}
|
1
2
3
4
5
6
7
| create table evententry {
id int not null AUTO_INCREMENT PRIMARY KEY,
`type` varchart(255),
`content_type` varchart(255),
payload MEDIUMTEXT,
`timestamp` datetime,
} character set utf8mb4
|
이벤트 저장을 위한 이벤트 핸들러 구현
- 이벤트 저장소를 위한 기반이 되는 클래스는 모든 구현했고 남은 것은 발생한 이벤트를 이벤트 저장소에 추가하는 이벤트 핸들러를 구현하는 것이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
| @Component
public class EventStoreHandler {
private EventStore eventStore;
public EventStoreHandler(EventStore eventStore) {
this.eventStore = eventStore;
}
@EventListener(Event.class)
public void handle(Event event) {
eventStore.save(event);
}
}
|
- Event 타입을 상속받은 이벤트 타입만 이벤트 저장소에 저장하는 핸들러이다.
REST API 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| @RestController
public class EventApi {
private EventStore eventStore;
@Autowired
public void setEventStore(EventStore eventStore) {
this.eventStore = eventStore;
}
@RequestMapping(value = "/api/events", method = RequestMethod.GET)
public List<EventEntry> list(
@RequestParam("offset") Long offset,
@RequestParam("limit") Long limit) {
return eventStore.get(offset, limit);
}
}
|
- EventApi 가 처리하는 URL에 연결하면 아래와 같이 JSON 형식의 EventEntry 목록을 구할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| [
{
"id": 1,
"type": "com.myshop.eventstore.infra.SampleEvent",
"contentType": "application/json",
"payload": "{\"name\": \"name1\", \"value\": 11}",
"timestamp": 1641684436000
},
{
"id": 2,
"type": "com.myshop.eventstore.infra.SampleEvent",
"contentType": "application/json",
"payload": "{\"name\": \"name2\", \"value\": 12}",
"timestamp": 1641684436000
}
]
|
- 이벤트를 수정하는 기능이 없으므로 REST API도 단순 조회 기능만 존재한다.
- API를 사용하는 클라이언트는 일정 간격으로 다음 과정을 실행한다.
- 1)가장 마지막에 처리한 데이터의 offset 인 lastOffset을 구한다. 저장한 lastOffset이 없으면 0을 사용한다.
- 2)마지막에 처리한 lastOffset 을 offset 으로 사용해서 API를 실행한다.
- 3)API 결과로 받은 데이터를 처리한다.
- 4)offset + 데이터 개수를 lastOffset 으로 저장한다.
- 마지막으로 처리한 lastOffset 을 저장하는 이유는 같은 이벤트를 중복해서 처리하지 않기 위해서이다.
- API를 사용하는 과정을 그림으로 정리하면 다음과 같다.
포워더 구현
- 포워더는 일정 주기로 EventStore 에서 이벤트를 읽어와 이벤트 핸들러에 전달하면 된다.
- API 방식 클라이언트와 마찬가지로 마지막으로 전달한 이벤트의 off을 기억해두었다가 다음 조회 시점에 마지막으로 처리한 offset 부터 이벤트를 가져오면 된다.
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
| @Component
public class EventForwarder {
private static final int DEFAULT_LIMIT_SIZE = 100;
private EventStore eventStore;
private OffsetStore offsetStore;
private EventSender eventSender;
private int limitSize = DEFAULT_LIMIT_SIZE;
@Scheduled(initialDelay = 1000L, fixedDelay = 1000L)
public void getAndSend() {
long nextOffset = getNextOffset();
List<EventEntry> events = eventStore.get(nextOffset, limitSize);
if (!events.isEmpty()) {
int processedCount = sendEvent(events);
if (processedCount > 0) {
saveNextOffset(nextOffset + processedCount);
}
}
}
private long getNextOffset() {
return offsetStore.get();
}
private int sendEvent(List<EventEntry> events) {
int processedCount = 0;
try {
for (EventEntry entry : events) {
eventSender.send(entry);
processedCount++;
}
} catch(Exception ex) {
// 로깅 처리
}
return processedCount;
}
private void saveNextOffset(long nextOffset) {
offsetStore.update(nextOffset);
}
}
|
getAndSend()
메서드를 주기적으로 실행하기 위해 스프링의 @Scheduled
어노테이션을 사용했다.- 스프링을 사용하지 않으면 별도 스케줄링 프레임워크를 이용해서 getAndSend() 메서드를 원하는 주기로 실행하면 된다.
- getNextOffset() 메서드와 saveNextOffset() 메서드에선 사용한 OffsetStore 인터페이스는 다음 두 메서드를 정의하고 있다.
1
2
3
4
| public interface OffsetStore {
long get();
void update(long nextOffset);
}
|
OffsetStore 를 구현한 클래스는 offset 값을 DB 텡니블에 저장하거나 로컬 파일에 보관해서 마지막 offset 을 물리적 저장소에 보관해야 한다.
실제 이벤트 발송 로직에 따르면 getAndSend() 메서드를 실행하면 마지막으로 전송에 성공한 이벤트의 다음 이벤트 부터 읽어와 전송을 시도하게 된다.(익셉션이 발생하면 전송을 멈추고 전송에 성공한 이벤트 개수를 리턴하므로)
EventSender 인터페이스는 다음과 같이 단순하다.
1
2
3
| public interface EventSender {
void send(EventEntry event);
}
|
- 이 인터페이스를 구현한 클래스는 send() 메서드에서 외부 메시징 시스템에 이벤트를 전송하거나 원하는 핸들러에 이벤트를 전달하면 된다.
- 이벤트 처리 중에 익셉션이 발생하면 그대로 전파해서 다음 주기에 getAndSend() 메서드를 실행할 때 재처리할 수 있도록 한다.
자동 증가 칼럼 주의사항: primary key로 자동 증가 칼럼을 사용할 때는 주의할 점이 있다. insert 쿼리 실행하는 시점에 값이 증가하지만 실제 데이터는 트랜잭션 커밋 시점에 DB에 반영된다. 즉 insert 쿼리 실행해서 자동 증가 칼럼이 증가했더라도 트랜잭션 커밋 전에 조회하면 증가한 값을 가진 레코드는 조회되지 않는다. 또한 커밋 시점에 따라 DB에 반영되는 시점이 달라질 수도 있다. 예를 들어 마지막 자동 증가 칼럼 값이 10인 상태에서 A트랜잭션이 insert 쿼리를 실행 후 B 트랜잭션이 insert 쿼리를 실행하면 A는 11, B는 12를 사용하게 된다. 그런데 B 트랜잭션 커밋 후 A 가 커밋되면 12가 DB에 먼저 반영되고 그다음 11이 반영된다. 만약 두 트랜잭션 커밋 사이에 데이터 조회시 11은 조회되지 않고 12만 조회되는 상황이 발생한다. 이런 문제가 발생하지 않도록 하려면 ID를 기준으로 데이터를 지연 조회하는 방식을 사용해야 한다. 관련 내용은 여기를 참고하면 된다.
10.6 이벤트 적용 시 추가 고려사항
1) 이벤트 소스를 EventEntry에 추가할지?
- EventEntry는 이벤트 발생 주체에 대한 정보를 갖지 않는다.
- 특정 주체가 발생시킨 이벤트만 조회하는 기능을 구현할 수 없다.
- 이 기능을 구현하려면 이벤트에 발생 주체 정보를 추가해야 한다.
2) 포워더에서 전송 실패를 얼마나 허용할지?
- 포워더는 이벤트 전송에 실패하면 실패한 이벤트부터 다시 읽어와 전송을 시도한다.
- 실패한 이벤트의 재전송 횟수 제한을 두어야 한다.
- 실패한 이벤트는 실패용 DB나 메시지 큐에 저장한다.
3) 이벤트 손실이 된다면 어떻게 할지?
- 이벤트 저장소를 사용하면 이벤트 발생과 이벤트 저장을 한 트랜잭션으로 처리하기 때문에 트랜잭션에 성공하면 이벤트가 저장소에 보관된다는 것을 보장할 수 있다.
- 반면 로컬 핸들러를 이용해서 이벤트를 비동기로 처리할 경우 이벤트 처리에 실패하면 이벤트를 유실하게 된다.
4) 이벤트 순서는?
- 이벤트 발생 순서대로 외부 시스템에 전달해야 할 경우는 이벤트 저장소를 사용하는 것이 좋다.
- 이벤트 저장소는 젖아소에 이벤트를 발생 순서대로 저장하고 그 순서대로 이벤트 목록을 제공하기 떄문이다
- 반면 메시징 시스템은 사용 기술에 따라 이벤트 발생 순서와 메시지 순서가 다를 수 있다.
5) 이벤트 재처리는?
- 동일한 이벤트를 다시 처리할 때 이벤트를 어떻게 할지 결정해야 한다.
- 가장 쉬운 방법은 마지막으로 처리한 이벤트의 순번을 기억해두었다가 이미 처리한 순번의 이벤트가 도착하면 해당 이벤트를 처리하지 않고 무시하는 것이다.
- 예를 들어, 회원 가입 신청 이벤트가 처음 도착하면 이메일을 발송하는데, 동일한 순번의 이벤트가 다시 들어오면 이메일을 발송하지 않는 방식으로 구현하는 것이다.
- 이 외에 이벤트를 멱등으로 처리하는 방법도 있다.
멱등성이란?: 연산을 여러번 적용해도 결과가 달라지지 않는 성질을 멱등성이라 한다. 수학에서 절대값 함수인 abs()가 멱등성을 갖는 대표적인 예이다. 어떤 x에 대해 abs() 연산을 여러 번 적용해도 결과는 동일하다. 즉, abs(x), abs(abs(x)), abs(abs(abs(x))) 는 모두 결과가 같다. 비슷하게 이벤트 처리도 동일 이벤트를 한 번 적용하나 여러 번 적용하나 시스템이 같은 상태가 되도록 핸들러를 구현할 수 있다. 예를 들어 배송지 정보 변경 이벤트를 받아서 주소를 변경하는 핸들러는 그 이벤트를 한 번 처리하나 여러 번 처리하나 결과적으로 동일 주소를 값으로 갖는다. 같은 이벤트를 여러 번 적용해도 결과가 같으므로 이 이벤트 핸들러는 멱등성을 갖는다. 이벤트 핸들러가 멱등성을 가지면 시스템 장애로 인해 같은 이벤트가 중복해서 발생해도 결과적으로 동일 상태가 된다. 이는 이벤트 중복 발생이나 중복 처리에 대한 부담을 줄여준다.
10.6.1 이벤트 처리와 DB 트랜잭션 고려
- 이벤트를 처리할 땐 DB 트랜잭션을 함꼐 고려해야 한다.
- 예를 들어 주문 취소와 환불 기능을 다음과 같이 이벤트를 이용해서 구현했다고 하자.
- 주문 취소 기능은 주문 취소 이벤트를 발생시킨다.
- 주문 취소 이벤트 핸들러는 환불 서비스에 환불 처리를 요청한다.
- 환불 서비스는 외부 API를 호출해서 결제를 취소한다.
- 이벤트 발생과 처리를 모두 동기로 처리하면 실행 흐름은 다음과 같을 것이다.
- 주문 쉬초 이벤트를 비동기로 처리할 때의 실행흐름이다.
- 이벤트 핸들러를 호출하는 5번 과정은 비동기로 실행한다.
- DB 업데이트와 트랜잭션을 다 커밋한 뒤에 환불 로직인 11~13번 과정을 실행했다고 하자.
- 만약 12번 과정에서 외부 API 호출에 실패하면 DB 에는 주문이 취소된 상태로 데이터가 바뀌었는데 결제는 취소되지 않은 상태로 남게 된다.
어떻게 하면 가장 효율적으로 해결할 수 있을까?
- 이벤트 처리를 동기로 하든 비동기로 하든 이벤트 처리 실패와 트랜잭션 실패를 함께 고려해야 한다.
- 트랜잭션 실패와 이벤트 처리 실패를 모두 고려하면 복잡해지므로 경우의 수를 줄이면 도움이 된다.
- 경우의 수를 줄이는 방법은 트랜잭션이 성공할 때만 이벤트 핸들러를 실행하는 것이다.
- 스프링은
@TransactionalEventListener
어노테이션을 지원하여 스프링 트랜잭션 상태에 따라 이벤트 핸들러를 실행할 수 있게 한다.
1
2
3
4
5
6
7
| @TransactionalEventListener(
classes = OrderCanceledEvent.class,
phase = TransactionPhase.AFTER_COMMIT
)
public void handle(OrderCanceledEvent event) {
refundService.refund(event.getOrderNumber());
}
|
- 위 코드에서 phase 속성 값으로
TransactionPhase.AFTER_COMMIT
을 지정했는데 이는 스프링이 트랜잭션 커밋에 성공한 뒤에만 핸들러를 실행하도록 한다.- 중간에 에러가 발생해서 트랜잭션이 롤백 되면 핸들러 메서드를 실행하지 않는다.
- 이 기능을 사용하면 이벤트 핸들러를 실행했는데 트랜잭션이 롤백되는 상황은 발생하지 않는다.
- 이벤트 저장소로 DB를 사용해도 동일한 효과를 볼 수 있다.
- 이벤트 발생 코드와 저장 처리를 한 트랜잭션으로 처리하면 된다.
- 이렇게 하면 트랜잭션 성공시에만 이벤트가 DB에 저장되므로, 트랜잭션은 실패했는데 이벤트 핸들러가 실행되는 상황은 발생하지 않게 된다.
- 트랜잭션 성공할 때만 이벤트 핸들러를 실행하게 되면 트랜잭션 실패에 대한 경우의 수가 줄어 이제 이벤트 처리 실패만 고민하면 된다. 이벤트 특성에 따라 재처리 방식을 결정하면 된다.
Reference
Chapter11-CQRS
11. 1 단일 모델의 단점
- 주문 내역 조회 기능을 구현하려면 여러 애그리거트에서 데이터를 가져와야 한다.
- Order 에서 주문 정보를 가져와야 하고, Product 에서 상품 이름을 가져와야 하고, Member 에서 회원 이름과 ID를 가져와야 한다.
- 조회 화면 특성상 조회 속도가 빠를수록 좋은데 여러 애그리거트의 데이터가 필요하면 구현 방법을 고민해야 한다.
- 3장에서 언급한 식별자를 이용해서 애그리거트를 참조하는 방식을 사용하면 즉시 로딩 방식과 같은 JPA의 쿼리 관련 최적화 기능을 사용할 수 없다.
- 이는 한 번의 SELECT 쿼리로 조회 화면에 필요한 데이터를 읽어올 수 없어 조회 성능에 문제가 생길 수 있다.
- 애그리거트 간 연관을 식별자가 아닌 직접 참조하는 방식으로 연결해도 고민거리가 생긴다.
- 조회 화면 특성에 따라 같은 연관도 즉시 로딩이나 지연 로딩으로 처리해야 하기 때문이다.
조회 기능을 구현할 때 DBMS 가 제공하는 전용 기능이 필요하면 JPA의 네이티브 쿼리를 사용해야 할 수도 있다.
- 이런 고민이 발생하는 이유는 시스템 상태를 변경할 때와 조회할 때 단일 도메인 모델을 사용하기 떄문이다.
객체 지향으로 도메인 모델을 구현할 때 주로 사용하는 ORM 기법은 Order#cancel() 이나 Order#changeShippingInfo() 기능처럼 도메인 상태 변경 기능을 구현하는데는 적합하지만 주문 상세 조회 화면처럼 여러 애그리거트에서 데이터를 가져와 출력하는 기능을 구현하기엔 고려할게 많아서 구현을 복잡하게 만드는 원인이 된다.
- 이런 구현 복잡도를 낮추는 간단한 방법은 상태 변경을 위한 모델과 조회를 위한 모델을 분리하는 것이다.
Note: CQRS 에 대한 내용을 저자가 영상으로도 정리한 것이 있다. 유튜브 영상을 함께 보면 CQRS 를 이해하는데 도움이 될 것이다.
11.2 CQRS
Note: CQRS 패턴을 적용하기 위해 사용해야 할 필수 기술이 따로 존재하는 것은 아니다. 5장에서 언급한 것처럼 JPA만 사용해서 명령 모델과 조회 모델을 구현할 수도 있다. 명령 모델은 JPA로 구현하고 조회 모델은 직접 SQL을 사용해서 구현할 수도 있다.
11.2.1 웹과 CQRS
11.2.2 CQRS 장단점
장점
- 1) 명령 모델을 구현할 떄 도메인 자체에 집중할 수 있다.
- 복잡한 도메인은 주로 상태 변경 로직이 복잡한데 명령 모델과 조회 모델을 구분하면 조회 성능을 위한 코드가 명령 모델에 없으므로 도메인 로직을 구현하는데 집중할 수 있다.
- 또한 명령 모델에서 조회 관련 로직이 사라져 복잡도가 낮아진다.
- 2) 조회 성능을 향상시키는데 유리하다.
- 조회 단위로 캐시 기술을 적용할 수 있고, 조회에 특화된 쿼리를 마음대로 사용할 수도 있다.
- 캐시뿐만 아니라 조회 전용 저장소를 사용하면 조회 처리량을 대폭 늘릴 수도 있다.
- 조회 전용 모델을 사용하기 때문에 조회 성능을 높이기 위한 코드가 명령 모델에 영향을 주지 않는다.
단점
- 2) 구현해야 할 코드가 더 많다.
- 단일 모델을 사용할 때 발생하는 복잡함 때문에 발생하는 구현 비용과 조회 전용 모델을 만들 때 발생하는 구현 비용을 따져봐야 한다.
- 도메인이 복잡하거나 대규모 트래픽이 발생하는 서비스라면 조회 전용 모델을 만드는 것이 향후 유지 보수에 유리하다.
- 반면에 도메인이 단순하거나 트래픽이 많지 않은 서비스라면 조회 전용 모델을 따로 만들때 얻을 이점이 있는지 따져봐야 한다.
- 1) 더 많은 구현 기술이 필요하다.
- 명령 모델과 조회 모델을 다른 구현 기술을 사용해서 구현하기도 하고 경우에 따라 다른 저장소를 사용하기도 한다.
- 또한 데이터 동기화를 위해 메시징 시스템을 도입해야 할 수도 있다.
위와 같은 장단점을 고려해서 CQRS 패턴을 도입할지 여부를 결정해야 한다. 도메인이 복잡하지 않은데 CQRS를 도입하면 두 모델을 유지하는 비용만 높아지고 얻을 수 있는 이점은 없다. 반면에 트래픽이 높은 서비스인데 단일 모델을 고집하면 유지 보수 비용이 오히려 높아질 수 있으므로 CQRS 도입을 고려하자.
Reference