kok202
DDD Start! (2/2)

2021. 10. 2. 04:01[공부] 독서/DDD Start!

06. 응용 서비스와 표현 영역

표현 영역의 역할

요청을 받은 표현 영역은 URL, 요청 파라미터, 쿠키, 헤더 등을 이용해서 사용자가 어떤 기능을 실행하고 싶어 하는지 판별하고 그 기능을 제공하는 응용 서비스를 실행한다. 표현 영역의 주요 목표는 아래와 같다.

1. 사용자가 시스템과 상호 작용할 수있는 흐름을 제공하고 제어한다.

2. 알맞은 응용 서비스에 전달하고 결과를 사용자에게 제공한다.

3. 세션을 관리한다.

 

도메인의 핵심로직이 응용 서비스 계층에 구현되어선 안된다.

[사견] 현재 프로젝트의 테스트가 어려운 이유: 비즈니스 로직이 전부 서비스 레이어에 있기 때문, 서비스레이어에 있다보니 DB와 강결합 되어있다. 테스트를 위한 완전한 도메인이 어디까지인지 파악이 안되고 서비스 레이어에 있는 비즈니스 로직 테스트를 위해 DB에 데이터 저장을 미리 해줘야한다. (그래야 서비스 레이어에서 findBy해서 가져와줘야하므로) 애초에 비즈니스 로직이 도메인에 있고 도메인의 생성자가 완전한 도메인 생성을 보장한다면, 비즈니스 로직 검증은 DB와 결합될 일이 없다.(그냥 Text 에서 new Domain() 해서 사용하면 되므로)

 

서비스 레이어를 분할해야한다.

서비스 레이어에 코드가 모이기 시작하면 분리하는 것이 좋은 상황임에도 습관적으로 기존에 존재하는 클래스에 억지로 끼워넣게 된다. 이는 코드를 점점 얽히게 만들어 코드 품질을 낮추는 결과를 초래한다. 더 나은 방법은 별도의 기능만을 위한 으용 서비스 클래스를 별도로 구현하는 것이 좋다.

 

권한 검사

1. 표현 계층

2. 응용 계층

3. 도메인 계층

권한 검사는 보통 다음 세 곳에서 한다. 하지만 원칙적으로 응용서비스 레이어에서 하는 것이 좋다.

 

응용 서비스는 인터페이스를 만들어주어야 할까?

응용 서비스를 구현하면서 논쟁이 될만한 것이 인터페이스가 필요한지 여부이다.  인터페이스가 필요한 상황이 몇 가지 있는데, 구현 클래스가 여러개 존재해서 런타임에 교체가 자주 일어날 경우이다. 하지만 서비스는 교체가 드물고 구현 클래스가 두 개인 경우도 드물다. 물론 TDD 가 들어가기 시작하면 인터페이스를 작성하는 것이 더 좋을 수 도 있다. 그럼에도 불구하고 Mockito 같은 테스트 도구를 이용하면 fake 객체를 만들수 있기 때문에 인터페이스의 필요성이 다소 약하다.

 

응용 서비스 계층은 표현 계층을 의존해선 안된다.

즉 응용 서비스의 파라미터 타입을 결정할 때 주의할 점은 표현 계층과 관련된 타입을 사용해서는 안된다. 예를들어 표현 영역에 해당하는 HttpServletRequest나 HttpSession이 파라미터로 전달되선 안된다. 이렇게 되면 응용 서비스만 단독으로 테스트하기 어려워진다. 심지어 응용 서비스가 표현 계층의 역할까지 대신하는 상황이 벌어질 수 있다.

 


07. 도메인 서비스

도메인 영역의 코드를 작성하다 보면 한 Aggregate로 기능을 구현할 수 없을 때가 있다. 대표적인 예가 결제 금액 계산 로직이다. 결제 금액 계산 로직에는 대략 다음과 같은 Aggregate의 로직과 데이터가 필요하다.

1. 상품 Aggregate의 상품 가격

2. 주문 Aggregate의 구매 개수

3. 할인 Aggregate의 할인 비율

4. 회원 Aggregate의 추가 할인

이런 상황에서 결제 금액을 계산하는 주체는 어떤 Aggregate일까? 이럴 때 사용할 수 있는 것이 DomainService이다. Aggregate 에 억지로 로직을 넣기보다는 도메인 서비스를 이용해서 도메인 개념을 명시적으로 드러낸다.

 

 


08. Aggregate 트랜잭션 관리

대표적인 트랜잭션 처리 방식에는 Optimistic(낙관적, 비선점) 잠금과 Pessimistic(비관적, 선점) 잠금이 있다. 

 

Optimistic lock

JPA의 @Version attribute 을 이용해서 엔티티의 변경을 감지하는 방식.  비동기 처리가 있으면 OptimisticLockException 이 발생합니다. 

select * from coupon where id = 1; 
update coupon set used = 1 where id = 1 and version = 1;

 

Lock 에 의한 강제 버전 증가는 Aggregate 관점에서 보면 문제가 된다. 루트 엔티티값이 바뀌지 않았더라도 Aggregate 의 구성요소 중 일부 값이 바뀌면 논리적으로 그 Aggregate은 바뀐 것이다.

 

Pessmistic lock

DB 의 도움을 받아 lock을 거는 방식

select * from coupon where id = 1 for update; 
update coupon set used = 1 wherer id = 1;

 

PESSIMISTIC_READ: 다른 트랜잭션에서 읽기 가능 쓰기 불가능, 나도 읽기만 할것이다.

PESSIMISTIC_WRITE: 다른 트랜잭션에서 읽기 쓰기 불가능

PESSIMISTIC_FORCE_INCREMENT: PESSIMISTIC_WRITE + 버전 정보 사용

 

오프라인 선점 잠금(Offline Pessimistic Lock)

더 엄격하게 데이터 충돌을 막고 싶다면, 누군가 수정 화면을 보고 있을 때 수정 화면 자체를 실행하지 못하도록 하는 것이다. 이 때 필요한 것이 오프라인 선점 잠금 방식이다. 이 방식에서는 여러 트랜잭션에 걸쳐 동시 변경을 막는다. 잠금을 걸고 수정을 완료할 때까지 잠금을 해제하지 않는다. 다른 사용자는 잠금이 풀리지 않았으므로 수정 페이지를 누르면 에러 응답을 받게된다.

이 경우 유의할 점이 잠금해제가 이루어지지 않을 때 다른 사용자가 영원히 잠금을 얻을 수 없을 수 있다는 것이다. 이런 상황을 만들지 않으려면 프론트에서 일정 주기로 유효 시간을 증가시키도록 해야한다. LockManager에 대한 구현이 재미있으므로 볼만하다.

https://github.com/madvirus/ddd-start/blob/1bab71e9f97b0cb2482a671f63ecc4802b673b52/src/main/java/com/myshop/lock/SpringLockManager.java

 

GitHub - madvirus/ddd-start

Contribute to madvirus/ddd-start development by creating an account on GitHub.

github.com

 


09. 도메인 모델과 BOUNDED CONTEXT

처음 도메인 모델을 만들 때 빠지기 쉬운 함정이 도메인을 완벽하게 표현하는 단일 모델을 만드려한다는 것이다. 한 개의 모델로 여러 하위 도메인을 모두 표현하려고 시도하게 되면 모든 하위 도메인에 맞지 않는 모델을 만들게 된다. 예를 들어 사람이라는 모델은 회원 도메인 관점에서 회원이고 주문 도메인 관점에서는 주문자이며, 배송 도메인 관점에서는 발송인이다.

하위 도메인 마다 사용하는 용어가 다르기 때문에 올바른 도메인 모델을 개발하려면, 하위 도메인 마다 모델을 만들어야한다. 각 모델은 명시적으로 구분되는 경계를 가져서 섞이지 않도록 해야 한다. 모델은 특정한 컨텍스트에서 완전한 의미를 갖는다. 이렇게 구분되는 경계를 갖는 컨텍스트를 DDD에서는 BoundedContext 라고 부른다.

 

이상적으로 BoundedContext가 하위 도메인과 일대일 관계를 가지면 좋겠지만 현실은 그렇지 못할 때가 많다. 프로젝트에 하위 도메인의 모델이 위치하면 아무래도 전체 하위 도메인을 위한 단일 모델을 만들고 싶은 유혹에 빠진다. 이는 결과적으로 확장을 어렵게 만든다.

 

모든 BoundedContext를 반드시 도메인 주도로 개발할 필요는 없다. 예를들어 상품 리뷰처럼 도메인에 복잡한 로직이 없다면 표현-서비스-DAO 구조를 이용하여 단순 CRUD 방식으로 구현할 수도 있다.

 

BoundedContext는 마이크로 서비스와 잘 어울린다.

BoundedContext는 모델의 경계를 형성하는데 마이크로서비스로 구현하면 자연스럽게 컨텍스트별로 모델이 분리된다. 마이크로서비스는 코드 수준에서 모델을 분리해서 BoundedContext의 모델이 섞이지 않도록 해준다. 

 

SharedKernel

BoundedContext가 같은 모델을 공유하는 경우도 있다. 모델을 공유함으로써 중복 개발을 막을 수 있다. 이 때 공유되는 모델을 SharedKernel 이라고 부른다. 만약 SharedKernel을 다른 팀과 공유해서 사용하고 있다면 함부로 변경을 해서는 안된다.

 


10. 이벤트

예를들어 환불을 처리해야한다면 환불 기능을 제공하는 도메인 서비스를 파라미터로 전달받고 취소 도메인 기능에서 도메인 서비스를 실행하게된다.

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()); // 문제 2
      order.refundCompleted();
    }
    catch(Exeception exception) {
      // 문제 1
    }
  }
}

 

위 코드에서 문제를 2개 정도 확인할 수 있다.

문제 1. 외부 서비스가 정상이 아닐 때 트랜잭션 처리가 애매하다.

문제 2. 외부 시스템의 응답 시간이 길어지는 만큼 대기 시간이 길어진다 .

 

만약 위와 같이 구현하지 않고 order 에 환불 로직을 넣어 order.cancel(refundService) 처럼 구현할 경우 주문로직과 결제 로직이 섞이는 문제가 발생한다. 이러한 시스템 간 강결합 문제가 있을 때 이벤트를 이용하여 결합을 완화시킬 수 있다.

 

 

이벤트에는 다음과 같은 구성요소가 있다.

1. 이벤트

2. 이벤트 생성 주체

3. 이벤트 publisher(dispatcher)

4. 이벤트 handler

 

이벤트는 '과거에 벌어진 어떤 일'을 나타내며 상태가 변경되었다는 것을 의미한다. 따라서 클래스 이름을 표현할 때 과거시제가 들어가야한다. 예를 들어 OrderCanceledEvent 처럼 만들 수 있다. 이벤트 자체를 위한 상위 타입은 존재하지 않는다.

 

이벤트는 주로 다음과 같은 프로퍼티가 들어가있다.

1. 이벤트 종류: 보통 클래스 이름으로 이벤트 종류를 표현

2. 이벤트의 발생 시각

3. 추가 데이터

 

이벤트의 용도

1. 트리거: 도메인의 상태가 바뀔 때 다른 핸들러에서 후처리 해야하는 경우 이벤트를 트리거 할 수 있다.

2. 데이터 동기화

 

이벤트 publisher인 Events 클래스가 아주 재미있으므로 참고해볼만하다.

https://github.com/madvirus/ddd-start/blob/1bab71e9f97b0cb2482a671f63ecc4802b673b52/src/main/java/com/myshop/common/event/Events.java#L8

 

GitHub - madvirus/ddd-start

Contribute to madvirus/ddd-start development by creating an account on GitHub.

github.com

이렇게 만들어진 Events 는 아래와 같이 사용할 수 있다.

@Transactional
public void cancel(OrderNo orderNo, Canceller canceller) {
  Events.handle((OrderCanceledEvent evt) -> refundService.refund(evt.getOrderNumber()));

  Order order = findOrder(orderNo);
  if (!cancelPolicy.hasCancellationPermission(order, canceller)) {
      throw new NoCancellablePermission();
  }
  order.cancel();

  //Events.reset(); AOP로 @Service 메소드는 모두 자동으로 reset하도록 처리할 수 있다.
}

 

비동기 이벤트 처리

1. 로컬 핸들러를 비동기로 실행한다. (Events를 비동기 가능하도록 확장한다.)

2. 메시지 큐를 사용한다.

3. 이벤트 저장소와 이벤트 포워더 사용한다. (JDBC에 이벤트 스토어를 개발하는 방식, 사실상 JDBC에 MQ를 구현한 케이스)

4. 이벤트 저장소와 이벤트 제공 API를 사용한다.

 

3번 케이스인 이벤트 저장소의 코드가 또 볼만하다.

https://github.com/madvirus/ddd-start/blob/1bab71e9f97b0cb2482a671f63ecc4802b673b52/src/main/java/com/myshop/eventstore/infra/JdbcEventStore.java

 

GitHub - madvirus/ddd-start

Contribute to madvirus/ddd-start development by creating an account on GitHub.

github.com

https://github.com/madvirus/ddd-start/blob/1bab71e9f97b0cb2482a671f63ecc4802b673b52/src/main/java/com/myshop/integration/EventForwarder.java 

 

GitHub - madvirus/ddd-start

Contribute to madvirus/ddd-start development by creating an account on GitHub.

github.com

 

멱등성

연산을 여러 번 적용해도 결과가 달라지지 않는 성질. 예를들어 abs(abs(abs(x)) 는 abs(x) 와 같다.

 


11. CQRS

단일 모델로 구현하면 연관 관계로 인해 조회가 느려지는 문제가 있을 수 있다. Aggregate간의 연관을 ID가 아니라 직접 참조 방식으로 연결해도 고민거리가 생긴다. 조회 화면의 특성에 따라 같은 연관도 즉시 로딩이나 지연 로딩으로 처리해야 하기 때문이다.

ORM 기법은 상세 조회가 있을 때 Aggregate 에서 데이터를 가져와 출력하는 기능을 구현하기에 고려할 것이 많아서 구현을 복잡하게 만드는 원인이 된다. 이러한 구현 복잡도를 낮추는 간단한 방법이 있는데, 모델을 목적에 따라 다르게 분리하는 것이다.

 

CQRS는 Command Query Responsibility Segregation의 약자다.

CQRS에서는 단일 모델을 사용하지 않는다. 모델에게 명령을 하기 위해 사용되는 명령 모델과 단순 조회를 위해 사용되는 조회 모델로 구분한다. 명령 모델과 조회 모델은 DB를 다르게 가져갈 수도 있다. 예를들어 명령 모델은 RDB를 사용하고 조회 모델은 NoSQL을 사용할 수도 있다. 이렇게 사용할 때 발생할 수 있는 문제는 동기화 문제이다. 동기화 문제는 성능 문제로도 연결이 되는데, 동기화가 즉각적으로 필요한게 아니라면 동기화 주기를 늦춰서 성능을 보완할 수도 있다.

 

CQRS의 장점

1. 명령 모델을 구현할 때 도메인 자체에 집중할 수 있다.

2. 조회 성능 향상을 시키는데 유리하다. (캐시를 적용할 수 도 있고 조회에 특화된 쿼리를 마음대로 사용할 수도 있다.)

 

CQRS의 단점

1. 코드가 많아지고 복잡해진다.

2. 더 많은 구현 기술이 필요해진다.

 


회고

DDD 책을 읽는 것은 처음인데, 업무하면서 관성으로 해왔던 일들의 이유를 찾을 수 있어서 좋았다. 더불어 설계를 해오면서 몇 가지는 또 반성하거나 후회스러운 포인트들이 떠올랐다. 책을 읽고 테크닉적인 부분을 제외한 얻을 수 있었던 노하우를 진짜 간단하게 정리하면, "1. Aggregate, Entity, Value 를 구분할 수 있어야 한다.", "2. 무분별하게 Entity라는 명칭을 사용해선 안된다.", "3. 비즈니스 로직은 도메인 계층에 있어야한다." 이다.

 

 

'[공부] 독서 > DDD Start!' 카테고리의 다른 글

DDD Start! (1/2)  (0) 2021.10.02