개요
과거 채팅 시스템의 구조는 Java 내부 메시지 큐를 이용하여 DB에 배치 저장하도록 구현되어 있다.
이 경우 다음과 같은 문제점들이 발생할 수 있다는 것을 생각하지 못했다.
메시지 큐와 DB 사이의 데이터 정합성 문제
메시지 큐에 저장된 내용을 DB에 저장하는 동시에 새롭게 참가한 사용자에게 최근 메시지를 조회하도록 하였다. 그런데 이 경우, DB에 데이터를 저장하면서 최근 메시지를 보여주기 때문에 메시지 큐와 DB 사이에 데이터 정합성이 발생한다. 따라서, 이를 위해 최근 메시지와 저장할 메시지를 분리해야 한다.
쉽게 생각할 수 있는 방법이 캐시를 사용하는 방법이다. Write Through와 비슷한 방식으로 접근하는 것이다. 간단하게 말하면 캐시는 최근 메시지를 보여주는 용도이고, 메시지 큐는 DB 저장용으로 사용하는 것이다. 예를 들어, 메시지가 서버로 전송될 경우는 캐시와 메시지 큐에 각각 메시지를 저장한다. 만약 새로운 사용자가 들어오면 캐시에 있는 메시지를 보여준다. 또한, 메시지 큐가 일정 수준이상 차면 DB에 저장한다.
이 방식의 장점은 아래와 같다.
- DB와 캐시 사이의 데이터 정합성 문제를 신경쓸 필요가 없다.
- 캐시 처리 전략을 통해 최근 메시지 개수를 지정할 수 있다.
- 메시지 큐가 다운되더라도, 캐시를 통해 복구가 가능하다.
이 방식을 사용할 경우 생각해봐야할 점은 아래와 같다.
- 만약 캐시가 비어있는 경우 또는 특정 개수 이하로 있는 경우, 어떻게 할 것인가?
- 메시지가 오랫동안 오지 않을 경우, 메시지 큐 안에 내용을 어떻게 저장할 것인가?
- 캐시가 다운될 경우에는 어떻게 할 것인가?
분산 환경에서의 문제
현재 내부 메시지 큐를 사용할 경우, 단일 인스턴스 상에서만 사용된다. 따라서, scale-out 시 데이터 중복 문제가 발생할 수 있다. 따라서, 전체 서버에서 메시지를 받아 처리해줄 미들웨어가 필요하다.
굳이 배치 저장이 필요할까?
보통 다음과 같은 경우에 배치 처리를 고민한다.
- 초당 insert 수가 꽤 크고, 단일 row insert가 DB I/O를 많이 잡아먹는가?
- 트랜잭션 오버헤드, 인덱스 갱신 비용을 줄이고 싶을 때
- 강한 실시간 일관성이 필요 없을 때
그런데 채팅방 인원을 100명으로 제한하기 때문에 insert가 많을 것 같지도 않고, 채팅 메시지도 100자 이내로 제한하기 때문에 DB I/O도 심하지 않다고 판단했다. 일단 배치 처리를 구현할 경우, 고려해야하는 점이 많아지기 때문에 배제한 상황에서 해결을 해보고자 한다.
미들웨어 선택
다시 한 번 채팅의 목적을 정리하면 다음과 같다.
- 방 1개, 공개 채팅
- 방당 최대 100명
- 읽기:쓰기 1:1
- 순수 WebSocket + 세션 Set 방식
- scale-out 대비 필요
이 경우에 적합한 미들웨어로 Redis Pub/Sub을 선정했다.
Pub/Sub 모델과 결합도
Pub/Sub 모델을 사용할 경우, Publisher와 Subscriber가 서로를 전혀 모른 채 “채널”이라는 추상화로만 소통이 가능하다.
이게 왜 Scale-out에서 유리할까? 서버를 추가하거나 삭제해도 Publisher 코드를 바꾸지 않고 확장이 가능하다. 이로 인해 Scale-out되는 서버의 개수 상관없이 변경이 가능하여 결합도를 낮출 수 있다. 또한, 같은 채널을 구독한 모든 서버 인스턴스에 메시지가 전달되는 구조이기 때문에, 한 명에게만 보내야 하는 경우에는 Pub/Sub가 비효율적일 수 있다. 하지만, 공개 채팅인 현재 시스템에선 Pub/Sub 모델이 지닌 강점이라고 생각한다.
한 유저가 보낸 메시지를 같은 방의 다른 모든 유저에게 즉시 전달하는 목적에서 Redis Pub/Sub의 장점이 아주 유리하다. 지연 시간이 거의 없고, 초당 수천만 개의 메시지 전달 가능하다. 현재 코드 WebSocket에서 Pub/Sub 추가만 하면 scale-out 준비 완료된다. 복잡한 메시지 큐 로직, 컨슈머 그룹, 오프셋 관리 등 불필요하다. 마지막으로 방 1개, 100명 정도는 Pub/Sub만으로 충분한 처리량이다. Kafka 같은 무거운 인프라는 과한 선택이라고 판단했다.
데이터 유실과 정합성은?
Redis Pub/Sub는 메시지 저장 없다. 따라서, 구독자가 잠깐 끊긴 동안의 메시지는 유실 가능이 가능하다. 즉, at-most-once 전달이 보장할 수 있다. 이번 프로젝트에서의 채팅은 1:1 금융 거래 채팅이 아니라, 커뮤니티 성격이기에 at-most-once도 수용 가능하다고 판단했다. 또한, Redis의 메시지 미저장 문제을 MySQL로 보완하기 위해, DB와 캐시에 Write Through 전략을 도입했다. 메시지가 전송되면 Redis와 MySQL에 모두 메시지를 저장하는 방식이다.
캐시 삭제 전략
최대 100명의 사용자가 채팅을 입력할 경우, 한 사람당 2개의 메시지를 입력한다고 가정하고 캐시 메시지 크기를 200으로 고정하려고 한다. 또한, 사용자가 오랫동안 채팅을 하지 않을 경우 캐시에 채팅 메시지가 계속 남아있는 걸 방지하기 위해 TTL을 설정하여 자동으로 삭제되도록 구현했다.
상태 관리와 세션
WebSocket 연결 자체는 개별 서버가 상태를 가지고 있지만, 메시지 라우팅은 Redis를 통해 최대한 상태를 탈중앙화한다. 만약 서버가 죽으면 그 서버에 붙어있던 클라이언트들은 다른 서버로 재연결하는데, Pub/Sub 구조 덕에 어느 서버로 붙든 같은 방 메시지를 받기 때문에 훨씬 관리가 수월하다. 이는 Pub/Sub의 낮은 결합도 덕분에 가능한 일이다.
적용된 아키텍처
쓰기 시나리오

읽기 시나리오

장애 발생 시 대응 전략
현재 프로젝트 시스템은 1개의 RDB 서버, 1개의 Redis 서버로 구성된 단순한 구성이다. 비용 문제로 인해 분산 데이터베이스 대신 단일 서버일 경우에 장애 대응 전략을 구상해보자.
단일 Redis 서버만 다운된 경우
현재 Redis에서 Pub/Sub과 최근 메시지 List를 함께 제공하고 있다. 이 경우, Pub/Sub 중단되면 실시간 브로드캐스트가 멈추며 캐시 조회 실패로 인해 최근 메시지 조회 실패한다. 그러나 DB는 살아 있으니 저장은 된다.
즉, Redis가 다운되면 실시간 브로드캐스트는 포기하되, DB 저장은 계속해서 채팅 기록 유실을 막는 전략을 선택했다. 이후 Redis 복구 후에는 DB 기준으로 화면을 다시 구성할 수 있도록 설계했다.
단일 DB 서버만 다운된 경우
DB 쓰기/읽기 둘 다 장애 나면, 새 메시지 영구 저장 불가, 과거 기록 조회도 불가, Redis 캐시에 남아 있는 “최근 메시지”만 일시적으로 보여줄 수 있다.
따라서, 메시지 수신 시 DB에 insert를 실패하면 Redis 캐시/Publish도 하지 않고, 클라이언트에 전송 실패 응답한다. DB 읽기 실패 시에는 최근 N개는 Redis 캐시에서만 보여주고, 더 과거는 “지금은 조회 불가” 안내한다. 현재는 단일 DB가 SPOF의 원인이 되고 있다. 추후 규모가 커지면, 읽기 부하 분산용 read replica 및 정기 백업/복구 절차 준비를 계획을 예정하고 있다.
정리
- Case A: DB는 성공, Redis 전송은 실패
- 결과
- 기록은 DB에 남음.
- 실시간으로는 안 보였고, 캐시에도 없을 수 있음.
- 나중에 클라이언트가 최근 메시지 재요청 하면 DB에서 읽어와 다시 보여줄 수는 있음.
- 시스템 전체 관점에선 전송이 안 됐지만 기록은 있는 메시지가 생김.
- 이론적으로 완벽하진 않지만, 채팅 UX 관점에서 수용 가능한 불일치로 판단
- 결과
- Case B: DB에서 예외 → 트랜잭션 롤백
- DB 실패 시 나머지는 전부 하지 않는다라고 설정
- DB insert 예외 → 트랜잭션 롤백
- 캐시/ Pub/Sub도 실행하지 않음
- 클라이언트에 전송 실패 응답
- 이 경우엔 기록과 전송 둘 다 실패이므로, 일관성은 유지돼요.
완벽한 설계인가?
메시지 전송이라는 전체 작업을 하나의 트랜잭션으로 보면, 저장은 됐는데 브로드캐스트는 안 된 메시지가 생길 수 있다. 그래서 엄밀히 말하면 트랜잭션 경계는 없다. 다만, 채팅 서비스은 돈이나 포인트, 재고 같은 강한 트랜잭션 도메인이 아니라 유저가 보낸 메시지가 나중에라도 저장돼 있으면 문제가 없다고 생각한다.
따라서, 우리는 우선순위를 다음과 같이 메겼다.
- 기록(DB)은 최대한 잃지 않는다.
- 실시간 전송, 캐시, Pub/Sub은 장애 시 포기할 수 있다.
아키텍처 개선 결과
부하테스트
시나리오
- 동시 접속자: 100 VU (가상 유저), 모두 같은 방
/ws/chat/{roomId}에 WebSocket 연결 유지. - 테스트 시간: 약 5분.
- 메시지 전송 패턴: 각 유저가 3초마다 1건씩
type=TALK메시지 전송 → 방 기준 약 30~35 TPS 수준의 지속적인 채팅 트래픽. - 서버 동작: 메시지 수신 시마다 DB에 INSERT, Redis Pub/Sub 로 브로드캐스트, Redis 캐시에 최근 메시지 저장.
K6 측정 결과
- WebSocket 세션
- 세션 수: 100 (각 VU당 1개 세션, 테스트 내내 유지).
- 연결 시간:
ws_connecting평균 약 320 ms 수준으로, 초기에 핸드셰이크는 0.3초 안팎에 안정적으로 완료.
- 메시지 전송·수신
- 전송 메시지 수: 총 1만 건 이상 (
ws_msgs_sent ≈ 10,782정도). - 전송 레이트: 초당 약 32~33 메시지.
message_send_latency_ms평균이 수십 µs~수 ms 수준으로, 클라이언트 측에서send()호출 자체는 거의 지연 없이 처리.
- 전송 메시지 수: 총 1만 건 이상 (
- 네트워크
- 전송량: 클라이언트→서버 약 1.4MB, 서버→클라이언트 약 40KB 정도.
- WebSocket 텍스트 프레임이 꾸준히 오가고 있음을 확인할 수 있는 수준.
→ 결론: 100명 동시 접속·30TPS 정도의 채팅 부하는, 클라이언트 관점에서 특별한 지연이나 에러 없이 소화되었습니다.
MySQL

테스트 결과 총 10,782개의 메시지가 전송된 것으로 계산되었는데, MySQL에도 정확하게 10,782개가 저장된 것을 통해 데이터베이스 쓰기에선 크게 문제가 발생하지 않았다.
| 지표 | 값 | 해석 |
| ws_connecting (평균) | 321.38 ms | 각 VU가 처음 WebSocket 연결을 맺는 데 평균 0.32초 정도 소요. 초기 핸드셰이크/네트워크 지연 포함. |
| ws_connecting p(90) / p(95) |
558.92 ms / 615.87 ms | 상위 10~5% 구간에선 연결 수립이 0.56~0.62초 정도로 더 느려짐. |
| ws_sessions | 100 세션 | 100명의 유저가 각각 1개 세션을 유지 (테스트 동안 세션 재생성 거의 없음). |
| ws_msgs_sent | 10,782 건 (약 32.7 msg/s) | 전체 테스트 동안 약 1만 건 이상 메시지 전송, 방 기준 TPS는 초당 약 33건 수준으로 부하가 잘 걸린 상태. |
성과
다중 서버, 중앙 Redis, 단일 방 공개 채팅 구조를 설계하고 구현하며, Redis Pub/Sub을 활용해 서버 간 세션 분산 문제를 해결했다. 메시지 저장 순서(DB → 캐시 → Pub/Sub)를 명확히 정의하여, DB 기록을 우선하는 트랜잭션 전략을 수립했다.
또한, 실제 WebSocket 부하 테스트를 통해 100명 동시 접속, 30TPS 수준의 트래픽에서 시스템이 안정적으로 동작함을 수치로 검증했다. Redis와 DB의 리소스를 모니터링해, 현재 구성이 이 수준에선 병목이 아니라는 것을 확인했다.
개선 방향
VU 수 및 메시지 전송 빈도를 단계적으로 올려, 방당/서버당 최대 처리 가능한 TPS 및 지연 한계를 측정 예정이다. 현재는 DB와 Pub/Sub 간 “최종 일관성” 수준이므로, 향후 Transactional Outbox 패턴 등 도입을 통해 저장 + 발행의 재시도/추적 가능성을 높이는 방안을 고민 중이다.
더 규모가 커질 경우, 데이터베이스 다중화를 고려해야할 것이다. Redis Sentinel/Cluster, DB read replica 및 백업/복구 전략을 설계하여, 단일 인스턴스 장애에 대한 복원력 강화 예정이다.
'Spring' 카테고리의 다른 글
| [Spring Boot] Redis Pub/Sub 기반 쿠폰 발급 시스템 구축 (0) | 2025.11.30 |
|---|---|
| [Spring Boot] AI 서빙에서 WebClient와 RestClient 비교 (0) | 2025.11.30 |
| [Spring Boot] N+1 문제 해결 기술 비교 (FetchJoin, @EntityGraph, batch-size) (0) | 2025.11.30 |
| [Spring Boot] Soft Delete로 개인정보 보호하기 (0) | 2025.11.30 |
| [Spring Boot] 공개 채팅 구축 및 동시성 문제 해결 (0) | 2025.11.27 |