저는 스스로 문제를 찾고 해결하는 사람입니다.
주문 알림 시스템에 Redis Stream 메시지 큐를 도입한 프로젝트는 단순히 알림 전송을 비동기로 바꾼 작업이 아니었습니다. 주문 응답 지연, 외부 알림 API timeout, consumer 장애, 서버 재시작 이후 미처리 메시지 복구, 메시지 produce 유실 가능성까지 함께 다뤄야 했던 작업이었습니다.
[질문1] 엔지니어링 문제들은 어떤 것이 있었는가?
가장 먼저 보였던 문제는 주문 응답 지연이었습니다. 기존 주문 완료 흐름에서는 PUSH -> SMS -> 알림톡 순서의 외부 알림 failover가 동기적으로 실행되고 있었습니다. 각 외부 API timeout을 5초로 잡으면 최악의 경우 주문 응답이 최대 15초까지 밀릴 수 있었습니다. 주문 완료는 빠르게 응답되어야 하는 핵심 흐름인데, 외부 알림 API의 상태가 주문 응답 시간에 직접 영향을 주고 있었습니다.
두 번째 문제는 대량 알림 배치와 실시간 주문 알림이 같은 처리 흐름에 묶여 있었다는 점입니다. 대량 알림 배치가 주문 시간대와 맞물리면 외부 Push API 호출량과 서버 처리량이 동시에 증가했고, timeout과 retry가 겹치면서 주문 알림 처리까지 흔들릴 수 있었습니다. 즉시성이 필요한 주문 알림이 배치 부하의 영향을 받는 구조였고, 이는 주문 응답 지연뿐 아니라 알림 누락 가능성으로 이어질 수 있었습니다.
세 번째 문제는 실패한 알림을 복구할 기준이 부족했다는 점입니다. 알림 전송은 외부 API 장애, timeout, retry, 서버 재시작의 영향을 크게 받습니다. 서버가 알림을 처리하던 중 죽거나 예외가 발생하면, 이미 가져간 메시지가 실제로 전송되었는지 확인하기 어렵고 다시 처리할 기준도 명확하지 않았습니다. 단순히 메서드를 비동기로 실행하는 수준으로는 이 문제를 해결할 수 없었습니다.
네 번째 문제는 메시지 큐 선택 기준이었습니다. Kafka도 검토했지만, 당시 알림 시스템은 엄격한 순서 보장이나 장기 디스크 보관보다 빠른 처리, 낮은 운영 비용, 기존 인프라 재사용이 더 중요했습니다. 이미 Redis를 운영하고 있었고, 알림 메시지는 비교적 가볍고 빠르게 소비되는 이벤트였기 때문에 Redis Stream 메시지 큐가 요구사항에 더 맞았습니다.
마지막으로 메시지 produce 자체의 유실 가능성도 고려해야 했습니다. Redis Stream은 메시지 큐로 사용할 수 있지만, DB transaction과 Redis publish 경계가 분리되어 있으면 주문 데이터는 저장됐는데 알림 이벤트 적재가 실패하는 상황이 생길 수 있습니다. 따라서 consumer 재처리뿐 아니라 produce 단계의 유실 가능성까지 함께 설계해야 했습니다.
[질문2] 해당 문제들을 어떻게 풀어갔는가?
먼저 주문 처리와 알림 전송을 분리했습니다. 주문 API는 알림 이벤트를 Redis Stream에 적재하고 응답 흐름으로 돌아가도록 했고, 실제 PUSH/SMS/알림톡 전송은 별도 consumer가 Consumer Group으로 읽어 처리하도록 구성했습니다. 이 변경으로 주문 API가 외부 알림 API timeout을 직접 기다리지 않게 만들었습니다.
메시지 재처리는 Redis Stream의 PEL(Pending Entries List)을 기준으로 설계했습니다. consumer가 XREADGROUP으로 메시지를 가져가면 해당 메시지는 ACK되기 전까지 PEL에 남습니다. 서버가 처리 도중 죽거나 예외로 ACK하지 못하면 메시지가 PEL에 남기 때문에, 일정 idle time이 지난 메시지를 XPENDING, XAUTOCLAIM/XCLAIM으로 다른 살아있는 consumer가 회수하도록 만들었습니다. 이 방식으로 at-least-once 시맨틱 기반의 알림 재처리를 지원했습니다.
consumer 생존 여부는 Redis Stream의 XINFO CONSUMERS만 믿지 않고 별도로 관리했습니다. XINFO CONSUMERS의 idle 값은 이벤트가 없을 때도 증가하기 때문에, 정상 consumer와 죽은 consumer를 구분하기 어렵습니다. 그래서 consumer heartbeat를 key + TTL과 Set으로 관리해 실제 살아있는 consumer 목록을 추적했습니다. 이 목록은 실패 메시지를 어느 consumer가 회수할지 결정하는 기준으로도 사용했습니다.
실패 메시지가 특정 consumer에 몰리는 문제도 함께 풀었습니다. 각 consumer가 같은 주기로 XPENDING을 조회하면 먼저 실행되는 consumer가 실패 메시지를 과도하게 가져갈 수 있습니다. 이를 막기 위해 min(hash(eventId + consumerId)) 기준으로 담당 consumer를 정했습니다. consumer 추가/삭제 시 같은 메시지를 동시에 처리할 수 있는 구간은 Redis Lock으로 막아 중복 처리 가능성을 줄였습니다.
반복 실패 메시지는 정상 큐에서 분리했습니다. PEL에는 메시지가 몇 번 소비되었는지 확인할 수 있는 정보가 있으므로, 재시도 횟수가 임계값을 넘는 메시지는 계속 재처리하지 않고 CDL/DLQ 성격의 별도 스트림으로 이동시켰습니다. 정상 메시지 흐름과 악성 메시지 흐름을 분리해, 하나의 실패 이벤트가 전체 알림 큐를 막지 않도록 했습니다.
produce 유실 가능성은 outbox 테이블로 보완했습니다. 주문 처리 중 알림 이벤트를 Redis Stream에 적재하는 과정이 실패할 수 있으므로, 내부적으로 outbox 테이블에 produce 대상 메시지를 관리했습니다. 이후 별도 스케줄러가 produce 누락 상태를 관측하고 Redis Stream에 다시 적재할 수 있도록 해, consumer 재처리 이전 단계의 유실 가능성도 줄였습니다.
결과적으로 주문 응답 흐름에서 외부 알림 전송 지연을 제거했고, 서버 재시작이나 consumer 장애가 발생해도 ACK되지 않은 메시지를 PEL 기준으로 다시 회수할 수 있게 만들었습니다. 대량 알림 배치와 주문 시간대가 겹치는 상황에서도 주문 알림 처리 흐름이 같이 무너지지 않도록 분리했으며, 알림 누락을 0% 수준으로 안정화했습니다. Kafka를 별도로 도입하지 않고도 기존 Redis 인프라 위에서 빠른 메시지 처리, 낮은 운영 비용, 장애 후 재처리 구조를 확보했습니다.
추가로 Redis 8.4 이후 환경에서는 실패 메시지 재처리 흐름을 더 단순하게 가져갈 수 있습니다. 기존에는 별도 스케줄러가 PEL을 주기적으로 조회하고 XAUTOCLAIM/XCLAIM으로 회수하는 구조가 필요했지만, 최신 Redis Stream 기능을 활용하면 재처리 구현 부담을 줄일 수 있습니다. 다만 제가 설계할 당시의 핵심은 특정 명령어 하나가 아니라, 메시지를 가져간 뒤 ACK되지 않은 상태를 추적하고 다시 처리할 수 있는 구조를 만드는 것이었습니다.