채팅 서비스에서 푸시 알림은 부가 기능처럼 보이기 쉽습니다. 하지만 메시지가 저장됐는데 상대방에게 아무것도 가지 않거나, 같은 알림이 두 번 가는 순간부터 이야기가 달라집니다. 사용자에게는 메시지가 저장됐는지보다, 그 메시지가 제때 도착했는지가 더 직접적인 경험으로 남기 때문입니다.

처음에는 FCM이 문제인 것처럼 보였습니다. 실제로는 달랐습니다. 알림 자체보다, 알림 이벤트를 어떤 전달 모델로 다루고 있는지가 문제였습니다. 이 글은 Redis 자료구조를 비교하는 글이라기보다, 채팅 알림을 실시간 이벤트가 아니라 복구 가능한 작업으로 다시 정의한 과정에 가깝습니다.

먼저 요구 사항을 다시 세웠습니다

푸시 알림 파이프라인에 실제로 필요했던 조건은 네 가지였습니다.

  • 하나의 알림은 하나의 소비자만 처리해야 했습니다.
  • 채팅 저장과 알림 이벤트 생성은 같은 트랜잭션 안에 있어야 했습니다.
  • 소비 중 장애가 나더라도 메시지는 복구 가능한 상태로 남아야 했습니다.
  • 실패는 Pending, ACK, DLQ 기준으로 설명할 수 있어야 했습니다.

이 기준으로 다시 보니 문제는 Redis 하나를 잘못 고른 것이 아니라, 초기 구조가 서비스 요구 사항과 맞지 않았다는 데 있었습니다.

요구 사항이 먼저 보였습니다
요구 사항이 먼저 보였습니다

Pub/Sub은 왜 탈락했을까요

초기 구조는 Redis Pub/Sub 기반이었습니다. 채팅 메시지를 저장한 뒤 알림 이벤트를 발행하고, 각 서버가 이를 구독해 FCM을 호출했습니다. 단일 서버에서는 크게 문제되지 않았습니다.

하지만 서버를 여러 대로 늘리자 한계가 바로 드러났습니다. Pub/Sub은 브로드캐스트 모델이기 때문에 같은 이벤트를 모든 구독자에게 전달합니다. 서버가 두 대면 두 대가 모두 같은 알림을 보냅니다. 서버 수가 늘어날수록 중복 발송도 함께 늘어나는 구조였습니다.

핵심은 Pub/Sub이 나쁜 기술이라는 뜻이 아닙니다. 우리가 해결하려던 문제가 브로드캐스트가 아니라는 점입니다. 채팅 실시간 전파에는 맞을 수 있지만, 알림 발송처럼 하나의 작업을 하나의 소비자만 처리해야 하는 경우에는 전달 모델이 맞지 않았습니다.

복구도 문제였습니다. 구독자가 없는 순간에 발행된 이벤트는 그대로 사라집니다. 배포 중 인스턴스가 교체되거나 소비자가 잠시 비는 순간의 이벤트를 다시 설명하거나 복구할 방법이 없었습니다.

List Queue는 어디까지 해결했을까요

다음 단계에서는 Redis List Queue로 바꿨습니다. LPUSHBRPOP 조합을 사용하면, 여러 서버가 동시에 대기하더라도 메시지를 가져가는 소비자는 하나뿐입니다.

이 변경으로 중복 발송은 정리됐습니다. 하지만 여기서 멈출 수는 없었습니다. BRPOP은 메시지를 반환하는 순간 큐에서 제거합니다. 소비자가 메시지를 받은 직후 종료되면, FCM 발송은 되지 않았는데 메시지는 이미 사라진 상태가 됩니다. 중복은 막았지만 유실은 막지 못한 셈입니다.

채팅 저장과 알림 이벤트 생성이 분리되어 있다는 문제도 그대로 남아 있었습니다. 채팅 저장은 MySQL 트랜잭션 안에서 처리되지만, 알림 큐 발행은 그 밖에서 별도로 수행됐습니다. 채팅은 저장됐는데 알림 이벤트는 생성되지 않는 상태가 남을 수 있었고, 이 불일치는 운영에서 가장 설명하기 어려운 실패였습니다.

여기까지 오고 나서야 필요한 조건이 더 선명해졌습니다.

  • 하나의 메시지를 하나의 소비자만 처리해야 했습니다.
  • 소비 중 장애가 나도 메시지는 복구할 수 있어야 했습니다.
  • 채팅 저장과 알림 이벤트 생성이 서로 다른 상태로 남으면 안 됐습니다.

그래서 생성 단계와 전달 단계를 분리했습니다

문제를 정리하고 나니 구조도 분명해졌습니다.

  1. 알림 이벤트는 채팅 저장과 함께 생성되어야 했습니다.
  2. 생성된 이벤트는 소비가 끝날 때까지 추적 가능해야 했습니다.

이 요구를 만족시키기 위해 Transactional Outbox와 Redis Streams를 함께 사용했습니다.

생성 단계와 전달 단계를 분리했습니다
생성 단계와 전달 단계를 분리했습니다

Transactional Outbox로 이벤트 생성의 정합성을 보장했습니다

Redis에 직접 이벤트를 발행하는 대신, 같은 MySQL 트랜잭션 안에서 outbox 테이블에 알림 이벤트를 함께 저장했습니다. 이렇게 하면 채팅이 저장되었다면 대응되는 알림 이벤트도 반드시 존재합니다. 반대로 outbox 쓰기가 실패하면 채팅 저장도 함께 롤백됩니다.

이 구조에서 먼저 해결한 것은 “Redis에 어떻게 넣을 것인가”가 아니라 “이벤트가 생성되었는지 아닌지를 어떻게 확실하게 만들 것인가”였습니다.

Redis Streams로 소비와 복구를 분리했습니다

그다음 전달 계층을 Redis Streams로 바꿨습니다. Streams는 Consumer Group과 ACK를 제공하기 때문에, 누가 어떤 메시지를 처리 중인지 추적할 수 있습니다.

가장 큰 차이는 메시지가 읽혔다고 바로 사라지지 않는다는 점입니다. 소비자가 메시지를 읽고, FCM 발송을 마친 뒤, ACK를 보낼 때까지는 처리 중 상태로 남습니다.

이 구조에서는 다음 흐름이 가능합니다.

  • 소비자가 메시지를 읽습니다.
  • FCM 발송 전에 프로세스가 종료됩니다.
  • 메시지는 Pending 상태로 남습니다.
  • 다른 소비자가 XCLAIM으로 소유권을 가져옵니다.
  • 재시도 후 성공하면 ACK 처리합니다.

이제 장애는 곧 유실이 아니라, 복구 가능한 작업 상태가 됩니다.

재시도는 무한정 하지 않았습니다

복구 가능한 구조를 만들었다고 해서 모든 실패를 계속 재시도할 수는 없습니다. 네트워크 오류처럼 일시적인 실패도 있지만, 잘못된 디바이스 토큰처럼 재시도해도 성공하지 않는 실패도 있기 때문입니다.

그래서 재시도와 종료 조건을 분리했습니다.

  • 일시 실패는 Pending 상태로 남기고 다시 처리합니다.
  • 일정 횟수 이상 실패한 메시지는 DLQ로 보냅니다.
  • DLQ로 보낸 뒤에는 원본 메시지를 ACK 처리해 전체 파이프라인을 막지 않게 합니다.

이 구조를 적용한 뒤부터는 실패 메시지가 파이프라인 전체를 끌어내리지 않게 됐습니다. 동시에 영구 실패는 DLQ 기준으로 분리해서 추적할 수 있게 됐습니다.

실패는 유실이 아니라 복구 가능한 상태가 됩니다
실패는 유실이 아니라 복구 가능한 상태가 됩니다

운영 방식은 이렇게 바뀌었습니다

항목기존 구조개선 후
중복 발송여러 서버가 같은 이벤트를 함께 처리할 수 있었습니다.하나의 알림을 하나의 소비자만 처리하는 구조를 확보했습니다.
메시지 유실소비 중 장애가 나면 복구 경로가 없었습니다.Pending 기반으로 재처리할 수 있게 됐습니다.
이벤트 정합성채팅 저장과 알림 생성이 서로 다른 상태로 남을 수 있었습니다.Outbox로 같은 트랜잭션 안에서 관리합니다.
운영 가시성실패 위치를 설명하기 어려웠습니다.Pending, ACK, DLQ 기준으로 상태를 추적합니다.

핵심은 성공률 자체보다 실패를 설명할 수 있게 됐다는 점입니다. 이전에는 “보냈는지 아닌지 알기 어려운 구조”였다면, 지금은 어느 단계에서 멈췄는지 구간 단위로 확인할 수 있습니다.

이 구조의 비용도 분명했습니다

Outbox + Redis Streams가 항상 가장 단순한 답은 아닙니다.

  • outbox 테이블을 운영해야 합니다.
  • Poller와 Consumer Group을 함께 관리해야 합니다.
  • Pending, XCLAIM, DLQ까지 운영 포인트가 늘어납니다.

그럼에도 이 구조를 선택한 이유는 분명했습니다. 채팅 알림은 “가끔 빠져도 괜찮은 이벤트”가 아니었기 때문입니다. 사용자에게는 메시지가 저장됐는지보다, 상대방에게 제때 도착했는지가 더 직접적인 경험으로 남습니다.

정리

이 문제에서 중요했던 것은 Redis 자료구조가 아니라 전달 모델이었습니다. Pub/Sub은 전파에는 맞았지만 작업 처리에는 맞지 않았고, List Queue는 중복을 줄였지만 복구를 제공하지 못했습니다. Outbox + Streams는 구현 복잡도를 감수하는 대신, 정합성과 복구 가능성을 함께 확보했습니다.

결국 바꾼 것은 큐가 아니라 기준이었습니다. 채팅 알림을 단순한 실시간 이벤트가 아니라, 실패를 복구할 수 있어야 하는 작업으로 취급하게 된 것입니다.