도메인 이벤트 디스패치와 처리를 통한 비즈니스 로직 분리
James Reed
Infrastructure Engineer · Leapcell

소개
빠르게 발전하는 백엔드 개발 환경에서 복원력 있고 확장 가능하며 유지보수 가능한 시스템을 구축하는 것이 가장 중요합니다. 애플리케이션이 복잡해짐에 따라 서비스 계층 내의 비즈니스 로직이 얽히는 것은 종종 구성 요소 간의 긴밀한 결합을 초래합니다. 이러한 긴밀한 결합은 코드를 변경, 테스트 및 이해하기 어렵게 만들어, 마이크로서비스 아키텍처 내에서도 마치 "모놀리식"인 것처럼 느껴지게 합니다. 이 문제를 해결하고 보다 모듈식 설계를 촉진하는 가장 효과적인 전략 중 하나는 도메인 이벤트를 신중하게 사용하는 것입니다. 도메인 이벤트를 채택함으로써 비즈니스 로직의 여러 부분을 상당히 분리할 수 있어, 독립적으로 변경 및 상태 전환에 반응할 수 있습니다. 이 글에서는 실제 백엔드 프레임워크 내에서 도메인 이벤트가 어떻게 디스패치되고 처리되어 진정한 분리를 달성하는지 살펴보고, 보다 강력하고 유연한 시스템 아키텍처를 향한 길을 제시합니다.
핵심 개념 및 원칙
구현 세부 사항을 살펴보기 전에 도메인 이벤트의 기반이 되는 핵심 개념을 이해하는 것이 중요합니다.
도메인 이벤트 (Domain Event)
도메인 이벤트는 도메인 내에서 발생한 사건으로, 동일한 도메인(인프로세스) 또는 다른 도메인(아웃프로세스)의 다른 부분이 인지하기를 원하는 것입니다.
이는 비즈니스 프로세스에서 중요한 변경 또는 발생을 나타냅니다. 예를 들어, OrderPlacedEvent
, UserRegisteredEvent
또는 ProductStockUpdatedEvent
등이 있습니다.
도메인 이벤트는 과거 발생 사건의 변경 불가능한 기록으로, 일단 생성되면 변경할 수 없습니다.
이는 최종 일관성 시스템 및 반응형 아키텍처를 구현하는 데 중요합니다.
이벤트 디스패처 (Event Dispatcher)
이벤트 디스패처는 도메인 이벤트를 받아 등록된 모든 이벤트 핸들러에게 브로드캐스트하는 구성 요소입니다. 애플리케이션 내에서 중앙 허브 또는 메시지 버스 역할을 하며, 관심 있는 당사자들이 서로에 대한 직접적인 지식 없이 도메인 이벤트에 대한 알림을 받도록 보장합니다.
이벤트 핸들러 (Event Handler)
이벤트 핸들러는 특정 유형의 도메인 이벤트를 수신 대기하고, 이에 대한 응답으로 특정 비즈니스 로직을 실행하는 구성 요소입니다. 핸들러는 이벤트에 대한 반응을 캡슐화하여, 이벤트 발생자(이벤트를 게시한 애그리게이트 또는 서비스)가 자신의 이벤트에 누가 관심이 있는지 또는 수신 시 무엇을 할 것인지 모르게 합니다.
애그리게이트 루트 (Aggregate Root)
도메인 주도 설계(DDD)에서 애그리게이트 루트는 단일 단위로 취급될 수 있는 도메인 객체들의 클러스터입니다. 이는 애그리게이트 내의 객체에 대한 모든 변경이 일관되게 발생하도록 보장합니다. 애그리게이트 루트는 종종 도메인 이벤트의 발생자이며, 상태가 변경될 때 이를 게시합니다.
분리 (Decoupling)
분리란 소프트웨어 구성 요소 간의 상호 의존성을 줄이는 것을 의미합니다. 도메인 이벤트의 맥락에서, 이는 이벤트를 발생하는 구성 요소가 이벤트의 소비자를 알 필요가 없으며, 그 반대도 마찬가지임을 의미합니다. 이는 변경의 파급 효과를 줄이고 시스템의 유연성과 유지보수성을 증가시킵니다.
도메인 이벤트 처리의 원칙
분리를 위해 도메인 이벤트를 사용하는 핵심 원칙은 이벤트 생산자가 이벤트 자체에 대해서만 알고, 소비자에 대해서는 알지 못해야 한다는 것입니다. 마찬가지로, 소비자는 자신이 관심 있는 이벤트에 대해서만 알고, 발생자에 대해서는 알지 못해야 합니다. 이 "게시-구독" 메커니즘은 매우 분리된 아키텍처를 육성합니다.
작동 방식
- 이벤트 생성: 애그리게이트 루트 또는 서비스의 상태를 변경하는 중요한 비즈니스 작업이 발생하면, 이 발생을 기록하기 위해 도메인 이벤트가 생성됩니다. 이 이벤트는 잠재적 소비자가 필요로 하는 모든 관련 데이터를 캡처합니다.
- 이벤트 디스패치: 트랜잭션 내의 애그리게이트 루트 또는 서비스는 여러 도메인 이벤트를 누적할 수 있습니다. 트랜잭션의 성공적인 완료 전 또는 후에, 이 이벤트들은 이벤트 디스패처로 디스패치됩니다.
- 이벤트 처리: 이벤트 디스패처는 이벤트를 등록된 모든 이벤트 핸들러로 라우팅합니다. 각 핸들러는 이벤트에 대한 응답으로 특정 비즈니스 로직을 실행합니다. 이 실행은 동기식(동일 트랜잭션 내) 또는 비동기식(별도의 스레드 또는 프로세스)으로 수행될 수 있습니다.
Python 백엔드 (FastAPI 및 간단한 이벤트 디스패처 사용) 구현 예제
시스템에 새 사용자가 등록하는 일반적인 시나리오를 살펴보겠습니다. 사용자가 등록되면 다음과 같은 작업을 수행할 수 있습니다:
- 환영 이메일 보내기.
- 등록 활동 기록.
- 사용자 통계 업데이트.
도메인 이벤트가 없으면 UserService
는 EmailService.sendWelcomeEmail()
, ActivityLogService.logUserRegistration()
, StatisticsService.updateUserStatistics()
를 직접 호출합니다.
이는 결합을 상당히 좁힙니다.
먼저 이벤트와 간단한 디스패처를 정의해 보겠습니다.
# events.py from dataclasses import dataclass from datetime import datetime @dataclass(frozen=True) class DomainEvent: occurred_on: datetime @dataclass(frozen=True) class UserRegisteredEvent(DomainEvent): user_id: str username: str email: str # event_dispatcher.py from typing import Dict, List, Callable, Type from collections import defaultdict class EventDispatcher: def __init__(self): self._handlers: Dict[Type[DomainEvent], List[Callable]] = defaultdict(list) def register_handler(self, event_type: Type[DomainEvent], handler: Callable): self._handlers[event_type].append(handler) def dispatch(self, event: DomainEvent): if type(event) in self._handlers: for handler in self._handlers[type(event)]: handler(event) else: print(f"No handlers registered for event type: {type(event).__name__}") # Global dispatcher instance (for simplicity in this example) event_dispatcher = EventDispatcher()
다음은 이벤트 핸들러입니다.
# handlers.py from events import UserRegisteredEvent def send_welcome_email(event: UserRegisteredEvent): print(f"Sending welcome email to {event.email} for user {event.username} (ID: {event.user_id})") # In a real application, this would integrate with an email sending service. def log_user_activity(event: UserRegisteredEvent): print(f"Logging user registration activity for user {event.username} (ID: {event.user_id})") # In a real application, this would store activity in a database or log stream. def update_user_statistics(event: UserRegisteredEvent): print(f"Updating user statistics for new user {event.username} (ID: {event.user_id})") # In a real application, this would update a statistics service or database.
이제 FastAPI 애플리케이션 내에서 서비스 로직에 통합해 보겠습니다.
# main.py or user_service.py from datetime import datetime from fastapi import FastAPI, HTTPException from pydantic import BaseModel from events import UserRegisteredEvent from event_dispatcher import event_dispatcher from handlers import send_welcome_email, log_user_activity, update_user_statistics app = FastAPI() # Register handlers when the application starts @app.on_event("startup") async def startup_event(): event_dispatcher.register_handler(UserRegisteredEvent, send_welcome_email) event_dispatcher.register_handler(UserRegisteredEvent, log_user_activity) event_dispatcher.register_handler(UserRegisteredEvent, update_user_statistics) print("Event handlers registered.") class UserCreate(BaseModel): username: str email: str password: str # In a real application, this would interact with a database and perform password hashing. # For demonstration, we'll use a dummy user storage. dummy_users_db = {} user_id_counter = 0 @app.post("/users/register") async def register_user(user_data: UserCreate): global user_id_counter if user_data.username in dummy_users_db: raise HTTPException(status_code=400, detail="Username already taken") user_id_counter += 1 user_id = f"user-{user_id_counter}" dummy_users_db[user_data.username] = {"id": user_id, **user_data.model_dump()} # Create and dispatch the domain event user_registered_event = UserRegisteredEvent( occurred_on=datetime.utcnow(), user_id=user_id, username=user_data.username, email=user_data.email ) event_dispatcher.dispatch(user_registered_event) return {"message": "User registered successfully", "user_id": user_id} if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)
이 예에서 register_user
가 호출될 때:
- 사용자가 "생성"됩니다(시뮬레이션).
UserRegisteredEvent
가 인스턴스화됩니다.- 이 이벤트는
event_dispatcher
를 통해 디스패치됩니다. event_dispatcher
는UserRegisteredEvent
에 대해 등록된 핸들러를 반복하여send_welcome_email
,log_user_activity
,update_user_statistics
를register_user
함수가 이러한 특정 작업에 대해 알 필요 없이 호출합니다.
이 설정은 상당한 분리를 달성합니다:
register_user
함수(또는UserService
)는 이제 사용자 생성 및 이벤트 디스패치만 알면 됩니다. 사용자 등록에 대한 응답으로 무엇이 발생하는지는 알지 못합니다.UserRegisteredEvent
에 대한 새 핸들러는UserService
로직을 변경하지 않고 추가, 수정 또는 제거될 수 있습니다. 이는 유지보수를 극적으로 단순화하고 시스템의 기능을 확장합니다.- 각 핸들러는 단일 책임을 다하며 단일 책임 원칙을 준수합니다.
이벤트 소싱 및 비동기 처리
더 크고 복잡한 시스템 또는 장기 실행 작업 처리 시, 동기식 이벤트 처리(위에 표시된 대로)는 이상적이지 않을 수 있습니다. 비동기 처리를 도입할 수 있습니다:
- 비동기 핸들러: 이벤트 핸들러는 별도의 스레드에서 실행되거나 프레임워크가 지원하는 경우 비동기 I/O를 사용하여 설계될 수 있습니다.
- 메시지 큐: 진정한 분산 및 복원력을 갖춘 시스템을 위해, 도메인 이벤트는 종종 메시지 큐(예: RabbitMQ, Kafka, AWS SQS)에 게시됩니다. 별도의 마이크로서비스 또는 워커가 이러한 이벤트를 소비하고 비동기적으로 처리합니다. 이 패턴은 이벤트 기반 아키텍처 및 마이크로서비스 통신의 기본입니다.
- 이벤트 소싱: 이벤트 소싱 시스템에서는 애플리케이션의 상태가 도메인 이벤트 시퀀스로 유지됩니다. 현재 상태를 저장하는 대신, 모든 변경 사항은 이벤트로 저장됩니다. 이는 강력한 감사 추적을 제공하고 언제든지 애플리케이션 상태를 재구성할 수 있는 능력을 제공합니다.
결론
백엔드 프레임워크 내에서 도메인 이벤트를 배포하고 처리하는 것은 비즈니스 로직에서 의미 있는 분리를 달성하는 강력한 전략입니다. "무언가 발생했다"(이벤트 게시) 행위와 "발생한 일에 반응하기"(이벤트 처리)를 명확하게 분리함으로써, 우리는 보다 모듈식이고 확장 가능하며 쉽게 발전하는 시스템을 구축합니다. 이 접근 방식은 개발자 생산성을 높일 뿐만 아니라, 이벤트 기반 마이크로서비스와 같은 보다 정교한 아키텍처 패턴의 기반을 마련합니다. 도메인 이벤트를 채택하면 긴밀하게 결합된 모놀리스가 독립적으로 반응하는 구성 요소의 별자리가 되어, 보다 복원력 있고 적응력 있는 소프트웨어 생태계를 이끌어냅니다.