2025. 3. 12. 18:42ㆍ컴퓨터 공학
EDA에 많은 관심을 가지고 있어서 이벤트에 대해 많은 공부를 하게 되었고,
그중 멱등성 있는 이벤트 발행하기라는 주제로 글을 작성하려고 합니다.
# 이벤트
EDA에서 이벤트는 단순한 기록으로 처리되는 것이 아니라 시스템 구성요소 간의 동기/비동기 통신 및 비즈니스 로직 실행의 핵심 메시지로 취급합니다.
일반적인 layered architecture를 생각해 보면,
A 엔티티의 변경사항이 발생하고, 이 이벤트를 A와 관련된 엔티티에 전파해야 한다면,
A service <- B Service, C Service, D Service... 등과 같이 하나의 layer에 다른 도메인의 layer가 의존하게 될 것입니다.
이런 패턴은, 응집도를 떨어뜨리고, 결합을 강하게 만들어 좋은 패턴이라고 볼 수는 없죠.
@RequiredArgsConstructor
public class Aservice {
private final ARepository aRepository;
private final BService bService;
private final CService cService;
private final DService dService;
public void foo() {
A a = new A();
aRepository.delete(a);
bService.someMethod(a);
cService.someMethod(a);
dService.someMethod(a);
}
}
이벤트를 활용한다면 이를 좀 더 우아하게 풀어낼 수 있습니다.
1. A 엔티티의 변경사항 이벤트를 publish 합니다.
2. A 엔티티의 변경사항 이벤트를 구독하고 있던 subscriber/listener가 이를 인지하고 처리합니다.
이런 방식의 경우
A Service는 publisher에 대한 참조만 가지고 있게 되므로 결합도가 줄어들게 되고, 응집도도 높아지게 됩니다.
public class AService {
private final ARepository aRepository;
private final EventPublisher publisher;
public void foo() {
A a = new A();
aRepository.delete(a);
ADeleteEvent event = new ADeleteEvent();
publisher.publish(event);
}
}
## 비즈니스적 요구사항 (트랜잭션 aware, async)
보통 이벤트는 비동기적으로 발행합니다.
하지만 이런 경우는 어떻게 해결해야 할까요?
예시:
- A라는 엔티티는 B라는 엔티티에 contraint가 걸려있습니다.
- 그래서 A가 삭제되려면 B가 먼저 삭제되어야 합니다.
이 경우는 비동기 이벤트로는 해결할 수 없습니다.
또한 DB접근이기 때문에 RDB의 경우 트랜잭션에서 엔티티 update 실패 시 롤백이 되기 때문에 이 기능을 활용하고 싶을 것입니다.
그래서 이런 비즈니스 요구사항이 있을 때는 동기/트랜잭션 이벤트 발행이 필요합니다.
Spring Boot에서는 이를 직접적으로 제공하고 있습니다.
- @TransactionalEventListener
TransactionalEventListener (Spring Framework 6.2.3 API)
The event classes that this listener handles. If this attribute is specified with a single value, the annotated method may optionally accept a single parameter. However, if this attribute is specified with multiple values, the annotated method must not dec
docs.spring.io
공식 문서를 살펴보면 다음과 같은 설정을 제공합니다.

- Classes는 어떤 클래스에 대해서 이벤트 처리를 할 것인지 설정할 수 있고,
- phase 값을 통해 어떤 트랜잭션 페이즈에서 동작할지를 설정할 수 있습니다. (BeforeCommit, AfterCommit, AfterRollback, AfterCompletion)
비동기 실행 시에는 @Async 어노테이션만 붙이면 사용가능합니다.(비동기 설정이 되어 있다는 가정하에)
하지만 이는 어디까지나 서버 내에서 이벤트를 발행하고 구독했을 때의 얘기입니다.
만약 이벤트를 외부로 전송해야 하는 상황에서는 어떻게 해야 할까요?
# 외부로 이벤트 전송하기
MSA환경에서의 이벤트 기반 통신은 매우 흔한 일입니다.
User가 삭제되었을 때 User와 관련된 다른 리소스를 지우는 이벤트를 외부로 발행하는 상황을 가정해 봅시다.
정상적인 상황이라면
1. client의 User 삭제 요청이 들어옴
2. 내부 리소스를 삭제(User를 삭제)함
3. 외부로 UserDeletedEvent를 보냄.
4. 외부에서도 제거가 완료됨.
그런데 만약 중간에 네트워크 분단이 발생했을 경우 어떻게 될까요?
1. client의 User 삭제 요청이 들어옴.
2. 내부 리소스를 삭제함.
3. 외부로 UserDeletedEvent를 보냄.
4. 이벤트를 성공적으로 받았지만 응답을 하는 과정에서 네트워크 분단이 발생.
5. 서버 측에서 재시도
6. 이벤트를 2번 수신.
7. ??
이렇게 네트워크 분단 문제뿐만 아니라 외부에서 응답이 처리가 늦어졌을 경우 timeout 등의 이유로 여러 번 재시도될 수 있습니다.
어떻게 이를 극복할 수 있을까요?
이를 극복하기 위해서는 멱등성 있는 이벤트 처리가 필요합니다.
# 멱등성

멱등성이란 동일한 연산을 여러 번 수행해도 그 결과가 처음 수행한 것과 같은 것을 말합니다.
간단한 예시로
유저 삭제를 한다고 합시다.
첫 번째 요청에서는 유저를 실제로 삭제하고 200을 응답했습니다.
두 번째 요청에서는 삭제할 유저가 없었지만 유저가 삭제된 상태는 동일합니다.
또한 여러 번 요청해도 유저가 삭제된 상태가 결과라는 것은 변하지 않습니다.
세 번째도... 네 번째도...
이는 멱등적이라고 할 수 있습니다.
그럼 이 경우는 어떨까요?
유저의 잔고에 1000원을 더하는 작업이라면
첫 번째 요청에서는 r(기존 값) + 1000이 될 것이고,
두 번째 요청에서는 r + 2000,
세 번째 요청에서는 r + 3000이 될 것입니다.
이는 여러 번 연산을 할 경우 결과가 계속 달라지기에 멱등적이지 않습니다.
만약 이 요청을 이벤트로 처리하는 상황을 가정해 봅시다.
1. 이벤트를 받았을 때 r + 1000이 되었습니다.
2. 정상 처리 응답을 할 때 네트워크 분단이 발생했습니다.
3. 클라이언트가 재시도해서 다시 이벤트를 수신하게 되었고, r + 2000이 됩니다.
클라이언트가 원하는 것은 r + 1000인데, r + 2000이 되었습니다.
어떻게 이것을 방지할 수 있을까요?
## 멱등적인 이벤트
이벤트 전송을 멱등적으로 하기 위해서는 이벤트를 식별할 수 있는 이벤트 식별키가 필요합니다.
X-Request-Event-Id 같은 값 말이죠.
그리고 이 키를 이벤트 전송 시에 함께 전송해 주면,
이벤트를 수신하는 컨슈머 입장에서는 해당 키를 통해 해당 메시지가 처리된 메시지인지 아닌지를 알 수 있습니다.
만약 처리된 메시지라면 에러를 발생시키고,
처리되지 않은 메시지라면 로직을 수행할 수 있겠죠.
간략하게 sudo code를 작성해 보자면 다음과 같습니다.
func handle(messageKey string, event Event) error {
_, ok := store.get(messageKey)
if ok {
return errors.new("The event was already procesed")
}
_ = store.put(messageKey, event)
return processEvent(event)
}
여기서 event 저장소는 redis 같은 캐시를 사용할 수도 있고, RDBMS나 NoSQL을 사용할 수도 있고, 그 외의 다른 방법으로 사용할 수도 있습니다.
간편한 방법은 redis 같은 저장소에 timeout을 함께 명시해서 저장하는 방법이 있을 수 있습니다.
다만 이 경우 redis의 데이터가 재부팅 등으로 인해 휘발된 경우 문제가 있을 수 있습니다.
(비동기 작업으로 FileSystem에 추가적으로 저장하는 패턴을 사용해 휘발되는 상황을 막을 수 있습니다.)
# wrap-up
지금까지 이벤트가 무엇이고 이벤트를 활용하면 아키텍처적으로 어떤 이점이 있는지,
그리고 분산 환경에서 어떻게 이벤트를 멱등성 있게 발행할 수 있는지에 대해 알아봤습니다.
'컴퓨터 공학' 카테고리의 다른 글
당신의 Go 코드의 nil 체크가 실패하는 이유(feat. interface) (0) | 2024.05.13 |
---|---|
(디자인 패턴 복습 시리즈) 어댑터 패턴(Adapter pattern) (0) | 2023.02.04 |
(Python) FastAPI MVC(1) (0) | 2023.01.20 |
웹 서버 vs 웹 애플리케이션 서버(Web Server vs WAS) (0) | 2023.01.02 |