CQRS Documents by Greg Young (한글 번역 및 해설)
이 글은 CQRS(Command and Query Responsibility Segregation)라는 용어를 처음 정립한 Greg Young이 cqrsinfo.com을 통해 공개한 원문 “CQRS Documents”(전 56페이지)을 한글로 옮기고, 각 절의 맥락을 풀어 설명한 완역 해설입니다. 단순한 직역이 아니라 실무 개발자가 읽었을 때 자연스럽게 이해되도록 문맥과 행간까지 고려하여 번역했으며, 원문의 페이지 번호와 Figure·Listing 번호를 그대로 표기해 원본과 대조하며 읽을 수 있도록 했습니다.
56
원문 페이지
6
주요 장
28
Figure · Listing
100%
문맥 번역
ℹ️ 이 번역에 대해
원문 저자: Greg Young / 출처: http://cqrsinfo.com / 원문은 해당 사이트를 통해 무료로 배포되었습니다. 본 번역은 한국 독자를 위한 학습 목적이며, 원문의 흐름을 따라가되 우리말로 자연스러운 표현을 택했습니다. 용어는 가급적 영어 원어(CQRS, Aggregate, Event Sourcing 등)를 유지했습니다.
원문의 전체 목차
- A Stereotypical Architecture (판에 박힌 아키텍처) — p.2
- Task Based User Interface (태스크 기반 UI) — p.9
- Command and Query Responsibility Segregation (CQRS 본론) — p.17
- Events as a Storage Mechanism (저장 메커니즘으로서의 이벤트) — p.25
- Building an Event Storage (이벤트 스토리지 구축) — p.41
- CQRS and Event Sourcing (CQRS와 이벤트 소싱의 결합) — p.50
1장. 판에 박힌 아키텍처 (A Stereotypical Architecture)
원문 p.2Domain Driven Design(DDD) 기반 프로젝트의 아키텍처를 논하기 전에, 많은 팀이 프로젝트에 적용하려는 “표준적이라 여겨지는 아키텍처”가 무엇인지 먼저 분석하는 것이 중요합니다. 거기서 출발해, 더 나은 아키텍처로 한 걸음씩 합리적으로 개선하되 각 단계에서 생산성 측면의 비용은 최소화하는 방향을 취해야 합니다. 원문은 이 대표적 아키텍처의 다이어그램을 먼저 제시합니다.
[Figure 1]
데이터 저장소(Data Storage) 위에 Domain Object들이 있고, 그 위를 Application Services와 Remote Facade가 감싼 구조. 클라이언트는 네트워크를 통해 DTO를 요청(Request DTO/DTO returned)하거나 변경된 DTO를 업로드(Send up DTO, Status returned)한다.
애플리케이션 서버 (Application Server)
이 아키텍처는 백엔드 데이터 저장소를 중심으로 구성됩니다. 일반적으로 RDBMS지만 반드시 그럴 필요는 없으며, key/value 스토어, 객체 데이터베이스, 심지어 평범한 XML 파일이어도 무방합니다. 중요한 것은 이 백엔드가 도메인 객체들의 현재 상태(current state)를 표현한다는 점입니다.원문 p.3
그 위에 Application Server가 있고, Figure 1에서 “domain”이라고 표시된 로직 영역이 시스템의 비즈니스 로직을 담습니다. 이 영역에서 Application Server에 들어온 요청을 처리하기 위한 검증(validation)과 오케스트레이션(orchestration)이 일어납니다. 원문은 여기서 한 가지 중요한 단서를 답니다. Figure 1에는 데이터 티어가 별도로 그려져 있지 않지만, Application Server와 Data Storage 사이에 데이터 티어를 둘 수도 있습니다. 또한 “domain”이라는 표현을 썼지만 이 아키텍처를 구현하기 위해 반드시 DDD식 도메인이 있어야 하는 것은 아니며, Table Module이나 Transaction Script 같은 다른 패턴을 써도 됩니다. 그 경우 Application Services만 존재 하는 형태가 됩니다.
도메인을 추상화한 앞단에는 Application Services라는 파사드가 있습니다. Application Services는 도메인과 그 하부 데이터에 대한 단순한 인터페이스를 제공하며, 도메인 소비자와 도메인 자체 간의 결합도를 낮추는 역할도 합니다. Application Server의 바깥에는 어떤 형태로든 Remote Facade가 자리합니다. SOAP, 커스텀 TCP/IP, HTTP 위의 XML, TomCat, 심지어 사람이 비둘기 다리에 묶인 메시지를 손으로 입력하는 방식까지 — 원문은 농담 섞어 “무엇이든 될 수 있다”고 말합니다. 상황과 도구에 따라 Remote Facade가 그 하부 기술과 분리되어 있을 수도, 그렇지 않을 수도 있습니다.
이처럼 Application Server로 데이터 저장소를 추상화하고 비즈니스 로직을 한 곳에 모으는 방식은 수년간 매우 대중화되었으며, 이 문서가 작성된 시점(원문 기준)에는 많은 상황에서 사실상의 “기본 아키텍처”로 여겨집니다.
클라이언트 상호작용 (Client Interaction)
Application Server와 상호작용하는 쪽에는 하나 혹은 여러 개의 클라이언트가 있습니다. 클라이언트의 일반적 상호작용은 Figure 2에서 확인할 수 있습니다.원문 p.4
[Figure 2]
클라이언트 상호작용 순환 — (1) 서버에 DTO를 요청하여 받아오고, (2) 화면에 DTO를 표시하고, (3) 사용자가 DTO와 상호작용(편집)하고, (4) DTO를 다시 서버에 전송한다(DTO Sent → Ack/Nak Response).
클라이언트의 기본 상호작용은 DTO(Data Transfer Object)의 업/다운상호작용으로 설명할 수 있습니다. API의 동작을 가장 쉽게 보여주는 방식은 한 번의 오퍼레이션의 라이프사이클을 따라가보는 것입니다. 사용자가 예컨대 고객을 편집하는 화면으로 이동합니다. 클라이언트는 Remote Facade에 Customer #id를 표현하는 DTO를 요청합니다. Remote Facade는 필요한 도메인 객체들을 로드하고, 이 도메인 객체를 DTO로 매핑한 뒤 클라이언트에 반환합니다. XML 형태의 DTO 예시는 Figure 3에서 확인할 수 있는데, 핵심은 이 판에 박힌 아키텍처에서의 DTO가 해당 객체의 현재 상태를 담고 있다는 점입니다.
클라이언트는 Remote Facade로부터 받은 정보를 화면에 표시해 사용자가 상호작용할 수 있게 합니다. 이는 종종 뷰 모델(view model)이나 데이터 바인딩을 통해 이뤄집니다. 어느 시점엔가 사용자가 화면에서 데이터 편집을 마치면 어떤 동작으로 UI에 “Save”가 트리거됩니다. 일반적으로는 Save 버튼으로 구현되지만, 어떤 UI는 현재 데이터를 “떠나는” 행위 자체가 저장을 강제하는 형태도 있습니다.원문 p.5
<Contact id="1234">
<Name>Greg Young</Name>
<Address>
<Street>111 Some St.</Street>
<City>Vernon</City>
<State>CT</State>
<Zip>06066</Zip>
<Country>USA</Country>
</Address>
</Contact>클라이언트 쪽의 Save 처리는 사용자가 편집한 데이터를 DTO로 다시 포장(보통 Remote Facade에서 표시용으로 받은 것과 동일한 DTO 형태)한 뒤 Application Server로 올려 보내는 과정입니다. Application Server는 DTO를 수신하면 트랜잭션/세션을 시작하고, DTO를 도메인 객체로 역매핑한 뒤, 도메인 객체가 변경 사항을 검증하도록 하고, 이를 ORM 같은 도구를 통해 데이터 저장소에 반영합니다. 이러한 ORM은 도메인 객체에서 무엇이 바뀌었는지를 식별하고 그에 맞게 데이터 저장소를 갱신해줍니다. 그리고 Application Server는 변경 사항이 영속화되었음을 알리는 ACK 또는 실패 이유를 담은 에러 목록을 클라이언트에 반환합니다.
이 아키텍처에 대한 분석
어떤 아키텍처든 여러 속성(property)을 가지며, 그 속성들은 특정 시나리오에서는 훌륭 하고 다른 상황에서는 치명적일 수 있습니다. 아키텍트는 이 속성들을 각자의 필요에 맞게 정렬하려 애써야 합니다.
단순성(Simplicity)
이 아키텍처가 대중화된 가장 큰 이유는 단순함입니다. 주니어 개발자에게도 매우 짧은 시간 안에 이 아키텍처로 만든 시스템을 다루는 법을 가르칠 수 있습니다. 게다가 완전히 범용적이어서 어떤 프로젝트에도 적용 가능합니다. 많은 팀이 이를 사용하고 있기 때문에, 새 팀원이 들어와도 일반적 구조에 친숙해 신규 인력 온보딩 비용을 낮출 수 있습니다. 이 두 가지의 결합 덕분에 팀은 이 아키텍처를 적용하는 데 극도로 능숙해지고, 그것을 기본값으로 삼게 됩니다. 마주하는 프로젝트의 80%에 “충분히 좋은(good enough)” 아키텍처라는 확신 때문에, 비기능 요구사항을 일일이 맞춰야 하는 고민이 사라지는 것입니다.원문 p.6
도구(Tooling)
이 아키텍처로 시스템을 만들 때의 시간을 줄여주는 수많은 프레임워크가 존재합니다. 가장 대표적인 것이 ORM입니다. ORM은 변경 추적(change tracking)과 복잡한 객체 그래프의 트랜잭션 관리 등 가치 있는 서비스를 제공합니다. 또 다른 예로는 도메인 객체와 DTO를 양방향으로 매핑해주는 자동 매핑 프레임워크가 있으며, 이로 인해 Application Server에서 DTO를 왔다 갔다 매핑하는 “배관 코드(plumbing code)”의 양이 크게 줄어듭니다.
DDD를 적용할 수 없다는 결정적 한계
⚠️ 핵심 주장
이 아키텍처의 가장 중요한 단점은 Domain Driven Design을 적용하는 것이 불가능하다는 점입니다.
많은 이들이 이 아키텍처 위에서 DDD를 “한다”고 말하지만, 왜 불가능한지는 Ubiquitous Language가 객체 모델에 어떻게 표현되는지를 한번 보면 곧바로 드러납니다. 이 아키텍처에는 네 가지 동사만 존재합니다(그리고 update 대신 edit 같은 동의어 정도). 바로 Create, Read, Update, Delete — 업계에서 흔히 CRUD라 부르는 것입니다. Remote Facade가 데이터 지향 인터페이스를 가지고 있기 때문에 Application Services도 필연적으로 같은 인터페이스를 가집니다.
이는 도메인 내부에도 다른 동사가 없다는 뜻입니다. 도메인 전문가와 대화하여 Ubiquitous Language를 다듬으려 할 때, 결국 이 네 가지 동사로 귀결되는 경우는 극히 드뭅니다. 여기에는 Anemic Model이라는 잘 알려진 안티 패턴이 관련되어 있습니다.
“Anemic Domain Model의 기본 증상은 얼핏 보면 진짜 도메인 모델처럼 보인다는 것입니다. 객체가 있고, 대개 도메인 공간의 명사에서 이름을 따오며, 진짜 도메인 모델이 갖는 풍부한 관계와 구조로 연결되어 있습니다. 그러나 행동을 들여다보면, 이 객체들에는 거의 아무런 행위가 없다는 사실을 깨닫게 됩니다. 그저 getter와 setter의 가방에 불과한 것이죠. 실제로 이런 모델은 대개 도메인 로직을 도메인 객체에 넣지 말라는 설계 규칙을 달고 옵니다. 대신 도메인 로직을 붙잡는 서비스 객체들이 존재합니다. 이 서비스들은 도메인 모델 위에 살면서, 도메인 모델을 데이터처럼 사용합니다.” (Fowler, 2003)
이 아키텍처가 만들어 내는 모델은 처음엔 Anemic Domain Model처럼 들립니다. Application Services가 DTO와 데이터를 주고받으며 매핑하기 때문에 도메인 객체에는 행동이 거의 없고, 매핑 과정에 쓰이는 getter와 setter로 도배되어 있습니다. 객체들 사이의 관계를 보여주는 구조는 있지만…원문 p.7
사실 이 아키텍처에서는 Anemic Domain Model조차 만들 수 없습니다. Anemic Model이라면 최소한 비즈니스 로직이 서비스에 들어 있어야 하지만, 여기서는 서비스 자체가 그저 DTO를 도메인 객체로 매핑하는 역할만 합니다. 실제 비즈니스 로직은 도메인에도, Application Server에도 존재하지 않으며, 클라이언트에, 혹은 더 흔하게는 매뉴얼의 종이쪽이나 시스템 사용자의 머릿속에 존재합니다. 이런 아키텍처는 “직원의 성별을 바꾸려면 그 후 건강보험 정보도 가서 수정해야 한다” 같은 방식의 작업 절차 지시서를 동반하는 경우가 흔합니다. Anemic Model보다도 나쁜, 잘 꾸며진 엑셀 스프레드시트에 가까운 것의 탄생입니다.
확장성(Scaling)
확장성 관점에서 이 아키텍처를 보면 큰 병목이 보입니다. 바로 데이터 저장소입니다. 현재 90% 이상이 사용하는 RDBMS는 수평 확장이 되지 않으며, 수직 확장은 금세 비용이 감당하기 어려워집니다. 다만 대부분의 시스템은 확장이 필요하지 않다는 점도 중요합니다. 그러므로 확장성은 항상 심각한 문제는 아닙니다.
요약(Summary)
많은 프로젝트에 쓰이는 DTO 업/다운 아키텍처는 많은 애플리케이션에 적용 가능하며 단순성 측면에서 팀에 많은 이점을 제공합니다. 그러나 DDD 기반 프로젝트에는 사용할 수 없고, 억지로 적용하면 DDD 자체가 실패로 끝납니다. 이 아키텍처는 훌륭한 기준선 역할을 하며, 이 문서의 나머지는 이 기준선에서 출발해 비용을 제한하거나 제거하면서 단계마다 비즈니스 가치를 더하는 방향으로 점진적 개선을 이어갑니다.원문 p.8
인용 문헌: Fowler, M. (2003). MF Bliki: AnemicDomainModel. http://martinfowler.com/bliki/anemicdomainmodel
2장. 태스크 기반 UI (Task Based User Interface)
원문 p.9이 장은 태스크 기반 UI라는 개념을 소개하고 CRUD 스타일 UI와 비교합니다. 또한 API가 좀 더 태스크 지향 스타일로 바뀌었을 때 Application Server 내부에서 어떤 변화가 일어나는지 함께 살펴봅니다.
1장 “판에 박힌 아키텍처”의 가장 큰 문제 중 하나는 사용자의 의도(intent)가 사라진다는 점이었습니다. 클라이언트가 데이터 중심의 DTO를 Application Server와 주고받는 방식으로 상호작용하기 때문에, 도메인에는 어떤 동사도 들어올 수 없었고 결과적으로 도메인은 데이터 모델의 잘 꾸며진 추상화가 되어버립니다. 행위(behavior)가 존재하지 않고, 존재한다고 해도 클라이언트에, 혹은 종이 문서나 사용자 머릿속에 있을 뿐입니다.
이런 애플리케이션은 흔합니다. 사용자에게는 문서화된 “업무 흐름(work flow)” 정보가 주어집니다. “xyz 화면에 가서 foo를 bar로 편집하고, 다음에 이 다른 화면에 가서 xyz를 abc로 편집하라” 같은 식이죠. 많은 시스템에서는 이런 워크플로가 문제가 되지 않습니다. 이런 시스템은 보통 비즈니스 가치도 낮습니다. 그러나 충분히 복잡하고, ROI가 높아 DDD를 적용할 만한 영역에서는 이런 식의 워크플로가 곧 다루기 어려워집니다.
이런 방식으로 시스템을 만들고 싶어 하는 흔한 이유는 “비즈니스 로직과 워크플로를 언제든 소프트웨어 변경 없이 원하는 대로 바꿀 수 있다”는 것입니다. 사실일 수 있지만, 그 대가를 물어야 합니다. 머릿속 프로세스에서 한 단계를 빼먹거나, 여러 사용자가 제각각 다르게 하는 경우(흔히 그렇습니다) 어떻게 될까요? 리포팅을 위해 시스템에서 말이 되는 정보를 어떻게 뽑을 수 있을까요?
이 문제를 다루는 한 방법은 1장에 있던 DTO 업/다운 아키텍처에서 벗어나는 것입니다. Figure 4는 DTO 업/다운 아키텍처의 클라이언트 상호작용 측면을 보여줍니다.원문 p.10
[Figure 4]
DTO Up/Down 아키텍처에서의 상호작용 — (1) Request DTO/DTO returned (2) Show DTO on Screen (3) User Interacts With DTO (4) Send DTO Back to Server → DTO Sent Ack/Nak Response 순환.
이 동작의 기본 설명은 UI가 Application Server에 예컨대 Customer 1234의 DTO를 요청 한다는 것입니다. 이 DTO가 클라이언트에 반환되어 화면에 표시됩니다. 사용자는 어떤 식으로든(직접 또는 View Model을 통해) DTO와 상호작용합니다. 결국 클라이언트가 Save를 누르거나 다른 트리거가 발생하면 클라이언트는 이 DTO를 Application Server로 다시 올려 보냅니다. Application Server는 내부적으로 데이터를 도메인 모델로 역매핑하고 변경을 저장한 뒤, 성공 또는 실패를 반환합니다.
앞서 말했듯, 이때 사용자의 의도는 사라집니다. 왜냐하면 클라이언트의 동작이 끝난 뒤의 객체의 현재 상태만 담긴 DTO가 올라가기 때문입니다. 의도를 앞으로 끌어올 수 있다면, Application Server는 “데이터를 저장”하는 대신 “행위를 처리”할 수 있게 됩니다. 다음 그림은 의도를 포착한 상호작용을 보여줍니다.
[Figure 5]
행동(Behavioral) 인터페이스 — (1) Request DTO/DTO returned (2) Show DTO on Screen (3) User Interacts With Screen, Build Messages (4) Send Messages → Message Sent Ack/Nak Response. 마지막 단계에서 단순 DTO가 아니라 메시지(Command)가 전송된다.
원문 p.11의도를 포착하는 클라이언트 상호작용은 상호작용 순환 관점에서 DTO 업/다운 방식과 매우 비슷합니다. 클라이언트는 먼저 Application Server에서 Customer 1234를 표현하는 DTO를 요청합니다. Application Server는 이 DTO를 반환하고 화면에 표시되어 사용자가 직접 또는 View Model을 통해 상호작용합니다. 그러나 유사성은 여기까지입니다. 사용자가 행동을 마친 뒤 동일한 DTO를 다시 올려 보내는 대신, 클라이언트는 Application Server에 “무언가를 하라”고 지시하는 메시지를 보내야 합니다. “Complete a Sale”, “Approve a Purchase Order”, “Submit a Loan Application” 같은 식입니다. 다시 말해 클라이언트는 Application Server에 사용자가 완료하고자 하는 태스크를 수행하라고 메시지로 지시해야 합니다. 이렇게 사용자가 무엇을 하고 싶어 하는지를 Application Server에 알려줌으로써 의도를 파악할 수 있습니다.
커맨드 (Commands)
Application Server에 무엇을 할지 지시하는 수단이 바로 Command입니다. Command는 오퍼레이션의 이름과 그 오퍼레이션을 수행하는 데 필요한 데이터를 담은 단순한 객체입니다. 많은 사람이 Command를 “직렬화 가능한 메서드 호출”로 생각합니다. Listing 1은 기본적인 Command 코드입니다.
public class DeactivateInventoryItemCommand {
public readonly Guid InventoryItemId;
public readonly string Comment;
public DeactivateInventoryItemCommand(Guid id, string comment) {
InventoryItemId = id;
Comment = comment;
}
}원문 p.12참고로 Listing 1의 예시에는 Command 이름 뒤에 패턴 이름이 붙어 있습니다. 이는 언어적으로도 운영적으로도 장단점이 있는 선택입니다. 클래스 이름에 패턴 이름을 쓸지는 개발팀이 가볍게 결정할 일이 아닙니다.
Command에서 중요한 한 가지 사실은 항상 명령형(imperative) 시제를 사용한다는 점입니다. 즉, Application Server에게 무언가를 하라고 지시하는 것입니다. Command의 언어적 표현은 중요합니다. 예컨대 이미 Sale 같은 일이 일어났는데 연결이 끊긴 클라이언트가 “SaleOccurred”라는 Command 객체를 올려 보내고 싶을 수 있습니다. 이를 분석할 때, 도메인이 “아니, 그 일은 일어나지 않았다”고 말할 수 있을까요? Command를 명령형으로 표현한다는 것은 언어적으로 Application Server가 그 Command를 거절할 권한이 있다는 뜻을 내포합니다. 반대로 거절할 수 없는 경우라면 그것은 Command가 아니라 Event입니다. 자세한 내용은 뒤의 “Events” 장을 참조하세요.
가끔 영어에는 재미있는 예가 있습니다. 대표적으로 “Purchase”는 명령형 동사로 쓸 수도 있고 그 결과를 묘사하는 명사로 쓸 수도 있습니다. 이런 상황을 다룰 때는, 밀어올리는 개념이 명사가 아닌 동사의 명령형을 나타내도록 해야 합니다. 예로 “purchase”를 보내야 한다면, 구입할 대상이 무엇인지 담고 구입이 일어난 시점 같은 일부 정보는 도메인이 채우도록 기대하는 형태여야 하며, 구입을 완전히 묘사하는 purchase DTO를 올려 보내는 식이 되어서는 안 됩니다.
Listing 1의 단순 Command에는 두 개의 데이터 속성이 있습니다. 이 Command가 적용될 InventoryItem의 Id와, 왜 비활성화하는지를 나타내는 comment입니다. comment는 Command에 딸린 전형적 속성이며, 이 행위를 처리하는 데 필요한 데이터의 조각입니다. Command에는 해당 행위를 처리하는 데 필요한 데이터만 존재해야 합니다. 이는 객체의 전체 데이터를 Application Server로 돌려보내는 판에 박힌 아키텍처와는 극명한 대조를 이룹니다.
무엇보다 중요한 데이터는 대상 인벤토리 아이템의 Id입니다. 상태를 갱신하는 모든 Command에는 최소 하나의 Id가 존재해야 합니다. 모든 Command는 결국 어떤 객체로 라우팅되어야 하기 때문입니다. Create 성격의 Command를 낼 때는 Id가 반드시 필요하지는 않지만 포함하면 가치가 있습니다. 분산 시스템에서는 클라이언트가 UUID 형태로 Id를 미리 발급하는 것이 특히 유용합니다.원문 p.13
개발자들은 Command를 배우기 시작하면 익숙한 어휘로 Command를 찍어내기 쉽습니다. “ChangeAddress”, “CreateUser”, “DeleteClass” 같은 것 말이죠. 이는 기본값으로 피해야 합니다. 대신 유스 케이스가 정말로 무엇인지에 초점을 맞추어야 합니다. “ChangeAddress”인가요? “주소를 정정하는 것”과 “고객을 이사시키는 것”은 다른가요? 만약 해당 도메인이 고객에게 옐로 페이지를 보내는 전화 회사라면 그 차이는 실제로 의미가 있을 가능성이 높습니다. “CreateUser” 인가요, 아니면 “RegisterUser”인가요? “DeleteClass”인가요, “DeregisterStudent”인가요? 이 네이밍 과정은 풍부한 도메인 통찰로 이어질 수 있습니다. Command를 정의하기에 가장 좋은 출발점은 유스 케이스를 정의하는 것입니다. 일반적으로 Command와 유스 케이스는 대응됩니다.
물론 특정 데이터에 존재하는 유스 케이스가 단지 “create”, “edit”, “update”, “change”, “delete”일 수도 있습니다. 모든 애플리케이션은 단순히 다른 부분을 보조하는 정보도 담게 마련입니다. 다만, 의도가 있는 유스 케이스가 있는 자리까지 CRUD로 착각하는 함정에 빠지지 않아야 합니다.
Command라는 개념 자체는 어렵지 않지만, 많은 개발자에게는 낯섭니다. Command를 만드는 일을 “추가 작업”으로 여기는 개발자도 많습니다. 워크플로에서 Command 생성이 병목이 된다면, 그건 여기서 논의되는 아이디어들이 잘못된 곳에 적용되고 있을 가능성이 높습니다.
사용자 인터페이스 (User Interface)
Command를 구성하려면 UI는 DTO 업/다운 시스템과는 다르게 동작해야 합니다. UI가 Command 객체를 만들어야 하므로, 사용자의 의도가 사용자 행동에서 도출될 수 있도록 설계되어야 합니다.
이 문제를 푸는 방식은 Microsoft가 “Inductive UI”라 부르는 “태스크 기반 UI” 쪽으로 기우는 것입니다. 이 UI 스타일은 어떤 의미로도 새롭지 않으며, UI 설계에 대한 꽤 다른 관점을 제시합니다. Microsoft는 Inductive UI를 연구하면서 Deductive UI의 세 가지 주요 문제를 지적했습니다.
사용자는 제품의 적절한 멘탈 모델을 구성하지 못하는 것 같다.
대부분의 현재 소프트웨어 제품의 인터페이스 설계는, 사용자가 설계자가 세심히 만든 개념 모델을 이해할 것이라고 가정합니다. 불행히도 대부분의 사용자는 자신의 탐색을 인도하기에 충분히 철저하고 정확한 멘탈 모델을 결코 얻지 못하는 것처럼 보입니다. 그들이 멍청해서가 아니라, 단지 매우 바쁘고 정보에 압도되어 있기 때문입니다. 이들은 자신의 소프트웨어를 위한 개념 모델을 고민할 시간도, 에너지도, 욕망도 없습니다.
많은 장기 사용자조차 일반적 절차를 숙달하지 못한다.
설계자는 신규 사용자가 초반에 어려움을 겪더라도 일반적 태스크를 배우면 해소될 것이 라 기대하지만, 사용성 데이터는 종종 그렇지 않다는 점을 보여줍니다. 한 연구에서 연구자들은 집에서 사용자를 자동 녹화했는데, 테이프에는 사용자가 당면한 태스크에 집중하면서 자신이 따르는 절차를 반드시 의식하지는 않는 모습이 담겼습니다. 다음 번에 같은 작업을 할 때도, 그들은 정확히 똑같이 더듬거리며 지나갑니다.
사용자는 각 기능이나 화면을 파악하기 위해 열심히 애써야 한다.
대부분의 소프트웨어 제품은 개념 모델을 이해하고 일반 절차를 숙달한 (소수의) 사용자를 위해 설계되어 있습니다. 다수의 고객에게 각 기능이나 절차는 좌절스럽고 원치 않는 퍼즐입니다. 사용자는 이런 퍼즐을 컴퓨터를 사용하는 피할 수 없는 비용으로 여길 수 있지만, 분명히 이 짐 없이 더 행복해할 것입니다. (Microsoft Corporation, 2001)
원문 p.14태스크 기반 또는 Inductive UI의 기본 아이디어는, 사용자가 소프트웨어를 어떻게 사용하고 싶어 하는지를 파악해 UI가 그 프로세스를 따라가도록 안내하는 것이 중요하다는 점입니다.
“많은 상용 소프트웨어 애플리케이션은, 화면에 컨트롤 한 묶음을 제시해 놓고 페이지의 목적과 해당 컨트롤로 그 목적을 달성하는 방법을 알아내는 일을 사용자에게 떠넘기는 UI를 포함합니다.” (Microsoft Corporation, 2001)
목표는 사용자를 프로세스를 따라 안내하는 것입니다. 앞서 본 DeactivateInventoryItem 예로 그 차이를 볼 수 있습니다. 전형적인 Deductive UI는 인벤토리 아이템 전부가 들어 있는 편집 가능한 데이터 그리드를 보여주며, 여러 데이터의 편집 가능한 필드와 인벤토리 아이템 상태(그 중 하나가 Deactivated)의 드롭다운이 있을 것입니다. 어떤 아이템을 비활성화하려면 사용자는 그리드의 해당 행을 찾아 비활성화 사유 코멘트를 입력하고 드롭다운을 “Deactivated”로 바꿔야 합니다. 비슷한 예로 편집 화면으로 들어가 같은 과정을 거치는 경우도 있습니다.
[Figure 6]
CRUD 방식 Inventory Item 편집 화면 — Name, Supplier, Description, Supplier Cost, Count, Status, Deactivation Comment 필드가 한꺼번에 노출된 폼.
사용자가 “deactivated”로 바꾸고 코멘트를 입력하지 않고 제출하면, 비활성화된 아이템에서는 필수 필드라는 에러 메시지를 받게 됩니다. 좀 더 친절한 UI는 사용자가 드롭다운에서 deactivated를 선택했을 때에만 코멘트 필드를 노출할 수도 있습니다. 이것만으로도 사용자가 그 필드에 데이터를 입력해야 한다는 신호가 되어 한결 직관적입니다. 하지만 더 잘할 수 있습니다.원문 p.15
[Figure 7]
리스팅 화면 + 링크 — Name / Supplier / Active 컬럼의 인벤토리 아이템 목록에서 각 행 우측에 ‘Deactivate’ 링크가 배치된 태스크 지향 화면.
태스크 기반 UI는 다른 접근을 택합니다. 인벤토리 아이템 목록을 보여주고, 각 아이템 옆에 “Deactivate” 링크를 두는 식입니다. 그 링크는 비활성화 사유 코멘트를 묻는 별도 화면으로 사용자를 데려갑니다. 이렇게 하면 사용자의 의도가 명확하고, 소프트웨어가 인벤토리 아이템을 비활성화하는 과정을 안내합니다. 그리고 사용자의 의도를 담은 Command를 이 스타일의 인터페이스에서 구축하는 것도 매우 쉽습니다.
[Figure 8]
Inventory Item 비활성화 팝업 — ‘비활성화하는 이유를 설명하는 코멘트가 필요합니다’ 문구와 입력창, Cancel / Deactivate 버튼만 있는 초점화된 화면.
웹, 모바일, 특히 맥 UI는 태스크 기반 방향으로 흐르고 있습니다. UI가 프로세스를 안내하고 문맥에 맞는 가이드를 제공해 사용자를 올바른 방향으로 이끕니다. 이는 이 스타일이 훨씬 좋은 사용자 경험을 제공할 수 있기 때문입니다. 사용자가 어떻게, 왜 소프트웨어를 사용하는지에 확실히 집중하며, 사용자 경험이 프로세스의 필수 요소가 됩니다. 그 너머로, 일반적으로 사용자가 어떻게 소프트웨어를 쓰고 싶어 하는지에 더 집중하는 가치가 있으며, 이는 도메인의 동사를 정의하는 훌륭한 첫 걸음이 됩니다.원문 p.16
인용 문헌: Microsoft Corporation. (2001, Feb 9). Microsoft Inductive User Interface Guidelines. http://msdn.microsoft.com/en-us/library/ms997506
3장. Command and Query Responsibility Segregation
원문 p.17이 장은 Command and Query Responsibility Segregation(CQRS) 개념을 소개합니다. 시스템 에서 역할을 분리하는 것이 어떻게 훨씬 효과적인 아키텍처로 이어지는지 살펴보고, CQRS가 적용된 시스템에 존재하는 여러 아키텍처 속성도 분석합니다.
기원 (Origins)
CQRS는 Bertrand Meyer의 Command and Query Separation 원칙에서 비롯되었습니다. 위키피디아는 이 원칙을 다음과 같이 정의합니다.
“모든 메서드는 동작을 수행하는 Command이거나, 호출자에게 데이터를 돌려주는 Query여야 하며, 둘 모두일 수는 없다. 다시 말해, 질문을 던지는 것이 답을 바꿔서는 안 된다. 더 형식적으로, 메서드는 참조 투명(referentially transparent)할 때에만 값을 반환해야 하며, 따라서 부수효과가 없어야 한다.” (Wikipedia)
본질적으로는 이렇게 요약됩니다. 반환값이 있다면 상태를 변경할 수 없고, 상태를 변경 한다면 반환 타입은 반드시 void여야 한다는 것입니다. 이 원칙에는 몇 가지 이슈가 있을 수 있습니다. Martin Fowler는 자신의 bliki에서 한 가지 예를 제시합니다.
“Meyer는 command-query 분리를 절대적으로 쓰길 좋아하지만, 예외도 있다. 스택을 pop 하는 것은 상태를 변경하는 수정자의 좋은 예다. Meyer는 이 메서드를 피할 수 있다고 올바르게 말하지만, 이는 유용한 관용구다. 그래서 나는 가능하면 이 원칙을 따르되, pop 하나 얻자고 그것을 깰 준비가 되어 있다.” (Fowler)
CQRS는 원래 이 개념의 확장 정도로 여겨졌습니다. 오랫동안 더 높은 층위의 CQS 정도로 단순히 논의되었지요. 두 개념이 혼동되는 것을 겪은 뒤, 비로소 다른 패턴으로 올바로 인식되기 시작했습니다.
CQRS는 Meyer가 사용한 것과 같은 Command와 Query의 정의를 사용하며, 이들이 순수해야 한다는 관점도 유지합니다. 근본적인 차이는, CQRS에서는 객체가 두 개의 객체로 나뉜다는 점입니다 — 하나는 Command를, 다른 하나는 Query를 담당합니다. 이 패턴은 그 자체로는 별로 흥미롭지 않지만, 아키텍처적 관점에서 볼 때 매우 흥미로워집니다.원문 p.18
[Figure 9]
판에 박힌 아키텍처 재표시 — 1장에서 논의한 구조. 서비스 계층이 Command와 Query를 모두 처리한다.
Figure 9는 1장에서 다룬 판에 박힌 아키텍처를 그대로 담고 있습니다. 이 아키텍처의 핵심은 서비스가 Command와 Query를 모두 처리한다는 것입니다. 대개의 경우 도메인도 Command와 Query 양쪽에 함께 사용되고 있습니다. 이 아키텍처에 CQRS를 적용하는 것 자체는 정의상 매우 단순하지만, 아키텍처적 기회를 급격히 바꿉니다. 변환 대상이 되는 간단한 서비스는 Listing 2와 같습니다.
CustomerService
void MakeCustomerPreferred(CustomerId)
Customer GetCustomer(CustomerId)
CustomerSet GetCustomersWithName(Name)
CustomerSet GetPreferredCustomers()
void ChangeCustomerLocale(CustomerId, NewLocale)
void CreateCustomer(Customer)
void EditCustomerDetails(CustomerDetails)원문 p.19CustomerService에 CQRS를 적용하면 Listing 3에서처럼 두 개의 서비스가 됩니다.
CustomerWriteService
void MakeCustomerPreferred(CustomerId)
void ChangeCustomerLocale(CustomerId, NewLocale)
void CreateCustomer(Customer)
void EditCustomerDetails(CustomerDetails)
CustomerReadService
Customer GetCustomer(CustomerId)
CustomerSet GetCustomersWithName(Name)
CustomerSet GetPreferredCustomers()비교적 단순한 과정이지만, 판에 박힌 아키텍처에 존재하던 여러 문제를 해결합니다. 서비스가 읽기와 쓰기(또는 Command와 Query) 두 개로 분리되었습니다. 이 분리는 Command 측과 Query 측의 요구가 매우 다르다는 사실을 강제합니다. 각 측 유스 케이스의 아키텍처 속성도 상당히 다릅니다. 몇 가지만 짚어보면 다음과 같습니다.
일관성 (Consistency)
Command: 결과적 일관성이 초래하는 엣지 케이스를 모두 다루기보다는, 일관된 데이터로 트랜잭션을 처리하는 것이 훨씬 쉽습니다.
Query: 대부분의 시스템은 Query 측에서 결과적으로 일관(eventually consistent)해도 괜찮습니다.
데이터 저장 (Data Storage)
Command: 관계형 구조에서 트랜잭션 처리 역할을 맡는 Command 측은 데이터를 정규화된 방식(대개 3차 정규형, 3NF 근방)으로 저장하고 싶어 할 것입니다.
Query: Query 측은 데이터를 비정규화된 방식으로 갖고, 필요한 집합을 얻는 데 조인 수를 최소화하고 싶어 할 것입니다. 관계형 구조에서는 대개 1차 정규형(1NF) 에 가깝습니다.
확장성 (Scalability)
Command: 대부분의 시스템, 특히 웹 시스템에서 Command 측은 전체에서 매우 작은 비율의 트랜잭션을 처리합니다. 따라서 확장성이 항상 중요한 것은 아닙니다.
Query: 대부분의 시스템, 특히 웹 시스템에서 Query 측은 전체 트랜잭션 중 매우 큰 비율을 처리합니다(종종 2배 이상의 차수 차이). 확장성이 필요한 곳은 거의 Query 측입니다.
하나의 모델로 검색·리포팅·트랜잭션 처리에 대한 최적 솔루션을 만드는 것은 불가능합니다.
Query 측 (The Query Side)
원문 p.20앞서 말한 대로, Query 측은 데이터를 가져오는 메서드만 담습니다. 기존 아키텍처 기준 으로는 클라이언트가 화면에 표시하기 위해 소비하는 DTO를 반환하는 모든 메서드가 이에 해당합니다.
기존 아키텍처에서 DTO 생성은 도메인 객체에서 투영(projection)하는 방식으로 처리 되었습니다. 이 과정은 많은 고통의 원천이 될 수 있습니다. 가장 큰 고통은 DTO가 도메인과 다른 모델이라는 점이며, 그래서 매핑이 필요합니다. DTO는 서버와의 다중 왕복을 방지하기 위해 클라이언트 화면에 맞춰 최적으로 만들어집니다. 많은 클라이언트 가 존재하는 경우엔 모든 클라이언트가 공통으로 쓸 정규(canonical) 모델을 만드는 편이 더 나을 수 있습니다. 어느 쪽이든 DTO 모델은 트랜잭션을 처리하기 위해 만든 도메인 모델과는 매우 다릅니다.
문제의 흔한 냄새는 여러 도메인에서 발견할 수 있습니다.
- 리포지토리에 페이징/정렬 정보까지 포함한 수많은 read 메서드가 생깁니다.
- DTO를 만들기 위해 도메인 객체의 내부 상태를 노출하는 getter가 생깁니다.
- ORM이 더 많은 데이터를 로드해야 하므로 read 유스 케이스에서 prefetch path를 사용하게 됩니다.
- DTO를 만들기 위해 여러 Aggregate Root를 로드하면 데이터 모델에 대한 최적이 아닌 쿼리가 발생하거나, DTO 구축 과정 때문에 Aggregate 경계가 혼란스러워지기도 합니다.
가장 큰 냄새는 쿼리 최적화가 극도로 어렵다는 점입니다. 쿼리가 객체 모델 위에서 동작하고, 그것이 ORM 등을 거쳐 데이터 모델로 번역되기 때문에 이 쿼리들을 최적화하기가 어려워집니다. 개발자는 ORM과 DB를 깊이 이해해야 하며, Impedance Mismatch 문제를 다루게 됩니다(자세한 내용은 “Events as a Storage Mechanism” 참조).
CQRS를 적용한 뒤에는 자연스러운 경계가 생깁니다. 분리된 경로가 명시적이 되었으므로, 이제는 DTO 투영에 도메인을 쓰지 않는 선택이 매우 합리적입니다. 대신 새로운 DTO 투영 방식을 도입할 수 있습니다.원문 p.21
[Figure 10]
Query 측 — Data Storage 위에 Thin Read Layer(Direct to DTO)와 Remote Facade가 있고, 클라이언트는 Request DTO/DTO returned 한 방향으로만 상호작용한다. 도메인을 우회한다.
도메인이 우회되었습니다. 이제 Thin Read Layer라는 새로운 개념이 있습니다. 이 레이어는 DB에서 직접 읽고 DTO를 투영합니다. 이를 구현하는 방법은 많은데, 저수준에서는 수작업 ADO.NET과 매핑 코드부터, 고수준에서는 본격적인 ORM까지 가능합니다. 어느 것이 옳은가는 팀 자체와 팀이 가장 편하게 느끼는 것에 크게 좌우됩니다. 아마 최적은 그 사이 어딘가일 겁니다. ORM이 제공하는 많은 것이 이 용도에는 필요 없고, 수작업 매핑은 시간을 크게 잡아먹기 때문입니다. 작은 컨벤션 기반 매핑 유틸리티를 쓰는 것이 가능한 해법입니다.
Thin Read Layer는 데이터베이스로부터 격리될 필요가 없습니다. 읽기 레이어에서 DB 벤더에 묶이는 것도 반드시 나쁜 일이 아니며, 읽기에 저장 프로시저를 쓰는 것도 나쁘지 않습니다. 역시 팀과 시스템의 비기능 요구에 따릅니다.
Thin Read Layer는 복잡한 코드 조각이 아닙니다(유지보수가 지루할 수는 있습니다). 별도 읽기 레이어의 한 가지 이점은 Impedance Mismatch를 겪지 않는다는 점입니다. 데이터 모델에 직접 연결되어 있어 쿼리 최적화가 훨씬 쉬워집니다. Query 측에 참여하는 개발자는 도메인 모델이나 ORM 도구를 이해할 필요가 없고, 가장 단순하게는 데이터 모델만 이해하면 됩니다.원문 p.22
Thin Read Layer의 분리와 읽기에서 도메인을 우회하는 것은 도메인의 전문화(specialization) 도 가능하게 합니다.
Command 측 (The Command Side)
전반적으로 Command 측은 “판에 박힌 아키텍처”와 매우 유사한 모습을 유지 합니다. 주요 차이는 이제 행동 중심 계약을 갖게 되었다는 점(DDD를 실제로 쓰기 위해 필요했던 부분)과, 읽기가 분리되어 있다는 점입니다.
[Figure 11]
Command 측 — Data Storage 위에 Domain Object들과 Application Services, Remote Facade가 있다. 클라이언트와의 상호작용은 Message Sent, Ack/Nak Response 한 방향이다(읽기는 Figure 10의 경로로 분리됨).
원문 p.23판에 박힌 아키텍처에서는 도메인이 Command와 Query 둘 다 처리했기 때문에 도메인 자체에 여러 문제가 발생했습니다. 그 일부는 다음과 같습니다.
- 리포지토리에 페이징/정렬 정보를 포함한 많은 read 메서드.
- DTO 구축을 위해 도메인 객체의 내부 상태를 노출하는 getter.
- read 유스 케이스에서 ORM이 더 많은 데이터를 로드하도록 강제하는 prefetch path.
- DTO 구축을 위해 여러 Aggregate를 로드하는 비최적 쿼리 또는 DTO 구축으로 혼란스러워진 Aggregate 경계.
읽기 레이어를 분리하면 도메인은 오직 Command 처리에만 집중하게 됩니다. 이 문제들은 돌연 사라집니다. 도메인 객체는 내부 상태를 더 이상 노출할 필요가 없으며, 리포지토리의 쿼리 메서드는 GetById 외에는 거의 없거나 전혀 없어집니다. Aggregate 경계에 대해서도 더 행동 중심의 초점을 맞출 수 있습니다.
이 변경은 원래 아키텍처 대비 낮거나 없는 비용으로 이뤄집니다. 많은 경우 이 분리는 실제로 비용을 낮춥니다. Thin Read Layer에서의 쿼리 최적화가 도메인 모델에서보다 훨씬 간단하기 때문입니다. 또한 쿼리가 분리되므로 도메인 모델을 다룰 때 개념적 오버헤드가 낮아져 비용을 더 낮추는 방향으로 작용할 수 있습니다. 최악의 경우에도 비용은 서로 상쇄되어 동등해야 합니다. 사실 이뤄진 것은 단지 책임의 이동이므로, 읽기 측이 여전히 도메인을 쓰도록 하는 것도 충분히 가능합니다.
CQRS를 적용함으로써 읽기와 쓰기의 개념이 분리되었습니다. 이제 두 개가 같은 데이터 모델을 읽어야 하는가, 아니면 두 개의 통합된 시스템처럼 취급해도 되는가 하는 질문이 자연스레 떠오릅니다. Figure 12가 그 개념을 보여줍니다. 여러 데이터 소스 사이에 일관된 또는 결과적으로 일관된 방식으로 동기성을 유지하기 위한 잘 알려진 통합 패턴들이 있습니다. 두 개의 서로 다른 데이터 소스를 두면 각 작업에 맞게 데이터 모델을 최적화할 수 있습니다. 예컨대 Read 측은 1NF로, 트랜잭션 모델은 3NF로 모델링할 수 있습니다.
하지만 통합 모델의 선택은 매우 중요합니다. 모델 간의 변환과 동기화는 상당히 비싼 작업이 될 수 있기 때문입니다. 가장 잘 맞는 모델은 이벤트의 도입입니다. 이벤트는 잘 알려진 통합 패턴이며, 모델 동기화를 위한 최고의 메커니즘을 제공합니다.원문 p.24
[Figure 12]
CQRS로 분리된 데이터 모델 — 좌측은 Domain Object / Application Services / Remote Facade와 Data Storage, 우측은 Thin Read Layer와 Remote Facade 및 별도의 Data Storage. 두 Data Storage는 ‘Eventually’ 화살표로 연결되어 있다. 클라이언트에는 Command 전송(Message Sent)과 Query(Request DTO) 두 경로가 분리되어 존재한다.
4장. 저장 메커니즘으로서의 이벤트 (Events as a Storage Mechanism)
원문 p.25오늘날 프로덕션에 있는 대부분의 시스템은 트랜잭션을 처리하기 위해 “현재 상태”를 저장하는 방식에 의존합니다. 사실, 다른 방식으로 현재 상태를 유지하는 시스템에서 일해본 개발자를 만나는 건 드뭅니다. 하지만 항상 이랬던 것은 아닙니다.
RDBMS가 아키텍처의 중심으로 널리 받아들여지기 전에는 많은 시스템이 현재 상태를 저장 하지 않았습니다. 특히 고성능·미션 크리티컬·고보안 시스템에서 그랬습니다. 실제로 RDBMS 내부를 들여다보면 대부분의 RDBMS 자체가 현재 상태를 관리하는 방식으로 동작하지 않는다는 사실을 알게 됩니다.
이 장의 목표는 event sourcing 개념을 소개하고, 그 이점을 보여주며, 관계형 DB를 하부 데이터 관리로 활용해 단순한 이벤트 저장 시스템을 만드는 방법을 제시하는 것입니다.
도메인 이벤트란 무엇인가?
이벤트는 과거에 일어난 어떤 일(something that has happened in the past)입니다.
모든 이벤트는 과거 시제 동사로 표현되어야 합니다. 예컨대 CustomerRelocated, CargoShipped, InventoryLossageRecorded 같은 이름이지요. 프랑스어를 쓰는 사람에게는 Passé Composé가 되어야 합니다. 이들은 과거에 이미 완료된 일입니다. 영어에서는 어떤 것을 명사로 쓰고 싶은 유혹이 드는 흥미로운 예시도 있습니다. 예로 “Earthquake” 나 “Capsize” 같은 것들이죠(최근 한 의원이 괌을 걱정했던 일). 그러나 도메인 이벤트를 만들 때는 이런 이름을 쓰려는 유혹을 피하고 과거 시제 동사의 사용을 고수해야 합니다.
이벤트가 항상 과거 시제 동사여야 한다는 것은 절대적으로 중요합니다. 이벤트는 Ubiquitous Language의 일부이기 때문입니다. 고객을 이사시키는 일의 부수효과를 논할 때 Ubiquitous Language에서의 차이를 생각해 봅시다. 이벤트는 그 개념을 명시적으로 만들고, 이전에는 Aggregate 내부 혹은 여러 Aggregate 간의 변화가 암묵적 개념으로 남아 탐색하고 정의해야 했습니다. 예로, 대부분의 시스템에서 부수효과가 발생했다는 사실은 그저 Hibernate나 Entity Framework 같은 도구가 찾아내는 것이었습니다. 유스 케이스의 부수 효과가 변한다면, 그것은 암묵적 개념입니다. 이벤트의 도입은 그 개념을 명시적으로 만들고 언어의 일부로 만듭니다. 고객을 이사시키는 일은 단지 어떤 것을 바꾸는 것이 아니라, CustomerRelocatedEvent를 만들어 내는 것이며 이는 언어 내에서 명시적 으로 정의됩니다.
코드 관점에서 이벤트는 Listing 4에서 보듯 단순한 데이터 보관 구조입니다.원문 p.26
public class InventoryItemDeactivatedEvent {
public readonly Guid InventoryItemId;
public readonly string Comment;
public InventoryItemDeactivatedEvent(Guid id, string comment) {
InventoryItemId = id;
Comment = comment;
}
}이 코드는 앞서 Command에서 본 코드와 매우 유사해 보입니다. 주된 차이는 의미와 의도에 있습니다. Command는 시스템에 어떤 동작을 수행하라고 요청하는 의도를 가지며, Event는 이미 발생한 행위의 기록입니다.
다른 정의와 논의
이 설명과 관련된 개념이 Streamlined Object Modeling(SOM)에도 정의되어 있습니다. 많은 이들이 SOM에서 “The Event Principle”을 이야기할 때 “Domain Event” 라는 용어를 사용합니다.
“사람들이 어떤 장소에서 어떤 사물과 상호작용하는 사건(event)을 트랜잭션 객체로 모델링하라. 특정 시점의 상호작용은 하나의 타임스탬프를 가진 트랜잭션으로, 시간 구간의 상호작용은 여러 타임스탬프를 가진 트랜잭션으로 모델링하라.” (Jill Nicola, 2002, p.23)
많은 사람이 이 개념을 묘사하려 Domain Event라는 용어를 사용하지만, 이 문서의 맥락에서 Domain Event와 같은 정의가 아닙니다. SOM은 이 개념에 대해 객체가 무엇인지 더 잘 설명하는 다른 용어, 즉 Transaction을 사용합니다. Transaction 객체는 도메인에서 중요한 개념이며, 이름이 붙을 가치가 있습니다. 예로 야구 방망이를 휘두른 선수의 한 번의 동작은 특정 시점에 일어난 행동이며 그렇게 모델링되어야 합니다. 그러나 이것이 Domain Event와 동일한 것은 아닙니다.
이는 Martin Fowler가 예로 든 Domain Event의 정의와도 다릅니다.
“예: 나는 화요일에 Babur's에 식사하러 가서 신용카드로 결제한다. 이는 type이 ‘Make Purchase’, subject가 내 신용카드, occurred date가 화요일인 이벤트로 모델링될 수 있다. 만약 Babur's가 낡은 수동 시스템을 쓰고 금요일까지 트랜잭션을 전송하지 않는다면, noticed date는 금요일이 될 것이다.” (Fowler)
이어지는 부분은 다음과 같습니다.
“시스템의 입력을 Domain Event의 스트림으로 깔때기화함으로써 시스템의 모든 입력에 대한 기록을 유지할 수 있다. 이것은 처리 로직을 조직하는 데 도움이 되며, 시스템의 감사 로그를 유지할 수 있게 해준다.” (Fowler)
원문 p.27눈썰미 있는 독자는 Fowler가 여기서 실제로 묘사하는 것이 앞서 Task Based UI에서 논의 했던 Command임을 알아차릴 것입니다. “Make Purchase”라는 언어는 잘못 되었습니다. PurchaseMade 이벤트를 도입하는 편이 훨씬 낫습니다. Martin은 매장에서 실제로 구입했고, 신용카드가 실제로 청구되었으며, 그는 음식을 맛있게 먹었을 가능성이 높습니다. 이 모든 일은 이미 과거 시제로 일어난 일이며 되돌릴 수 없습니다.
이런 예시는 시스템 내부에서 이차 문제도 유발합니다. 도메인이 이벤트의 일부를 채워 넣는 책임을 지게 될 수 있다는 점입니다. 판매가 도메인에서 처리되는 시스템을 생각해 봅시다. 예컨대 판매세(sales tax)가 얼마인가? 도메인이 이를 계산의 일부로 다루는 경우가 흔합니다. 이는 이벤트의 이중 정의를 야기합니다. 클라이언트가 보낸 판매세 없는 이벤트가 존재하고, 도메인은 그것을 받아 판매세를 더해야 합니다. 이는 같은 이벤트를 여러 정의로 가지게 하며, 일부 속성에 변경 가능성(mutability)을 강제합니다. 이중 이벤트로 이 문제를 우회할 수 있지만(하나는 클라이언트가 제공한 것, 다른 하나는 도메인이 채운 것) 본질적으로 Command/Event 모델이며 언어적 문제도 여전히 존재합니다.
이런 언어적 문제의 또 다른 예는 에러 상황에서 볼 수 있습니다. 클라이언트가 어떤 것을 하라고 도메인에 말했는데 도메인이 그것을 할 수 없다는 사실을 어떻게 처리해야 할까요? 예컨대 클라이언트가 이벤트를 올바른 방식으로 출처할 충분한 정보를 단순히 가지고 있지 않은 단순한 경우를 생각해 봅시다. 언어적으로 Command/Event 분리는 훨씬 말이 됩니다. “Place Sale”이라는 명령형으로 Command가 오고, “SaleCompleted” 같은 과거 시제로 이벤트가 도착하는 편이 자연스럽습니다. 도메인이 “Place a sale”이라는 클라이언트 시도를 거절하는 것은 매우 자연스럽지만, 과거 시제로 이미 일어난 어떤 일이 사실은 일어나지 않았다고 클라이언트에게 말하는 건 부자연스럽습니다. 도메인 전문가와의 대화를 상상해 봅시다. 도메인에 타임머신이 있을까요? 대부분의 비즈 니스 시스템에서 평행 현실(parallel realities)을 모델링하는 것은 지나치게 복잡하고 비쌉니다.
이것이 바로 Command와 Event 개념의 분리로 이어진 문제들입니다. 이 분리는 언어를 훨씬 명확하게 하고, 사용되는 언어에만 기반해 문맥을 명확히 이해하도록 개발자를 이끕니다. 개념의 이중 정의는 개발자가 문맥을 인식하고 구분하게 만들며, 그 무게는 프로젝트 새 인력의 온보딩 시간과, 팀원이 늘 기억해야 하는 부담으로 번역됩니다. 문맥을 구별해야 할 때마다 간과되거나 다른 문맥으로 오해될 확률이 더 높아집니다. 언어에서 명시적으로 표현하고 이중 정의를 피하는 것은, 도메인 전문가, 개발자, 그리고 API를 소비하는 누구에게든 더 명확하게 만드는 데 도움이 됩니다.
저장 메커니즘으로서의 이벤트
대부분의 사람이 객체의 저장을 고려할 때, 그것을 구조적(structural) 관점에서 생각합니다. 앞서 논의한 “sale”을 어떻게 저장해야 하는가를 고려하면, 그들은 그것을 “Line Items”와 연관된 “Shipping Information”을 가진 “Sale”로 저장된다고 생각합니다. 그러나 이 문제를 개념화할 수 있는 방법은 이것만이 아니며, 다른 해법은 다르면서도 종종 흥미로운 아키텍처 속성을 제공합니다.원문 p.28
[Figure 13]
Order의 구조적 관점 — Purchase Order가 Line Items(n개)와 Shipping Information을 가리킨다. 전통적인 구조 중심 모델링의 전형.
웹 기반 판매 시스템에서 작은 Order 객체의 생성을 잠시 생각해 봅시다. 대부분의 개발자는 Figure 13과 비슷한 것을 떠올릴 것입니다. 이는 Order가 무엇인지에 대한 구조적 관점 입니다. Order는 n개의 Line Items와 Shipping Information을 가집니다. 이는 Order의 지나치게 단순화된 관점이지만, 초점이 주문의 구조와 그 부분들에 있다는 점을 볼 수 있습니다.
그러나 이 데이터를 보는 방식은 이것뿐이 아닙니다. 앞서 트랜잭션이라는 개념이 논의 되었습니다. 개발자는 트랜잭션을 정기적으로 다루며, 이를 한 시점과 다음 시점 사이의 변경을 나타내는 것으로 볼 수 있습니다. 이들은 흔히 Delta라고도 불립니다. Delta는 두 개의 정적 상태 사이에 항상 정의될 수 있지만, 대개는 암묵적 개념 으로 남아 자바 진영의 Hibernate나 Microsoft 진영의 Entity Framework 같은 프레임워크에 위임됩니다. 이 프레임워크들은 원본 상태를 저장하고 새 상태와의 차이를 계산해 기반 데이터 모델을 그에 맞게 갱신합니다. 이 Delta를 명시적으로 만드는 것은 기술적으로도, 더 중요하게는 비즈니스적으로도 매우 가치 있을 수 있습니다.
이러한 Delta의 활용은 많은 성숙한 비즈니스 모델에서 볼 수 있습니다. 정규적 예시는 회계 분야입니다. Figure 14 같은 원장(ledger)을 보면, 각 트랜잭션(Delta)이 기록됩니다. 그 옆에는 그 Delta 이후 계좌 상태의 비정규화 합계가 있습니다. 이 숫자를 계산하려면 현재 Delta를 마지막으로 알려진 값에 적용하면 됩니다. 마지막으로 알려진 값은 신뢰할 수 있습니다. 어느 시점에도 “태초부터”의 트랜잭션을 다시 재생해 그 값의 유효성을 검증할 수 있기 때문입니다. 이로써 검증 가능한 감사 로그가 존재하게 됩니다.원문 p.29
[Figure 14]
단순화된 원장 — Date / Comment / Change / Current Balance 컬럼. 예: 1/1/2000 Deposit from 1372 +10000.00 → 10000.00, 1/3/2000 Check 1 -4000.00 → 6000.00, 1/4/2000 Purchase Coffee -3.00 → 5997.00 식으로 각 Delta가 누적된다.
계좌와 관련된 모든 트랜잭션(Delta)이 존재하기 때문에, 이를 하나씩 훑어 결과를 검증할 수 있습니다. 어느 시점의 “Current Balance”는 그 시점의 “Current Balance”를 보거나, 태초부터의 모든 “Changes”를 합산해 도출할 수 있습니다. 두 번째 속성은 회계 같은 도메인에서 명백히 가치 있습니다. 회계사는 돈을 다루며, 계산이 올바르게 수행되었는지 확인할 수 있는 능력은 극도로 가치 있습니다. 컴퓨터가 흔하기 전에는 새벽 세 시까지 장부와 씨름한 지친 회계사가 계산에서 실수하는 일이 흔했기에 더욱 그랬습니다.
이 상태 표현 메커니즘에는 또 다른 흥미로운 속성이 있습니다. 예로 특정 시점의 상태를 되돌아볼 수 있습니다. 어떤 계좌가 0 이하 잔액에 도달했는데 그것이 허용되지 않아야 한다는 규칙이 있다고 가정해 봅시다. 그 무효 상태를 초래한 트랜잭션을 처리하기 직전의 계좌 상태를 보는 것은 가능하고, 비교적 쉽습니다. 이는 다른 상황에서 흔히 하이젠버그(heisenbugs)로 끝나는 문제의 재현을 훨씬 수월하게 만듭니다.
이러한 이점은 자연스레 트랜잭션 기반 도메인에만 국한되지 않습니다. 사실 DDD가 적용 되고 있다면 모든 도메인은 자연히 트랜잭션 기반 도메인입니다. DDD를 적용할 때는 행동에 무게 중심이 크게 쏠리며, 보통 유스 케이스와 일치합니다. DDD는 사용자가 시스템을 어떻게 사용하는지에 관심이 있습니다.
Figure 13의 Order 예로 돌아가면, 같은 주문을 Figure 15처럼 트랜잭션 모델 형태로 표현할 수도 있습니다.
[Figure 15]
Order의 트랜잭션 관점 — Cart Created → Added 2 Socks Item 137 → Added 4 Shirts Item 354 → Shipping Information Added. 각 단계가 과거 시제 Domain Event로 표현되며 하나의 화살표(순서)로 이어진다.
원문 p.30이 방식은 어떤 종류의 객체에도 적용할 수 있습니다. 이벤트를 재생(replay)함으로써 객체는 마지막으로 알려진 상태로 되돌아갈 수 있습니다. 방정식의 결과를 저장하는 것과, 그것을 나타내는 방정식을 저장하는 것은 수학적으로 동등합니다. 객체의 구조적 표현이 존재하지만, 그것은 이전 트랜잭션들을 재생함으로써만 구축됩니다. 데이터는 구조로 영속화되지 않고 트랜잭션의 시퀀스로 영속화됩니다. 여기서 매우 흥미로운 가능성 하나는, 현재 상태를 구조적으로 저장할 때와 달리 도메인에서의 현재 상태 표현과 저장소의 표현 사이에 결합이 없다는 점입니다. 도메인의 현재 상태 표현은 영속화 메커니즘 을 신경 쓰지 않고 얼마든지 달라질 수 있습니다.
Figure 15의 언어에 주목하는 것이 매우 중요합니다. 모든 동사가 과거 시제로 되어 있습니다. 이들은 Domain Event입니다. 만약 언어가 명령형이라면 어떨까요? “Add 2 socks item 137”, “Create Cart”. 아이템 추가에 관련된 행동이 있다면 (예: 웹 서비스 호출을 통해 인벤토리 시스템에서 예약하는 것), 그 행동들이 객체를 재구성 할 때 일어나야 할까요? 로직이 바뀌어 주어진 문맥에서 그 아이템을 더 이상 추가할 수 없게 되었다면 어떻게 될까요? 이는 Command와 Event 사이의 이중 문맥이 필요한 많은 예 중 하나입니다. 주어진 상태로 되돌아가는 것과 새로운 상태로 전이하는 것 사이에는 문맥적 차이가 존재합니다.
삭제는 없다 (There is no Delete)
원문 p.31자주 등장하는 질문은 정보를 어떻게 삭제하느냐는 것입니다. 앞서 말한 대로 타임머신에 올라타 “그 이벤트는 결코 일어나지 않았다”고 말하는 것은 불가능합니다 (예: 이전 이벤트를 삭제). 따라서 삭제는 Figure 16에서 보듯 새로운 트랜잭션으로 명시적으로 모델링해야 합니다. 이 메커니즘에서 삭제를 다루는 것의 비즈니스 가치에 대한 추가 논의는 “Business Value of the Event Log”에서 확인할 수 있습니다.
[Figure 16]
삭제가 포함된 Order의 트랜잭션 관점 — Cart Created → Added 2 Socks Item 137 → Added 4 Shirts Item 354 → Removed 2 Socks Item 137 → Shipping Information Added. 삭제는 이전 이벤트를 없애는 것이 아니라 새로운 Reversal Transaction으로 추가된다.
Figure 16의 이벤트 스트림에서 양말 두 켤레가 추가된 뒤 나중에 제거되었습니다. 최종 상태는 양말을 추가하지 않은 것과 동등합니다. 그러나 데이터가 삭제된 것은 아닙니다. 객체를 첫 번째 이벤트가 일어나지 않은 것과 같은 상태로 만들기 위해 새로운 데이터가 추가된 것뿐입니다. 이 프로세스는 Reversal Transaction이라고 부릅니다.
Reversal Transaction을 이벤트 스트림에 넣는 방식은 단순히 객체를 아이템이 추가되지 않은 것 같은 상태로 되돌리는 것이 아니라, 주어진 시점에 그 객체가 그 상태에 있었다는 흔적을 남깁니다.
데이터를 삭제하지 않는 것은 아키텍처적 이점도 있습니다. 저장 시스템이 추가 전용(additive only) 아키텍처가 되며, append-only 아키텍처는 처리할 락이 훨씬 적기 때문에 업데이트 아키텍처보다 분산이 훨씬 쉽다는 것은 잘 알려져 있습니다.
성능과 확장성
원문 p.32append-only 모델로 이벤트를 저장하는 것은 확장이 훨씬 쉬운 모델입니다. 판에 박힌 관계형 모델과 비교해도 성능과 확장성 측면의 이점은 이것만이 아닙니다. 예로 이벤트 저장은 append-only 단일 모델에 국한되므로 최적화할 수 있는 훨씬 단순한 메커니즘을 제공합니다. 다른 이점도 많습니다.
파티셔닝 (Partitioning)
오늘날 시스템에서 매우 흔한 성능 최적화가 수평 파티셔닝(Horizontal Partitioning)입니다. 같은 스키마가 여러 곳에 존재하고 각 행의 어떤 키로 데이터가 어느 곳에 존재할지를 결정합니다. 최근에는 이를 “Sharding”으로 바꿔 부르기도 합니다. 기본 아이디어는 같은 스키마를 여러 곳에 유지하고 주어진 행의 키를 기준으로 여러 파티션 중 하나에 배치하는 것입니다.
관계형 DB에서 수평 파티셔닝을 시도할 때 한 가지 문제는 파티셔닝에 어떤 키를 쓸지 정의 해야 한다는 것입니다. 이벤트를 사용하면 이 문제가 사라집니다. Aggregate Id가 시스템의 유일한 파티션 지점입니다. 얼마나 많은 Aggregate가 존재하는지, 혹은 구조가 어떻게 변하는지와 관계없이, 이벤트에 연관된 Aggregate Id가 시스템의 유일한 파티션 지점입니다. 이벤트 스토어의 수평 파티셔닝은 매우 단순한 과정입니다.
객체 저장 (Saving Objects)
판에 박힌 관계형 저장소 기반 시스템을 다룰 때, Aggregate 내에서 무엇이 바뀌었는지 알아내는 일은 꽤 복잡합니다. 이 복잡한 작업의 고통을 덜기 위해 많은 도구가 만들어 졌지만, 이런 도구가 필요하다는 사실 자체가 더 큰 문제의 신호는 아닐까요?
대부분의 ORM은 그래프 내에서 일어난 변경을 알아낼 수 있습니다. 보통 주어진 그래프의 사본 두 개를 유지하는 방식으로 동작합니다. 먼저 읽어온 것이 메모리에 유지되고, 두 번째 것은 코드가 상호작용하는 대상입니다. 복잡한 코드가 저장을 시도할 때가 오면, 이 코드가 다룬 그래프를 순회하며 무엇이 바뀌었는지를 원본 사본과 비교해 파악합니다. 그 변경들은 데이터 저장 시스템에 다시 저장됩니다.
Domain Event 중심 시스템에서 Aggregate 자체는 자신에게 일어난 변경에 대한 강한 이벤트를 추적합니다. 그래프를 다른 사본과 비교할 복잡한 과정이 없습니다. Aggregate에 변경 사항을 그냥 물어보면 됩니다. 무엇이 바뀌었는지를 알아내야 하는 것보다 훨씬 효율적인 연산입니다.
객체 로딩 (Loading Objects)
객체 로딩에도 비슷한 이슈가 있습니다. 판에 박힌 RDBMS 시스템에서 객체 그래프를 로드하려면 Aggregate를 구성하기 위해 여러 쿼리를 내야 하는 경우가 많습니다. 이런 쿼리 비용의 지연시간을 최소화하려고 ORM은 Lazy Loading(Delayed Loading) 이라는 휴리스틱을 도입했습니다. 실제 객체 대신 프록시를 주고, 어떤 코드가 그 특정 객체를 쓸 때만 데이터를 로드합니다.원문 p.33
Lazy Loading이 유용한 이유는, 어떤 행동이 Aggregate에서 일부 데이터만 쓰는 경우가 많아서 개발자가 어떤 데이터인지 명시적으로 표현하지 않아도 되며, 동시에 Aggregate 로딩 비용을 상각(amortize)할 수 있기 때문입니다. 하지만 비용 상각이 필요하다는 점 자체가 문제를 드러냅니다.
“Aggregate는 Aggregate Root로 표현되는 하나의 전체로 간주된다. 개념적으로 Aggregate는 전체가 로드되고 전체가 저장된다.” (Evans, 2001)
개념적으로는 Aggregate가 전체로 로드되고 저장된다는 개념이 다루기 훨씬 쉽습니다. Lazy Loading 개념은 추가 자체만 해도 간단하지 않으며, 유스 케이스를 최적화할 때는 특히 그렇습니다. 관계형 DB에서 완전한 Aggregate를 로딩하는 것이 운영적으로 너무 느리기 때문에 이 휴리스틱이 필요해졌습니다.
이벤트를 저장 메커니즘으로 다룰 때는 이야기가 꽤 다릅니다. 저장되는 것은 단 하나, 이벤트들입니다. 그냥 Aggregate의 모든 이벤트를 불러와 재생하기만 하면 됩니다. 시스템 에서 유일한 쿼리만 존재하며, Lazy Loading 같은 것을 구현할 필요가 없습니다. Lazy Loading 같은 것을 관리하기 위한 복잡하고 인상적인 프레임워크를 만들고 싶은 사람에게는 나쁜 소식이지만, 이런 프레임워크를 배울 필요가 없어지는 개발팀에는 좋은 소식입니다.
많은 이가 즉시 지적할 것처럼, 비록 관계형 시스템에서 쿼리 수가 많긴 하지만 이벤트 저장에서는 일부 Aggregate에 엄청난 수의 이벤트가 있을 수 있습니다. 이는 꽤 자주 일어나며, 비교적 간단한 해법이 존재합니다.
Rolling Snapshots
Rolling Snapshot은 어떤 시점에서의 Aggregate의 현재 상태에 대한 비정규화입니다. 그 시점까지 모든 이벤트를 재생했을 때의 상태를 나타냅니다. Rolling Snapshot은 Aggregate의 전 기록에 대한 모든 이벤트를 로드할 필요를 없애는 휴리스틱으로 사용됩니다. Figure 17은 전형적인 이벤트 스트림을 보여줍니다. 이벤트 스트림을 처리하는 한 방법은 태초부터 이벤트 스트림 끝까지 이벤트를 재생하는 것입니다.
[Figure 17]
이벤트 스트림 — 1부터 7까지 번호가 매겨진 이벤트가 아래에서 위로 쌓여 있는 스택 형태. 화살표는 재생 방향을 가리킨다.
원문 p.34문제는 태초와 현재 사이에 매우 많은 수의 이벤트가 있을 수 있다는 점입니다. 백만 개 이상의 이벤트를 가진 스트림을 쉽게 상상할 수 있고, 그것을 로드하는 것은 매우 비효율적 입니다.
해법은 Rolling Snapshot을 써서 어떤 시점의 상태 비정규화를 두는 것입니다. 그러면 그 시점부터 현재까지의 이벤트만 재생해 Aggregate를 로드할 수 있습니다.
[Figure 18]
스냅샷이 있는 이벤트 스트림 — 1, 2, 3, 4, snap, 5, 6의 형태. snap은 특정 시점의 상태 비정규화이며, 그 이후의 이벤트만 재생해 현재 상태를 복원한다.
원문 p.35Figure 18은 Rolling Snapshot이 포함된 이벤트 스트림을 보여줍니다. Rolling Snapshot을 사용할 때 Aggregate를 재구축하는 프로세스가 바뀝니다. 태초부터 앞으로 읽는 대신, 뒤에서 앞으로 읽으며 이벤트를 스택에 쌓다가, 더 이상 이벤트가 없거나 스냅샷을 찾을 때까지 진행합니다. 스냅샷을 찾으면 그것을 적용한 뒤 스택에서 이벤트를 꺼내 스택이 빌 때까지 차례로 적용합니다.
Rolling Snapshot을 이해하는 쉬운 방식이긴 하지만, 이는 프로덕션 시스템에서는 여러 이유로 이상적이지 못한 해법입니다. Rolling Snapshot 구현에 대한 자세한 논의는 “Building an Event Storage”에서 다룹니다.
스냅샷 자체는 그 시점의 그래프 직렬화본에 불과합니다. 그 시점의 그래프 상태를 가짐으로써 그 스냅샷 이전의 모든 이벤트 재생을 피할 수 있습니다. 스냅샷은 이벤트 스토어를 모니터링하는 프로세스에 의해 비동기적으로 생성될 수 있습니다.
Rolling Snapshot의 도입은 이벤트에서 로딩할 때의 최악 경우를 제어할 수 있게 합니다. 처리될 이벤트의 최대 수를 조정해 해당 시스템의 성능을 최적화할 수 있습니다. Rolling Snapshot 도입으로 이벤트 스토리지가 지원하는 두 연산 모두에서 한두 자릿수(order of magnitude)의 성능 향상을 비교적 단순한 과정으로 달성할 수 있습니다. 다만 Rolling Snapshot은 어디까지나 휴리스틱이며, 개념적으로 이벤트 스트림은 여전히 전체로 간주된다 는 점을 기억해야 합니다.
임피던스 불일치 (Impedance Mismatch)
원문 p.36이벤트를 저장 메커니즘으로 사용하는 것은, 전형적 관계형 모델과 객체 지향 도메인 모델 사이에 존재하는 임피던스 불일치를 분석할 때에도 매우 다른 속성을 제공합니다. Scott Ambler는 agiledata.org의 한 에세이에서 이 문제를 이렇게 설명합니다.
“왜 이 임피던스 불일치가 존재하는가? 객체 지향 패러다임은 검증된 소프트웨어 공학 원칙에 기반하고 있다. 관계형 패러다임은 검증된 수학적 원칙에 기반하고 있다. 그 하부 패러다임이 다르기 때문에 두 기술은 서로 매끄럽게 동작하지 않는다. 임피던스 불일치는 선호하는 접근 방식을 볼 때 명확해진다. 객체 패러다임에서는 객체를 그 관계를 통해 순회하지만, 관계형 패러다임에서는 테이블의 데이터 행을 조인한다. 이 근본적 차이는 두 기술을 이상적이지 않게 결합한 결과를 낳는다. 물론 두 개의 다른 것을 걸림 없이 결합한 예를 본 적이 있긴 한가?” (Ambler)
도메인 모델과 관계형 DB 사이의 임피던스 불일치에는 큰 비용이 따라붙습니다. 이 효과를 최소화하기 위한 ORM 같은 도구가 많이 있지만, 대개는 잘 동작하지만 ORM을 써도 임피던스 불일치에는 여전히 꽤 큰 비용이 붙습니다.
그 비용은, 개발자가 관계형 모델과 객체 지향 데이터 양쪽에 친밀해야 한다는 것입니다. 또한 두 모델 사이의 미묘한 차이도 많이 알고 있어야 합니다. Scott은 이를 이렇게 표현합니다.
“객체와 관계형 DB를 함께 성공적으로 사용하려면 두 패러다임과 그 차이를 모두 이해하고, 그 지식에 기반해 지능적 트레이드오프를 해야 한다.” (Ambler)
이 미묘한 차이의 일부는 위키피디아의 “Object-Relational Impedance Mismatch” 페이지에서 볼 수 있습니다. 주요 차이 몇 가지만 살펴보겠습니다.
선언적 vs 명령적 인터페이스 — 관계형 사고는 데이터 자체를 인터페이스로 사용하는 경향이 있으며, 행동을 인터페이스로 보는 객체지향과 대조됩니다. 따라서 설계 철학이 선언적 쪽으로 기울며, 객체지향의 행동 중심 성향과 대비됩니다. 일부 관계형 지지자들은 트리거, 저장 프로시저 등을 제안해 복잡한 행동을 제공하려 하지만, 흔한 관점은 아닙니다. (Object-Relational Impedance Mismatch)
원문 p.37구조 vs 행동 — 객체지향은 주로 프로그램 구조의 합리성(유지보수, 이해 가능성, 확장성, 재사용성, 안전성)을 보장하는 데 초점을 맞추는 반면, 관계형 방법은 결과 런타임 시스템이 어떤 행동을 보이는지(효율성, 적응성, 내결함성, 활성, 논리적 무결성 등)에 초점을 맞춥니다. 객체지향 방법은 보통 객체지향 코드와 그 인터페이스의 주 사용자가 애플리케이션 개발자라고 가정합니다. 관계형 시스템에서는 때로 시스템 행동에 대한 최종 사용자 관점이 더 중요하게 간주됩니다. 그러나 관계형 쿼리와 “view” 는 정보를 애플리케이션 또는 태스크 특화 구성으로 재표현하는 데 자주 쓰이는 기법입니다. 또한 관계형은 지역적 또는 애플리케이션 특화 구조나 테이블 생성 자체를 금지하지 않지만, 많은 공용 개발 도구가 이런 기능을 직접 제공하지 않아 사용될 객체들을 가정하기 어렵게 만듭니다. 이로 인해 관계형의 명시된 비개발자 관점이 관계형의 본질인지, 아니면 단지 현재 실천과 도구 구현의 부산물인지 판단하기가 어렵습니다. (Object-Relational Impedance Mismatch)
집합 vs 그래프 관계 — 서로 다른 항목(객체 또는 레코드) 간의 관계는 패러다임에 따라 다르게 처리됩니다. 관계형 관계는 보통 집합론에서 온 관용구에 기반하고, 객체 관계는 그래프 이론(트리 포함)에서 채택된 관용구로 기울어집니다. 각각이 같은 정보를 표현할 수는 있지만, 정보 접근과 관리를 위한 접근 방식은 다릅니다. (Object-Relational Impedance Mismatch)
데이터 타입, 아이덴티티, 트랜잭션 동작 방식 등 다른 미묘한 차이도 많습니다. 객체-관계형 임피던스 불일치는 효과적으로 다루기에 상당히 귀찮은 문제이며, 대처에 많은 지식이 요구됩니다.
이벤트와 도메인 모델 사이에는 임피던스 불일치가 없다.
이벤트 자체가 도메인 개념이며, 주어진 상태에 도달하기 위해 이벤트를 재생한다는 발상 역시 도메인 개념입니다. 시스템 전체가 도메인 용어로 정의됩니다. 모든 것을 도메인 용어로 정의하는 것은 개발자가 알아야 하는 지식의 양을 낮춰줄 뿐 아니라, 필요한 모델 표현의 수를 한정합니다. 이벤트가 도메인 모델 자체와 직접 연결되어 있기 때문입니다.
이벤트 로그의 비즈니스 가치
이 절의 시작 부분에서 Event Log의 가치는 DDD를 애초에 쓰고 싶은 장소와 직접적 상관 관계가 있다는 점을 명확히 해둘 필요가 있습니다. DDD는 비즈니스가 경쟁 우위를 얻는 장소에서 쓰여야 합니다. DDD 자체가 적용하기 매우 어렵고 비싸기 때문에, 도메인이 복잡 하고 경쟁 우위가 도메인에서 나오는 경우에만 그 노력은 높은 ROI를 돌려줍니다. Event Log 도 마찬가지로, 경쟁 우위가 있는 영역을 다룰 때는 높은 ROI를 돌려주지만, 다른 영역에선 부정적 ROI일 수 있습니다.
현재 상태만 저장하는 방식은 데이터에 특정 종류의 질문만 던질 수 있게 합니다. 예로 주식 시장의 주문을 생각해 봅시다. 주문은 여러 이유로 바뀔 수 있습니다. 사용자가 사거나 팔고 싶은 수량을 바꿀 수 있고, 거래 시스템이 주문의 수량을 자동으로 조정하거나, 또는 거래가 발생해 현재 주문에서 사용 가능한 수량이 줄어들 수 있습니다.
시장의 현재 유동성에 관한 질문(어떤 주식의 주어진 가격에서 얼마나 주식이 있는가 등) 에는 이 변경들이 어떻게 일어났는지가 중요하지 않습니다. 주어진 시점에 무엇이 어떤 상태인지가 중요할 뿐입니다. 비즈니스 세계의 쿼리 중 대다수조차 “무엇”에 초점을 둡니다. 어떤 고객에게 메일을 보낼 라벨, 4월에 판매된 양, 창고에 얼마나 많은 위젯이 있는지 같은 질문입니다.
그러나 점점 더 인기를 얻는 다른 유형의 질문도 있습니다. 이들은 “어떻게”에 초점을 둡니다. “Business Intelligence”라는 유행어에서 흔히 보이는 예시들 이죠. 어쩌면 사람들이 어떤 행동을 한 이후 특정 제품을 구매할 가능성 사이에 상관관계가 있을까요? 이런 유형의 질문은 보통 어떤 것이 어떻게 생겨났는지(how)에 초점을 두지, 그것이 어떻게 끝났는지(what)에 초점을 두지 않습니다.원문 p.38
예를 들어 보는 것이 가장 좋습니다. 대형 온라인 소매업체의 개발팀이 있다고 해봅시다. 이터레이션 기획 회의에서 한 도메인 전문가가 아이디어를 냅니다. 사용자가 장바구니에서 아이템을 제거하는 행동과, 나중에 그 제품을 구매하도록 제안에 반응할 가능성 사이에 상관관계가 있다고 믿는 것입니다. 이 기능이 다음 이터레이션에 추가됩니다.
가정한 첫 번째 팀은 상태 저장에 판에 박힌 현재 상태 기반 메커니즘을 쓰고 있습니다. 이들은 이번 이터레이션에서 장바구니에서 제거된 아이템을 추적하는 fact 테이블을 추가합니다. 다음 이터레이션에서는 리포트를 만들 것이라고 계획합니다. 두 번째 이터레이션 이 끝나면 비즈니스는 팀이 해당 기능 릴리스 시점까지 거슬러 올라가는 정보를 보여주는 리포트를 받게 됩니다.
이는 매우 판에 박힌 과정이며, 어떤 조직에서는 리포트와 추적이 동시에 릴리스될 수도 있지만 처리는 비슷합니다. 비즈니스 관점에서 도메인 전문가는 기쁩니다. 팀에 요청했고 팀은 빠르고 비교적 무통증으로 그 요청을 수행했습니다. 새 기능이 비교적 빠르고 거의 고통 없이 추가되었습니다. 그러나 두 번째 팀은 상당히 다른 결과를 얻게 됩니다.
두 번째 팀은 이벤트를 저장하고 있습니다. 이들은 현재 상태를 일련의 이벤트로 구축함 으로써 표현합니다. 이들은 첫 번째 팀과 똑같이 하되 fact 테이블을 통해 장바구니에서 제거된 아이템을 추적하는 handler를 추가합니다. 그러나 비즈니스가 시작된 시점부터 이벤트 로그를 다시 돌려 이 handler를 실행해 모든 데이터를 재구성합니다. 리포트는 같은 이터레이션에 릴리스되고, 몇 년 전까지 거슬러 올라가는 데이터를 담고 있습니다.
두 번째 팀이 이걸 할 수 있는 이유는, 현재 데이터 상태가 아니라 시스템이 실제로 한 일을 저장해왔기 때문입니다. 과거 데이터를 새롭고 흥미로운 방식으로 되돌아보며 재해석하는 것이 가능합니다. 장바구니에서 제거된 아이템을 추적하는 것이 중요하다고 이전에는 고려된 적이 없거나, 사용자가 장바구니에서 아이템을 몇 번 제거했는지가 중요 하다고 고려된 적이 없을 수 있습니다. 이들은 모두 데이터를 새롭고 흥미로운 방식으로 보는 예입니다.
이벤트가 시스템이 수행한 모든 행동을 나타내기 때문에, 시스템을 설명하는 어떤 모델도 이벤트로부터 구축될 수 있다.
비즈니스는 정기적으로 데이터를 보는 새롭고 흥미로운 방식을 생각해냅니다. 오늘의 데이터를 5년 후에 비즈니스가 어떻게 보고 싶어 할지는 어느 정도 확신으로 예측하는 것이 불가능합니다. 비즈니스가 데이터를 보고 싶어 할 방식이 5년 후에 어떤 모습일지는 알 수 없지만, 엄청나게 높은 가치일 수도 있습니다. 앞서 말했듯 이는 비즈니스가 경쟁 우위를 도출하는 영역에서 이뤄져야 합니다. 따라서 오늘의 데이터를 예상치 못한 방식으로 들여다 보는 능력은 비즈니스에 경쟁 우위가 될 수 있습니다. 지금의 아키텍처 결정이 회사의 미래 성공이나 실패에 끼칠 영향을 어떻게 평가할 수 있을까요?원문 p.39
개발팀은 비즈니스가 5년, 심지어 10년 후에 필요할 것을 예측하는 “Magic 8 Ball” 을 어떻게 정당화할까요? 많은 이가 YAGNI(You Ain't Gonna Need It)를 쓰려 하지만, YAGNI는 실제로 그것을 필요로 하지 않을 것임을 알 때에만 적용됩니다. 비즈니스의 동적인 세계에서 그들이 5년 또는 10년 후에 데이터를 어떻게 보고 싶어 할지 어떻게 예측할 수 있을까요?
- 시스템의 모든 행동을 실제로 모델링하는 것이 더 비싼가? 그렇다.
- 디스크 비용과 사고 과정 측면에서 모든 이벤트를 시스템에 저장하는 것이 더 비싼가? 그렇다.
- 그렇다면 비즈니스가 데이터로부터 경쟁 우위를 도출할 때, 이 비용은 ROI에 값하는가?
원문 p.40 인용 문헌: Ambler, S. W. The Object Relational Mismatch (agiledata.org); Evans, E. (2001) Domain Driven Design, Addison Wesley; Fowler, M. Domain Event (EAA Dev); Jill Nicola, M.M. (2002) Streamlined Object Modelling, Prentice Hall; Object-Relational Impedance Mismatch (Wikipedia); Wikipedia — You ain't gonna need it.
5장. 이벤트 스토리지 구축 (Building an Event Storage)
원문 p.41“Events as a Storage Mechanism” 장에서는 일련의 이벤트로부터 상태를 재구성한다는 개념을 개념적 관점에서 다뤘습니다. 이번 장은 실제 이벤트 스토리지의 구현과 구현에서 맞닥뜨리는 몇 가지 이슈에 초점을 맞춥니다.
이 장에서 논의하는 구현은 프로덕션 품질의 이벤트 스토리지가 되는 것을 의도하지 않습니다. 오히려 이벤트 스토리지를 어떻게 만드는가에 대한 논점으로 제공됩니다. 여기서 제시하는 구현은 고성능이라 할 수는 없지만, 오늘 만들어지는 많은 애플리케이션의 요구에 부합할 수 있습니다.
설명용 구현에서는 RDBMS 같은 기존 기술 위에 이벤트 스토리지를 만드는 것이 가장 쉽습 니다. 이는 트랜잭션 커밋 모델이나 읽기 성능을 위한 데이터 지역성 같은, 이벤트 스토리지 구축 기본 논의 범위를 벗어나는 기술적 이슈를 많이 줄여줍니다.
구조 (Structure)
기본 이벤트 스토리지는 관계형 DB에서 단 두 개의 테이블만으로 표현할 수 있습니다.
Column Name | Column Type
AggregateId | Guid
Data | Blob
Version | Int이 테이블은 실제 이벤트 로그를 나타냅니다. 이벤트 하나당 한 행이 생깁니다. 이벤트 자체는 [Data] 컬럼에 저장되며, 어떤 형태든 직렬화를 거쳐 저장됩니다. 이후 논의에서는 직렬화 위에 구축된 메커니즘을 가정하지만, memento 패턴의 활용이 매우 유리할 수 있습니다.
표는 최소한의 정보만 보여줍니다. 대부분의 조직은 변경이 일어난 시간, 변경과 관련된 문맥 정보 같은 몇 개의 컬럼을 추가하고 싶어 할 것입니다. 예로 변경을 시작한 사용자, 변경을 요청한 IP 주소, 요청 시의 권한 레벨 등이 문맥 정보가 될 수 있습니다.
버전 번호도 각 이벤트와 함께 Events 테이블에 저장됩니다. 대부분의 경우 이것은 증가하는 정수로 생각할 수 있습니다. 저장되는 각 이벤트의 버전 번호는 하나씩 증가합니다. 버전 번호는 주어진 Aggregate의 문맥 안에서만 유일하고 순차적입니다. 이는 Aggregate Root 경계가 곧 일관성 경계이기 때문입니다.
[AggregateId] 컬럼은 인덱싱되어야 하는 외래 키이며, 다음 테이블 즉 Aggregates 테이블을 가리킵니다.원문 p.42
Column Name | Column Type
AggregateId | Guid
Type | Varchar
Version | Int저자의 코멘트: 나는 이벤트 스토리지에서 이 개념을 “Aggregate”라고 부르는 것과, “Event Provider” 같은 다른 이름으로 부르는 것 사이를 왔다 갔다 했습니다. “Aggregate”는 실제로 도메인 개념이며 이벤트 스토리지는 도메인 없이도 동작할 수 있기 때문입니다.
Aggregates 테이블은 시스템에 현재 존재하는 Aggregate들을 나타냅니다. 모든 Aggregate는 이 테이블에 엔트리를 가져야 합니다. 식별자와 함께 현재 버전 번호의 비정규화도 있습니다. 이는 주로 최적화로, Events 테이블에서도 유도할 수 있지만 이 비정규화를 쿼리하는 쪽이 훨씬 빠릅니다. 이 값은 optimistic concurrency 검증에도 쓰입니다.
예시에는 [Type] 컬럼도 포함되어 있는데, 저장 중인 Aggregate 타입의 전체 이름입니다. 디버깅 등 다양한 목적에 유용하지만, 기본 이벤트 스토리지 생성에 꼭 필요한 것은 아닙니다.
연산 (Operations)
이벤트 스토리지는 범용 쿼리를 지원하지 않기 때문에 대부분의 데이터 저장 메커니즘보다 훨씬 단순합니다. 가장 단순한 수준에서 이벤트 스토리지는 단 두 개의 연산만 가집니다. 이 점이 최적화도 쉽게 해줍니다.
첫 번째 연산은 어떤 Aggregate의 모든 이벤트를 가져오는 것입니다. 이벤트가 저장된 순서와 동일한 순서로 반환되는 것이 매우 중요하며, 이 순서는 버전 번호로 확보할 수 있습니다. 기반 RDBMS를 사용하면 매우 간단히 처리됩니다.
SELECT * FROM EVENTS WHERE AGGREGATEID='' ORDER BY VERSION이것이 프로덕션 시스템이 이벤트 스토리지에 대해 실행해야 할 유일한 쿼리입니다. 가끔은 결과 집합을 실제 날짜로 제한해 특정 시점에 객체가 어떤 상태였는지를 보는 쿼리가 유용할 수 있지만, 일반적으로 프로덕션 시스템에서는 하지 않는 편이 낫습니다.
이벤트 스토리지가 지원해야 할 다른 연산은 Aggregate Root에 대한 이벤트 집합 의 쓰기입니다. 코드나 저장 프로시저 어느 쪽으로도 구현할 수 있습니다. 저장 프로시저 또는 if 문이 포함된 동적 SQL이 선호됩니다. 그렇지 않으면 insert 과정이 여러 왕복을 필요로 하기 때문입니다. insert 과정의 의사코드는 Listing 5에서 확인할 수 있습니다.원문 p.43
Begin
version = SELECT version from aggregates where AggregateId = ''
if version is null
Insert into aggregates
version = 0
end
if expectedversion != version
raise concurrency problem
foreach event
insert event with incremented version number
update aggregate with last version number
End Transaction쓰기 연산도 비교적 단순하지만, 몇 가지 미묘한 점이 있습니다. 기본 흐름은 먼저 해당 고유 식별자로 Aggregate가 존재하는지 확인하고, 없으면 새로 만들고 현재 버전을 0으로 설정하는 것입니다. 그런 다음 들어오는 데이터에 대해 optimistic concurrency 검사를 시도합니다. 기대 버전이 실제 버전과 일치하지 않으면 concurrency 예외를 발생시킵니다. 버전이 같으면 저장할 이벤트들을 순회하며 Events 테이블에 insert 하고, 이벤트 하나당 버전 번호를 1씩 증가시킵니다. 마지막으로 Aggregates 테이블을 해당 Aggregate의 새로운 현재 버전 번호로 갱신합니다. 이 연산들이 트랜잭션 안에 있어야 한다는 점이 중요합니다. 분산 환경에서 다른 일들 사이에서도 optimistic concurrency가 동작하는 것을 보장하기 위해 필요하기 때문입니다.
public interface IEventStore {
void SaveChanges(Guid AggregateId, int OriginatingVersion, IEnumerable<Event> events);
IEnumerable<Event> GetEventsFor(Guid AggregateId);
}프로덕션 품질의 이벤트 스토리지를 만드는 것은 사소한 일이 아니지만, 이벤트 스토리지 의 전반적 개념은 비교적 쉽습니다. 앞으로 제품이나 오픈소스 프로젝트 형태의 기성 이벤트 스토리지가 많이 등장할 가능성이 높습니다. 그러나 “Events as a Storage Mechanism”에서 논의된 매우 중요한 최적화 하나가 대부분의 시스템에 실제로 존재 해야 하는데, 바로 “Rolling Snapshot” 개념입니다.
Rolling Snapshots (구현)
원문 p.44Rolling Snapshot은 Aggregate를 재구성하는 쿼리를 낼 때 모든 이벤트를 로드할 필요를 없애는 휴리스틱입니다. 어떤 시점의 Aggregate 비정규화를 말합니다. 쿼리 로직 변경과 추가 테이블 하나만 있으면 기본 이벤트 스토리지에 이 휴리스틱을 추가할 수 있습니다. 개념적 논의는 “Events as a Storage Mechanism” 장을 참고하세요.
Column Name | Column Type
AggregateId | Guid
SerializedData | Blob
Version | IntSnapshots 테이블은 비교적 기본적입니다. 주 데이터는 해당 시점의 Aggregate 직렬화 버전이 담긴 blob입니다. 직렬화 데이터는 바이너리, XML, 원시 텍스트 등 여러 스키마 중 어느 하나일 수 있습니다. 스냅샷을 어떻게 직렬화할지는 구축되는 시스템에 따라 달라 집니다. 버전 번호도 스냅샷에 포함되는데, 이는 해당 스냅샷이 Aggregate의 어느 버전을 나타내는지를 의미합니다.
스냅샷을 생성하기 위해서는 스냅샷 생성 태스크를 처리하는 프로세스가 도입되어야 합니다. 이 프로세스는 Application Server 외부에 백그라운드 프로세스로 살 수 있습니다. 처리량 요구에 따라 단일 프로세스가 실행될 수도, 여러 개가 실행될 수도 있습니다. 모든 스냅샷은 비동기로 생성됩니다. Figure 22는 SnapShotter 프로세스가 도입된 개념적 아키 텍처를 보여줍니다.
[Figure 22]
SnapShotter 도입 — 이벤트 스토리지 뒤에 SnapShotter가 있고 App Server 1, App Server 2가 이벤트 스토리지와 상호작용한다. SnapShotter는 백그라운드에서 주기적으로 동작하며 앱 서버와 독립적이다.
원문 p.45SnapShotter는 이벤트 스토리지 뒤에 위치하며, 허용된 이벤트 수를 넘어 스냅샷을 만들어야 할 Aggregate들을 주기적으로 쿼리합니다. 이 쿼리는 Aggregates 테이블을 Snapshots 테이블 과 Aggregate 식별자로 조인해 쉽게 수행할 수 있습니다. 차이는 현재 버전에서 마지막 스냅샷 버전을 빼 계산하며, 그 차이가 일정 수를 넘는 Aggregate만 where 절로 반환합니다. 이 쿼리는 스냅샷이 생성되어야 할 모든 Aggregate를 반환합니다. SnapShotter는 이 Aggregate 목록을 순회하며 스냅샷을 생성합니다(여러 SnapShotter를 사용할 경우 competing consumer 패턴이 잘 맞습니다).
스냅샷 생성은 도메인이 해당 Aggregate의 현재 버전을 로드한 뒤 그것의 스냅샷을 찍는 과정을 포함합니다. 스냅샷 생성 자체는 다양한 방식이 가능합니다. 스냅샷이 찍히고 나면 쿼리가 그 스냅샷을 사용할 수 있도록 Snapshots 테이블에 저장됩니다.원문 p.46
많은 팀이 플랫폼의 기본 직렬화 패키지를 사용해 좋은 결과를 얻지만, 스냅샷을 다룰 때 Memento 패턴이 꽤 유용합니다. Memento 패턴(또는 커스텀 직렬화)은 도메인 객체 구조가 시간이 흐르며 변경될 때 스냅샷을 도메인으로부터 더 잘 격리합니다. 기본 직렬화는 새로운 구조가 릴리스될 때 버전 문제를 야기합니다(기존 스냅샷을 지우고 재생성하거나, 새 스키마에 맞게 갱신해야 합니다). Memento 패턴의 사용은 스냅샷 스키마의 버전 관리를 도메인 객체 자체와 분리합니다.
“Events as a Storage Mechanism” 장에서는 스냅샷 저장을 위한 다른, 더 단순한 메커니즘이 제시되었습니다. 그 방식은 이벤트 로그 안에 스냅샷을 인라인으로 두는 것이었습니다. 이 메커니즘은 개념적으로 더 단순하지만, 프로덕션 시스템에서는 몇 가지 문제를 야기할 수 있습니다. 이 문제들은 이벤트 로그 안에서 스냅샷의 위치 순서와 관련되어 있습니다.
SnapShotter가 어떤 Aggregate Root에 스냅샷이 필요하다고 판단했다고 해봅시다. Aggregate를 로드하고 스냅샷을 찍습니다. 불행히도 그 사이 Application Server 중 하나가 같은 Aggregate에 변경을 가했습니다. 스냅샷의 위치가 이벤트 로그 내에 의존하기 때문에, optimistic concurrency 실패가 발생합니다. 간단한 답은 그 과정을 다시 시도하는 것이지만, 다시 실패한다면 어떻게 될까요? 매우 바쁜 Aggregate에 대한 SnapShotter는 실제로 스냅샷을 성공적으로 기록할 확률이 매우 낮은 상황에 갇힐 수 있습니다.
스냅샷을 별도 테이블로 분리하고 Aggregate의 어떤 버전에 연결해두면 이 문제가 해결 됩니다. 스냅샷의 순서가 필요하지 않으며, 스냅샷이 최신 버전일 필요도 없습니다. 찍힌 시점의 버전에서 유효한 스냅샷이면 충분합니다.
스냅샷은 많은 시스템의 성능을 극적으로 향상시키는 휴리스틱이지만, 모든 시스템에 필요 하지는 않습니다. 일반적으로는 개발을 스냅샷 없이 진행하는 것이 권장됩니다. 나중에 시스템의 간단한 성능 개선으로 언제든 도입할 수 있기 때문입니다.
큐로서의 이벤트 스토리지 (Event Storage as a Queue)
앞서 도메인에서 나오는 이벤트가 통합 모델이기도 하다는 점을 논의했습니다. 이러한 이벤트는 단지 저장되는 데 그치지 않고 동일 시스템 내(예: 리포트 모델)나 다른 애플리 케이션의 리스너들에게 비동기적으로 발행되는 큐에 자주 게시됩니다. 이벤트를 발행하는 많은 시스템에 존재하는 이슈는, 사용 중인 저장소(관계형이든 아니든)와 큐에 이벤트를 발행하는 것 사이에 2단계 커밋(two-phase commit)이 필요하다는 것입니다.
2단계 커밋이 필요한 이유는, 데이터 저장소 쓰기가 커밋되는 순간과 큐 쓰기가 커밋되는 순간 사이의 짧은 시간 동안 대재앙이 발생할 수 있기 때문입니다. 이 시기에 장애가 발생하면 메시지가 큐에 발행되지 못할 수 있습니다(반대로 발행은 되었지만 변경은 저장 되지 않았을 수도 있습니다). 어느 쪽이든 이벤트의 리스너는 생산자와 동기화되지 않게 됩니다.원문 p.47
2단계 커밋은 비쌀 수 있지만, 낮은 지연시간 시스템에서는 더 큰 문제가 있습니다. 보통 큐 자체가 영속적이므로 2단계 커밋 과정에서 이벤트가 디스크에 두 번 쓰입니다 — 이벤트 스토리지에 한 번, 영속 큐에 한 번. 대부분의 시스템에서 이중 쓰기는 큰 문제가 아니지만, 낮은 지연시간 요구가 있는 시스템에서는 디스크 탐색도 강제되어 상당히 비싼 연산이 될 수 있습니다. Figure 23은 데이터 저장소와 발행 큐 사이의 2단계 커밋을 보여줍니다.
[Figure 23]
큐와의 2단계 커밋 — App Server 1이 Data Storage에 쓰고 동시에 Publish Queue에 발행한다. 두 연산이 하나의 분산 트랜잭션 안에서 이뤄진다.
이 문제를 우회하려는 시도로, 큐에만 쓰고 큐의 반대쪽에서 이벤트에 따라 데이터 저장소를 갱신하게 하는 방식이 있습니다. 그러나 이 방식에도 이슈가 있습니다. 가장 큰 문제는 모든 이벤트가 저장소에 쓰일 수 있는 것은 아니라는 점입니다. 이벤트 쓰기 시 결과적 일관성이 도입되고, optimistic concurrency 문제가 발생할 가능성이 있으며, 프로덕션 시스템에서 이를 다루는 것은 사소한 일이 아닙니다.
많은 조직은 반대 방향을 택합니다. 이벤트 스토리지를 큐로 사용하는 것입니다. 앞서 논의한 Events 테이블에 sequence number를 추가하면 이벤트 스토리지를 큐로 쓸 수 있습니다. Figure 24는 Events 테이블 스키마 변경을 보여줍니다.
Column Name | Column Type
AggregateId | Guid
Data | Blob
SequenceNumber | Long
Version | Int원문 p.48DB는 sequence number 값이 고유하고 증가한다는 것을 보장해줍니다. auto-increment 타입을 쓰면 쉽게 가능합니다. 값이 고유하고 증가하므로, 보조 프로세스가 Events 테이블을 추적해 이벤트를 큐로 발행할 수 있습니다. 이 추적 프로세스는 자신이 처리한 마지막 이벤트의 sequence number 값을 저장하기만 하면 되고, 이 값 갱신과 큐 발행을 2단계 커밋으로 묶어 동일 트랜잭션으로 처리할 수도 있습니다. 이 프로세스는 Figure 25에서 확인할 수 있습니다.
[Figure 25]
큐로서의 이벤트 스토리지 — 아래에서 위로: App Server 1 → Event Storage → Publisher → Publish Queue. 앱 서버는 이벤트 스토리지에 쓰기만 하고, 퍼블리셔가 비동기로 스토리지를 따라잡으며 큐로 이벤트를 내보낸다.
원문 p.49초기 처리에서 이 일이 알려진 안전한 방식으로 분리되었습니다. 발행은 실제 쓰기와 비동기로 발생할 수 있습니다. 이는 초기 연산 완료의 지연시간을 낮추고, 초기 요청 처리 의 디스크 쓰기 수를 한 번으로 제한합니다. 이 전략은 낮은 지연시간 요구가 있는 상황에서 매우 가치가 있습니다. 초기 처리의 많은 작업이 다른 프로세스로 비동기적이고 안전하게 오프로드될 수 있게 하기 때문입니다. 발행이 초기 처리의 일부로 일어나든 비동기로 일어나든 큰 차이는 없습니다. 일반적으로 메시지는 어차피 비동기로 발행되기 때문입니다. 이벤트 스토어를 큐로 쓰는 것은 메시지가 실제로 발행될 때까지의 시간을 살짝 올리는 정도이며, 이는 SLA를 살짝 높이는 것으로 볼 수 있습니다.
6장. CQRS와 Event Sourcing의 결합
원문 p.50CQRS와 Event Sourcing은 함께 결합될 때 가장 흥미로워집니다. 이 장은 DDD가 적용된 시스템에서 두 개념의 교차점을 살펴봅니다.
CQRS와 Event Sourcing은 공생 관계입니다. CQRS는 Event Sourcing이 도메인의 데이터 저장 메커니즘으로 사용될 수 있게 해줍니다. Event Sourcing 사용 시 가장 큰 이슈 중 하나는, “이름이 ‘Greg’인 모든 사용자를 달라” 같은 쿼리를 시스템에 던질 수 없다는 점입니다. 이는 현재 상태의 표현이 없기 때문입니다. CQRS를 쓰면 도메인에 존재하는 유일한 쿼리는 GetById이며, 이것은 Event Sourcing이 지원 합니다.
Event Sourcing은 사소하지 않은 CQRS 기반 시스템을 구축할 때에도 매우 중요합니다. 두 모델 간의 통합 문제가 큰 이슈이기 때문입니다. 예를 들어 관계형 모델 두 개(하나는 읽기, 다른 하나는 쓰기)를 유지하는 것은 꽤 비쌉니다. 특히 둘을 동기화하기 위한 이벤트 모델까지 있는 경우를 고려하면 더욱 비싸집니다. Event Sourcing에서는 이벤트 모델이 Write 측의 영속성 모델이기도 합니다. 이는 모델 간 변환이 필요 없으므로 개발 비용을 극적으로 낮춥니다.
[Figure 26]
Command를 보내는 판에 박힌 아키텍처 — Figure 1과 동일한 구조지만 클라이언트가 DTO 대신 Command를 ‘Send up Command, Status returned’로 보낸다. 읽기 측은 여전히 Request DTO/DTO returned.
원문 p.51Command를 쓰는 Figure 26의 원래 판에 박힌 아키텍처를 Figure 27의 Event Sourcing이 결합된 CQRS와 비교하면 대략 동일한 양의 작업이 든다는 것을 알 수 있습니다.
[Figure 27]
Event Sourcing과 결합된 CQRS — 좌측은 Domain Object/Application Services/Remote Facade 위에 Event Storage, 이것이 ‘Eventually’ Handlers를 거쳐 우측 Data Storage로 흐른다. 우측은 Thin Read Layer와 Remote Facade. 클라이언트는 좌측으로 Command Sent Ack/Nak, 우측으로 Request DTO/DTO returned.
비용 분석 (Cost Analysis)
두 아키텍처 사이에서 클라이언트의 작업량은 동일합니다. 클라이언트가 정확히 같은 방식으로 동작하기 때문입니다. 두 아키텍처 모두에서 클라이언트는 DTO를 받고 Application Server에 무언가를 하라고 지시하는 Command를 만듭니다.
두 모델 간 쿼리 비용도 매우 유사합니다. 판에 박힌 아키텍처에서는 쿼리가 도메인 모델 위에서 만들어지고, CQRS 기반 아키텍처에서는 Thin Read Layer가 직접 DTO로 투영합니다. 앞서 논의한 것처럼 Thin Read Layer가 같거나 혹은 조금 덜 비쌉니다.
원문 p.52비용 관점에서 두 아키텍처의 주된 차이는 도메인 모델과 영속성에 있습니다. 판에 박힌 아키텍처에서는 ORM이 대부분의 무거운 일을 하여 도메인 모델을 관계형 DB에 영속화합니다. 이 과정은 도메인 모델과 저장 메커니즘 사이에 임피던스 불일치를 도입하며, 앞서 논의한 대로 생산성과 개발자가 알아야 하는 지식 양 양쪽에서 매우 비쌀 수 있습니다.
CQRS와 Event Sourcing 기반 아키텍처는 Write 측에서 도메인 모델과 저장 메커니즘 사이에 임피던스 불일치가 없습니다. 도메인은 이벤트를 만들어 내고, 이 이벤트가 전부가 저장되는 것입니다. 이벤트 사용이 도메인 모델이 알아야 하는 전부입니다. 그러나 읽기 모델에는 임피던스 불일치가 존재합니다. Event Handler가 이벤트를 받아 read 모델을 이벤트가 의미하는 개념으로 갱신해야 합니다. 여기서의 임피던스 불일치는 이벤트와 관계형 모델 사이입니다.
이벤트와 관계형 모델 사이의 임피던스 불일치는 객체 모델과 관계형 모델 사이의 불일치 보다 훨씬 작으며 훨씬 단순한 방식으로 메우기 쉽습니다. 그 이유는 Event Model에 구조가 없고, 관계형 모델 안에서 취해져야 할 행동을 나타내기 때문입니다.
이런 관점에서 보면 두 아키텍처는 대략 같은 양의 작업을 수행합니다. 작업이 더 많거나 적다는 것이 아니라 다른 작업이라는 것입니다. 이벤트 기반 모델은 이벤트를 정의해야 하는 필요 때문에 약간 더 비쌀 수 있지만, 이 비용은 비교적 낮고 메우기 쉬운 더 작은 임피던스 불일치를 제공합니다. 이벤트 기반 모델은 또한 “Events” 에서 논의한 모든 이점도 제공하므로 이벤트 생성의 초기 비용 상당 부분을 상쇄합니다.
말하자면 CQRS와 Event Sourcing 모델은 대부분의 경우 실제로 더 저렴합니다!
통합 (Integration)
지금까지는 시스템을 고립된 상태로 비교했습니다. 조직에서 이런 일은 드물게 일어납니다. 조직은 대개 단일 시스템이 아니라 어떤 식으로든 통합된 여러 시스템의 시스템에 의존 합니다.
판에 박힌 아키텍처에서는 통합이 지원되지 않습니다. 기껏해야 DB를 통한 통합 정도지만, 이는 대부분의 시스템에서 잘 정립된 안티 패턴입니다. 통합은 사후적으로 고려됩니다. 통합은 커스텀으로 작성해야 합니다. 많은 조직이 모델 위에 서비스를 만들어 통합을 허용 합니다. 일부는 클라이언트가 쓰는 서비스와 동일할 수 있지만, 대개는 통합을 지원하기 위해 추가 작업이 필요합니다.
제품이 많은 고객에게 배포될 때 더 큰 문제가 존재합니다. 팀은 모든 고객과 그들이 시스템 과 통합하고자 하는 방식을 위한 훅을 제공할 책임을 집니다.원문 p.53
이는 수백 또는 수천 클라이언트에 설치되는 시스템에서 특히 매우 크고 다루기 어려운 코드가 됩니다. 각 고객별 커스텀 통합에 대해 요금을 청구하는 비즈니스 모델은 꽤 수익성 있을 수 있지만, 소프트웨어 관점에서는 끔찍한 모델입니다.
CQRS와 Event Sourcing 기반 모델에서는 통합이 첫 번째 유스 케이스부터 이미 고려된 상태입니다. Read 측은 Write 측에서 일어나는 일을 통합해 표현해야 하며, 그 자체가 통합 지점입니다. 통합 모델은 시스템의 초기 구축 전반에 걸쳐 “프로덕션 준비” 상태이며, Read 측과의 통합을 통해 계속 테스트됩니다.
이벤트 기반 통합 모델은 완전(complete)하다고 알려져 있습니다. 시스템 내부 모든 행동에 대응하는 이벤트가 있기 때문입니다. 시스템이 무언가를 할 수 있다면 정의상 자동으로 통합됩니다. 어떤 경우엔 이벤트 발행을 제한하는 것이 바람직할 수 있지만, 이는 발행되는 것을 제한하는 결정이지, 발행할 코드를 따로 작성해야 하는 문제가 아닙니다.
이벤트 기반 모델은 본질적으로 push 모델이며, pull 모델에 비해 여러 이점이 있습니다. 판에 박힌 아키텍처가 push 기반 모델을 원했다면, 이벤트를 추적하고 시스템의 자체 데이터 모델에 기록된 것과 동기화하는 데 많은 작업이 추가되었을 것입니다.
작업 습관의 차이 (Differences in Work Habits)
두 아키텍처는 작업의 병렬화 측면에서도 크게 다릅니다. 판에 박힌 아키텍처에서는 보통 작업이 수직 슬라이스 방식으로 이뤄집니다. 네 가지 흔한 방법론이 쓰입니다.
- Data Centric: DB에서 시작해 바깥으로 작업.
- Client Centric: 클라이언트에서 시작해 안쪽으로 작업.
- Façade/Contract First: 파사드에서 시작해 데이터 모델로 돌아간 뒤 마지막으로 클라이언트 구현.
- Domain Centric: 도메인 모델에서 시작해 클라이언트로 나간 뒤 데이터 모델 구현.
이 방법론들에는 공통점이 있습니다. 모두 수직 슬라이스로 작업하는 경향이 있다는 것 입니다. 같은 개발자가 이 단계들을 통해 하나의 기능을 작업합니다. CQRS와 Event Sourcing 기반 아키텍처에서도 같은 방식으로 할 수 있지만 반드시 그럴 필요는 없습니다. Figure 28의 매우 하이레벨 뷰를 생각해 봅시다.
[Figure 28]
CQRS와 Event Sourcing의 하이레벨 뷰 — 세 노드 삼각형. Domain → Events → Read Model → DTOs → Client → Commands → Domain의 순환. 세 영역이 명확히 분리되어 있다.
원문 p.54이 아키텍처는 세 개의 서로 다른 결합이 느슨한 영역으로 볼 수 있습니다. 첫째는 클라이언트로, DTO를 소비하고 Command를 생산합니다. 둘째는 도메인으로, Command를 소비 하고 이벤트를 생산합니다. 셋째는 Read Model로, 이벤트를 소비하고 DTO를 생산합니다. 세 영역의 결합이 느슨하다는 특성은 팀의 특성 관점에서 매우 가치가 있을 수 있습니다.
병렬화 (Parallelization)
어느 시점에 5~8명의 개발자가 수직 슬라이스로 작업하면서 큰 충돌 없이 일하는 것은 비교적 쉽습니다. 개발자 수가 적으면 각 개발자가 무엇을 하는지 알리고 겹치는 영역이 많지 않도록 하기가 비교적 쉽기 때문입니다. 개발자 수가 늘어날수록 이 문제는 훨씬 어려워집니다.
수직 슬라이스로 작업하는 대신, 팀은 세 개의 동시 수직 슬라이스(클라 이언트, 도메인, read 모델)로 작업할 수 있습니다. 이렇게 하면 개발자 수 확장이 훨씬 잘 됩니다. 각 영역의 개발자들이 서로 격리되어 변경 시 충돌이 덜 일어나기 때문입니다. 더 많은 커뮤니케이션을 도입할 필요 없이 팀 크기를 거의 3배로 늘려도 더 많은 충돌을 일으키지 않을 수 있습니다. 개발자들은 여전히 같은 방식으로 소통하지만, 더 작고 결합이 느슨한 조각들에 대해 소통합니다. 출시까지의 시간이 중요할 때 이는 극도로 유리할 수 있으며, 프로젝트 완료에 필요한 달력 시간을 급격히 줄일 수 있습니다.
모든 개발자가 동등하지 않다
원문 p.55팀에는 여러 유형의 개발자가 있습니다. 개발자 간 차이를 고려할 때 살펴볼 속성들은 다음과 같습니다.
- 기술 숙련도(Technical Proficiency)
- 비즈니스 도메인 지식(Knowledge of the Business Domain)
- 비용(Cost)
- 소프트 스킬(Soft Skills)
결합 해제 지점들은 자연스러우며, 특정 영역에서 팀의 전문화를 지원합니다. 예컨대 도메인 의 최적 후보는 비용이 높지만 많은 비즈니스 지식을 가지고 있고 도메인 전문가와 대화할 소프트 스킬을 갖춘 사람입니다. Read 모델과 DTO 생성을 다룰 때는 이런 역량이 꼭 필요 하지 않습니다. Read 모델 작업은 비교적 직관적인 일이며, 요구사항이 다르므로 자연스레 다음 항목으로 이어집니다.
아웃소싱 (Outsourcing)
비용이 낮고 중급 기술의 개발자를 팀에 계속 두는 것은 비용 효율적이지 않은 경우가 많습 니다. 고용 부담(급여, 정부 규제 준수 등)이 고용 이점보다 큰 경우가 많기 때문입니다. 회사가 고비용 지역에 있다면, 해외에서 더 저렴한 개발자를 얻는 것도 확실히 가능합니다. 오프쇼어든 온쇼어든, 이 분리는 프로젝트의 일부를 성공적으로 아웃소싱하는 데 도움이 됩니다.
아웃소싱 프로젝트는 아웃소서와 로컬 팀 또는 도메인 전문가 사이에 많은 커뮤니케이션이 필요하기 때문에 실패하는 경우가 많습니다. 이 커뮤니케이션에는 시차, 문화, 언어 등 여러 문제가 뒤따릅니다.
예로 Read Model은 아웃소싱하기 이상적인 영역입니다. Read Model의 계약과 동작 명세는 꽤 구체적이고 쉽게 설명할 수 있습니다. 비즈니스 지식이 거의 필요 없으며, 대부분 시스템에서 기술 숙련도 요구도 중간 수준입니다.
반면 Domain Model은 아웃소싱하면 전혀 동작하지 않을 것입니다. Domain Model 개발자는 도메인 전문가와 많은 양의 커뮤니케이션이 필요합니다. 이들은 초기 도메인 지식이 있으면 크게 도움이 됩니다. 이런 개발자는 로컬 팀 안에 두고 매우 가치 있게 여겨야 합니다.원문 p.56
회사는 낮은 위험으로 시스템의 이 영역을 아웃소싱함으로써 많은 자본을 절약할 수 있으며, 이 자본은 시스템의 다른 더 중요한 영역에 재투자할 수 있습니다. 자본의 방향성 있는 사용은 더 품질 좋고 비용이 낮은 시스템에 도달하는 데 매우 중요합니다.
전문화 (Specialization)
수직 슬라이스로 작업할 때 한 가지 문제가 존재합니다. “최고”의 개발자 (가장 가치 있는)는 도메인에서 일합니다. 수직 슬라이스로 작업할 때 일화적 증거는, 이 개발자들이 자신의 노력 중 대략 20~30%만을 도메인에 쓴다는 것을 시사합니다.
두 번째 아키텍처에서는 도메인과 함께 일하는 개발자 팀이 시간의 80% 이상을 도메인과 도메인 전문가와의 상호작용에 씁니다. 개발자들은 데이터 모델이 어떻게 영속화되는지, 사용자에게 어떤 데이터가 표시되어야 하는지를 신경 쓰지 않습니다. 개발자들은 시스템의 유스 케이스에 집중합니다. 그들이 알아야 할 것은 Command와 Event뿐입니다.
이 전문화는 이들이 훨씬 더 중요한 활동, 즉 좋은 모델과 풍부하게 기술적인 Ubiquitous Language에 도달하는 활동에 몰두할 수 있게 해줍니다. 또한 도메인 전문가의 시간을 최적화하는 데도 도움이 됩니다. 수직 슬라이스의 기술적 측면이 작업되는 동안 도메인 전문가가 idle하게 앉아 있는 대신 말이죠.
때로는만 (Only Sometimes)
분리를 통해 많은 이점이 제공되지만 반드시 사용할 필요는 없습니다. 평범한 크기의 팀이 여전히 수직 슬라이스로 일하는 것도 흔합니다. 작거나 중간 크기의 팀이 전체 시스템의 수직 슬라이스로 일하는 것에는 리스크 관리 등 장점이 있습니다.
CQRS와 Event Sourcing 기반 아키텍처의 진정한 이점은 “한 사이즈로 모두를 맞추는” 방식 대신, 각각 고유한 속성으로 최적화된 세 개의 구분된 수직 슬라이스로 시스템을 가져올 수 있는 선택지가 존재한다는 점입니다.
정리 — 한국 개발자 관점에서의 해설
Greg Young의 CQRS Documents는 2010년대 초에 쓰였지만, 그 핵심 주장은 2026년 현재에도 여전히 유효합니다. CRUD 중심의 판에 박힌 아키텍처가 왜 DDD와 잘 맞지 않는지에서 출발해, Task Based UI → Command → CQRS → Event Sourcing → Event Storage 구현 → 두 패턴의 결합까지 자연스럽게 한 길로 이어지는 이 문서는 한 번쯤 정주행할 가치가 있습니다.
꼭 기억해야 할 여섯 가지
- CRUD는 의도(intent)를 지운다. DTO 업/다운 구조에서는 도메인에 동사가 들어오지 못하고, 결과적으로 Anemic Domain Model보다 못한 “잘 꾸며진 엑셀 스프레드시트”가 된다.
- Task Based UI가 Command로 가는 입구이다. 사용자가 무엇을 하려 하는지를 화면이 안내하면 Command 구축이 자연스러워진다.
- Command는 명령형, Event는 과거 시제. 이 언어적 구분이 Ubiquitous Language의 명확함을 가르는 경계다.
- CQRS의 핵심은 Read 측의 Thin Read Layer이다. 도메인을 우회해 DB에서 직접 DTO로 투영하면 임피던스 불일치, getter 노출, 리포지토리의 쿼리 메서드 난립 문제가 한꺼번에 사라진다.
- Event Sourcing은 “무엇”이 아니라 “어떻게”를 저장 한다. 비즈니스가 5년 후 데이터를 어떻게 보고 싶을지 예측 불가능하므로, 이벤트로 기록해두면 그 시점에서 어떤 모델이든 재구성할 수 있다.
- 이벤트 스토리지는 단 두 테이블로 시작 가능하다. Events와 Aggregates 테이블, optimistic concurrency, Rolling Snapshot, Events 테이블의 sequence number를 활용한 큐 동작까지 점진적으로 확장할 수 있다.
한국 실무에서의 적용 포인트
💡 작게 시작하자
Greg Young 자신도 문서 말미에서 “이 분리가 모든 팀에 필요한 것은 아니다” 라고 분명히 말합니다. 국내 대부분의 SaaS/사내 시스템 수준이라면, 처음부터 Event Sourcing + CQRS를 풀로 적용할 필요는 없습니다. 먼저 Command/Query 분리부터 시도하고(같은 DB를 두고 메서드 단위만 분리해도 충분한 개선), 경쟁 우위가 걸린 Bounded Context에서 선택적으로 이벤트 저장을 도입하는 것이 현실적입니다.
⚠️ 가장 흔한 함정
- • 전체 시스템에 Event Sourcing을 적용하려다 YAGNI 위반 + 팀 학습 부담 폭발.
- • Event 이름을 명사나 명령형으로 만드는 실수 (“MakePurchase” X, “PurchaseCompleted” O).
- • Command에 Aggregate의 모든 데이터를 담아 결국 DTO 업/다운으로 회귀.
- • Thin Read Layer를 또 하나의 ORM 기반 “제2의 도메인 모델”로 만드는 것.
더 읽어보기
원문은 “WORKS CITED”에 Martin Fowler의 Anemic Domain Model, Eric Evans의 Domain Driven Design(2001), Streamlined Object Modelling(Jill Nicola, 2002), Scott Ambler의 Object-Relational Impedance Mismatch, Microsoft Inductive User Interface Guidelines(2001)를 참고 문헌으로 듭니다. CQRS와 Event Sourcing을 처음 접하는 분이라면 Evans의 원저서와 함께 이 문서를 한 번 정독하고, 이후 실제 이벤트 스토어 (EventStore, Kurrent, Axon 등) 구현체의 문서를 따라가는 순서가 자연스럽습니다.
📝 이 글의 핵심 정리
- •Greg Young의 CQRS Documents 56페이지 전체를 한국어로 번역·해설했습니다.
- •판에 박힌 아키텍처 → Task Based UI → Command → CQRS → Event Sourcing → Event Storage의 흐름이 하나의 이야기로 이어집니다.
- •한국 실무에서는 Command/Query 메서드 분리부터 점진적으로 도입하는 편이 현실적입니다.
- •Event Sourcing은 경쟁 우위가 있는 Bounded Context에 선택적으로 적용할 때 가장 ROI가 높습니다.
원문 저자: Greg Young · 출처: http://cqrsinfo.com · 번역 및 해설: Treeru (2026-04-14)
댓글
(6)로그인 하면 댓글을 작성할 수 있습니다.
Greg Young 원문을 처음부터 끝까지 한글로 읽을 수 있다는 것만으로도 가치가 큽니다. 특히 Event Storage 구현 파트 번역이 깔끔해서 팀 스터디 자료로 쓸 수 있겠어요.
Anemic Domain Model이 왜 문제인지, CQRS가 왜 그 대안이 되는지 흐름이 자연스럽게 이어집니다. 원문의 뉘앙스까지 살아있어서 좋네요.
Rolling Snapshot, Optimistic Concurrency 등 실전 이슈가 원문 그대로 번역되어 있어서 도입 결정 전에 꼭 읽어볼 만합니다.
관련 글
© 2026 TreeRU. All rights reserved.
본 콘텐츠의 저작권은 TreeRU에 있으며, 출처를 밝히지 않은 무단 전재 및 재배포를 금합니다. 인용 시 출처(treeru.com)를 반드시 명시해 주세요.