채팅 알림은 종종 부가 기능처럼 취급됩니다. 메시지가 저장되는 것이 핵심이고, 푸시 알림은 그 사실을 사용자에게 알려 주는 부수 효과처럼 보이기 때문입니다. 하지만 실제 운영에서는 반대로 체감되는 경우가 많습니다. 사용자는 메시지가 DB에 커밋됐는지보다, 상대방에게 제때 도착했는지를 먼저 경험합니다. 채팅은 남았는데 알림이 오지 않거나, 같은 알림이 두 번 도착하는 순간부터 문제는 저장 계층이 아니라 사용자 경험의 층위에서 바로 드러납니다.
CATXI에서도 처음 보인 증상은 전형적이었습니다. 어떤 경우에는 채팅은 저장됐는데 푸시가 가지 않았고, 어떤 경우에는 같은 알림이 여러 번 발송됐습니다. 겉으로만 보면 FCM 설정이나 디바이스 토큰 품질 문제처럼 보입니다. 실제로는 조금 더 안쪽의 문제였습니다. 알림을 어떤 전달 모델로 다루고 있었는지가 더 본질적이었습니다.
이 글은 Redis 자료구조 비교 글이 아닙니다. 더 정확히 말하면, 푸시 알림을 “빨리 전파되는 실시간 이벤트”가 아니라 “실패를 복구할 수 있어야 하는 비동기 작업”으로 다시 정의한 과정에 가깝습니다. 무엇을 더 빠르게 보내는지가 아니라, 어떤 실패를 설명 가능하게 만들고 어떤 실패를 구조적으로 제거할 것인가가 중심입니다.
푸시 알림을 다시 정의해야 했던 이유
초기에는 문제를 증상 단위로 보기 쉬웠습니다.
- 같은 알림이 여러 번 온다.
- 채팅은 남았는데 알림은 오지 않는다.
- 배포 직후 특정 시간대에만 푸시가 빠진다.
하지만 이런 증상은 모두 결과일 뿐입니다. 중요한 것은 그 결과가 어떤 전달 계약 위에서 생겼는지였습니다. 채팅 알림이 만족해야 하는 조건을 다시 세워 보면 요구는 분명했습니다.
- 하나의 알림은 하나의 소비자만 처리해야 합니다.
- 채팅 저장과 알림 이벤트 생성은 같은 트랜잭션 안에서 결정돼야 합니다.
- 소비 도중 장애가 나더라도 메시지는 복구 가능한 상태로 남아야 합니다.
- 운영에서는
Pending,ACK,DLQ같은 상태로 실패를 설명할 수 있어야 합니다.
이 기준으로 다시 보면 문제는 Redis를 잘못 골랐다기보다, 전달 모델이 서비스 요구 사항과 어긋나 있었다는 데 더 가까웠습니다. 브로드캐스트가 필요한 문제와, 단일 소비와 복구 가능성이 필요한 문제를 같은 방식으로 다루고 있었던 셈입니다.
첫 번째 한계: Pub/Sub은 전파에는 맞았지만 작업 처리에는 맞지 않았습니다
초기 구조는 Redis Pub/Sub 기반이었습니다. 채팅 메시지를 저장한 뒤 알림 이벤트를 발행하고, 각 서버가 이를 구독해 FCM을 호출했습니다. 단일 서버에서는 큰 문제가 드러나지 않았습니다. 구현이 단순하고 지연도 낮았습니다. 메시지가 발생하면 바로 전달된다는 감각도 좋았습니다.
문제는 이 구조가 서버 수가 늘어나는 순간 본질적으로 다른 의미를 갖게 된다는 점입니다. Pub/Sub은 브로드캐스트 모델입니다. 이벤트가 발생하면 모든 subscriber가 그 이벤트를 받습니다. 실시간 채팅 전파처럼 여러 소비자가 동시에 받아도 되는 문제에는 잘 맞습니다. 하지만 푸시 알림처럼 하나의 작업을 단 하나의 소비자만 처리해야 하는 경우에는 출발점부터 어긋납니다.
운영에서 관찰된 증상도 정확히 그 결과였습니다. 서버가 두 대면 두 대가 모두 같은 알림을 보냈고, 인스턴스 수가 늘어날수록 중복 발송도 함께 늘어났습니다. 이건 구현 실수라기보다 모델의 자연스러운 결과였습니다. 전달이 아니라 처리의 관점에서 보면, Pub/Sub은 애초에 너무 많은 consumer에게 같은 책임을 동시에 주고 있었습니다.
복구도 문제였습니다. 구독자가 없는 순간에 발행된 이벤트는 그대로 사라집니다. 배포 중 인스턴스가 비거나 consumer가 잠시 없는 순간의 이벤트를 나중에 다시 설명하거나 복구할 방법이 없었습니다. 즉, 장애는 유실로 바로 번졌고, 그 유실은 구조적으로 설명되지 않았습니다.
두 번째 한계: List Queue는 중복을 줄였지만 유실은 막지 못했습니다
다음 단계에서는 Redis List Queue로 바꿨습니다. LPUSH와 BRPOP 조합을 사용하면 여러 서버가 동시에 대기하더라도 메시지를 가져가는 소비자는 하나뿐입니다. 브로드캐스트 대신 경쟁 소비 모델로 바뀌면서, 적어도 “한 알림을 여러 서버가 동시에 보낸다”는 문제는 정리할 수 있었습니다.
하지만 이 구조도 오래 버티지 못했습니다. BRPOP은 메시지를 반환하는 순간 큐에서 제거합니다. 소비자가 메시지를 받은 직후 프로세스가 종료되면, FCM 발송은 아직 되지 않았는데 메시지는 이미 사라진 상태가 됩니다. 즉, 중복은 줄였지만 유실은 여전히 구조적으로 남아 있었습니다.
여기에 더 근본적인 문제가 하나 있었습니다. 채팅 저장과 알림 이벤트 생성이 서로 다른 시스템 경계에 놓여 있었다는 점입니다. 채팅 저장은 MySQL 트랜잭션 안에서 처리되지만, 큐 발행은 그 밖에서 별도로 일어났습니다. 그래서 채팅은 저장됐는데 알림 이벤트는 생성되지 않는 상태가 남을 수 있었습니다. 운영에서 가장 다루기 어려운 실패가 바로 이런 종류입니다. 채팅은 보이는데 푸시는 없고, 그렇다고 그 실패를 시스템 상태로 설명할 수도 없는 경우입니다.
즉, List Queue는 전달 모델을 한 단계 개선했지만, 서비스가 실제로 필요로 하는 정합성과 복구 가능성까지 닫아 주지는 못했습니다.
그래서 문제를 생성 단계와 전달 단계로 분리했습니다
문제를 다시 정리하면 구조는 두 단계로 나뉩니다.
- 알림 이벤트는 채팅 저장과 함께 만들어져야 합니다.
- 만들어진 이벤트는 소비가 끝날 때까지 추적 가능해야 합니다.
이 요구를 만족시키기 위해 Transactional Outbox와 Redis Streams를 함께 사용했습니다. 핵심은 큐 하나를 바꾼 것이 아니라, 이벤트의 생성과 전달을 서로 다른 책임으로 분리했다는 점입니다.
Transactional Outbox로 생성의 정합성을 먼저 확보했습니다
Redis에 직접 이벤트를 발행하는 대신, 같은 MySQL 트랜잭션 안에서 outbox 테이블에 알림 이벤트를 함께 저장했습니다. 이렇게 하면 채팅이 저장되었다면 대응되는 알림 이벤트도 반드시 존재합니다. 반대로 outbox 쓰기가 실패하면 채팅 저장도 함께 롤백됩니다.
이 구조에서 먼저 해결한 것은 “어떻게 빨리 보낼 것인가”가 아니었습니다. “이 이벤트가 생성되었는지, 아니면 애초에 존재하지 않는지”를 확실하게 만드는 것이 먼저였습니다. 실제로 운영에서 더 무서운 것은 느리게 가는 알림보다, 저장은 됐는데 이벤트 자체가 남지 않는 상태였기 때문입니다.
아래처럼 보면 outbox는 큐가 아니라 생성 계약을 고정하는 장치에 가깝습니다.
@Transactional
public void saveMessage(ChatMessage message) {
ChatMessage saved = chatMessageRepository.save(message);
PushOutboxEvent outbox = PushOutboxEvent.builder()
.eventId(UUID.randomUUID().toString())
.messageId(saved.getId())
.eventType(PushEventType.CHAT_MESSAGE)
.payload(objectMapper.writeValueAsString(toPushPayload(saved)))
.status(OutboxStatus.PENDING)
.createdAt(Instant.now())
.build();
pushOutboxRepository.save(outbox);
}이후 poller가 PENDING 상태의 outbox를 읽어 Redis Streams에 publish하고, 성공 시 상태를 SENT로 바꾸도록 구성했습니다. 핵심은 Redis publish 성공 여부가 더 이상 채팅 저장의 운명을 바꾸지 않도록 만드는 것입니다.
Redis Streams로 전달과 복구를 분리했습니다
전달 계층은 Redis Streams로 바꿨습니다. Streams는 Consumer Group과 ACK를 제공하기 때문에 누가 어떤 메시지를 처리 중인지 추적할 수 있습니다.
가장 큰 차이는 메시지가 읽혔다고 바로 사라지지 않는다는 점입니다. 소비자가 메시지를 읽고 FCM 발송을 마친 뒤 ACK를 보낼 때까지는 처리 중 상태로 남습니다. 이 구조에서는 장애가 곧 유실이 아니라, 복구 가능한 상태 전이로 바뀝니다.
실제 흐름은 이렇게 바뀝니다.
- consumer가 메시지를 읽습니다.
- FCM 발송 전에 프로세스가 종료됩니다.
- 메시지는
Pending상태로 남습니다. - 다른 consumer가
XCLAIM으로 소유권을 가져옵니다. - 재시도 후 성공하면 ACK 처리합니다.
즉, 예전에는 실패가 곧 사라짐이었다면, 이제는 실패가 곧 재시도 가능한 상태가 됩니다.
이 흐름을 의사코드로 보면 구조는 더 단순합니다.
1. poller가 outbox PENDING row를 읽는다.
2. Redis Streams에 event를 publish한다.
3. publish 성공 시 outbox status를 SENT로 바꾼다.
4. consumer group이 stream message를 읽는다.
5. FCM send 성공 시 ACK 한다.
6. consumer crash 또는 timeout이면 message는 pending list에 남는다.
7. idle timeout 이후 다른 consumer가 XCLAIM 한다.
8. retry count 초과 시 DLQ로 분리하고 원본은 ACK 한다.중요한 것은 이제 장애가 “사라짐”이 아니라 “설명 가능한 상태 전이”로 보인다는 점입니다.
재시도는 무한정 하지 않았습니다
복구 가능한 구조를 만들었다고 해서 모든 실패를 끝없이 재시도할 수는 없습니다. 네트워크 오류처럼 일시적인 실패도 있지만, 잘못된 디바이스 토큰처럼 재시도해도 성공하지 않는 실패도 있기 때문입니다.
그래서 재시도와 종료 조건을 분리했습니다.
- 일시 실패는
Pending상태로 남기고 다시 처리합니다. - 일정 횟수 이상 실패한 메시지는
DLQ로 보냅니다. DLQ로 보낸 뒤에는 원본 메시지를 ACK 처리해 전체 파이프라인을 막지 않게 합니다.
이 구조를 적용한 뒤부터는 실패 메시지 하나가 전체 파이프라인을 끌어내리지 않게 됐습니다. 동시에 영구 실패는 DLQ 기준으로 분리해서 추적할 수 있게 됐습니다. 실패가 더 적게 보이게 만든 것이 아니라, 어떤 실패가 재시도 대상이고 어떤 실패가 분리 대상인지 구분 가능하게 만든 것입니다.
운영 반례를 하나만 들어도 차이는 분명합니다.
기존 구조:
- consumer가 메시지를 읽는다.
- 프로세스가 죽는다.
- 메시지는 이미 큐에서 사라졌다.
- 운영자는 “왜 안 갔는지”를 로그로 추정해야 한다.
변경 후 구조:
- consumer가 메시지를 읽는다.
- 프로세스가 죽는다.
- 메시지는 pending list에 남는다.
- 다른 consumer가 XCLAIM 한다.
- 실패 횟수가 누적되면 DLQ로 간다.
- 운영자는 현재 상태를 시스템 안에서 바로 볼 수 있다.이 차이는 구현 취향의 문제가 아니라, 실패를 관측 가능한가의 문제입니다.
운영 방식은 이렇게 바뀌었습니다
| 항목 | 기존 구조 | 개선 후 |
|---|---|---|
| 중복 발송 | 여러 서버가 같은 이벤트를 함께 처리할 수 있었습니다. | 하나의 알림을 하나의 소비자만 처리하는 구조를 확보했습니다. |
| 메시지 유실 | 소비 중 장애가 나면 복구 경로가 없었습니다. | Pending 기반으로 재처리할 수 있게 됐습니다. |
| 이벤트 정합성 | 채팅 저장과 알림 생성이 서로 다른 상태로 남을 수 있었습니다. | Outbox로 같은 트랜잭션 안에서 관리합니다. |
| 운영 가시성 | 실패 위치를 설명하기 어려웠습니다. | Pending, ACK, DLQ 기준으로 상태를 추적합니다. |
핵심은 성공률 자체보다 실패를 설명할 수 있게 됐다는 점입니다. 이전에는 “보냈는지 아닌지 알기 어려운 구조”였다면, 지금은 어느 단계에서 멈췄는지 구간 단위로 설명할 수 있습니다. 운영에서 이 차이는 큽니다. 재현이 어려운 실패보다, 상태 전이로 드러나는 실패가 훨씬 다루기 쉽기 때문입니다.
이 구조의 비용도 분명했습니다
물론 Outbox + Redis Streams가 항상 가장 단순한 답은 아닙니다.
- outbox 테이블을 운영해야 합니다.
- poller와 consumer group을 함께 관리해야 합니다.
Pending,XCLAIM,DLQ까지 운영 포인트가 늘어납니다.
즉, 구현 복잡도는 분명히 올라갑니다. 하지만 이 비용은 채팅 알림이 갖는 중요도를 생각하면 감수할 만한 비용이었습니다. 채팅 알림은 “가끔 빠져도 괜찮은 이벤트”가 아니라, 사용자 경험에 직접 닿는 기능이었기 때문입니다. 실제 사용자는 메시지가 저장됐는지보다, 상대방에게 제때 도착했는지로 시스템을 기억합니다.
운영 측면에서도 단순함이 항상 이득은 아니었습니다. 단순하지만 상태가 안 남는 구조보다, 조금 더 복잡하더라도 실패가 추적 가능한 구조가 훨씬 다루기 쉬웠습니다. 특히 배포, 장애, consumer 재시작 같은 현실적인 상황에서는 이 차이가 더 크게 드러납니다.
마치며
이번 개선의 핵심은 Redis 자료구조를 교체한 것이 아니었습니다. 더 근본적으로는, 알림을 어떤 종류의 작업으로 볼 것인지 다시 정의한 일이었습니다. Pub/Sub은 빠른 전파에는 적합했지만 작업 처리에는 맞지 않았고, List Queue는 단일 소비를 만들었지만 복구 가능성을 제공하지 못했습니다. Outbox + Streams는 구현 복잡도를 감수하는 대신, 정합성과 복구 가능성을 함께 확보했습니다.
결국 바꾼 것은 큐가 아니라 기준이었습니다. 채팅 알림을 단순한 실시간 이벤트가 아니라, 실패를 복구할 수 있어야 하는 작업으로 취급하게 된 것입니다. 이 기준이 바뀌고 나서야, 중복 발송과 유실이라는 두 증상을 같은 구조 안에서 설명할 수 있게 됐습니다.
