들어가며

CATXI는 하루에 150명 이상의 대학생이 사용하는 택시 합승 서비스입니다. 실시간 채팅 기능을 운영하면서 "알림이 두 번 와요", "알림이 안 왔어요"라는 피드백이 지속적으로 들어왔습니다.

처음에는 단순한 버그라고 생각했지만, 조사해보니 아키텍처 레벨의 구조적 문제였습니다. 이 글은 Redis Pub/Sub에서 시작해 Outbox 패턴과 Redis Streams까지, 세 번의 아키텍처 진화를 거치며 중복 발송과 메시지 손실을 해결한 과정을 기록합니다.


첫 번째 시도: Redis Pub/Sub

초기 설계와 가정

채팅 기능 구현 당시, 이미 실시간 메시지 전파에 Redis Pub/Sub을 사용하고 있었습니다. FCM 알림도 같은 채널을 구독하면 자연스럽게 발송될 거라 생각했습니다.

문제 발생

서버를 2대로 스케일 아웃하자마자 문제가 터졌습니다. 사용자에게 동일한 알림이 2번씩 도착했고, 서버가 3대가 되면 3번 도착했습니다.

원인은 명확했습니다. Pub/Sub은 브로드캐스트 모델입니다. 하나의 메시지를 모든 구독자에게 전달하도록 설계된 구조에서, "딱 한 번만 처리"를 기대한 것 자체가 잘못된 가정이었습니다.

추가로, 구독 중인 서버가 하나도 없으면 메시지는 그냥 사라졌습니다. 배포나 장애로 서버가 잠시 내려가면 그 사이의 알림은 모두 유실되었습니다.


두 번째 시도: Redis List Queue

가설과 구현

중복 발송의 근본 원인은 브로드캐스트였습니다. 그렇다면 경쟁적 소비(Competitive Consumption) 모델로 바꾸면 어떨까요?

Redis List의 LPUSH/BRPOP 조합을 사용했습니다. BRPOP은 blocking pop으로, 여러 서버가 동시에 대기하더라도 메시지를 가져가는 건 단 하나뿐입니다.

결과와 새로운 문제

중복 발송 문제는 해결되었습니다. 하지만 운영 중 더 심각한 문제를 발견했습니다.

BRPOP은 메시지를 반환하는 순간 Redis에서 삭제합니다. 만약 메시지를 가져간 서버가 FCM 발송 직전에 죽으면 어떻게 될까요? 메시지는 Redis에서 사라졌고, FCM은 발송되지 않았습니다.

물론 RPOPLPUSH 명령어를 사용하면 메시지를 꺼내면서 동시에 다른 리스트(처리 중 큐)로 복사할 수 있습니다. 하지만 이 방식도 결국 "처리 중 큐를 주기적으로 스캔하고, 타임아웃된 메시지를 복구하는 로직"을 직접 구현해야 합니다. 이미 이런 기능을 내장한 Redis Streams로 넘어가는 게 낫다고 판단했습니다.

또 다른 문제도 있었습니다. 채팅 메시지 저장(MySQL)과 알림 큐 발행(Redis)이 별개의 작업이었습니다. 채팅은 저장됐는데 Redis 발행이 실패하면, 사용자는 채팅방에서 메시지를 보지만 알림은 받지 못하는 상황이 발생했습니다.

이 두 문제를 각각 At-Most-Once Delivery와 Dual Write Problem이라고 부릅니다. 둘 다 분산 시스템에서 흔히 마주치는 고전적인 문제였습니다.


세 번째 시도: Outbox 패턴 + Redis Streams

설계 목표

두 가지 문제를 동시에 해결해야 했습니다.

  1. 원자성 확보: 채팅 저장과 알림 이벤트 생성이 하나의 트랜잭션으로 묶여야 함
  2. 전달 보장: 처리 완료를 명시적으로 확인(ACK)받기 전까지 메시지를 보관해야 함

이를 위해 Transactional Outbox 패턴과 Redis Streams를 조합했습니다.

Outbox 패턴으로 트랜잭션 경계 안에서 이벤트 기록

핵심 아이디어는 단순합니다. Redis에 직접 발행하는 대신, 같은 MySQL 트랜잭션 안에서 outbox 테이블에 이벤트를 기록합니다.

java
@Transactional
public void saveMessage(Long roomId, ChatMessageSendReq req) {
    // 1. 채팅 메시지 저장
    ChatMessage savedMessage = chatMessageRepository.save(chatMsg);

    // 2. FCM 이벤트를 outbox에 기록 (같은 트랜잭션)
    FcmOutbox outbox = FcmOutbox.builder()
        .eventId(UUID.randomUUID().toString())
        .eventType(EventType.CHAT_MESSAGE)
        .payload(objectMapper.writeValueAsString(event))
        .status(OutboxStatus.PENDING)
        .build();
    fcmOutboxRepository.save(outbox);

    // 트랜잭션 커밋: 둘 다 저장되거나, 둘 다 롤백됨
}

채팅이 저장되면 outbox 레코드도 반드시 존재합니다. 반대로 outbox 저장이 실패하면 채팅도 롤백됩니다. Dual Write 문제가 원천적으로 사라졌습니다.

별도의 Poller가 500ms 주기로 PENDING 상태의 outbox 레코드를 조회하고, Redis Streams에 발행한 뒤 상태를 SENT로 변경합니다. 이를 통해 DB에 저장된 이벤트가 누락 없이 비동기 시스템으로 전달되는 것을 보장합니다.

Redis Streams로 ACK 기반의 신뢰성 확보

Redis Streams는 List와 달리 Consumer Group과 ACK 메커니즘을 지원합니다.

Consumer Group을 사용하면 여러 서버가 메시지를 나눠 처리하되, 같은 메시지가 두 서버에 전달되지 않습니다. 그리고 결정적으로, ACK를 보내기 전까지 메시지는 Pending 상태로 보관됩니다.

java
// 서버 식별자: 호스트네임 + PID 조합으로 재시작 시에도 고유성 보장
String consumerName = InetAddress.getLocalHost().getHostName()
    + "-" + ProcessHandle.current().pid();

// 메시지 소비
List<MapRecord<String, Object, Object>> messages = redisTemplate.opsForStream().read(
    Consumer.from("fcm-consumers", consumerName),
    StreamReadOptions.empty().count(10).block(Duration.ofSeconds(2)),
    StreamOffset.create("fcm:stream", ReadOffset.lastConsumed())
);

// FCM 발송 성공 후에만 ACK
for (MapRecord<String, Object, Object> message : messages) {
    fcmService.send(message);
    redisTemplate.opsForStream()
        .acknowledge("fcm:stream", "fcm-consumers", message.getId());
}

만약 서버가 메시지를 가져간 후 죽으면? 해당 메시지는 Pending List에 남아있고, 30초 후 다른 서버가 XCLAIM 명령으로 소유권을 가져와 처리합니다. 장애 복구가 자동화되었습니다.

재시도와 Dead Letter Queue

네트워크 일시 장애 등으로 FCM 발송이 실패하면, ACK를 보내지 않습니다. 메시지는 Pending 상태로 유지되고 다음 사이클에서 재처리됩니다.

다만 무한 재시도는 위험합니다. 잘못된 토큰 등 영구적 실패의 경우, 재시도 횟수가 3회를 초과하면 Dead Letter Queue(fcm:dlq)로 이동시키고 원본은 ACK 처리합니다. 이렇게 하면 하나의 실패한 메시지가 전체 파이프라인을 막지 않습니다.


최종 아키텍처


결과

세 단계의 진화를 거치며 다음과 같은 결과를 얻었습니다.

정량적 성과

  • 중복 발송: 서버 대수만큼 발생 → 발생 건수 0건
  • 메시지 손실: 간헐적 발생(측정 불가) → Zero Data Loss 달성
  • 장애 복구: 수동 대응 필요 → 30초 내 자동 인수

운영 안정성

  • 배포 중에도 알림 유실 없음 (Pending 메시지 자동 인수)
  • DLQ 모니터링을 통한 영구 실패 케이스 추적 가능
  • 7일 이전 처리 완료 데이터 자동 정리로 outbox 테이블 크기 관리

마치며

이 글에서 다룬 세 가지 아키텍처는 각각 다른 trade-off를 가집니다.

  • Pub/Sub: 구현이 단순하지만, 단일 소비와 전달 보장이 필요한 곳에는 부적합
  • List Queue: 경쟁적 소비로 중복을 해결하지만, At-Most-Once만 보장
  • Outbox + Streams: 원자성과 전달 보장을 모두 확보하지만, 구현 복잡도가 높음

"정답"은 없습니다. 중요한 건 현재 시스템의 요구사항을 정확히 파악하고, 그에 맞는 trade-off를 선택하는 것입니다. 저희에게는 알림의 신뢰성이 가장 중요했고, 그래서 세 번째 방식을 선택했습니다.

이 경험을 통해 분산 시스템에서의 메시지 전달 보장이 왜 어려운 문제인지, 그리고 Exactly-Once Semantics에 "근접"하기 위해 얼마나 많은 장치가 필요한지 체감할 수 있었습니다.