-
Event Driven Architecture란?프로젝트/선착순 쿠폰 발급 시스템 2023. 5. 1. 00:01728x90
개요
회원과 쿠폰 도메인을 설계할 때 Event 기반으로 다른 도메인들, 모듈들과 결합도를 느슨하게 가져가려고 합니다.
지금 정리하는 부분은 "우아한 형제들 권용근님의 회원시스템 이벤트기반 아키텍처 구축하기"를 보면서 더 잘 이해하기 위해 글로 한번 정리해보고자 합니다.
이외에도 다른 글들을 조금씩 참조하긴 했지만 적다 보니 용근님의 글이 거의 90% 같습니다.
회원 시스템 이벤트 기반 아키텍처 구축과정
회원과 가족계정이라는 두 가지 도메인의 관계를 예시로 알아보고자 합니다.
"회원의 본인인증이 초기화되는 경우 가족계정 서비스에서 탈퇴되어야 한다"라는 정책이 존재합니다.
이를 코드로 작성하면 다음과 같습니다.
public void initCertificationOwn(MemberNumber memberNumber){ member.initCertificationOwn(memberNumber); family.leave(memberNumber); }
회원의 본인인증 해제 로직과 가족계정 서비스 탈퇴로직이 깊게 관여되어 강한 결합을 가집니다.
마이크로서비스를 구성하여 회원 시스템, 가족계정 시스템이 되었습니다.
이제 두 도메인의 물리적인 분리가 일어납니다.
HTTP 통신으로
public void initCertificationOwn(MemberNumber memberNumber){ member.initCertificationOwn(memberNumber); familyClient.leave(memberNumber); }
코드레벨의 호출이 동기적인 HTTP 통신으로 변했습니다.
하지만 여전히 대상 도메인을 호출해야 한다는 의도가 남아있습니다.
또한 물리적인 의존도 남아있기 때문에 @Async를 활용한 비동기로 이를 제거할 수 있습니다.
@Async void leave(MemberNumber memberNumber);
메시징 시스템을 사용하면 느슨한 결합을 가져갈 수 있을 것이라고 기대됩니다.
public void initCertificationOwn(MemberNumber memberNumber){ member.initCertificationOwn(memberNumber); eventPublisher.familyLeave(memberNumber); }
하지만 여전히 본인인증 해제가 발생할 때 가족계정 탈퇴 메시지를 발송합니다.
메시지를 발송하는 것으로 물리적인 의존은 제거되었지만 결합은 느슨해지지 않았습니다.
가족계정 시스템의 정책이 변경될 때 회원 시스템의 메시지도 함께 변경되어야 합니다.
어떤 일을 해야 하는지 메시지 발행자가 알려주는 경우 해야 하는 일이 변경될 때 메시지 발행자와 수신자 양쪽 모두의 코드가 변경되어야 합니다.
대상 도메인에게 기대하는 목적을 담았다면, 이것은 이벤트라 부르지 않고 메시징 시스템을 이용한 비동기 요청입니다.
드디어 이벤트를 전달
public void initCertificationOwn(MemberNumber memberNumber){ member.initCertificationOwn(memberNumber); eventPublisher.initCertificationOwn(memberNumber); }
회원의 본인인증 해제가 발생할 때 본인인증 해제 이벤트가 발행됩니다.
회원시스템은 더 이상 가족계정 시스템의 정책을 알지 못합니다.
가족계정 시스템에서 비즈니스를 구현
public void listenMemberOwnInitEvent(MemberNumber memberNumber){ family.leave(memberNumber); }
이제 두 시스템 간의 결합이 느슨해지며 회원시스템은 가족계정 시스템의 비즈니스 변경에 더 이상 영향을 받지 않습니다.
중요한 것은?
발행해야 할 이벤트는 도메인 이벤트 그 자체입니다.
예를 들어 "밥을 먹었다"라는 이벤트를 발행해야 합니다.
"밥을 먹었으니 양치를 해라"라는 이벤트를 발행하면 안 됩니다.
이벤트로 인해 달성하려는 목적이 담기면 안 됩니다.
도메인의 핵심 가치나 행위를 정의하기 어렵다면 이벤트 스토밍이 추천됩니다.
이벤트 발행과 구독
권용근님은 회원시스템을 위해 3가지 이벤트 종류와 3가지 이벤트 구독자 계층을 정의하였습니다.
애플리케이션 이벤트 & 첫 번째 구독자 계층
Spring Application Event를 통해 분산-비동기를 다룰 수 있는 이벤트 버스를 제공하며, 트랜잭션을 제어할 수 있도록 지원합니다.
애플리케이션 내에서 도메인 내부의 비관심사를 효율적으로 처리할 수 있습니다.
대표적으로 메시징 시스템으로 이벤트를 발행하는 것입니다.
이벤트 구독은 발행 시스템에 영향 없이 자유롭게 확장이나 변경이 가능하므로, 우리는 도메인에 영향 없이 메시징 시스템에 대한 연결을 쉽게 작성하고 확장하고 변경할 수 있습니다.
또한 트랜잭션을 제어할 수 있게 됩니다.
도메인에서 정의된 트랜잭션의 범위가 외부로부터 제어될 수 있다는 것을 도메인에 대한 침해로 볼 수 있지만 이를 감수하는 대신 더 강력한 구독자를 만들 수 있습니다.
상태 변경을 야기하는 모든 도메인 행위는 메시징 시스템으로 전달되어야 하는 시스템 정책을 세웠습니다.
이벤트를 메시징시스템으로 전달하는 것은 도메인의 관심사는 아니지만 시스템에서는 중요한 정책입니다.
이제 도메인 정책에 변경 없이 트랜잭션을 확장하여 구독자의 행위를 트랜잭션 내에서 처리되도록 변경할 수 있습니다.
First Subscriber Layer 코드 예시
@Async(EVENT_HANDLER_TASK_EXECUTOR) @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) //트랜잭션을 외부에서 제어해서 변경시에는 항상 이벤트가 발행되도록 public void handleJoinEvent(MemberJoinApplicationEvent event) { MemberJoinEventPayload payload = MemberJoinEventPayload.from(event); notificationMessagingTemplate.sendNotification(clientNameProperties.getSns().getJoin(), payload, null); }
First Subscriber Layer에 해당하여 이벤트를 받아 AWS SNS 발행을 책임지는 이벤트 구독자가 만들어집니다.
내부 이벤트 & 두 번째 구독자 계층
첫 번째 구독자 계층이 애플리케이션 내에서 해결해야 하는 비관심사를 처리했다면, 내부 이벤트를 구독하는 두 번째 구독자 계층은 이외의 모든 도메인 내의 비관심사를 처리합니다.
도메인 내의 비관심사?
도메인 행위가 수행될 때 함께 수행되어야 하는 정책들이지만 부가 정책들이 도메인의 주 행위인 것으로 착각될 수 있으며, 주 행위에 대한 응집을 방해하게 됩니다.
회원 로그인 예시
- 회원을 로그인 상태로 변경
- “동일 계정 로그인 수 제한” 규칙에 따라 동일 계정이 로그인된 타 디바이스 로그아웃 처리
- 회원이 어느 디바이스에서 로그인되었는지 기록
- 동일 디바이스의 다른 계정 로그아웃 기록
@Transactional public void login(MemberNumber memberNumber, DeviceNumber deviceNumber) { devices.login(memberNumber, deviceNumber); devices.logoutMemberOtherDevices(memberNumber, deviceNumber); devices.logoutOtherMemberDevices(memberNumber, deviceNumber); member.login(memberNumber); applicationEventPublisher.publishEvent(MemberLoginApplicationEvent.from(memberNumber, deviceNumber)); }
위의 코드를 보았을 때 도메인의 주 행위가 무엇인지 알기 어렵습니다.
부가 정책들이 도메인 로직과 함께 작성되어 있기 때문입니다.
주요 관심사와 비관심사를 분리하여 비관심사에 대한 결합을 느슨하게 만들어야 합니다.
로그인 기능의 주 정책은 회원을 로그인 상태로 변경하는 것입니다.
@Transactional public void login(MemberNumber memberNumber, DeviceNumber deviceNumber) { member.login(memberNumber); applicationEventPublisher.publishEvent(MemberLoginApplicationEvent.from(memberNumber, deviceNumber)); }
이 외의 행위들은 로그인 행위에 부가적으로 붙어있는 정책들입니다.
3가지 비관심사들은 서로의 작업에 의존적이지 않으며 AWS SNS-SQS 메시징 시스템을 통해 하나의 이벤트를 여러 구독으로 나누어 처리할 수 있습니다.
3가지 부가적인 정책을 Consume 해서 처리한다
@SqsListener(value = "${sqs.login-device-login}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS) public void loginDevice(@Payload MemberLoginApplicationEvent payload) { devices.login(payload.getMemberNumber(), payload.getDeviceNumber()); } @SqsListener(value = "${sqs.login-member-other-device-logout}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS) public void logoutMemberOtherDevices(@Payload MemberLoginApplicationEvent payload) { devices.logoutMemberOtherDevices(payload.getMemberNumber(), payload.getDeviceNumber()); } @SqsListener(value = "${sqs.login-other-member-device-logout}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS) public void logoutOtherMemberDevices(@Payload MemberLoginApplicationEvent payload) { devices.logoutOtherMemberDevices(payload.getMemberNumber(), payload.getDeviceNumber()); }
외부 이벤트 발행
시스템 내의 비관심사가 분리되었습니다.
MSA를 위한 외부 시스템과의 관심사 분리를 위해 외부 이벤트 발행이 필요합니다.
@SqsListener(value = "${sqs-join-broadcast}", deletionPolicy = SqsMessageDeletionPolicy.ON_SUCCESS) public void handleBroadcast(@Payload MemberJoinApplicationEvent payload) { messageBroadcastExecutor.broadcast(MemberBroadcastMessage.from(payload)); }
내부 이벤트를 외부에서 구독하도록 할 수 있지만, 내부 이벤트와 외부 이벤트를 분리함으로써 내부에서는 열린, 외부에서는 닫힌 이벤트를 제공할 수 있습니다.
열린 내부이벤트, 닫힌 외부이벤트
내부이벤트는 시스템 내에 존재하기 때문에 이벤트의 발행이 구독자에게 미치는 영향을 파악하고 관리할 수 있습니다.
또한 외부에 알릴 필요가 없기 때문에 내부의 개념을 이벤트에 녹일 수도 있습니다.
반면 외부 시스템으로 전파되는 외부이벤트는 내부이벤트와 다릅니다.
내부이벤트는 도메인 내의 비관심사를 분리하고 응집성을 높이는 역할을 합니다.
외부이벤트는 시스템 간의 결합을 줄이는 것을 목적으로 합니다.
외부 이벤트는 이벤트 발행처에서 이벤트 구독자가 어떤 행위를 하는지 관심을 가지면 안 되며, 관리할 수 없습니다.
만약 이벤트 발행처가 구독자의 행위에 관심을 가지는 순간 논리적인 의존관계가 형성됩니다.
외부시스템에서 이벤트를 처리하기 위해 많은 정보가 필요할 수 있습니다.
외부시스템에서 필요한 데이터를 페이로드에 추가하게 되면 이 또한 의존관계가 형성됩니다.
이벤트 일관화
외부시스템이 이벤트로 수행하려는 범위는 광범위하겠지만, 이벤트가 인지하는 과정은 쉽게 일반화할 수 있습니다.
“언제, 어떤 회원이(식별자) 무엇을 하여(행위) 어떤 변화(변화 속성)가 발생했는가"
식별자, 행위, 속성, 이벤트 시간이 있다면 어떠한 시스템에서도 필요한 이벤트를 인지할 수 있음을 알 수 있습니다.
이를 페이로드로 구현하면 이벤트를 수신하는 측에서 필요한 이벤트를 분류하여 각 시스템에서 필요한 행위를 수행할 수 있습니다.
public class ExternalEvent { private final String memberNumber; private final MemberEventType eventType; private final List<MemberEventAttributeType> attributeTypes; private final LocalDateTime eventDateTime; }
외부 시스템들은 정해진 이벤트 형식 내에서 필요한 행위를 수행하면 되므로, 이벤트를 발행하는 시스템은 외부 시스템의 변화에 영향을 받지 않을 수 있습니다.
ZERO-PAYLOAD 방식
닫혀있는 외부이벤트의 부가 데이터를 전달하는 방식으로는 ZERO-PAYLOAD 방식을 선택했습니다.
ZERO-PAYLOAD 방식은 이벤트의 순서에 대한 보장 문제를 해소하는 방식으로 주로 소개되곤 하지만, 페이로드에 외부시스템에 대한 의존을 제거하여 느슨한 결합을 만들 수 있는 장점 또한 있습니다.
외부시스템은 일반화된 이벤트를 필터링하여 필요한 이벤트를 구독하고, 필요한 부가 정보는 API를 통해 보장된 최신상태의 데이터를 사용할 수 있습니다.
이제 이벤트의 순서에 대한 보장을 고민할 필요가 없습니다.
API Call을 통해 동기화받는 정보가 항상 최신일 거라 신뢰할 수 있기 때문입니다.
하지만 Event를 발행하는 서비스 장애 시 연쇄 장애 발생 가능성이 존재합니다.
또한 api call을 추가적으로 진행해야 하고 api를 개발해야 하는 오버 엔지니어링이 발생할 수도 있습니다.
이벤트 저장소 구축
이벤트의 계층을 분리하고, 메시징 시스템을 통해 안정적인 이벤트를 처리할 수 있게 되었지만 여전히 문제점들이 존재하고 있습니다.
첫 번째 문제: 이벤트 발행에 대한 보장 유실
SNS-SQS-애플리케이션 구간에서는 SQS 정책을 통해 안정적인 실패 처리, 재시도 처리가 가능합니다.
하지만 애플리케이션-SNS 구간에서는 HTTP 통신을 사용하므로 이벤트를 발행하는 과정에 문제가 발생할 수 있습니다.
내부 이벤트를 발행하는 과정을 트랜잭션 내부로 정의하면서, 메시징 시스템의 장애가 곧 시스템의 장애로 이어질 수 있습니다. 메시징 시스템의 장애가 시스템 장애로 이어지는 문제는 굉장히 큰 문제이므로 반드시 해결이 필요합니다.
@Async(EVENT_HANDLER_TASK_EXECUTOR) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleJoinEvent(MemberJoinApplicationEvent event) { MemberJoinEventPayload payload = MemberJoinEventPayload.from(event); notificationMessagingTemplate.sendNotification(clientNameProperties.getSns().getJoin(), payload, null); }
이 문제는 내부 이벤트 발행을 트랜잭션 이후로 정의를 하면서 해결할 수 있습니다.
그러나 트랜잭션 외부에서 처리되기 때문에 이벤트 발행에 대한 보장이 사라지게 되었습니다. 애플리케이션-SNS 구간에서는 HTTP 통신을 사용하므로 네트워크 구간에서는 다양한 문제로 충분히 실패가 발생할 수 있습니다.
두 번째 문제: 이벤트 재발행
구독자들이 이벤트를 정상적으로 처리하더라도, 이벤트 처리를 잘못할 수 있기 때문에 언제든 이벤트를 재발행해줄 수 있어야 합니다.
이때 구독자들이 원하는 이벤트들의 형태는 자유롭습니다. 특정 이벤트, 특정 기간, 특정 회원이나, 특정 타입, 특정 속성 의 이벤트 발행을 원할 수 있습니다. 일부 메시징 시스템은 재발행에 대한 기능을 제공하지만, 모든 메시징 시스템이 이 기능을 제공하지 않으며 모든 요구사항을 수용하기도 힘듭니다.
대부분의 데이터는 최종 상태만을 보관하여 특정 시점의 상태를 복원하기 어려우며, 변경 내역을 가지고 있다고 하더라도 이벤트를 고려하지 않고 저장된 데이터로 이벤트를 복원하기는 쉽지 않습니다.
이 두 가지 문제점을 해결하기 위해 우리는 이벤트 저장소를 구축하기로 하였습니다.
이벤트 저장 시점
메시징 시스템의 장애가 시스템의 장애로 이어지지 않도록 메시징 시스템으로 이벤트 발행을 별도 트랜잭션으로 정의를 하였습니다. 이는 “메시징 시스템으로 이벤트 발행을 도메인의 중요한 행위로 본다"는 정의를 깨버리는 것이었고, 이것이 이벤트 발행에 대한 보장을 사라지게 만들었습니다.
이 정의를 이벤트 저장소로 다시 복구를 하기 위해 우리는 “이벤트 저장소에 이벤트 저장하는 것을 도메인의 중요한 행위로 본다"라고 정의를 하였습니다. 모든 도메인 이벤트는 반드시 저장소에 저장되어야 하며, 저장소에 저장이 실패하게 되었을 때 도메인 행위도 실패했다고 간주한다는 리스크가 있지만, 어딘가에서는 반드시 데이터를 보장을 해야 하기 때문에 이런 정의가 필요합니다.
@EventListener @Transactional public void handleEvent(MemberJoinApplicationEvent event) { memberEventRecorder.record(event.toEventCommand()); }
이 정의를 통해 이벤트 저장소에 대한 저장을 트랜잭션 범위 내에서 처리하는 구독자를 만들었습니다.
이벤트 저장소의 종류
이벤트는 작은 단위로 저장이 되고, 고속 처리되어야 하기 때문에 RDBMS 가 아닌 다른 데이터베이스를 선택해야 한다고 생각할 수 있습니다.
도메인 저장소와 다른 종류의 데이터베이스를 사용할 경우 두 저장소에 대한 트랜잭션 처리를 할 수 있어야 합니다. 그러나 다중 데이터베이스의 분산 트랜잭션을 구현하는 것은 굉장히 어려운 일입니다.
이벤트 저장소를 도메인 저장소와 동일한 저장소로 선택을 했을 경우 트랜잭션에 대한 처리는 DBMS를 믿고 맡길 수 있으며, 인프라에 장애가 발생해도 트랜잭션을 통해 데이터 일관성을 보장할 수 있습니다.
동일 저장소를 통해 데이터베이스를 저장하고 이벤트를 발행함에 안정적인 정합성을 보장하는 방식은 Transactional outbox Pattern이라고 소개되기도 합니다. 이 패턴의 핵심은 로컬 트랜잭션(동일 저장소를 사용한 트랜잭션)을 사용하여 데이터베이스를 저장하고 이벤트를 발행함에 정합성을 보장하는 내용입니다. 이벤트 저장소를 사용하기로 한 것이 이벤트 발행에 대한 보장 문제를 해결하기 위함이니 이 구현은 Transactional outbox Pattern의 또 다른 구현이라고 볼 수도 있습니다.
단일 저장소의 쓰기 량 및 읽기 량에 대한 성능적 리스크를 동반할 수 있겠지만, 이는 스케일업/아웃 혹은 샤딩을 통해 대응할 수 있습니다.
또는 다른 저장소를 사용하고 싶다면 Saga 패턴 등을 사용하여 트랜잭션 관리를 시도해 볼 법합니다.
이벤트 발행을 보장하자
- 도메인 이벤트가 발생할 때 첫 번째 계층의 이벤트 저장 구독자는 트랜잭션을 확장하여 도메인 행위와 함께 이벤트가 저장소에 저장됩니다.
- 첫 번째 계층의 SNS 발행 구독자는 AFTER_COMMIT 옵션으로 도메인의 트랜잭션이 정상 처리되었을 때 SNS로 내부 이벤트를 발행하게 됩니다.
- 두 번째 계층의 이벤트 발행 기록 구독자는 내부이벤트를 수신하여 이벤트가 정상 발행되었음을 기록합니다.
- 이후 이벤트 저장 시간을 기준으로 5분이 지나도 발행처리되지 않은 이벤트(false)들을 배치로 자동 재발행합니다.
- 여기 어때에서는 카프카의 실패를 Redis를 통해 재발행한다고 합니다.
이벤트 기반 아키텍처의 장점
- 회원시스템은 개인정보를 처리하기 때문에 데이터 조회에 대한 많은 요구사항을 가지며 이를 위해 수십 개의 기록 테이블이 존재했습니다. 이벤트 저장소를 구축함으로써 회원에 대한 모든 활동이 일관성 있는 방식으로 저장되어 더 이상 별도 기록 테이블들이 필요하지 않게 되었습니다.
- 비동기와 낮은 결합도를 통해 더 이상 핵심 도메인 로직과 비관심사를 같이 다 루지 않아도 됩니다.
- 확장성이 높다.
이벤트 기반 아키텍처의 단점
- 도메인의 주행이 이외의 다른 비관심사 정책들은 다른 곳들에 흩어져 있기 때문에 파악하기 힘들다.
- 디버깅이 어렵다
- 시스템 의존도는 낮아지지만 메시지브로커에 대한 의존성이 발생합니다. 브로커의 장애가 발생하면 큰 장애로 확산될 가능성이 있다.
- Transaction 단위 분리 (장애나 이슈발생 시 Retry/Rollback에 대한 고려가 필요)
참고자료
https://www.youtube.com/watch?v=b65zIH7sDug&t=10s
https://techblog.woowahan.com/7835/
https://www.samsungsds.com/kr/insights/1239180_4627.html
https://dealicious-inc.github.io/2021/10/25/k-fashion-shop.html
'프로젝트 > 선착순 쿠폰 발급 시스템' 카테고리의 다른 글
쿠폰 발급을 위한 Redis Set Document 읽기 (0) 2023.05.06 Embedded Redis 구성하여 테스트 수행하기 (0) 2023.05.04 Spring Boot Flyway 적용하기(flyway, h2 DB test 에러 해결) (0) 2023.04.30 Database local dev환경 구성하기(postgreSQL with Docker) (0) 2023.04.29 회원 도메인 모듈 만들기 (0) 2023.04.27