채팅 서비스 운영하다 보면
“이건 그냥 API 하나 추가하면 끝나는 거 아님?”
싶은 기능이 생각보다 깊은 구조 고민으로 이어질 때가 많음.
이번에 고민했던 건 채팅방 목록 페이징이었음.
정확히는 채팅방 목록을 보여줄 때,
각 방의 마지막 채팅, 안 읽은 개수, 마지막 읽음 시점/seq 같은 정보들을
어떻게 효율적으로 갱신하고 조회할지가 핵심이었음.
처음엔 단순히 DB 잘 조회하면 되겠지 싶었는데,
트래픽이 조금만 올라가도 이야기가 달라짐.
특히 다수의 사용자가 동시에 들어오고,
채팅이 자주 발생하고,
읽음 처리까지 겹치면
생각보다 “갱신 작업” 자체가 부담이 됨.
그러다 보니 자연스럽게
이럴 때 Kafka 같은 MQ를 쓰는 건가?
라는 생각까지 가게 됐음.
처음 고민했던 구조
대략 이런 흐름이었음.
- 사용자가 채팅을 보냄
- 채팅방의 마지막 채팅 정보 갱신 필요
- 읽음 처리 발생 시 마지막 읽은 seq/time 갱신 필요
- 채팅방 목록 API에서는 이 값을 기반으로 정렬/페이징/표시 필요
문제는 이 갱신을 어디서, 어떤 방식으로 처리할지였음.
가장 단순한 방법은
각 API 서버 또는 Socket 서버에서 바로 DB update 치는 방식임.
그런데 이 방식은 금방 한계가 보였음.
이유는 명확했음.
- 동일한 채팅방에 대한 update가 짧은 시간 안에 너무 많이 들어올 수 있음
- 여러 컨테이너가 동시에 같은 room_id를 갱신하려 들 수 있음
- 결국 DB write가 불필요하게 많아짐
- 순서 보장도 신경 써야 함
특히 “마지막 읽음 seq” 같은 값은
줄어들면 안 되는 값이라서
순서 꼬임이나 중복 update에 더 민감했음.
그러면 MQ 컨테이너 하나 두면 되는 거 아님?
처음 떠오르는 건 이 방식이었음.
- 별도의 MQ 역할 컨테이너를 둠
- 모든 서버가 그쪽으로 이벤트를 보냄
- MQ consumer가 순차적으로 처리함
장점은 분명함.
- 이벤트 처리 흐름이 모임
- 순서 제어가 쉬워짐
- 서버별 중복 처리 줄이기 쉬움
근데 운영 관점에서 부담이 생김.
1. 컨테이너 하나 더 운영해야 함
이게 생각보다 큼.
실서비스에서는 “기술적으로 가능함”보다
“운영 포인트가 얼마나 늘어나는가”가 훨씬 중요함.
MQ 역할 컨테이너 하나 늘어나면
그에 따른 체크 포인트도 같이 늘어남.
- 장애 감시
- 재기동 정책
- 로그 확인
- 리소스 튜닝
- 배포 시 영향 범위
기능 하나 때문에 운영 복잡도가 올라가면
생각보다 손해일 수 있음.
2. 그 컨테이너도 결국 병목이 될 수 있음
모든 이벤트를 한 곳으로 모으면
설계는 깔끔해지는데
그 한 곳이 핫스팟이 될 수 있음.
특히 읽음 처리나 채팅방 메타 갱신이 많아지면
“단일 consumer” 구조는 결국 한계가 옴.
즉, 구조적으로는 예뻐 보여도
트래픽이 커지면 다시 확장성 고민을 해야 함.
그럼 각 노드마다 worker를 두면 되는 거 아님?
이것도 많이 떠오르는 방식임.
- 8개 컨테이너가 있으면
- 각 컨테이너에 worker 하나씩 둠
- 각자 이벤트를 받아 처리하게 함
겉보기엔 분산도 잘 되고 좋아 보임.
근데 이건 또 다른 문제가 생김.
1. 같은 room_id를 여러 worker가 동시에 처리할 수 있음
이게 제일 큼.
예를 들어 room_id 123에 대한 이벤트가 들어왔는데
여러 서버가 거의 동시에 받으면
여러 worker가 같은 채팅방에 대한 갱신을 중복 수행할 수 있음.
그러면 생기는 문제는 뻔함.
- 중복 update
- 불필요한 write 증가
- 락 경합 가능성
- 순서 꼬임 가능성
즉, 분산은 됐는데
정작 같은 엔티티에 대한 단일 처리 보장이 안 됨.
2. 성능적으로도 꼭 좋은 게 아님
worker가 많다고 무조건 좋은 게 아님.
오히려 이벤트량보다 worker 수가 과하면
경합과 context switching만 늘고
실효성 없는 구조가 될 수 있음.
그래서 Kafka가 떠오름
여기서 Kafka가 왜 생각났냐면,
딱 필요한 기능이 보였기 때문임.
Kafka는 단순히 “메시지 보내는 큐”라기보다
파티션 단위로 순서를 보장하면서 분산 처리하기 좋음.
예를 들면 room_id 기준으로 partition key를 잡으면
- 같은 room_id는 같은 파티션으로 감
- 그 파티션은 하나의 consumer가 처리함
- 결국 같은 채팅방 이벤트는 순차 처리됨
이게 엄청 매력적이었음.
즉, 내가 원했던 건 사실 “MQ” 자체가 아니라
이런 특성이었음.
- 같은 room_id는 한 군데서 처리됐으면 좋겠음
- 여러 worker로 분산은 하고 싶음
- 순서는 보장됐으면 좋겠음
- 특정 컨테이너 하나에 몰리진 않았으면 좋겠음
이 요구사항만 보면 Kafka가 정말 잘 맞음.
그래서 “아, Kafka를 이런 이유로 쓰는구나”가 이해됐음.
그런데도 Kafka를 쓰고 싶진 않았음
이게 핵심이었음.
내가 처리하고 싶은 건
거대한 이벤트 스트리밍 플랫폼이 아니었음.
그냥 채팅방 마지막 읽음 시간, 마지막 seq,
목록 페이징을 위한 보조성 메타데이터였음.
여기에 Kafka를 넣는 순간 생기는 것들:
- 클러스터 운영 복잡도 증가
- 브로커 관리 필요
- consumer group 관리 필요
- 장애 복구/오프셋 관리 고려 필요
- 운영팀 부담 증가
- 기능 대비 시스템이 너무 무거워짐
즉, 문제는 이해했는데 해법이 너무 무거운 느낌이었음.
이럴 때 진짜 중요한 건
“기술적으로 가장 멋진 구조”가 아니라
현재 서비스 규모와 운영 여건에 맞는 최소 충분 해법이라고 생각했음.
결국 필요한 건 Kafka가 아니라 “샤딩된 단일 처리”였음
정리해보면 내가 원한 건 이거였음.
- 같은 chatroomId는 동시에 여러 군데서 처리되지 않게 하고 싶음
- 여러 worker에 분산은 하고 싶음
- worker 하나 죽어도 전체가 멈추면 안 됨
- 운영 복잡도는 크게 늘리고 싶지 않음
- 읽음 처리 같은 메타 갱신 때문에 Kafka까지 쓰고 싶진 않음
즉 필요한 건
Kafka 그 자체가 아니라
chatroomId 기준으로 소유권이 분배되고, 장애 시 재분배되는 구조였음.
%8 해싱이 애매했던 이유
처음에는 단순히 이런 생각도 했음.
- worker 8개면
- chatroomId % 8
- 이렇게 나눠서 각 worker가 자기 방만 처리하면 되지 않나?
이 방식은 아이디어 자체는 단순하고 좋음.
근데 실운영에서는 치명적인 문제가 있음.
1. 인스턴스 수가 고정이라는 가정이 필요함
8개일 때만 맞는 구조임.
스케일 아웃/인 되면 분배 기준이 바뀜.
2. 8개 중 1개가 죽으면 그 샤드는 비게 됨
예를 들어 3번 worker가 죽으면
3번 담당 room_id들은 처리 주체가 없어짐.
이걸 보완하려면 결국
- 멤버십 관리
- 리밸런싱
- 리더 선출
- 소유권 이전
같은 문제가 다시 생김.
결국 이쯤 되면
이미 Kafka consumer group이나
분산 coordinator가 제공하는 기능을
직접 만들고 있는 셈이 됨.
그래서 현실적인 답은 Redis shared + 분산락/리스 기반 워커였음
여기서 Redis가 다시 강하게 떠오름.
이미 Redis를 shared하게 쓰고 있다면
굳이 Kafka까지 가지 않아도
어느 정도 필요한 기능을 만들 수 있음.
핵심 아이디어는 이거였음.
1. 이벤트 자체는 가볍게 적재
예를 들면 Redis 자료구조를 활용해서
갱신이 필요한 chatroomId를 적재함.
중복을 줄이려면 list보다 set이 유리함.
- 채팅 발생
- 읽음 처리 발생
- 해당 chatroomId를 Redis set에 넣음
그러면 같은 방에 대해 이벤트가 여러 번 와도
set 특성상 중복이 줄어듦.
2. worker들이 공용 큐/셋을 가져가 처리
각 worker는 Redis에서 할 일을 가져와 처리함.
3. 처리 전 락 또는 lease 획득
같은 room_id를 여러 worker가 동시에 처리하지 않도록
Redis lock 또는 짧은 lease를 사용함.
예시 느낌은 이런 식임.
- lock:chatroom:{roomId} 키를 SET NX EX로 획득
- 락 획득한 worker만 처리
- 처리 후 해제 또는 TTL 만료
이렇게 하면
중복 update 가능성을 꽤 줄일 수 있음.
4. 값 자체는 “증가 방향으로만” 반영
마지막 읽음 seq처럼 역행하면 안 되는 값은
Lua script나 원자 연산으로
“기존 값보다 클 때만 반영”하게 하면 됨.
이게 중요함.
즉 설계 자체를
“중복 이벤트가 와도 괜찮고, 순서가 약간 흔들려도 최종값은 안전한 구조”
로 만드는 게 핵심임.
내가 보기엔 이 문제에서 제일 중요한 건 exactly-once가 아님
이런 류의 기능은
많은 사람들이 처음에
“정확히 한 번만 처리돼야 하지 않나?”
로 접근하는데, 실제로는 그보다 중요한 게 있음.
바로 idemopotent 해야 한다는 점임.
즉,
- 두 번 처리돼도 결과가 같아야 함
- 조금 늦게 처리돼도 치명적이지 않아야 함
- 최종적으로 올바른 상태에 수렴해야 함
채팅방 마지막 읽음 seq, 마지막 채팅 시간, unread count 재계산 같은 건
대부분 이런 방향으로 설계 가능함.
이렇게 되면
굳이 무거운 MQ를 도입하지 않아도
Redis shared 기반으로 꽤 실용적인 구조를 만들 수 있음.
그럼 가장 현실적인 구조는?
내 기준에서는 아래 구성이 제일 현실적이었음.
구조
- API/Socket 서버들은 이벤트 발생 시 Redis에 chatroomId 적재
- Redis는 shared 저장소 역할
- 여러 worker가 Redis에서 처리할 room_id를 가져감
- room_id 처리 전 Redis lock/lease 획득
- 처리 시 DB update는 배치 또는 조건부 update로 최소화
- 마지막 읽음 seq는 “더 클 때만 갱신”
- 채팅방 목록 API는 Redis/DB 조합으로 조회
장점
- Kafka까지는 안 가도 됨
- 컨테이너 하나에 모든 부담이 몰리지 않음
- 여러 worker로 분산 가능
- 같은 room_id 중복 처리 줄일 수 있음
- 운영 복잡도가 비교적 낮음
단점
- Kafka처럼 파티션 단위의 강한 순서 보장은 아님
- 락/lease 설계를 잘못하면 중복 처리 가능성 있음
- worker 수가 너무 많으면 Redis 경합이 생길 수 있음
그래도 현재 문제 크기와 운영 조건을 생각하면
가장 균형 잡힌 선택지라고 봤음.
'TIL' 카테고리의 다른 글
| [CKA] crictl, journctl 및 명령어 기본 정리 (0) | 2026.04.22 |
|---|---|
| 정보처리기사 후기 (0) | 2026.04.22 |
| 중요한 로그가 무엇인지 (0) | 2026.04.03 |
| 공인 IP 2개 나오는 이유 (VIP 구조 정리) (0) | 2026.04.01 |
| Jenkins + GitOps 기반 배포 자동화 정리 (0) | 2026.03.26 |