성진혁
← Projects

다중 인스턴스 채팅 — 메시지 영속화 · 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. 로그인
  2. 방 목록
  3. 1:1 · 그룹 방
  4. 대화
  5. 전달 상태 확인
일곱 참여자가 있는 그룹 대화 화면. 같은 사람이 이어 보낸 메시지는 이름과 프로필을 한 번만 쓰고, 맨 아래는 이름 옆에 BOT 배지가 붙은 안내봇의 마감 공지다. 오른쪽 위에 초대 링크 복사 단추가 있고, 내가 보낸 메시지 옆에 전달 완료 표시가 붙어 있다
그룹 대화 — 전달 완료는 DB 커밋이 끝난 뒤에 붙는다. 화면에 떴다는 뜻이 아니다. 안내봇은 BOT으로 표시되고 부를 때만 답하며, 사람과 같은 경로로 메시지를 보낸다
1:1 대화 화면. 왼쪽 목록에서 다른 대화로 옮겨 온 상태이고 각 대화의 마지막 메시지와 날짜가 함께 보인다
대화 목록 — 1:1과 그룹이 섞여 있고 마지막 메시지와 날짜로 정렬된다. 데모 스택은 인스턴스가 2대라 메시지는 다른 노드를 거쳐 도달한다
데모 UI
React 19 · TypeScript · zustand · STOMP · Playwright e2e
직접 띄워보기
docker compose -f docker-compose.demo.yml uplocalhost:14173

e2e가 화면에서 재현하는 것

  • 둘러보기로 창을 두 개 열면 서로 다른 사람이 되고, 한쪽에서 보낸 메시지가 다른 쪽 화면에 실제로 도착한다 — 데모 스택은 인스턴스가 2대라 그 사이에 다른 노드를 거친다
  • 인스턴스를 지정해 붙는 e2e가 app-1이 받은 메시지를 app-2가 중복 없이 정확히 한 번 전달하는 것을 확인한다 — DB 저장 실패와 Redis 발행 실패를 일부러 주입한 뒤 복구까지, 재접속 경계를 포함해서

위 화면은 로컬 데모 실행 결과입니다. 운영 중인 서비스가 아닙니다.

요약

01채팅방 목록 조회 · 쿼리와 인덱스

채팅방 목록이 방 개수만큼 쿼리를 날리던 것을 한 번에 모아 101회 → 3회

변경 전 Entity 그래프 로드로 방마다 추가 쿼리가 발생하는 경로와, 변경 후 JPQL 프로젝션 1회에 IN 배치 조회 2회를 더해 3회로 고정되는 경로를 비교한 도식
변경 전 / 후 — 방 개수에 비례하던 쿼리를 3회로 고정

문제 원인

  1. 채팅방 목록 API가 Entity 그래프를 로드한 뒤 DTO로 변환하는 구조라 컬렉션 접근마다 Lazy Loading 발생
  2. 방 N개당 채팅방 N회 + 멤버 컬렉션 N회 + 최초 1회 = 2N+1 쿼리, 방 50개 기준 101회
  3. JOIN FETCH로 묶어도 DTO 변환 과정에서 컬렉션 접근이 남아 근본 해결 불가

해결 과정

  1. 목록에 필요한 6개 값(방 ID·이름·타입·멤버 수·읽지 않은 수·생성일)만 JPQL constructor expression으로 직접 조회해 Entity 로드를 없애고, 나중에 붙인 표시 이름·최근 메시지 미리보기도 roomIds IN 배치 쿼리 2개로 처리
  2. 커서 페이지네이션 · 멱등성 체크 · unread 계산 · 멤버 확인 4개 쿼리를 EXPLAIN ANALYZE로 실행계획까지 확인한 뒤 인덱스 5개 설계
  3. Redis Cache Aside(TTL 5분)에서 자주 일어나는 이벤트만 선택 무효화 — 메시지 수신은 해당 방 멤버의 키만, 읽음 처리는 해당 사용자 키만 제거하고 커밋 이후에 실행. 드물게 일어나는 방 생성·참여는 전체 무효화로 남김

결과

  1. 방 개수에 비례하던 2N+1 구조를 제거 — 방이 50개든 500개든 프로젝션 1회 + 배치 조회 2회로 총 3회 고정
  2. 최적화 직전 커밋을 꺼내 같은 부하 도구로 나란히 재보니 중앙값 9.9ms → 1.8ms, 처리량 41 → 65 반복/초
  3. 다만 200 VU에서는 양쪽 다 포화라 p95가 나아지지 않았다 — 병목을 없앤 만큼 더 받아들이고 큐가 길어진다
02실시간 채팅 · 메시지 생명주기

화면에 뜬 메시지가 DB에 없을 수 있던 것을 저장 뒤 전달로 바꿔 누락·중복 0건

이전에는 브로드캐스트 컨슈머와 저장 컨슈머가 같은 Kafka 이벤트를 따로 처리해 저장보다 브로드캐스트가 먼저 끝날 수 있었고, 지금은 한 컨슈머가 DB 커밋을 마친 뒤 그 DB ID로 브로드캐스트하는 구조를 비교한 도식
메시지 생명주기 — 따로 처리하기 vs 저장을 끝내고 내보내기

문제 원인

  1. 브로드캐스트 컨슈머와 저장 컨슈머가 같은 Kafka 이벤트를 각자 처리해 완료 순서가 보장되지 않음
  2. 브로드캐스트가 먼저 끝나면 상대는 DB에 아직 없는 메시지를 보게 됨
  3. 직후 저장이 실패하면 새로고침·재접속에서 그 메시지가 사라짐 — 보였다가 없어지는 것은 처음부터 안 보이는 것보다 나쁨

해결 과정

  1. 두 컨슈머를 하나로 합쳐 DB 커밋이 끝난 뒤에만 브로드캐스트하고, 그때 DB가 발급한 message id를 함께 실어 재접속 보충 조회가 같은 메시지를 찾게 함
  2. ACCEPTED와 PERSISTED를 상태로 분리해 화면이 둘을 구분해 표시 — 화면에는 '전송됨'과 '전달 완료'로 나온다
  3. Redis 발행이 실패하면 Kafka ACK를 보류하고, 재전달에서 기존 DB 행으로 fan-out만 다시 시도

결과

  1. DB 저장 실패를 주입하면 그 시점에 전달되지 않고, 재시도로 저장된 뒤에 전달되는 것을 e2e로 확인
  2. Redis 발행 실패를 주입해도 저장은 1건인 채 전달만 지연되고 이후 정확히 한 번 도착
  3. 같은 clientMessageId로 두 번 보내도 저장 1건·전달 1건 — 인스턴스를 지정해 붙는 e2e가 고정

구현 기능

위 문제 해결 외에, 서비스가 돌아가기 위해 구현한 것들입니다.

← Projects