Ch5. 간단한 비즈니스 로직 구현
비즈니스 로직은 소프트웨어에서 가장 중요한 부분이며, 애초에 소프트웨어를 구현한는 이유이기도 하다.
모든 하위 도메인마다 전략적 중요성과 복잡성은 다르다.
이번 장에서는 비즈니스 로직 코드를 모델링하고 구현하는 다양한 방법에 대해 검토해 보자.
[트랜잭션 스크립트]
“프레젠테이션으로부터 단일 요청을 처리하는 여러 프로시저를 모아서 비즈니스 로직을 구현하라”
- 마틴 파울러
시스템의 퍼블릭 인터페이스는 그림 5-1과 같이 사용자가 실행할 수 있는 비즈니스 트랜잭션의 모음으로 볼 수 있다.
이러한 트랜잭션은 시스템에서 관리하는 정보를 검색, 수정 또는 둘 다 할 수 있다.
트랜잭션 스크립트 패턴은 프로시저를 기반으로 시스템의 비즈니스 로직을 구성하며, 각 프로시저는 퍼블릭 인터페이스를 통해 시스템 사용자가 실행하는 작업을 구현한다.

구현
각 프로시저는 간단하고 쉬운 절차지향 스크립트(procedural script)로 구현한다.
저장 장치와 연동하기 위해 얇은 추상화 계층을 사용할 수 있지만 DB에 직접 접근도 가능하다.
이 프로시저가 구현해야 하는 유일한 요구사항은 트랜잭션 동작이다.
각 작업은 성공하거나 실패할 수 있지만, 유효하지 않은 상태를 만들면 안된다.
즉, 실행에 실패하더라도 시스템은 오류가 발생할 때까지 변경사항을 롤백하거나 보상조치를 실행하여 일관성을 유지해야 한다.
다음은 JSON 파일들을 XML 파일로 변환하는 트랜잭션 스크립트의 예다.
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()
그렇게 쉽지 않다!
트랜잭션 스크립트 패턴은 다음 장에서 배우게 될 고급 비즈니스 로직 구현 패턴의 기반이 된다.
트랜잭션 스크립트를 올바르게 구현하지 못해 발생하는 데이터 손상의 실제 사례 세 가지를 살펴보자.
트랜잭션 동작 구현 실패
트랜잭션 동작 구현에 실패한 간단한 예는 전체를 아우르는 트랜잭션 없이 여러 업데이트를 하는 경우다.
Users 테이블의 레코드를 업데이트하고 VisitsLog 테이블에 레코드를 삽입하는 다음 메서드를 보자.
public class LogVisit
{
...
public void Execute(Guid userId, DataTime visitedOn) {
_db.Execute("UPDATE Users Set last_visit=@p1 WHERE user_id=@p2",
visitedOn, userId);
_db.Execute(@"INSERT INTO VisitsLog(user_id, visit_date)
VALUES(@p1, @p2)", userId, visitedOn);
}
}
만약 Users 테이블에 레코드가 업데이트가 되고, 로그 레코드를 성공적으로 추가하기 전에 문제가 생긴다면 시스템이 일관된 상태가 되지 않게 된다.
따라서 두 데이터 변경을 모두 포함하는 트랜잭션을 만들어서 해결할 수 있다.
public class LogVisit
{
...
public void Execute(Guid userId, DataTime visitedOn) {
{
try
{
_db.StartTransaction();
_db.Execute("UPDATE Users Set last_visit=@p1 WHERE user_id=@p2",
visitedOn, userId);
_db.Execute(@"INSERT INTO VisitsLog(user_id, visit_date)
VALUES(@p1, @p2)", userId, visitedOn);
_db.Commit();
} catch {
_db.Rollback();
throw;
}
}
}
}
분산 트랜잭션
최신 분산 시스템에서는 데이터베이스의 데이터를 변경한 다음 메시지 버스에 메시지를 발행하여 시스템의 다른 컴포넌트에 변경사항을 알리는 것이 일반적이다.
이전 예에서 로그를 기록하는 대신 메시지 버스에 메시지를 발행한다고 해보자.
public class LogVisit
{
...
public void Execute(Guid userId, DataTime visitedOn) {
_db.Execute("UPDATE Users Set last_visit=@p1 WHERE user_id=@p2",
visitedOn, userId);
_messageBus.Publish("VISITS_TOPIC",
new { UserId = userId, VisitDate = visitedOn });
}
}
앞의 예에서와 같이 Users 테이블에 레코드가 업데이트가 되고, 메시지를 보내기 전에 발생한 모든 오류는 시스템 상태를 손상 시킨다.
안타깝게도 문제를 수정하는 것은 쉽지 않다.
이를 해결하기 위해 8장에서는 CQRS 패턴, 9장에서는 아웃박스 패턴을 소개한다.
암시적 분산 트랜잭션
다음과 같이 믿을 수 없을 정도로 간단한 방법을 생각해보라.
public class LogVisit
{
...
public void Execute(Guid userId) {
_db.Execute("UPDATE Users Set visits=visits+1 WHERE user_id=@p1",
userId);
}
}
이전 예제처럼 마지막 방문 날짜를 추적하는 대신 이 메서드는 각 사용자에 대한 방문 카운터를 유지한다.
메서드를 호출하면 해당 카운터의 값이 1 증가한다.
그러나 이것은 여전히 잠재적으로 일관성 없는 상태로 이어질 수 있는 분산 트랜잭션이다.
이 예제는 그럼 5-2에서 보듯이 메서드를 호출한 외부 프로세스와 DB에 정보를 전달하기 때문에 분산 트랜잭션을 구성한다.

execute 메서드가 void라서 데이터를 반환하지 않지만 작업이 성공 또는 실패했는지 여부는 여전히 호출자에게 전달된다.
메서드는 성공했지만 호출자에게 결과를 전달하는데 실패할 경우 어떻게 될까?
사용자는 실패를 가정하고 LogVisit을 다시 호출할 것이다.
대체로 1이 아닌 2로 증가할 것이다.
이 예제에서 트랜잭션 동작을 보장하는 한 가지 방법은 작업을 멱등성으로 만드는 것이다.
즉, 같은 요청을 여러 번 하더라도 그 결과는 매번 동일하게 만드는 것이다.
예를 들어 사용자에게 카운터 값을 전달하도록 요청한다.
이렇게 하면 작업을 여러 번 실행하더라도 최종 결과는 변하지 않는다.
public class LogVisit
{
...
public void Execute(Guid userId, long visits) {
_db.Execute("UPDATE Users Set visits= @p1 WHERE user_id=@p2",
visits, userId);
}
}
또 다른 해결 방법은 낙관적 동시성 제어를 사용하는 것이다.
LogVisit 작업을 호출하기 전에 호출자는 카운터의 현재 값을 읽고 매개변수로 LogVisit에 전달했다.
LogVisit은 호출자가 처음 읽은 값과 동일한 경우에만 카운터 값을 업데이트 한다.
public class LogVisit
{
...
public void Execute(Guid userId, long expectedVisits) {
_db.Execute("UPDATE Users Set visits=visits+1
WHERE user_id=@p1, visits = @p2",
userId, visits);
}
}
트랜잭션 스크립트를 사용하는 경우
트랜잭션 스크립트 패턴은 비즈니스 로직이 단순한 절차적 작업처럼 매우 간단한 문제 도메인에 효과적이다.
예를 들어 ETL(추출-변환-적재) 작업에서 각 작업은 원천 시스템에서 데이터를 추출하고 변환 로직을 적용하여 데이터를 다른 형식으로 변환하고 결과를 목적 시스템에 적재한다.
이 프로세스는 다음 그림에 나와 있다.

트랜잭션 스크립트 패턴은 정의상 비즈니스 로직이 단순한 지원 하위 도메인의 경우 적합하다.
트랜잭션 스크립트 패턴의 주요 장점은 단순함이다.
따라서 핵심 하위 도메인처럼 비즈니스 로직이 복잡한 경우 사용해서는 안된다.
[액티브 레코드]
“데이터베이스 테이블 또는 뷰의 행을 감싸고 데이터베이스 접근을 캡슐화하고 해당 데이터에 도메인 로직을 추가하는 오브젝트” - 마틴 파울러
트랜잭션 스크립트 패턴과 마찬가지로 액티브 레코드는 비즈니스 로직이 단순한 경우 사용한다.
그러나 액티브 레코드는 좀 더 복잡한 자료구조에서도 비즈니스 로직이 작동할 수 있다.
예를 들어, 그럼 5-4와 같이 더 복잡한 오브젝트 트리와 계층 구조를 가질 수 있다.

구현
결과적으로 이 패턴은 액티브 레코드라는 전용 객체를 사용하여 복잡한 자료구조를 표현한다.
자료구조 외에도 CRUD도 구현한다.
그 결과, 액티브 레코드 객체는 객체 관계 매핑(ORM) 또는 다른 데이터 접근 프레임워크와도 관련이 있다.
즉, 액티브 레코드는 데이터 접근 로직을 구현한다.
다음 예제를 살펴보자.
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 ;
}
}
}
이 패턴의 목적은 메모리 상의 객체를 데이터베이스 스키마에 매핑하는 복잡성을 숨기는 것이다.
영속성을 담당하는 것 외에도 액티브 레코드 객체에는 비즈니스 로직이 포함될 수 있다.
즉, 액티브 레코드 객체의 고유한 기능은 자료구조와 동작(비즈니스 로직)의 분리다.
일반적으로 액티브 레코드의 필드에는 외부 프로시저가 상태를 수정할 수 있게 퍼블릭 getter와 setter가 있다.
액티브 레코드를 사용하는 경우
액티브 레코드는 본질적으로 데이터베이스에 대한 접근을 최적화하는 트랜잭션 스크립트이기 때문에 이 패턴은 기껏해야 사용자 입력의 유효성을 검증하는 CRUD 작업과 같은 비교적 간단한 비즈니스 로직만 지원한다.
따라서 지원 하위 도메인, 일반 하위 도메인과 외부 솔루션의 연동, 모델 변환 작업에 적합하다.
액티브 레코드 패턴은 빈약한 도메인 모델 안티패턴이라고도 하며, 다시 말해 부적절하게 설계된 도메인 모델이다.
[실직적인 접근 방법]
비록 비즈니스 데이터가 중요하고 설계 및 개발되는 코드의 무결성도 보호해야 하지만, 실용적인 접근 방식이 더 바람직한 경우가 있다.
특히 대규모로 데이터를 다루는 시스템에서는 데이터의 일관성 보장이 덜 엄격할 수 있다.
예를 들어, IoT 장치에서 매일 수십억 개의 이벤트를 수집하는 시스템을 구축한다고 가정해 보자.
이벤트의 0.001%가 중복되거나 손실되면 큰일날까?
항상 그렇듯이 보편적인 법칙은 없다.
그것은 작업 중인 비즈니스 도메인에 달려있다.
[결론]
이번 장에서는 비즈니스 로직을 구현하기 위해 두 가지 패턴을 배웠다.
트랜잭션 스크립트
- 이 패턴은 시스템 작업을 간단하고 쉬운 절차지향 스크립트로 구성한다.
액티브 레코드
- 비즈니스 로직이 단순하지만 복잡한 자료구조에서 작동하는 경우 해당 자료구조를 액티브 레코드로 구현할 수 있다.
Ch6. 복잡한 비즈니스 로직 다루기
이번장에서는 복잡한 비즈니스 로직에 사용되는 도메인 모델 패턴을 소개한다.
[배경]
트랜잭션 스크립트와 액티브 레코드 패턴 모두 마틴 파울러의 책 ‘엔터프라이즈 애플리케이션 아키텍처 패턴’ 에서 처음 소개됐다.
에반스는 2011년에 ‘도메인주도 설계’ 를 집필했는데, 비즈니스 도메인의 하위 모델과 코드를 긴밀하게 연결 짓는데 쓰이는 aggregate, value object, repository 등과 같은 패턴을 제시했다.
에반스가 소개한 패턴은 종종 ‘전술적 도메인 주도 설계(tactical domain-driven-design)’로 불린다.
이 패턴이 ‘도메인 모델’이고, 애그리게이트와 밸류 오브젝트는 그 구성요소다.
[도메인 모델]
도메인 모델 패턴은 복잡한 비즈니스 로직을 다루기 위한 것이다.
CRUD 인터페이스 대신 복잡한 상태 전환, 항상 보호해야 하는 규칙인 비즈니스 규칙과 불변성을 다룬다.
헬프데스크 시스템을 구현한다고 가정해보자.
그리고 다음 지원 티켓의 수명주기를 다루는 로직을 설명한 요구사항에서 발췌한 내용을 살펴보자.
- 고객은 직면한 문제를 설명하는 지원 티켓을 연다.
- 고객과 지원 할당된 에이전트 모두 메시지를 추가하고 모든 내용은 지원 티켓에서 관리된다.
- 각 티켓은 낮음, 중간 ,높음, 긴급의 우선순위를 갖는다.
- 에이전트는 티켓의 우선순위에 따른 SLA(응답 제한 시간) 내에 해법을 제시해야 한다.
- 할당된 에이전트가 응답 제한 시간 내에 응답하지 못하면 고객은 티켓을 에이전트의 상위 관리자에게 보고할 수 있다.
- 티켓이 상위 관리자에게 보고되면 에이전트의 응답 제한 시간이 33% 줄어든다.
- 에이전트가 상부 보고된 티켓의 응답 제한 시간의 절반이 지나기 전에 티켓을 열람하지 않으면 자동으로 다른 에이전트에게 할당된다.
- 할당된 에이전트의 질문에 고객이 7일 이내에 응답하지 않으면 티켓은 자동으로 닫힌다.
- 상부 보고된 티켓은 자동으로 또는 할당된 에이전트에 의해 닫힐 수 없고, 고객 또는 에이전트의 매니저만 닫을 수 있다.
- 고객은 티켓이 닫힌 지 7일 이내에 닫힌 티켓을 다시 열 수 있다.
이 같은 요구사항은 다양한 규칙 간에 그물 같은 의존성을 형성하고, 모든 규칙은 지원 티켓의 수명주기 관리 로직에 영향을 준다.
만약 액티브 레코드 객체를 사용하여 로직을 구현한다면 로직이 중복되거나 일부 비즈니스 규칙이 잘못 구현되어 시스템에 상태를 손상시키기 쉽다.
구현
도메인 모델은 행동과 데이터 모두를 포함하는 도메인의 객체 모델이다.
DDD의 전술 패턴인 애그리게이트, 밸류 오브젝트, 도메인 이벤트, 도메인 서비스는 모두 객체 모델의 구성요소다.
이 같은 모든 패턴은 비즈니스 로직을 최우선으로 둔다는 공통 관심사가 있다.
복잡성
도메인 비즈니스 로직은 이미 본질적으로 복잡하므로, 모델링에 사용되는 객체가 모델에 조금이라도 우발적 복잡성을 추가하면 안 된다.
모델에는 데이터베이스 또는 외부 시스템 구성 요소의 호출 구현 같은 인프라 또는 기술적인 관심사를 피해야 한다.
이 같은 제약에 따르면 모델의 객체는 plain old object(자바의 경우 POJO)가 된다.
이 POO는 인프라 또는 프레임워크에 의지하지 않고 비즈니스 로직을 구현하는 객체다.
유비쿼터스 언어
도메인 모델의 객체가 기술적 관심사가 아닌 비즈니스 로직에 집중하게 하면 바운디드 컨텍스트에서 사용하는 유비쿼터스 언어의 용어를 따르기 쉬워진다.
따라서 이 패턴은 코드에서 유비쿼터스 언어를 사용하게 하고 도메인 전문가의 멘탈 모델을 따르게 된다.
구성요소
이제 벨류 오브젝트, 에그리게이트, 도메인 서비스와 같은 DDD에서 제공하는 도메인 모델의 구성요소와 전술적 패턴을 살펴보자.
밸류 오브젝트(Value Object)
밸류 오브젝트는 예를 들어, 색(color)처럼 복합적인(composition) 값에 의해 식별되는 객체다.
class Color
{
int _red;
int _green;
int _blue;
}
빨강, 초록, 파랑의 세 필드 값이 복합적으로 색을 정의한다.
필드 중 하나의 값이 바뀌면 새로운 색이 탄생한다.
같은 값을 갖는 두 개 이상의 색은 존재하지 않는다.
또한 같은 색의 두 인스턴스는 반드시 같은 값을 갖는다.
유비쿼터스 언어
언어의 표준 라이브러리에 포함된 문자열(string), 정수(integer), 딕셔너리(dictionary) 같은 원시 데이터 타입에 전적으로 의존해서 비즈니스 도메인의 개념을 표현하는 것은 원시 집착 코드 징후로 알려져 있다.
다음 예제를 살펴보자.
class Person
{
private int _id;
private string _firstName;
private string _lastName;
private string _email;
private string _phone;
public Person(...){...}
}
static void Main(string[] args)
{
var dave = new Person(
id: 3066,
firstName: "Dave",
lastName: "Ancelovici",
email: "dave@gogle.com"
phone: "05122321424"
)
}
Person 클래스 구현에서 값의 대부분은 문자열 타입이고, 관례에 따라 값이 할당되었다.
이 같은 방식에는 몇 가지 설계 위험이 있다.
우선 유효성 검사 로직이 중복되기 쉽다.
둘째, 값이 사용되기 전에 유효성 검사 로직을 호출하기 어렵다.
마지막으로 다른 엔지니어가 코드베이스를 개선하는 것과 같은 유지보수가 더 어렵다.
그럼 다음과 같이 밸류 오브젝트를 사용하는 설계를 살펴보자.
class Person
{
private PersonId _id;
private Name _firstName;
private EmailAddress _email;
private PhoneNumber _phone;
public Person(...){...}
}
static void Main(string[] args)
{
var dave = new Person(
id: new PersonId(3066),
firstName: new Name("Dave", "Ancelovici"),
email: Email.Parse("dave@gogle.com"),
phone: PhoneNumber.Parse("05122321424")
)
}
우선 명료성이 향상됐음을 볼 수 있다.
둘째, 유효성 검사 로직이 밸류 오브젝트 자체에 들어 있어 값을 할당하기 전에 유효성 검사를 할 필요가 없다.
가장 중요한 점은 밸류 오브젝트를 사용하면 코드에서 유비쿼터스 언어를 사용하게 하므로 코드에서 비즈니스 도메인의 개념을 표현하게 된다는 것이다.
이름(Name), 전화번호(phone Number), 이메일(email)과 같은 개념을 VO로 표현할 때 구현된 시스템의 타입이 얼마나 더 풍부해지고 사용하기에 직관적인지 살펴보자.
height 변수를 정수 타입으로 했을 때보자 Height 밸류 오브젝트로 구현하면 의도가 명확해지고 특정 도량형에 종속되지 않는다.
예를 들어 Height VO를 미터법 또는 영국식 단위를 모두 사용하여 초기화할 수 있어서 다른 단위로 변환하거나 문자열로 표현, 다른 단위의 값과 비교하는 것이 쉬워진다.
var heightMetric = Height.Metric(180);
var heightImperial = Height.Imperial(5, 3);
var string1 = heightMetric.ToString(); // "180com"
var string2 = heightImperial.ToString(); // "5 feet 3 inches"
var string3 = heightMetric.ToImperial().ToString(); // "5 feet 11 inches"
var firstIsHigher = heightMetric > heightImperial; // true
PhoneNumber VO의 경우 문자열 값의 파싱, 유효성 검사, 그리고 소속된 국가 또는 유선/무선 전화번호 유형과 같은 다양한 전화번호 속성을 추출하는 로직을 담을 수 있다.
var phone = PhoneNumber.Parse("+3512433433");
var country = phone.Country; // "BG"
var phoneType = phone.PhoneType; // "MOBILE"
var isValid = PhoneNumber.IsValid("+921252423213"); // false
구현
밸류 오브젝트는 불변의 객체로 구현되므로 밸류 오브젝트에 있는 필드가 하나라도 바뀌면 다른 값이 생성된다.
직, 밸류 오브젝트의 필드 중 하나라도 바뀌면 개념적으로 다른 인스턴스가 생성된다.
그러므로 다음 예제에서 MixWith 메서드에서 새로운 값을 반환하게 하듯이, 새로운 인스턴스를 생성해서 반환하게 해야 한다.
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),
}
...
}
밸류 오브젝트의 동일성은 id 필드나 참조 대신 값을 기반으로 하므로 동일성 검사 함수를 오버라이딩해서 적절히 구현하는 것이 중요하다.
다음은 C#으로 구현한 예제다.
public class Color
{
...
public ovverride 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();
}
}
밸류 오브젝트를 사용하는 경우
간단히 말해 밸류 오브젝트를 가능한 모든 경우에 사용하는 것이 좋다.
VO는 코드의 표현력을 높여주고 분산되고 쉬운 비즈니스 로직을 한데 묶어줄 뿐만 아니라 코드를 더욱 안전하게 해준다.
또한 VO는 불변이기 때문에 내포된 동작은 부작용과 동시성 문제가 없다.
경험상 비즈니스 도메인 관점에서 유용한 법칙은 다른 객체의 속성을 표현하는 도메인의 요소에 VO를 사용하는 것이다.
VO를 적용한 다른 대표적인 예로는 다양한 상태, 비밀번호, 돈처럼 가치를 표현하는 모든 화폐 등이 있다.
엔티티
엔티티는 밸류 오브젝트와 정반대다.
엔티티는 다른 엔티티 인스턴스와 구별하기 위한 명시적인 식별 필드가 필요하다.
다음 클래스를 살펴보자.
class Person
{
public Name Name (get; set; )
public Person(Name name)
{
this.Name = name;
}
}
이 클래스는 한 개의 밸류 오브젝트인 name을 가지고 있다.
그러나 다른 사람이 정확히 같은 이름을 가질 수 있기 때문에 이 설계는 최적이 아니다.
따라서 식별 필드가 필요하다.
class Person
{
public readonly PersonId Id;
public Name Name (get; set; )
public Person(PersonId id, Name name)
{
this.Id = id;
this.Name = name;
}
}
PersonId는 밸류 오브젝트로, 비즈니스 도메인에서 모든 기본 데이터 타입을 사용할 수 있다.
예를 들어 Id 필드는 GUID, 숫자, 문자열, 또는 사회 보장 번호와 같은 특정 도메인의 값일 수 있다.
식별 필드의 핵심 요구사항은 각 엔티티의 인스턴스마다 고유해야 한다는 것이다.
그림 6-2의 사람이 그 예다.

밸류 오브젝트와는 반대로 엔티티는 불변이 아니고 변할 것으로 예상된다.
또 다른 차이점은 밸류 오브젝트는 엔티티의 속성을 설명한다는 것이다.
엔티티는 모든 비즈니스 도메인의 필수 구성요소다.
엔티티는 단독으로 구현하지 않고 애그리게이트 패턴의 컨텍스트에서만 엔티티를 구현한다.
애그리게이트
애그리게이트는 엔티티다.
즉, 명시적인 식별 필드가 필요하고, 인스턴스의 생애 주기 동안 상태가 변할 것으로 예상된다.
하지만 애그리게이트는 단순한 엔티티가 아닌 그 이상이다.
이 패턴은 데이터의 일관성을 유지하기 위해 해결해야 할 과제가 있다는 의미도 포함된다.
일관성 강화
애그리게이트의 상태는 변형될 수 있으므로 데이터가 손상될 수 있는 여러 경로가 있다.
데이터의 일관성을 강화하려면 애그리게이트 주변에 명확한 경계를 설정해야 한다.
구현 관점에서 보면 데이터의 일관성은 애그리게이트의 비즈니스 로직을 통해서만 상태를 변경하게 변경해야 한다.
애그리게이트의 퍼블릭 인터페이스로 노출된 상태 변경 메서드는 ‘어떤 것을 지시하는 명령’을 뜻하는 의미에서 커맨드라고도 부른다.
커맨드는 두가지 방식으로 구현가능 하다.
첫째, 애그리게이트 객체에 평범한 퍼블릭 메서드로 구현하는 것이다.
public class Ticket
{
...
public void AddMessage(UserId from, 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);
}
...
}
어떤 방식으로 구현할지는 선호도의 문제다.
필자는 명시적으로 커맨드 구조를 정의해서 다형적으로 관련 Execute 메서드에 전달하는 것을 선호한다.
애그리게이트의 퍼블릭 인터페이스는 입력값의 유효성을 검사하고, 관련된 모든 비즈니스 규칙과 불변성을 강화하는 것을 담당한다.
또한 이러한 엄격한 경계는 애그리게이트와 관련된 모든 비즈니스 로직은 애그리게이트 자체에서 구현되게 한다.
이렇게 하면 애그리게이트에서 애플리케이션 계층의 조율 동작을 좀 더 단단하게 만들 수 있다.
다시 말해, 조율 동작에서 해야 할 모든 일은 결국 애그리게이트의 현재 상태를 적재해서 필요한 동작을 수행하고, 수정된 상태를 저장한 후 오퍼레이션의 결과를 호출자에게 반환하는 것이다.
public ExecutionResult Escalte(TicketId id, EscalationReason reason)
{
try
{
var ticket = _ticketRepository.Load(id);
var cmd = new Escalate(reason);
ticket.Execute(cmd);
_ticketRepository.Save(ticket);
return ExecutionResult.Success();
}
catch (ConcurrencyException ex)
{
return ExecutionResult.Error(ex);
}
}
위의 코드에서 catch 절을 주목하자.
애그리게이트 상태의 일관성을 유지하는 것이 중요하다.
그러므로 여러 프로세스가 동시에 동일한 애그리게이트를 갱신하려고할 때, 첫번째 트랜잭션이 커밋한 변경을 나중의 트랜잭션이 은연 중에 덮어쓰지 않게 해야 한다.
그러므로 애그리게이트를 저장하는 데이터베이스는 동시성 관리를 지원해야 한다.
가장 간단한 형태는 매번 갱신할 때마다 증가하는 버전 필드를 애그리게이트에서 관리하는 것이다.
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;
트랜잭션 경계
애그리게이트의 상태는 자신의 비즈니스 로직을 통해서만 수정될 수 있기 때문에 애그리게이트가 트랜잭션 경계의 역할을 한다.
모든 애그리게이트 상태 변경은 원자적인 단일 오퍼레이션으로 트랜잭션을 처리해야 한다.
트랜잭션별로 하나의 애그리게이트 인스턴스만 갖게 제한하면, 애그리게이트의 경계가 비즈니스 도메인의 불변성과 규칙을 따르도록 신중히 설계하게 된다.
그럼 만약 동일한 트랜잭션에서 여러 객체를 수정해야 한다면 어떻게 해야 할까?
엔티티 계층
이번 장의 초반부에 논의했듯이, 엔티티는 독립적 패턴이 아닌 애그리게이트의 일부로서만 사용된다.
이제 엔티티와 애그리게이트의 근본적인 차이점을 살펴보자.
여러 객체가 하나의 트랜잭션 경계를 공유하는 비즈니스 시나리오가 있다.
예를 들어, 둘 다 동시에 변경되거나 객체 하나가 다른 객체의 상태에 의존하는 비즈니스 규칙이 될 수 있다.
DDD에서 비즈니스 도메인이 시스템의 설계를 주도해야 한다고 규정한다.
애그리게이트도 마찬가지다.

그림 6-3처럼 여러 객체의 변경을 원자적인 단일 트랜잭션으로 지원하기 위해 애그리게이트 패턴은 엔티티 계층 구조와 유사하게 모든 트랜잭션을 공유해서 일관성을 유지한다.
이 패턴은 동일한 트랜잭션 경계에 속한 비즈니스 엔티티와 밸류 오브젝트를 한데 묶기 때문에 ‘애그리게이트’로 명명됐다.
다음 코드는 애그리게이트 경계에 속한 여러 엔티티에 걸친 비즈니스 규칙의 예다.
public class Ticket
{
...
List<Message> _messages;
...
public void Execute(EvaluateAutomaticActions cmd)
{
if (this.IsEscalated && this.RemainingTimePercetage < 0.5 &&
GetUnreadMessagesCount(for: AssingedAgent) > 0)
{
_agent = AssingNewAgent();
}
}
public int GetUnreadMessagesCount(UserId id)
{
return _messages.Where(x => x.To == id && !x.WasRead).Count();
}
...
}
이 메서드는 티켓이 상부에 보고되었는지, 그리고 남은 처리 시간이 정의된 50% 임계치 아래인지 확인하기 위해 티켓의 값을 검사한다.
또한 현재 에이전트가 메시지를 아직 읽기 전인지 검사한다.
모든 조건이 충족되면 티켓은 다른 에이전트에게 할당되도록 요청된다.
이렇듯 애그리게이트는 일관된 데이터에 대해 모든 조건을 엄격하게 검사하도록 확인한다.
다른 애그리게이트 참조하기
애그리게이트 내의 모든 객체는 같은 트랜잭션 경계를 공유하기 때문에 애그리게이트가 너무 커지면 성능과 확장에 문제가 생길 수 있다.
데이터의 일관성은 애그리게이트의 경계를 설계하는데 편리한 가이드 원칙이다.
애그리게이트의 비즈니스 로직에 따라 강력하게 일관성이 필요한 정보 외에는 그림 6-4 처럼 애그리게이트 경계 밖에 다른 애그리게이트의 일부로 둔다.

경험상 애그리게이트를 가능한 한 작게 유지하고, 애그리게이트의 비즈니스 로직에 따라 강력하게 일관적으로 상태를 유지할 필요가 있는 객체만 포함한다.
public class Ticket
{
private UserId _customer;
private List<ProductId> _products;
private UserId _assignedAgent;
private List<Message> _messages;
...
}
티켓과 관련된 고객과 제품의 모음, 그리고 할당된 에이전트는 애그리게이트에 속하지 않아 ID로 참조된다.
외부 애그리게이트를 참조할 때 ID로 참조하는 이유는 이 같은 객체가 애그리게이트 경계에 속하지 않음을 명확히 하고, 각 애그리게이트가 자신의 트랜잭션 경계를 갖게 보장하기 위함이다.
엔티티가 애그리게이트에 속하는지 판단하는 방법은 우선 비즈니스 로직 내에 궁극적으로 일관된 데이터를 다루는 상황이 되면 시스템의 상태를 손상시킬 수 있는지 여부를 판단한 후, 그 비즈니스 로직이 애그리게이트에 있는지 여부를 조사하는 것이다.
애그리게이트 루트
앞에서 봤듯이, 애그리게이트의 상태는 커맨드 중 하나를 실행해서만 수정할 수 있다.
그림 6-5처럼 애그리게이트가 엔티티의 계층 구조를 대표하기 때문에 그중 하나만 애그리게이트의 퍼블릭 인터페이스, 즉 애그리게이트 루트로 지정돼야 한다.

Ticket 애그리게이트에서 발췌한 다음의 예를 보자.
public class Ticket
{
...
List<Message> _messages;
...
public void Execute(AcknowledgeMessage cmd)
{
var message = _messages.Where(x = > x.id == cmd.id).First();
message.WasRead = true;
}
...
}
애그리게이트는 특정 메시지의 읽음 상태를 수정할 수 있는 커멘드를 노출한다.
즉, 애그리게이트 루트인 Ticket을 통해서만 접근할 수 있다.
애그리게이트 루트의 퍼블릭 인터페이스 외에도 외부에서 애그리게이트와 커뮤니케이션할 수 있는 다른 메커니즘이 있는데, 바로 도메인 이벤트다.
도메인 이벤트
도메인 이벤트는 비즈니스 도메인에서 일어나는 중요한 이벤트를 설명하는 메시지다.
예를 들면 다음과 같다.
- 티켓이 할당됨
- 티켓이 상부에 보고됨
- 메시지가 수신됨
도메인 이벤트는 이미 발생된 것이기 때문에 과거형으로 명명한다.
도메인 이벤트의 목적은 비즈니스 도메인에서 일어난 일을 설명하고, 이벤트와 관련된 모든 필요한 데이터를 제공하는 것이다.
예를 들면 다음과 같다.
{
"ticket-id": "asdjflsaikdjflasv0asrfawefasdf",
"event-id": 146,
"event-type": "ticket-escalated",
"escalation-reason": "missed-sla",
"escalation-time": 12363322
}
도메인 이벤트의 이름이 비즈니스 도메인에서 일어난 일을 간결하고 정확하게 반영해야 한다.
도메인 이벤트는 애그리게이트의 퍼블릭 인터페이스의 일부다.
애그리게이트는 자신의 도메인 이벤트를 발행한다.
그림 6-6처럼 다른 프로세스, 애그리게이트, 심지어 외부 시스템도 이 도메인 이벤트를 구독하고, 이벤트에 반응하는 자신만의 로직을 실행할 수 있다.

도메인 이벤트의 예는 다음과 같다.
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, cmd.Reason);
_domainEvents.Append(escalatedEvent);
}
}
...
}
유비쿼터스 언어
마지막으로, 애그리게이트는 유비쿼터스 언어를 사용해야 한다.
도메인 서비스
언젠가는 애그리게이트도 밸류 오브젝트에도 속하지 않거나 복수의 애그리게이트에 관련된 비즈니스 로직을 다루게 될 것이다.
이 경우 도메인 주도 설계에서는 도메인 서비스로 로직을 구현할 것을 제안한다.
도메인 서비스는 비즈니스 로직을 구현한 상태가 없는 객체다.
대부분의 경우 이런 로직은 어떤 계산이나 분석을 수행하기 위한 다양한 시스템의 호출의 조율을 한다.
티켓 애그리게이트 예제로 돌아가 보자.
티켓은 제한된 시간 내에 고객에게 솔루션을 제공해야 하는데, 이 시간은 이에전트 소속 부서의 우선순위별 SLA 관련 정책, 이에전트의 스케줄에 종속된다.
다음 예제를 살펴보자.
public class ResponseTimeFramecalculationService
{
...
public ResponseTimeframe CalculateAgentResponseDeadline(Userid agentId,
Priority priority, bool escalated, DateTime startTime)
{
var policy = _departmentRepository.GetDepartmentPolicy(agentId);
var maxProcTime = policy.GetMaxresponseTimeFor(priority);
if (escalated) {
maxProcTime = maxProcTime * policy.EscalationFactor;
}
var shifts = _departmentRepository.GetUpcomingShifts(agentId,
startTime, startTime.Add(policy.MaxAgentResponseTime));
return CalculateTargetTime(maxProcTime, shifts);
}
...
}
도메인 서비스는 여러 애그리게이트의 작업을 쉽게 조율할 수 있다.
그러나 한 개의 데이터베이스 트랜잭션에서 한 개의 애그리게이트 인스턴스만 수정할 수 있다고 했던 애그리게이트 패턴의 한계를 명심해야 한다.
[결론]
도메인 모델 패턴은 복잡한 비즈니스 로직을 다루는데 목적이 있다.
여기에는 세 개의 구성요소가 있다.
밸류 오브젝트
- 이것은 값만으로 식별되는 비즈니스 도메인의 개념이기 때문에 명시적인 ID 필드가 필요없다.
- 필드 중 하나가 변경되면 의미상 새로운 값을 생성하므로 불변이다.
애그리게이트
- 트랜잭션 경계를 공유하는 엔티티의 계층이다.
- 애그리게이트의 경계에 속하는 모든 데이터는 비즈니스 로직의 구현을 통해 강력한 일관성을 제공한다.
- 애그리게이트의 상태와 내부 객체는 애그리게이트의 커맨드를 실행하여 퍼블릭 인터페이스를 통해서만 수정 될 수 있다.
- 애그리게이트는 트랜잭션의 경계 역할을 한다. 즉, 내부 객체를 포함한 모든 데이터는 원자적인 단일 트랜잭션으로 데이터베이스에 커밋되어야 한다.
도메인 서비스
- 도메인 서비스란 도메인 모델에서 애그리게이트 또는 밸류 오브젝트에 속하지 않는 비즈니스 로직을 담는 상태가 없는 객체다.
이러한 도메인 모델 구성요소들은 경계 내의 밸류 오브젝트와 애그리게이트를 감싸서 비즈니스 로직의 복잡성을 다룬다.
'Book Notes' 카테고리의 다른 글
| [개발서적] 도메인 주도 설계 첫걸음 3부(ch10~11) 요약 (1) (0) | 2026.01.11 |
|---|---|
| [개발서적] 도메인 주도 설계 첫걸음 2부(ch7~9) 요약 (2) (0) | 2026.01.11 |
| [개발서적] 도메인 주도 설계 첫걸음 1부(ch3~4) 요약 (2) (0) | 2026.01.10 |
| [개발서적] 도메인 주도 설계 첫걸음 1부(ch1~2) 요약 (1) (0) | 2026.01.10 |
| [개발서적] Clean Architecture 6부(ch30~34) 요약 (4) (0) | 2025.12.15 |