이 책을 읽게 된 이유, 그리고 나의 고민
비즈니스 로직을 개발하면서 꽤 자주, ‘이 로직을 서비스단에서 처리하는게 맞을까?’ 라는 고민에 처하곤 했다. 대부분의 비즈니스 로직을 서비스단에서 처리하려다보니, 자연히 서비스단은 비대해졌다. 그 결과로 애플리케이션의 응집도는 떨어지고, 결합도는 높아져만 갔다. 하지만, ‘MVC패턴에서 대부분의 비즈니스로직은 서비스단에서 처리하는게 맞지않나? Controller와 View에서 비즈니스로직을 처리할 순 없잖아?’ 라는 관습과도 같은 생각에서 벗어나기는 매우 어려웠다. Controller에서 Service 그리고 DAO로 이어지는 MVC패턴은 실무에서도 사용하고 있을 뿐더러 어디서나 쉽게 찾아볼 수 있는 익숙한 설계/구현 방법이었기 때문이다.
내가 도메인 주도 설계라는 개념에 관심을 가지게 된 건, 새로 시작하는 프로젝트 애플리케이션의 package를 어떻게 나누는게 효율적일까라는 단순한 고민으로부터 시작되었다. 회사에서는 주로 위에서 언급했던 Controller-Service-DAO를 통한 개발(을 가장한 무지성 유지보수)을 주로 하고 있었는데, 새로운 프로젝트를 시작하면서 비록 작은 애플리케이션이었지만 설계부터 구현까지 내 스스로가 결정권을 가지게 되었던 것이다. 처음부터 끝까지 내가 책임져야한다니.. 잊고있었던 내안의 개발자 본능이 오랜 잠에서부터 깨어나고 있었다. 애플리케이션 설계를 시작한 그날부터 실제 실무(여기서 실제 실무란, 내로라하는 서비스기업의 신기술을 사용한 개발 업무를 말한다.)에서 package를 어떻게 나눌까? 궁금해졌다. 그래서 실무에서 사용한다는 최신 기술을 공부하기 시작했다. 그렇게 JPA를 만났고, 그로부터 “도메인 주도 설계”라는 개념을 접하게 되었다. 도메인을 통해 객체를 효율적으로 표현하고, 비즈니스 로직을 구현할 수 있으며, 유연한 설계를 가져갈 수 있다니.. 그렇다면 내 애플리케이션의 서비스단은 더이상 비대해지지 않아도 된단 말인가?.. 마치 꿈같은 이야기처럼 들렸다.
그렇게 시작한 도메인 주도 설계는 익숙하지만 어려운 것이었다. JPA강의를 들으며 Entity의 개념, Entity가 가지는 비즈니스 로직에 대해 얼추 이해는 할 수 있었으나, 도메인 주도 설계라는 개념이 사실 깊이 와닿지는 않았다. 그러다 김영한 강사님께서 DDD 추천서 목록을 발견했고, 본격적으로 DDD를 공부해보리라 마음 먹었다. DDD의 바이블 격인 에릭 에반스가 쓴 “도메인 주도 설계”는 DDD 입문으로 다가가기엔 너무나도 먼 책이라는 다수 개발자의 의견에 따라, 반 버논의 “도메인 주도 설계 핵심”을 DDD 입문서로 결정했다. 하지만, 애그리거트, 컨택스트 매핑, 전술적 설계.. 등의 수학 개념같은 단어들이 내 의지를 꺾었다. 무엇이든지 흥미가 생기지 않으면 쉽게 포기하고마는 나에게, 접하지 못했던 어려운 단어들의 등장은, 곧 도메인 주도 설계에 대한 (있다가도 없어져버릴) 흥미를 없애기 직전이었다. 꺼져가는 흥미를 잡기 위해 여러 주니어 개발자들이 추천한 DDD 입문서를 검색했고, DDD Start!가 나에게 왔다. (사실은 책이 품절이라 어디서도 구할 수가 없어 답십리 도서관 보존서고까지 가서 귀하게 모셔왔다.)
2016년 책이라 JPA를 사용해 구현한 코드들이 현재 트렌드와는 완벽하게 들어맞지는 않는다는 문제점은 있지만, DDD에 대한 어려움, 장벽을 해소해줄 수 있는 좋은 입문서라는 생각이 든다. 이 책을 읽고, 다시 도메인 주도 설계에 대한 관심과 흥미가 생겼다. 이 책을 통해 배운 점을 잊지 않고 활용하기 위해 정리한다.
도메인 모델 시작
도메인
•
소프트웨어로 해결하고자하는 문제 영역
◦
예) 온라인 서점 소프트웨어의 도메인 → ‘온라인 서점’
•
도메인은 다시 하위 도메인으로 나눌 수 있다.
◦
주문, 정산, 배송, 결제, 혜택, 회원, 카탈로그 등
도메인 모델
•
도메인 모델이란? 특정 도메인을 개념적으로 표현한 것
▲ 객체 기반 주문 도메인 모델
▲ 상태 다이어그램을 이용한 주문 상태 모델링
도메인 모델 패턴
일반적인 애플리케이션의 아키텍쳐
•
(사용자) - 표현 - 응용 - 도메인 - 인프라스트럭쳐 - (DB)
•
사용자 인터페이스(UI) 또는 표현(Presentation)
◦
사용자의 요청을 처리하고, 사용자에게 정보를 보여준다.
•
응용(Application)
◦
사용자가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며, 도메인 계층을 조합해서 기능을 실행한다.
•
도메인
◦
시스템이 제공할 도메인의 규칙을 구현한다.
•
인프라스트럭쳐
◦
데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다.
도메인 모델 패턴
•
도메인의 핵심 규칙을 구현
◦
주문 도메인의 경우, ‘출고 전에 배송지를 변경할 수 있다'는 규칙과 ‘주문 취소는 배송 전에만 할 수 있다’는 규칙을 구현한 코드가 위치
◦
위와 같은 도메인 규칙을 객체 지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.
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;
}
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;
}
}
}
Java
복사
◦
위 코드는 주문 도메인의 일부 기능을 도메인 모델 패턴으로 구현한 것.
◦
OrderState는 주문 대기 중이거나, 상품 준비 중에는 배송지를 변경할 수 있다는 도메인규칙 구현
◦
실제 배송지 정보를 변경하는 Order 클래스의 changeShippingInfo() 메소드는 OrderState의 isShippingChangeable() 메소드를 이용해서 변경 가능 여부를 확인한 후, 변경 가능한 경우에만 배송지를 변경한다.
◦
배송지 변경이 가능한지 여부를 판단할 규칙이 주문 상태와 다른 정보를 함께 사용한다면, 배송지 변경 가능 여부 판단을 OrderState만으로 할 수 없으므로 로직 구현을 Order에서 해야할 것이다.
•
핵심 규칙을 구현한 코드는 도메인 모델에만 위치하기 때문에, 규칙이 바뀌거나 규칙을 확장해야 할 때 다른 코드에 영향을 덜주고 변경 내역을 모델에 반영할 수 있다.
도메인 모델 도출
•
도메인을 모델링할 때 기본이 되는 작업은, 모델을 구성하는 핵심 구성요소, 규칙, 기능을 찾는 것이다.
•
이 과정은 요구사항에서 출발한다.
◦
최소 한 종류 이상의 상품을 주문해야 한다.
◦
한 상품을 한 개 이상 주문할 수 있다.
◦
총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
◦
각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 값이다.
◦
주문할 때, 배송지 정보를 반드시 지정해야 한다.
◦
배송지 정보는 받는 사람 이름, 전화번호, 주소로 구성된다.
◦
출고를 하면 배송지 정보를 변경할 수 없다.
◦
출고 전에 주문을 취소할 수 있다.
◦
고객이 결제를 완료하기 전에는 상품을 준비하지 않는다.
요구사항을 도메인 모델로 도출하기
•
주문은 ‘출고 상태로 변경하기’, ‘배송지 정보 변경하기’, ‘주문 취소하기’, ‘결제 완료로 변경하기’의 네 기능을 제공한다는 것을 알 수 있다.
◦
Order Class에 관련 기능을 메소드로 추가할 수 있다.
▪
changeShipped
▪
changeShippingInfo
▪
cancel
▪
completePayment
•
다음 요구사항은 주문 항목이 어떤 데이터로 구성되는지 알려준다.
◦
한 상품을 한 개 이상 주문할 수 있다.
◦
각 상품의 구매 가격 합은 상품 가격에 구매 개수를 곱한 것이다.
◦
위 요구사항에 따르면, 주문 항목을 표현하는 OrderLine은 적어도 주문할 상품, 상품의 가격, 구매 개수를 포함하고 있어야 한다.
→ 요구 사항을 반영한 OrderLine
public class OrderLine {
private Product product;
private int price;
private int quantity;
private int amounts;
public OrderLine(Product product, int price, int quantity, int amounts) {
this.product = product;
this.price = price;
this.quantity = quantity;
this.amounts = amounts;
}
private int calculateAmounts() {
return price * quantity;
}
public int getAmounts() { ... }
...
}
Java
복사
•
한 상품을 얼마에, 몇 개 살지 필드에 담고 있고, calculateAmounts() 메소드로 구매 가격을 구하는 로직을 구현하고 있다.
•
다음 요구사항은 Order와 OrderLine과의 관계를 알려준다.
◦
최소 한 종류 이상의 상품을 주문해야 한다.
▪
Order는 최소 한 개 이상의 OrderLine을 포함해야 한다.
◦
총 주문 금액은 각 상품의 구매 가격 합을 모두 더한 금액이다.
▪
OrderLine으로부터 총 주문 금액을 구할 수 있다.
→ 요구 사항을 반영한 Order
public class Order {
private List<OrderLine> orderLines;
private int totalAmounts;
private OrderState state;
private ShippingInfo shippingInfo;
public Order(List<OrderLine> orderLines) {
setOrderLines(orderLines);
}
private void setOrderLines(List<OrderLine> orderLines) {
verifyAtLestOneOrMoreOrderLines(orderLines);
this.orderLines = orderLines;
calculateTotalAmounts();
}
private void verifyAtLestOneOrMoreOrderLines(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();
}
}
Java
복사
•
ShippingInfo (배송지 정보)
◦
배송지 정보는 이름, 전화번호, 주소 데이터를 가진다.
public class ShippingInfo {
private String receiverName;
private String receiverPhoneNumber;
private String shippingAddress1;
private String shippingAddress2;
private String shippingZipcode;
}
Java
복사
◦
주문할 때 배송지 정보를 반드시 지정해야 한다.
public class Order {
private List<OrderLine> orderLines;
private int totalAmounts;
private OrderState state;
private ShippingInfo shippingInfo;
public Order(List<OrderLine> orderLines) {
setOrderLines(orderLines);
setShippingInfo(shippingInfo);
}
private void setShippingInfo(ShippingInfo shippingInfo) {
if (shippingInfo == null)
throw new IllegalArgumentException("no ShippingInfo");
this.shippingInfo = shippingInfo;
}
}
Java
복사
▪
Order를 생성할 때 OrderLine의 목록뿐만 아니라, ShippingInfo도 함께 전달해야함을 의미한다.
▪
Order의 생성자에서 호출하는 setShippingInfo() 메소드는 ShippingInfo가 null이면 Exception을 발생시킨다.
•
특정 조건에나 상태에 따라 제약이나 규칙이 달리 적용되는 경우
◦
출고를 하면 배송지 정보를 변경할 수 없다.
◦
출고 전에 주문을 취소할 수 있다.
▪
주문은 적어도 출고 상태를 표현할 수 있어야 한다.
▪
고객이 결제를 완료하기 전에는 상품을 준비하지 않는다. 결제 완료 전을 의미하는 상태와, 결제 완료 내지 상품 준비중이라는 상태가 필요하다.
public enum OrderState {
PAYMENT_WAITING, PREPARING, SHIPPED, DELIVERING, DELIVERY_COMPLETED, CANCELED;
public boolean isShippingChangeable() {
return false;
}
}
Java
복사
▪
배송지 변경이나 주문 취소 기능은 출고 전에만 가능하다는 제약 규칙이 있으므로, 이 규칙을 적용하기 위해 changeShippingInfo()와 cancel()은 verifyNotYetShipped() 메소드를 먼저 실행한다.
public class Order {
private OrderState state;
public Order(List<OrderLine> orderLines) {
setOrderLines(orderLines);
setShippingInfo(shippingInfo);
}
public void cancel() {
verifyNotYetShipped();
this.state = OrderState.CANCELED;
}
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
}
}
Java
복사
엔티티와 밸류
엔티티
•
식별자를 갖는다. 식별자는 엔티티 객체마다 고유해서, 각 엔티티는 서로 다른 식별자를 갖는다.
•
엔티티를 생성하고, 엔티티의 속성을 바꾸고, 엔티티를 삭제할 때까지 식별자는 유지된다.
•
엔티티의 식별자는 바뀌지 않고 고유하기 때문에, 두 엔티티 객체의 식별자가 같으면 두 엔티티는 같다고 판단할 수 있다.
엔티티의 식별자 생성
•
엔티티의 식별자를 생성하는 시점은, 도메인의 특징과 사용하는 기술에 따라 달라진다.
•
흔히 식별자는 다음 중 한 가지 방식으로 생성한다.
◦
특정 규칙에 따라 생성
◦
UUID 사용
◦
값을 직접 입력
◦
일련번호 사용(시퀀스나 DB의 자동 증가 컬럼 사용)
밸류 타입
•
밸류 타입은 개념적으로 완전한 하나를 표현할 때 사용한다.
public class ShippingInfo {
private String receiverName;
private String receiverPhoneNumber;
private String shippingAddress1;
private String shippingAddress2;
private String shippingZipcode;
}
Java
복사
◦
receiverName 필드와 receiverPhoneNumber 필드는 서로 다른 두 데이터를 담고 있지만, 두 필드는 개념적으로 받는 사람을 의미한다.
◦
shippingAddress1 필드, shippingAddress2 필드, shippingZipcode 필드는 주소라는 하나의 개념을 표현한다.
•
받는 사람을 위한 밸류 타입인 Receiver를 다음과 같이 작성할 수 있다.
public class Receiver {
private String name;
private String phoneNumber;
public Receiver(String name, String phoneNumber) {
this.name = name;
this.phoneNumber = phoneNumber;
}
}
Java
복사
◦
Receiver는 ‘받는 사람’이라는 도메인 개념을 표현한다. 개념적으로 완전한 하나를 잘 표현할 수 있다.
•
ShippingInfo의 주소 관련 데이터도 Address 밸류 타입을 사용해서 보다 명확하게 표현할 수 있다.
public class Address {
private String address1;
private String address2;
private String zipcode;
}
Java
복사
→ 밸류 타입을 이용해서, ShippingInfo 클래스를 다시 구현할 수 있다. 배송 정보가 받는 사람과 주소로 구성된다는 것을 쉽게 알 수 있다.
public class ShippingInfo {
private Receiver receiver;
private Address address;
}
Java
복사
•
의미를 명확하게 표현하기 위해 밸류타입을 사용하는 경우
◦
OrderLine의 price와 amounts는 int 타입의 숫자를 사용하고 있지만, 이들이 의미하는 값은 ‘돈’이다. 따라서, ‘돈'을 의미하는 Money 타입을 만들어 사용하면 명확한 의미표현이 가능하다.
public class Money {
private int value;
public Money(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
Java
복사
→ Money를 사용하도록 OrderLine을 변경
public class OrderLine {
private Product product;
private Money price;
private int quantity;
private Money amounts;
...
}
Java
복사
•
밸류 타입을 위한 기능 추가
◦
돈을 계산할 수 있는 add, multiply 메소드 추가
public class Money {
private int value;
public Money(int value) {
this.value = value;
}
public Money add(Money money) {
return new Money(this.value + money.value);
}
public Money multiply(int multiplier) {
return new Money(value * multiplier);
}
public int getValue() {
return value;
}
}
Java
복사
◦
Money를 사용하는 OrderLine의 코드 의미 향상
public class OrderLine {
private Product product;
private Money price;
private int quantity;
private Money amounts;
public OrderLine(Product product, Money price, int quantity, Money amounts) {
this.product = product;
this.price = price;
this.quantity = quantity;
this.amounts = amounts;
}
private Money calculateAmounts() {
return price.multiply(quantity);
}
public Money getAmounts() {
return amounts;
}
}
Java
복사
•
밸류 타입은 불변타입이어야 한다.
◦
불변 타입을 사용하면 더욱 안전한 코드를 작성할 수 있다.
◦
Money가 불변 객체가 아니라면, price 파라미터가 변경될 때 발생하는 문제를 방지하기 위해 데이터를 복사한 새로운 객체를 생성해야 한다. (방어적 복사)
public class OrderLine {
private Product product;
private Money price;
private int quantity;
private Money amounts;
public OrderLine(Product product, Money price, int quantity, Money amounts) {
this.product = product;
this.price = new Money(price.getValue());
this.quantity = quantity;
this.amounts = amounts;
}
}
Java
복사
•
두 밸류 객체의 비교
◦
모든 속성이 같은지 비교해야한다.
도메인 모델에 set 메소드 넣지 않기
•
도메인 객체가 불완전한 상태로 사용되는 것을 막으려면, 생성 시점에 필요한 것을 전달해주어야 한다.
•
즉, 생성자를 통해 필요한 데이터를 모두 받아야 한다.
•
생성자로 필요한 것을 모두 받으므로, 생성자 호출 시점에 필요한 데이터가 올바른지 검사할 수 있다.
public class Order {
private List<OrderLine> orderLines;
private int totalAmounts;
private OrderState state;
private ShippingInfo shippingInfo;
private Orderer orderer;
public Order(Orderer orderer, List<OrderLine> orderLines,
ShippingInfo shippingInfo, OrderState orderState) {
setOrderer(orderer);
setOrderLines(orderLines);
setShippingInfo(shippingInfo);
}
}
Java
복사
아키텍쳐 개요
계층 구조 아키텍쳐
•
계층 구조
◦
표현 → 응용 → 도메인 → 인프라스트럭쳐
▪
계층 구조는 상위 계층에서 하위 계층으로의 의존만 존재하고 하위 계층에서 상위 계층에 의존하지는 않는다
▪
계층 구조를 엄격하게 적용하면 상위 계층은 바로 아래 계층에만 의존을 가져야하지만, 구현의 편리함을 위해 계층 구조를 유연하게 적용한다
→ 표현, 응용, 도메인 계층이 상세한 구현 기술을 다루는 인프라스트럭쳐에 의존할 수 있다.
public class CalculateDiscountService {
private DroolsRuleEngine ruleEngine = new DroolsRuleEngine();
public Money calculateDiscount(List<OrderLine> orderLines,
String customerId) {
Customer customer = findCustomer(customerId);
// 초기 돈
MutableMoney money = new MutableMoney(0);
// 조건들 추가하고
List<?> facts = Arrays.asList(customer, money);
facts.addAll(orderLines);
// DroolsRulsEngine을 이용해 할인율 적용
ruleEngine.evaluate("discountCalculation", facts);
return money.toImmutableMoney();
}
}
Java
복사
◦
도메인에 메시지를 보내는 것 외에 특정 엔진을 사용해야하는 상황이다. (DroolsRulsEngine)
◦
하지만 위 코드는 아래 2가지 문제점을 가지고 있다.
▪
테스트하기 어렵다
•
DroolsRuleEngine이 완벽하게 동작해야만 CalculateDiscountService를 테스트할 수 있다
▪
구현 방식을 변경하기 어렵다.
•
DroolsRuleEngine이 아니라 다른 엔진을 사용하도록 변경하고자 한다면 많은 부분이 변경되어야 한다.
DIP
•
인프라스트럭쳐에 의존하면 ‘테스트 어려움’과 ‘기능 확장의 어려움’이라는 두 가지 문제가 발생한다.
◦
인프라스트럭쳐는 실제 구현기술이므로, 표현, 응용, 도메인 계층 대비 저수준 모듈에 속한다.
•
고수준 모듈은 의미 있는 단일 기능을 제공하는 모듈로, CalculateDiscountService는 ‘가격 할인 계산’이라는 기능을 구현하는 고수준 모듈이다. (응용 계층)
◦
가격 할인 계산 기능을 구현하려면, 고객 정보를 구해야하고, 룰을 이용해서 할인 금액을 구해야한다. (고수준 모듈의 하위 기능)
•
저수준 모듈은 아래와 같이 하위 기능을 실제로 구현한 것이다.
◦
JPA를 이용해서 고객 정보를 읽어오는 모듈
◦
Drools로 룰을 실행하는 모듈
•
고수준 모듈은 저수준 모듈을 사용하여 동작하여야 하지만, 이렇게 되면 위에서 언급한 문제점이 생기게 된다.
◦
따라서, 반대로 저수준 모듈이 고수준 모듈에 의존하도록 해야한다. → 추상화한 인터페이스를 사용한다.
•
CalculateDiscountService 입장에서 봤을 때에는, 룰 적용을 Drools로 구현했는지, 자바로 직접 구현했는지는 중요하지 않다. ‘고객 정보와 구매 정보에 룰을 적용해서 할인 금액을 구한다.’라는 것만이 중요하다.
◦
이를 추상화한 인터페이스는 아래와 같다.
public interface RuleDiscounter {
public Money applyRules(Customer customer, List<OrderLine> orderLines);
}
Java
복사
◦
CalculateDiscountService가 RuleDiscounter를 이용하도록 변경한다.
public class CalculateDiscountService {
private RuleDiscounter ruleDiscounter;
public CalculateDiscountService(RuleDiscounter ruleDiscounter) {
this.ruleDiscounter = ruleDiscounter;
}
public Money calculateDiscount(List<OrderLine> orderLines, String customerId) {
Customer customer = findCustomer(customerId);
return ruleDiscounter.applyRules(customer, orderLines);
}
}
Java
복사
▪
CalculateDiscountService는 더 이상 구현 기술인 Drools에 의존하지 않는다.
▪
‘룰을 이용한 할인 금액 계산’을 추상화한 RuleDiscounter 인터페이스에 의존한다.
▪
DroolsRuleDiscounter는 고수준의 하위 기능인 RuleDiscounter를 구현한 것이므로 저수준 모듈에 속한다.
◦
구현 기술을 변경하더라도 CalculateDiscountService를 수정할 필요가 없다.
•
위와 같이, 저수준 모듈이 고수준 모듈에 의존하는 것을 DIP(의존 관계 역전)이라고 한다.
DIP 주의사항
•
단순히 인터페이스와 구현 클래스를 분리하는 것이 아니다.
•
핵심은 고수준 모듈이 저수준 모듈에 의존하지 않도록 하기 위함이다!
•
DIP를 적용할 때 하위 기능을 추상화한 인터페이스는 고수준 모듈 관점에서 도출한다.
◦
CalculateDiscountService 입장에서 할인 금액을 구하기 위해 룰 엔진을 사용하는지, 직접 연산하는지 여부는 중요하지 않다. 단지 규칙에 따라 할인 금액을 계산한다는 것이 중요할 뿐이다.
→ ‘할인 금액 계산’을 추상화한 인터페이스는 저수준 모듈이 아닌 고수준 모듈에 위치한다.
DIP와 아키텍쳐
•
인프라스트럭쳐 영역은 구현 기술을 다루는 저수준 모듈
•
응용 영역과 도메인 영역은 고수준 모듈
◦
DIP를 적용하면, 인프라스트럭쳐 영역이 응용영역과 도메인 영역에 의존(상속)하는 구조가 된다!
◦
도메인과 응용 영역에 대한 영향을 주지 않거나, 최소화하면서 구현 기술을 변경하는 것이 가능하다.
도메인 영역의 주요 구성요소
엔티티
•
고유의 식별자를 갖는 객체로 자신의 라이프사이클을 갖는다.
•
주문, 회원, 상품과 같이 도메인의 고유한 개념을 표현한다.
•
도메인 모델의 데이터를 포함하며, 해당 데이터와 관련된 기능을 함께 제공한다.
밸류
•
고유의 식별자를 갖지 않는 객체로, 주로 개념적으로 하나인 도메인 객체의 속성을 표현할 때 사용한다.
•
배송지 주소를 표현하기 위한 주소(Address), 구매 금액을 위한 금액(Money)
•
엔티티의 속성으로 사용될 뿐만 아니라, 다른 밸류 타입의 속성으로도 사용할 수 있다.
애그리거트
•
애그리거트는 관련된 앤티티와 밸류 객체를 개념적으로 하나로 묶은 것이다.
•
주문과 관련된 Order 엔티티, OrderLine 밸류, Orderer 밸류 객체를 ‘주문' 에그리거트로 묶을 수 있다.
리포지터리
•
도메인 모델의 영속성을 처리한다.
•
DBMS 테이블에서 엔티티 객체를 로딩하거나 저장하는 기능을 제공한다.
도메인 서비스
•
특정 엔티티에 속하지 않은 도메인 로직을 제공한다.
•
‘할인 금액 계산’은 상품, 쿠폰, 회원 등급, 구매 금액 등 다양한 조건을 이용해서 구현하게 되는데, 이렇게 도메인 로직이 여러 엔티티와 벨류를 필요로 할 경우 도메인 서비스에서 로직을 구현한다.
엔티티와 밸류
•
DB 테이블의 엔티티와 도메인 모델의 엔티티는 같은 것이 아니다.
•
도메인 모델의 엔티티는 데이터와 함께 도메인 기능을 함께 제공한다.
◦
주문을 표현하는 엔티티는 주문과 관련된 데이터뿐만 아니라, 배송지 주소 변경을 위한 기능을 함께 제공한다.
•
도메인 모델의 엔티티는 단순히 데이터를 담고 있는 데이터 구조라기보다는, 데이터와 함께 기능을 제공하는 객체이다.
◦
도메인 관점에서 기능을 구현하고, 기능 구현을 캡슐화해서 데이터가 임의로 변경되는 것을 막는다.
•
도메인 모델의 엔티티는 두 개 이상의 데이터가 개념적으로 하나인 경우 밸류 타입을 이용해서 표현할 수 있다.
◦
RDBMS와 같은 관계형 데이터베이스는 밸류 타입을 제대로 표현하기 힘들다.
◦
Order 객체의 데이터를 저장하기 위한 테이블은 Orderer의 개별 데이터를 저장하거나, 별도 테이블로 분리해서 저장해야한다.
애그리거트
•
도메인이 커질수록 개발할 도메인 모델도 커지고, 많은 엔티티와 밸류가 생기고, 모델은 점점 더 복잡해진다.
•
개별 객체뿐만아니라, 상위 수준에서 모델을 볼 수 있어야 전체 모델과 개별 모델을 이해하는데 도움이 되고, 이것이 바로 애그리거트이다.
•
애그리거트는 관련 객체를 하나로 묵은 군집이다.
◦
예) 주문이라는 도메인 개념은 ‘주문’, ‘배송지 정보’, ‘주문자’, ‘주문 목록’, ‘총 결제 금액’의 하위 모델로 구성된다. → 이 하위 개념을 포함한 모델을 하나로 묶어서 ‘주문’이라는 상위 개념으로 표현할 수 있다.
•
애그리거트는 군집에 속한 객체들을 관리하는 루트 엔티티를 갖는다.
◦
루트 엔티티는 애그리거트에 속해 있는 엔티티와 밸류 객체를 이용해서, 애그리거트가 구현해야할 기능을 제공한다.
◦
애그리거트를 사용하는 코드는 애그리거트 루트가 제공하는 기능을 실행하고, 애그리거트 루트를 통해서 간접적으로 애그리거트 내의 다른 엔티티나 밸류 객체에 접근하게 된다.
▪
애그리거트의 내부 구현을 숨겨 애그리거트 단위로 구현을 캡슐화할 수 있도록 돕는다.
리포지토리
•
엔티티나 밸류가 요구사항에서 도출되는 도메인 모델이라면, 리포지토리는 구현을 위한 도메인 모델이다.
•
리포지토리는 애그리거트 단위로 도메인 객체를 저장하고 조회하는 기능을 정의한다.
◦
도메인 모델 관점에서 리포지토리는 도메인 객체를 영속화하는 데 필요한 기능을 추상화 한 것으로 고수준 모듈에 속한다. (OrderRepository)
◦
Repository를 구현한 클래스는 저수준 모듈로 인프라스트럭쳐 영역에 속한다. (JpaOrderRepository)
•
응용 서비스와 리포지토리는 밀접한 연관이 있다.
◦
응용 서비스는 필요한 도메인 객체를 구하거나 저장할 때, 리포지토리를 사용한다.
◦
응용 서비스는 트랜잭션을 관리하는데, 트랜잭션 처리는 리포지토리 구현 기술에 영향을 받는다.
•
리포지토리의 사용 주체가 응용 서비스이기 때문에, 리포지토리는 응용 서비스가 필요로 하는 메소드를 제공한다.
public interface SomeRepository {
void save(Some some);
Some findbById(SomeId id);
}
Java
복사
◦
애그리거트를 저장하는 메소드
◦
애그리거트 루트 식별자로 애그리거트를 조회하는 매소드
◦
이 외에 필요에 따라 delete(id) 나 counts()등의 메소드를 제공
요청 처리 흐름
표현영역
•
사용자가 전송한 데이터 형식이 올바른지 검사
•
문제가 없는 경우, 데이터를 이용해서 응용 서비스에 기능 실행 위임
◦
사용자가 전송한 데이터를 응용 서비스가 요구하는 형식으로 변환해서 전달
응용서비스 영역
•
도메인 모델을 이용해서 기능 구현
◦
기능 구현에 필요한 도메인 객체를 리포지토리에서 가져와 실행하거나, 신규 도메인 객체를 생성해서 리포지토리에 저장
◦
두 개 이상의 도매인 객체를 사용해서 구현하기도 한다.
•
예매하기나 예매 취소와 같은 기능을 제공하는 응용서비스는 도메인의 상태를 변경하므로, 변경 상태가 물리 저장소에 올바르게 반영되도록 트랜잭션을 관리해야한다.
모듈 구성
•
아키텍쳐의 각 영역은 별도 패키지에 위치한다.
•
도메인이 크면 하위 도메인으로 나누고, 각 하위 도메인마다 별도 패키지를 구성한다.
•
각 애그리거트와 모델과 리포지토리는 같은 패키지에 위치시킨다.
애그리거트
애그리거트
•
복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만들기 위해, 상위 수준에서 모델을 조망할 수 있는 방법
•
애그리거트는 관련된 객체를 하나의 군으로 묶어준다.
•
수많은 객체를 애그리거트로 묶어서 바라보면 좀 더 상위 수준에서 도메인 모델 관의 관계를 파악할 수 있다.
•
애그리거트는 일관성을 관리하는 기준이 된다.
◦
모델을 보다 잘 이해할 수 있고, 애그리거트 단위로 일관성을 관리하기 때문에 애그리거트는 복잡한 도메인을 단순한 구조로 만들어준다.
◦
복잡도가 낮아지는 만큼 도메인 기능을 확장하고 변경하는 데 필요한 노력(개발 시간)도 줄어든다.
•
애그리거트는 관련된 모델을 하나로 모은 것이기 때문에, 애그리거트에 속한 객체는 유사하거나 동일한 라이프사이클을 갖는다.
◦
애그리거트에 속한 구성요소는 대부분 함께 생성하고 제거한다.
•
애그리거트는 경계를 갖는다. 한 애그리거트에 속하나 객체는 다른 애그리거트에 속하지 않는다.
◦
애그리거트는 독립된 개체군이며, 각 애그리거트는 자기 자신을 관리할 뿐 다른 애그리거트를 관리하지 않는다.
•
경계를 설정할 때 기본이 되는 것은 도메인 규칙과 요구사항이다.
◦
도메인 규칙에 따라 함께 생성되는 구성요소는 한 애그리거트에 속할 가능성이 높다.
애그리거트 루트
•
애그리거트는 여러 객체로 구성되기 때문에, 한 객체만 상태가 정상이어서는 안 된다. 도메인 규칙을 지키려면 애그리거트에 속한 모든 객체가 정상 상태를 가져야한다.
◦
주문 애그리거트는 다음을 포함한다.
▪
총 금액인 totalAmounts를 갖고 있는 Order 엔티티
▪
개별 구매 상품의 개수인 quantity와 금액인 price를 갖고 있는 OrderLine 밸류
◦
구매할 상품의 개수 변경 → 한 OrderLine의 quantity 변경 → Order의 totalAmounts 변경
•
애그리거트 루트 앤티티
◦
애그리거트에 속한 객체가 일관된 상태를 유지하기 위해 애그리거트 전체를 관리할 주체
◦
애그리거트의 대표 엔티티
◦
애그리거트에 속한 객체는 애그리거트 루트 엔티티에 직접 또는 간접적으로 속한다.
▪
주문 애그리거트에서 루트 역할을 하는 엔티티는 Order이다.
▪
OrderLine, ShippingInfo, Orderer 등 주문 애그리거트에 속한 모델은 Order에 직접 또는 간접적으로 속한다.
도메인 규칙과 일관성
•
애그리거트 루트의 핵심 역할은 애그리거트의 일관성이 깨지지 않도록 하는 것이다.
◦
배송이 시작되기 전까지만 배송지 정보를 변경할 수 있다는 규칙이 있다면, 애그리거트 루트인 Order의 changeShippingInfo() 메소드는 이 규칙에 따라 배송 시작 여부를 확인하고, 변경이 가능한 경우에만 배송지 정보를 변경해야 한다.
// 애그리거트 루트는 도메인 규칙을 구현한 기능을 제공한다.
public void changeShippingInfo(ShippingInfo newShippingInfo) {
verifyNotYetShipped();
setShippingInfo(newShippingInfo);
}
private void verifyNotYetShipped() {
if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING)
throw new IllegalStateException("already shipped!");
}
Java
복사
•
애그리거트 루트가 아닌 다른 객체가 애그리거트에 속한 객체를 직접 변경하면 안된다.
◦
애그리거트 루트가 강제하는 규칙을 적용할 수 없어 모델의 일관성을 깨는 원인이 된다.
ShippingInfo si = order.getShippingInfo();
si.setAddress(newAddress);
Java
복사
◦
위 코드는 주문 상태에 상관없이 배송지 주소를 변경할 수 있다.
▪
논리적인 데이터 일관성을 깨는 결과를 낳는다.
▪
일관성을 지키기 위해 아래와 같이 상태 확인 로직을 응용 서비스에 구현할 수도 있지만, 이렇게 되면 동일한 검사 로직을 여러 응용 서비스에서 중복해서 구현할 가능성이 높아진다.
ShippingInfo si = order.getShippingInfo();
if (state != OrderState.PAYMENT_WAITING && state != OrderState.PREPARING)
throw new IllegalStateException("already shipped!");
si.setAddress(newAddress);
Java
복사
•
아래 두 규칙을 통해 불필요한 중복을 피하고 애그리거트 루트를 통해서만 도메인 로직을 구현하게 만들어야한다.
◦
단순히 필드를 변경하는 set 메소드를 공개(public) 범위로 만들지 않는다.
▪
의미가 드러나는 메소드를 사용해서 구현할 가능성이 높아진다.
▪
cancel이나, changePassword처럼 의미가 더 잘 드러나는 이름을 사용하는 빈도가 높아진다.
◦
밸류 타입은 불변으로 구현한다.
▪
밸류 객체의 값을 변경할 수 없으면 애그리거트 루트에서 밸류 객체를 구해도 값을 변경할 수 없기 때문에 애그리거트 외부에서 밸류 객체의 상태를 변경할 수 없게 된다.
▪
애그리거트 외부에서 내부 상태를 함부로 바꾸지 못하므로, 애그리거트의 일관성이 깨질 가능성이 줄어든다.
▪
밸류 객체가 불변이면 밸류 객체의 값을 변경하는 방법은 새로운 밸류 객체를 할당하는 것뿐이다.
트랜잭션 범위
•
트랜잭션 범위는 작을수록 좋다.
◦
DB 테이블을 기준으로 한 트랜잭션이 한 개 테이블을 수정하는 것과 세 개의 테이블을 수정하는 것은 성능에서 차이가 발생한다.
•
한 트랜잭션에서는 한 개의 애그리거트만 수정해야 한다.
◦
한 트랜잭션에서 두 개 이상의 애그리거트를 수정하면 트랜잭션 충돌이 발생할 가능성이 더 높아진다.
◦
한번에 수정하는 애그리거트 개수가 많아 질수록 전체 처리량이 떨어지게 된다.
•
애그리거트에서 다른 애그리거트를 변경하지 않는다.
◦
한 애그리거트에서 다른 애그리거트를 수정하면 결과적으로 두 개의 애그리거트를 한 트랜잭션에서 수정하게 되므로, 한 애그리거트 내부에서 다른 애그리거트의 상태를 변경하는 기능을 실행하면 안 된다.
◦
한 애그리거트가 다른 애그리거트의 기능에 의존하기 시작하면, 애그리거트 간 결합도가 높아지게 된다.
◦
결합도가 높아지면 높아질수록, 향후 수정 비용이 증가하므로 애그리거트에서 다른 애그리거트의 상태를 변경하지 말아야 한다.
◦
부득이하게 한 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면, 애그리거트에서 다른 애그리거트를 직접 수정하지 말고, 응용 서비스에서 두 애그리거트를 수정하도록 구현해야 한다.
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());
}
}
...
}
Java
복사
응용 서비스와 표현 영역
표현 영역과 응용 영역
표현영역
•
사용자의 요청을 해석한다.
•
사용자가 웹 브라우져에서 폼에 아이디와 암호를 입력한 뒤 전송 버튼을 클릭 → 요청 파라미터를 포함한 Http 요청을 표현 영역에 전달 → 요청을 받은 표현 영역은 URL, 요청 파라미터, 쿠키, 헤더 등을 이용해서 사용자가 어떤 기능을 실행하고 싶어하는지 판별 → 응용서비스 실행 → 실행 결과를 사용자에 알맞은 형식으로 응답
•
사용자와의 상호작용은 표현 영역이 처리하므로, 응용 서비스는 표현 영역에 의존하지 않는다.
◦
응용 영역은 사용자가 웹 브라우져를 사용하는지, REST API를 호출하는지, TCP 소켓을 사용하는지 여부를 알 필요가 없다.
응용 서비스의 역할
•
사용자(클라이언트)가 요청한 기능을 실행한다.
•
사용자의 요청을 처리하기 위해 리포지토리로부터 도매인 객체를 구하고, 도메인 객체를 사용한다.
•
응용 서비스는 주로 도메인 객체 간의 흐름을 제어하기 때문에, 다음과 같이 단순한 형태를 갖는다.
◦
도메인 객체간 흐름 제어
public Result doSomeFunc(SomeReq req) {
// 1. 리포지토리에서 애그리거트를 구한다.
SomeaAgg agg = someAggRepository.findById(req.getId());
checkNull(agg);
// 2. 애그리거트의 도메인 기능을 실행한다.
agg.doFunc(req.getValue());
// 3. 결과를 리턴한다.
return createSuccessResult(agg);
}
Java
복사
◦
새로운 애그리거트 생성
public Result doSomeCreation(CreateSomeReq req) {
// 1. 데이터 중복 등 데이터가 유효한지 검사한다.
checkValid(req);
// 2. 애그리거트를 생성한다.
SomeAgg newAgg = createSome(req);
// 3. 리포지토리에 애그리거트를 저장한다.
someAggRepository.save(newAgg);
// 4. 결과를 리턴한다.
return createSuccessResult(newAgg);
}
Java
복사
◦
트랜잭션 처리
▪
응용서비스는 도메인의 상태 변경을 트랜잭션으로 처리해야한다.
public void blockMembers(String[] blockingIds) {
if (blockingIds == null || blockingIds.length == 0) return;
List<Member> members = memberRepository.findByIds(blockingIds);
for (Member mem : members) {
mem.block();
}
}
Java
복사
→ 응용 서비스가 이것보다 복잡하다면, 응용 서비스에서 도메인 로직의 일부를 구현하고 있을 가능성이 높다.
도메인 로직 넣지 않기
•
도메인 로직은 도메인 영역에 위치하고 응용 서비스는 도메인 로직을 구현하지 않는다.
•
도메인 로직을 도메인 영역과 응용 서비스에 분산해서 구현하면 코드 품질에 문제가 발생한다.
◦
코드의 응집성이 떨어진다.
▪
도메인 데이터와 그 데이터를 조작하는 도메인 로직이 한 영역에 위치하지 않고 서로 다른 영역에 위치한다는 것은 도메인 로직을 파악하기 위해 여러 영역을 분석해야 한다는 것을 뜻한다.
◦
여러 응용 서비스에서 동일한 도메인 로직을 구현할 가능성이 높아진다.
→ 결과적으로 코드 변경을 어렵게 만든다. (소프트웨어 가치가 떨어진다.)
응용 서비스의 구현
•
응용 서비스는 표현 영역과 도메인 영역을 연결하는 매개체 역할을 한다. (디자인 패턴에서의 파사드와 같은 역할)
응용 서비스의 크기
•
응용 서비스는 보통 다음의 두가지 방법 중 한 가지 방식으로 구현한다.
◦
한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기
▪
장점: 동일한 로직을 위한 코드 중복을 제거하는 것이 쉽다.
▪
단점: 한 서비스의 클래스가 커진다.
◦
구분되는 기능별로 응용 서비스 클래스를 따로 구현하기
▪
한 응용 서비스 클래스에서 한 개 내지 2~3개의 기능을 구현한다.
▪
클래스 개수는 많아지지만 한 클래스에 관련 기능을 모두 구현하는 것과 비교해서, 코드 품질을 일정 수준으로 유지하는데 도움이 된다.
표현 영역에 의존하지 않기
•
응용 서비스의 파라미터 타입을 결정할 때, 표현 영역과 관련된 타입을 사용하지 않도록 주의한다.
◦
HttpServletRequest나 HttpSession을 응용 서비스에 파라미터로 전달하면 안 된다.
•
응용 서비스에서 표현 영역에 대한 의존이 발생하면, 응용 서비스만 단독으로 테스트하기가 어려워진다.
◦
표현 영역의 구현이 변경되면 응용 서비스의 구현도 함께 변경해야하는 문제가 생긴다.
•
응용 서비스가 표현 영역의 역할까지 대신하는 일이 벌어질 수 도 있다.
표현 영역
•
사용자가 시스템을 사용할 수 있는 (화면)흐름을 제공하고 제어한다.
•
사용자의 요청을 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다.
•
사용자의 세션을 관리한다.
값 검증
•
값 검증은 표현 영역과 응용 서비스 두 곳에서 모두 수행할 수 있다.
•
원칙적으로, 모든 값에 대한 검증은 응용 서비스에서 처리한다.
◦
표현 영역에서 필수 값과 값의 형식을 검사
◦
응용 서비스는 아이디 중복 여부와 같은 논리적 오류만 검사
◦
같은 값 검사를 표현 영역과 응용 서비스에서 중복해서 할 필요는 없다.
도메인 서비스
여러 애그리거트가 필요한 기능
•
한 애그리거트로 기능을 구현할 수 없을 때
◦
결제 금액 계산 로직
▪
상품 애그리거트 : 구매하는 상품의 가격이 필요하다. 또는 상품에 따라 배송비가 추가되기도 한다.
▪
주문 애그리거트 : 상품별로 구매 개수가 필요하다.
▪
할인 쿠폰 애그리거트 : 쿠폰별로 지정한 할인 금액이나 비율에 따라 주문 총 금액을 할인한다. 할인 쿠폰을 조건에 따라 중복 사용할 수 있다거나 지정한 카태고리의 상품에만 적용할 수 있다는 제약 조건이 있다면 할인 계산이 복잡해진다.
▪
회원 애그리거트 : 회원 등급에 따라 추가 할인이 가능하다.
→ 여기서 실제 결제 금액을 계산해야하는 주체는 누구일까?
◦
해결 방안 1
▪
주문 애그리거트가 필요한 애그리거트나 필요 데이터를 모두 가지도록 한 뒤, 할인 금액 계산 책임을 주문 애그리거트에 할당한다.
→ 그러나, 결제 금액 계산 로직이 정말로 주문 애그리거트의 책임이 맞을까?
•
한 애그리거트에 넣기에 애매한 도메인 기능을 특정 애그리거트에서 억지로 구현하면 안 된다.
•
애그리거트는 자신의 책임 범위를 넘어서는 기능을 구현하기 때문에, 코드가 길어지고 외부에 대한 의존이 높아지게 되며, 코드를 복잡하게 만들어 수정을 어렵게 만든다.
◦
해결방안 2
▪
도메인 서비스를 별도로 구현한다.
도메인 서비스
•
한 애그리거트에 넣기 애매한 도메인 개념을 구현하려면 애그리거트에 억지로 넣기보다는 도메인 서비스를 이용해서 도메인 개념을 명시적으로 드러내면 된다.
•
도메인 서비스가 도메인 영역의 애그리거트나 밸류와 같은 다른 구성요소와 비교할 때 다른 점이 있다면, 상태 없이 로직만 구현한다는 점이다.
◦
도메인 서비스를 구현하는 데 필요한 상태는 애그리거트나 다른 방법으로 전달받는다.
•
할인 금액 계산 로직을 위한 도메인 서비스는 다음과 같이 도메인의 의미가 드러나는 용어를 타입과 메소드 이름으로 갖는다.
public class DiscountCalculationService {
public Money calculateDiscountAmounts(List<OrderLine> orderLines,
List<Coupon> coupons,
MemberGrade grade) {
Money couponDiscount =
coupons.stream()
.map(coupon -> calculateDiscountAmounts(coupon))
.reduce(new Money(0), (v1, v2) -> v1.add(v2));
Money membershipDiscount =
calculateDiscountAmounts(grade);
return couponDiscount.add(membershipDiscount);
}
private Money calculateDiscountAmounts(Coupon coupon) {
//
}
private Money calculateDiscountAmounts(MemberGrade grade) {
//
}
}
Java
복사
특정 기능이 응용 서비스인지 도메인 서비스인지 감을 잡기 어려울 때
→ 해당 로직이 애그리거트의 상태를 변경하거나 애그리거트의 상태 값을 계산하는지 검사한다.
예) 계좌 이체 로직은 계좌 애그리거트의 상태를 변경한다.
결제 금액 로직은 주문 애그리거트의 주문 금액을 계산한다.
이 두 로직은 각각 애그리거트를 변경하고 애그리거트의 값을 계산하는 도메인 로직이다.
도메인 로직이면서 한 애그리거트에 넣기 적합하지 않으므로 이 로직은 도메인 서비스로 구현하게 된다.
애그리거트 트랜잭션 관리
애그리거트와 트랜잭션
•
한 애그리거트를 두 사용자가 거의 동시에 변경할 때, 트랜잭션이 필요하다.
◦
메모리 캐시를 사용하지 않을 경우 운영자 스레드와 고객 스레드는 같은 주문 애그리거트를 나타내는 다른 객체를 구하게 된다. (트랜잭션마다 리포지토리는 새로운 애그리거트 객체를 생성한다.)
▪
운영자 스레드와 고객 스레드는 개념적으로 동일한 애그리거트이지만, 물리적으로 서로 다른 애그리거트 객체를 사용한다.
▪
때문에, 운영자 스레드가 주문 애그리거트 객체를 배송 상태로 변경하더라도 고객 스레드가 사용하는 주문 애그리거트 객체에는 영향을 주지 않는다.
▪
고객 스레드 입장에서 주문 애그리거트 객체는 아직 배송 상태 전이므로 배송지 정보를 변경할 수 있다.
▪
이 상황에서 두 스레드는 각각 트랜잭션을 커밋할 때 수정한 내용을 DBMS에 바녕ㅇ한다.
▪
즉, 배송 상태로 바뀌고 배송지 정보도 바뀌게 된다.
▪
이 순서의 문제점은 운영자는 기존 배송지 정보를 이용해서 배송 상태로 변경 햇는데 그 사이 고객은 배송지 정보를 변경했다는 점이다.
→ 즉, 애그리거트의 일관성이 깨지는 것이다.
•
위와 같은 문제를 발생하지 않도록 하려면 다음의 두 가지 중 하나를 해야한다.
◦
운영자가 배송지 정보를 조회하고 상태를 변경하는 동안, 고객이 애그리거트를 수정하지 못하게 막는다.
◦
운영자가 배송지 정보를 조회한 이후에 고객이 정보를 변경하면 운영자가 애그리거트를 다시 조회한 뒤 수정하도록 한다.
→ 애그리거트의 트랜잭션 처리 기법을 통해 처리한다.
선점 잠금 (비관적 잠금, Pessimistic Lock)
•
선점 잠금(Pessimistic Lock)은 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다를ㄴ 스레드가 해당 에그리거트를 수정하는 것을 막는 방식이다.
◦
스레드1이 선점 잠금 방식으로 애그리거트를 구한다.
◦
이어서 스레드2가 같은 애그리거트를 구한다.
→ 이 경우 스레드2는 스레드1이 애그리거트에 대한 잠금을 해제할 때까지 블로킹된다.
◦
스레드1이 애그리거트를 수정하고 트랜잭션을 커밋하면 잠금을 해제한다.
◦
이 순간, 대기하고 있던 스레드2가 애그리거트에 접근하게 된다.
◦
스레드1이 트랜잭션을 커밋한 뒤에 스레드2가 애그리거트를 구하게 되므로 스레드2는 스레드1이 수정한 애그리거트의 내용을 보게 된다.
한 스레드가 애그리거트를 구하고 수정하는 동안 다른 스레드가 수정할 수 없으므로, 동시에 애그리거트를 수정할 때 발생하는 데이터 충돌 문제를 해소할 수 있다.
•
선점 잠금은 보통 DBMS가 제공하는 행 단위 잠금을 사용해서 구현한다.
◦
오라클을 비롯한 다수 DBMS가 for update와 같은 쿼리를 사용해서 특정 레코드에 한 사용자만 접근할 수 있는 잠금 장치를 제공한다.
◦
JPA의 EntityManager는 LockModeType을 인자로 받는 find() 메소드를 제공하는데, LockModeType.PESSIMISTIC_WRITE를 값으로 전달하면 해당 엔티티와 매핑된 테이블을 이용해서 선점 잠금 방식을 적용할 수 있다.
Order order = entityManater.find(Order.lcass, orderNo, LockModeType.PESSIMISTIC_WRITE);
Java
복사
◦
참고) Spring Data JPA에서 Lock (실전! Spring DATA JPA - JPA Hint & Lock)
@Lock(LockModeType.PESSIMISTIC_WRITE)
List<Member> findLockByUsername(String username);
Java
복사
선점 잠금과 교착 상태
•
선점 잠금 기능을 사용할 때는 잠금 순서에 따른 교착 상태(deadlock)가 발생하지 않도록 주의해야 한다.
1.
스레드1: A 애그리거트에 대한 선점 잠금 구함
2.
스레드2: B 애그리거트에 대한 선점 잠금 구함
3.
스레드1: B 애그리거트에 대한 선점 잠금 시도
4.
스레드2: A 애그리거트에 대한 선점 잠금 시도
→ 이 순서에 따르면, 스레드1은 영원히 B 애그리거트에 대한 선점 잠금을 구할 수 없다. 스레드2가 B 애그리거트에 대한 잠금을 이미 선점하고 있기 때문
→ 스레드2는 A애그리거트에 대한 잠금을 구할 수 없다. 두 스레드는 상대방 스레드가 먼저 선점한 잠금을 구할 수 없어 더이상 다음 단계를 진행할 수 없는 교착 상태에 빠지게 된다.
•
선점 잠금에 따른 교착 상태는 상대적으로 사용자 수가 많을 때 발생할 가능성이 높고, 사용자 수가 많아지면 교착 상태에 빠지는 스레드가 더 빠르게 증가하게 된다.
•
이런 문제가 발생하지 않도록 하려면 잠금을 구할 때 최대 대기시간을 지정해야한다. JPA에서 선점 잠금을 시도할 때 최대 대기 시간을 지정하려면 다음과 같이 힌트를 사용하면 된다.
◦
JPA에서 대기 시간 힌트 사용
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistende.lock.timeout", 2000);
Order order = entityManager.find(Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, hints);
Java
복사
◦
Spring Data JPA에서 대기 시간 힌트 사용
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="5000")})
Optional<Post> findById(Long id)
Java
복사
비선점 잠금 (Optimistic Lock, 낙관적 잠금)
•
선점 잠금으로 해결할 수 없는 트랜잭션 충돌 문제
1.
운영자는 배송을 위해 주문 정보를 조회한다. 시스템은 정보를 제공한다.
2.
고객이 배송지 변경을 위해 변경 폼을 요청한다. 시스템은 변경 폼을 제공한다.
3.
고객이 새로운 배송지를 입력하고 폼을 전송해서 배송지를 변경한다.
4.
운영자가 1번에서 조회한 주문 정보를 기준으로 배송지를 정하고 배송 상태 변경을 요청한다.
→ 운영자가 배송지 정보를 조회하고 배송 상태로 변경하는 사이에 고객이 배송지를 변경한다.
•
위와 같이 선점 잠금 방식으로는 해결할 수 없는 문제를 해결하는 방식이 비선점 잠금(Optimistic Lock)이다.
•
비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입의 프로퍼티를 추가해야한다.
◦
애그리거트를 수정할 때마다 아래와 같은 쿼리를 사용해 버전으로 사용할 프로퍼티의 값이 1씩 증가한다.
UPDATE aggtable SET version = version + 1, colx = ? , coly = ?
WHERE aggid = ? and version = 현재 버젼
SQL
복사
◦
수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정한다.
◦
수정에 성공하면 버전 값을 1 증가시킨다.
◦
따라서, 다른 트랜잭션이 먼저 데이터를 수정해서 버전 값이 바뀌면 데이터 수정에 실패하게 된다.
•
JPA는 아래와 같이 버전을 이용한 비선점 잠금 기능을 지원한다.
@Entity
@Table(name = "pusrchase_order")@Access(AccessType.FIELD)
public class Order {
@Version
private long version;
}
Java
복사
•
비선점 잠금을 위한 쿼리를 실행할 때, 쿼리 실행 결과로 수정된 행의 개수가 0이면 이미 누군가 앞서 데이터를 수정한 것이다.
◦
이는 트랜잭션이 충돌한 것이므로, 트랜잭션 종료 시점에 익셉션이 발생한다.OptimisticLockingFailureException
◦
익셉션 발생 여부에 따라 트랜잭션 충돌이 일어났는지 표현 영역에서 확인할 수 있다.
•
비선점 잠금을 통해 위 그림에 대한 트랜잭션 충돌 방지를 아래와 같이 확장할 수 있다.
◦
2.1.2에서 버전 A와 B사이의 차이가 발생하기 때문에, 시스템은 운영자가 이전 데이터를 기준으로 작업을 요청한 것으로 간주하여 수정할 수 없다는 에러를 응답으로 전송한다.
◦
만약 버전 A와 버전 B가 같다면, 과정 1과 과정 2 사이에 누구도 애그리거트를 수정하지 않은 것이다.
▪
이 경우, 시스템은 과정 2.1.3과 같이 애그리거트를 수정하고, 과정 2.1.4를 이용해서 변경 내용을 DBMS에 반영한다.
◦
위 그림과 같이 비선점 잠금 방식을 여러 트랜잭션으로 확장하려면, 애그리거트 정보를 뷰로 보여줄 때 버전 정보도 함께 사용자 화면에 전달해야 한다.
▪
사용자 요청을 처리하는 응용 서비스를 위한 요청 데이터는 사용자가 전송한 버전 값을 포함한다.
▪
응용 서비스는 전달받은 버전 값을 이용해서 애그리거트의 버전과 일치하는지 확인하고, 일치하는 경우에만 요청한 기능을 수행한다.
▪
응용 서비스는 버전이 충돌하면 익셉션을 발생시켜 표현 계층에 이를 알린다.
◦
표현 계층은 버전 충돌 익셉션이 발생하면, 버전 충돌을 사용자에게 알려주고 사용자가 알맞은 후속 처리를 할 수 있도록 한다.
@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";
}
}
...
Java
복사
◦
비선점 잠금과 관련해서 발생하는 두 개의 익셉션 (위 코드 참고)
▪
OptimisticLockingFailureException
•
스프림 프레임워크가 발생시키는 익셉션
•
누군가가 거의 동시에 애그리거트를 수정했다는 것을 의미
▪
VersionConflictException
•
응용 서비스 코드에서 발생시키는 익셉션
•
이미 누군가가 애그리거트를 수정했다는 것을 의미
→ 버전 충돌 상황에 대한 구분이 명시적으로 필요 없다면, 응용 서비스에서 프레임워크용 익셉션을 발생시키도록 구현해도 된다.
강제 버전 증가
•
애그리거트 루트가 아닌 다른 엔티티의 값만 변경되었을 경우 → JPA는 루트 엔티티의 버전값을 증가시키지 않는다.
•
애그리거트 관점에서 보면, 위와 같이 루트 엔티티의 값이 바뀌지 않았더라도 애그리거트의 구성요소 중 일부 값이 바뀌면 논리적으로 그 애그리거트는 바뀐것이다. → 애그리거트 내에 어떤 구성요소의 상태가 바뀌면 루트 애그리거트의 버전 값을 증가해야 비선점 잠금이 올바르게 동작한다.
•
JPA는 이런 문제를 처리할 수 있도록 EntityManager#find() 메소드로 엔티티를 구할 때 강제로 버전 값을 증가시키는 잠금 모드를 지원하고 있다.
entityManager.find(Order.class, id, LockModeType.OPTIMISTIC_FORCE_INCREMENT);
Java
복사
◦
LockModeType.OPTIMISTIC_FORCE_INCREMENT
▪
해당 엔티티의 상태가 변경되었는지 여부에 상관없이 트랜잭션 종료 시점에 버전 값 증가처리를 한다.
오프라인 선점 잠금
•
단일 트랜잭션에서 동시 변경을 막는 선점 잠금 방식과 달리, 오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다.
◦
첫 번째 트랜잭션을 시작할 때 오프라인 잠금을 선점하고, 마지막 트랜잭션에서 잠금을 해제한다.
◦
잠금을 해제하기 전까지 다른 사용자는 잠금을 구할 수 없다.
◦
사용자A가 과정 3의 수정 요청을 수행하지 않고 프로그램을 종료한다면? → 잠금을 해제하지 않으므로, 다른 사용자는 영원히 잠금을 구할 수 없는 상황이 발생한다.
▪
이런 사태를 방지하기 위해 오프라인 선점 방식은 잠금의 유효 시간을 가져야한다.
▪
유효 시간이 지나면 자동으로 잠금을 해제해서 다른 사용자가 잠금을 일정 시간 후에 다시 구할 수 있도록 해야한다.
도메인 모델과 BOUNDED CONTEXT
도메인 모델과 경계
•
도메인은 여러 하위 도메인으로 구분되기 때문에 한 개의 모델로 여러 하위 도메인을 모두 표현하려고 시도하게 되면 모든 하위 도메인에 맞지 않는 모델을 만들게 된다.
◦
상품이라는 모델
▪
카탈로그에서의 상품
▪
재고 관리에서의 상품
▪
주문에서의 상품
▪
배송에서의 상품
→ 이름만 같지 실제로 의미하는 것이 다르다. 카탈로그에서 물리적으로 한 개인 상품이 재고 관리에서는 여러 개 존재할 수 있다.
•
논리적으로 같은 존재처럼 보이지만, 하위 도메인에 따라 다른 용어를 사용하는 경우도 있다.
◦
카탈로그 도메인에서 상품이 검색 도메인에서는 문서로 불리기도 한다.
◦
시스템을 사용하는 사람을 회원 도메인에서는 회원이라고 부르지만, 주문 도메인에서는 주문자라고 부르고, 배송 도메인에서는 보내는 사람이라 부르기도 한다.
•
하위 도메인마다 사용하는 용어가 다르기 때문에, 올바른 도메인 모델을 개발하려면 하위 도메인마다 모델을 만들어야 한다.
◦
각 모델은 명시적으로 구분되는 경계를 가져서 섞이지 않도록 해야한다.
◦
여러 하위 도메인의 모델이 섞이기 시작하면 모델의 도메인별로 다르게 발전하는 요구사항을 모델에 반영하기 어려워진다.
•
모델은 특정한 컨텍스트(문맥)하에서 완전한 의미를 갖는다. → 이렇게 구분되는 경계를 갖는 컨텍스트를 DDD에서는 BOUNDED CONTEXT라고 부른다.
BOUNDED CONTEXT
•
BOUNDED CONTEXT는 모델의 경계를 결정하며, 한 개의 BOUNDED CONTEXT는 논리적으로 한 개의 모델을 갖는다.
•
BOUNDED CONTEXT는 용어를 기준으로 구분한다.
◦
카탈로그 컨텍스트와 재고 컨텍스트는 서로 다른 용어를 사용하므로, 이 용어를 기준으로 컨텍스트를 분리할 수 있다.
◦
또한, BOUNDED CONTEXT는 실제로 사용자에게 기능을 제공하는 물리적 시스템으로 도메인 모델은 이 BOUNDED CONTEXT안에서 도메인을 구현한다.
•
BOUNDED CONTEXT는 도메인 모델을 구분하는 경계가 되기 때문에 BOUNDED CONTEXT는 구현하는 하위 도메인에 알맞은 모델을 포함한다.
◦
같은 사용자라 하더라도 주문 BOUNDED CONTEXT와 회원 BOUNDED CONTEXT가 갖는 모델이 달라진다.
◦
같은 상품이라도 카탈로그 BOUNDED CONTEXT의 Product와 재고 BOUNDED CONTEXT의 Product는 각 컨텍스트에 맞는 모델을 갖는다.
BOUNDED CONTEXT의 구현
•
BOUNDED CONTEXT가 도메인 모델만 포함하는 것은 아니다. BOUNDED CONTEXT는 도메인 모델뿐만 아니라 도메인 기능을 사용자에게 제공하는 데 필요한 표현 영역, 응용 서비스, 인프라 영역 등을 모두 포함한다.
◦
도메인 모델의 데이터 구조가 바뀌면, DB 테이블 스키마도 함께 변경해야 하므로 해당 테이블도 BOUNDED CONTEXT에 포함된다.
•
모든 BOUNDED CONTEXT를 반드시 도메인 주도로 개발할 필요는 없다.
◦
각 BOUNDED CONTEXT는 도메인에 알맞은 아키텍처를 사용한다.
▪
주문 BOUNDED CONTEXT : 표현영역 - 응용 서비스 - 도메인 - 인프라스트럭처 - DBMS
▪
리뷰 BOUNDED CONTEXT : 표현 영역 - 서비스 - DAO - DBMS
•
한 BOUNDED CONTEXT에서 두 방식을 혼합해서 사용할 수도 있다.
◦
CQRS (Command Query Responsibility Segregation)
▪
상태를 변경하는 명령 기능과 내용을 조회하는 쿼리 기능을 위한 모델을 구분하는 패턴
•
각 BOUNDED CONTEXT는 서로 다른 구현 기술을 사용할 수도 있다.
•
BOUNDED CONTEXT가 반드시 사용자에게 보여지는 UI를 가져야하는 것은 아니다.
◦
REST API
◦
UI 서버를 통한 간접적인 통신
BOUNDED CONTEXT 간 통합
•
직접 통합 방식
◦
REST API 호출을 통하여 BOUNDED CONTEXT간 통신
•
간접 통합 방식
◦
메세지 큐를 이용한 통합
▪
카탈로그 BOUNDED CONTEXT → 메세지 시스템 ← 추천 BOUNDED CONTEXT
▪
두 BOUNDED CONTEXT를 개발하는 팀은 메세징 큐에 담을 데이터의 구조를 협의하게 되는데, 그 큐를 누가 제공하느냐에 따라 데이터 구조가 결정된다.
BOUNDED CONTEXT 간 관계
•
BOUNDED CONTEXT는 어떤 식으로든 연결되기 때문에 두 BOUNDED CONTEXT는 다양한 방식으로 관계를 맺는다.
◦
두 BOUNDED CONTEXT 간 관계 중 가장 흔한 관계는 한쪽에서 API를 제공하고, 다른 한쪽에서 그 API를 호출하는 관계이다. (대표적인 예: REST API)
▪
이 관계에서 API를 사용하는 BOUNDED CONTEXT는 API를 제공하는 BOUNDED CONTEXT에 의존하게 된다.
◦
두 BOUNDED CONTEXT가 같은 모델을 공유하는 경우
▪
공유 커널 : 두 팀이 공유하는 모델
•
중복을 줄여준다. 두 팀이 하나의 모델을 개발해서 공유하기 때문에 두 팀에서 동일한 모델을 두 번 개발하는 중복을 줄일 수 있다.
•
하지만, 두 팀이 한 모델을 공유하기 때문에 한 팀에서 임의로 모델을 변경해서는 안 되며, 두 팀이 밀접한 관계를 유지해야 한다.
◦
독립 방식(SEPERATE WAY)
▪
두 BOUNDED CONTEXT가 서로 통합하지 않는 방식
•
서로 독립적으로 모델을 발전시킨다.
▪
독립방식에서 두 BOUNDED CONTEXT 간의 통합은 수동으로 이루어진다.
CONTEXT MAP
•
개별 BOUNDED CONTEXT에 매몰되면 전체를 보지 못할 때가 있다. 이럴 때, 전체 비즈니스를 조망할 수 있는 지도인 컨텍스트 맵이 필요하다.
•
컨텍스트 맵은 BOUNDED CONTEXT 간의 관계를 표시한 것이다.
•
컨텍스트 맵은 시스템의 전체 구조를 보여준다. 이는 하위 도메인과 일치하지 않는 BOUNDED CONTEXT를 찾아 도메인에 맞게 BOUNDED CONTEXT를 조절하고 사업의 핵심 도메인을 위해 조직 역량을 어떤 BOUNDED CONTEXT에 집중할지 파악하는 데 도움을 준다.
•
컨텍스트 맵은 전체 시스템의 이해 수준을 보여준다. 즉, 시스템을 더 잘 이해하거나 시간이 지나면서 컨텍스트 간 관계가 바뀌면 컨텍스트 맵도 함께 바뀐다.