본문 바로가기
프로그래밍/기타

도메인 주도 설계 첫걸음

by 동네로봇 2025. 5. 1.

위키북스, 블라드 코노노프 지음

 

도메인 주도 설계 (DDD: Domain-Driven Design) : 비즈니스 도메인을 깊이 이해하고 이를 바탕으로 소프트웨어를 설계하는 것을 목표로 하는 소프트웨어 개발 방법론


  • 목차
  •  
  • Part1. 전략적 설계
    • 기업의 비즈니스 전략 분석
      • 하위 도메인
    • 유비쿼터스 언어
    • 바운디드 컨텍스트
    • 바운디드 컨텍스트 연동
      • 협력형 패턴 그룹
      • 사용자-제공자 패턴 그룹
      • 분리형 노선
  • Part2. 전술적 설계
    • 트랜잭션 스크립트
    • 액티브 레코드
    • 도메인 모델
      • 밸류 오브젝트
      • 엔티티
      • 에그리게이트
      • 시간 차원의 모델링
        • 이벤트 소싱
      • 아키텍처 패턴
    • 커뮤니케이션 패턴
      • 에그리게이트 연동
        • 아웃박스
        • 사가
        • 프로세스 관리자

 

Part1. 전략적 설계

기업의 비즈니스 전략 분석

하위 도메인

비즈니스 도메인은 기업의 주요 활동 영역을 정의한다. 일반적으로 회사가 고객에게 제공하는 서비스를 말한다.

비즈니스 도메인의 목표를 달성하기 위해 기업은 여러 가지 하위 도메인(subdomain)을 운영해야 한다. 하위 도메인은 비즈니스 활동의 세분화된 영역이다.

  • 핵심 하위 도메인
    • 회사가 다른 경쟁업체와 다르게 수행하고 있는 것
    • 구현하기 쉬운 핵심 하위 도메인은 일시적인 경쟁 우위만을 제공할 수 있다. 따라서 핵심 하위 도메인은 자연스럽게 복잡해진다.
    • 예) 우버 - 승차 공유 서비스
  • 일반 하위 도메인
    • 모든 회사가 같은 방식으로 수행하는 비즈니스 활동을 말한다.
    • 이미 실무에서 검증된 솔루션으로 널리 이용 가능하며, 모든 회사에서 사용하고 있어서 더 이상 혁신이나 최적화가 필요 없다.
    • 예) 사용자 인증 시스템
  • 지원 하위 도메인
    • 회사의 비즈니스를 지원하는 활동을 말한다.
    • 회사에 어떠한 경쟁 우위도 제공하지 않는다.
    • 예) 회사 배너

유비쿼터스 언어

“운영환경에 배포되는 것은 도메인 전문가의 지식이 아니라 개발자의 이해 혹은 오해다.”

효과적인 커뮤니케이션과 지식 공유를 위한 도메인 주도 설계 도구인 유비쿼터스 언어를 사용하고, 기술 용어는 빼고 비즈니스 도메인에 관련된 용어로만 구성해야 한다.

바운디드 컨텍스트

바운디드 컨텍스트란 도메인 모델이 명확한 의미와 경계를 가지며 독립적으로 일관성을 유지하는 영역이다.

유비쿼터스 언어의 범위(바운디드 컨텍스트)를 정의하는 것은 전략적인 설계 의사결정이다. 경계는 비즈니스 도메인의 고유한 컨텍스트에 따라 넓힐 수 있고, 비즈니스 도메인을 더 작은 문제 도메인으로 세분화하여 좁힐 수도 있다.

바운디드 컨텍스트는 모델 경계뿐만 아니라 이를 구현하는 시스템의 물리적 경계 역할도 한다. 각 바운디드 컨텍스트는 개별 서비스/프로젝트로 구현돼야 한다.

 

바운디드 컨텍스트 연동

바운디드 컨텍스트는 서로 독립적으로 발전할 수 있지만 상호작용해야 한다. 결국, 바운디드 컨텍스트 사이에는 항상 접점이 있는데 이것을 컨트랙트(contract)라고 한다.

협력형 패턴 그룹

  • 파트너십 패턴

  • 공유 커널 패턴

사용자-제공자 패턴 그룹

  • 순응주의자 패턴

  • 충돌 방지 계층 패턴

  • 오픈 호스트 서비스 패턴

 

Part2. 전술적 설계

트랜잭션 스크립트

프로시저를 기반으로 시스템의 비즈니스 로직을 구성하며, 각 프로시저는 퍼블릭 인터페이스를 통해 시스템 사용자가 실행하는 작업을 구현한다.

각 프로시저는 간단하고 쉬운 절차지향 스크립트(procedural script)로 구현한다. 이 프로시저가 구현해야 하는 유일한 요구사항은 트랜잭션 동작이다. 트랜잭션 스크립트 실행이 실패하더라도 시스템은 오류가 발생할 때까지 변경사항을 롤백하거나 보상 조치를 실행하여 일관성을 유지해야 한다.

DB.StartTransaction();

var job = DB.LoadNextJob();
var json = LoadFile(job.Source);
var xml = ConvertJsonToXml(json);
WriteFile(job.Destination, xml.ToString();
DB.MarkJobAsCompleted(job);

DB.Commit()

 

트랜잭션 스크립트 패턴을 올바르게 구현하지 못하면 의도와 다르게 시스템 상태를 손상시킨다.

트랜잭션 동작을 보장하는 한 가지 방법은 작업을 멱등성으로 만드는 것이다. (시스템에서 카운터 값을 받아서 업데이트 함)

public class LogVisit
{
    // ...
    
    public void Execute(Guid userId, long visits)
    {
        _db.Execute(“UPDATE Users SET visits = @p1 WHERE user_id=@p2”,
                   visits, userId);
    }
}

 

 

또 다른 방법은 낙관적 동시성 제어(optimistic concurrency control)를 사용하는 것이다.

public class LogVisit
{
    // ...

    public void Execute(Guid userId, long expectedVisits)
    {
        _db.Execute(@“UPDATE Users SET visits=visits+1
                      WHERE user_id=@p1 and visits = @p2”,
                      userId, visits);
    }
}

 

트랜잭션 스크립트 패턴은 정의상 비즈니스 로직이 단순한 지원 하위 도메인에 적합하다.

 

액티브 레코드

액티브 레코드도 비즈니스 로직이 단순한 경우 사용한다. 그러나 액티브 레코드는 좀 더 복잡한 자료구조에서도 비즈니스 로직이 작동할 수 있다.

이 패턴은 액티브 레코드라고 하는 전용 객체를 사용하여 복잡한 자료구조를 표현한다. 자료구조 외에도 이러한 객체는 레코드 생성, 읽기, 업데이트, 삭제를 위한 데이터 접근 방법(소위 CRUD 작업)도 구현한다. 그 결과 액티브 레코드 객체는 객체 관계 매핑(ORM) 또는 다른 데이터 접근 프레임워크와도 관련이 있다.

액티브 레코드는 트랜잭션 스크립트로 시스템의 비즈니스 로직을 만든다. 두 패턴의 차이점은 액티브 레코드의 경우 데이터베이스에 직접 접근하는 대신 트랜잭션 스크립트가 액티브 레코드 객체를 조작한다는 것이다. 작업이 완료되면 트랜잭션의 원자성(atomic)으로 인해 작업이 성공하거나 실패한다.

 

public class CreateUser
{
    // ...

    public void Execute(userDetails)
    {
        try
        {
            _db.StartTransaction();

            var user = new User();
            user.Name = userDetails.Name;
            user.Email = userDetails.Email;
            user.Save();

            _db.Commit();
        } catch {
            _db.Rollback();
            throw;
        }
    }
}

이 패턴의 목적은 메모리 상의 객체를 데이터베이스 스키마에 매핑하는 복잡성을 숨기는 것이다. 영속성을 담당하는 것 외에도 액티브 레코드 객체에는 비즈니스 로직이 포함될 수 있다.

 

도메인 모델

도메인 모델 패턴은 복잡한 비즈니스 로직을 다루기 위한 것이다. CRUD 인터페이스 대신 복잡한 상태 전환, 항상 보호해야 하는 규칙인 비즈니스 규칙과 불변성을 다룬다. 도메인 모델은 행동과 데이터 모두를 포함하는 도메인의 객체 모델이다.

도메인 비즈니스 로직은 이미 본질적으로 복잡하므로 모델링에 사용되는 객체가 모델에 조금이라도 우발적 복잡성을 추가하면 안 된다. 모델에는 데이터베이스 또는 외부 시스템 구성요소의 호출 구현 같은 인프라 또는 기술적인 관심사를 피해야 한다.

밸류 오브젝트

언어의 표준 라이브러리에 포함된 String, Integer, Dictionary 같은 원시 데이터 타입에 전적으로 의존해서 비즈니스 도메인의 개념을 표현하는 것은 원시 집착 코드 스멜(primitive obsession code smell)로 알려져 있다.

밸류 오브젝트는 불변의 객체로 구현되므로 밸류 오브젝트에 있는 필드가 하나라도 바뀌면 다른 값이 생성된다. 다시 말해, 밸류 오브젝트의 필드 중 하나가 바뀌면 개념적으로 밸류 오브젝트의 다른 인스턴스가 생성된다.

 

public class Color
{
    public readonly byte Red;
    public readonly byte Green;
    public readonly byte Blue;

    public Color(byte r, byte g, byte b)
    {
        this.Red = r;
        this.Green = g;
        this.Blue = b;
    }

    public Color MixWith(Color other)
    {
        return new Color(
            r: (byte) Math.Min(this.Red + other.Red, 255),
            g: (byte) Math.Min(this.Green + other.Green, 255),
            b: (byte) Math.Min(this.Blue + other.Blue, 255)
        );
    }

    public override bool Equals(object obj)
    {
        var other = obj as Color;
        return other != null &&
            this.Red == other.Red &&
            this.Green == other.Green &&
            this.Blue == other.Blue;
    }

    public static bool operator == (Color lhs, Color rhs)
    {
        if (Object.ReferenceEquals(lhs, null)) {
            return Object.ReferenceEquals(rhs, null);
        }
        return lhs.Equals(rhs);
    }

    public static bool operator != (Color lhs, Color rhs)
    {
        return !(lhs == rhs);
    }

    public override int GetHashCode()
    {
        return ToString().GetHashCode();
    }

    // ...
}

 

  • primitive obsession code smell
class Person
{
    private int    _id;
    private string _firstName;
    private string _lastName;
    private string _landlinePhone;
    private string _mobilePhone;
    private string _email;
    private int    _heightMetric;
    private string _countryCode;

    public Person(...) {...}
}

static void Main(string[] args)
{
    var dave = new Person(
        id: 30217,
        firstName: "Dave",
        lastName: "Ancelovici",
        landlinePhone: "023745001",
        mobilePhone: "0873712503",
        email: "dave@learning-ddd.com",
        heightMetric: 180,
        countryCode: "BG");
}

 

  • 밸류 오브젝트 적용

명료성이 향상됐음을 볼 수 있다. 예를 들어, country 변수는 완전한 국가 이름 대신 국가 코드를 저장한다는 의도를 전달하기 위해 countryCode처럼 상세한 변수명을 쓸 필요가 없다. 이처럼 밸류 오브젝트를 사용하면 짧은 변수 이름을 사용하더라도 의도를 명확하게 전달한다.

또한, 유효성 검사 로직이 밸류 오브젝트 자체에 들어 있기 때문에 값을 할당하기 전에 유효성 검사를 할 필요가 없다. 게다가 밸류 오브젝트는 값을 조작하는 비즈니스 로직을 한곳에 모을 때 더욱 진가를 발휘한다.

class Person
{
    private PersonId     _id;
    private Name         _name;
    private PhoneNumber  _landline;
    private PhoneNumber  _mobile;
    private EmailAddress _email;
    private Height       _height;
    private CountryCode  _country;

    public Person(...) { ... }
}

static void Main(string[] args)
{
    var dave = new Person(
        id:       new PersonId(30217),
        name:     new Name("Dave", "Ancelovici"),
        landline: PhoneNumber.Parse("023745001"),
        mobile:   PhoneNumber.Parse("0873712503"),
        email:    Email.Parse("dave@learning-ddd.com"),
        height:   Height.FromMetric(180),
        country:  CountryCode.Parse("BG"));
}

 

 

엔티티

엔티티는 밸류 오브젝트와 정반대로 다른 엔티티 인스턴스와 구별하기 위해 명시적인 식별 필드가 필요하다. 식별 필드의 핵심 요구사항은 각 엔티티의 인스턴스마다 고유해야 한다는 것이다.

밸류 오브젝트와는 반대로 엔티티는 불변이 아니고 변할 것으로 예상된다. 엔티티와 밸류 오브젝트의 또 다른 차이점은 밸류 오브젝트는 엔티티의 속성을 설명한다는 것이다.

class Person
{
    public readonly PersonId Id;
    public Name Name { get; set; }
    
    public Person(PersonId id, Name name)
    {
        this.Id = id;
        this.Name = name;
    }
}

 

필자는 도메인 모델의 구성요소로 ‘엔티티’를 포함하지는 않았다. ‘엔티티’가 누락된 이유는 엔티티를 단독으로 구현하지 않고 에그리게이트 패턴의 컨텍스트에서만 엔티티를 구현하기 때문이다.

 

에그리게이트

에그리게이트는 엔티티다. 하지만 에그리게이트는 단순한 엔티티가 아닌 그 이상이다. 이 패턴의 목적은 데이터의 일관성을 보호하는 데 있다.

 

일관성 강화

에그리게이트의 상태는 변형될 수 있으므로 데이터가 손상될 수 있는 여러 경로가 있다. 데이터의 일관성을 강화하려면 에그리게이트 패턴에서는 에그리게이트 주변에 명확한 경계를 설정해야 한다.

에그리게이트의 퍼블릭 인터페이스로 노출된 상태 변경 메서드는 ‘어떤 것을 지시하는 명령’을 뜻하는 의미에서 커맨드라고도 부른다.

public class Ticket
{
    // ...
 
    public void AddMessage(UserId from, string subject, string body)
    {
        var message = new Message(from, body);
        _messages.Append(message);
    }
 
    // ...
}

 

public class Ticket
{
    // ...
 
    public void Execute(AddMessage cmd)
    {
        var message = new Message(cmd.from, cmd.body);
        _messages.Append(message);
    }

    // ...
}

 

에그리게이트를 저장하는 데이터베이스에서 동시성 관리를 지원해야 한다. 가장 간단한 형태는 매번 갱신할 때마다 증가하는 버전 필드를 에그리게이트에서 관리하는 것이다.

class Ticket
{
    TicketId _id;
    int      _version;

    // ...
}

UPDATE tickets
SET ticket_status = @new_status,
agg_version = agg_version + 1,
WHERE ticket_id=@id and agg_version=@expected_version;

 

트랜잭션 경계

에그리게이트의 상태는 자신의 비즈니스 로직을 통해서만 수정될 수 있기 때문에 에그리게이트가 트랜잭션 경계의 역할을 한다. 모든 에그리게이트 상태 변경은 원자적인 단일 오퍼레이션으로 트랜잭션 처리돼야 한다. 트랜잭션 별로 하나의 에그리게이트 인스턴스만 갖게 제한하면 에그리게이트의 경계가 비즈니스 도메인의 불변성과 규칙을 따르도록 신중히 설계하게 된다.

동일한 트랜잭션에서 여러 객체를 수정하는 패턴에 대해 살펴본다.

엔티티는 독립적 패턴이 아닌 에그리게이트의 일부로서만 사용된다. 이 패턴은 동일한 트랜잭션 경계에 속한 비즈니스 엔티티와 밸류 오브젝트를 한데 묶기 때문에 ‘에그리게이트’로 명명됐다.

public class Ticket
{
    // ...
    List<Message> _messages;
    // ...
   
    public void Execute(EvaluateAutomaticActions cmd)
    {
        if (this.IsEscalated && this.RemainingTimePercentage < 0.5 &&
            GetUnreadMessagesCount(forAgent: AssignedAgent) > 0)
        {
            _agent = AssignNewAgent();
        }
    }
    
    public int GetUnreadMessagesCount(UserId id)
    {
        return _messages.Where(x => x.To == id && !x.WasRead).Count();
    }
    
    // ...
}

 

다른 에그리게이트 참조하기

에그리게이트를 가능한 한 작게 유지하고 에그리게이트의 비즈니스 로직에 따라 강력하게 일관적으로 상태를 유지할 필요가 있는 객체만 포함한다.

 

public class Ticket
{
    private UserId          _customer;
    private List<ProductId> _product;
    private UserId          _assignedAgent;
    private List<Message>   _messages;

    // ...
}

앞에서 봤듯이, 에그리게이트의 상태는 커맨드 중 하나를 실행해서만 수정할 수 있다. 여러 에그리게이트가 참조되었을 때 그중 하나만 에그리게이트의 퍼블릭 인터페이스, 즉 애그리게이트 루트로 지정돼야 한다.

 

도메인 이벤트

도메인 이벤트는 비즈니스 도메인에서 일어나는 중요한 이벤트를 설명하는 메시지다. 도메인 이벤트는 에그리게이트의 퍼블릭 인터페이스의 일부다. 에그리게이트는 자신의 도메인 이벤트를 발행한다.

{
    "ticket-id": "c9d286ff-3bca-4f57-94d4-4d4e490867d1",
    "event-id": 146,
    "event-type": "ticket-escalated",
    "escalation-reason": "missed-sla",
    "escalation-time": 1628970815
}

 

다음 Ticket 에그리게이트에서는 새로운 도메인 이벤트가 인스턴스로 만들어지고(12행), 티켓의 도메인 이벤트 모음에 추가된다(13행).

public class Ticket
{
    // ...
    private List<DomainEvent> _domainEvents;
    // ...
  
    public void Execute(RequestEscalation cmd)
    {
        if (!this.IsEscalated && this.RemainingTimePercentage <= 0)
        {
            this.IsEscalated = true;
            var escalatedEvent = new TicketEscalated(_id);
            _domainEvents.Append(escalatedEvent);
        }
    }
 
    // ...
}

 

시간 차원의 모델링

이벤트 소싱 도메인 모델 패턴은 도메인 모델 패턴과 동일한 전제를 기반으로 한다.

하지만 이 두가지 구현 패턴은 에그리게이트의 상태를 저장하는 방식이 다르다. 이벤트 소싱 도메인 모델은 이벤트 소싱 패턴을 사용하여 데그리게이트 상태를 관리한다. 즉, 에그리게이트의 상태를 유지하는 대신 모델은 각 변경사항을 설명하는 도메인 이벤트를 생성하고 에그리게이트 데이터에 대한 원천 데이터로 사용한다.

 

이벤트 소싱

 
lead_id name status phone number reg_dt upd_dt
1 David NEW_LEAD 111-1111 25.01.01 25.04.04
2 Rola CANCELED


텔레마케팅 시스템에서 잠재 고객 또는 리드를 관리하는 데 사용하는 테이블이다. 각 리드에 대해 ID, 이름, 현재 상태, 업데이트 된 시간 등을 볼 수 있다.

그러나 이 테이블은 리드의 현재 상태를 문서화하지만 각 리드가 현재 상태에 도달한 이력에 대한 이야기는 누락되었다. 리드의 수명주기 동안 어떤 일이 발생했는지 분석할 수도 없다. 리드가 CONVERTED되기 전에 몇 번이나 전화를 걸었는지도 모른다.

누락된 정보를 채우는 방법 중 하나는 이벤트 소싱을 사용하는 것이다.

이벤트 소싱 패턴은 데이터 모델에 시간 차원을 도입한다. 에그리게이트의 현재 상태를 반영하는 스키마 대신 이벤트 소싱 기반 시스템은 에그리게이트의 수명주기의 모든 변경사항을 문서화하는 이벤트를 유지한다.

 

[
    {
        "lead-id": 12,
        "event-id": 0,
        "event-type": "lead-initialized",
        "first-name": "Casey",
        "last-name": "David",
        "phone-number": "555-2951",
        "timestamp": "2020-05-20T09:52:55.95Z"
    },
    {
        "lead-id": 12,
        "event-id": 1,
        "event-type": "contacted",
        "timestamp": "2020-05-20T12:32:08.24Z"
    },
    {
        "lead-id": 12,
        "event-id": 2,
        "event-type": "followup-set",
        "followup-on": "2020-05-27T12:00:00.00Z",
        "timestamp": "2020-05-20T12:32:08.24Z"
    },
    {
        "lead-id": 12,
        "event-id": 3,
        "event-type": "contact-details-updated",
        "first-name": "Casey",
        "last-name": "Davis",
        "phone-number": "555-8101",
        "timestamp": "2020-05-20T12:32:08.24Z"
    },
    {
        "lead-id": 12,
        "event-id": 4,
        "event-type": "contacted",
        "timestamp": "2020-05-27T12:02:12.51Z"
    },
    {
        "lead-id": 12,
        "event-id": 5,
        "event-type": "order-submitted",
        "payment-deadline": "2020-05-30T12:02:12.51Z",
        "timestamp": "2020-05-27T12:02:12.51Z"
    },
    {
        "lead-id": 12,
        "event-id": 6,
        "event-type": "payment-confirmed",
        "status": "converted",
        "timestamp": "2020-05-27T12:38:44.12Z"
    }
]

 

고객의 현재 상태는 이러한 도메인 이벤트로부터 쉽게 프로젝션 할 수 있다. 간단한 변환 로직을 각 이벤트에 순차적으로 적용하면 된다.

public class LeadSearchModelProjection
{
    public long LeadId { get; private set; }
    public HashSet<string> FirstNames { get; private set; }
    public HashSet<string> LastNames { get; private set; }
    public HashSet<PhoneNumber> PhoneNumbers { get; private set; }
    public int Version { get; private set; }

    public void Apply(LeadInitialized @event)
    {
        LeadId = @event.LeadId;
        FirstNames = new HashSet < string > ();
        LastNames = new HashSet < string > ();
        PhoneNumbers = new HashSet < PhoneNumber > ();
        FirstNames.Add(@event.FirstName);
        LastNames.Add(@event.LastName);
        PhoneNumbers.Add(@event.PhoneNumber);
        Version = 0;
    }

    public void Apply(ContactDetailsChanged @event)
    {
        FirstNames.Add(@event.FirstName);
        LastNames.Add(@event.LastName);
        PhoneNumbers.Add(@event.PhoneNumber);
        Version += 1;
    }

    public void Apply(Contacted @event)
    {
        Version += 1;
    }

    public void Apply(FollowupSet @event)
    {
        Version += 1;
    }

    public void Apply(OrderSubmitted @event)
    {
        Version += 1;
    }

    public void Apply(PaymentConfirmed @event)
    {
        Version += 1;
    }
}

 

 

이벤트 스토어

이벤트 소싱 패턴이 작동하려면 객체 상태에 대한 모든 변경사항이 이벤트로 표현되고 저장되어야 한다. 이러한 이벤트는 시스템의 원천 데이터가 된다.

이벤트를 저장하는 데 사용되는 데이터베이스를 지칭하는 이름이 이벤트 스토어(event store)다. (DB, Kafka)

이벤트 저장과 읽기 모델을 데이터베이스에 유지해야 하므로 CQRS(Command-Query Responsibility Segregation) 패턴을 적용한다.

 

장점

  • 시간 여행
    • 모든 과거 상태를 복원하는데 사용할 수 있다.
    • 시스템의 동작을 분석하고, 시스템의 의사결정을 검사하고, 비즈니스 로직을 최적화할 때 종종 필요하다.
    • 문제가 생긴 시점의 상태로 되돌릴 수 있다.
  • 심오한 통찰력
    • 시스템의 상태와 동작에 대한 깊은 통찰력을 제공한다.
    • 추가 통찰력을 제공할 새로운 프로젝션 방법을 언제든지 추가할 수 있다.
  • 감사로그
    • 영속적인 도메인 이벤트는 에그리게이트 상태에 발생한 모든 것에 대한 강력하게 일관된 감사 로그를 나타낸다.
  • 고급 낙관적 동시성 제어
    • 이벤트 소싱을 사용할 때 기존 이벤트를 읽고 새 이벤트를 작성하는 사이에 정확히 무슨 일이 일어났는지 더 깊은 통찰력을 얻을 수 있다.

단점

  • 학습 곡선
  • 모델의 진화
    • 이벤트 소싱 모델을 발전시키는 것은 어려울 수 있다. 스키마를 조정해야 한다면?
  • 아키텍처 복잡성
    • CQRS 패턴

아키텍처 패턴

계층형 아키텍처

계층형 아키텍처(layered architecture)는 가장 일반적인 아키텍처 패턴 중 하나다. 계층은 탑다운 커뮤니케이션 모델에 따라 연동한다. 계층형 아키텍처 패턴을 확장해서 서비스 계층을 추가하는 것을 흔히 볼 수 있다.

  • 프레젠테이션 계층
    • 사용자와 상호작용을 하기 위한 프로그램의 사용자 인터페이스를 구현한다.
  • 서비스 계층
    • 프레젠테이션 계층과 비즈니스 로직 계층 사이의 중간 역할을 한다.
  • 비즈니스 로직 계층
    • 비즈니스 로직 구현. 액티브 레코드 또는 도메인 모델과 같은 비즈니스 로직 패턴을 이 계층에서 구현한다.
    • 엔티티, 규칙, 프로세스
  • 데이터 접근 계층
    • 영속성 매커니즘에 접근할 수 있게 해준다.
    • 프로그램의 기능을 구현하는 데 필요한 다양한 외부 정보 제공자와 연동하는 것을 포함한다.

 

비즈니스 로직과 데이터 접근 계층 간에는 의존성이 있다. 따라서 비즈니스 로직이 트랜잭션 스크립트 또는 액티브 레코드 패턴을 사용하여 구현된 시스템에 계층형 아키텍처 패턴이 적합하다.

반면, 도메인 모델을 구현하는 데 계층형 아키텍처 패턴을 적용하는 것은 어렵다. 도메인 모델에서는 에그리게이트와 밸류 오브젝트가 하부의 인프라스트럭처에 대해 의존성이 없어야 하고 그것을 몰라야 하기 때문이다.

 

포트와 어댑터

포트와 어댑터 아키텍처는 계층형 아키텍처의 단점을 해결하고 좀 더 복잡한 비즈니스 로직을 구현하는 데 적합하다.

포트와 어댑터 아키텍처의 핵심 목적은 인프라스트럭쳐 구성요소로부터 시스템의 비즈니스 로직을 분리하는 것이다. 인프라스트럭쳐 구성요소를 직접 참조하고 호출하는 대신, 비즈니스 로직 계층은 인프라스트럭쳐 계층이 구현해야 할 ‘포트’를 정의한다. 인프라스트럭쳐 계층은 ‘어댑터’를 구현한다. 즉, 다양한 기술을 사용하기 위해 정의된 포트의 인터페이스를 구체적으로 구현한다.

 

 

CQRS

CQRS 패턴은 시스템 모델의 책임을 분리시킨다. 여기에는 커맨드 실행 모델과 읽기 모델의 두 유형이 있다.

CQRS 패턴은 이벤트 소싱과 밀접하게 관련이 있으며 이벤트 소싱 모델의 질의 한계를 극복하려고 정의됐다. 즉, 한 번에 하나의 에그리게이트 인스턴스에 대한 이벤트를 질의할 수 있다. CQRS 패턴은 프로젝션된 모델을 물리적 데이터베이스에 materialized view를 생성하여 유연한 질의에 사용할 수 있게 해준다.

 

  • 커맨드 실행 모델
    • 시스템의 상태를 수정하는 오퍼레이션을 전담으로 수행하는 단일 모델
    • 커맨드 실행 모델은 시스템의 원천(write DB)인 강력한 일관성을 가진 데이터를 표현하는 유일한 모델이다. 비즈니스 엔티티의 일관적 상태를 읽을 수 있어야 하고 갱신할 때 낙관적 동시성을 지원해야 한다.
  • 읽기 모델 (프로젝션)
    • 읽기 모델은 캐시에서 언제든 다시 추출할 수 있는 프로젝션이다. 이는 견고한 데이터베이스, 일반 파일, 또는 인메모리 캐시에 위치할 수 있다.

 

운영의 관점에서 보면, 이 패턴은 당면한 과제에 가장 효과적인 모델을 사용하고 비즈니스 도메인 모델을 지속적으로 개선하는 도메인 주도 설계의 핵심 가치를 지원한다. 한편 인프라스트럭쳐 관점에서는 CQRS가 다양한 종류의 데이터베이스의 장점을 활용할 수 있게 해준다. 예를 들어, 커맨드 실행 모델을 저장을 위한 관계형 데이터 베이스, 전문 검색을 위한 검색 인덱스, 빠른 데이터 검색을 위한 사전 렌더링된 플랫 파일 등이 있으며, 이러한 모든 스토리지 매커니즘이 신뢰성 있게 동기화 된다.

또한 이벤트 소싱 모델에서는 에그리게이트의 상태에 기반한 레코드 조회가 불가능하지만 CQRS는 상태를 질의할 수 있는 데이터베이스에 상태를 프로젝션하므로 이것이 가능하다.

 

커뮤니케이션 패턴

에그리게이트 연동

에그리게이트가 시스템의 나머지 부분과 통신하는 방법 중 하나는 도메인 이벤트를 발행하는 것이다. 외부 컴포넌트는 이러한 도메인 이벤트를 구독하고 해당 로직을 실행할 수 있다.

아웃박스

DB에 변경사항이 커밋된 후 이벤트 메세지 발행을 보장하는 패턴

사가

핵심 에그리게이트 설계 원칙 중 하나는 각 트랜잭션을 에그리게이트의 단일 인스턴스로 제한하는 것이다.

예를 들어, 광고 캠페인이 활성화되면 캠페인의 광고 자료를 퍼블리셔에게 자동으로 제출해야 한다. 퍼블리셔로부터 확인을 받으면 캠페인의 발행 상태가 발행됨으로 변경되어야 한다. 퍼블리셔가 거부한 경우 캠페인은 거부됨으로 표시되어야 한다.

이 흐름은 광고 캠페인과 퍼블리셔라는 두 가지 비즈니스 엔티티에 걸쳐 있다. 동일한 에그리게이트 경계에 두 가지 엔티티를 배치하는 것은 명백하게 잘못됐다. 이들은 책임이 다르고 다른 바운디드 컨텍스트에 속할 수 있는 분명히 다른 비즈니스 엔티티이기 때문이다. 대신 이 흐름을 사가(saga)로 구현할 수 있다.

 

프로세스 관리자

사가 패턴에 올바른 동작 과정을 선택하는 if-else 문이 포함되어 있다면 아마도 프로세스 관리자일 것이다. 프로세스 관리자는 여러 단계로 구성된 응집된 비즈니스 프로세스다.

예를 들어, 출장 예약은 가장 비용 효과적인 비행 경로를 선택하고 직원에게 승인을 요청하는 라우팅 알고리즘으로 시작한다. 직원이 다른 경로를 선호하는 경우 직속 관리자가 승인해야 한다. 항공편을 예약한 후 사전 승인된 호텔 중 하나를 적절한 날짜에 예약해야 한다. 이용 가능한 호텔이 없으면 항공권을 취소해야 한다.

출장 예약은 프로세스이며, 프로세스 관리자로 구현해야 한다.