jeongmyeong zZZ,,

MSA 환경에서 Kafka 사용에 대한 고찰

Kafka 를 사용해보자

대부분의 서비스 구조가 API 를 통한 Ack 전달, 또는 사용처에서의 Api 기반 polling 방식이었다.

MSA 환경에서 여러 관심사 별로 마이크로 서비스들이 생기고, 서비스들이 많아질 수록 해당 서비스들에 대한 강한 의존성이 생기기 마련이다.

데이터를 매번 각 서비스에게 전달해줘야했고, 이 분산 서비스 구조 사이의 데이터 전달 과정에서의 트랜잭션 핸들링마저 핸들링 되기 시작한다.

동기 방식이 꼭 필요한 상황에선 Saga pattern, 2PC 과 같은 형태를 구성해야겠지만 일반 커머스 플랫폼에선 이런 강한 트랜잭션이 필요한 행위는 많이 없는 것 같다.


이런 문제점들을 해결하기 위해 대규모 서비스를 개편하면서 카프카를 통한 이벤트 발행 방식을 활발하게 사용해보기로 했다.


겪었던 문제들

직접적으로 “Kafka를 사용해서” 라고 보기는 어렵지만, MSA 환경에선 관심사에 대한 컨텍스트를 나누기가 정말 어렵다.

각 서비스에서 필요한 데이터를 스스로 구축해야하며, 각 서비스에서 정책을 유연하게 주무르려면 발행되는 데이터에 대해 정확한 이해가 필요하다.

“주문”은 특히 커머스에서 많은 서비스들을 통합해 만들어낸 데이터이므로 upstream service 에선 이 데이터가 어떤 데이터인지를 알기 어렵다.

주문 데이터로는 후기도 작성할 수 있으며, 클레임 문의도 작성할 수 있고, 매출, 회계, 정산등에 대한 집계도 이루어질 수 있으며 회원의 혜택이 변경되기도 한다.


Payload

첫 도입시에 발목을 잡은 게 우선 발행되는 메시지 구조였다.

각 도메인에서 원하는 주문 데이터를 원하는 구조에 맞게 payload 를 설계하기가 정말 어렵다.

주문서에서 상품을 결제하는 단순한 행위처럼 보이지만 내부적으론 배송을 어떻게 해야할지, 각 셀러들의 상품에 할인 금액은 어떻게 분배 되는지 등 엄청나게 많은 일들이 일어나게 되는데, 이게 메시지를 소비(consume) 하는 구독자(subscriber) 들에겐 너무 과한 메시지일 수 있다.

또 반대로 메시지를 너무 빈약하게 설계한다면 회계/정산 처리는 이런 자세한 금액정보나 배송정보들이 부족하다고 느낄 수 있다.

zero payload 와 같은 방식으로 설계 후 각 서비스에 알맞은 api 를 제공해주는 방법도 생각 해볼 수 있지만, 또 주문 데이터는 그 행위가 일어난 시점의 데이터가(snapshot) 중요할 수 있다.

또, 서비스는 항상 확장된다.

처음 설계했던 데이터 구조가 크게 변경되어 기존 payload 로는 의도한 모든 데이터를 담아내기 어려울 수도 있다.


Topic

위와 같은 문제들이 많아, 최대한 이벤트 단위로 토픽을 분리해 설계했다.

A 라는 토픽은 주문이 완료 되었을 때 발행되고, B 라는 토픽은 주문이 취소 되었을 때, C 라는 토픽은 배송이 완료되었을 때 발행되도록.

각자 궁금한 데이터가 있으면 해당 토픽을 구독하시어 메시지를 소비하는 방식으로 진행했다.

초기엔 각 행위 (토픽) 별로 원하는 소비자들이 명확했고, 각 서비스 담당자에게만 토픽에 대한 설계 방향, 데이터의 사용방법을 설명하면 됐다.

그치만, 서비스는 역시 항상 확장된다.

배송완료 시점으로 매출 집계를 했던 게 주문완료 시점으로, 그리고 취소 데이터도 포함해 준 실시간으로 집계하고 싶어질 수 있다.

A 토픽과 B 토픽을 예로 들면 한 주문이 주문되고, 취소 되고 순서가 보장되길 원한다.

B 토픽엔 물건이 하나만 취소 됐을 때도, 그 뒤에 또 두개를 취소했을 때도, 한 번에 전 상품을 취소했을 때 메시지가 발행된다.

메시지를 발행할 때 key 기반으로 동일한 파티션에 순서대로 발행되도록 보장할 순 있지만, A 토픽과 B 토픽의 순서를 보장 할 순 없다.

A 토픽엔 B 토픽보다 메시지가 많이 발행되며, lag 이 있는 경우 같은 주문이어도 B 토픽을 통해 주문 취소 이벤트를 더 먼저 소비하는 상황이 생길 수 있다.

서비스가 운영중이 아니더라도 서비스 정기점검, 또는 임시 서비스 중단, 배포 중에도 이런 문제가 발생할 수 있다.

각 컨슈머들은 이럴 때를 대비해 DLT(DLQ) 나, 내부 fallback 로직을 새로 개발하게 될 수 있다.

이런 경우를 대비해 각 토픽의 파티션 갯수도 잘 설정해야 각 서비스들에서 인스턴스 별로 스케일을 맞춰 서비스들을 파티션에 할당할 수 있다.

같은 도메인에서 비슷한 message payload 를 가지고 있다면 소비자들이 구분할 수 있는 “event(action)” 과 같은 필드를 payload 에 추가해 같은 토픽으로 메시지를 발행하는 것도 좋은 방법이 될 수 있다.

각 서비스들에선 해당 필드를 기반으로 필터링 하도록 설정하도록 가이드를 드리면 된다.


message producing transaction

주문완료와 같은 메시지를 예로 들어보자.

주문이 정상적으로 결제되어 DB 엔 데이터가 정상적으로 적재 되었지만, kafka message 를 발행하는 데 실패했다면 어떨까.

실제로 매니지드 서비스를 (msk) 사용할 땐 이런 경우가 거의 없지만, maintenance patch 나 dns resolving issue 등 간헐적으로 메시지 발행에 실패할 수 있다.

이런 경우를 대비해 메시지 발행에 대한 예외 핸들링을 잘 해야한다.

간헐적인 문제인 경우 메시지를 재발행한다면 좋겠지만, 메시지 발행이 어려운 경우 주문이 정말 완료처리 되어도 되는지도 돌아봐야한다.

이벤트 방식은 결합도가 낮고, 소비자들을 깊게 관여해도 되지 않지만 이런 문제가 발생했을 때 꼭 어디선가 삐그덕대기 마련이다.

이런 결합도를 최대한 서비스 내부에 두고 싶지 않다면 outbox pattern 과 같은 message relay 방식을 고려해볼 수 있겠지만, 역시 이런 예외 핸들링은 꼭 필요하다.

메시지가 발행되거나, 또 메시지가 실수로 중복 발행되면 어떻게 할지 설계 과정에서 깊은 고민이 필요하다.

idempotent 설정은 선택이 아닌, 필수이다.