다중 인스턴스 채팅 — 메시지 영속화 · fan-out · 전달 검증
실시간 채팅 서버
- 기간
- 2026.02 – 2026.05
- 역할
- 설계 · 구현 · 측정 전체
- 참여 인력
- 개인 프로젝트
Java 21Spring Boot 3.4.3WebSocket · STOMPApache Kafka 3.9.0 (KRaft)Redis 7 Pub/SubPostgreSQL 16JPATestcontainersk6 v1.5.0
GitHub에서 코드 보기 ↗서비스
1:1과 그룹 대화를 오가는 채팅 서비스입니다. 서버는 한 대가 아니라 여러 대로 뜹니다.
채팅에서 어려운 지점은 메시지를 빨리 띄우는 게 아니라, 화면에 보인 메시지가 실제로 저장됐다고 보장하는 것입니다.
그래서 DB 커밋이 끝난 뒤에만 브로드캐스트하고, 끊겼다 돌아온 사용자가 놓친 구간을 따라잡을 수 있게 했습니다.
- 로그인
- 방 목록
- 1:1 · 그룹 방
- 대화
- 전달 상태 확인


- 데모 UI
- React 19 · TypeScript · zustand · STOMP · Playwright e2e
- 직접 띄워보기
docker compose -f docker-compose.demo.yml up→ localhost:14173
e2e가 화면에서 재현하는 것
- 둘러보기로 창을 두 개 열면 서로 다른 사람이 되고, 한쪽에서 보낸 메시지가 다른 쪽 화면에 실제로 도착한다 — 데모 스택은 인스턴스가 2대라 그 사이에 다른 노드를 거친다
- 인스턴스를 지정해 붙는 e2e가 app-1이 받은 메시지를 app-2가 중복 없이 정확히 한 번 전달하는 것을 확인한다 — DB 저장 실패와 Redis 발행 실패를 일부러 주입한 뒤 복구까지, 재접속 경계를 포함해서
위 화면은 로컬 데모 실행 결과입니다. 운영 중인 서비스가 아닙니다.
요약
- 채팅방 목록이 방 개수만큼 쿼리를 날리던 것을 JPQL 프로젝션과 IN 배치로 모아 방 50개 기준 101회 → 3회 고정
- 커서 페이지네이션·멱등성·unread 등 핵심 쿼리를 EXPLAIN ANALYZE로 분석해 인덱스 5개 설계, 이미 커버되는 3개는 근거를 적고 추가하지 않음
- DB 커밋이 끝난 뒤에만 브로드캐스트하도록 순서를 강제 — 50명이 두 인스턴스에 나뉜 3회 반복에서 기대 4,900건 전부 도착, 누락·중복·순서 위반 0건
- 현재 커밋에서 200 VU 조회 부하를 3회 반복 재측정해 RPS 1,806–1,940 · p95 129–133ms · 39.8만 요청 중 HTTP 실패 0건 확인
01채팅방 목록 조회 · 쿼리와 인덱스
채팅방 목록이 방 개수만큼 쿼리를 날리던 것을 한 번에 모아 101회 → 3회
문제 원인
- 채팅방 목록 API가 Entity 그래프를 로드한 뒤 DTO로 변환하는 구조라 컬렉션 접근마다 Lazy Loading 발생
- 방 N개당 채팅방 N회 + 멤버 컬렉션 N회 + 최초 1회 = 2N+1 쿼리, 방 50개 기준 101회
- JOIN FETCH로 묶어도 DTO 변환 과정에서 컬렉션 접근이 남아 근본 해결 불가
해결 과정
- 목록에 필요한 6개 값(방 ID·이름·타입·멤버 수·읽지 않은 수·생성일)만 JPQL constructor expression으로 직접 조회해 Entity 로드를 없애고, 나중에 붙인 표시 이름·최근 메시지 미리보기도 roomIds IN 배치 쿼리 2개로 처리
- 커서 페이지네이션 · 멱등성 체크 · unread 계산 · 멤버 확인 4개 쿼리를 EXPLAIN ANALYZE로 실행계획까지 확인한 뒤 인덱스 5개 설계
- Redis Cache Aside(TTL 5분)에서 자주 일어나는 이벤트만 선택 무효화 — 메시지 수신은 해당 방 멤버의 키만, 읽음 처리는 해당 사용자 키만 제거하고 커밋 이후에 실행. 드물게 일어나는 방 생성·참여는 전체 무효화로 남김
결과
- 방 개수에 비례하던 2N+1 구조를 제거 — 방이 50개든 500개든 프로젝션 1회 + 배치 조회 2회로 총 3회 고정
- 최적화 직전 커밋을 꺼내 같은 부하 도구로 나란히 재보니 중앙값 9.9ms → 1.8ms, 처리량 41 → 65 반복/초
- 다만 200 VU에서는 양쪽 다 포화라 p95가 나아지지 않았다 — 병목을 없앤 만큼 더 받아들이고 큐가 길어진다
검증목록 조회 쿼리 수 (방 N개)2N+1회→3회 고정방 50개 기준 101회 → 3회 · 프로젝션 1 + IN 배치 2 · 코드로 확인되는 구조적 카운트측정응답 시간 중앙값 (10 VU · 포화 전)9.9ms→1.8ms최적화 직전 커밋(787781c)과 현재 커밋을 같은 k6 스크립트로 연달아 측정 · 인덱스·캐시 변경도 함께 포함된 값측정처리량 (10 VU · 포화 전)41 반복/초→65 반복/초200 VU에서는 양쪽 다 포화라 p95가 나아지지 않는다 — 근거 문서에 함께 적었다측정조회 부하 RPS / p95 (3회 반복)1,806–1,940 / 129–133ms2026-08-06 재측정 · 앱을 Docker로 띄운 별도 실행이라 위 전후 비교와 같은 조건이 아니다 · 개선율 아님측정HTTP 실패 (3회 합계 398,256 요청)0건checks 100% 통과 · threshold p(95)<500ms · 실패율<1% 모두 충족검증설계한 인덱스5개커버되는 단일 인덱스 3개는 근거를 적고 미추가
02실시간 채팅 · 메시지 생명주기
화면에 뜬 메시지가 DB에 없을 수 있던 것을 저장 뒤 전달로 바꿔 누락·중복 0건
문제 원인
- 브로드캐스트 컨슈머와 저장 컨슈머가 같은 Kafka 이벤트를 각자 처리해 완료 순서가 보장되지 않음
- 브로드캐스트가 먼저 끝나면 상대는 DB에 아직 없는 메시지를 보게 됨
- 직후 저장이 실패하면 새로고침·재접속에서 그 메시지가 사라짐 — 보였다가 없어지는 것은 처음부터 안 보이는 것보다 나쁨
해결 과정
- 두 컨슈머를 하나로 합쳐 DB 커밋이 끝난 뒤에만 브로드캐스트하고, 그때 DB가 발급한 message id를 함께 실어 재접속 보충 조회가 같은 메시지를 찾게 함
- ACCEPTED와 PERSISTED를 상태로 분리해 화면이 둘을 구분해 표시 — 화면에는 '전송됨'과 '전달 완료'로 나온다
- Redis 발행이 실패하면 Kafka ACK를 보류하고, 재전달에서 기존 DB 행으로 fan-out만 다시 시도
결과
- DB 저장 실패를 주입하면 그 시점에 전달되지 않고, 재시도로 저장된 뒤에 전달되는 것을 e2e로 확인
- Redis 발행 실패를 주입해도 저장은 1건인 채 전달만 지연되고 이후 정확히 한 번 도착
- 같은 clientMessageId로 두 번 보내도 저장 1건·전달 1건 — 인스턴스를 지정해 붙는 e2e가 고정
구현 기능
위 문제 해결 외에, 서비스가 돌아가기 위해 구현한 것들입니다.
- JWT 인증(가입·로그인)과 사용자 검색, 1:1·그룹 방 생성과 참여
- 커서 기반 메시지 조회와 읽음 처리, 재접속 시 마지막 수신 ID 기준으로 놓친 구간 보충 조회
- Redis 패턴 구독이 수신 채널명을 목적지로 쓰던 것을 payload 기준으로 고쳐 다른 방으로 새던 메시지를 막고 단위 테스트로 고정