1. 소개

이 사용방법(예제)에서는 명령 쿼리 책임 분리(CQRS) 및 이벤트 소싱 디자인 패턴의 기본 개념을 살펴봅니다.

종종 보완적인 패턴으로 인용되지만, 우리는 그것들을 개별적으로 이해하려고 노력하고 마침내 그것들이 서로를 보완하는 방법을 볼 것입니다. 이러한 패턴을 채택하는 데 도움이 되는 Axon 과 같은 여러 도구와 프레임워크가 있지만 기본 사항을 이해하기 위해 Java로 간단한 응용 프로그램을 만들겠습니다.

2. 기본 개념

구현을 시도하기 전에 먼저 이러한 패턴을 이론적으로 이해할 것입니다. 또한 개별 패턴으로 매우 잘 서 있기 때문에 혼합하지 않고 이해하려고 노력할 것입니다.

이러한 패턴은 엔터프라이즈 애플리케이션에서 함께 사용되는 경우가 많습니다. 이와 관련하여 여러 다른 엔터프라이즈 아키텍처 패턴의 이점도 있습니다. 우리는 함께 진행하면서 그들 중 일부에 대해 논의할 것입니다.

2.1. 이벤트 소싱

이벤트 소싱 은 애플리케이션 상태를 순서가 지정된 이벤트 시퀀스로 유지하는 새로운 방법을 제공합니다 . 이러한 이벤트를 선택적으로 쿼리하고 언제든지 애플리케이션 상태를 재구성할 수 있습니다. 물론 이 작업을 수행하려면 애플리케이션 상태에 대한 모든 변경 사항을 이벤트로 다시 이미지화해야 합니다.

여기서 이러한 이벤트 는 발생한 사실이며 변경할 수 없습니다. 즉, 변경 불가능해야 합니다. 애플리케이션 상태를 다시 만드는 것은 모든 이벤트를 재생하는 문제일 뿐입니다.

이것은 또한 이벤트를 선택적으로 재생하고 일부 이벤트를 역으로 재생하는 등의 가능성을 열어줍니다. 결과적으로 이벤트 로그를 기본 정보 소스로 사용하여 애플리케이션 상태 자체를 2차 시민으로 취급할 수 있습니다.

2.2. CQRS

간단히 말해서 CQRS는 애플리케이션 아키텍처의 명령 및 쿼리 측을 분리하는 것 입니다. CQRS는 Bertrand Meyer가 제안한 CQS(Command Query Separation) 원칙을 기반으로 합니다. CQS는 도메인 개체에 대한 작업을 쿼리와 명령의 두 가지 범주로 나눌 것을 제안합니다.

쿼리는 결과를 반환하고 시스템 의 관찰 가능한 상태변경하지 않습니다 . 명령은 시스템의 상태를 변경하지만 반드시 값을 반환하지는 않습니다 .

도메인 모델의 명령 및 쿼리 측면을 명확하게 분리하여 이를 달성합니다. 한 단계 더 나아가 데이터 저장소의 쓰기 및 읽기 측을 분할하여 동기화를 유지하는 메커니즘을 도입할 수도 있습니다.

3. 간단한 애플리케이션

도메인 모델을 구축하는 간단한 Java 응용 프로그램을 설명하는 것으로 시작하겠습니다.

응용 프로그램은 도메인 모델에 대한 CRUD 작업을 제공 하고 도메인 개체에 대한 지속성을 특징으로 합니다. CRUD는 Create, Read, Update, Delete의 약자 로 도메인 개체에서 수행할 수 있는 기본 작업입니다 .

이후 섹션에서 동일한 애플리케이션을 사용하여 이벤트 소싱 및 CQRS를 소개합니다.

이 과정에서 우리는 이 예에서 DDD(Domain-Driven Design)의 일부 개념을 활용할 것입니다 .

DDD는 복잡한 도메인 관련 지식에 의존하는 소프트웨어의 분석 및 설계를 다룹니다 . 이는 소프트웨어 시스템이 잘 개발된 도메인 모델을 기반으로 해야 한다는 아이디어를 기반으로 합니다. DDD는 Eric Evans에 의해 패턴 카탈로그로 처음 처방되었습니다. 이러한 패턴 중 일부를 사용하여 예제를 구축할 것입니다.

3.1. 애플리케이션 개요

사용자 프로필을 만들고 관리하는 것은 많은 응용 프로그램에서 일반적인 요구 사항입니다. 지속성과 함께 사용자 프로필을 캡처하는 간단한 도메인 모델을 정의합니다.

보시다시피 도메인 모델은 정규화되어 여러 CRUD 작업을 노출합니다. 이러한 작업은 단지 데모용이며 요구 사항에 따라 간단하거나 복잡할 수 있습니다 . 또한 여기의 지속성 저장소는 메모리에 있거나 대신 데이터베이스를 사용할 수 있습니다.

3.2. 애플리케이션 구현

먼저 도메인 모델을 나타내는 Java 클래스를 만들어야 합니다. 이것은 상당히 단순한 도메인 모델이며 Event Sourcing 및 CQRS와 같은 복잡한 디자인 패턴이 필요하지 않을 수도 있습니다 . 그러나 기본 사항을 이해하는 데 집중할 수 있도록 간단하게 유지하겠습니다.

public class User {
private String userid;
    private String firstName;
    private String lastName;
    private Set<Contact> contacts;
    private Set<Address> addresses;
    // getters and setters
}

public class Contact {
    private String type;
    private String detail;
    // getters and setters
}

public class Address {
    private String city;
    private String state;
    private String postcode;
    // getters and setters
}

또한 애플리케이션 상태의 지속성을 위해 간단한 메모리 내 저장소를 정의합니다. 물론 이것은 가치를 추가하지 않지만 나중에 시연하기에 충분합니다.

public class UserRepository {
    private Map<String, User> store = new HashMap<>();
}

이제 도메인 모델에서 일반적인 CRUD 작업을 노출하는 서비스를 정의합니다.

public class UserService {
    private UserRepository repository;
    public UserService(UserRepository repository) {
        this.repository = repository;
    }

    public void createUser(String userId, String firstName, String lastName) {
        User user = new User(userId, firstName, lastName);
        repository.addUser(userId, user);
    }

    public void updateUser(String userId, Set<Contact> contacts, Set<Address> addresses) {
        User user = repository.getUser(userId);
        user.setContacts(contacts);
        user.setAddresses(addresses);
        repository.addUser(userId, user);
    }

    public Set<Contact> getContactByType(String userId, String contactType) {
        User user = repository.getUser(userId);
        Set<Contact> contacts = user.getContacts();
        return contacts.stream()
          .filter(c -> c.getType().equals(contactType))
          .collect(Collectors.toSet());
    }

    public Set<Address> getAddressByRegion(String userId, String state) {
        User user = repository.getUser(userId);
        Set<Address> addresses = user.getAddresses();
        return addresses.stream()
          .filter(a -> a.getState().equals(state))
          .collect(Collectors.toSet());
    }
}

이것이 우리가 간단한 애플리케이션을 설정하기 위해 해야 할 일입니다. 이것은 프로덕션 준비가 된 코드와거리멀지만 이 사용방법(예제)의 뒷부분에서 숙고할 몇 가지 중요한 사항보여줍니다 .

3.3. 이 응용 프로그램의 문제

Event Sourcing 및 CQRS에 대한 논의를 더 진행하기 전에 현재 솔루션의 문제에 대해 논의하는 것이 좋습니다. 결국, 우리는 이러한 패턴을 적용하여 동일한 문제를 해결할 것입니다!

여기에서 알아차릴 수 있는 많은 문제 중에서 우리는 그 중 두 가지에 초점을 맞추고자 합니다.

  • 도메인 모델 : 읽기 및 쓰기 작업이 동일한 도메인 모델에서 발생합니다. 이와 같은 단순한 도메인 모델에서는 문제가 되지 않지만 도메인 모델이 복잡해지면 문제가 악화될 수 있습니다. 읽기 및 쓰기 작업의 개별 요구 사항에 맞게 도메인 모델과 기본 스토리지를 최적화해야 할 수도 있습니다.
  • 지속성 : 도메인 객체에 대한 지속성은 도메인 모델의 최신 상태만 저장합니다. 이것은 대부분의 상황에 충분하지만 일부 작업을 어렵게 만듭니다. 예를 들어, 도메인 개체가 어떻게 상태를 변경했는지에 대한 기록 감사를 수행해야 하는 경우 여기에서는 불가능합니다 . 이를 달성하기 위해 일부 감사 로그로 솔루션을 보완해야 합니다.

4. CQRS 소개

우리는 애플리케이션에 CQRS 패턴을 도입하여 지난 섹션에서 논의한 첫 번째 문제를 다루기 시작할 것입니다. 이것의 일부로 쓰기 및 읽기 작업을 처리하기 위해 도메인 모델과 지속성을 분리합니다 . CQRS 패턴이 애플리케이션을 재구성하는 방법을 살펴보겠습니다.

여기 다이어그램은 쓰기 및 읽기 측면에서 애플리케이션 아키텍처를 어떻게 깔끔하게 분리할 것인지 설명합니다. 그러나 여기에 더 잘 이해해야 하는 몇 가지 새로운 구성 요소가 도입되었습니다. 이들은 CQRS와 엄격하게 관련이 없지만 CQRS는 다음과 같은 이점을 제공합니다.

  • 집계/애그리게이터 :

집계는 엔터티를 집계 루트에 바인딩하여 서로 다른 엔터티를 논리적으로 그룹화하는 DDD(Domain-Driven Design)에 설명된 패턴 입니다. 집계 패턴은 엔터티 간의 트랜잭션 일관성을 제공합니다.

CQRS는 쓰기 도메인 모델을 그룹화하여 트랜잭션 보장을 제공하는 집계 패턴을 자연스럽게 활용합니다. 집계는 일반적으로 더 나은 성능을 위해 캐시된 상태를 유지하지만 없이도 완벽하게 작동할 수 있습니다.

  • 프로젝션/프로젝터 :

투영은 CQRS에 크게 도움이 되는 또 다른 중요한 패턴입니다. 투영은 본질적으로 다양한 모양과 구조의 도메인 개체를 나타내는 것을 의미합니다 .

원본 데이터의 이러한 예측은 읽기 전용이며 향상된 읽기 경험을 제공하도록 고도로 최적화되어 있습니다. 더 나은 성능을 위해 프로젝션을 캐시하기로 다시 결정할 수 있지만 반드시 필요한 것은 아닙니다.

4.1. 애플리케이션의 쓰기 측 구현

먼저 응용 프로그램의 쓰기 쪽을 구현해 보겠습니다.

필요한 명령을 정의하는 것으로 시작하겠습니다. 명령은 도메인 모델의 상태를 변이 할 의도이다 . 성공 여부는 우리가 구성하는 비즈니스 규칙에 따라 다릅니다.

우리의 명령을 보자:

public class CreateUserCommand {
    private String userId;
    private String firstName;
    private String lastName;
}

public class UpdateUserCommand {
    private String userId;
    private Set<Address> addresses;
    private Set<Contact> contacts;
}

이들은 우리가 변경하려는 데이터를 보유하는 매우 간단한 클래스입니다.

다음으로 명령을 받고 처리하는 집계를 정의합니다. 집계는 명령을 수락하거나 거부할 수 있습니다.

public class UserAggregate {
    private UserWriteRepository writeRepository;
    public UserAggregate(UserWriteRepository repository) {
        this.writeRepository = repository;
    }

    public User handleCreateUserCommand(CreateUserCommand command) {
        User user = new User(command.getUserId(), command.getFirstName(), command.getLastName());
        writeRepository.addUser(user.getUserid(), user);
        return user;
    }

    public User handleUpdateUserCommand(UpdateUserCommand command) {
        User user = writeRepository.getUser(command.getUserId());
        user.setAddresses(command.getAddresses());
        user.setContacts(command.getContacts());
        writeRepository.addUser(user.getUserid(), user);
        return user;
    }
}

집계는 리포지토리를 사용하여 현재 상태를 검색하고 변경 사항을 유지합니다. 또한 모든 명령을 처리하는 동안 저장소로의 왕복 비용을 피하기 위해 현재 상태를 로컬에 저장할 수 있습니다.

마지막으로 도메인 모델의 상태를 보관할 저장소가 필요합니다. 이것은 일반적으로 데이터베이스 또는 기타 영구 저장소이지만 여기서는 단순히 메모리 내 데이터 구조로 대체합니다.

public class UserWriteRepository {
    private Map<String, User> store = new HashMap<>();
    // accessors and mutators
}

이것으로 우리 애플리케이션의 쓰기 부분을 마칩니다.

4.2. 애플리케이션의 읽기 측 구현

이제 애플리케이션의 읽기 쪽으로 전환해 보겠습니다. 도메인 모델의 읽기 쪽을 정의하는 것으로 시작하겠습니다.

public class UserAddress {
    private Map<String, Set<Address>> addressByRegion = new HashMap<>();
}

public class UserContact {
    private Map<String, Set<Contact>> contactByType = new HashMap<>();
}

읽기 작업을 기억하면 이러한 클래스가 이를 처리하기 위해 완벽하게 잘 매핑되는 것을 보는 것이 어렵지 않습니다. 이것이 우리가 가진 쿼리를 중심으로 도메인 모델을 만드는 것의 아름다움입니다.

다음으로 읽기 저장소를 정의합니다. 다시 말하지만, 실제 애플리케이션에서는 이것이 더 내구성 있는 데이터 저장소가 될지라도 메모리 내 데이터 구조를 사용할 것입니다.

public class UserReadRepository {
    private Map<String, UserAddress> userAddress = new HashMap<>();
    private Map<String, UserContact> userContact = new HashMap<>();
    // accessors and mutators
}

이제 지원해야 하는 필수 쿼리를 정의합니다. 쿼리는 데이터를 가져오기 위한 의도입니다. 반드시 데이터가 생성되는 것은 아닙니다.

쿼리를 살펴보겠습니다.

public class ContactByTypeQuery {
    private String userId;
    private String contactType;
}

public class AddressByRegionQuery {
    private String userId;
    private String state;
}

다시 말하지만, 이들은 쿼리를 정의하기 위한 데이터를 보유하는 간단한 Java 클래스입니다.

지금 필요한 것은 다음 쿼리를 처리할 수 있는 프로젝션입니다.

public class UserProjection {
    private UserReadRepository readRepository;
    public UserProjection(UserReadRepository readRepository) {
        this.readRepository = readRepository;
    }

    public Set<Contact> handle(ContactByTypeQuery query) {
        UserContact userContact = readRepository.getUserContact(query.getUserId());
        return userContact.getContactByType()
          .get(query.getContactType());
    }

    public Set<Address> handle(AddressByRegionQuery query) {
        UserAddress userAddress = readRepository.getUserAddress(query.getUserId());
        return userAddress.getAddressByRegion()
          .get(query.getState());
    }
}

여기에서 프로젝션은 이전에 정의한 읽기 저장소를 사용하여 쿼리를 처리합니다. 이것으로 우리 애플리케이션의 읽기 쪽도 거의 끝납니다.

4.3. 읽기 및 쓰기 데이터 동기화

이 퍼즐의 한 조각은 아직 해결되지 않았습니다. 쓰기 및 읽기 저장소동기화할 것이 없습니다 .

여기에서 프로젝터로 알려진 것이 필요합니다. 프로젝터는 읽기 도메인 모델로 쓰기 도메인 모델을 투사 할 수있는 논리가 .

이를 처리하는 훨씬 더 정교한 방법이 있지만 비교적 간단하게 유지하겠습니다.

public class UserProjector {
    UserReadRepository readRepository = new UserReadRepository();
    public UserProjector(UserReadRepository readRepository) {
        this.readRepository = readRepository;
    }

    public void project(User user) {
        UserContact userContact = Optional.ofNullable(
          readRepository.getUserContact(user.getUserid()))
            .orElse(new UserContact());
        Map<String, Set<Contact>> contactByType = new HashMap<>();
        for (Contact contact : user.getContacts()) {
            Set<Contact> contacts = Optional.ofNullable(
              contactByType.get(contact.getType()))
                .orElse(new HashSet<>());
            contacts.add(contact);
            contactByType.put(contact.getType(), contacts);
        }
        userContact.setContactByType(contactByType);
        readRepository.addUserContact(user.getUserid(), userContact);

        UserAddress userAddress = Optional.ofNullable(
          readRepository.getUserAddress(user.getUserid()))
            .orElse(new UserAddress());
        Map<String, Set<Address>> addressByRegion = new HashMap<>();
        for (Address address : user.getAddresses()) {
            Set<Address> addresses = Optional.ofNullable(
              addressByRegion.get(address.getState()))
                .orElse(new HashSet<>());
            addresses.add(address);
            addressByRegion.put(address.getState(), addresses);
        }
        userAddress.setAddressByRegion(addressByRegion);
        readRepository.addUserAddress(user.getUserid(), userAddress);
    }
}

이것은 매우 조잡한 방법이지만 CQRS가 작동하는 데 필요한 것에 대한 충분한 통찰력을 제공 합니다. 또한 읽기 및 쓰기 저장소를 서로 다른 물리적 저장소에 둘 필요가 없습니다. 분산 시스템에는 고유한 문제 몫이 있습니다!

그것의주의하시기 바랍니다 다른 읽기 도메인 모델로 쓰기 영역의 현재 상태를 프로젝트에 편리하지 . 여기에서 취한 예는 매우 간단하므로 문제가 표시되지 않습니다.

그러나 쓰기 및 읽기 모델이 더 복잡해짐에 따라 투영하기가 점점 더 어려워질 것입니다. Event Sourcing을 사용하는 상태 기반 프로젝션 대신 이벤트 기반 프로젝션을 통해 이 문제를 해결할 수 있습니다 . 이 예제의 뒷부분에서 이를 달성하는 방법을 살펴보겠습니다.

4.4. CQRS의 장점과 단점

우리는 CQRS 패턴에 대해 논의하고 일반적인 애플리케이션에서 이를 도입하는 방법을 배웠습니다. 우리는 읽기와 쓰기를 모두 처리할 때 도메인 모델의 경직성과 관련된 문제를 해결하기 위해 범주적으로 노력했습니다.

이제 CQRS가 애플리케이션 아키텍처에 제공하는 몇 가지 다른 이점에 대해 논의해 보겠습니다.

  • CQRS는 쓰기 및 읽기 작업에 적합한 별도의 도메인 모델을 선택할 수 있는 편리한 방법을 제공 합니다. 두 가지를 모두 지원하는 복잡한 도메인 모델을 만들 필요가 없습니다.
  • 쓰기를 위한 높은 처리량 및 읽기를 위한 짧은 대기 시간과 같은 읽기 및 쓰기 작업의 복잡성을 처리하는 데 개별적으로 적합한 리포지토리선택 하는 데 도움이 됩니다.
  • 관심사의 분리와 더 간단한 도메인 모델을 제공하여 분산 아키텍처에서 이벤트 기반 프로그래밍 모델자연스럽게 보완 합니다.

그러나 이것은 무료로 제공되지 않습니다. 이 간단한 예에서 알 수 있듯이 CQRS는 아키텍처에 상당한 복잡성을 추가합니다. 많은 시나리오에서 적합하지 않거나 고통을 겪을 가치가 없을 수 있습니다.

  • 복잡한 도메인 모델이 이 패턴의 추가된 복잡성으로부터 이점얻을 수 있습니다 . 이 모든 것 없이 간단한 도메인 모델을 관리할 수 있습니다.
  • 자연스럽게 어느 정도 코드 복제이어지며 , 이는 우리가 얻는 이득에 비해 수용 가능한 악입니다. 그러나 개인의 판단이 권장됩니다
  • 별도의 리포지토리 는 일관성 문제로 이어 지며 쓰기 및 읽기 리포지토리를 항상 완벽한 동기화 상태로 유지하기 어렵습니다. 우리는 종종 최종 일관성에 만족해야 합니다.

5. 이벤트 소싱 소개

다음으로 간단한 응용 프로그램에서 논의한 두 번째 문제를 다룰 것입니다. 우리가 기억한다면 그것은 우리의 지속성 저장소와 관련이 있습니다.

이 문제를 해결하기 위해 이벤트 소싱을 소개합니다. 이벤트 소싱 은 애플리케이션 상태 저장에 대한 우리의 생각을 극적으로 변화시킵니다 .

저장소가 어떻게 변경되는지 봅시다.

여기에서 도메인 이벤트의 정렬된 List을 저장하도록 저장소를 구성 했습니다 . 도메인 개체에 대한 모든 변경은 이벤트로 간주됩니다. 이벤트가 얼마나 거칠거나 세분화되어야 하는지는 도메인 디자인의 문제입니다. 여기서 고려해야 할 중요한 사항은 이벤트에 시간적 순서가 있고 변경할 수 없다는 것 입니다.

5.1. 이벤트 및 이벤트 저장소 구현

이벤트 기반 애플리케이션의 기본 개체는 이벤트이며 이벤트 소싱도 다르지 않습니다. 앞에서 본 것처럼 이벤트는 특정 시점에서 도메인 모델 상태의 특정 변경을 나타냅니다 . 따라서 간단한 애플리케이션에 대한 기본 이벤트를 정의하는 것으로 시작하겠습니다.

public abstract class Event {
    public final UUID id = UUID.randomUUID();
    public final Date created = new Date();
}

이렇게 하면 애플리케이션에서 생성하는 모든 이벤트가 고유한 ID와 생성 타임스탬프를 갖게 됩니다. 이것들은 그것들을 더 처리하는 데 필요합니다.

물론 이벤트의 출처를 설정하는 속성과 같이 관심을 가질 수 있는 다른 속성이 여러 개 있을 수 있습니다.

다음으로 이 기본 이벤트에서 상속되는 일부 도메인별 이벤트를 생성해 보겠습니다.

public class UserCreatedEvent extends Event {
    private String userId;
    private String firstName;
    private String lastName;
}

public class UserContactAddedEvent extends Event {
    private String contactType;
    private String contactDetails;
}

public class UserContactRemovedEvent extends Event {
    private String contactType;
    private String contactDetails;
}

public class UserAddressAddedEvent extends Event {
    private String city;
    private String state;
    private String postCode;
}

public class UserAddressRemovedEvent extends Event {
    private String city;
    private String state;
    private String postCode;
}

이들은 도메인 이벤트의 세부 사항을 포함하는 Java의 간단한 POJO입니다. 그러나 여기서 주목해야 할 중요한 것은 이벤트의 세분성입니다.

사용자 업데이트를 위한 단일 이벤트를 만들 수도 있었지만 대신 주소 및 연락처 추가 및 제거를 위한 별도의 이벤트를 만들기로 결정했습니다. 선택은 도메인 모델 작업을 보다 효율적으로 만드는 항목에 매핑됩니다.

이제 자연스럽게 도메인 이벤트를 보관할 저장소가 필요합니다.

public class EventStore {
    private Map<String, List<Event>> store = new HashMap<>();
}

이것은 도메인 이벤트를 보관하기 위한 간단한 메모리 내 데이터 구조입니다. 실제로 Apache Druid와 같은 이벤트 데이터를 처리하기 위해 특별히 만들어진 여러 솔루션이 있습니다 . KafkaCassandra를 포함하여 이벤트 소싱을 처리할 수 있는 범용 분산 데이터 저장소가 많이 있습니다 .

5.2. 이벤트 생성 및 사용

이제 모든 CRUD 작업을 처리하는 서비스가 변경됩니다. 이제 이동하는 도메인 상태를 업데이트하는 대신 도메인 이벤트를 추가합니다. 또한 동일한 도메인 이벤트를 사용하여 쿼리에 응답합니다.

이를 달성하는 방법을 살펴보겠습니다.

public class UserService {
    private EventStore repository;
    public UserService(EventStore repository) {
        this.repository = repository;
    }

    public void createUser(String userId, String firstName, String lastName) {
        repository.addEvent(userId, new UserCreatedEvent(userId, firstName, lastName));
    }

    public void updateUser(String userId, Set<Contact> contacts, Set<Address> addresses) {
        User user = UserUtility.recreateUserState(repository, userId);
        user.getContacts().stream()
          .filter(c -> !contacts.contains(c))
          .forEach(c -> repository.addEvent(
            userId, new UserContactRemovedEvent(c.getType(), c.getDetail())));
        contacts.stream()
          .filter(c -> !user.getContacts().contains(c))
          .forEach(c -> repository.addEvent(
            userId, new UserContactAddedEvent(c.getType(), c.getDetail())));
        user.getAddresses().stream()
          .filter(a -> !addresses.contains(a))
          .forEach(a -> repository.addEvent(
            userId, new UserAddressRemovedEvent(a.getCity(), a.getState(), a.getPostcode())));
        addresses.stream()
          .filter(a -> !user.getAddresses().contains(a))
          .forEach(a -> repository.addEvent(
            userId, new UserAddressAddedEvent(a.getCity(), a.getState(), a.getPostcode())));
    }

    public Set<Contact> getContactByType(String userId, String contactType) {
        User user = UserUtility.recreateUserState(repository, userId);
        return user.getContacts().stream()
          .filter(c -> c.getType().equals(contactType))
          .collect(Collectors.toSet());
    }

    public Set<Address> getAddressByRegion(String userId, String state) throws Exception {
        User user = UserUtility.recreateUserState(repository, userId);
        return user.getAddresses().stream()
          .filter(a -> a.getState().equals(state))
          .collect(Collectors.toSet());
    }
}

여기에서 사용자 업데이트 작업을 처리하는 과정에서 여러 이벤트를 생성하고 있습니다. 또한 지금까지 생성된 모든 도메인 이벤트를 재생하여 도메인 모델의 현재 상태를 생성하는 방법에 주목하는 것도 흥미로웠습니다 .

물론 실제 응용 프로그램에서는 이것이 가능한 전략이 아니며 매번 상태를 생성하지 않도록 로컬 캐시를 유지해야 합니다. 프로세스 속도를 높일 수 있는 이벤트 저장소의 스냅샷 및 롤업과 같은 다른 전략이 있습니다.

이것으로 간단한 애플리케이션에 이벤트 소싱을 도입하려는 노력을 마칩니다.

5.3. 이벤트 소싱의 장점과 단점

이제 이벤트 소싱을 사용하여 도메인 개체를 저장하는 대체 방법을 성공적으로 채택했습니다. 이벤트 소싱은 강력한 패턴이며 적절하게 사용되는 경우 애플리케이션 아키텍처에 많은 이점을 제공합니다.

  • 차종은 훨씬 더 빨리 쓰기 작업을 필요하지 읽기, 업데이트 및 쓰기가 없기 때문에; write는 단순히 이벤트를 로그에 추가하는 것입니다.
  • 개체 관계형 임피던스를 제거 하므로 복잡한 매핑 도구가 필요하지 않습니다. 물론 객체를 다시 생성해야 합니다.
  • 완전히 신뢰할 수 있는 부산물로 감사 로그제공합니다 . 도메인 모델의 상태가 어떻게 변경되었는지 정확하게 디버그할 수 있습니다.
  • 시간적 쿼리지원하고 시간 여행 (과거 시점의 도메인 상태)을 달성할 수 있습니다 !
  • 메시지를 교환하여 비동기적으로 통신하는 마이크로 서비스 아키텍처에서 느슨하게 결합된 구성 요소를 설계 하는 데 자연스럽게 적합 합니다.

그러나 항상 그렇듯이 이벤트 소싱도 만병통치약이 아닙니다. 그것은 우리가 데이터를 저장하는 데 있어 극적으로 다른 방식을 채택하도록 강요합니다. 다음과 같은 경우에는 유용하지 않을 수 있습니다.

  • 거기에 관련된 학습 곡선과 사고 방식의 변화가 필요합니다 이벤트 소싱을 채택; 처음부터 직관적이지 않습니다.
  • 그것은 만드는 오히려 어려운 전형적인 쿼리를 처리하기 위해 우리는 우리가 로컬 캐시의 상태를 유지하지 않는 상태를 다시 만들어야으로
  • 모든 도메인 모델에 적용할 수 있지만 이벤트 기반 아키텍처에서 이벤트 기반 모델더 적합합니다.

6. 이벤트 소싱이 있는 CQRS

이제 간단한 응용 프로그램에 Event Sourcing 및 CQRS를 개별적으로 도입하는 방법을 살펴보았으므로 이제 함께 가져올 때입니다. 그것은해야 이러한 패턴이 크게 서로 혜택을 누릴 수 있다는 것을 지금 상당히 직관적 . 그러나 우리는 이 섹션에서 더 명확하게 만들 것입니다.

먼저 애플리케이션 아키텍처가 이들을 어떻게 결합하는지 봅시다.

이것은 지금까지 놀라운 일이 아닙니다. 리포지토리의 쓰기 쪽을 이벤트 저장소로 교체했지만 리포지토리의 읽기 쪽은 계속 동일합니다.

애플리케이션 아키텍처에서 이벤트 소싱 및 CQRS를 사용하는 유일한 방법은 아닙니다. 우리 는 매우 혁신적이며 이러한 패턴을 다른 패턴함께 사용 하고 여러 아키텍처 옵션을 제시할 수 있습니다.

여기서 중요한 것은 단순히 복잡성을 더 늘리는 것이 아니라 복잡성을 관리하는 데 사용하도록 하는 것입니다!

6.1. CQRS와 이벤트 소싱 통합

Event Sourcing과 CQRS를 개별적으로 구현했기 때문에 이들을 함께 가져올 수 있는 방법을 이해하는 것은 그리 어렵지 않을 것입니다.

우리는거야 우리가 CQRS을 도입 응용 프로그램으로 시작 관련 변경 사항을 바로 겹으로 이벤트 소싱을 가지고. 또한 이벤트 소싱을 도입한 애플리케이션에서 정의한 것과 동일한 이벤트 및 이벤트 저장소를 활용할 것입니다.

몇 가지 변경 사항이 있습니다. state를 업데이트하는 대신 이벤트생성 하도록 집계를 변경하는 것으로 시작하겠습니다 .

public class UserAggregate {
    private EventStore writeRepository;
    public UserAggregate(EventStore repository) {
        this.writeRepository = repository;
    }

    public List<Event> handleCreateUserCommand(CreateUserCommand command) {
        UserCreatedEvent event = new UserCreatedEvent(command.getUserId(), 
          command.getFirstName(), command.getLastName());
        writeRepository.addEvent(command.getUserId(), event);
        return Arrays.asList(event);
    }

    public List<Event> handleUpdateUserCommand(UpdateUserCommand command) {
        User user = UserUtility.recreateUserState(writeRepository, command.getUserId());
        List<Event> events = new ArrayList<>();

        List<Contact> contactsToRemove = user.getContacts().stream()
          .filter(c -> !command.getContacts().contains(c))
          .collect(Collectors.toList());
        for (Contact contact : contactsToRemove) {
            UserContactRemovedEvent contactRemovedEvent = new UserContactRemovedEvent(contact.getType(), 
              contact.getDetail());
            events.add(contactRemovedEvent);
            writeRepository.addEvent(command.getUserId(), contactRemovedEvent);
        }
        List<Contact> contactsToAdd = command.getContacts().stream()
          .filter(c -> !user.getContacts().contains(c))
          .collect(Collectors.toList());
        for (Contact contact : contactsToAdd) {
            UserContactAddedEvent contactAddedEvent = new UserContactAddedEvent(contact.getType(), 
              contact.getDetail());
            events.add(contactAddedEvent);
            writeRepository.addEvent(command.getUserId(), contactAddedEvent);
        }

        // similarly process addressesToRemove
        // similarly process addressesToAdd

        return events;
    }
}

필요한 유일한 다른 변경 사항은 프로젝터에 있으며 이제 도메인 개체 상태 대신 이벤트처리 해야 합니다 .

public class UserProjector {
    UserReadRepository readRepository = new UserReadRepository();
    public UserProjector(UserReadRepository readRepository) {
        this.readRepository = readRepository;
    }

    public void project(String userId, List<Event> events) {
        for (Event event : events) {
            if (event instanceof UserAddressAddedEvent)
                apply(userId, (UserAddressAddedEvent) event);
            if (event instanceof UserAddressRemovedEvent)
                apply(userId, (UserAddressRemovedEvent) event);
            if (event instanceof UserContactAddedEvent)
                apply(userId, (UserContactAddedEvent) event);
            if (event instanceof UserContactRemovedEvent)
                apply(userId, (UserContactRemovedEvent) event);
        }
    }

    public void apply(String userId, UserAddressAddedEvent event) {
        Address address = new Address(
          event.getCity(), event.getState(), event.getPostCode());
        UserAddress userAddress = Optional.ofNullable(
          readRepository.getUserAddress(userId))
            .orElse(new UserAddress());
        Set<Address> addresses = Optional.ofNullable(userAddress.getAddressByRegion()
          .get(address.getState()))
          .orElse(new HashSet<>());
        addresses.add(address);
        userAddress.getAddressByRegion()
          .put(address.getState(), addresses);
        readRepository.addUserAddress(userId, userAddress);
    }

    public void apply(String userId, UserAddressRemovedEvent event) {
        Address address = new Address(
          event.getCity(), event.getState(), event.getPostCode());
        UserAddress userAddress = readRepository.getUserAddress(userId);
        if (userAddress != null) {
            Set<Address> addresses = userAddress.getAddressByRegion()
              .get(address.getState());
            if (addresses != null)
                addresses.remove(address);
            readRepository.addUserAddress(userId, userAddress);
        }
    }

    public void apply(String userId, UserContactAddedEvent event) {
        // Similarly handle UserContactAddedEvent event
    }

    public void apply(String userId, UserContactRemovedEvent event) {
        // Similarly handle UserContactRemovedEvent event
    }
}

상태 기반 프로젝션을 처리하는 동안 논의한 문제를 상기하면 이에 대한 잠재적인 솔루션입니다.

이벤트 기반 투사 오히려 편리하고 구현하기가 쉽다 . 발생하는 모든 도메인 이벤트를 처리하고 모든 읽기 도메인 모델에 적용하기만 하면 됩니다. 일반적으로 이벤트 기반 응용 프로그램에서 프로젝터는 관심 있는 도메인 이벤트를 수신하고 직접 호출하는 사람에 의존하지 않습니다.

이것은 우리의 간단한 애플리케이션에서 Event Sourcing과 CQRS를 함께 사용하기 위해 해야 할 거의 전부입니다.

7. 결론

이 사용방법(예제)에서는 이벤트 소싱 및 CQRS 디자인 패턴의 기본 사항에 대해 논의했습니다. 우리는 간단한 애플리케이션을 개발하고 이러한 패턴을 개별적으로 적용했습니다.

그 과정에서 우리는 그것들이 가져오는 장점과 그들이 제시하는 단점을 이해했습니다. 마지막으로 이 두 패턴을 애플리케이션에 통합하는 이유와 방법을 이해했습니다.

이 사용방법(예제)에서 논의한 간단한 응용 프로그램은 CQRS 및 이벤트 소싱의 필요성을 정당화하는 데에도 근접하지 않습니다. 우리의 초점은 기본 개념을 이해하는 것이었으므로 예제는 간단했습니다. 그러나 앞서 언급했듯이 이러한 패턴의 이점은 상당히 복잡한 도메인 모델이 있는 응용 프로그램에서만 실현할 수 있습니다.

평소와 같이 이 기사의 소스 코드는 GitHub 에서 찾을 수 있습니다 .

Junit footer banner