1. 애그리거트 트랜잭션

그림 8.1

운영자는 고객의 배송 상태를 배송 시작으로 변경하고, 고객은 배송지를 변경하는 것이 동시에 발생하는 상황이다. 이때 요구 사항이 `배송이 시작되면 배송지 변경 불가`라면, 애그리거트의 일관성이 깨질 수가 있다.

 

이러한 문제를 해결하기 위해서는 트랜잭션 처리가 적용되어야 한다. 대표적인 처리 방식으로는 비관적 락(선점 잠금)과 낙관적 락(비선점 잠금)이 있다.

 

2. 비관적 락

그림 8.2

스레드 2는 스레드 1이 잠금을 해제한 뒤에 애그리거트에 접근할 수 있다. 이처럼 한 스레드의 애그리거트 사용이 끝날 때까지 다른 스레드의 접근을 막는 방식을, 비관적 락이라고 한다.

보통 DBMS의 for update와 같은 쿼리를 사용해서 행단위 잠금을 건다. JPA를 사용하면 보다 쉽게 구현할 수 있다.

 

애그리거트를 동시에 수정할 수 없도록 막기 때문에(상호 배제) 갱신 손실 문제가 발생하지 않는다. 다만 순환 대기, 비선점, 점유 대기를 만족하는 경우에는 데드락이 발생할 수 있는 점을 주의해야 한다.

이에 대한 해결 방법에는 최대 자원 대기 시간을 설정하는 방법이 있다. 이는 힌트를 사용하여 쉽게 구현할 수 있다. 그런데  DBMS에 따라 1) 쿼리별로 대기 시간을 지정하거나 2) 커넥션 단위로만 지정하는 경우로 나뉘니, 사전에 꼭 확인하자

 

3. 낙관적 락

그림 8.3

그림 8.2에서는 운영자와 고객이 변경을 동시에 수행하는 상황이었다. 하지만 그림 8.3에서는 운영자가 사전에 조회한 정보를 바탕으로 변경이 따로 발생하는 상황이다. 이는 비관적 락으로 해결할 수 없지만, 낙관적 락으로는 해결할 수 있다.

 

낙관적 락은 동시에 접근하는 것을 막는 방식이 아니라, 변경한 데이터를 실제 DBMS에 반영하는 시점메 변경 가능 여부를 확인하는 방식이다. 변경 가능 여부는 버전 관리 방식을 통해 구현할 수 있다.

 

그림 8.4

좀 더 원리를 살펴보자면, 사전에 조회한 애그리거트의 버전 값이 새로 조회한 버전 값과 같은 경우에만 데이터를 수정할 수 있다. 그리고 수정에 성공하면 버전 값을 1 증가시키는 방식이다.

 

강제 버전 증가

애그리거트는 여러 엔티티와 밸류로 구성되어 있다. 이때 루트 엔티티가 아닌 다른 엔티티의 값이 변경되더라도, 애그리거트 관점에서는 버전이 달라져야 하는 게 올바르다. 이 역시 JPA는 관련 기능을 지원한다.

 

4. 오프라인 비관적 락

사실 비관적 락을 사용해서 그림 8.3을 해결하는 방법이 있다.

그림 8.5

첫 번째 트랜잭션에서 오프라인 락을 걸고 마지막 트랜잭션에서 락을 해제하면 동시성 문제를 해소할 수 있다. 이러한 방식을 오프라인 비관적 락이라고 부른다.

 

락을 걸 때는 애그리거트에 lockId를 저장하고, 이후에 lockId를 사용하여 락을 해제하는 방식으로 구현할 수 있다. 사용자가 락을 걸기만 하고 해제하지 않을 수도 있으므로, 잠금 유효 시간을 가져야 함을 유의하자.

 

5. 비관적 락과 낙관적 락 

그러면 비관적 락과 낙관적 락을 언제 사용해야 할까? 결론부터 말하자면 낙관적 락은 많은 충돌이 예상되지 않을 때(낙관적인 상황) 사용하고, 비관적 락은 잦은 충돌이 예상될 때(비관적인 상황) 사용하면 된다.

 

갱신 충돌이 자주 발생하지 않는 경우에는 낙관적 락이 성능이 더 좋다. 비관적 락은 다른 트랜잭션이 접근하지 못하도록 락을 걸기 때문에 동시성이 떨어지기 때문이다. 하지만 반대되는 상황에서는 그렇지 않다.

충돌이 잦을수록 트랜잭션이 중단되어 롤백할 가능성이 높아진다. 하지만 롤백은 보류 중인 모든 변경 사항을 되돌려야 하므로 비용이 많이 드는 작업이다.

 

이러한 이유로 충돌이 자주 발생하는 경우에는 비관적 락이 더 적합할 수 있다.

 

참고자료

https://stackoverflow.com/questions/129329/optimistic-vs-pessimistic-locking

1. 여러 애그리거트가 필요한 기능

도메인 영역의 코드를 작성하다 보면, 한 애그리거트로 기능을 구현할 수 없을 때가 있다. 대표적인 예가 상품, 주문, 할인 쿠폰, 회원 애그리거트들이 관여하는 결제 금액 계산 로직이다.

 

이 상황에서는 어떤 애그리거트가 주체인지 쉽게 판단할 수 없다. 어찌어찌 한 도메인 안에 욱여 넣더라도, 자신의 책임 범위를 넘어서는 기능을 구현하기 때문에 다음의 단점이 존재한다. 1) 코드가 길어지고 2) 외부에 대한 의존이 높아지게 되며 3) 코드가 복잡하여 수정이 어렵다. 4) 게다가 애그리거트 범위를 넘어서는 도메인 개념이 애그리거트에 숨어들어 명시적으로 드러나지 않게 된다.

 

이에 대한 해결 방법으로는 별도 서비스로 구현하는 것이다.

 

2. 도메인 서비스

도메인 서비스는 도메인 영역에 위치한 도메인 로직을 표현할 때 사용한다. 주로 여러 애그리거트가 필요한 계산이나 외부 시스템 연동이 필요한 경우에 사용한다.

 

계산 로직과 도메인 서비스

결제 금액 계산처럼 한 애그리거트에 넣기 애매한 도메인 기능은, 도메인 서비스를 이용해서 도메인 개념을 명시적으로 드러낼 수 있다.

 

응용 서비스 vs 도메인 서비스

응용 서비스는 도메인 로직 없이, 그저 표현 영역과 도메인 영역을 연결하는 창구 역할을 수행한다. 따라서 응용 서비스는 애그리거트나 도메인 서비스의 로직을 실행하기만 할 뿐이다.

반면 도메인 서비스는 애그리거트의 상태를 변경하거나 상태 값을 계산하는 도메인 로직이 존재한다. 트랜잭션 처리와 같은 로직은 응용 로직이므로 응용 서비스에서 처리한다.

 

도메인 서비스 vs 애그리거트가 가진 기능들

애그리거트는 도메인 로직 뿐만 아니라 관련된 필드를 가지고 있다. 반면 도메인 서비스는 상태를 가지지 않고 기능만 가진다. 애그리거트의 상태값이 필요하다면, 응용 서비스의 도움을 받아 메서드 파라미터를 통해 주입받을 수 있다.

 

외부 시스템 연동과 도메인 서비스

전에 우리는 JPA 레포지터리 인터페이스는 도메인 영역에 포함시키고, 구현 클래스는 인프라 영역에 포함시켰다. 또한, 도메인 로직 관점에서 인터페이스를 작성했었다. 이와 비슷하다.

 

외부 시스템이나 타 도메인과의 연동은 도메인 서비스 포함시킬 수 있다. 가령 구현 부분이 HTTP 호출로 이루어져 있더라도 상관없다. 도메인 로직 관점에서 인터페이스를 작성한 뒤 도메인 서비스로 포함시키고, 구현 클래스를 인프라 영역으로 보내면 그만이다.

1. 표현 영역과 응용 영역

앞서 우리는 소프트웨어로 해결하고자 하는 영역이 도메인이라는 점을 학습하였다. 그러나 이것만으로 끝나는 것이 아니다. 사용자와 도메인 영역을 연결해 주는 매개체인 표현 영역과 응용 영역이 필요하다.

 

2. 응용 영역(응용 서비스)

1. 역할

응용 서비스의 역할은 다음과 같다.

1. 사용자(클라이언트)가 요청한 기능을 실행한다.

 코드의 응집성을 높이고 코드 중복을 제거하기 위해, 도메인 로직 없이 그저 도메인의 기능을 실행하는 역할만 한다.

2. 트랜잭션 처리를 담당한다.

 기능 실행 도중에 문제가 발생하면 초기 상태로 되돌아 갈 수 있어야 한다.

 

다만 조회 기능만 필요한 경우라면, 응용 서비스 없이 구현하는 것을 고려해도 좋다.

2. 크기

응용 서비스의 크기를 어떻게 하느냐에 따라 구현 방식이 1) `한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기` 2) `구분되는 기능별로 응용 서비스 클래스를 따로 구현하기`로 나뉜다.

 

방식 1) 한 응용 서비스 클래스에 회원 도메인의 모든 기능 구현하기

장점 : 도메인에 관련된 기능을 구현한 코드가 한 클래스에 위치하므로, 각 기능에서 동일 로직에 대한 코드 중복을 쉽게 제거할 수 있다.

  ex) 여러 메서드에 있는 null값 검사 기능을 별도의 메서드로 추출

단점 : 한 서비스 클래스의 크기(코드 줄 수)가 커진다. 그로 인해 관련 없는 코드가 추가되면서 코드의 품질이 저하된다.

 

방식 2)  구분되는 기능별로 응용 서비스 클래스를 따로 구현하기 -> 글쓴이가 선호하는 방식

장점 : 코드의 품질을 유지할 수 있고, 클래스별로 필요한 의존 객체만 보유할 수 있다.

단점 : 클래스의 개수가 많아진다.

 

3. 인터페이스와 클래스

인터페이스와 클래스를 따로 구현하면 1) 소스 파일이 많아지고 2) 구현 클래스에 대한 간첩 참조가 증가해서 전체 구조가 복잡해진다. 따라

서 필요한 상황(ex. 구현 클래스가 여러 개인 경우)에만 만드는 것을 고려하자.

 

4. 메서드 파라미터

응용 서비스는 필요한 값을 개별 파라미터나 별도의 dto로 전달받을 수 있다. 보통 요청 파라미터가 두 개 이상이면 dto를 사용하는 것이 편리하다.

여기서 주의할 점은 HttpServleRequest와 같이 표현 영역과 관련된 타입을 사용하지 않는 것이다. 표현 영역에 의존이 발생하면 1) 응용 서비스만 단독으로 테스트하기 어렵고 2) 표현 영역이 변경되면 응용 서비스 구현이 변경될 수 있다. 3) 응용 서비스가 표현 영역의 역할까지 대신한다는 단점도 존재한다.

 

5. 값 리턴

응용 서비스에서 애그리거트 자체를 리턴하면 코딩은 편할 수 있다. 하지만 도메인 로직 실행을 응용 서비스와 표현 영역 두 곳에서 할 수 있게 된다. 이는 곧, 기능 실행 로직이 두 영역에 분산되기 때문에 코드의 응집도를 낮추는 원인이 된다. 추가로 JPA에서 OSIV 설정을 false로 하는 경우, 지연 로딩이 서비스 단까지만 가능하기 때문에 더더욱 애그리거트를 표현 영역에 리턴하지 말자.

 

4. 표현 영역

표현 영역의 책임은 크게 다음과 같다.

1) 사용자가 시스템을 사용할 수 있는 화면을 제공하고 제어한다.

2) 사용자의 요청을 응용 서비스에 전달하고, 그 결과를 사용자에게 제공한다.

3) 사용자의 세션을 관리한다.

 

5. 값 검증

원칙적으로는 모든 값에 대한 검증은 응용 서비스에서 처리한다. 다만 구현의 편리함을 위해 다음처럼 구현하는 것을 고려해도 좋다.

- 표현 영역 : 필수 값, 값의 형식, 범위 등을 검증한다. -> 필드 에러 검증

- 응용 서비스 : 데이터의 존재 유무와 같은 논리적 오류를 검증한다. -> 글로벌 에러 검증

 

글쓴이는 코드 작성의 불편함보다는 응용 서비스의 완성도가 높아지는 이점이 더 크다고 하였다. 하지만 그 이점이 무엇인지는 서술하지 않았기 때문에 이 부분은 찾아봐야겠다.

해당 챕터는 JPA에 대한 내용이 주를 이루기 때문에 간략하게 정리하였다.
5장은 Specification에 대해 다루는데, QueryDSL이 더 좋은 대안이라고 생각해서 가볍게 훑고 넘어갔다.

1. JPA를 이용한 리포지터리 구현

2장에서 언급한 것처럼 리포지터리 인터페이스는 도메인 영역에 속하고, 구현 클래스는 인프라스트럭처 영역에 속한다. 

 

TMI 삭제기능

삭제 요구사항이 있더라도 데이터를 실제로 삭제하는 경우는 많지 않다. 관리자 기능에서 삭제 데이터를 조회하는 경우도 있고 원복을 위해 일정 기간 동안 보관하는 경우도 있기 때문이다. 따라서 데이터를 바로 삭제하기보다는, 삭제 플래그를 사용하는 방식으로 구현하자.

 

2. 매핑 구현

객체는 여러개지만 테이블은 하나이다.

주문 애그리거트는 위와 같이 여러 개의 객체로 구성되지만 테이블은 하나이다. 구현 방법은 아래처럼 @Embeddable@Embedded 애노테이션을 사용하면 된다.

@Entity
pulic class Order {

  @Embedded
  private Orderer orderer;
  
  @Embedded
  private ShippingInfo shippingInfo;
  ...
}

@Embeddable
public class ShippingInfo {
  
  @Embedded
  private Address address;
  
  @Embedded
  private Receiver receiver;
  
  protected ShippingInfo() {} // JPA에서는 private이 아닌 기본 생성자가 있어야 한다.
  
  public ShippingInfo(Address address, Receiver receiver) {
    this.address = address;
    this.receiver = receiver;
  }
  ...
}

 

3. 별도 테이블에 저장하는 밸류 매핑

보통 애그리거트에서 루트 엔티티를 뺀 나머지 구성요소는 대부분 밸류이다. 루트 엔티티 외에 다른 엔티티가 있다면 진짜 엔티티인지 의심하라. 그저 별도 테이블에 저장되어 있는 밸류일 수도 있다. 또는 다른 애그리거트일 수도 있으니 유념하자.

 

4. 도메인 구현과 DIP

@Entity, @Table은 구현 기술에 속한다. 하지만 해당 애노테이션을 가지는 엔티티는 도메인에 속한다. 즉, 도메인이 인프라에 의존하기 때문에 DIP 위반이다.

 

DIP를 적용하는 주된 이유는 저수준 구현이 변경되더라도 고수준이 영향을 받지 않도록 하기 위함이다. 하지만 필자는 다음의 이유로 타협해도 된다고 판단하였다.

1. 리포지터리와 도메인 모델의 구현 기술은 거의 바뀌지 않는다. 변경이 거의 없는 상황에서 변경을 대비하는 것은 과하다고 생각한다.

2. JPA 전용 애노테이션을 사용했지만 도메인 모델을 단위 테스트 하는 데 문제없다. 리포지터리 역시 마찬가지다.

 

1. 애그리거트

애그리거트 단위로 묶으니 이해하기 쉬운 형태가 되었다.

복잡한 도메인을 이해하고 관리하기 쉬운 단위로 만들기 위해 관련된 객체를 하나로 묶을 수 있는데, 이를 애그리거트라고 한다. 또한, 애그리거트는 모델을 이해하는 데 도움을 줄 뿐만 아니라, 일관성을 관리하는 기준도 된다. 

 

관련된 객체를 하나로 모았기 때문에 한 애그리거트에 속한 객체는 유사하거나 동일한 라이프 사이클을 갖는다. 도메인 규칙에 따라 모든 객체를 동시에 생성하지 않는 경우도 있지만, 일반적으로는 함께 생성하고 함께 제거한다.

 

필자의 경험에서는, 보통 애그리거트는 하나의 엔티티 객체만 갖는 경우가 많았으며 두 개 이상의 엔티티로 구성되는 애그리거트는 드물었다.

 

2. 애그리거트 루트의 역할

애그리거트는 여러 객체로 구성되기 때문에 한 객체만 정상이면 안된다. 가령 Order의 필드가 OrderLine과 관련이 있는 경우, OrderLine을 변경하면, Order에도 변경 사항을 반영해주어야 한다. 이처럼 애그리거트에 속한 모든 객체가 일관된 상태를 유지하기 위해, 관리의 책임을 가지고 있는 엔티티를 루트 엔티티(이 경우에는 Order가 해당)라고 한다. 

 

애그리거트가 관리의 책임을 진다는 것은 곧, 외부에서는 오직 루트 엔티티와만 교류해야 한다는 것을 의미한다. 다른 객체에 직접 접근할 수 있다면, 애그리거트 루트가 강제하는 규칙(= 요구사항 or 도메인 규칙)을 적용할 수 없기 때문에 일관성을 깨는 원인이 되기 때문이다.

 이를 실현하기 위해서는 1) 의미가 모호한 public setter를 가급적 피하고 2) 밸류 타입을 불변으로 구현해야 한다. 그 후에 루트 엔티티만이 내부 객체를 변경할 수 있도록 하여, 애그리거트 전체의 일관성을 지키도록 하자. 단순히 기능을 위임하는 코드이더라도 루트 엔티티로 통하도록 한다.

 혹시나 팀 표준이나 구현 기술의 제약으로 밸류 타입을 불변으로 구현할 수 없다면, 변경 기능을 패키지나 protected 범위로 한정해서 외부에서 실행할 수 없도록 제한하는 방법도 있다.

 

트랜잭션 범위는 작을수록 좋다. 락을 거는 대상이 많을수록(범위가 클수록) 동시성이 떨어지게 되고, 이는 전체적인 성능을 떨어뜨린다. 그러므로 가급적이면 한 트랜잭션이 한 개의 애그리거트만 수정하도록 한다. 부득이하게 한 트랜잭션으로 두 개 이상의 애그리거트를 수정해야 한다면, 애그리거트 간에는 독립성을 유지하고 응용 서비스에서 처리하도록 한다.

물론 팀 표준이나 기술 제약, UI 구현의 편리 등의 이유로, 한 트랜잭션이 두 개 이상의 애그리거트를 변경하도록 고려할 수 있다.

 

3. 애그리거트와 리포지터리

애그리거트는 개념상 완전한 한 개의 도메인 모델을 표현하므로, 객체의 영속성을 처리하는 리포지터리는 애그리거트 단위로 존재한다. 데이터를 저장할 때는 에그리거트 전체를 영속화해야 하고, 동일하게 조회를 할 때에도 완전한 애그리거트를 제공해야 한다.

 

개인적으로 생각해보았을 때, 조회 시 완전한 애그리거트를 제공한다면 성능에 악영향이 있지 않을까 우려했었다. 그러다 이내 JPA에는 지연로딩 기능을 통해 해결할 수 있다는 것을 인지할 수 있었다.

 

4. ID를 이용한 애그리거트 참조

한 객체가 다른 객체를 참조하는 것처럼 애그리거트도 다른 애그리거트를 참조한다. 이때 애그리거트 관리 주체는 루트이므로, 루트 엔티티를 참조한다는 것과 같다.

 

ORM 기술 덕분에 객체 참조를 바탕으로 다른 애그리거트로 쉽게 접근할 수 있다. 하지만 아래의 문제를 야기할 수 있다.

1. 편한 탐색 오용 : 필드로 바로 접근할 수 있기 때문에, 한 애그리거트에서 다른 애그리거트를 수정하기 쉬운 환경이다. 앞서 우리는 한 트랜잭션은 가급적 하나의 애그리거트만 수정해야 한다는 것을 학습했다.

2. 성능에 대한 고민 : JPA는 즉시 로딩과 지연 로딩을 모두 제공하기 때문에 어떤 것을 적용할지 고민해야 한다. 이 부분을 덧붙이자면, 김영한님 강의를 통해 기본적으로 지연 로딩을 걸고, 필요할 때만 즉시 로딩을 거는 것이 적절하다고 학습하였다.

3. 확장의 어려움 : 트래픽이 증가하면 부하 분산을 위해 하위 도메인별로 시스템을 분리하기 시작한다. 이때 도메인마다 서로 다른 DBMS를 사용한다면 JPA라는 단일 기술을 적용하기 힘들어진다.

 

ID를 이용해서 다른 애그리거트를 참조한다면 이러한 문제들을 완화할 수 있다. ID 참조 방식의 장점은 1) 애그리거트의 경계를 명확히 하고 물리적인 연결을 제거하기 때문에 모델의 복잡도를 낮출 수 있다. 2) 애그리거트 간의 의존을 제거하므로 응집도를 높여줄 수 있다. 또한, 한 애그리거트에서 다른 애그리거트를 수정하는 문제를 근원적으로 방지할 수 있다. 3) 애그리거트별로 DBMS가 다르더라도 구현 난이도가 낮아진다. 

다른 애그리거트를 ID로 참조하면 여러 애그리거트를 읽을 때 조회 속도가 문제 될 수 있다. 이는 조회 쿼리를 직접 작성하여 해결할 수 있다. 애그리거트마다 서로 다른 저장소를 사용한 경우라면, 조회 성능을 높이기 위해 캐시를 적용하거나 조회 전용 저장소를 따로 구성하여 해결한다.

 

5. 애그리거트를 팩토리로 사용하기

우리는 앞서 한 트랜잭션에서는 가급적 하나의 애그리거트를 수정해야한다고 배웠다. 다만 도메인 기능과 관련되어 있는 경우, 가령 고객이 특정 상점을 여러 차례 신고해서 해당 상점이 더 이상 물건을 등록하지 못하도록 차단한 상태라고 하자. 이 때는 매번 서비스에서 중복 코드를 작성하지 않고 아래와 같이 루트가 가지도록 하는 것이 좋다.

public classs Store {
  public Product createProduct(ProductId new ProuctId, ...) {
    if (isBlocked()) throw new StoreBlockedException();
    
    return new Product(new ProductId, getId(), ...);
  }
}

이처럼 애그리거트가 팩토리로 사용하면 얻을 수 있는 장점은 다음과 같다. 1) 요구 사항이 변경되더라도 해당 로직 수정하면 되기 때문에 변경에 유연하다. 2) 도메인의 응집도가 높아진다.

 

여기서 핵심은 코드의 중복이 발생하지 않도록 한곳에서 관리한다는 것이다.

1. 계층 구조 아키텍처

네 영역을 구성할 때 많이 사용하는 아키텍처는 아래와 같다.

계층 구조를 엄격하게 적용한다면 상위 계층은 바로 아래의 계층에만 의존을 가져야 한다. 하지만 구현의 편리함을 위해 계층 구조를 유연하게 가져갈 수 있다.

 

구조가 엄격하든 유연하든 하위 계층은 상위 계층을 의존하지 않는다. 그 말은 표현, 응용, 도메인 계층이 상세한 구현 기술을 다루는 인프라 계층을 의존한다는 것이다. 그로 인해 테스트의 어려움과 기능 확장의 어려움을 야기한다. 아래에서 자세하게 알아보자.

 

public class DroolsRuleEngine {
  private KieContainer kContainer;
    
  public DroolsRuleEngine() {
    KieServices ks = KieServices.Factory.get();
    kContainer = ks.getKieClasspathContainer();
  }

  public void evalute(String sessionName, List<?> facts) {
    KieSession ksession = kContainer.newKieSession(sessionName);
        
    try {
        facts.forEach(x -> ksession.insert(x));
        ksession.fireAllRules();
    } finally {
        kSession.dispose();
    }
  }
}
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(custome, money);
    facts.addAll(orderLines);
    ruleEngine.evalute("discountCalculation", facts);
    return money.toImmutableMoney();
  }
  ...
}

위의 코드를 보면 CalculateDiscountService가 DroolsRuleEngine을 의존하고 있다. Service 클래스를 테스트하려면 RulesEngine이 완벽하게 동작해야만 테스트가 가능하다. 또한, Drools에 특화된 코드가 Service 내부에 존재하기 때문에 변경에 유연하지 못하다는 문제를 인지할 수 있다. 이러한 문제를 어떻게 해결할 수 있을까?

 

2. DIP

위의 계층 구조는 테스트의 어려움과 변경에 유연하지 못하다는 단점이 존재한다. 이를 DIP로 해결할 수 있다.

저수준 모듈이 고수준 모듈을 의존하도록 DIP를 적용한 코드는 다음과 같다.

public interface RuleDiscounter {
  public Money applyRules(Customer customer, List<OrderLine> orderLines);
}

public class DroolsRuleDiscounter implements RuleDiscounter {
  private KieContainer kContainer;
    
  public DroolsRuleEngine() {
    KieServices ks = KieServices.Factory.get();
    kContainer = ks.getKieClasspathContainer();
  }

  @Override
  public void applyRules(String sessionName, List<?> facts) {
    KieSession ksession = kContainer.newKieSession(sessionName);
        
    try {
        facts.forEach(x -> ksession.insert(x));
        ksession.fireAllRules();
    } finally {
        kSession.dispose();
    }
  }
}
public class CalculateDiscountService {
  private RuleDiscounter ruleDiscounter;

  public Money calculateDiscount(OrderLine orderLines, String customerId) {
    Customer customer = findCusotmer(customerId);
    return ruleDiscounter.applyRules(customer, orderLines);
  }
}

Service는 더 이상 Drools에 의존하지 않는다. 대신 RuleCounter를 통해 룰을 적용한다는 사실만 알 뿐이다. 이를 도식화하면 아래와 같다.

저수준 모듈이 고수준 모듈을 의존하도록 구조를 변경함으로써 단점을 극복하였다. 구현 객체의 변경이 필요하면 설정 파일에서 변경하기만 하면 된다. 테스트는 RuleDiscounter가 인터페이스이므로 대역 객체를 사용해서 진행하면 된다.

 

인터페이스 분리 시 주의사항

DIP는 단순히 인터페이스와 구현 클래스를 분리하는 것이 아니다. 고수준 모듈이 저수준 모듈에 의존하지 않도록 분리하는 것이 중요하다.

왼쪽이 잘못된 예이고, 오른쪽이 올바른 예이다.

DIP를 적용할 때 하위 기능을 추상화한 인터페이스는 고수준 모듈 관점에서 도출한다. CalculateDiscountService는 이름을 보면 알 수 있듯이 가격 할인 계산이라는 기능을 구현한다. 그에 따라 RuleDiscount 인터페이스는 가격 할인을 위한 룰을 적용한다라는 하위 기능을 메서드로 가지고 있다. 별 의도 없이 DroolsRuleEngine가 그저 인터페이스를 구현하도록 한 것이 아니다.

 

이런 장점을 가지고 있는 DIP이지만, 모든 상황에서 인터페이스로 분리하는 것은 번거롭다. 이점이 있다고 판단되면 적용하자.

 

인프라스트럭처 주의사항

무조건 인프라스트럭처에 대한 의존을 없앨 필요는 없다.

 

@Entity, @Transactional과 같은 애노테이션은 인프라스트럭처에 해당된다. 이를 사용하면 의존성을 가지긴 하지만, 구현의 편리함이라는 장점이 훨씬 크기 때문이다.

 

3. 도메인 영역의 주요 구성 요소

요소 설명
애그리거트 애그리거트는 연관된 엔티티와 밸류를 개념적으로 하나로 묶은 것이다. 애그리거트는 구성 객체들을 관리하기 위해 루트 엔티티를 갖는다.
리포지터리 도메인 모델의 영속성을 처리한다. 애그리거트 단위로 저장하고 조회하는 기능을 정의한다.
도메인 서비스 특정 엔티티에 속하지 않은 도메인 로직을 제공한다. 가령 할인 금액 계산을 구현하기 위해서는 상품, 쿠폰, 회원 등급, 구매 금액 등 다양한 조건을 이용해서 구현해야 한다. 이렇게 도메인 로직이 여러 엔티티와 밸류를 필요로 하면 도메인 서비스에서 로직을 구현한다.

 

4. 모듈 구성

패키지를 어떻게 세분화해야 하는지 정해진 규칙은 없다. 단순히 4개의 영역으로 구분하거나 하위 도메인 별로 나누어도 된다. 도메인이 복잡하면 애그리거트(도메인 모델)와 도메인 서비스를 별도의 패키지에 위치시켜도 된다.

 

한 패키지에 너무 많은 타입이 몰려서 불편하지 않으면 된다. 필자는 가급적 한 패키지에 10 ~ 15개 미만으로 타입 개수를 유지하려 한다.

1. 도메인이란?

도메인이란 소프트웨어로 해결하고자 하는 문제 영역을 의미한다. 만약 우리가 온라인 서점을 구현하고 싶다고 해보자. 그러면 이 온라인 서점이 구현해야 할 소프트웨어의 대상, 즉 도메인이 되는 것이다. 

 

한 도메인은 다시 하위 도메인으로 나눌 수 있다. 하위 도메인들이 서로 연동하면서 완전한 기능을 제공하는 것이다.

 

도메인이 제공해야 할 모든 기능을 직접 구현하는 것은 아니다. 예를 들어 결제나 배송과 같은 하위 도메인은 외부 업체의 시스템을 사용하는 방식으로 도메인을 구성할 수 있다.

 

2. 도메인 전문가와 개발자 간 지식 공유

개발자는 요구 사항을 바탕으로 구조를 설계하고 개발을 진행한다. 첫 단추인 요구사항 분석의 중요성은 두말할 것도 없다. 

 

정산과 배송과 같이 각 도메인에는 전문가가 있다. 개발자는 이러한 전문가들과 직접 대화하면서 요구사항을 올바르게 이해하는 것이 중요하다. 물론 사전에 도메인 지식을 어느 정도 갖춰야 한다.

번거롭다고 느낄 수 있지만, 전문가와 관계자와 지식을 공유해야 원하는 제품을 만들 가능성이 높아진다.

 

3. 도메인 모델

도메인 모델 설명에 앞서, 먼저 애플리케이션의 아키텍처 구성은 다음과 같다.

영역 설명
표현 클라이언트의 요청을 처리하거나 정보를 보여준다. 여기서 클라이언트는 사용자 또는 외부 시스템이다.
응용 클라이언트가 요청한 기능을 실행한다. 업무 로직을 직접 구현하지 않으며 도메인 계층을 조합해서 기능을 실행한다.
도메인 시스템이 제공할 도메인 규칙을 제공한다.
인프라스트럭처 데이터베이스나 메시징 시스템과 같은 외부 시스템과의 연동을 처리한다. 도메인, 응용, 표현 영역은 구현 기술을 사용한 코드를 직접 만들지 않는다. 인프라 영역에서 제공하는 기능을 사용해서 필요한 기능을 개발한다.

 

도메인 계층은 도메인의 핵심 규칙을 구현한다. 주문 취소는 배송 전에만 할 수 있다.출고 전에 배송지를 변경할 수 있다. 와 같은 요구 사항이 도메인 규칙에 해당된다. 그리고 이런 도메인 규칙을 객체 지향 기법으로 구현하는 패턴이 도메인 모델 패턴이다.

 

4. 엔티티와 밸류

도메인 영역에는 엔티티와 밸류가 존재한다. 

엔티티

엔티티는 테이블가 매핑되어 있는 객체를 말한다. 엔티티의 가장 큰 특징은 식별자를 가진다는 것이다. 이 식별자는 엔티티 객체마다 서로 다른 값을 가진다. 위의 그림에서는 Order가 엔티티이고, 식별자는 주문번호가 되겠다.

 

식별자는 변하지 않고 고유하기 때문에, 두 엔티티 객체의 식별자가 서로 같으면 두 엔티티는 같다고 판단할 수 있다. 그에 맞춰 equals()와 hashCode() 메서드를 적절하게 구현하자.

 

밸류

응집도 높은 필드들을 모아 새로운 타입으로 정의한 것을 밸류 타입이라고 부른다. 가령 도시, 번지, 우편 번호 세 개의 필드를 Address로 묶어서 사용할 수 있다. 해당 타입을 사용하더라도 테이블에 영향을 주지 않는다. (아무리 밸류 타입이 많아져도 하나의 Order 테이블만 존재한다는 의미이다.)

 

밸류 타입을 선언할 때 불변으로 설계하는 것이 좋다. 불필요하게 setter를 열어놓으면 어디서든 접근할 수 있기 때문에 잘못 사용될 가능성이 존재한다. 따라서 생성자를 통해서만 값을 설정할 수 있도록 하고, 변경이 필요하면 새로운 객체를 생성하는 방법을 고려하자.

추가로 setter라는 이름도 애매하다. 그 대신 changePassword와 같이 직관적인 메서드 이름을 사용하자.

 

5. 도메인 용어와 유비쿼터스 언어

코드를 작성할 때 도메인에서 사용하는 용어는 매우 중요하다. 
public OrderState {
	STEP1, STEP2, STEP3, STEP4, STEP5, STEP6
}
public OrderState {
	PAYMENT_WAITING, PREPARING, SHIPPED, DELEVERING, DELIVERY_COMPLETED
}

 

 

이처럼 명확한 이름은 코드의 가독성이 높아지고 버그도 줄어든다는 장점을 지닌다. 개발자뿐만 아니라 전문가, 관계자들 간에 공통의 언어를 규정하고 사용하자. 시간이 흘러 도메인 이해가 높아지면 그에 걸맞게 이름을 재정의하면 더더욱 좋다.

+ Recent posts