복사되었습니다.

Event와 command의 차이점 쉽게 이해하기 - Events vs commands in EDA

Cover Image for Event와 command의 차이점 쉽게 이해하기 - Events vs commands in EDA

Event Driven Architecture(EDA)를 시스템에 적용할 때는 거창한 이름 때문에 마치 모든 것을 event로 처리해야 할 것만 같다. 반은 맞고 반은 틀리다. Command라는 개념을 event와 분리하여 생각할 필요가 있다. Event와 command가 도대체 뭐가 다른지 간단히 정리해보자.

공통점: Event와 command는 둘 다 message다.

EDA에서는 다른 구성요소 사이의 느슨한 결합을 위해, 서로를 직접적으로 호출하지 않고 messaging system을 거쳐서 통신하도록 강제한다. 즉, EDA에서 모든 구성요소들이 서로 주고받는 것은 event라기보다는 message라고 인식하고 출발해야 한다.

Event와 command는 둘 다 message의 일종이다. 객체지향적으로 생각해보면, message라는 부모 클래스를 각각 상속한 두 종류의 자식 클래스라고 생각하면 된다. 이 관계를 다이어그램으로 표현하면 다음과 같다.

event vs command class diagram

Message라는 것은 기본적으로 아무런 동작(메서드)도 포함하지 않는 껍데기 dataholder일 뿐이다. 다시 말해서, 서로 다른 시스템 사이에 교환할 데이터만 갖고 있다는 뜻이다. 따라서, 표면적으로는 event와 command 사이에 별다른 차이가 없다.

차이점: Event와 command는 서로 의도가 다르다.

Event와 command 사이에 표면적인 차이는 없지만 의미적인 차이는 있다. 이름에서 느껴지는 뉘앙스를 있는 그대로 받아들이면 두 개념을 구분하기 쉽다.

  • Event: 시스템의 동작이나 상태 변화에 의해 발생한 사건의 기록
  • Command: 시스템의 특정 행위자에게 보내는 명령

실제로 이 두 가지 개념을 구분하기 위해 event나 command로 사용될 클래스 이름을 지을 때 관습이 있다. Event는 무엇인가 발생되었다는 식의 "과거형" 문구를 사용하고, command는 "명령형" 문구를 사용한다.

예를 들어, "사용자 계정 생성"과 관련된 event와 command 클래스는 각각 다음과 같이 이름을 붙이는 식이다.

class UserAccountCreated(Event):
  ...  # 사용자 계정이 생성되었다.

class CreateUserAccount(Command):
  ...  # 사용자 계정을 생성해라.

이 미묘한 의미적 차이로 인해 event와 command는 각각 다른 방식으로 처리된다. 가장 두드러진 차이점은 다음과 같이 세 가지로 요약할 수 있다.

  • 처리 주체: 메시지를 수신하고 처리하는 handler가 특정되어있는가?
  • 오류 처리: Handler의 메시지 처리 중 오류가 발생할 경우 어떻게 대응해야 하는가?
  • 결과 반환: 메시지 처리 결과를 반환해주는가?

처리 주체: 누가 handling해야 할까?

event handler vs command handler

Event는 broadcast 방식의 메시지이다. 특정 event에 관심이 있는 모든 listener들에게 message가 전달된다. 만약, 아무도 event를 구독하지 않았다면 event는 아무에게도 전달되지 않는다. 그래도 전혀 상관없다. Log에 기록만 해두면 그만이다.

반면, command는 특정 handler를 지목하여 명시적으로 전달되는 message이다. 따라서, 일반적으로 특정 command를 구독하는 handler는 하나다. Handler가 없는 command는 의미가 없다. 시스템에 정의되지 않은 함수를 호출하는 것과 마찬가지다.

오류 처리: 문제가 있다면 어떻게 대응해야 할까?

Event의 경우 message를 전달받는 handler가 없더라도 괜찮다고 했다. 즉, event는 시스템의 핵심적인 흐름에서 필수 요소가 아니라는 뜻이다. 쉽게 말해서, event를 발생시키는 sender 측에서는 event가 오류없이 잘 처리되었는지 전혀 관심이 없다.

따라서, event handler 측에서는 event 처리 중 문제가 있을 때 exception을 raise해서 시스템 중단을 요구하거나 rollback을 하는 등의 과잉 대처를 할 필요가 없다. 오류가 발생했다는 또 다른 event를 발생시키거나 조용히 log에 기록해둔 뒤, 다음 event 처리를 이어가면 된다.

반면, command의 sender는 특정 행위가 일어나기를 기대하며 message를 보내는 것이다. 따라서, command 처리 중 문제가 있으면 command handler는 app의 흐름을 제어하고 이를 적절히 처리할 의무가 있다.

특히 app의 상태를 변경하는 command라면 반드시 transaction 관리를 통해 일관성을 유지해주어야 한다. Command handler는 commit, rollback을 통해 시스템의 일관성을 관리하고, 필요한 경우 exception을 raise해서 시스템에게 오류가 발생했음을 통보한다.

결과 반환: 결과를 즉시 받을 수 있을까?

Event는 일종의 notification, 즉, 통보에 가깝기 때문에 받는 쪽에서 메시지 처리 결과를 반환해주리라 기대하지 않는다. Event sender 쪽에서는 event 메시지 전송 후 더 이상 해당 메시지에 관심을 갖지 않는다. 따라서, event handler들은 메시지 처리 결과를 반환할 의무가 없다.

반면, command의 경우 command sender 쪽에서 오류 처리와 마찬가지로 메시지 처리 결과에 대한 피드백을 원한다. 따라서, command handler는 command 메시지의 처리 결과를 반환해줄 필요가 있다. 동기적으로 반환해주지 않더라도 적어도 event를 발생시켜줄 필요가 있다.

정리하자면, event handler들은 메시지 처리 결과를 즉시 반환해주지 않고, command handler는 메시지 처리 결과를 반환해줄 수 있다. 만약 시스템 전체를 비동기적으로 동작하도록 하려면, command handler가 결과를 반환하는 대신 event를 발생시키도록 구성하면 된다.

구현 방법: Event와 command 메시지 수신 및 처리 과정

앞서 살펴본 차이점을 바탕으로 대략적인 코드를 작성해보자. 한 가지 분명한 사실은 여러 차이점으로 인해 event와 command를 처리하는 과정은 별도로 분리하여 구현해야 한다는 점이다. Python pseudocode를 통해 event와 command를 처리하는 과정을 각각 살펴보자.

Event handling 구현하기

  • 처리 주체: Event handler가 아예 없거나, 여럿일 수 있다.
  • 오류 처리: 메시지 처리 중 오류가 발생해도 넘어간다.
  • 결과 반환: 메시지 처리 결과를 반환하지 않는다.
def handle_event(event: Event) -> None:
  event_handlers = get_event_handlers(event=event)
  for event_handler in event_handers:  # 0 ~ N handlers
    try:
      event_handler.handle(event)  # No result
    except Exception as e:
      logger.error(f"Error occurred while handling {event}: {e}")
      continue  # Keep going

Command handling 구현하기

  • 처리 주체: Command handler는 하나다.
  • 오류 처리: 메시지 처리 중 오류가 발생하면 이 사실을 알린다.
  • 결과 반환: 메시지 처리 결과를 반환한다.
def handle_command(command: Command) -> Any:
  command_handler = get_command_handler(command=command)  # Only 1 handler
  try:
    result = command_handler.handle(command)
    return result  # Send result
  except Exception as e:
    logger.error(f"Error occurred while handling {command}: {e}")
    raise e  # Propagate error

예시: Event와 command 중 무엇으로 정의해야 할까?

대략 event와 command의 의미적인 차이는 이해했으리라 생각한다. 그런데, 막상 구현 단계에 들어가면 여전히 어떤 것을 event로 정의해야 하는지, 아니면 command로 정의해야 하는지 헷갈리기 마련이다. 대략 다음의 가이드를 따르면 편하다.

  • Event는 기록해야 할 사건들을 모델링한다. 이를 처리하는 handler들은 부수적인 기능을 제공한다.
  • Command는 핵심 use case를 모델링한다. 이를 처리하는 handler는 app의 핵심적인 기능을 제공한다.

Event는 result다.

기능적으로 on/off 시키거나 처리 방법을 다른 것으로 대체해도 상관없는 것들은 event와 event handler로 모델링한다. 대표적으로 notification 기능을 예시로 들 수 있다. App에서 사용자가 어떤 행동을 했을 때, 결과를 비동기적으로 사용자에게 알리는 기능을 떠올려보자.

예를 들어, 사용자가 비밀번호를 변경했을 때, 비밀번호가 변경되었음을 메일로 보내주는 기능은 말그대로 "부수적인" 기능이다. 메일이 제대로 도착하지 않았다고 해서 app에서 오류가 발생하거나 핵심적인 로직의 흐름을 방해할 필요가 전혀 없다.

메일 알림 기능은 언제든 on/off를 해도 상관이 없다. 게다가 알림 방식을 메일 대신 문자나 카카오톡 등으로 대체해도 된다. 사용자가 원한다면 여러 방식 모두 동시에 사용할 수도 있다. 쉽게 말해서, optional한 기능들은 event와 event handler들로 모델링한다고 생각하면 편하다.

따라서, 사용자의 비밀번호 변경과 관련된 event에는 다음과 같이 사용자 정보와 변경 시점 등의 정보가 들어갈 것이다. 그리고, 이 event를 담당하는 handler들에는 각자의 관심사에 맞게 메일이나 문자 등을 보내고 결과는 반환하지 않는 코드들이 포함될 것이다.

class PasswordChanged(Event):
  user_id: int
  changed_at: datetime
  ...

class EmailHandler(EventHandler):
  def handle(self, event: PasswordChanged) -> None:
    ...
    self.send_email(email, content)

class SmsHandler(EventHandler):
  def handle(self, event: PasswordChanged) -> None:
    ...
    self.send_sms(phone_number, message)

그런데, handler 말고 event 자체를 모델링할 때는 기능적 측면에만 집중할 필요는 없다. App에서 발생하는 모든 종류의 "행동의 결과"들을 event로 만들면 된다. 당장 필요는 없더라도 "기록용"으로 남겨둔다면, 디버깅에 활용할 수도 있고 언젠가 추가 기능을 개발할 때도 편해진다.

물론 event 종류가 많아지면 처리 성능 저하, 유지보수 부담 증가 등의 문제를 겪게 된다는 사실을 간과하면 안된다. 그래도 개인적으로는 app에서 발생하는 모든 기록할 만한 것들을 logging 차원에서 event로 모델링하는 것을 선호한다. 팀 상황에 맞는 결정을 내리도록 하자.

Command는 request다.

앞서 언급했듯이 command는 app에 보내는 명령/요청이다. 쉽게 생각하려면 app에 EDA를 적용하지 않았을 때를 가정하면 된다. 일반적으로 구현하여 사용자들에게 제공하는 메서드들이나 API들을 떠올리면 된다.

에를 들어, 비밀번호를 변경하는 행위 자체는 누군가가 명시적으로 처리해야 한다. 비밀번호를 변경하는 도중 실패했다면 오류를 사용자에게 알리거나 rollback으로 DB update를 취소해야 한다. 성공했다면 commit하여 DB에 반영하고 사용자에게 성공했다는 결과를 반환해야 한다.

따라서, 사용자의 비밀번호 변경과 관련된 command에는 다음과 같이 사용자 정보와 매개변수들이 포함될 것이다. 이를 담당하는 handler에는 사용자의 비밀번호를 실제로 변경한 뒤 PasswordChanged 이벤트를 발생시키고, 변경 결과를 반환해주는 코드가 포함될 것이다.

class ChangePassword(Command):
  user_id: int
  old_password: str
  new_password: str
  ...

class PasswordHandler(CommandHandler):
  def handle(self, command: ChangePassword) -> Any:
    with self.session() as session:
      try:
        result = self.change_password(
          user_id=user_id,
          old_password=old_password,
          new_password=new_password,
        )
        session.commit()
        MESSENGER.send_event(PasswordChanged(user_id=user_id))
        return result
      except Exception as e:
        session.rollback()
        MESSENGER.send_event(PasswordChangeFailed(user_id=user_id, reason=e))
        raise e

비밀번호 변경 과정의 transaction을 관리하고, 성공 여부에 대해 event를 발생시키고 있다. 메서드의 인자가 command라는 점 외에는 사용자 use case를 처리해주는 일반적인 서비스 로직과 크게 다르지 않다는 것을 확인할 수 있다.

한 가지 주의할 점은 commit이든 rollback이든 DB update를 명시적으로 한 후에 event를 발생시켜야 한다는 것이다. Event를 handling하는 것은 시스템에서 필수적인 요소가 아니기 때문에 DB transaction과 관련된 중요한 작업이 방해를 받아서는 안된다.

주의사항: 여전히 헷갈리는 일관성 문제

앞선 내용들을 통해 event handler에는 부수적인 기능들을 담고, command handler에는 핵심적인 기능들을 담는다고 설명했다. 하지만, 실제로는 경계를 명확히 구분하기가 쉽지 않다. 특히 다양한 단계를 거쳐야 하는 큰 작업들을 event와 command로 쪼갤 때 매우 혼란스럽다.

명확하게 이해하고 모델링하려면 transaction 관점에서 "일관성 경계(aggregate)"와 "결과적 일관성(eventual consistency)"에 대한 지식이 있어야 한다. Harry Percival, Bob Gregory의 <Architecture Patterns with Python>에서는 다음과 같이 설명하고 있다.

이런 예제를 통해 메시지를 커맨드와 이벤트로 분리하는 이유를 더 잘 이해할 수 있다. 사용자가 시스템이 어떤 일을 하기를 원한다면 이 요청을 커맨드로 표현한다. 커맨드는 한 애그리게이트를 변경해야 하고, 전체적으로 성공하거나 전체적으로 실패해야 한다. 시스템이 수행하는 다른 북키핑, 정리, 통지는 이벤트를 통해 발생한다. 커맨드가 성공하기 위해 이벤트 핸들러가 성공할 필요는 없다.

Aggregate, 결과적 일관성 등의 개념들은 이 글의 범위를 벗어나므로 다른 글에서 자세히 다루도록 하겠다. 우선 인용문에서 언급하듯이, 하나의 transaction 안에서 처리해야 할 것은 command로 모델링하고, event는 이 흐름을 방해하지 않도록 설계한다고 이해하고 넘어가자.

consistency problem with multi-step task

예를 들어, 하나의 큰 작업을 자잘하게 event들로 쪼갠 뒤, 순차적으로 모든 event들이 정상 처리되어야 작업을 "완료되었다"고 판단한다고 가정해보자. Event handler들 중 일부는 DB에 변경사항을 commit하는 경우도 있을 것이다.

만약 하나의 작업을 이루는 event들 중 일부가 실패하면 어떻게 될까? DB에 이미 commit된 내용들을 rollback할 수가 없다. 어떤 내용들을 다시 되돌려야 하는지 추적도 어려울 뿐더러, 원래의 값은 무엇이었는지도 알기 어렵다. DB는 "어중간한 상태"에 남게 된다.

따라서, 다시 말하지만 일관성을 유지해야 하는, 즉, 하나의 transaction 안에서 관리되어야 하는 "큰 작업"은 command로 모델링해야 한다. Event가 app의 일관성을 깨드리는 일이 없도록 주의하여 설계하자.

Comments

    More Posts

    Domain model에서 repository를 직접 사용해도 될까? - 도메인 모델의 영속성 무지

    Repository는 인프라적인 요소에 가깝다고 할 수 있다. 그런데, 이 repository를 도메인 모델에서 직접 사용해도 괜찮을까? DDD와 관련된 여러 참고 문서들에서는 이에 대해 통일되지 않은 견해를 보이고 있다. 이 글을 통해 여러 견해들을 한꺼번에 모아서 생각해보자.

    Aggregate의 문제점과 바람직한 설계 방법 - DDD aggregate diet

    DDD의 aggregate는 일관성 관리를 위해 매우 중요한 개념이지만, 덩치가 커질수록 성능과 확장성 측면에서 문제가 발생하게 된다. Composition 기반 aggregate의 문제점에 대해 자세히 살펴보고, 효율적인 aggregate를 설계하기 위한 방법들을 알아보자.

    Aggregate란 무엇인가 도대체! - DDD aggregate의 기초 쉽게 이해하기

    DDD에서는 aggregate라는 용어가 자주 등장한다. DDD의 다양한 개념들을 이해하기 위해서는 aggregate에 대한 이해가 선행되어야 하는데, 보통 DDD 자료들에서는 aggregate에 대한 설명이 중후반부에 등장한다. DDD 초보자도 쉽게 이해할 수 있도록 aggregate를 겉핥기해보자.

    Font Size