오늘 포스팅은 실무에서 리딩했던 프로젝트인 메시징 플랫폼에서 FCM SDK 버전 업그레이드로 성능 향상을 이루었던 에피소드를 기록하려고 한다.
담당하고 있는 서비스는 목표 관리 앱 서비스와 스토어 웹 서비스 2개가 있으며 이 2개의 서비스 회원 통합을 계획하고 있다. 그에 따라 앱 서비스의 회원 수가 약 15만 이상 증가되며, DAU 역시 증가할 것으로 예측하고 있다.
앱 서비스에서는 목표 관리라는 특성에 맞게 다양한 리마인드 알림, 이벤트 알림 등이 발생하고 있다. 하루 평균 약 38만 건의 이벤트가 발생하며, 동시 발송의 경우 최대 13만 건이 발생하고 있다.
회원 통합 이후 알림 이벤트의 수가 더욱 증가할 것으로(약 2배 이상) 예상하고 있다.
기존에 사내에서 판매하는 디지털 상품 발송 수단과 광고(홍보)를 위한 문자 발송 등이 파편화되어 있고, 외부 솔루션의 비용이 각기 달랐다. 또한, 커머스 서비스와의 연계 및 회원 통합 과정에 필요한 회원 인증 절차(가령 휴대폰 인증이나 이메일 인증 등)에 따라 메일과 문자 발송이 필요해졌다.
앱 서비스와 스토어 서비스 등에서 공통의 필요성이 제기되어 이를 통합하는 프로젝트를 시작하게 되었다. 이 과정에서 외부 솔루션 이용료를 최적화하는 것을 함께 진행했다.
푸시 알림 발송 최소 8.6만건, 최대 25만건 지원이 가능해야 한다. (단, 지연 시간은 최대 1시간을 허용한다.)
플랫폼에 상관 없이 메일 발송을 지원해야 한다.
플랫폼에 상관 없이 문자 발송을 지원해야 한다.(단, 지연 시간은 최대 5분을 허용한다.)
메일/문자 발송 시, 첨부파일을 지원해야 한다.
메일/문자 발송 이후 발송 상태를 추적할 수 있어야 한다.
각 이벤트를 발행하는 서버에 장애가 나더라도 발송 서버에 영향을 끼쳐서는 안된다.
역으로 발송 서버에 장애가 나더라도 이벤트를 발행하는 서버에 영향을 끼쳐서는 안된다.
운영 환경 서버 스펙 기준, 발송 서버 1대 기준, CPU 최고 사용률이 50%를 넘어서는 안된다. (푸시 알림 약 13만 건 동시 발송 기준, CPU 최고 사용률 44%.)
운영 환경의 서버 스펙은 공개할 수 없습니다.
본 프로젝트의 목표인 앱 푸시 알림 동시 발송 최대 25만 건을 지원할 수 있어야 하며, 그 과정 중에 서버의 다운은 없어야 한다.
현재 앱 푸시 알림은 아래 그림과 같이 설계 및 개발되어 있다.

이벤트 발행 서버에서 알림 이벤트를 DB에도 적재하고 카프카에도 발행한다. 이때, 알림 내역을 만들기 위해 필요한 정보를 레디스에서 조회하도록 하여 지연 시간을 단축하도록 했다.
이후, 카프카를 구독하는 발송 서버에서 FCM 서버로 멀티 캐스트 형태로 요청을 보낸다.
FCM Admin SDK를 이용해 Multicast 요청을 보낼 때, 타겟 유저는 최대 500건이라고 공식문서에 기재되어 있다.
하지만, 필자는 그 보다 더 작은 단위를 채택해서 더 FCM 서버 쪽에서 더 빠르게 처리할 수 있도록 했다.
카프카를 발행 서버와 발송 서버 사이에 둠으로써 비동기적으로 처리를 하고 있으며, 단 건 발송이 아닌 다 건 발송(멀티 캐스트)으로 묶음 단위 요청을 FCM에 보내고 있다.
여기서 더 성능적 개선을 이루기 위해 어떤 액션을 취할 수 있을지 고민해보았다.
1. 발송 서버를 늘린다. (스케일 아웃)
2. 발송 서버 내 FCM 발송 유스케이스 처리를 비동기 병렬 처리를 한다.
이 방법은 당장의 해결에는 도움이 될 수 있지만, 인프라 추가 비용이 발생한다는 점과 가까운 시일 내에 이벤트가 급증하거나 회원 수가 더 늘면 또 다시 개선점으로 직면해야 하기에 최후의 수단으로 생각하기로 했다.
이 방법을 우선 채택하여 25만 건의 이벤트를 동시에 발행시켜 부하 테스트를 진행해보았다.
[환경 구성]
AWS 환경에서 운영과 같은 스펙으로 발송 서버를 구성해 테스트를 수행했다.
[결과]
알림 이벤트가 FCM 서버로 가는 도중 실패하는 건들이 다수 발견되었으며, 발송 서버의 CPU 사용률이 최대 98%를 찍었다. 이벤트 처리 완료까지는 약 10분이 소요되었다.
[회고]
이벤트 알림 한 번 보내고 나면, 서버가 언제 죽을지 모르는 상태가 될 수 있는 상태가 되었으며 발송 실패하는 건들이 다수 발견되었다는 점에서 이 방법은 채택하지 않기로 했다.
카프카를 구독하는 발송 서버는 Reactor 기반으로 구성이 되어 있으며, 카프카 토픽에 레코드가 쌓이면 구독해서 처리한다.
이때, 발송 유스케이스에 코루틴을 이용해 비동기 처리를 해두면서 여러 개의 코루틴이 병렬적으로 FCM에 발송 요청을 보내며 CPU 사용률도 치솟고, FCM 서버에는 팬아웃 현상을 야기한 것이다.
즉, 너무 빠른 시간 간격으로 대량의 요청을 FCM으로 보내려고 한 것이 문제가 된 것이다.
팬아웃 제한 (아래는 공식 문서 내용 일부 발췌)
메시지 팬아웃이란 주제 및 그룹을 타겟팅하거나 알림 작성기를 사용하여 잠재고객 및 사용자 세그먼트를 타겟팅하는 경우와 같이 여러 기기로 메시지를 전송하는 프로세스입니다.
메시지 팬아웃은 즉각적인 프로세스가 아니므로 경우에 따라 여러 개의 팬아웃을 동시에 진행하는 것이 가능합니다. 프로젝트당 동시 메시지 팬아웃 수는 1,000개로 제한되며, 제한 수에 도달하면 일부 진행 중인 팬아웃이 완료될 때까지 추가 팬아웃 요청이 거부되거나 팬아웃 요청이 지연될 수 있습니다.
실제 가능한 팬아웃 속도는 동시에 팬아웃을 요청하는 프로젝트 수의 영향을 받습니다. 개별 프로젝트의 팬아웃 속도 10,000QPS는 드물지 않지만 이 속도는 보장되지 않으며 시스템 전체 부하에 따라 달라집니다. 사용 가능한 팬아웃 용량은 팬아웃 요청이 아닌 프로젝트 간에 분할됩니다. 따라서 프로젝트에 진행 중인 팬아웃이 두 개 있으면 각 팬아웃 속도는 사용 가능한 팬아웃 속도의 절반이 됩니다. 따라서 팬아웃 속도를 최대화하려면 활성 팬아웃을 한 번에 하나만 진행하는 것이 가장 좋습니다.
참고 링크 : https://firebase.google.com/docs/cloud-messaging/concept-options?hl=ko#fanout_throttling
요구사항에서 앱 푸시 25만 건 발송이 완료되기까지 최대 1시간을 허용하기 때문에, FCM 발송 서버로의 요청 간 딜레이를 주었다.
추가 액션 1. 위 현행 시스템 그림에서 발행 서버 측에서 발행 시점에 딜레이를 준다.
추가 액션 2. 발송 서버가 카프카로부터 구독 시, 백프레셔의 Request 전략으로 처리할 수 있는 만큼의 이벤트를 구독하도록 한다.
위 2가지 액션을 통해 팬아웃 현상은 줄었지만 성능 개선은 크게 이루어지지 않았다.
그러던 중, FCM Admin SDK 자바 공식 깃헙을 보면서 눈에 들어온 것이 있다.

9.4.0 버전부터는 기본 Transport가 ApacheHttp2Transport로 변경된 것이다.
기존에 사용하던 9.2.0과 9.4.0의 차이는 다음과 같다.
FCM의 HTTP/2 도입은 배치 엔드포인트 지원 중단으로 인한 요청량 증가를 효율적으로 처리하기 위해 이루어졌다.
HTTP/2의 특징인 멀티플렉싱과 헤더 압축 덕분에 요청 처리 속도가 빨라졌고, 25만 건 동시 발송 부하 테스트 결과 CPU 사용률은 기존 40%에서 10%로 감소, 발송 지연 시간은 약 20분으로 단축되었습니다.
깃헙 위키 링크 : https://github.com/firebase/firebase-admin-java/wiki/HTTP-Transport#new-default-transport-apachehttp2transport
관련 PR 링크 : https://github.com/firebase/firebase-admin-java/issues/834#issuecomment-1890969169
네트워크의 변경만이 성능 개선을 이루어낸 것인지 궁금해서 버전별 실제 발송을 구현한 함수 내부를 들여다보았다.
이 함수를 타고 내려가면 아래 함수가 실질적 동작을 담당한다.
private ApiFuture<BatchResponse> sendEachOpAsync(List<Message> messages, boolean dryRun) {
List<Message> immutableMessages = ImmutableList.copyOf(messages);
Preconditions.checkArgument(!immutableMessages.isEmpty(), "messages list must not be empty");
Preconditions.checkArgument(immutableMessages.size() <= 500, "messages list must not contain more than 500 elements");
List<ApiFuture<SendResponse>> list = new ArrayList();
Iterator var5 = immutableMessages.iterator();
while(var5.hasNext()) {
Message message = (Message)var5.next();
ApiFuture<SendResponse> messageId = this.sendOpForSendResponse(message, dryRun).callAsync(this.app);
list.add(messageId);
}
ApiFuture<List<SendResponse>> responsesFuture = ApiFutures.allAsList(list);
return ApiFutures.transform(responsesFuture, (responses) -> {
return new BatchResponseImpl(responses);
}, MoreExecutors.directExecutor());
}
위 함수는 FCM 서버로 요청을 보내는 작업이 완료된 후, ApiFutures.transform을 사용해 결과를 변환한다.
그리고 이 메서드는 호출 시 바로 ApiFuture<BatchResponse>를 반환하며, 호출자는 반환된 ApiFuture를 통해 결과를 비동기로 기다릴 수 있다.
public ApiFuture<BatchResponse> sendEachAsync(@NonNull List<Message> messages, boolean dryRun) {
return this.sendEachOp(messages, dryRun).callAsync(this.app);
}
private CallableOperation<BatchResponse, FirebaseMessagingException> sendEachOp(List<Message> messages, final boolean dryRun) {
final List<Message> immutableMessages = ImmutableList.copyOf(messages);
Preconditions.checkArgument(!immutableMessages.isEmpty(), "messages list must not be empty");
Preconditions.checkArgument(immutableMessages.size() <= 500, "messages list must not contain more than 500 elements");
return new CallableOperation<BatchResponse, FirebaseMessagingException>() {
protected BatchResponse execute() throws FirebaseMessagingException {
List<ApiFuture<SendResponse>> list = new ArrayList();
Iterator var2 = immutableMessages.iterator();
while(var2.hasNext()) {
Message message = (Message)var2.next();
ApiFuture<SendResponse> messageId = FirebaseMessaging.this.sendOpForSendResponse(message, dryRun).callAsync(FirebaseMessaging.this.app);
list.add(messageId);
}
try {
List<SendResponse> responses = (List)ApiFutures.allAsList(list).get();
return new BatchResponseImpl(responses);
} catch (ExecutionException | InterruptedException var5) {
throw new FirebaseMessagingException(ErrorCode.CANCELLED, FirebaseMessaging.SERVICE_ID);
}
}
};
}
위 함수는 FCM 으로 요청 보내는 작업을 ApiFutures.allAsList(list).get()를 호출해 모든 작업이 완료될 때까지 현재 스레드가 블록(block)된다.

9.4.0 버전부터 내부 구현 로직도 변경되면서 성능 개선에 영향을 미쳤다는 사실까지 알게 되었다.
최종적으로 3번 액션 FCM Admin SDK Java 버전 업그레이드(9.2.0 -> 9.4.0)을 선택하였고 발송 서버의 스케일 아웃 없이 운영 배포까지 진행했다.
약 1개월 간, 진행된 이 프로젝트는 일정 내에 출시가 되었고, 앱 푸시 알림 발송 유스케이스 쪽에 성능 개선이 일어나면서 기존 시스템보다 더 빠른 발송 그리고 더 적은 CPU 사용률을 보이고 있다.
TF로 진행된 이 프로젝트로 앱 서비스에서는 빠른 시기에 회원 통합 파트에서 인증 수단으로 활용될 기반이 마련되었고, 스토어 서비스에서는 다양한 마케팅 푸시 수단이 마련되었다.
카프카를 이용한 메시징 플랫폼 프로젝트를 초반 설계부터 운영 단계에서의 개선, 고도화 과정을 거치면서 분산 환경과 이벤트 기반 시스템에 대한 이해를 다지는 경험이 되었다.
또한, 사용하는 라이브러리의 릴리즈 노트를 살펴보며 내부 구현 로직도 들여다보고 다른 개발자들이 남긴 PR과 이슈 등도 보며 내가 직면한 상황에 해결책이 정말 없는 것일까를 탐색하는 경험에서 라이브러리 하나를 선택할 때, 좀 더 면밀히 살펴보고 문제 상황에 맞는 것을 선택할 수 있는 안목(혹은 역량)을 길러야겠다는 생각도 들었다.