MSA 사고실험 첫번째 포스팅인 데이터베이스 설계에 이어 두번째는 Kafka를 유연하게 설계해보았습니다. 보통 MSA에서 이벤트 기반 아키텍처를 설계할 때 카프카를 주로 사용하게 되는데 카프카에 대한 이론적인 내용보다 설계에 초점을 맞추게 되다보니 두 가지 관점에서 카프카를 바라볼 수 있을 것 같습니다.
바로 쓰기 연산이 주를 이루는 애플리케이션과 읽기 연산이 주를 이루는 애플리케이션 이렇게 두 가지 관점에서 볼 수 있을 것 같은데요. 보통 쓰기 연산에 부하가 많이 가는 채팅앱, 읽기 연산이 자주 일어나는 쇼핑몰과 SNS처럼 두 가지 관점에서 설계를 해보려 합니다.
주의!!
제 얄팍한 지식으로 설계하는만큼 실제 아키텍처를 공부하는게 아니고 직접 설계하는 능력을 기르기 위해서 쓰는 포스팅임을 알립니다. 실제로 설계를 먼저 해보고 이후 트위터나 디스코드같은 글로벌 대규모 서비스들의 기술 블로그를 보고 아키텍처를 다시 공부하는 작업이 이루어질 예정입니다.
이번 쓰기 부하 편에 이어 읽기 부하 편까지 포스팅을 작성하고 실제 벤치마킹을 할 애플리케이션의 아키텍처를 직접 공부할 예정이니 지금 포스팅은 단순하게 봐주시면 감사하겠습니다.
아키텍처
일단 아키텍처의 흐름도를 이야기하고 왜 그렇게 설계하게 되었는지 이야기해보겠습니다. 채팅을 예시로 서술해보겠습니다.
- 클라이언트에서 채팅을 입력하는 순간 백엔드 서버에 요청이 들어갑니다.
- API 게이트웨이를 타고 들어온, 혹은 다른 백엔드 서버에서 클라이언트가 되어 Redis Stream을 생산하는 서버로 요청이 들어옵니다.
- 해당 서버는 Redis Stream 메세지를 생산하는 Producer가 되면서 Redis Stream에 메세지를 밀어넣습니다.
- Redis Stream의 메세지를 소비하는 Consumer Group인 다른 서버들이 요청을 받아서 특정 Consumer Group은 클라이언트로 WebSocket을 이용해서 메세지를 뿌려줍니다. 그리고 다른 Consumer Group은 메세지의 보존을 위해서 데이터베이스 write 요청을 비동기로 처리하기 위한 Kafka 클러스터 Producer가 됩니다.
즉, 해당 서버는 Redis Stream을 소비하는 Consumer이면서 Kafka 클러스터에 메세지를 다시 밀어넣는 Producer이기도 한 것이죠. - Kafka에서는 들어온 메세지를 다시 해당 Topic을 구독하는 Consumer Group에 메세지를 보내고 최종적으로 이 Consumer Group이 데이터베이스 직접 쓰기 연산을 수행합니다.
- Kafka에서 브로커의 관리나 리밸런싱은 Cruise Control이 도맡아서 처리하게 됩니다.
이렇게 설계한 이유는 카프카의 특징때문인데요, 우선 채팅은 쓰기 연산이 많아 비동기로 처리해야하지만 카프카는 스파이크성 트래픽에 취약하기 때문에 앞에 버퍼를 하나 두어 카프카의 부하를 최소화하기 위함입니다.
카프카는 뛰어난 처리량을 보여주지만 스파이크성 트래픽에 부하가 심하고 특히 Zookeeper를 사용하는 KRaft 이전 버전들은 Topic이 늘어나면 늘어날수록 Zookeeper에 부하가 심해져 카프카 클러스터의 전체적인 성능하락에 큰 영향을 줍니다.
이렇게 버퍼를 둠과 동시에 Redis Stream에서 메세지를 소비하는 서버의 경우 바로 WebSocket으로 채팅을 상대방에게 전송해 UX적인 측면에서 메세지가 빠르게 전송된다는 느낌을 줄 수도 있어 해당 방식을 채택하게 되었습니다.
이 아키텍처에서 꼭 고려해야하는 점은 다음과 같습니다.
- 카프카는 메세지가 실패하는 경우 자동으로 Retry하는 로직이 있지만 Redis Stream은 자체적인 Retry전략이 없어 직접 DLQ를 구현해야 한다는 점
- Redis Standalone은 Redis Stream을 사용할 때 1000TPS 밖에 나오지 않아 클러스터링이 가능하면서 수평확장에 능한 Redis Cluster를 사용해야하지만 그렇게 되면 Retry전략을 사용할 때 샤드로 나누어진 슬롯으로 인해 DLQ를 구현하기 까다롭다는 점
- Redis Cluster는 클러스터 하나에 마스터, 슬레이브 노드가 들어가지만 모종의 이유로 클러스터 내부에 있는 모든 마스터, 슬레이브가 죽으면 해당 클러스터는 다시 복구할 수 없기 때문에 완벽한 HA는 구축할 수 없다는 점
- Topic이 늘어남에 따라 Zookeeper에 부하가 생기기 때문에 Topic 또한 샤딩이 되어야하고 그로인해 Consumer Group 쪽에서도 Group ID를 샤딩해야하기 때문에 구현, 운영 복잡성이 올라간다는 점
- 카프카의 메세지를 소비하는 Consumer Group에서 메세지와 데이터베이스 쓰기 연산을 트랜잭션으로 묶을 수 없어 Outbox Pattern이나 Transaction Tailing 방식으로 하나로 묶어야하지만 이는 구현, 운영 복잡성이 올라간다는 점
Redis Stream 재시도 전략
Redis Stream은 DLQ를 직접 구현해 재시도를 해야합니다. Redis Stream에서는 처리되지 않은 메세지를 로그로 가지고 있게 되는데 이 로그를 지속적으로 조회하면서 실패한 메세지를 DLQ에 넣어놓고 DLQ에 들어간 메세지를 지속적으로 Redis Stream으로 다시 밀어넣는 작업을 통해 직접 구현해야한다는 단점이 있습니다.
또한, Redis Cluster는 해시 슬롯으로 Automatic Sharding이 가능하기에 자체적으로 샤딩이 되지만 이렇게 샤딩이 되는 순간 Redis Stream의 재시도 전략을 수행하기 힘들어 Producer -> Cosumer -> Retry 에 이르는 이 한 사이클에서 같은 슬롯 넘버를 사용해야한다는 점이 또 하나의 고려할 점인데요.
그렇기 때문에 Redis Stream으로 메세지를 밀어넣을 때 어떤 슬롯 넘버를 사용하는지 Retry하는 쪽에서 알아야하기에 샤딩의 장점이 조금은 희석된다는 단점이 있습니다.
Redis Cluster의 HA
기본적으로 Redis Cluster는 HA를 지원하지만 하나의 클러스터 내부에 있는 모든 노드가 죽어버리면 이 클러스터를 살릴 수 있는 방법이 없기 때문에 완벽한 HA라고는 할 수 없습니다.
이건 뭔가 해결책이 있는건 아니지만 만약 클러스터 내부에 있는 모든 노드가 죽어버린다면 다른 클러스터에 부하가 심해져 전체적인 장애로 이어질 수 있어 각별히 주의해야합니다.
Topic, Consumer Group Sharding
Zookeeper를 사용하는 경우 Topic이 1만개 이상이 되면 Zookeeper에 부하가 생겨 카프카 클러스터의 전체적인 성능 하락 이슈가 생길 수 있습니다. 때문에 Topic을 분산시켜 부하를 줄여야하는데요.
기본적으로 Producer -> Topic -> Partition -> Consumer Group -> Consumer 로 이어지는 이 흐름에서 Topic과 Consumer Group을 샤딩하게 되면 전체적인 부하를 분산시킬 수 있어 해당 방법이 크게 도움이 될 것입니다.
하지만 해당 방법은 메세지가 어디로 흐르는지 정확하게 추적할 수 없다는 점 때문에 디버깅을 하기 매우 까다롭다는 문제가 생긴다는 단점이 있습니다.
때문에 KRaft를 사용하도록 버전을 업그레이드 하는 방법도 생각해볼 수 있습니다. KRaft는 수만개의 Topic을 수용할 수 있기 때문에 만약 Topic을 샤딩하는 것이 운영 복잡도를 높일 수 있고 이것이 싫다면 KRaft로 업그레이드하는 방법이 좋은 해결책이 될 것입니다.
카프카 메세지와 데이터베이스 연산의 트랜잭션
카프카 메세지는 자체적으로 트랜잭션으로 메세지를 묶을 수 있지만 (EOS) 이 작업이 데이터베이스의 연산과 묶을 수는 없기에 EOS만으로는 부족하고 추가적으로 Outbox Pattern이나 Transaction Tailing전략으로 카프카 메세지와 데이터베이스 연산을 묶어야합니다.
하지만 이 과정에서 개발, 운영 복잡도가 올라가고 만약 데이터베이스도 여러개의 노드로 분리되어 있다면 Transaction Tailing을 구현하기 매우 까다로워 복잡도가 올라가게 됩니다.
때문에 구현하기 편한 Outbox Pattern을 생각해볼 수 있지만 Outbox Pattern은 트랜잭션의 원자성을 보장하기 위한 테이블에 UPDATE쿼리가 지속적으로 날아가야하기에 쿼리가 여러번 날아가고 쓰기 연산이 많은 채팅 앱에서는 부담되는 것도 사실입니다.
때문에 구현, 운영 복잡도가 매우 올라가게 되어 들어가는 리소스가 많아질 우려가 있다는 단점이 있습니다.
마치며
MSA가 개발, 운영 복잡도가 올라가니까 함부로 시작하지 말라는 선배님들의 말처럼 복잡도가 정말 끝도없이 올라가는 모습을 보면서 정말 함부로 시작할게 아니라는 생각이 다시금 들게 하는 설계였습니다.
저는 단순히 설계만 해보는 입장에서 실제 구현하고 실제 운영은 안해봤지만 이 설계가 개발, 운영 복잡도가 매우 높다는건 굳이 먹어보지 않아도 알겠더군요.
하지만 이렇게 설계하면 정말 쓰기 연산에 부하가 아무리 심해도 크게 보완할 수 있어 대규모 서비스에서 빛을 발하게 될 것으로 예상됩니다.
이번 쓰기 부하 편에 이어 다음 포스팅은 읽기 부하 편으로 이어집니다. 읽기 연산이 주를 이루는 SNS, 쇼핑몰 등에 빚대어 아키텍처를 설계해보고 고려해야하는 점은 어떤 것인지 한번 학습해보도록 하겠습니다.
이번에도 긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~
'DevOps > 사고실험' 카테고리의 다른 글
MSA에서 데이터베이스 아키텍처 설계해보기 (RDBMS) (0) | 2025.06.21 |
---|---|
MSA 사고실험 시작 (0) | 2025.06.21 |