본문 바로가기
BackEnd

Event

by oncerun 2023. 1. 30.
반응형

최근 이벤트를 통한 느슨한 결합과 알림 기능을 구현하면서 여러 선택의 갈림길에 마주쳤는데 이를 고민하는 과정을 적는다.  

 

 이벤트를 무엇으로 구현할 것인가? 

 

현재 자원에 맞춰서 생각했다. scale-out은 비즈니스적으로 불가능한 구조였고 scale-up은 어느 정도 합의를 통해 가능하다는 이야기를 들었다. 따라서 애플리케이션 서버의 리소스만을 사용하도록 구현해야 했다. 

 

이를 위해 Spring Framework의 Application Event를 사용했다. 

 

Spring에서 제공하는 Applicaiton Event는 ApplicaitonContext의 확장 기능 중 하나로 ApplicationContext를 Wrapper Class인 ApplicationEventPublisher를 사용하여 ApplicationListener 인터페이스를 구현한 Bean에게 이벤트를 발행해 주는 기능이다.

 

Spring 4.2부터 어노테이션 기반으로 ApplicationEvent를 제공하기에 많은 부분이 추상화되어 있다. 
Annotation-based의 Event Listners는 실제 공식문서에 잘 설명되어 있습니다.

더보기

1. 이벤트 발행에 필요한 객체는 ApplicationEvent를 상속받고 Bean으로 등록되어야 한다.

public class BlockedListEvent extends ApplicationEvent {

    private final String address;
    private final String content;

    public BlockedListEvent(Object source, String address, String content) {
        super(source);
        this.address = address;
        this.content = content;
    }

    // accessor and other methods...
}



2. custom Event를 발행하기 위해선 publishEvent() method를 호출해야 하며, 이는 ApplicationEventPublisher에 정의되어 있다.  ApplicationEventPublisher를 주입받기 위해 ApplicationEventPublisherAware interface를 구현하고 사용하는 서비스를 Bean으로 등록해야 한다.

public class EmailService implements ApplicationEventPublisherAware {

    private List<String> blockedList;
    private ApplicationEventPublisher publisher;

    public void setBlockedList(List<String> blockedList) {
        this.blockedList = blockedList;
    }

    public void setApplicationEventPublisher(ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public void sendEmail(String address, String content) {
        if (blockedList.contains(address)) {
            publisher.publishEvent(new BlockedListEvent(this, address, content));
            return;
        }
        // send email...
    }
}

 

공식 문서를 보면 ApplicationEventPublisherAware를 구현한 클래스를 감지하여, ApplicationEventPublisher를 주입해 준다. 

 

3, 정의된 ApplicaitonEvent를 받아 처리하는 Handler를 정의할 때는 ApplicationListener <T> 인터페이스를 구현한 이후 T 타입 파라미터를 받는 method를 정의하는 것으로 이벤트를 수신할 수 있다. 

public class BlockedListNotifier implements ApplicationListener<BlockedListEvent> {

    private String notificationAddress;

    public void setNotificationAddress(String notificationAddress) {
        this.notificationAddress = notificationAddress;
    }

    public void onApplicationEvent(BlockedListEvent event) {
        // notify appropriate parties via notificationAddress...
    }
}

 

 

 

 

 이벤트 리스너는 이벤트를 동기적으로 처리한다.

 

이는 default이다.  따라서 도메인 로직 시 이벤트를 발행하는 순간 스레드가 블락이 걸리고 이벤트가 처리된 이후 실행된다
이는 도메인의 주요 기능 처리 성능에 영향을 미치게 하는 버틀넥으로 작용할 수 있다는 것을 의미합니다. 

하지만 스프링 이벤트와 도메인 로직을 동기적으로 처리하기 때문에 동일 트랜잭션에 결합될 수 있습니다. 

이는 도메인의 트랜잭션의 범위가 외부로부터 제어된다는 점에서는 유연하다라고는 하지 못하지만, 매우 강력하게 이벤트를 구독하는 구독자를 만들 수 있습니다. 

@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)

추가적으로 트랜잭션을 commit한 이후 이벤트를 발행하도록 전략을 변경할 수 있기 때문에 다양한 예외에 대한 고민을 조금 더 작은 표본으로 줄일 수도 있습니다.

하지만 EventListener의 handler가 비동기로 처리되면 동일 트랜잭션에 참여할 수 없게 됩니다. 

그 이유는 Spring Transaction의 구현과 관련이 있습니다. Spring은 TransactionSynchronizationManager를 통하여 현재 트랜잭션의 여부를 체크합니다.  

TransactionSynchronizationManager는 현재 스레드와 관련된 트랜잭션에 대한 정보만 제공하고 다른 스레드의 트랜잭션은 지원하지 않습니다. 

 

역설적으로 비동기 메서드를 트랜잭션에 참여시켜 원자성을 보장받기 위해선 비동기가 주는 성능상 이점을 포기해야 함을 의미하는 것 같습니다. 

많은 장점이 있지만 비동기를 사용한다라는 것 자체가 비동기 메서드의 성공/실패 여부를 떠나서 메인 스레드의 block으로 인한 성능 저하를 발생시키지 않는 것을 의도하는 것인데, 과연 비동기 메서드의 성공/실패를 기다리고, 트랜잭션의 참여하도록 매우 복잡한 코드를 작성하는 것이 좋은 방법인지 의문이 듭니다. 

 

공식 문서에서 Spring의 eventing mechanism에 대해 설명하는 부분이 있습니다. 

 

Spring’s eventing mechanism is designed for simple communication between Spring beans within the same application context. However, for more sophisticated enterprise integration needs, the separately maintained Spring Integration project provides complete support for building lightweight, pattern-oriented, event-driven architectures that build upon the well-known Spring programming model.

 

 

Spring의 Event들은 Bean들에 대한 간단한 커뮤니케이션을 의도로 디자인되었습니다. 따라서 보다 정교한 엔터프라이즈의 요구 사항을 위해서는 Spring Integration project에서 더 완벽한 방법을 지원해 준다고 설명되어 있습니다. 

(그리고 책을 사라고 아마존으로 보내버리네요.. ㅋㅋ 나중에 읽겠습니다.)

https://www.amazon.com/o/asin/0321200683/ref=nosim/enterpriseint-20

 

Amazon.com

Enter the characters you see below Sorry, we just need to make sure you're not a robot. For best results, please make sure your browser is accepting cookies.

www.amazon.com

 

이렇게 Global Transaction이 필요한 경우 외부 시스템을 사용할 수밖에 없다는 결론이 나오게 됩니다. 

 

만약 정말 사용해야 한다면 제 생각은 비동기 메세지의 실패 시 이를 try/catch로 잡아 데이터베이스에 저장한 이후 스케쥴링을 통해 이벤트를 재시도하는 방법이 합리적인 것 같습니다. 

 

또한 이벤트는 반드시 멱등성을 가져야한다는 것도 잊으면 안 될 것 같습니다.  이는 동일한 이벤트가 여러 번 발생해도 그 결과는 동일하다는 성질입니다. 

 

실제 여러 대기업의 기술 블로그를 보면 이를 해결하기 위해 Message broker를 사용하는 것으로 보입니다. 

 

대표적인 Message Broker로는 다음과 같은 종류가 있습니다.

  1. RabbitMQ
  2. Apache Kafka
  3. ActiveMQ
  4. Google Cloud Pub/Sub
  5. Amazon Simple Notification Service (SNS)
  6. Apache Pulsar
  7. Microsoft Azure Event Grid
  8. IBM MQ
  9. NATS
  10. Redis Pub/Sub.

 

결론적으로 이벤트를 구현하기 위해선 Spring Application Event를 사용하도록 하였습니다.

 

트랜잭션의 실패로 인해 이벤트와의 불일치성은 @TransactionalEventListener을 통해 해결하고

동기적으로 발생하는 성능 이슈는 비동기로 처리하며 이벤트의 실패에 대해선 데이터베이스에 실패 내역을 저장하여 특정 횟수를 재시도하고 중단하게 합니다. 이후 이러한 시도를 별도의 채널을 통해 개발자에게 알리도록 합니다

 

이벤트를 어떻게 구현할 것인가?

 

실제 이와 관련되어 많은 도움을 받은 곳이 우아한 형제들의 기술블로그이다.

현재 내가 하는 프로젝트는 거대한 서비스는 아니지만 이벤트 드리븐에 관련하여 매우 경험이 녹아있는 포스팅이며, 읽다 보면 매우 많은 것을 얻을 수 있다. 

 

우선 도메인의 주요 로직 외 비관심사를 찾아내어 이를 느슨하게 결합해야 하는 상황이다.  또한 메시징 큐를 추후 변경할 여지도 있어 보인다. 

 

그럼 두 가지를 고려해 볼 수 있다. 

 

첫 번째는 메시징 큐의 변경으로 인해 인프라 영역의 코드가 변경되는 경우 이를 사용하는 이벤트 리스너에서는 영향이 없도록 구성해야 한다. 

 

가장 간단한 방법으로는 interface를 활용하는 것이다.

private final MessageBroker firebaseCloudMessaging;
private final EventLogRepository eventLogRepository;

@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void contentCreateEvent(ContentEvent contentEvent) {
    try {
        firebaseCloudMessaging.send(contentEvent);
    } catch (RuntimeException e) {
        log.info("Processing during the event failed.");
        eventLogRepository.save(contentEvent);
    }

}
@Transactional
public ContentDto create(ContentForm contentForm){
    Content content = ContentForm.toContent();

	//예외 처리 로직이 있다고 가정
    ContentDto contentDto = contentRepository.save(content)
            .toMap(ContentDto::convert);

    eventPublisher.publishEvent(contentDto.toContentEvent());
    return contentDto;
}

 

 

만약 타 도메인에게 메시지를 보낼 때 의도를 가득 담아 보낸다고 하자. 

우아한 형제들 기술 블로그

이러한 접근 방법이 신기해서 우형 블로그의 글을 참고했다.

 

그림 상으로는 회원과, 가족계정 시스템이 물리적으로 분리되어 있고 이를 느슨하게 결합하기 위해 메시징 큐를 두었다. 

그럼에도 이는 결합이 느슨하지 않다고 말한다. 

 

 

그 이유는 어떤 일을 해야 하는 지를 메시지 발행자가 알려주는 경우 해야 하는 일이 변경될 때 발행자와 수신자 양쪽 모두의 코드가 변경되어야 하기 때문에 높은 결합도가 발생한다는 것이다. 

 

음. 메시지 수신측의 정책 변경이 실제 Member 시스템에서 목적을 가득 담았기 때문에 코드의 변경이 일어나는구나!

이는 이벤트라고 할 수 없다. 메시징 시스템을 이용한 비동기 요청이다. 

이를 이벤트로 말하기 위해선 다음과 같이 회원의 본인인증 해제가 발생할 때 회원의 본인인증 해제 이벤트가 발송되어야 함을 뜻한다.

 

그런데 약간의 의문점이 든다. 

 

메시지에 담길 내용은 처음 구성할 때 ( 회원 본인인증 해제 시 가족계정 시스템과 연동) 필요한 메시지 내용은 미리 정해진 API가 아닌가? 

 

그렇다면 도메인에서 의도를 제거한 도메인 이벤트를 전송되어도 실제  전송될 메시지 내용의 변경 때문이라도 양측의 코드 변경이 있지 않을까라는 생각을 했다. 

 

하지만 그림을 잘 보면 이벤트 발생 시 도메인의 식별자를 넘기는 것으로 보인다. 

따라서 특정 내용을 담은 메시지가 아닌 각 도메인에서 식별자를 통해 엔티티를 조회하여 비즈니스로직을 처리하는 것 같다.!

 

 

 

이와 관련해 이벤트 일반화라는 주제가 있다. 

 

사실 시스템 내부에서 발행하는 이벤트는 구독자가 필요한 데이터 페이로드를 제공하여 충분한 효율성을 가져갈 수 있습니다. 

 

하지만 외부 시스템끼리 메시지 시스템을 사용한다는 것은 시스템 간의 결합도를 줄이겠다는 의도로 보이는데, 이 과정에서 시스템마다 원하는 데이터가 다를 것이다. 

 

즉 이벤트 발생을 인지하는 것 이상으로 데이터가 더 필요하게 될 수 있다는 이야기다.

 

여기서 이벤트를 일반화하여 이벤트의 식별자, 행위, 속성, 시간이 존재한다면 어떠한 시스템에서도 필요한 이벤트를 인지할 수 있다.

 

그 이유는 어떤 엔티티가, 어떠한 행위를 하여 어떤 속성이 변경되었고 그 변경 시각은 다음과 같다. 라는 식의 메시지를 가지면 외부 시스템은 일반화된 이벤트를 가지고 대부분의 로직을 진행할 수 있습니다. 

 

그렇다면 이를 활용해 내부 시스템간의 데이터 페이로드도 일반화를 시켜야 할까?

 

내 생각에는 불필요한 낭비로 보인다. 내부 시스템이라도 사용하는 리소스는 한정적인데, 아주 적은 데이터만 필요한 구독자에게도 불필요하게 거대한 객체를 넘겨준다면 이는 리소스의 낭비로 이어질 것 같습니다.  결국 트레이드오프 같고, 회바회같습니다만..

 

실무에서 이러한 것을 접하기엔 거대한 서비스를 하는 회사가 아니고서 잘 경험하지 못하는 것 같습니다. 

하지만 간접적으로나 경험하고 적은 서비스라도 내부 이벤트를 통해 도메인 간의 느슨한 결합을 만드는 것도 좋을 것 같습니다. 

 

다음에는 실제 메시지 중개인의 종류에 대해 자주 사용되는 3가지 정도와 이들을 언제 적재적소에 활용해야 효율적인지 공부하겠습니다. 

 

 

 

 

 

 

반응형

'BackEnd' 카테고리의 다른 글

🛠️ Swagger ?  (0) 2023.07.12
SSH 접속 불가  (0) 2023.03.15
Prometheus & Grafana  (0) 2023.02.17
Monitoring  (0) 2023.02.16
CQRS Pattern  (0) 2023.02.02

댓글