고객-상담사 실시간 채팅 솔루션을 운영하면서, 가장 찾기 어려웠던 버그 중 하나가 "부분 전달" 문제였다. 메시지가 완전히 유실되는 게 아니라, 한쪽에만 도착하고 다른 쪽에는 안 가는 현상. 이 글에서는 이 문제를 어떻게 발견하고, 어떤 판단을 거쳐 해결했는지 정리해본다.
시스템 개요
먼저 간단하게 시스템 구조를 설명하면, 이 채팅 솔루션은 고객이 웹 브라우저에서 상담을 요청하면 상담사와 실시간으로 대화하는 구조다.
Spring Boot 2.5 / Java 8 기반 멀티모듈 프로젝트
STOMP over WebSocket — 클라이언트 실시간 통신
RabbitMQ — STOMP Broker Relay로 메시지 라우팅 (AMQP 5672 + STOMP Plugin 61613)
Elasticsearch — 상담 세션(Counsel) + 메시지 저장
MariaDB — 마스터 데이터 (고객, 상담사, 도메인)
Redis Cluster — 상담 상태 캐시, 분산 락, 세션 관리
운영 환경은 API 서버 3대가 로드밸런서 뒤에서 동작하고, Redis는 9노드 클러스터(3 master × 3 replica), RabbitMQ는 HA 미러링 구성으로 돌아간다.
메시지 흐름

고객이 메시지를 보내면 서버는 broadcastMessage()를 호출해서 고객 본인과 상담사 양쪽에 동시에 라우팅한다. 고객 본인에게도 보내는 이유는, 같은 고객이 탭을 여러 개 열어놓았을 때 모든 탭에 메시지가 표시되어야 하고, 서버에 메시지가 실제로 도착했다는 확인 피드백을 주기 위해서다.
기존 재시도 로직 — 이미 꽤 견고했다
이 시스템에는 메시지 전달 실패에 대한 재시도 로직이 이미 구현되어 있었다. 꽤 정교하게.
1단계: RabbitMQ mandatory 플래그 + returnedMessage 콜백
mandatoryRabbitTemplate으로 publish하면, 바인딩된 큐가 없는 경우 RabbitMQ가 메시지를 돌려보낸다(returnedMessage 콜백). 이때 서버는 500ms 간격으로 최대 5회 재시도한다.
// RabbitMqService.java
public static final int REDELIVERY_DELAY_MILLIS = 500; // 재시도 간격
public static final int MAX_REDELIVERY_COUNT = 5; // 최대 재시도 횟수
@Override
public void returnedMessage(ReturnedMessage returned) {
if (isReturnedMessageWillRedelivery(returned, HEADER_REDELIVERY_COUNT, MAX_REDELIVERY_COUNT)) {
Thread.sleep(REDELIVERY_DELAY_MILLIS);
this.mandatoryRabbitTemplate.send(returned.getExchange(), returned.getRoutingKey(), returned.getMessage());
} else {
// 5회 실패 → Redis에 캐싱
cacheReturnedMessage(returned);
}
}
2단계: Redis 캐시 + 스케줄러 재전송
5회 재시도가 모두 실패하면, 해당 메시지를 Redis RMapCache에 저장한다. TTL은 10분.
// RedisService.java
public static final Long CACHE_TTL = 10L;
public static final TimeUnit CACHE_TTL_TIME_UNIT = TimeUnit.MINUTES;
그리고 MessageCacheCheckScheduler가 20초 주기로 Redis에 캐싱된 실패 메시지를 꺼내서 다시 publish를 시도한다. 이 스케줄러도 최대 5회까지 재시도한다.
// MessageCacheCheckScheduler.java — 20초 주기
public void process() {
rabbitMqService.retryAllCachedMessage();
}
// RabbitMqService.java
public static final int MAX_REDELIVERY_REDIS_COUNT = 5; // 스케줄러 재시도 최대 횟수
정리하면 이렇다.
| 단계 | 트리거 | 간격 | 횟수 | 실패 시 |
| 1단계 | returnedMessage 콜백 | 500ms | 5회 | Redis 캐시 저장 |
| 2단계 | MesaageCacheCheckScheduler | 20초 | 5회 | 로그 남기고 포기 |
총 재시도 가능 횟수: 10회, 총 재시도 가능 시간: 약 100초
꽤 견고해 보인다. 그런데 왜 문제가 생겼을까?
문제 발견 — "고객이 보냈다는데, 상담사 화면에 안 뜹니다"
운영 중에 고객사에서 반복적으로 문의가 올라왔다.
"고객이 '안녕하세요'라고 보냈다고 하는데, 상담사 화면에는 아무것도 안 뜹니다."
로그를 추적해보니, 고객 본인에게는 메시지가 정상적으로 표시되고 있었다. Elasticsearch에도 메시지는 저장되어 있었고. 즉, 메시지 자체는 서버에 도착해서 처리됐지만, 상담사에게만 전달이 실패한 상태였다.
더 심각했던 건, 양쪽 다 자기 화면만 보고 있으니까 싱크가 깨진 걸 아무도 모른다는 거였다. 고객은 "보냈으니 답 기다리는 중", 상담사는 "고객이 아직 말 안 한 상태" — 이 인식 불일치가 평균 3~5분 지속된 뒤에야 고객이 재문의를 하거나, CS팀이 개입하는 패턴이 반복됐다.

구조적 원인 — 두 경로가 독립적이었다
broadcastMessage()의 실제 코드를 보면 원인이 명확하다.
// ClientInteractionService.java:360
private void broadcastMessage(Counsel counsel, Message message, boolean isMandatoryForCustomer) {
JsonObject messageCommunicationBody = createCommunicationBody(counsel.getMasterInfo(), message);
// 경로 A: 고객에게 전송
if (message.getType().isForCustomer()) {
clientMessageSenderService.sendToCustomer(counsel, message, messageCommunicationBody, isMandatoryForCustomer);
}
// 경로 B: 상담사에게 전송
if (counsel.isAssigned()) {
clientMessageSenderService.sendToAgent(counsel, messageCommunicationBody);
}
}
sendToCustomer와 sendToAgent는 각각 독립적으로 RabbitMQ에 publish한다. 각각이 독립적인 Exchange, 독립적인 routing key, 독립적인 큐를 사용한다.
sendToCustomer → CLIENT_EXCHANGE → routing key: {customerId}.{domainId}.{ucid}
sendToAgent → AGENT_EXCHANGE → routing key: {agentId}.{ugId}.{domainId}.{ucid}
문제의 핵심: 경로 A는 성공하고 경로 B만 실패하면, 고객은 "정상 전송"으로 인식하지만 상담사는 메시지를 받지 못한다.

왜 한쪽만 실패할 수 있을까? 여러 가지 시나리오가 있다.
- 상담사가 잠시 연결이 끊겼을 때 — SockJS heartbeat(25초)로 끊김을 감지하기 전까지, exclusive 큐는 아직 살아있지만 컨슈머가 없는 상태가 될 수 있다. 이 틈에 메시지가 라우팅되면 큐에 들어가지 못하고 반환된다.
- 상담사 측 STOMP 세션 전환 중 — 상담사가 브라우저를 새로고침하면 기존 큐가 삭제되고 새 큐가 바인딩되기까지 수 초의 공백이 생긴다.
- RabbitMQ 내부 라우팅 지연 — HA 미러링 환경에서 AGENT_EXCHANGE의 큐 바인딩 정보가 미러 노드에 아직 동기화되지 않았을 때.
기존 재시도 로직은 이 각각의 경로에서 독립적으로 동작한다. 경로 A의 재시도는 경로 B의 성공/실패를 전혀 모르고, 경로 B의 재시도도 경로 A를 모른다. "각 경로의 전달 성공"은 보장하려 했지만, "양쪽 모두의 전달 성공"은 보장하지 않는 구조였던 거다.
더 치명적인 건, isMandatory 플래그였다. 코드를 보면
// 인입 시 첫 번째 메시지만 mandatory
boolean isFirstMessage = counsel.getLastMessage() == null;
broadcastMessage(counsel, botMessage, isFirstMessage);
첫 메시지(isFirstMessage=true)만 mandatory로 전송되고, 이후 메시지는 전부 isMandatory=false다. mandatory가 false면 returnedMessage 콜백 자체가 호출되지 않으므로, 재시도 자체가 트리거되지 않는다. 두 번째 메시지부터는 사실상 "보내고 잊기(fire and forget)" 방식인 셈이다.
이 문제를 세 개의 하위 문제로 쪼갰다
- 원자성 문제 — 두 라우팅이 "둘 다 성공 or 둘 다 실패"로 묶이지 않는 것
- 감지 문제 — 부분 전달이 발생했을 때 아무도 모른다는 것
- 복구 문제 — 감지한 후 누락된 메시지를 어떻게 채울 것인가
기술적 제약
- 기존 재시도 인프라를 유지해야 했다 — 이미 여러 고객사에 납품된 솔루션이라, 기존 재시도/Redis/스케줄러 파이프라인을 전면 교체하는 건 리스크가 너무 컸다.
- 실시간성 유지 — 채팅이니까 동기화 복구도 사용자가 체감하지 못할 정도로 빨라야 했다.
데이터 계층 설계 — "어디에 메시지를 저장할 것인가"부터 다시 생각했다.
해결을 시작하기 전에, 데이터 저장 전략부터 정리해야 했다. 처음에는 당연히 MySQL에 메시지를 저장하는 걸 생각했다. 하지만 두 가지 이유로 기각했다.
MySQL에 채팅 메시지를 저장하지 않은 이유
첫째, Sync가 필요한 범위는 "현재 활성 세션"뿐이다.
상담이 종료된 메시지에 대해서는 부분 전달을 복구할 필요가 없다. 종료된 상담의 메시지는 이미 Elasticsearch에 이력으로 저장되어 있고, 실시간 동기화와는 무관하다. 즉, 활성 세션 기간(보통 수십 분~2시간)만 보존하면 된다는 건데, 이걸 위해 영구 저장소에 모든 메시지를 누적하는 건 불필요하다.
둘째, 조회 패턴이 seq > N으로 단순하다.
"이 세션에서 sequence 4번 이후의 메시지를 전부 달라" — 이게 유일한 Sync 쿼리다. 이 패턴에는 MySQL WHERE seq > N ORDER BY seq보다 Redis Sorted Set의 ZRANGEBYSCORE가 압도적으로 적합하다.
운영 환경 기준 수치로 비교해봤다
실제 운영 환경에서 측정한 수치를 기반으로 추정하면
| 피크 시간대 동시 활성 세션 | 300 |
| 세션당 평균 메시지 수 | 35건 |
| 일일 총 메세지 수 | 15,000건 |
| 상담 평균 지속 시간 | 18분 |
| 최대 상담 지속 시간 | 2시간 (자동 종료 기준) |
만약 MySQL에 저장했다면
Outbox 패턴을 적용하면 메시지당 최소 2행(고객 수신자 + 상담사 수신자)이 필요하다. 일일 30,000행, 월 90만행. PUBLISHED 상태 행을 배치 삭제한다 해도, 삭제 주기에 따라 수십만 행이 상시 존재하게 되고, INSERT/DELETE 빈도가 높은 테이블에 대한 인덱스 재구성 부하도 무시할 수 없다.
Redis Sorted Set이면
ZADD chat:session:{ucid} {seq} {직렬화된 메시지}
EXPIRE chat:session:{ucid} 7200 ← 2시간 TTL
- 피크 시 Redis 메모리: 300세션 × 35메시지 × 0.5KB = 약 5.25MB. Redis Cluster 입장에서 사실상 무시할 수 있는 크기다.
- ZADD: O(log N) — 세션당 35건 기준 사실상 O(1)
- ZRANGEBYSCORE chat:session:{ucid} 4 +inf: O(log N + M) — 누락된 M건만큼만 반환
- 조회 지연: 0.1~0.2ms (로컬 Redis Cluster 기준)
- 세션 종료 시 TTL 만료로 자동 정리, 별도 삭제 로직 불필요
| 저장소 | 역할 | 보존기간 |
| Redis Sorted Set | 활성 세션 메세지(score=seq) | 세션 TTL (2H) |
| MySQL Outbox | 발행 상태 추적만(메세지 본문 없음) | PULISHED 후 배치 삭제 |
| Elasticsearch | 세션 종료 시 대화 이력 저장 | 장기 |
MySQL Outbox에 메시지 본문을 저장하지 않는다는 게 핵심이다. Outbox에는 (ucid, seq, recipient, status) 4개 컬럼만 있다. 메시지 본문은 Redis에만 존재하고, 발행 성공/실패 상태만 MySQL이 추적한다. 이렇게 하면 Outbox 테이블의 row당 크기가 극히 작아서(~50bytes) row 폭증 문제가 구조적으로 발생하지 않는다.
CREATE TABLE message_outbox (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
ucid VARCHAR(64) NOT NULL,
seq INT NOT NULL,
recipient ENUM('CUSTOMER', 'AGENT') NOT NULL,
status ENUM('PENDING', 'PUBLISHED', 'FAILED') NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_outbox_pending (status, created_at),
INDEX idx_outbox_ucid_seq (ucid, seq)
);
피크 시 Outbox 상주 행 수 추정
피크 활성 세션 300 × 세션당 평균 메시지 35 × 수신자 2 = 21,000행
PUBLISHED 배치 삭제 주기 5분 기준, 상시 존재하는 행: ~3,000행
→ 테이블 크기: 3,000 × 50bytes = ~150KB
MySQL 입장에서 거의 부하가 없는 수준이다.
1계층: Transactional Outbox — 발행의 원자성 확보
왜 Outbox인가
두 라우팅을 "하나의 단위"로 묶어야 한다. 하지만 RabbitMQ에 두 번 publish하는 걸 하나의 DB 트랜잭션으로 묶을 수는 없다. 분산 트랜잭션(2PC)은 이 규모의 시스템에서 오버스펙이기도 하고, RabbitMQ는 XA 트랜잭션을 지원하지 않는다.
대신 "무엇을 보내야 하는지"를 먼저 원자적으로 기록하고, 실제 발행은 이 기록을 기반으로 수행하는 구조로 바꿨다.
변경 전 흐름
메시지 수신
→ ES 저장 (메시지 원본)
→ RabbitMQ publish(고객) — 독립 재시도
→ RabbitMQ publish(상담사) — 독립 재시도
변경 후 흐름

메시지 수신
→ @Transactional 시작
Redis ZADD chat:session:{ucid} {seq} {message} ← 메시지 본문 저장
MySQL INSERT outbox (ucid, seq, CUSTOMER, PENDING)
MySQL INSERT outbox (ucid, seq, AGENT, PENDING)
ES 저장 (기존과 동일)
→ @Transactional 커밋
→ Outbox Publisher: PENDING 조회 → RabbitMQ publish
→ Confirm ACK 수신 → status = PUBLISHED
→ 하나라도 FAILED → 해당 seq의 전체 전달 상태를 PARTIAL로 마킹
@Transactional
public void processMessage(Counsel counsel, Message message) {
// 1. Sequence 채번 (Redis INCR — 원자적)
long seq = redisTemplate.opsForValue().increment("msg:seq:" + counsel.getUcid());
message.setSeq(seq);
// 2. 활성 세션 메시지 저장
redisTemplate.opsForZSet().add(
"chat:session:" + counsel.getUcid(),
serialize(message),
seq
);
redisTemplate.expire("chat:session:" + counsel.getUcid(), 2, TimeUnit.HOURS);
// 3. Outbox 상태 행 저장 (본문 없이 상태만)
outboxRepository.save(new MessageOutbox(counsel.getUcid(), seq, Recipient.CUSTOMER, Status.PENDING));
outboxRepository.save(new MessageOutbox(counsel.getUcid(), seq, Recipient.AGENT, Status.PENDING));
// 4. ES 저장 (기존 로직 유지)
elasticsearchService.saveMessage(message);
}
Redis-MySQL 원자성에 대한 고민
엄밀히 말하면 Redis ZADD와 MySQL INSERT는 분산 트랜잭션이 아니다. 실패 시나리오를 따져봤다.
| 시나리오 | 결과 | 위험도 |
| Redis ZADD 성공 → MySQL INSERT 성공 | 정상 | - |
| Redis ZADD 성공 → MySQL INSERT 실패 | Redis에 메시지 있지만 Outbox에 PENDING 없음 → 발행 안 됨 | 메시지 유실 아닌 "발행 지연" |
| Redis ZADD 실패 | 예외 발생 → MySQL 트랜잭션 롤백 | 양쪽 다 안 됨 → 클라이언트에 에러 응답 |
두 번째 시나리오가 최악의 케이스인데, Redis에 메시지가 존재하지만 Outbox에는 PENDING이 없어서 발행이 안 되는 상태다. 하지만 이건 "유실"이 아니라 "발행 지연"이다. 메시지 원본은 Redis와 ES에 모두 보존되어 있으므로, Sequence Gap Detection(2계층)에서 감지하고 복구할 수 있다. 안전한 방향의 실패라고 판단했다.
2계층: Sequence 기반 Gap Detection — 부분 전달을 감지하는 눈
Outbox로 원자성을 확보해도, Outbox Publisher의 재시도마저 최종 실패할 수 있다. 이때 부분 전달이 발생했다는 사실을 클라이언트가 감지할 수 있어야 한다.
Sequence 번호 설계
각 상담 세션(ucid) 내에서 메시지에 단조 증가하는 sequence 번호를 부여했다.
// Redis INCR — 원자적, ucid 단위 격리
long seq = redisTemplate.opsForValue().increment("msg:seq:" + ucid);

- ucid별로 독립적인 sequence 공간
- Redis INCR는 원자적이라 동시성 이슈 없음
- 서버가 3대여도 같은 Redis Cluster를 바라보므로 순서 보장
클라이언트에 전달되는 모든 메시지에 seq 필드가 포함된다. 클라이언트는 수신한 sequence를 추적하고 있다가, [1, 2, 3, 5]처럼 gap이 발생하면 "4번이 누락됐다"는 걸 즉시 알 수 있다.
감지 방식 — 별도 채널을 만들지 않았다
gap 감지를 위해 SSE나 별도 Polling API를 추가하는 것도 고려했는데, 기각했다.
| 방식 | 장점 | 단점 |
| Polling API | 구현 단순 | 주기적 HTTP 요청 → 감지 지연, 300 동시 세션 × 폴링 빈도 = 부하 |
| SSE (Server-Sent Events) | 서버 푸시, 즉시 전달 | 추가 연결 관리, WebSocket과 역할 중복 |
| WebSocket 채널 활용 | 추가 연결 비용 0 | STOMP 프로토콜 내에서 처리해야 함 |
이미 STOMP over WebSocket 연결이 상시 유지 중이다. 여기에 sync 프레임을 태우면 추가 연결 비용이 0이다. 선택은 당연히 세 번째였다.
Heartbeat 기반 주기적 검증
서버가 5초마다 각 세션의 latest_sequence를 STOMP MESSAGE로 브로드캐스트한다.
destination: /topic/{exchange}/{routingKey}
header: x-sync-type: HEARTBEAT
header: x-latest-seq: 42
클라이언트는 자신의 local_sequence와 비교한다.
- local_seq == server_seq → 정상
- local_seq < server_seq → gap 존재 → 자동으로 sync 요청 발동
왜 5초인가
이건 트레이드오프다.
감지 지연 = heartbeat 주기 (최대 5초)
부하 = 300 세션 × (1 프레임 / 5초) = 60 msg/sec
RabbitMQ가 처리하는 전체 메시지 볼륨(상담 메시지 + 시스템 메시지 등)에 비하면 60 msg/sec는 미미하다. 그리고 5초 지연은 채팅에서 사용자가 거의 체감하지 못하는 수준이다. 상담사가 타이핑하고 있을 때 5초 안에 자동 복구되면, 고객 입장에서는 "상담사가 좀 늦게 봤나 보다" 정도의 인식이다.
이전처럼 고객이 3~5분간 기다리다 재문의하는 것과는 비교할 수 없다.
3계층: Sync Request/Response — 누락 메시지 복구

gap이 감지되면 클라이언트가 sync 요청을 보낸다.
STOMP SEND
destination: /app/chat/{ucid}/sync
header: x-last-sequence: 3
서버는 Redis에서 누락된 메시지를 조회해서 재전달한다.
@MessageMapping("/chat/{ucid}/sync")
public void handleSyncRequest(@DestinationVariable String ucid,
@Header("x-last-sequence") long lastSeq,
SimpMessageHeaderAccessor headerAccessor) {
// Redis Sorted Set에서 누락 메시지 조회
Set<String> missed = redisTemplate.opsForZSet()
.rangeByScore("chat:session:" + ucid, lastSeq + 1, Double.MAX_VALUE);
if (missed == null || missed.isEmpty()) {
// Redis TTL 만료 시 ES fallback
missed = elasticsearchService.findMessagesAfterSeq(ucid, lastSeq);
}
// 해당 클라이언트의 구독 채널로 재전달
String destination = resolveDestination(ucid, headerAccessor);
for (String msg : missed) {
messagingTemplate.convertAndSend(destination, msg);
}
}
Sync 조회 성능
Redis ZRANGEBYSCORE chat:session:{ucid} 4 +inf
- 시간 복잡도: O(log 35 + M) ≈ O(M), M = 누락 메시지 수
- 실측 지연: 0.1~0.2ms
- 네트워크 포함 전체 RTT: ~1ms (Redis Cluster 동일 VPC 내)
세션당 평균 35건이면 Sorted Set의 skiplist depth가 5~6 수준이라, 사실상 상수 시간에 가깝다.
ES Fallback
Redis Sorted Set의 TTL이 2시간이다. 상담 자동 종료 타이머(counselAutoClose)는 운영 환경에서 60초로 설정되어 있는데, 이건 "마지막 메시지 이후 60초간 무응답이면 자동 종료"라는 뜻이다. 실제 상담은 평균 18분, 최대 2시간 내에 종료되므로 TTL 2시간이면 거의 모든 케이스를 커버한다.
그래도 혹시 Redis 데이터가 만료된 뒤에 sync 요청이 들어올 수 있으므로, Elasticsearch를 fallback으로 사용한다. ES에는 메시지가 영구 저장되어 있으니까.
// ES fallback 조회
List<Message> messages = esClient.search(
SearchRequest.of(s -> s
.index("messages-*")
.query(q -> q.bool(b -> b
.must(m -> m.term(t -> t.field("ucid").value(ucid)))
.must(m -> m.range(r -> r.field("seq").gt(JsonData.of(lastSeq))))
))
.sort(so -> so.field(f -> f.field("seq").order(SortOrder.Asc)))
),
Message.class
);
중복 수신 방어
sync로 재전달된 메시지가 기존에 이미 수신된 메시지와 중복될 수 있다. 이건 클라이언트 측에서 seq를 Set으로 관리하면서, 이미 있는 seq의 메시지는 무시하는 것으로 처리한다. 서버 측에서도 동일한 sync 요청이 여러 번 와도 Redis 조회 기반이라 항상 같은 결과를 반환한다. 멱등성이 자연스럽게 보장되는 구조다.
전체 흐름 — 하나의 시나리오로 보면
정상 흐름
1. 고객이 "환불 요청입니다" 전송
2. 서버: seq=4 채번 (Redis INCR)
3. Redis ZADD chat:session:{ucid} 4 "{message}"
4. MySQL INSERT outbox (ucid, 4, CUSTOMER, PENDING)
5. MySQL INSERT outbox (ucid, 4, AGENT, PENDING)
6. ES 저장
7. Outbox Publisher:
→ CLIENT_EXCHANGE publish → 성공 → outbox(CUSTOMER) = PUBLISHED
→ AGENT_EXCHANGE publish → 성공 → outbox(AGENT) = PUBLISHED
8. 양쪽 모두 수신
부분 전달 → 자동 복구 흐름
1. 고객이 "환불 요청입니다" 전송
2. 서버: seq=4 채번
3. Redis, MySQL, ES 저장 (동일)
4. Outbox Publisher:
→ CLIENT_EXCHANGE publish → 성공 → outbox(CUSTOMER) = PUBLISHED
→ AGENT_EXCHANGE publish → 실패 → outbox(AGENT) = FAILED
→ 해당 seq의 전체 상태 = PARTIAL
5. 고객은 seq=4를 수신함, 상담사는 못 받음
[5초 후: Heartbeat]
6. 서버 → 상담사에게 x-latest-seq: 4 브로드캐스트
7. 상담사 클라이언트: local_seq=3, server_seq=4 → gap 감지!
8. STOMP SEND /app/chat/{ucid}/sync, x-last-sequence: 3
9. 서버: ZRANGEBYSCORE chat:session:{ucid} 4 +inf → seq=4 메시지 조회
10. 상담사 구독 채널로 재전달
11. 상담사 수신, 복구 완료
감지부터 복구까지 최대 5초. 고객은 아무것도 모르고, 상담사도 "메시지가 좀 늦게 왔나?" 정도만 느낀다.
세션 종료 시 데이터 라이프사이클
세션이 종료되면 활성 세션 데이터를 정리해야 한다.
상담 종료 트리거 (상담사 수동 종료 or counselAutoClose 60초 타이머)
→ Redis: chat:session:{ucid} → TTL 만료 대기 (2시간 후 자동 삭제)
→ Redis: msg:seq:{ucid} → 삭제
→ MySQL: outbox WHERE ucid = ? AND status = 'PUBLISHED' → 배치 삭제 (5분 주기)
→ MySQL: outbox WHERE ucid = ? AND status = 'FAILED' → 로그 남기고 삭제
→ ES: 메시지 이미 저장되어 있음 → 변경 없음
Redis TTL을 세션 종료 즉시 삭제하지 않고 2시간 유지하는 이유: 세션 종료 직전에 발생한 부분 전달이 아직 복구 중일 수 있기 때문. 세션 종료 후에도 고객이 화면을 열어둔 상태에서 sync가 발동될 수 있으므로, 여유를 둔 거다.
결과
| 지표 | Before | After |
| 부분 전달 감지 | (CS 에스컬레이션으로만 발견) | sequence gap → 시스템 자동 감지 |
| 감지 → 복구 소요 시간 | 3~5분 (사람이 인지한 후) | 최대 5초 (heartbeat 주기 내) |
| 메시지 영구 유실 가능성 | 존재 (양쪽 재시도 모두 실패 시) | Redis 보존 + ES fallback → 구조적으로 불가능 |
| Sync 쿼리 비용 | - | Redis O(log N), ~0.2ms |
| Heartbeat 부하 | 60 msg/sec (피크 300세션 기준), 전체 트래픽 대비 미미 | |
| 추가 인프라 비용 | - | Redis 메모리 ~5MB (기존 클러스터 활용), MySQL Outbox ~150KB |
| 고객-상담사 싱크 불일치 VOC | 반복 발생 | 해소 |
회고 — 이 작업을 하면서 생각이 바뀐 것들
"전달 성공"의 정의를 다시 생각하게 됐다
기존에는 "MQ에 publish 성공 = 전달 성공"이라고 생각했다. RabbitMQ가 ACK를 돌려주면 끝이라고. 하지만 채팅처럼 양방향 참여자가 있는 시스템에서는 "모든 참여자에게 도달해야 비로소 성공"이다. 이 사고방식의 전환이 Outbox에서 PARTIAL 상태를 명시적으로 설계하게 만든 계기였다.
publish 성공은 "MQ가 받았다"는 뜻이지, "상대방이 읽었다"는 뜻이 아니다. 이 차이를 무시하면 시스템은 정상으로 보이지만 사용자는 불편을 겪는, 가장 찾기 어려운 종류의 버그가 된다.
"감지 없는 실패" 가 가장 위험하다
완전한 실패는 차라리 낫다. 에러 로그가 남고, 모니터링에 걸리고, 사용자에게 에러 메시지라도 뜬다. 하지만 부분 전달 같은 Silent Failure는 시스템 입장에서는 "성공"으로 기록되면서, 사용자 입장에서는 "고장"인 상태다. 이런 문제를 풀려면 감지 체계를 먼저 만들어야 한다. sequence 기반 gap detection은 단순하지만, "누락을 감지하는 책임을 사람이 아니라 시스템에 부여한" 설계 판단이었다.