타 서비스 간 통신을 위해 API Call 뿐 아니라, event 방식으로도 많이 이용한다.
특히, 강결합을 제외하고 싶을 때 + 서로간의 양방향 통신이 아닌, 특정 행위 발생 시 단방향으로 메시지를 보내고 싶을 때에 이벤트를 보내는 방식을 고려하게 된다.
이벤트를 보내는 방식은 다양하게 존재한다.
먼저, @EventListener를 이용하여 동일 서비스 내에서 보낸 이벤트를 받아 처리하는 방식이 있다.
이 방식은 이미 지난번에 작성한 포스팅이 존재한다.
https://kth990303.tistory.com/441
그러나, 타 서비스에서 보낸 이벤트를 받아서 처리하고 싶은 경우가 있다.
이런 경우는 MQ를 많이 활용한다. RabbitMQ, Kafka, Amazon SNS+SQS 등.
위 사진의 경우는 Amazon SNS로 특정 topic에 대한 이벤트를 발행하고, 해당 topic을 구독한 타 EC2에서 SQS로 이벤트를 받는 방식을 소개하고 있다.
Amazon SNS는 push 방식으로 EC2 내에서 발생하는 이벤트를 보낸다.
(참고로, Kafka는 이와 다르게 consumer 측에서 broker 내에 있는 이벤트를 polling하는 방식이고 일반적으로 SNS, SQS보다 더 큰 트래픽을 처리할 때 사용한다.)
그런데 문제는, 이벤트가 항상 정상적으로 보내지는 케이스만 존재하는 건 아니라는거다!
이벤트의 순서가 역전되거나, 이벤트가 유실되는 케이스가 존재할 수 있다.
이벤트 순서 역전 Case
예를 들어, 어떤 사용자가 물건을 주문하자마자 주문취소를 했다고 하자.
주문 쪽을 다루는 서비스에서는 해당 로직을 처리하고 주문 Event, 주문 취소 Event를 순서대로 Amazon SNS를 이용해서 전송했다.
그러나, 예시에서 SQS는 메시지 순서 보장이 되지 않는 Standard Queue를 사용하고 있다.
따라서 주문 Event와 주문취소 Event 가 SQS의 대기열에서 순서가 뒤바뀌는 케이스가 간혹 존재할 수 있다.
(Standard Queue가 아닌 FIFO Queue 유형도 존재하긴 한다. 조금 이따가 서술하겠다.)
결국 이벤트 순서가 역전되어, 주문취소 이벤트를 먼저 받고 주문 이벤트를 나중에 받게 된다.
최종 상태가 `주문취소`로 돼야 하는데, 이벤트 순서가 보장됐다고 믿고 로직을 처리하다간, 최종 상태가 `주문`으로 처리되는 것이다. 이로 인해 고객은 의도치 않은 배송을 받게 될 수도 있는 셈.
그럼 Standard Queue를 안쓰고 FIFO Queue 유형을 사용하면 되는 거 아냐?
Amazon SQS에 대해 잘 알고 계신 분들은 위와 같은 의문이 들었을 수도 있다.
FIFO Queue는
Standard Queue와 FIFO Queue 유형의 차이는 아래 사진으로 첨부한다.
요약하자면,
FIFO Queue는 Standard Queue에 비해 tps 처리량이 상당히 낮다. (무제한 vs 초당 300 api)
(다행인 점은, 해당 단점은 `높은 처리량` 조건으로 지원 요청 시, 꽤나 괜찮은 성능으로 사용할 수 있어서 해결이 가능하긴 하다.)
또, 위 사진에는 존재하지 않지만 FIFO Queue는 Standard Queue에 비해 요금이 비싸다.
그리고 가장 큰 단점은 FIFO Queue를 이용하면 순간적인 장애 또는 spark성 트래픽에 대처하기 어렵다는 단점이 있다. Queue 내에서 record를 반드시 올바른 순서대로 보내지므로, 앞의 record가 소비되지 않으면 전체적으로 blocking이 발생한다. 그리고 spark성 트래픽이 발생할 경우, 일정 성능 이상 발휘하기 힘든 FIFO Queue 특성상 blocking이 발생할 수 있다.
물론 로직을 추가적으로 보완하거나, 잘 대처한다면 위 단점들은 커버가능할 수도 있다.
하지만, 이 포스팅에서는 Standard Queue 유형의 SQS를 사용한다고 하자.
이벤트 유실 Case
이번에는 이벤트가 유실돼서 실패하는 케이스를 살펴보자.
마찬가지로 주문, 주문취소 이벤트를 순서대로 보내는 예시이다.
그런데, 하필 주문 이벤트가 특정 이슈로 인해 실패했다고 하자!
대부분은 수신하는 EC2 측의 서버 장애이지만, AWS 측의 장애일 수도 있고 혹은 Network 이슈로 인해 HTTP 이벤트 수신 실패일 수도 있다. 아무튼 이런 경우가 있긴 하다!
이벤트 송수신에 실패하게 되면 Amazon SQS 정책에 의해 retry를 하긴 한다. 하지만, 서버 측에서 몇초, 몇분동안 장애가 유지된다면 당연히 retry 횟수동안 모두 송수신에 실패할테고, 이렇게 되면 해당 Event는 DLQ에 들어가게 된다.
(DLQ 에 대해서는 https://docs.aws.amazon.com/ko_kr/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html 참고하자!)
주문 이벤트가 실패로직을 타는 동안, 주문취소 이벤트는 성공적으로 수신될 것이다.
그런데 이벤트를 수신받는 EC2 입장에서는 주문 이벤트를 받지 않았으므로, 주문 건에 대한 처리를 한적이 없다.
따라서 주문취소가 먼저 온 상황에 대해 의아해할 것이다.
따라서 해당 이벤트에 대해 무시하거나, 예외를 발생시키거나, 최악의 경우 이상한 값이 들어가 고객에게 혼란을 유발할 수 있게 된다.
다행히 DLQ가 존재하므로, 언젠가는 실패한 주문 이벤트를 다시 받긴 할 것이다.
(참고로 Standard Queue 유형을 사용했으면 DLQ도 같은 유형을 사용해야 한다.
FIFO Queue 유형이라면 DLQ도 FIFO Queue 유형이어야 한다.)
하지만 이 경우에도 이벤트 순서가 역전되는 문제는 남아있게 된다.
따라서 이벤트 순서가 역전되는 경우를 잘 처리해주어야 할 것이다.
내가 이벤트 순서 역전을 대처한 방법은?
이벤트 순서 역전을 대처하는 방법에는 상당히 여러가지가 있는 듯하다.
가장 유명한 방법은 아래 케이스인 듯.
zero-payload 방식으로, 이벤트에는 최소한의 정보를 담아주는 것이다.
그리고 그 이벤트에 해당되는 id 값을 이용하여 API Call을 이용해 응답을 받는 것이다.
이렇게 하면 API 응답을 통해, 가장 최신 정보를 이벤트송신측 서버로부터 받게 될 것이다. 따라서 이벤트 순서 역전 문제를 처리할 수 있게 된다.
나는 zero-payload 방식을 여기선 사용하지 않았다.
이유는 아래와 같다.
- 이미 해당 이벤트에는 데이터들이 존재했기 때문에, zero payload로 수정하기에는 공수가 좀 컸다.
- API Call 을 추가로 호출하기에 비용이 걱정되는 비즈니스 도메인
- 결정적으로, 순서 역전에 대해 매우 크리티컬하게 작용하는 도메인은 아니었기 때문에 zero payload 방식은 후순위로 미뤄두었다.
나는 위에서 설명한 주문/주문취소 건의 예시를 이용하자면, 이벤트 내에 주문발생시점인 created_at 정보를 넣어주는 것으로 해결했다.
이벤트를 수신한 측에서 주문취소 이벤트가 주문 이벤트보다 먼저 왔다고 하자.
아까 발생했던 문제는 두 가지이다.
- 주문 건이 없는데, 주문 취소가 와서 예외 발생!
- 주문취소를 처리한 후, 주문으로 최종적으로 업데이트돼서 최종상태가 주문으로 되는 이슈 발생!
2번은 이제 해결할 수 있을 듯하다!
왜냐하면, 주문 이벤트는 주문 취소 이벤트보다 created_at이 뒤쪽이다. 따라서 최종 상태로는 적절하지 않다!
created_at 정보 덕분에, 어떤 이벤트가 먼저 송신됐는지 알아볼 수 있게 된 것이다.
1번은 어떻게 해결할까?
1번 케이스에 대해서는 각 상황 및 비즈니스 도메인에 따라 다를 것 같다. 나는 애플리케이션 내에 방어로직을 작성하여 해결했다.
일단, 주문 건이 없어도 주문 취소 건을 저장해놓자.
주문 취소 이벤트가 왔다는 것은, 언젠가 주문 이벤트도 올 것이라는 의미이다.
(주문 쪽 EC2 에서 로직을 제대로 처리했다면, 주문 -> 주문 취소 순으로 시나리오가 발생했을 것이다. 따라서, 주문 취소 이벤트가 왔다면 주문 이벤트도 올 것)
이후, 주문 이벤트가 도착하게 되면 주문 관련 데이터를 애플리케이션 로직 상으로 적절히 필터링하면서 잘 처리한다.
최종 status를 `주문`으로 업데이트하진 않되, 주문 관련 필요 데이터들을 잘 update 하거나 insert 하도록.
아래처럼 주문취소 이벤트가 먼저 왔다면, 일단 주문취소 정보를 넣어둔다.
order table | status | sales_amount | order_date_time | cancelled_date_time |
CANCELLED | -17,000 | 13:22:12 |
그리고 주문 이벤트가 도착했다면, 주문 관련 정보를 update 또는 insert한다.
단, 주문 이벤트 내의 created_at (주문시점)이 주문취소 이벤트의 created_at (주문취소 시점) 보다 앞이다. 따라서 최종 상태는 `주문`으로는 update하지 않는다. 주문취소 이벤트가 먼저 오면서 생긴, 누락된 주문 관련 정보를 채워넣어준다.
order table | status | sales_amount | order_date_time | cancelled_date_time |
CANCELLED | 0 | 13:21:08 | 13:22:12 |
주문 이벤트가 도착함으로써, 주문 시점 정보도 넣을 수 있었고, 최종적인 매출금액도 0 (주문 -> 주문취소 됐으므로 매출금액은 0 이라 하자.) 으로 잘 업데이트될 수 있었다.
주의점
단, 이벤트에 필드를 추가하는 것은 상당히 조심해야 한다고 생각한다. 이벤트 내에, 해당 사건에 맞지 않는 불필요한 필드를 추가하게 되면 객체의 책임 면에서나, 보안 면에서나 적절치 않은 스파게티 코드가 탄생할 수 있기 때문.
다행히 주문, 주문취소 이벤트에 주문관련 시점 데이터를 넣는 것은, 이벤트 역할에 어긋나지는 않는다고 판단하였고 그에 따라 필드를 추가했다.
또, 애플리케이션 방어로직 뿐 아니라 zero-payload 방식 등으로 최대한 이벤트 순서 역전에 꼼꼼하게 대처하는 것이 베스트라 생각된다.
그리고 이벤트 유실에 대해서도, DLQ만 믿지 말고 애플리케이션 내에서 방어로직을 세우면 더 좋을 듯하다. 우아콘 2023, 2022 등에서 꾸준히 나오고 있는 transactional outbox pattern 이 하나의 좋은 대안이 될 수 있을 듯?
(참고: https://techblog.woowahan.com/7835/)
이벤트 처리는 참으로 어려운 듯하다.
하지만 잘 사용한다면 그만큼 유용한 존재이기도 한듯? 마치 async 처럼...
Amazon SNS, SQS 말고 Kafka를 사용하면서 발생하는 여러 이슈에 대해서도 언젠가 포스팅해볼 수 있음 좋겠다.
Kafka 리밸런싱 이슈가 요즘 핫하던데 ㅎㅎ
근데 사실 이벤트 말고도 동시성, 스레드, Redis, MySQL 등 공부할 건 산더미긴 하다 ㅋㅋ
'JAVA > JAVA | Spring 학습기록' 카테고리의 다른 글
[240406] GDSC Konkuk KPrintf 행사 참여 후기 (28) | 2024.04.06 |
---|---|
[Spring] @Transactional(readOnly=true)에서 write 시 예외 좀 더 살펴보기 (13) | 2024.01.22 |
[231115] 우아콘 2023 참여 후기 (34) | 2023.11.15 |
[231023] 흔한 백엔드 개발자 모임 컨퍼런스 후기 (29) | 2023.10.26 |
[Spring] @async 로직 실패 일대기, ThreadPoolTaskExecutor의 awaitTerminate와 Async (44) | 2023.09.23 |