설계없이 시작가능한 AI에 의한 즉흥 개발방법도 지속적으로 유행하고 발전해 나가겠지만 상대적으로
느린 개발방법처럼 보이는 도메인 모델을 먼저 고민하고 시작하는 DDD(+이벤트 드리븐) 개발방법에서도 AI툴을 활용하는 방법이 없을까?
고민하다가 시도된 변종실험으로 Qlerify를 간단하게 이용해보았으며 도메인모델을 통해 코드생성도 후속으로 시도해보았습니다.
시도된 프롬프트 내용은 이러한 정보 Style이 이용되었으며, 나머지는 AI응답에 의해 생성된 컨텐츠입니다. |
이용된 AI 툴 :
장바구니 기능을 만들고 싶은 단순하게 장바기느를 구매만 이용하는게 아닌 장바구니의 이벤트를 활용(담기/빼기..), 이벤트를 추적해 구매자의 최종 구매 심리적 요인과 구매예측을 만들고 싶음 기본 이벤트를 설계해.... |

이 툴을 사용하기전 DDD에서 소개하는 개념을 간단하게 살펴보겠습니다.
CQRS는 명령(Command) 과 조회(Query) 를 명확히 책임 분리(Responsibility Segregation) 하여 시스템을 설계하는 아키텍처 패턴입니다.
읽기와 쓰기 모델이 분리되어 각각 독립적으로 최적화될 수 있습니다.
| 개념 | 설명 | DDD 요소와 연결 |
|---|---|---|
| Command | 데이터를 변경하는 요청 (예: 주문 생성) | Command, Aggregate Root, When |
| Command Handler | 명령을 받아 도메인 로직 실행 (비즈니스 규칙 검증 포함) | 도메인 서비스 or 애그리거트 메서드 |
| Aggregate Root | 실제 비즈니스 로직이 작동하는 단위. 상태 변경을 캡슐화 | Aggregate Root |
| Event | Command 처리 후 발생하는 결과 (이벤트 소싱일 경우 저장됨) | Then, 이벤트 핸들러 |
| Query | 데이터를 조회하는 요청 (상태 변경 없음) | Read Model, Projection, DTO |
| Read Model | 조회에 최적화된 모델 (DB View, Cache, Elastic 등) | Read Model, Given |
정의: 시스템에 "무언가를 하라"고 요청하는 객체.
예시: CreateOrderCommand, ChangeUserEmailCommand
특징:
상태를 변경하는 의도(Intent) 를 나타냄
보통 쓰기 작업(write) 에 해당하며, 반환값은 없거나 간단한 결과 (ex: 성공/실패, ID 등)
컨트롤러나 API에서 입력값을 받아 생성됨
DDD 문맥에서 역할:
Aggregate에게 전달되어 비즈니스 규칙에 따라 실행됨
CQRS 구조에서는 Query와 명확히 분리됨
정의: 관련된 도메인 객체들의 일관성을 책임지는 루트 엔티티
예시: Order는 OrderItem들을 포함하지만, 외부에서는 Order를 통해서만 접근
역할:
모든 상태 변경은 Aggregate Root를 통해 수행
도메인 규칙과 트랜잭션 경계를 보장
ID로 식별됨
실제 코드 예시 (Kotlin):
class Order(private val id: UUID, private val items: List<OrderItem>) { fun addItem(item: OrderItem) { /* 비즈니스 규칙 검증 후 추가 */ } } |
정의: 조회를 빠르게 하기 위해 최적화된 별도의 데이터 구조
특징:
Command 모델과 분리됨 (특히 CQRS에서 중요)
보통 DB View, Projection, DTO 등으로 구성
일관성보다는 응답 속도를 중시함
예시: OrderSummaryView, UserDashboardDto
정의: 도메인 동작을 검증하기 위한 테스트 시나리오 패턴
→ BDD(Behavior-Driven Development) 스타일의 도메인 테스트에 사용
형식:
Given: 초기 상태(이전 이벤트 또는 존재하는 Aggregate 상태)
When: 명령(command)을 실행할 때
Then: 예상되는 결과(이벤트 또는 상태 변화)
예시 (Kotlin 테스트 코드 형태):
Given(주문이 생성되어 있고) When(상품을 추가하면) Then(상품이 주문에 포함되어야 한다) |
given(OrderCreatedEvent) when(AddItemCommand) then(shouldEmit(ItemAddedEvent)) |



이 도메인 모델을 참고해~ 이벤트를 처리하는 녀석들을 액터모델로 만들고 -도메인 모델이 이미지로 첨부되었습니다. |

업로드하신 도메인 모델 이미지를 참고하고 기존 액터 구현 (HelloActor, BulkProcessor, HelloRouter) 코드 스타일을 기반으로 이벤트 기반 도메인 모델을 액터 기반으로 리팩토링하고 퍼시스턴스를 연동하는 예시를 제안드리겠습니다.
도메인 이벤트를 처리하는 액터 정의
이벤트들을 저장하기 위해 Pekko Persistence (Event Sourcing 방식) 연동
기존 스타일에 맞춘 구현 (Router, Buffering 포함)
업로드하신 이미지를 분석한 결과, 다음과 같은 도메인 흐름이 있었습니다:
예: 고객 등록 이벤트 흐름
1. RegisterCustomerCommand → CustomerRegisteredEvent → CustomerState 업데이트 |
// 명령
sealed interface CustomerCommand
data class RegisterCustomer(val name: String, val replyTo: ActorRef<CustomerResponse>) : CustomerCommand
// 이벤트
sealed interface CustomerEvent
data class CustomerRegistered(val customerId: String, val name: String) : CustomerEvent
// 상태
data class CustomerState(val customers: Map<String, String> = emptyMap()) {
fun applyEvent(event: CustomerRegistered): CustomerState {
return copy(customers = customers + (event.customerId to event.name))
}
}
// 응답
sealed interface CustomerResponse
data class RegisterSuccess(val customerId: String) : CustomerResponse |
class CustomerPersistentActor private constructor(
context: ActorContext<CustomerCommand>,
persistenceId: PersistenceId,
) : EventSourcedBehavior<CustomerCommand, CustomerEvent, CustomerState>(
persistenceId,
emptyState = CustomerState(),
commandHandler = commandHandler(),
eventHandler = eventHandler()
) {
companion object {
fun create(entityId: String): Behavior<CustomerCommand> {
return Behaviors.setup { context ->
CustomerPersistentActor(
context,
PersistenceId.ofUniqueId("Customer-$entityId")
)
}
}
private fun commandHandler(): CommandHandler<CustomerCommand, CustomerEvent, CustomerState> {
return newCommandHandlerBuilder()
.forAnyState()
.onCommand(RegisterCustomer::class.java) { state, cmd ->
val event = CustomerRegistered(UUID.randomUUID().toString(), cmd.name)
Effect().persist(event)
.thenReply(cmd.replyTo) { newState -> RegisterSuccess(event.customerId) }
}
.build()
}
private fun eventHandler(): EventHandler<CustomerState, CustomerEvent> {
return newEventHandlerBuilder()
.forAnyState()
.onEvent(CustomerRegistered::class.java) { state, event ->
state.applyEvent(event)
}
.build()
}
}
} |
BulkProcessor나 HelloRouter처럼 다수 이벤트를 분산 처리할 경우:
val customerRouter = Routers.pool(5,
Behaviors.supervise(CustomerPersistentActor.create("routered"))
.onFailure(SupervisorStrategy.restart())
).withRoundRobinRouting() |
pekko.persistence.journal.plugin = "pekko.persistence.journal.inmem" pekko.persistence.snapshot-store.plugin = "pekko.persistence.snapshot-store.local" |
testKit.spawn(CustomerPersistentActor.create("test")) 식으로 ActorTestKit을 활용한 검증도 가능하며, BulkProcessorTest.kt 스타일을 그대로 따를 수 있습니다.
필요 시 Snapshotting, DurableStateBehavior 기반 방식도 함께 설명드릴 수 있습니다.
다음으로 어떤 이벤트나 도메인을 우선 적용해볼까요?
최종 장바구니에 등록된 아이템을 단순하게 구매만 하기위한 기능도 필요함 |
sealed interface CartCommand
data class AddItem(val itemId: String, val replyTo: ActorRef<CartResponse>) : CartCommand
data class Checkout(val replyTo: ActorRef<CartResponse>) : CartCommand
sealed interface CartResponse
data class CartUpdated(val items: List<String>) : CartResponse
data class CheckoutSuccess(val purchased: List<String>) : CartResponse
data class CartState(val items: List<String> = emptyList()) {
fun add(itemId: String) = copy(items = items + itemId)
fun clear() = copy(items = emptyList())
}
|
class CartActor private constructor(
context: ActorContext<CartCommand>,
persistenceId: PersistenceId
) : DurableStateBehavior<CartCommand, CartState>(persistenceId) {
override fun emptyState(): CartState = CartState()
companion object {
fun create(userId: String): Behavior<CartCommand> {
return Behaviors.setup { context ->
CartActor(context, PersistenceId.ofUniqueId("cart-$userId"))
}
}
}
override fun commandHandler(): CommandHandler<CartCommand, CartState> {
return newCommandHandlerBuilder()
.forAnyState()
.onCommand(AddItem::class.java) { state, cmd ->
val updated = state.add(cmd.itemId)
Effect().setState(updated)
.thenReply(cmd.replyTo) { CartUpdated(updated.items) }
}
.onCommand(Checkout::class.java) { state, cmd ->
val purchased = state.items
Effect().setState(state.clear())
.thenReply(cmd.replyTo) { CheckoutSuccess(purchased) }
}
.build()
}
}
|
| 항목 | DurableStateBehavior | EventSourcedBehavior |
|---|---|---|
| 저장 전략 | 최종 상태만 저장 (snapshot-like) | 모든 이벤트를 append-only log로 저장 |
| 조회 성능 | 빠름 (단일 상태 조회) | 느림 (리플레이 필요) |
| 디스크 사용량 | 적음 (단일 상태만 저장) | 많음 (전체 이벤트 저장) |
| 롤백/감사 추적 | 불가능 or 제한적 | 완전한 시간 순 추적 가능 |
| 주 용도 | CRUD 스타일, 단순 상태 저장 (ex. 장바구니, 설정 등) | 이벤트 소싱 기반 복원 필요 시스템 |
| 복원 속도 | 매우 빠름 | 느릴 수 있음 (스냅샷 없을 시 전체 이벤트 재생) |
| 복잡도 | 단순함 | 상대적으로 복잡함 (이벤트 설계 필요) |
| Use Case | 권장 방식 |
|---|---|
| 장바구니 구매 확정 | ✅ DurableStateBehavior |
| 주문 처리 로그 추적 | ❗ EventSourcedBehavior |
| 사용자 프로필 설정 저장 | ✅ DurableStateBehavior |
| 이력 기반 복원 가능한 상태 | ❗ EventSourcedBehavior |
DurableStateBehavior를 기반으로 하되, CheckoutSuccess를 외부 Kafka/DB 등에 이벤트로 publish하려면:
.onCommand(Checkout::class.java) { state, cmd ->
val purchased = state.items
// publishToKafka(purchased) 등의 외부 IO 필요 시 외부 알림 처리 포함
Effect().setState(state.clear())
.thenReply(cmd.replyTo) { CheckoutSuccess(purchased) }
}
|
도메인에서 이벤트 소싱이 필요한 부분과 단순한 상태 저장이 필요한 부분을 잘 분리하면, 시스템 복잡도와 성능 모두 잡을 수 있습니다.
장바구니 외에 구매 주문 처리도 필요하신가요? (예: OrderPlaced → OrderActor로 분리)