현직자 피드백
공유해주신 채팅 시스템 설계 문서 꼼꼼하게 잘 읽어보았습니다
단순한 기능 구현을 넘어서 서버 다운, 메시지 유실 방지, 리소스 최적화 등 분산 시스템 전반에 대해 깊이 고민하신 흔적이 많이 보여서 인상 깊었습니다. 코멘트로 짧게 남기기에는 다루고 있는 아키텍처적 맥락이 중요하다고 느껴져서, 이렇게 따로 정리해서 공유드립니다. (노션에 어디에 달지 고민하다가 포기한 것은 아닙니다… 😄)
가장 먼저 인상 깊었던 부분은 큐 설계의 진화와 ACK 처리 기준입니다.
세션 단위 큐에서 서버(인스턴스) 단위 큐로 전환하신 것은 확장성 측면에서 매우 합리적인 선택으로 보입니다. 이는 결과적으로 특정 대상에게 메시지를 라우팅하기 위한 일종의 샤딩 전략으로 볼 수 있고, 리소스 관리 측면에서도 좋은 방향이라고 생각됩니다.
또한 Sink를 통해 실제 WebSocket으로 전달이 완료된 시점(Sinks.EmitResult.OK)을 기준으로 ACK를 처리하도록 하신 점은, 단순 적재가 아닌 실제 전달을 기준으로 한 신뢰성 확보라는 측면에서 굉장히 견고한 설계라고 느꼈습니다.
문서 하단에 남겨주신 고민들과 관련해서, 몇 가지 관점에서 같이 고민해볼 수 있을 것 같아 의견을 드려봅니다.
- 재전송 메시지와 신규 메시지 구분
작성해주신 것처럼 event payload를 확장하는 방향은 분산 환경에서 일반적으로 많이 사용하는 접근입니다.
originalMessageId, retryCount 등의 메타데이터를 추가하면 재전송 여부를 명시적으로 구분할 수 있고, 이후 처리 로직도 훨씬 단순해질 수 있습니다.
여기서 한 가지 추가로 생각해볼 수 있는 부분은, 서버에서 완벽하게 순서를 재조립하려 하기보다는 일부 책임을 클라이언트와 나누는 방식입니다.
백엔드 입장에서는 최대한 정제된 데이터를 내려주고 싶지만, 소켓 스트림 환경에서는 완전한 순서 보장을 위해
락
버퍼링 대기 (Head-of-Line Blocking)
같은 요소들이 들어오게 되고, 이 경우 지연이나 메모리 병목으로 이어질 가능성이 있습니다.
그래서 실무에서는 다음과 같은 하이브리드 접근을 많이 사용하는 것으로 알고 있습니다.
서버 (Best-effort)
최근 messageId를 짧은 TTL로 캐싱하여 명백한 중복은 사전에 제거
가능한 범위 내에서 순서 유지
클라이언트 (Final guarantee)
messageId 기반 UPSERT
timestamp 등을 활용한 최종 정렬 및 중복 제거
전체적으로 채팅 시스템은
“low latency vs ordering” 사이의 trade-off를 가지는 경우가 많아서,
시스템 전체는 eventual consistency 성격을 가지되,
대화방/유저 단위에서는 ordering을 최대한 보존하려는 방향
으로 설계하는 경우가 일반적인 것 같습니다.
- Consumer Group 병렬 처리에 대한 부분
이 부분은 아쉬움으로 보기보다는, 현재 구조의 특성에서 자연스럽게 나온 제약으로 보입니다.
채팅은 특정 유저가 물리적으로 연결된 서버로 메시지를 전달해야 하는 stateful한 특성이 있기 때문에,
푸시 알림처럼 자유롭게 consumer 간에 작업을 분산하기는 어려운 구조입니다.
특히 userId 또는 roomId 단위로 순서를 보장해야 하는 경우에는,
해당 키가 매핑된 큐(또는 샤드)를 하나의 consumer가 처리하는 패턴이 일반적으로 많이 사용됩니다.
다만, 향후 구조를
shared stream
또는 userId 기반 shard 구조
로 확장하게 되면, 일부 영역에서는 consumer group 기반 병렬 처리도 함께 활용할 수 있는 여지는 있어 보입니다.
- Heartbeat 관리 방식
Relay Worker가 각 소켓 서버의 상태를 직접 polling 하는 구조는 말씀하신 것처럼 결합도가 높아질 수 있어서,
가능하다면 상태 관리는 별도의 메커니즘으로 분리하는 것이 좋아 보입니다.
이 부분은 상황에 따라 여러 접근이 가능한 것 같습니다.
Redis Keyspace Notification 활용
별도 인프라 없이 Redis 기반으로 처리 가능
다만 Pub/Sub 기반이라 이벤트 유실 가능성은 고려 필요
Kubernetes 환경 연동
Pod 상태 변화를 직접 watch
인프라 레벨에서 가장 정확한 이벤트 수신 가능
Service Discovery / Coordinator (Eureka, Consul, Zookeeper 등)
가장 전통적이고 안정적인 방식
다만 운영해야 할 컴포넌트가 늘어남
Lazy Sweeper (주기적 정리)
구현 단순, 안정성 높음
대신 일정 시간 지연 허용 필요
시스템 특성(실시간성 vs 단순성)에 따라 선택지가 달라질 수 있는 부분이라,
지금 단계에서는 가장 운영 부담이 낮은 방식부터 시작해보는 것도 좋은 접근일 것 같습니다.
- 트래픽 확장 및 샤드 구조 (조금 더 확장된 아이디어)
현재 구조는 말씀해주신 것처럼 Redis Streams를 활용한 경량 메시징 시스템으로 잘 동작할 수 있을 것 같습니다.
다만 트래픽이 증가하는 상황을 가정하면,
queue를 instanceId에 종속시키기보다는 고정된 shard 단위로 분리하는 방식도 하나의 선택지가 될 수 있습니다.
예를 들면:
socket:inbox:shard-0 ~ shard-N
hash(userId) % N으로 shard 결정
이렇게 하면
메시지 라우팅이 단순해지고
특정 서버에 종속되지 않는 구조가 됩니다.
그리고 서버는:
shard를 점유 (lock + TTL 기반 lease)
살아있는 동안 heartbeat로 lease 유지
장애 시 lease 만료 → 다른 서버가 takeover
하는 방식으로 구성할 수 있습니다.
이 구조는 Kafka의 partition + consumer group과 유사한 형태로 확장할 수 있는 장점이 있습니다.
다만 이 부분은 한 가지 같이 고려하면 좋을 것 같습니다.
이런 shard / lease / rebalance 구조는
Kafka나 Zookeeper가 제공하는 기능을 애플리케이션 레벨에서 직접 구현하는 것과 유사해질 수 있어,
시스템 복잡도와 운영 부담이 빠르게 증가할 수 있습니다.
그래서
현재 트래픽 규모에서는 단순한 구조 유지
샤드 리밸런싱이 필요할 정도로 커지는 시점에 Kafka 등으로 전환
과 같은 단계적 접근도 현실적인 선택지가 될 수 있을 것 같습니다.
전체적으로 문제를 정의하고, 실제 제약 조건 안에서 현실적인 설계를 만들어가신 점이 굉장히 인상 깊었습니다 👍
지금 구조도 충분히 잘 동작할 수 있는 설계라고 생각되고,
말씀드린 부분들은 향후 트래픽이나 운영 복잡도가 증가했을 때 참고하실 수 있는 확장 방향 정도로 봐주시면 좋을 것 같습니다.