서론
FCM을 통해 매장의 신규 쿠폰 발행 시 찜한 사용자들에게 정보를 알려주는 기능이 요구되었다.
내가 생각한 플로우는 다음과 같다.
[흐름]
사용자가 매장을 찜 -> 매장이 신규 쿠폰을 발행 -> 해당 매장을 찜한 사용자에게 FCM 알림 발송

구독 등록 기능
FCM 공식문서를 참고하여 구독 기능을 구현했다.
Android에서 주제 메시징 | Firebase Cloud Messaging
4월 9~11일, Cloud Next에서 Firebase가 돌아옵니다. 지금 등록하기 의견 보내기 Android에서 주제 메시징 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. FCM 주제 메
firebase.google.com
https://firebase.google.com/docs/cloud-messaging/android/topic-messaging?hl=ko#java_1
try{
TopicManagementResponse res = FirebaseMessaging
.getInstance()
.subscribeToTopic(Collections.singletonList(member.getFcmToken())
,("market-"+ market.getId().toString()));
} catch (FirebaseMessagingException e) {
throw new CustomException(StatusCode.FCM_SUBSCRIBE_FAIL);
}
.subscribeToTopic을 통해 구독을 할 수 있는데 매개변수로 두 가지 인수를 넘겨줘야한다.
- 디바이스 토큰
- Topic 이름
디바이스 토큰
디바이스 토큰은 List<> 형태로 제공해야해서
Collections.singletonList을 사용해서
문자열인 단일 디바이스 토큰을 List 형태로 전달했다.
Topic 이름
Topic 이름은 매장의 엔티티 이름과 매장 PK를 조합해 'market-{매장PK}'로 설정했다.
구독 취소 기능
구독 취소도 공식 문서를 참고했다.
try{
TopicManagementResponse res = FirebaseMessaging
.getInstance()
.unsubscribeFromTopic(Collections.singletonList(member.getFcmToken())
,("market-"+ market.getId().toString()));
} catch (FirebaseMessagingException e) {
throw new CustomException(StatusCode.FCM_UNSUBSCRIBE_FAIL);
}
구독자 알림 전송 기능
알림을 보낼 메시지에는 쿠폰의 제목과 설명을 넣고, 토픽을 전달해 FCM 알림을 구현했다.
Message message = Message.builder()
.setNotification(
Notification.builder()
.setTitle(coupon.getName())
.setBody(coupon.getDescription())
.build()
)
.setTopic("market-"+ market.getId())
.build();
try {
String response = firebaseMessaging.send(message);
log.info("메세지 발송 성공: {}", response);
}catch (FirebaseMessagingException e) {
throw new CustomException(FCM_SEND_FAIL);
}
FCM 성능 개선 필요
앞선 코드들은 모두 동기식으로 작동한다.
외부 API의 응답을 받을 때까지 블로킹되어, 메인스레드를 점유하고 있다.
이것은 사용자가 많은 환경에서 서버의 성능을 저하시킬 수 있어
해당 부분을 비동기 방식으로 전환하기로 결정했다.
단적으로 예시를 보여주면 다음과 같다.
FCM 구독/구독취소를 적용하지 않은 매장 찜 기능

FCM 구독(동기식)을 적용한 매장 찜 기능

구독취소(동기식) 버전

약 10배 정도의 성능 차이가 나는 것을 볼 수 있다.
성능 개선을 위해 비동기 전환으로의 방식이 맞을까?

구독/구독취소는 여러 사용자가 동시에 요청하고, API를 요청할 일이 많다고 생각한다.
또한 사용자입장에서 고작 찜하나를 누르는데 시간이 오래걸린다면, 불쾌감이 들 것을 우려해 비동기 방식으로의 전환이 합당하다고 생각한다.
하지만, 알림 전송은 매장 사장님이 쿠폰을 발행할 때만 구독자들에게 알림을 전송한다.
어플에 입점할 매장의 수가 초기엔 10개정도 일 것으로 보이고, 쿠폰을 동시에 발행할일은 거의 없다고 생각했다.
그리고 신규 쿠폰을 발행하면 알림이 무조건 가야한다. 하지만 비동기 방식은 실패로 인한 예외처리가 어렵다는 것이다.
따라서 동기 방식으로 유지하는 것이 최선이라고 생각한다.
또한, 동기 방식 유지의 가장 큰 이유는 비동기로 전환할 경우, 발송 실패로 인한 재발송 로직 구현이 어렵다.
@Retryable을 통해 실패 시 재시도를 설정한 만큼 시도할 수 있고, @Recover을 통해 완전 실패 시 롤백 또는 사장님에게 알림 전송 실패 알림을 통해 실패 한정 재알림 api를 요청할 수 있도록 하면 비동기의 단점도 없앨 수 있다고 판단하여,
알림 기능도 비동기로 전환하기로 했다.
비동기 전환 과정
구독,구독취소, 알림 전송 세 기능 모두 전환 과정은 비슷하기에 구독 과정만 작성하겠다.
Firebase의 비동기 결과를 응답받아, 결과를 보려면 ApiFuture<> 타입으로 받은 다음 .get()을 하면된다.
ApiFuture<TopicManagementResponse> apiFuture = FirebaseMessaging
.getInstance()
.subscribeToTopicAsync(Collections.singletonList(member.getFcmToken())
,("market-"+ market.getId().toString()));
하지만 ApiFuture<> 변수에서 바로 .get()을 하면 메인 스레드에서 결과를 보는 것이기 때문에 블로킹 방식이 적용 돼,
동기 방식이랑 별 차이가 없게된다.
apiFuture.addListener(() ->{
try{
TopicManagementResponse response = apiFuture.get();
log.info("토픽 구독 성공: {}", response.getSuccessCount());
} catch (ExecutionException | InterruptedException e) {
log.error("토픽 구독 관련 예외 발생: {}", e.getMessage());
throw new CustomException(StatusCode.FCM_SUBSCRIBE_FAIL);
}
}, asyncConfig.getFcmExecutor());
따라서 ApiFuture의 addListener 콜백 메소드를 구현해,
apiFuture의 결과를 읽어오는 과정을 비동기 스레드에서 처리하도록 설정했다.
이제 리스너는 작업 완료 시에만 콜백을 실행하고, 비동기 스레드에서 동작하기 때문에 apiFuture.get()의 결과를 읽어오는 과정은 블로킹되지 않는다.
@Bean(name = "FcmExecutor")
public Executor getFcmExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(3);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("Async FcmExecutor ");
executor.initialize();
return executor;
}
AsyncConfig 클래스에 비동기 스레드풀을 하나 설정했다.
이 스레드는 FCM 관련 기능에서 콜백함수가 실행될 때 사용된다.
기본 메인 스레드 풀의 크기가 10이기에 3정도로 설정했다.
성능 개선 테스트 - 단일 요청
FCM 구독(동기식)을 적용한 매장 찜 기능

FCM 구독(비동기식)을 적용한 매장 찜 기능

FCM 구독취소(동기식)을 적용한 매장 찜 기능

FCM 구독취소(동기식)을 적용한 매장 찜 기능

FCM 알림전송(동기식)을 적용한 신규 쿠폰 생성 기능

FCM 알림전송(비동기식)을 적용한 신규 쿠폰 생성 기능

정리
구독 등록: 485ms -> 69ms
구독 취소: 308ms -> 68ms
구독자 알림 전송: 583ms -> 44ms
성능 개선 테스트 - 동시 요청
이번에는 동시 요청 상황에서 평균 응답시간의 차이가 어느정도인지 궁금해,
Ngrinder를 이용해서 테스트를 진행했다.
이번에는 매장의 신규쿠폰 발행 시 FCM 알림 전송 기능만 테스트를 진행했다.

50명의 사용자가 동시 요청을 진행하였고, 10초 동안 10번 진행하였다.
동기 방식

- 평균: 2324ms
- TPS: 19.1
비동기 방식

- 평균: 359ms
- TPS: 122.9
결과
FCM 알림 전송 기능을 동기 -> 비동기 방식으로 전환해서 평균 응답시간을
2324ms -> 359ms로 개선했다.
이제 동시 요청이 많은 환경에서도 사용자에게 빠르게 응답할 수 있다.
개선점
FCM 외부 API 요청을 비동기로 진행하면서, 발생하는 예외처리를 어떻게 처리할 것인지와
트랜잭션 롤백 시 이미 외부 API 호출을 요청해버려 철회할 수 없는 문제가 남아있다.
해당 내용들은 @Retry와 @Recover, 그리고 이벤트 처리를 통한 비지니스 로직과 FCM 비동기 외부 API 요청 로직을 분리해 해결하는 글을 다음에 써보도록 하겠다
'쿠러미' 카테고리의 다른 글
| [쿠러미] 쿠폰 발급 시 (동시성 제어 + 중복 체크) 문제해결 (0) | 2025.05.11 |
|---|---|
| [쿠러미] FCM 외부 API 이벤트 기반 비동기 처리 & Retry (0) | 2025.04.01 |
| [쿠러미] Springboot +프로메테우스 + 그라파나 연동 (0) | 2025.03.19 |
| Mysql 프로시저로 더미데이터 생성 (0) | 2025.03.18 |
| [쿠러미] 성능 테스트 시작 - Ngrinder (2) | 2025.03.17 |