티스토리 뷰

들어가기 앞서

이 글은 FCM 푸시 알림이 중복 전송되는 문제를 개선하기 위해 리팩터링해온 과정을 서술했습니다.

 

레디스 스트림을 활용하여 푸시 알림을 고도화하고자 하는 독자에게 추천드리며, 이 글을 읽고 나면 전체적인 흐름을 파악하고 어떻게 리팩터링 할 수 있을지 방향을 잡는 데 도움이 되었으면 합니다.

 

푸시 알림의 가용성과 신뢰성을 높이기 위해 구조를 점차 개선해온 과정이므로, 이전 글을 먼저 읽고 오시면 전체 흐름을 이해하는 데 훨씬 수월할 것입니다.

 

 

2025.04.08 - [개발 스토리] - 코루틴 기반 Watchdog으로 분산 서버에서 푸시 알림 중복 전송 문제 해결

 

 

지금 어떤 구조로 되어 있는가?

현재 구현되어 있는 알림 전송 구조는 다음과 같습니다.

분산 서버에서 각각 배치를 수행하며 레디스에 저장되어 있는 알림을 조회합니다. 이때 동일한 알림을 조회하지 않도록 레디스 분산락을 활용해 락을 획득한 뒤 조회합니다. 이후 조회된 알림을 FCM 서버에 요청을 보내 사용자에게 푸시 알림을 전송했습니다.

 

각 서버에서 실행되는 배치가 동일한 푸시 알림을 중복 조회하여 전송하지 않도록 레디스 분산락을 적용했습니다. 그 결과 사용자에게 푸시 알림을 여러 번 전송하지 않고 한 번만 전송되도록 보장할 수 있었습니다.

 

하지만 문제가 있다.

그러나 현재 구조로는 알림이 사용자에게 제대로 전송되었는지에 대한 보장은 불가능했습니다. 만약 FCM 서버에 요청을 보내는 과정에서 에러가 발생한다면 재전송 시나리오가 없어 사용자는 푸시 알림을 받지 못하게 됩니다. 알림이 크게 중요하지 않은 서비스에서는 재전송이 없어도 큰 문제가 되지 않겠지만, ‘앗차!’ 프로젝트는 사용자가 막차를 타야 하는 시간을 푸시 알림으로 알려주는 비즈니스 핵심 기능이기 때문에 사용자에게 알림은 매우 중요한 요소였습니다. 따라서 이러한 푸시 알림이 사용자에게 무사히 전달되도록 구조를 개선할 필요성이 있었습니다.

 

개선을 위해 어떤 목적을 가져야 하는지 아래와 같이 정리했습니다.

 

목적 정의

  1. 전송 실패 시 재시도가 가능해야 한다.
  2. 중복 전송 없이 딱 한 번만 처리되어야 한다.
  3. 여러 서버(분산 환경)에서 병렬 소비가 가능해야 한다.
  4. 전송 실패 원인을 추적 및 관찰 가능해야 한다.

 

 

어떻게 개선할 수 있었는가?

 

위 목적을 달성하기 위해 어떤 방법들이 있을지 고민했습니다.

 

크게 3가지 방식을 도출했고, 각 방식의 장점과 단점을 아래 표로 정리했습니다.

방식 장점 단점 추천 상황
1. 레디스 키 기반 전송 여부 체크

(e.g. notification:push:{id} = true/false)
- 구현 간단
- 락 기반 구조와 잘 맞음
- 기존 구조 최소 변경
- retry/backoff 직접 구현 필요
- 상태 추적 단순
- 대량 메시지 시 키 폭증 가능
- 정확히 한 번 처리 보장 어려움
✅ 단기적 개선엔 매우 효율적
❌ 장기적 확장성과 유연성은 부족
2. 레디스 pub/sub - 실시간성
- 구현 쉬움
- 수신자 없으면 메시지 유실
- 상태 추적 불가
- 재전송 불가
❌ 실시간 알림 등 일부 상황 외에는 신뢰성 보장 어려움
3. 레디스 Stream + Consumer Group - 재시도 가능
- ack 기반 유실 방지
- 상태 추적, 병렬 처리 가능
- 경량 Kafka 느낌
- 약간의 복잡도
- 학습 필요
✅ 신뢰성, 확장성 모두 중요할 때 가장 균형 잡힌 선택

 

각 방식에 대해서 좀 더 자세히 살펴보자.

 

 

1. 레디스 키 기반 전송 여부 체크

 

가장 먼저 떠올린 방법은 새로운 키를 등록하여 전송 여부를 레디스를 통해 관리하는 방식이었습니다.

notification:push:{id} = true/false

전송 처리한 알림은 true로, 아직 처리하지 않은 알림은 false로 등록하는 것입니다.

 

이후 각 서버의 배치에서 전송 여부에 따라 FCM 서버에 알림 요청을 보낼지 말지를 결정할 수 있습니다.

if (redis.get("notification:push:$id") != "true") {
    redis.set("notification:push:$id", "true")
    try {
        firebaseMessaging.send(message)
    } catch (e: Exception) {
		    redis.set("notification:push:$id", "false")
    }
}

 

 

 

문제점

 

이 방식의 문제는 정확히 한 번 처리된다는 보장이 없다는 점이었습니다.

redis.set("notification:push:$id", "true")
firebaseMessaging.send(message) // 실패
// catch에 들어감
redis.set("notification:push:$id", "false") ← 💥 여기서 장애 발생

레디스에 알림이 전송되었다고 true로 설정한 이후, FCM 서버로 요청을 보냅니다. 그런데 이때 전송이 실패하고, catch로 진입하여 다시 false로 설정하려고 했을 때 장애가 발생하면 어떻게 될까요? 레디스에는 여전히 true로 남아 있을 것입니다.

 

장애 가능성은 매우 낮았지만, 만약 발생한다면 디버깅이 매우 어려웠습니다. 따라서 알림 전송 여부를 true로 설정하는 작업과 실제 전송이 성공하는 작업 사이의 원자성이 반드시 보장되어야 했습니다. 그러나 외부 API 호출까지 포함한 원자성 보장은 어렵다는 결론에 도달했습니다. 

 

따라서 다른 해결 방식에 대해서 고민하게 되었습니다.

 

 

 

2. 레디스의 Pub/Sub 구조

레디스의 pub/sub 구조를 활용하는 경우 토픽이 발행되면 모든 서버가 소비하게 된다.

레디스의 Pub/Sub 구조를 활용하는 방식도 고려했습니다. 두 서버 중 하나가 알림을 등록하면, Subscribing 중인 서버들은 동시에 이벤트를 수신합니다.

 

이 구조는 실시간 반응성이 뛰어나고, 별도의 저장 없이 메시지를 전송하므로 메모리 사용 측면에서 효율적이었습니다.

 

 

문제점

하지만 이 방식에도 문제점이 있었습니다. 알림 등록과 동시에 두 서버에서 전송이 트리거 되므로 중복 전송이 발생할 가능성이 매우 높았습니다. 락으로 어느 정도 제어할 수 있었지만, 구조 자체가 중복 처리를 유도하는 형태였기 때문에 “정확히 한 번 전송”이라는 요건에 부합하지 않았습니다.

 

Pub/Sub 구조는 여러 서버가 동일한 작업을 수행해야 할 때 적합하다고 판단했습니다. 따라서 한 서버에서만 작업을 수행해야 하는 지금의 요구사항에는 적합하지 않은 해결책이라 생각했습니다.

 

 

 

 

✅ 3. 레디스 Stream + Consumer Group

Redis stream 구조는 토픽이 발행되면 하면 한 서버에서만 소비하게 된다.

레디스의 스트림 구조를 활용하는 방식으로 최종 결정했습니다. 이 방식은 한 서버에서 알림을 등록하면, Consumer 그룹에 속한 서버들이 알림을 나누어 처리합니다. Pub/Sub과 유사하지만 중복 없이 분산 처리된다는 점이 큰 차이점이었습니다.

 

이 구조는 처리 실패 시 ack를 생략하면 다음 읽기 때 다시 처리할 수 있어 재시도가 가능했습니다. 또한 메시지는 레디스에 저장되어 ack 되기 전까지 삭제되지 않기 때문에 유실 방지도 가능했습니다.

 

결과적으로, 전송 실패에 대한 재시도와 중복 없는 단일 처리 등 앞서 정의했던 목적을 모두 충족하는 구조였기 때문에 스트림 기반으로 리팩터링을 진행했습니다.