Search
🔔

FCM Bulk Notification 트러블 슈팅

1. 서론

최근에 회사에서 에픽을 진행하면서 전체 유저를 대상으로 푸시 노티피케이션을 보내는 요구사항이 있었다. 원래라면 특정 Trigger 조건에 따라 조건이 만족되면, Queue를 통해 이벤트를 발행하고 백그라운드 서버(우리는 워커 서버라고 부른다)에서 이벤트를 consuming 하여 노티를 백그라운드에서 보내는 형태로 구현했을 터이다.
그러나, 에픽 기간이 상당히 tight해서 우리는 API 서버에서 관리자 API로 우회하여 구현하는 형태를 진행하였다. async 함수에 await를 적용하지 않음으로써, 스레드가 해당 비즈니스 로직이 전부 완료될 때까지 기다리지 않고 백그라운드에서 bulk notification이 보내지도록 구현 방향을 설정하였다.
해당 작업은 함께 에픽에 투입된 직장 동료분께서 구현을 하시고 배포가 진행되었다. 테스트 환경에서는 이상이 없었으나, 운영 환경에서 노티를 보내보니, 많은 노티가 유실되고 (많은 내부 구성원들이 노티를 받지 못했다는 제보 받음) 로그가 확인이 안된다는 이슈가 발생하여 추가적인 트러블 슈팅을 직접 하게 되었다.

2. 기존 구현 방식

노티 서비스
Firebase FCM
발송 주체
API 서버에서 관리자 API를 통해 노티를 발송함
빠른 구현을 위하여 우선적으로 대응 진행
내부 로직
발송 로직은 await를 걸지 않고, 바로 request가 return 가능하게 로직 구현
node의 background thread에서 노티 발송이 진행된다.
결과
서비스 레이어에 작성한 로그 정상 출력
앰플리튜드 로그 수집 정상 확인
Firebase 로그는 확인 안됨

3. 노티 테스트

3.1. Stage 환경 재현

약 4000명의 전체 유저로 발송 진행
결과
디바이스 푸시 노티 수신 확인 완료
서비스 레이어 로그 정상 출력 확인 완료
Firebase 로그는 확인 여전히 안됨

4. 가설 검증

4.1. async / await 오동작

async 내부에서 await를 걸지 않을 경우, 정상적으로 로직이 내부적으로 잘 동작하는지 테스트
유사한 방식으로 샘플 코드를 작성하여 API를 호출하였을 때, 정상 확인
샘플 코드
다만, await를 걸지 않아 로직이 백그라운드에서 동작하다가 알수 없는 이유로 에러가 발생한다면, 에러가 핸들링되지 않아 어플리케이션 전체에 까지 이슈가 전파될 수 있을 수도 있다고 판단하여, 함수를 try / catch로 감싸주고 에러 발생할 때, 에러 로그를 출력하게 끔 하였다.
결과: 이상 없음. + try / catch로 예외 처리 (+로그 추가)

4.2. FireBase 로그 출력 정상 동작 검증

현재 로그 양식
chunk 사이즈에 맞게 파이어베이스로 메세지 발송 후, .then 을 통해 로그 출력
await firebaseAdmin .messaging() .sendAll(chunkMessage) .then((res) => { logger.log( `[SendPushNotification] response: ${JSON.stringify(res)}, message: ${JSON.stringify(messages)}`, ); })
JavaScript
복사
로그 출력 검증
Prod 환경
single 노티, chunk 노티 둘다 정상 출력 확인 완료
Stage환경
worker를 통한 chunk 노티 정상 출력 확인 완료
API를 통한 single 노티 정상 출력 확인 완료
API를 통한 chunk 노티 확인 안됨
추가 이슈
cloudwatch log event size 초과
성향 테스트 노티 발송 API에서만 chunk 노티에 대한 firebase 로그만 출력되지 않아 트러블 슈팅하는 도중 1가지 의심되는 이슈를 발견 할 수 있었다.
Cloudwatch에서 허용하는 log event size를 초과하는 경우 로그가 저장되지 않는다.
Log event size가 최대 256KB로 제한되어 있다. (관련 링크)
Cloudwatch 로그를 출력하기 위해 mesage의 길이를 잘라서 iteration을 돌며 chunk 노티를 보내고, 로그를 출력하는 로직이 기존에 구현되어 있었다.
그러나, 로그 메세지에 변수 할당이 잘못되어, slice 된 메세지 배열이 아니라, 전체 메세지 메세지 배열이 출력되고 있는것을 확인했다. 따라서, sliced 된 chunk 메세지 배열이 출력되도록 휴먼에러를 수정하였다.
이후, Stage 환경에서 테스트를 해보니 API를 통해 chunk 노티를 보내더라도 cloudwatch에서 로그를 정상적으로 확인할 수 있었다.
추가 대응 작업 내용: cloudwatch log event size를 초과하지 않게 로그 메세지 수정

4.3. Firebase Fanout throttling 이슈

지난주에 노티를 발송했던 시간을 확인해보니, worker 서버에서 백그라운드 job으로 bulk notification을 보내는 시간대와 100% 일치한 것을 확인되었다. 이 시간대와 맞물려 한번에 너무 많은 노티를 발송하다보니 FCM에서 처리가능한 quota나 limit에 문제가 발생한 것은 아닐까 라는 가설을 세워보았다.
FCM 공식 문서에서 Fanout throttling에 대한 내용을 발견할 수 있었다. 그 내용을 간단히 요약하면 다음과 같다.
10,000 QPS (Query Per Second) 가 정확한 수치는 아니지만, 큰 부하를 줄 수 있는 수치라고 간주한다. (의역) (A fanout rate of 10,000 QPS for an individual project is not uncommon, but that number is not a guarantee and is a result of the total load on the system)
Firebase 에서 동시에 실행되는 메시지 팬아웃 수를 최대 1,000으로 제한함 (We limit the number of concurrent message fanouts per project to 1,000)
팬아웃 스피드 최적화를 위해 활성화된 팬아웃 1개를 가질 것을 권장 (The recommended way to maximize your fanout speed is to only have one active fanout in progress at a time)
여기서 우선 팬아웃이라는 개념이 정확히 이해되지 않았다. 공식문서에서 팬아웃을 다음과 같이 정의한다.
Message fanout is the process of sending a message to multiple devices, such as when you target topics and groups, or when you use the Notifications composer  to target audiences or user segments.
위 내용을 요약하자면 “여러 기기로 메세지를 전송하는 프로세스” 라고 할 수 있는데, 간단히 정리하자면, 노티 메세지를 보내는 작업이라고 개인적으로 정의를 내렸다.
아래의 예시로 비유하자면, 총 10번의 노티 메세지 전송 작업이 수행되므로 총 팬아웃은 수는 10이라고 할 수 있을 것이다.
for (let i = 0; i < 10; i++) { await firebaseAdmin.messaging().sendAll(chunkMessage); }
JavaScript
복사
활성화된 팬아웃 (현재 동작중인 팬아웃)을 한번에 1개만 가질 것을 권장한다는 공식문서 내용에 따라, 노티를 보내는 시간대가 다른 대량 노티를 보내는 시간대와 겹치지 않으면 노티 유실이 발생할 잠재 요소들이 줄어들 것이라고 생각하였다.
또한, QPS 및 concurrent message fanout을 최소화 하기 위해, 노티를 발송하기 위해 조회하는 원시 데이터의 chunk 수를 조금 하향 조정하여 (질의를 더 자주하게 될꺼니까), 팬아웃 수가 너무 급증하지 않도록 조절하였다.
활성화된 팬아웃 수 등을 metric으로 모니터링 할 수 있는 방법도 찾아봤으나, 아쉽게도 존재하지 않았다.
추가 대응 작업 내용
1.
노티 발송 시간대가 다른 대량 노티 발송 시간과 겹치지 않게 수정
2.
팬아웃 수 급증하지 않도록 속도 조정

5. 결과 검증

Stage 환경에서 동일하게 재현을 하고 싶었으나, FCM 유효 토큰을 가진 Prod 환경만큼 동일한 유저 수를 만드는 등의 현실적인 조건 등에 부딪혀 결과는 Prod 환경에서 노티를 다시 발송하여 결과를 검증하기로 결정 지었다.
3번 가설 검증에 따라 작업한 내용을 다시한번 요약하자면 다음과 같다.
1.
async / await 매커니즘에는 문제가 없다. 따라서 API 서버에서 노티를 보내는 async 함수에 await를 걸지 않고 노티를 보내는 방식은 그대로 유지한다. 다만, 백그라운드에서 동작하는 노티 발송 로직에 문제가 발생했을 때 이슈 전파를 막기 위해 try / catch로 감싸주고 에러 로그를 출력한다.
2.
클라우드 와치에서 로그가 정상적으로 출력될 수 있도록 로그 메세지 크기를 조정한다.
3.
팬아웃 이슈 대응
a.
다른 대량 노티 발송 시간대와 겹치지 않는 시간대에 노티를 발송한다. (외부 변수 차단)
b.
팬아웃 수가 급증하지 않도록 노티 발송 로직 속도를 조정한다.
이를 통해 노티를 발송하였고, 결과적으로 나를 포함한 대부분의 내부 구성원분들이 노티를 받았으며, 클라우드 와치 로그를 통해 노티가 정상적으로 Firebase로 발송되었는지를 확인할 수 있었다.

6. 후기 / 회고

노티 관련된 에픽을 진행하다보면 제어하지 못하는 부분에서 이슈가 종종 발생하는 것이 다소 아쉽다고 느껴져왔다. 예를 들어, 서버에서 노티를 실제로 정상적으로 발송하였다고 뜨는데도 불구하고, 디바이스에서 노티가 오지 않는 상황이 발생한 경우도 있었다.
써드파티 서비스를 활용하다보니, 내부 로직이 은닉화 되어있고, 노티 발송의 전체 프로세스를 우리 스스로 통제하지 못하다보니, 특정 이슈가 발생하면 디버깅부터 시작해서 많은 부분이 난감하게 느껴졌다.
최근 스터디로 공부중인 데이터 중심 어플리케이션 챕터 8. 분산 시스템 내용을 보면 분산 시스템은 네트워크를 통해 노드끼리 통신하는데, 네트워크의 불확실성으로 인해 노드 동작 여부를 판단하기 어렵다는 내용이 나온다. 비동기 패킷 네트워크도 유사한데, 전송측에서는 패킷이 전송되었는지, 패킷이 언제 도착하는지, 도착을 보장하는지를 알 수 없다고 한다.
문득 이 포스팅을 작성하다 보니, 노티도 분산 시스템과 유사하다는 생각이 머리를 스쳐 지나갔다. 서버의 책임은 노티 메세지를 FCM 서버로 보내는 것으로 끝이 나다보니, 노티가 실제로 디바이스까지 도달하는지, FCM서버에서 어떤일이 생기는 지 등을 알 수 없고 그 이후에 발생하는 이슈들을 Tracking 하는 것이 쉽지 않았다.
또한, 위와 같은 대용량 노티 발송 같은 로직은 로컬 환경이나 내부 테스트 등에서 재현하기 매우 어렵다보니, Stage 환경에서 다양한 조건의 노티 테스트를 충분히 수행 할 수 있는 환경을 구축하는 것도 중요하다고 생각했다. (유효한 노티 토큰을 대용량으로 생성하는 방법이 있을 지 찾아보면 좋을 것 같다)
이번에는 빠르게 위와 같은 방법으로 이슈를 대응했지만, 조만간 추가 고도화 진행 해보려고 한다.
firebaseAdmin.sendAll() 의 chunk message 최적화
문제가 되는 곳은 팬아웃이므로, 한번의 팬아웃에 가능한한 많은 수의 message를 보낸다면 이슈가 근본적으로 해결되지 않을까 싶다.
로그 메세지 크기 조정
메세지 발송 후, 해당 메세지 내용을 모두 로그로 출력하고 있다보니 로그 메세지가 매우 커져서 클라우드와치에도 로그 출력이 되지 않는 이슈가 발생한건데, 로그 메세지 크기를 제한할 수 있는 방법을 모색해 볼 수 있을 것 같다.
예)
로그를 여러개 나눠서 출력하기 (반복문 등 활용)
꼭 필요한 데이터만 로그로 출력하기
다양한 노티 테스트를 검증할 수 있는 테스트 환경 구축
위에도 언급했지만, 유효한 노티 토큰을 만들 수 있는 방법을 찾을 수 있다면 Prod 환경이 아니더라도 충분히 테스트를 Stage 환경 등에서 가능할 것이라고 본다.
궁극적으로는 메세지 큐(카프카)를 이용하여, 노티 메세지를 담당하는 토픽을 생성하여 노티 발송 전용 worker를 통해 메세지를 consuming 하여 노티를 발송하는 방향으로 구현이 필요할 것 같다. 이러한 방법을 통해, FCM 공식문서에서도 나와 있듯이, 활성화된 팬아웃 (active fanout) 및 동시에 실행중인 팬아웃 (concurrent fanout)로부터 발생가능한 잠재 리스크를 최소화 할 수 있을 것으로 판단된다.