Development

3. 이벤트 구현하기 - [이벤트 기반 프로그래밍 시리즈]

성난소 2025. 3. 22. 00:41
목차
1.시작하며
2.이벤트의 조건
2.1.1. 이벤트는 전역적이어야 합니다
2.2.2. 이벤트의 구독 및 구독 해지는 자유로워야 합니다
2.3.3. 반응 행동도 자유로워야 합니다
3.그럼 구현해봅시다
3.1.1. 이벤트 정의하기
3.2.2. 발행과 구독 인터페이스 정하기
3.3.3. 발행과 구독 구현하기
4.프레임워크에서의 이벤트
4.1.Node.js
4.2.Spring Framework
4.3.Unity 엔진
5.마치며

 

시작하며

지난 글 "이벤트 기반 프로그래밍이란"에서는
이벤트란 무엇인지와 함께 이벤트 객체의 특징에 대해 살펴봤습니다.
복습해보자면,
이벤트는 '일어난 사건'입니다.
이벤트 객체는 이벤트에 반응하고, 필요하다면 새로운 이벤트를 발행하는 객체입니다.
이벤트 객체는 함수객체와 달리 능동적이며 독립적이고 수평적입니다.

이벤트 기반 프로그래밍이 왜 좋은지, 이벤트 객체가 어떻게 동작하는지는 알겠는데...
이걸 어떻게 구현하면 좋을까요?
구현하는데 있어 주의점은 없을까요?


이벤트의 조건

이벤트 객체를 구현하기에 앞서,
먼저 이벤트라는 개념을 구현해야 합니다.
이벤트의 구현에는 조건이 몇가지 있습니다.

 

1. 이벤트는 전역적이어야 합니다

이벤트 객체는 스스로 이벤트를 선택하는 객체입니다.
이벤트 객체가 이벤트를 선택하기 위해선
무슨 이벤트가 존재하는지 코드상에서 자유롭게 접근할 수 있어야 합니다.
코드상에서 자유롭게 접근할 수 있다는 말은
프로그래밍 용어로 바꾸면 전역적이라는 말과 같습니다.

 

2. 이벤트의 구독 및 구독 해지는 자유로워야 합니다

이벤트 객체는 이벤트를 선택하여 반응하기로 정할 수 있습니다.
이를 이벤트를 구독한다라고 합니다.
반대로, 구독하고 있던 이벤트에 반응하는 것을 멈추기로 정할 수도 있습니다.
이를 이벤트를 구독 해지한다고 합니다.
이벤트 객체는 반응 여부를 스스로 정할 수 있어야 하기 때문에,
이벤트의 구독과 구독 해지는 자유로워야 합니다.
우리가 여자친구의 메세지를 열심히 확인하다가도
롤 승급전에는 핸드폰을 잠시 내려놓는 것처럼 말이죠.

 

3. 반응 행동도 자유로워야 합니다

구독한 이벤트가 발생했을 때 정해진 동작만을 해야한다고 되어 있다면,
사실 이 이벤트는 더이상 단순한 '일어난 사실'이 아니라 '명령서'와 같습니다.
코드 상에 이벤트라 정의되어 있더라도 사실은 이벤트가 아닌 것입니다.
이벤트 객체는 스스로를 완벽히 책임지는 만큼
이벤트에 어떻게 반응할지도 스스로 정할 수 있어야 합니다.

 


그럼 구현해봅시다

이벤트 기반 프로그래밍은 특정 언어나 프레임워크에 국한된 주제는 아닙니다.
어떤 언어에서든 이벤트 기반 프로그래밍을 구현할 수 있습니다.
저는 프로그램을 처음 배울 때 C++로 배웠고 제일 많이 사용하기도 했기 때문에
C++을 활용해 설명해보겠습니다.
주석을 활용하여 C++을 잘 모르시더라도 충분히 이해할 수 있도록 하겠습니다.

예시 코드는 실제 동작하는 사용 가능한 코드지만
성능, 메모리 관리, 멀티스레드 등에서 부족한 부분이 많습니다.
이해를 돕기위해 간단화하였습니다.

 

1. 이벤트 정의하기

이벤트는 여러 방법으로 정의할 수 있습니다.
이벤트 기반 프로그래밍을 구현한 다른 글들을 찾아보면
다음과 같이 enum을 활용하여 정의한 예시가 있습니다.

enum EventType
{
  MemberJoined = 1,
  PostPublished,
  //...
};

 

하지만 이와 같은 방식은
이벤트를 식별할 때 switch문이나 if문과 같은 분기를 요구합니다.
게다가 이벤트 식별자에 이벤트를 설명하는 데이터가 묶여있지 않으므로
이를 코드에서 직접 짝지어줘야 합니다.
이 때 실수가 발생하기도 쉽습니다.
따라서 enum은 깔끔한 방식은 아닙니다.
그럼 어떻게 해야 깔끔하게 이벤트를 정의할 수 있을까요?

 

사실 이벤트의 정의는 구조체 하나면 충분합니다.

struct MemberJoined   // 타입 자체가 식별자
{
  std::string uuid;
  std::string nickName;
  std::string email;
  //...
};

 

예시를 보면
구조체 하나만으로 어떤 사건이 어떤 내용으로 발생했는지
충분히 설명이 가능하다는 것을 느끼실 수 있을 겁니다.
저는 자바스크립트와 같은 약타입 언어보다는 강타입 언어를 좋아하는데요.
강타입 언어에서는 이벤트의 식별에 타입을 이용할 수 있습니다.
이런 구조체의 타입 자체를 식별자로 사용한다면
식별자와 이벤트를 설명하는 데이터가 자연스럽게 짝지워집니다.
타입은 코드 내 어디서든 접근할 수 있으므로
이벤트는 전역적이어야 한다는 첫번째 조건을 만족합니다.

 

2. 발행과 구독 인터페이스 정하기

이벤트를 정의했으니, 이벤트의 발행과 구독을 구현할 차례입니다.
제일 먼저 이벤트를 발행하고 구독할 때의 모양, 즉 인터페이스를 정해야 합니다.
어떤 기능을 만들 때, 인터페이스부터 생각하는 것은 깔끔한 코드를 작성하는데 큰 도움이 됩니다.
이벤트의 구독과 발행은 자유로워야 하므로 구독/발행 인터페이스 또한 전역적으로 제공해야 한다는 것을 알 수 있습니다.
전역적인 기능을 생각하면 제일 먼저 생각나는 것은 싱글톤 패턴입니다.

// 발행
EventManager::getInstance()->publish(
  MemberJoined{
    uuid, nickName, email, ...
    }
);

// 구독
auto eventListener =
  EventManager::getInstance()->subscribe<MemberJoined>(/*...*/);

// 구독 해지
EventManager::getInstance()->unsubscribe(eventListener);

 

꺽쇠(<>) 괄호에 주목하세요.
C++에서는 템플릿(template) 문법을 이용해 타입마다 다른 구현을 하는 것이 가능합니다.
꺽쇠 괄호는 타입을 인자로 받는 템플릿 문법입니다.
템플릿 문법을 사용해서 이벤트를 타입으로 식별하는 것이 가능해졌습니다.
다른 언어에도 같지는 않지만 비슷한 기능을 찾아볼 수 있습니다.
자바, C#, 파이썬은 이와 비슷한 제네릭(Generic)이라는 문법을 지원합니다.

하지만,
솔직히 말하면 저는 이런 코드를 좋아하지 않습니다.
정말 많은 코드를 위와 같이 작성해 왔지만,
getInstance 함수는 보면 볼수록 예쁘지 않습니다.
어차피 전역으로 사용할 테니 저는 static 함수를 활용하도록 하겠습니다.

// 이벤트에 반응할 리스너 클래스
template<typename EVENT_TYPE>
class EventListener
{
  //...
};

// 주 인터페이스가 되는 이벤트 클래스
template<typename EVENT_TYPE>
class Event
{
public:
  static void publish(const EVENT_TYPE& event);

  // 반응을 구현한 함수 객체를 파라미터로 받아 리스너 객체를 반환합니다
  static std::shared_ptr<EventListener<EVENT_TYPE>> subscribe(
    std::function<void(const EVENT_TYPE&)> reaction
  );
  
  // 리스너 객체의 이벤트 구독을 해제합니다
  static void unsubscribe(
    std::shared_ptr<EventListener<EVENT_TYPE>> listener
  );
};

 

위와 같이 인터페이스를 설계하면
다음과 같이 사용할 수 있습니다.

std::string uuid = "...";
std::string nickName = "madcow";
std::string email = "...@...";

// 발행
Event<MemberJoined>::publish({
  uuid, nickName, email, ...
});

// 구독
auto eventListener =
  Event<MemberJoined>::subscribe([](auto event){
    // 람다 내부. 반응할 내용을 구현합니다.
  });

 

getInstance함수가 사라져 싱글톤을 이용했을 때 보다 훨씬 깔끔합니다.
Type a =new Type(); 같이 같은 키워드가 한 문장에 여러번 등장하지도 않습니다.
아주 마음에 듭니다.

 

3. 발행과 구독 구현하기

인터페이스가 정해졌으니 구현을 할 차례입니다.
이벤트의 발행과 구독은 옵저버 패턴을 이용해 구현하면 됩니다.
관찰자 패턴이라고도 합니다.
디자인 패턴을 소개하는 글은 아니니 자세한 설명은 생략하겠습니다.
여기서는 EventListener 클래스가 옵저버 역할을 하게 됩니다.

template<typename EVENT_TYPE>
class EventListener
{
private:
  // 반응 내용을 저장할 함수 객체
  std::function<void(const EVENT_TYPE&)> reaction_;
  
public:
  // 생성자
  EventListener(std::function<void(const EVENT_TYPE&)> reaction) {
    reaction_ = reaction;
  }
  
  // 반응을 호출하는 함수
  void react(const EVENT_TYPE& event) {
    reaction_(event);
  }
};

template<typename EVENT_TYPE>
class Event
{
private:
  // 이벤트를 구독하고 있는 전역 리스너 객체 리스트
  static std::vector<std::shared_ptr<EventListener<EVENT_TYPE>>> listenerList_;
  
public:
  // 이벤트 발행. 모든 리스너의 react 함수를 호출합니다.
  static void publish(const EVENT_TYPE& event){
    for(auto& listener : listenerList_)
    {
       listener->react(event);
    }
  }

  // 이벤트 구독. 리스너 객체를 만들어 리스트에 등록합니다.
  static std::shared_ptr<EventListener<EVENT_TYPE>>subscribe(
    std::function<void(const EVENT_TYPE&)> reaction
  )
  {
    auto listener = std::make_shared<Listner>(reaction);
    
    listenerList_.push_back(listener);
    
    return listener;
  }
  
  // 이벤트 구독 해제. 리스너 객체를 리스너에서 찾아 제거합니다.
  static void unsubscribe(
    std::shared_ptr<EventListener<EVENT_TYPE>> listener
  )
  {
    std::erase(
      std::remove(listenerList_.begin(), listenerList_.end(), listener),
      listenerList_.end()
    );
  }
};

 

C++로 코드라 꽤 복잡해보이지만 그렇게 어렵지는 않습니다.
subscribe를 호출하여 구독을 시작하면 이벤트 리스너가 전역 리스트에 등록되고,
publish를 호출하여 이벤트를 발행하면 등록된 모든 이벤트 리스너가 반응합니다.
unsubscribe를 호출하여 구독 해제를 하면 이벤트 리스너가 더이상 반응하지 않게 됩니다.
위 세 함수가 모두 전역 함수이기 때문에 이벤트의 구독, 발행, 구독 해제는 모두 자유롭습니다.
subscribe를 호출할 때, 함수 객체로 어떻게 반응할지 정의하기 때문에
반응 내용은 구독하는 측에서 자유롭게 구현할 수 있습니다.
이벤트의 세 조건을 모두 만족합니다.


프레임워크에서의 이벤트

제 편의상 C++로 설명하긴 했지만,
요즘 C++을 사용하는 사람은 소수인 듯 합니다.
보다 널리 사용되는 프레임워크에서의 이벤트를 소개합니다.
다른 언어나 프레임워크에서의 구현을 살펴보면,
위의 내용과 꽤 유사하다는 것을 느낄 수 있습니다.

 

Node.js

Node.js는 내장 event 모듈을 제공합니다. (공식 문서)
아래와 같이 사용할 수 있습니다.
약타입 언어 특성상, 이벤트 종류 식별에 문자열을 사용하는 것이나
파라미터 타입을 강제할 수 없는 점이 아쉽습니다.
타입스크립트를 도입해도, 이를 강제하려면 따로 구현해줘야 합니다.
그래도 간편하게 사용하기엔 좋습니다.

const EventEmitter = require('node:events');
class MyEmitter extends EventEmitter {}
const myEmitter = new MyEmitter();
myEmitter.on('event', (a, b) => {
  console.log(a, b, this);
  // Prints: a b {}
});
myEmitter.emit('event', 'a', 'b');

 

Spring Framework

스프링 프레임워크 또한 내장 이벤트 시스템을 제공합니다. (소개 글)
어노테이션으로 정의할 수 있어 간편하게 사용할 수 있습니다.
타입으로 이벤트를 식별하는 점도 훌륭합니다.
데이터베이스 트랜젝션 결과에 따라 이벤트 발행 여부를 정할 수도 있어 강력합니다.

// 발행하는 컴포넌트
@Component
public class CustomSpringEventPublisher {
    @Autowired
    private ApplicationEventPublisher applicationEventPublisher;

    private void publishCustomEvent(final String message) {
        var event = new CustomSpringEvent(this, message);
        applicationEventPublisher.publishEvent(event);
    }
}

// 반응하는 컴포넌트
@Component
public class AnnotationDrivenEventListener {
    @EventListener
    public void handleCustomSpringEvent(CustomSpringEvent cse) {
        System.out.println("Handling event.");
    }
}

 

Unity 엔진

저는 게임 개발자이기도 하기 때문에 유니티 엔진에서의 이벤트 구현도 언급해보겠습니다.
Unity엔진에서 이벤트 시스템을 구현하는 내용을 검색해보면,
ScriptableObject를 활용하는 구현을 주로 찾아볼 수 있습니다.(소개 글)
하지만 내용을 자세히 살펴보면,
이벤트의 식별까지는 가능하지만 이벤트의 내용 (본글 예시에서 nickName 등) 을 리스너에게 전달해주지 못하는 것을 알 수 있습니다.
따라서 저는 C#버전의 이벤트 시스템을 따로 구현하여 사용하는 것을 추천합니다.


마치며

 

이번 글에서는
이벤트의 조건을 생각해보고,
조건을 만족하는 이벤트 시스템을 구현해보았습니다.

 

다음 글에서는
이벤트 객체를 작성할 때의 원칙을 설명드리면서
이 글에서 구현한 이벤트 시스템을 이용해 간단한 이벤트 객체를 작성해보겠습니다.

 

이벤트 기반 프로그래밍에 관심이 있으시다면
구독하여 이벤트 기반 프로그래밍 시리즈를 지켜봐 주시기 바랍니다.
또한 다른 의견이나 궁금한 점이 있으시다면 댓글로 말씀해주세요.
토의는 언제나 환영합니다.

읽어주셔서 감사합니다.

'Development'의 다른글

  • 현재글 3. 이벤트 구현하기 - [이벤트 기반 프로그래밍 시리즈]

관련글