spring-boot 2.4.x spring-cloud 2020.x 의존성 상황에서 feign.hystrix.enabled=true가 안됨 feign.circuitbreaker.enabled=true 로 바꿔보지만 openfeign과 hystrix가 잘 통합이 안 됨.

이유는 스프링 클라우드 팀에서 hystrix를 버림 실제 circuitbreak-starter 에서는 현재(2022) 기준 hystrix를 지원하지 않고 spring-cloud-netflix-hystrix도 버림

그럼 기존 spring-boot 2.0.x 등에서 업그레이드 하는 경우에는 하위 호환이 안됨

하지만 우리가 어떤 개발자임?? 스프링 설정을 강제로 먹이면 됨 ㅎㅎㅎ

  1. 우선 feign.circuitbreaker.enabled=true 속성 자체를 지워야함. 하지만 feign.hystrix.enabled=true 속성은 유지 (아래 Config 참조)
  2. 의존성에 아래처럼 feign-hystrix를 추가 단 ${feign-hystrix.version} 버전은 feign-core 라이브러리와 버전을 맞추는 것이 좋음
         <dependency>
             <groupId>io.github.openfeign</groupId>
             <artifactId>feign-hystrix</artifactId>
             <version>${feign-hystrix.version}</version>
         </dependency>
    
  3. SpringCloudConfg를 하나 만들어서 적용. feign+hystrix가 잘된 버전의 FeignClientsConfiguration을 참고하면 됨
         @Configuration
         @ConditionalOnClass({HystrixCommand.class, HystrixFeign.class})
         @ConditionalOnProperty("feign.hystrix.enabled")
         public static class HystrixFeignConfiguration {
             @Bean
             @Scope("prototype")
             public Feign.Builder feignHystrixBuilder() {
                 return HystrixFeign.builder();
             }
         }
    

이제 Hystrix 통합 잘 됨. 통합이 잘되는지 안되는지 여부는 /actuator/hystrix.stream 에 있는 정보를 바탕으로 디버깅함 여기에서 HystrixCommand의 threadPool, group 이름을 보면 설정이 되는지 안되는지 알 수 있음.

설정이 제대로 안된 경우에는 group, threadPool 속성에 “HystrixCircuitBreakerFactory” 인가 하는 값이 들어가 있음. 거기에는 @FeignClient 애노테이션의 name(value) 속성이랑 같은 값이 설정되어 있어야함.

  • 사용자 인퍼페이스와 도메인 모델 사이에 위치
    • 유스케이스 태스크를 조정
  • 트랜잭션, 보안 권한 부여 담당

애플리케이션을 핵심 도메인 모델과 상호 교류하며 이를 지원하기 위해 잘 조합된 컴포넌트의 집합을 의미하기 위해 사용하고 있다. 이는 일반적으로 도메인 모델 그 자체와 사용자 인터페이스, 내부적으로 사용되는 애플리케이션 서비스, 인프라적 컴포넌트를 뜻한다. 이 각각의 영역에 어떤 것들이 들어가는지는 애플리케이션마다 다르며, 사용하는 아키텍처가 무엇인지에 따라서도 달라진다.

특정한 한 가지 아키텍처에 국한되지 않은 주요 애플리케이션 영역. 여전히 각기 다른 영역의 추상화에 의존적인 인프라의 DIP를 강조한다.

그림 14.1 특정한 한 가지 아키텍처에 국한되지 않은 주요 애플리케이션 영역. 여전히 각기 다른 영역의 추상화에 의존적인 인프라의 DIP를 강조한다.

ui.Controller -구현-> infra.TomcatController -위임-> application.Service -구현-> infra.TransactionalService -위임-> domain.Repository -구현-> infra.JpaRepository -의존-> infra.Mysql

  • 필자는 UI -> Appcliation -> Infra 순서로 설명해 나간다. (Top-Down 방식)

사용자 인터페이스

UI 종류

  1. Web 1.0 렌더링 되는 HTML
  2. Web 2.0 DHTML + Ajax
  3. 데스크톱 애플리케이션 + HTTP 통신

도메인 객체의 렌더링

사용자 인터페이스는 다수의 애그리게잇 인스턴스의 속성을 렌더링할 필요가 있지만 한 번에 하나의 인스턴스를 수정하도록 요청한다

그림 14.2 조회 시에는 복수 의 애그리게잇. 명령 시에는 한 개의 애그리게잇

애그리게잇 인스턴스로부터 데이터 전송 객체를 렌더링하기

DTO + Assembler

개인적으로 가장 선호하는 방식

애그리게잇 내부 상태를 발행하기 위해 중재자를 사용하자

중재자 패턴

개인적으로 비추 도메인 모델이 (약하긴 하지만) UI에 의존하게 됨

도메인 페이로드 객체로부터 애그리게잇 인스턴스를 렌더링하라

도메인 객체를 내부에 담고 있는 DPO(Domain Payload Object)로 렌더링 하는 방식

  • 지연로딩 이슈가 있기 때문에 OSIV와 같은 기술과 함께 쓰길 권장한다.

애그리게잇 인스턴스의 상태 표현

View Model? Presentation Model?

DTO와 차이점을 모르겠다. 상태를 표현한다는 것으로 보아 불변이 가능한 정도 차이인 것인가?

유스케이스 최적 리파지토리 쿼리

특정 유스케이스에 특화된 쿼리 메소드 제공.

CQRS와 구분하자.

다수의 개별 클라이언트 처리하기

이건 SKIP 하자. 최근의 MVC 프레임워크에서 지원하므로 Application의 책임이 아니라고 본다.

변환 어댑터와 사용자 편집의 처리

Adapter + Command (Or Presentation Model)

주로 명령(편집, 수정) 시에 사용하는 방식

애플리케이션 서비스

  • 애플리케이션 서비스를 얇게 유지(파사드)
  • 도메인 모델로 위임

애플리케이션 서비스 예제

원시타입의 나열 VS 인자 (커맨드) 객체

  • 원시타입이 너무 많이 나열되면 그것은 안티패턴이라고 보고 가능하면 인자(Or 커맨드)로 캡슐화 하는 것이 좋아 보인다.

단순 인자이며 명령을 캡슐화 하지 못하는데 커맨드 패턴이라고 부르는 것이 어색해 보인다.

package com.saasovation.identityaccess.application;

class TenantIdentityService {
    @Transactional  // 트랜잭션과 보안 처리
    @PreAuthorize("hasRole('SubscriberRepresentative')")
    public void activeTenant(TenantId tenantId) {
        // 도메인 모델로 위임
        this.nonNullTenant(tenant).activate();
    }

    @Transactional(readOnly = true)
    public Tenant nonNullTenant(TenantId tenantId) {
        Tenant tenant = this.tenant(tenantId);
        if (tenant == null) {
            throw new IllegalArgumentException("Tenant does not exists");
        }
        return tenant;
    }
}

결합이 분리된 서비스 출력

    @Override
    @Transactional(readOnly = true)
    public void findTenant(TenantId tenantId) {
        Tenant teannt = 
                this.tenantRepository.tenantOfId(tenantId)
        this.tenantIdentityOutputPort().write(tenant);
    }

최근 추세는 어쨋든 사용자에게 출력에 대한 책임은 가능한 UI가 하는 것이 좋다고 본다. 이는 애플리케이션의 책임이 아닌 것 같다.

그러한 것은 컴포넌트로 추상화 해서 UI 모듈에 있는 것이 더 어울리는 것 같다. 개인적으로 출력은 반환 값이 있는 것이 더 직관적으로 보인다.

여러 바운디드 컨텍스트 묶기

UI에서 다수의 모델을 구성해야만 할 때가 있다. 이 그림에서는 하나의 애플리케이션 계층을 사용해서 세 개의 모델을 구성한다.

그림 14.3 UI에서 다수의 모델을 구성해야만 할 때가 있다. 이 그림에서는 하나의 애플리케이션 계층을 사용해서 세 개의 모델을 구성한다.

  • 애플리케이션 계층이 유스케이스를 관리 - UI 계층이 아니다!!
  • 제품, 토론, 리뷰 컨텍스트가 분리 VS 하나의 컨텍스트로 통합 > 결론은 모델 정제!!

개인적으로 이러한 상황에서는 UI에서 각각 쿼리 해서 조합하던가, API-GW에서 조합하는 것이 좋다고 본다.

물론 특정 컨텍스트 내에서 조합해야 한다면 애플리케이션 서비스가 가장 어울리는 장소인 것 같다.

인프라

애플리케이션 서비스는 도메인 모델의 리파지토리 인터페이스에 의존적이지만, 인프라의 구현 클래스를 사용한다. 패키지는 넓은 범위의 책임을 캡슐화 한다

그림 14.4 애플리케이션 서비스는 도메인 모델의 리파지토리 인터페이스에 의존적이지만, 인프라의 구현 클래스를 사용한다. 패키지는 넓은 범위의 책임을 캡슐화 한다

엔터프라이즈 컴포넌트 컨테이너

EJB VS Spring

마무리

  • 도메인 모델 -> UI 모델
  • 사용자 입력 -> 도메인 모델
  • 애플리케이션 서비스의 책임
  • DIP로 도메인 모델과 기술(인프라) 분리 및 유연성 확보

통합의 기본

  1. API & RPC (RPC, SOAP, WEB/API) : 탄력성이 떨어짐(Legacy)
  2. 메시지
  3. RESTful API
    • 1.과 유사하지 않은가? 하지만 필자는 HTTP Verbs 로 차이를 이야기하고 있다.
  4. 그 외

분산 시스템은 근본적으로 다르다

분산 컴퓨팅의 원칙

  • 네트워크는 신뢰할 수 없다.
  • 언제나 지연은 있다.
  • 대역폭은 무한하지 않다.
  • 정보와 정책은 다수의 관리자에 걸쳐 퍼져 있다.
  • 네트워크 전송은 비용이 든다.
  • 네트워크는 다양성을 갖고 있다.

왜 원칙인가?

  • 반드시 해결해야하는 문제점
  • 계획해야하는 복잡성
  • 일반적으로 저지르는 실수가 아님

시스템 경계에 걸친 정보의 교환

결국은 발행된 언어Published Language에 의존해야한다.

  • 정보에서는 인터페이스/클래스가 중요한 것이 아니라 말 그대로 데이터의 특성이 중요

예를 들어서

BacklogItemCommitted 알림 이벤트를 발행한다고 하면 아래와 같은 계약을 의미할 것이다.

정의/속성 설명
타입 Notification
포멧 JSON
notificationId 식별자
typeName 알림의 타입(BacklogItemCommitted)
version 알림 버전
occurredOn 발생일시
event 본문(Payload)

직렬화 & 역직렬화 자바 소스는 SKIP

중요한 것

  • 위와 같은 계약을 통해 컨슈머와 프로슈머가 서로 통신(정보의 교환)을 할 수 있다
  • 시스템 상호간 의존성이 줄어든다. 공유 커널과 비교해보아라
    • 버전을 통해서 호환성 이슈가 더 없어진다.
  • 위 내용을 사용자 지정 미디어 타입 계약 이라 한다.

레스트풀 리소스를 사용한 통합

오픈 호스트

서비스의 집합으로서 여러분의 서브시스템으로의 액세스를 허용하는 프로토콜을 정의하자. 여러분과 통합해야 하는 모든 사람들이 사용할 수 있도록 프로토콜을 공개하자. 프로토콜을 강화하고 확장해서 새 통합 요구사항을 처리하도록 하자. [Evans]

  • 결합도를 느슨하게 하기 위해서(자율성) retry와 같은 기법이 요구된다.

RESTful API 예를 들면

특정 사용자의 권한을 조회 /tenants/{tenantId}/users/{username}/inRole/{role}

  • 200 : 권한 있음
  • 204 : 권한 없음 (404도 괜찮지 않을까?)

레스트풀 리소스의 구현

현업에서 우리는 오픈 호스트라고 생각했지만 실상은 공유된 커널 이거나 순응주의자인 경우가 많다.

오픈 호스트라면 바운디드 컨텍스트가 의존성이 매우 낮지만 공유된 커널이나 순응주의자는 상당한 결합도를 가지게 되므로 자율성을 위해서 오픈 호스트 지향하는 것이 좋다고 본다.

UserResource.java

    @GET
    @Path("{username}/inRole/{role}")
    @Produces({ OvationsMediaType.ID_OVATION_TYPE })
    public Response getUserInRole(
            @PathParam("tenantId") String aTenantId,
            @PathParam("username") String aUsername,
            @PathParam("role") String aRoleName) {

        User user = this.accessApplicationService()
                       .userInRole(
                               aTenantId,
                               aUsername,
                               aRoleName);

        Response response = this.userInRoleResponse(user, aRoleName);
        return response;
    }

사용자의 권한을 조회하는 API 응답

HTTP1.1 200 OK
Content-Type: application/vnd.saasovation.idovation+json
...
{
    "role": "Author",
    "username": "zoe",
    "tenantId": "A94A-...",
    "firstName": "Zoe",
    "lastName": "Doe",
    "emailAddress": "zoe@saasovation.com"
}

부패 방지 게층을 통한 REST 클라이언트 구현

ID와 액세스 컨텍스트와 협업 컨텍스트의 부패 방지 계층 사이의 통합을 위해 사용된 오픈 호스트 서비스

그림 13-1 ID와 액세스 컨텍스트와 협업 컨텍스트의 부패 방지 계층 사이의 통합을 위해 사용된 오픈 호스트 서비스

interface CollaboratorService {
    Author authorFrom(Tenant tenant, String identity);
    Creator creatorFrom(Tenant tenant, String identity);
    Moderator moderatorFrom(Tenant tenant, String identity);
    Owner ownerFrom(Tenant tenant, String identity);
    Participant participantFrom(Tenant tenant, String identity);
}

Java Code

리파지토리를 사용할 수도 있었지만 Author는 엔터티가 아닌 값 객체이므로 부패 방지 계층이 더 어울리다. 반대로 부패 방지 계층에서 애그리게잇(가변성)을 생성한다면 리파지토리가 더 어울린다.

메시징을 사용한 통합

레스트풀 리소스를 이용한 방식보다 더 높은 수준의 자율성을 가짐.

도메인 이벤트를 외부로 발행할 때 좋다.

제품 소유자와 팀 맴버의 정보를 계속해서 제공받는 것

사용자에게 권한을 할당

  1. 권한에 사용자를 추가
    • AccessService#asignUserToRole(AssignUserToRoleCommand)
    • Role#assignUser(User)
  2. 사용자에게 권한 할당됨 이벤트 발행 : new UserAssignedToRole()
  3. 컨텍스트 외부로 이벤트 발행
  4. AgilePM 컨택스트에서 이벤트를 구독
    • TeamMemberEnablerListener#filteredDispatch
    • TeamService#enabledProductOwner(EnableProductOwnerCommand)

당신은 책임을 감당할 수 있는가

외부 바운디드 컨텍스트에서 정보를 (이벤트를 통해서) 복제한 후 그것을 지속적으로 동기화한다. 즉, 데이트를 복사하는 복잡한 책임이 생긴다.

이벤트가 항상 순차적으로 구독자에게 온다는 보장이 없다. 고로 이벤트 처리 시 항상 발생시각을 확인해서 처리해야한다.

class TeamService {
    @Transactional  // 구독 처리
    public void disableTeamMember(DisableTeamMemberCommand command) {
        TenantId tenantId = new TenantId(command.getTenantId);
        TeamMember teamMember = 
                teamMemberRepository.teamMemberOfIdentity(
                        tenantId, command.getUsername());
        if (teamMember != null) {
            // 여기가 중요하다!!!
            teamMember.disable(command.getOccurredOn());
        }
    }
}

class Member {
    // 위에 이어서 disable 메서드를 보면
    public void disable(Date asOfDate) {
        if (this.changeTracker().canToggleEnabling(oaOfDate)) {
            this.setEnabled(false); // enable() 라고 하지..
            this.setChangeTracker(
                this.changeTracker().enablingOn(asOfDate));
        }
    }
    public void enable(Date asOfDate) {
        if (this.changeTracker().canToggleEnablig(asOfDate)) {
            this.setEnabled(true)); // disable() 라고 하지..
            this.setChangeTracker(
                this.changeTracker().enablingOn(asOfDate));
        }
    }
}
  • 위 처리를 통해서 이벤트 순서가 뒤바껴도 처리에 문제가 없다.
  • 또한 멱등하게 만드는 역할도 한다.

새로운 책임을 알아보자 : 정보 복제는 사소한 책임이 아니다

사용자 정보 복제 시 영향을 미치는 이벤트들

  • PersonContactInformationChanged
  • PersonNameChanged
  • UserAssignedToRole
  • UserUnassignedFromRole

하지만 이외에도

  • UserEnablementChanged
  • TenantActivated
  • TenantDeactivated

위와 같은 이벤트들이 존재한다.

= 가능하면 바운디드 컨텍스트 전반에서 정보의 중복을 최소화하거나 완전히 제거하는 것이 최선이다

= 필요할 때 마다 오픈 호스트 서비스와 같은 것을 이용해서 정보를 조회하는 것이 나을수도 있다

어쩌란 건지 명확히 이해가 안된다.??? 필요한 이벤트 발행을 막을 필요는 없지만 굳이 데이터 중복을 통해서 관리하지 말고 이벤트는 이벤트대로 사용하고, 데이터는 필요할 때 마다 조회하는 것이 낫다는 것인가?

장기 실행 프로세스와 책임의 회피

다른 바운디드 컨텍스트에게 데이터 생성 책임을 지게 하고, 단순하게 레코드 시스템이 그 고유한 정보를 처리하게 할 것이다. 즉, 책임을 네트워크 건너로 차버린다.

제품을 생성하는 유스케이스를 살펴본다

  1. 사용자는 제품 설명 정보를 제공한다.
  2. 사용자는 팀토론에 대한 의사를 표시한다.
  3. 사용자는 정의된 제품을 만들도록 요청한다.
  4. 시스템은 포럼과 토론이 들어간 제품을 만든다.

애자일 프로젝트 관리 컨텍스트에서

애자일 프로젝트 관리 컨텍스트에서 흐름도

ProjectService#newProductWithDiscussion(NewProductCommand)

  • ProductProductDiscussion을 생성
  • ProductCreated 이벤트 발행
    • Discussion#availablity#isRequested(토론 가능 상태)가 참이면 장기 프로세스를 시작 한다.
    • 장기 실행 프로세스 == 상품 토론을 생성
  • 추후에 구매를 통해서 토론을 생성할 수도 있다. Product#requestDiscussion(DiscussionAvailablity)
    • ProductDiscussionRequested 이벤트 발행

그렇다면 사용 가능 상태가 REQUESTED가 아니라면 이 이벤트의 발행의 의미가 있는가?

> 이것에 대한 책임은 이벤트 구독자가 갖는다.

ProductDiscussionRequestedListener#listensToEvents

  • ProductCreated, ProductDiscussionRequested 이벤트를 수신한다.

ProductDiscussionRequestedListener

    protected void filteredDispatch (String type, String textMessage) {
        // 토론 사용 가능상태가 아니면 이벤트 처리 없음
        if (!reater.eventBooleanValue("requestingDiscussion")) {
            return;
        }
        ...
        // CreateExclusiveDicussion 커맨드를 만들어 협업 컨텍스트에 메시징 인프라를 통해서 이벤트 발행
        this.messageProducer().send(...);
    }

여기서 잠깐

  • 애자일 프로젝트 관리 컨텍스트에서 Product 애그리게잇에서 발행된 이벤트(ProductCreated)를 구독할 리스터를 만들 필요가 있을까?
    • 차라리 협업 컨텍스트에서 ProductCreated 이벤트를 바로 구독하는 것이 낫지 않을까?
    • 그런데 ProductCreated는 협업 컨텍스트의 유비쿼터스 언어와 어울리지 않고, 이 이벤트가 ForumDiscussion을 생성하게 한다는 것을 연결시키는 것이 힘들다.
    • 고로 협업 컨텍스트 입장에서 CreateExclusiveDicussion 명령을 이벤트로 받는 것이 어색하지 않다.

협업 컨텍스트에서

협업 컨텍스트에서 흐름도

ExclusiveDiscussionCreationListener#filteredDispatch(String, String)

  • 위에서 이어 애자일 프로젝트 관리 컨텍스트에서 발행한 CreateExclusiveDicussion를 구독
  • ForumService#startExclusiveForumWithDiscussion를 호출해서 ForumDiscussion을 생성
    • 생성과 동시에 DiscussionStarted 이벤트를 발행한다.

다시 애자일 프로젝트 관리 컨텍스트로 돌아와서

다시 애자일 프로젝트 관리 컨텍스트에서 흐름도

DiscussionStartedListener#filteredDispatch(String, String)

  • 협업 컨텍스트에서 발행한 DiscussionStarted 이벤트를 구독한다.

이어서 ProductService#initiateDiscussion(InitiateDiscussionCommand)

  • Product#initiateDiscussion 를 실행해서 아까 생성한 Product에서 Discussion 생성 상태 변경
    • 당연히 멱등하다. 이미 Discussion을 생성해서 READY 상태라면 아무일도 일어나지 않는다. 즉 REQUESTED 상태에서만 이하 작업이 실행된다.
    • 그리고 ProductDiscussionInitiated 이벤트를 발행한다.

여기서 잠깐

  • 이 장기 실행 프로세스는 네트워크를 건너서 통신하고 메시징 인프라에 의존하는데 여기에 문제가 생기면 어떻게 될까?
  • 즉 프로세스가 끝까지 실행한다고 확신할 수 있을까?

프로세스 상태 머신과 타임아웃 트래커

위 문제점 해결을 위해서 타임아웃 트래커가 필요하게 된다.

타입아웃 트래커

트래커는 완료까지 부여된 시간이 만료된 프로세스를 감시하는데. 이런 프로세스는 만료되기 전까지 몇번이고 재시도될 수 있다. 트래커는 원하는 경우 고정된 시간 간격을 재시도하도록 설계할 수 있고, 재시도를 전혀 하지 않거나 정해진 횟수만큰을 재시도한 후에 타임아웃을 발생시키도록 할 수도 있다. 기술적 서브도메인의 일부다

상품의 토론 생성 흐름도 with 타임아웃 트래커

ProductService#startDiscussionInitiation

package com.saasovation.agilepm.application;

class ProductService {
    @Transactional
    public void startDiscussionInitiation(
            StartDiscussionInitiationComman command) {
        Product product = productRepository.findById(...)
        
        TimeConstrainedProcessTracker tracker = new TimeConstrainedProcessTracker(
                product.tenantId().id,
                ProcessId.newProcessId(),
                "Create discussion for product : " + product.name(),
                new Date(),
                5L * 60L * 1000L, // 5분 마다 재시도
                3,                // 총 3회 재시도한다.
                ProductDiscussionRequestTimedOut.class);

        processTrackerRepository.add(tracker);  // 타임아웃 트래커 등록
        
        product.setDiscussionInitiationId(
                tracker.processId().id());
    }
}

상품의 토론 타임아웃 or 재시도 흐름도

상품의 토론 타임아웃 or 재시도 흐름도 with 타임아웃 트래커

마지막에 상품과 토론이 정상적으로 생성되면 흐름도

상품과 토론이 정상적으로 생성 시 흐름도

  • 애자일 프로젝트 관리 컨텍스트에서 마지막으로 상품의 토론이 정상적으로 생성되면 타임아웃 트래커를 완료 시켜야 한다.
  • 이것으로 프로세스는 끝난다.

위 장기 실행 프로세스의 문제점과 해결책

  • 동일한 CreateExclusiveDiscussion 커맨드가 여러 번 발송된다.
    • 멱등성을 지켰기 때문에 문제가 없을 것 같다.
    • 하지만 애자일 프로젝트 관리 컨텍스트가 실패한다면 문제가 발생할 수 있다.
    • 결국 협업 컨텍스트의 ExclusiveDiscussionCreationListener가 멱등한 애플리케이션 서비스 메서드로 작업을 위임할 수 있다면 많은 문제점을 해결할 수 있다.

협업 컨텍스트에서 흐름도

  • 위 흐름도 기준에서 startForum, startDiscusssion 행위가 멱등하면 해결된다.

좀 더 복잡한 프로세스 설계하기

복잡한 상황에서 다수의 완료 단계가 필요하게 된다면 상태 머신이 좋은 선택

Proces process = new TestableTimeConstrainedProcess(
        TENANT_ID,
        ProcessId.newProcessId(),
        "Testable Time Constrained Process",
        5000L); // 재시도 없이 5초 내로 완료되야 한다.

TimeConstrainedProcessTracker tracker = 
        process.timeConstrainedProcessTracker();
    
process.confirm1();

assertFalse(process.isCompleted());
assertFalse(process.didProcessingComplete());
assertEquals(process.processCompletionType(), ProcessCompletionType.NotCompleted);

process.confirm2();

assertTrue(process.isCompleted());
assertTrue(process.didProcessingComplete());
assertEquals(process.processCompletionType(), ProcessCompletionType.CompletedNormally);
assertNull(process.timedOutDate());

tracker.informProcessTimedOut();    // 어짜피 이미 완료되어 있으므로 아무처리가 없을 것
assertFalse(process.isTimedOut());  // 즉 타임아웃 되지 않았으므로 언제나 false
  • TestableTimeConstrainedProcess는 재시도 없이 5초 내로 완료되야 한다.
  • confirm1(), confirm2()만 호출된다면 완전히 완료 상태로 표시된다.
    • 내부적으로 2가지 상태를 속성으로 지니고 있다.
    • 즉, confirm1, confirm2 둘 다 true 여야지 완료 상태

메시징이나 시스템을 활용할 수 없을 때

  • 고가용성 확보가 최선이다.
  • 메시징인프라가 죽었다가 다시 살아났을때 자동으로 리스너가 재활성화 될 수록 해야한다.

마무리

  • 분산 컴퓨팅에서의 통합
  • RESTful VS 메시징인프라
  • 장기 수행 프로세스
    • Retry와 타임아웃
    • 멱등성
    • 복잡한 상태를 해결해주는 상태 머신

전역(global) 액세스가 필요한 각 객체의 타입이다, 해당 타입의 모든 객체가 담긴 인메모리 컬렉션이란 허상을 제공하는 객체를 생성하자. 잘 알려진 전역의 인터페이스를 통한 액세스를 설정하자. 객체를 추가하거나 제거하는 메소드를 제공하자… 일정 조건에 부합되는 특성을 갖는 객체를 선택해 완전히 인스턴스화된 객체나 여러 객체의 컬렉션으로 반환하는 메소드를 제공하자… 애그리게잇에 대해서만 리파지토리를 제공하자.. [Evans]

애그리게잇 : 리파지토리 = 1(..N):1

컬렉션 지향 리파지토리

package java.util;

public interface Collection {
    public boolean add(Object o);
    public boolean addAll(Collection c);
    public boolean remove(Object o);
    public boolean removeAll(Collection c);
}
  • 같은 인스턴스를 두번 추가되도록 허용해서는 안된다.
  • 재저장을 할 필요가 없다.
    • 그저 객체의 상태를 변경시키면 자동으로 저장될 뿐이다.
  • 결국 Set처럼 행동해야 한다.

재저장할 필요가 없다면 객체의 상태를 추적해야한다.

영속성 매커니즘의 암시적 변경 추적 기법

  • 암시적 읽기 시 복사(Copy-on-Read) : 읽을 때 복사본을 만들어 두고 복사본과 비교해서 변경된 것이 있으면 Commit 시킨다.
  • 암시적 쓰기 시 복사(Copy-on-Write) : 프록시로 감싸두고 Dirty(상태 변경이 되면)인 경우 Commit 시킨다.

하이버네이트 구현

package com.saasovation.collaboration.domain.model.calendar;    //!!

interface CalendarEntryRepository {
    void add(CalendarEntry calendarEntry);
    void addAll(Collection<CalendarEntry> calendarEntries);
    void remove(CalendarEntry calendarEntry);
    void removeAll(Collection<CalendarEntry> calendarEntries);

    CalendarEntry calendarEntryOfId(Tenant tenant, CalendarEntriyId id);
    Collection<CalendarEntry> calendarEntriesOfCalendar(
        Tenant tenant, CalendarId calendarId);
    Collection<CalendarEntry> overlappingCalendarEntries(
        Tenant tenant, CalendarId calendarId, TimeSpan timeSpan);

    CalendarEntryId nextIdentity();
}
  • package는 도메인 모델과 함께한다.
  • 물리적 삭제 VS 논리적 삭제
package com.saasovation.collaboration.infrastructure.persistence;

public class HibernateCalendarEntryRepository 
        implements CalendarEntryRepository {
    
    private final SpringHibernateSessionProvider sessionProvider;

    public HibernateCalendarEntryRepository(
            SpringHibernateSessionProvider sessionProvider) {
        this.sessionProvider = sessionProvider;
    }

    private org.hibernate.Session session() {
        return this.sessionProvider.session();
    }

    @Override
    public void add(CalendarEntry calendarEntry) {
        this.session().saveOrUpdate(calendarEntry);
    }

    @Override
    public void addAll(Collection<CalendarEntry> calendarEntries) {
        for (CalendarEntry each : calendarEntries) {
            this.session().saveOrUpdate(each);
        }
    }

    @Override
    public void remove(CalendarEntry calendarEntry) {
        this.session().delete(calendarEntry);
    }

    @Override
    public void removeAll(Collection<CalendarEntry> calendarEntries) {
        for (CalendarEntry each : calendarEntries) {
            this.session().delete(each);
        }
    }
}
  • package : infrastructure.persistence
  • Set과 유사한 행위 제공
  • Cascade를 통한 연관된 엔터티를 같이 변경하는 기능도 있음
  • 복잡한 조회의 경우 HQL을 이용
    • 하지만 JPA를 사용한다면 JPQL을 사용하나, 현재까지는 querydsl 과 같은 기술이 좋은 것 같다.

탑링크 구현에 대한 고려

탑링크는 명시적으로 작업단위(Unit of work)를 지정할 수 있다.

  • 일종의 트랜잭션의 범위를 추상화 시킨 것
Calendar calendar = session.readObject(...);
UnitOfWork work = session.acquireUnitOfWork();
Calendar calendarToRename = work.registerObject(calendar);
calendarToRename.rename("CollabOvation Project Calendar");
work.commit();
package com.saasovation.collaboration.infrastructure.persistence;

public class ToplinkCalendarEntryRepository 
        implements CalendarEntryRepository {
    @Override
    public void add(Calendar calendar) {
        this.unitOfWork().registerNewObject(calendar);
    }

    @Override
    public void editingCopy(Calendar calendar) {
        return (Calendar) this.unitOfWork().registerObject(calendar);
    }
}

영속성 지향의 리파지토리

  • 컬렉션 지향 리파지토리 : Set
  • 영속성 지향 리파지토리 : HashMap
    • 하지만 꼭 put를 해야한다. - 원자적 쓰기를 통제할 수 없음(트랜잭션 없음)

No-Sql 종류가 많다.

  • 잼파이어
  • 코히어런스
  • 몽고DB
  • 리악

코히어런스 구현

package com.saasovation.agilepm.domain.model.product;

interface ProductRepository {
    ProductId nextIdentity();
    Collection<Product> allProductsOfTenant(Tenant tenant);
    Product productOfId(Tenant tenant, ProductId productId);
    void remove(Product product);
    void removeAll(Collection<Product> products);
    void save(Product product);
    void saveAll(Collection<Product> products);
}
package com.saasovation.agilepm.infrastructure.persistence;

class CoherenceProductRepository 
        implements ProductRepository {
    private Map<Tenant, NamedCache> caches;

    public CoherenceProductRepository() {
        this.caches = new HashMap<>();
    }

    private synchronized NamedCache(TenantId tenantId) {
        NamedCache cache = this.caches.get(tenantId);
        if (cache == null) {
            // ageilepm:Product:TenantId
            // 1단계 : 2단계 : 3단계
            cache = CacheFactory.getCache(
                "agilepm.Product." + tenantId.id(),
                Product.class.getClassLoader());
            this.caches.put(tenantId, cache);
        }
        return cache;
    }

    @Override
    public void save(Product product) {
        this.cache(product.tenantId())
            .put(this.idOf(product), product);
    }

    @Override
    public void saveAll(Collection<Product> products) {
        Map<String, Product> productsMap = 
            new HashMap<>(products.size());
        for (Product each : products) {
            if (tenantId == null) {
                tenantId = product.tenantId();
            }
            productsMap.put(this.idOf(product), each);
        }
        this.cache(tenantId).putAll(productsMap);
    }

    private String idOf(Product product) {
        return this.idOf(product.productId());
    }
    private String idOf(ProductId productId) {
        return productId.id();
    }

    @Override
    public void remove(Product product) {
        this.cache(product.tenantId()).remove(this.idOf(product));
    }

    @Override
    public void removeAll(Collection<Product> products) {
        for (Product each : products) {
            this.remove(product);
        }
    }

    @Override
    public Collection<Product> allProductsOfTenant(Tenant tenant) {
        Set<Map.Entry<String, Product>> entries = 
            this.cache(tenant).entrySet();
        Collection<Product> products = 
            new HashSet<Product>(entries.size());
        for (Map.Entry<String, Product> entry : entries) {
            products.add(entry.getValue());
        }
        return products;
    }

    @Override
    public Product productOfId(Tenant tenant, ProductId productId) {
        return (Product) this.cache(tenant).get(this.idOf(productId));
    }
}

몽고DB 구현

  1. 애그리게잇을 -> 몽고DB포맷(직렬화), 몽고DB포맷 -> 애그리게잇(역직렬화)
  2. 몽고DB 문서의 고유 식별자(_id)
  3. 몽고DB 노드/클러스터 참조
class MongoProductRepository 
        extends MongoRepository<Product>
        implements ProductRepository {
    
    public MongoProductRepository() {
        super();
        this.serializer(new BSONSerializer<Product>(Product.class));
    }

    public ProductId nextIdentity() {
        // 몽고DB에서 제공하는 식별자. _id 필드에 매핑시킬수 있음
        return new ProductId(new ObjectId().toString());
    }

    @Override
    public void save(Product product) {
        this.databaseCollection(
                this.collectionName(product.tenantId()))
            .save(this.serialize(product));
    }

    protected String collectionName(TenantId tenantId) {
        return "product" + tenantId.id();
    }

    protected String databaseName() {
        return "agilepm";
    }

    @Override
    public Collection<Product> allProductsOfTenant(
            TenantId tenantId) {
        Collection<Product> products = new ArrayList<>();
        DBCursor cursor = 
            this.databaseCollection(
                this.databaseName(),
                this.collectionName(tenantId)).find();
        while (curosr.hasNext()) {
            DBObject dbObject = cursor.next();
            Product product = this.deserialize(dbObject);
            products.add(product);
        }
        return products;
    }

    @Override
    public Product productOfId(
            TenantId tenantId, ProductId productId) {
        Product product = null;
        BasicDBObject query = new BasicDBObject();
        query.put("producetId", 
            new BasicDBObject("id", productId.id()));
        DBCursor cursor = 
            this.databaseCollection(
                this.databaseName(),
                this.collectionName(tenantId)).find(query);
        if (cursor.hasNext()) {
            product = this.deserialize(cursor.next());
        }
        return product;
    }
}
class BSONSerializer<T> {
    DBObject serialize(String key, T object) {
        DBObject serialization = this.serialize(object);
        serialization.put("_id", new ObjectId(key));
        return serialization;
    }
}
abstract class MongoRepository<T> {

    protected DBCollection databaseCollection(
            String databaseName, String collectionName) {
        return MongoDatabaseProvider
                .database(databaseName)
                .getCollection(collectionName);
    }
}
  • BSONSerializer 때문에 Setter를 제공할 필요가 없음

추가적인 행동

  • 예를 들면 count() or size()
  • 또는 리파지토리에서 애그리게잇 파트를 쿼리하는 것 (성능상 잇점)
    • 하지만 이건 안티패턴이기 때문에 가능한 사용하지 않는 것이 좋다 (애그리게잇 법칙 위배)
    • 만약 그게 당연시 하다고 느껴지면 애그리게잇을 분리하는 것도 한 방법
  • 일반적으로 도메인 서비스 제어하가 어울린다.

하지만 너무 유스케이스에 최적화된 조회 메서드를 많이 제공한다면 악취일 수도 있다

  • 그런 경우 CQRS를 고려해보자

트랜잭션의 관리

트랜잭션 관리는 애플리케이션 계층의 책임이다. = 애플리케이션 서비스

트랜잭션 관리 방법

class ApplicationServiceFacade {
    // 명식적인 트랜잭션
    public void doSomeUseCaseTask1() {
        Transaction transaction = null;
        try {
            transaction = this.session().beginTransaction();
            // 도메인 모델을 사용한다.
            transaction.commit();
        } catch (Exception e) {
            if (transaction ! null) {
                transaction.rollback();
            }
        }
    }

    // 선언적인 트랜잭션
    @Transactional
    public void doSomeUseCaseTask2() {
        // 도메인 모델을 사용한다.
    }
}
  • 선언적인 방식이 더 낫다. 순수하게 도메인 로직에 위임하는 것에 집중할 수 있다.
    • 관심사 분리!!

경고

단일 트랜잭션에서 여러 애그리게잇을 수정을 커밋하는 기능을 과도하게 사용하지 않도록 주의하라.

타입 계층구조

Type Hierarchy

// 도메인 모델의 클라이언트
serviceProviderRepository.providerOf(id)
        .scheduleService(date, description);
  • 상위 타입(ServiceProvider)으로 처리
// 도메인 모델의 클라이언트
if (id.identifiesWarble()) {
    serviceProviderRepository.warbleOf(id)
            .scheduleWarbleService(date, warbleDescription);
} else if (id.identifiesWonkle()) {
    serviceProviderRepository.wonkleOf(id)
            .scheduleWonkleService(date, wonkleDescription);
}
  • 하위 타입을 클라이언트에서 알고 분기 처리 해야하는 책임이 늘어난다.
  • 위 코드는 전형적인 악취이다.

그러므로 상위타입은 하위타입에 대한 구분정보(type 속성)를 알고 있어야 한다.

  • 그렇게 된다면 Repository 계층에서 캡슐화 시켜서 하위 타입 별 분기 처리를 할 수 있을 것이다.
@Entity
class ServiceProvider {
    ...
    private ServiceType type;

    public void scheduleService(Date date, ServiceDescription description) {
        if (type.isWarble()) {
            this.scheduleWarbleSevice(date, description);
        } else if (type.isWonkle()) {
            this.scheduleWonkleService(date, description);
        } else {
            this.scheduleCommonService(date, description);
        }
    }
}
  • 나라면 위의 경우에서도 ServiceType으로 위임해서 if문을 제거했을 것 같다.

리파지토리 대 데이터 액세스 객체

Repository != DAO

  • 리파지토리 : 객체 지향 > with 도메인 모델 패턴
  • DAO : 데이터 지향 > with 트랜잭션 스크립트 패턴

SMART DAO는 DDD 입장에서는 안티패턴

  • SMART DAO는 도메인 로직이 DB 쿼리나 DB의 프로시저에 존재하는 경우를 말함

중요한 것은 데이터 액세스 지향보다는 컬렉션 지향으로 설계하려고 노력해야하는 점

리파지토리의 테스트

  • 실제 인프라와 연동하는 테스트 (실제 DB와 통신)
  • 인메모리로 연동하는 테스트

개인적으로 실제 인프라와 연동하는 것이 중요하다고 봄.

인메모리 구현으로 테스트하기

Skip

마무리

  • 컬랙션 지향 VS 영속성 지향
  • 리파지토리의 추가적인 행동 (count())
  • 트랜잭션 처리
  • 타입 계층과 리파지토리
  • Repository vs DAO
  • 테스트

애그리게잇을 생성하는 책임을 가지는 메소드나 객체를 말한다.

  • 애그리게잇 생성을 캡슐화

도메인 모델 내의 팩토리

복잡한 객체와 애그리게잇 인스턴스를 생성하는 책임을 별도의 객체로 이동시키자. 여기서의 책임은 도메인 모델과 관련이 있지 않지만, 여전히 도메인 설계를 구성하는 한 요소다. 모든 복잡한 조립 과정을 캡슐화하고, 클라이언트가 인스턴스화된 객체의 구체적 클래스를 참조할 필요가 없도록 인터페이스를 제공하자. 전체 애그리게잇을 하나의 조각(원자성)으로 생성하고 고정자(Invariant)를 지정하자 [Evans]

  • 팩토리 메소드 : 이 책은 주로 여기만 나옴
  • 팩토리 객체(클래스)
    • 애그리게잇 생성이 매우 복잡하고 다른 객체의 도움이 필요하면 클래스 분리를 하는 것이 좋은 것 같다.

가능한 팩토리 위치

  • 애그리게잇 루트
    • 정정 생성자를 통해서 도메인 의도가 나오면 더 좋다. static Task#forDraft()
    • 아니면 상위 루트에서 하위 엔터리 생성도 가능 Project#createTask()
  • 도메인 서비스 : 서비스 기반 팩토리
    • ProjectService#createProject()
  • 팩토리 : 이 책은 다루지 않음
    • ProjectFactory#create()

애그리게잇 루트상의 팩토리 메소드

바운디드 컨텍스트 애그리게잇 팩토리 메소드
식별자와 액세스 컨텍스트 Tenant offerRegisterationInvitation()
    provisionGroup()
    provisionRole()
    registerUser()
협업 컨텍스트 Calendar scheduleCalendarEntry()
  Forun startDiscussion()
  Discussion post()
애자일 PM 컨텍스트 Product planBacklogItem()
    scheduleRelease()
    scheduleSprint()

CalendarEntry 인스턴스 생성하기

@Entity
class Calendar extends AbstractAggregateRoot {
    CalendarEntry scheduleCalendarEntry(...) {
        CalendarEntry calendarEntry = new CalendarEntry(...);
        this.registerEvent(new CalendarEntryScheduled(...));
        return calendarEntry;
    }
}
  • 유비쿼터스 언어에 부합되는 도메인이 표현됨 : scheduleCalendarEntry
  • 보호절이 없다 : 어짜피 new CalendarEntry(...)가 책임짐
  • Setter를 사용하지 않는다. > 원자성 + 부수효과 줄어듬 + 불변식 강제가 쉬움
  • 이벤트 발행 : CalendarEntryScheduled
  • 인자의 갯수가 줄어든다.
    • scheduleCalendarEntry(...)에서는 11개가 요구되지만, new CalendarEntryScheduled(...)에서는 9개로 줄어든다.
    • 개인적으로는 이것도 인자를 캡슐화 시켜서 객체로 말아버리는 것이 좋은 것 같다. 9개의 인자라도 너무 많은 것 같음.

하지만

  • CalendarEntry를 생성하기 위해서는 꼭 Calendar 인스턴스가 요구됨
    • 이는 DB 조회라는 부하가 추가됨

Discussion 인스턴스 생성하기

@Entity
class Forum extends AbstractAggregateRoot {
    Discussion startDiscussion(
        DiscussionId discussionId,
        Author author,
        String subject) {

        if (this.isClosed()) {
            throw new IllegalStateException("Forum is closed");
        }
        Discussion discussion = new Discussion(
            this.tenant(),
            this.forumId(),
            discussionId,
            author,
            subject);
        registerEvent(new DiscussionStarted(...));
        return discussion;
    }
}
  • 포럼이 열린 경우에만 토론을 시작할 수 있다. : this.isClosed()
  • 인자 5개 중 3개만 있으면 된다. : 나머지 2개는 포럼에서 제공
  • 역시나 유비쿼터스 언어가 표현된다. : startDiscussion

서비스의 팩토리

package com.saasovation.collaboration.domain.model.collaborator;

interface CollaboratorSerice {
    Author authorFrom(Tenant tenant, String identity);

    Creator creatorFrom(Tenant tenant, String identity);

    Moderator moderatorFrom(Tenant tenant, String identity);

    Owner ownerFrom(Tenant tenant, String identity);

    Participant participantFrom(Tenant tenant, String identity);
}
// infrastructure.services !!!
package com.saasovation.collaboration.infrastructure.services;

class UserRoleToCollaborationService implements CollaboratorSerice {
    @Override
    public Author authorFrom(Tenant tenant, String identity) {
        return (Author) userInRoleAdapter.toCollaborator(
            tenant, identity, "Author", Author.class);
        )
    }
}
package com.saasovation.collaboration.domain.model.collaborator;

class Author extends Collaborator {
    ...
}
  • 기술적 구현이므로 인프라 계층의 모듈에 위치한다.
  • 어댑터에 의존한다.
    • UserInRoleAdapter는 외래 컨텍스트와 의사소통 책임만 갖는다.
    • CollaboratorTranslator는 바운디드 컨텍스트 내 도메인 객체로 변환 책임만 갖는다.
  • 협업에서는 identity 식별자와 액세스에서는 username

마무리

  • 유비쿼터스 언어로 애그리게잇을 생성
  • 애그리게잇의 팩토리 메서드 VS 서비스의 팩토리 메서드