[3부] FCM TOPIC 구독기능 개발과 비동기 전환과정 - with Spring boot

황제연·2024년 12월 12일
0
post-thumbnail

서론

웹 푸시 알림 기능개발을 위해 FCM TOPIC 방법을 선택했습니다
이제 FCM TOPIC을 활용하는 방법에 대해 고민해야합니다

FCM TOPIC 설계

TOPIC 설정

먼저 무엇을 TOPIC으로 설정할 것인지 결정했습니다..

이벤트 정보를 사용자에게 전달한다는 것을 근거로 해서,
TOPIC의 대상을 행사의 이벤트로 결정했습니다

TOPIC 명칭 유의점

TOPIC 명칭은 다음 규칙을 따라야 합니다.

  • TOPIC 이름은 다음 정규표현식에 따라야합니다
    - [a-zA-Z0-9-_.~%]+
  • TOPIC 이름은 900자를 넘길 수 없습니다

TOPIC 이름 제약 테스트

공식문서와 StackOverflow 사이트에서 확인했을 때는 위 규칙에 따르지만,
검색했을 때 나오는 일부 정보는 위 규칙과 다릅니다.

어느 것이 맞는지 직접 확인하고자 테스트를 진행했습니다

테스트 결과 '-'을 넣었을 때는 정상적으로 등록이 되었지만,
'!@'을 넣었을 때는 위와 같이 TOPIC 구독 생성실패를 확인할 수 있습니다

이어서 TOPIC 이름의 최대 길이가 궁금했습니다.
최대 900자까지 가능하다고 했기 때문에, 길이가 901인 문자열로 테스트했습니다.

위와같은 에러 문구를 확인했습니다

900자 길이의 문자열로 테스트한 결과 정상적으로 등록되었습니다.

테스트 결론

테스트 결과 공식문서와 StackOverFlow의 규칙이 옳다는 것이 확인되었습니다.
이제 해당 규칙에 따라 TOPIC 명칭을 지정하면 됩니다

참고:

TOPIC 명칭

TOPIC의 경우 유일성을 가져야합니다.
만약 유일성을 확보하지 않으면, TOPIC이 중복 구독되는 문제가 발생할 수 있습니다.

또한 Topic의 이름은 대표성을 가져야합니다.
Topic의 이름은 식별할 수 있는 정보로 구성되어야지 활용할 수 있습니다.

현재 프로젝트에서 유일성과 대표성을 모두 갖추는 요소는 이벤트의 PK라고 판단했고,
조금 더 명확하게 하기위해 대회 PK와 함께 혼합해서 사용하는 것으로 결정했습니다

완성된 TOPIC 명칭 양식은 다음과 같습니다

  • (이벤트PK)-(대회PK)
    두 PK를 구분짓기 위해 '-'을 추가했습니다!

FCM TOPIC 구독과정 개발

FCM TOPIC을 구독하면, 구독한 모든 디바이스에 푸시알림을 발송합니다.

현재 프로젝트에서는 즐겨찾기를 통해 알림 받을 이벤트를 지정합니다.
따라서 즐겨찾기와 함께 구독 추가/취소를 진행하면, 적합한 푸시알림을 전달할 수 있습니다

어디서 개발할 것인가?

이어서 TOPIC 구독과정을 어디서 개발할지 고민했습니다.
프론트엔드에서 구독하고 즐겨찾기 데이터를 넘겨줄 것인지,
백엔드에서 구독하고 요청받은 즐겨찾기 데이터를 저장할 것인지 고민했습니다.

팀에서 선택한 방법은 백엔드에서 모두 관리하는 것이었습니다.
프론트엔드에 TOPIC 구독 과정이 추가된다면, 서버키가 노출될 위험이 있기 때문입니다.

따라서 백엔드에서 TOPIC 구독과정에 대한 개발을 진행하였습니다

FCM TOPIC 구독과정 개발

앞서 즐겨찾기 과정에 구독과정을 포함한다면
사용자에게 적합한 푸시알림을 줄 수 있다고 판단했습니다.

따라서 즐겨찾기의 CREATE 과정 안에 FCM TOPIC 구독 등록과정을 포함하고
DELETE 과정 안에 FCM TOPIC 구독 등록 취소과정을 포함하기로 결정했습니다

즐겨찾기 API 변경

FCM TOPIC기능을 위해 기존 즐겨찾기 API에 다음을 추가했습니다

  • 디바이스 토큰

어떤 디바이스가 구독할 것인지, 취소할 것인지 파악하고 FCM 서버에 요청해야하므로
디바이스 토큰 정보를 API에서 받도록 변경했습니다

CREATE

즐겨찾기 CREATE의 경우 Request Body에 deviceToken 항목을 추가했습니다.


프론트엔드에서 즐겨찾기 CREATE 요청하면
즐겨찾기 정보를 DB에 저장하고,
FCM 서버에 이벤트 TOPIC 구독요청을 처리하도록 개발했습니다.

DELETE

즐겨찾기 DELETE의 경우 PathVariable로 deviceToken을 포함하도록 설정했습니다.


프론트엔드에서 즐겨찾기 DELETE 요청하면
FCM 서버에서 TOPIC 구독 취소하고 즐겨찾기 정보를 DB에서 삭제하도록 개발했습니다

구독 기능 개발

이어서 구독 등록/취소 기능을 개발했습니다.

implementation 'com.google.firebase:firebase-admin:9.4.2'

Firebase-Admin SDK 의존성을 추가하면 구독 기능을 개발할 수 있습니다.

Firebase SDK 초기화 설정 관련 내용은 이후 트러블 슈팅글에서 다룰 예정이며,
알림 서비스 글에서는 자세한 내용을 생략하겠습니다.

구독 등록 기능 개발

FCM 공식문서를 참고하여 다음과 같은 구독 기능을 개발했습니다

TopicManagementResponse res = FirebaseMessaging  
        .getInstance()
        .subscribeToTopic(Collections.singletonList(favoriteRequestDto.getDeviceToken())  
                ,(event.getId().toString()+ "-" + contest.getId().toString()));

.subscribeToTopic의 경우 두가지 인수를 넘겨주어야 합니다

  • 디바이스 토큰
  • Topic 이름

디바이스 토큰

디바이스 토큰은 List<> 형태로 제공되므로,
Collections.singletonList을 사용해서
문자열인 단일 디바이스 토큰을 List 형태로 전달했습니다.

Topic 이름

Topic 이름은 앞서 결정한 양식인 '(이벤트PK)-(대회PK)' 형태로 전달했습니다.

예외처리

해당 메소드는 FirebaseMessagingException에 대한 예외처리가 필요합니다.

try{
	TopicManagementResponse res = FirebaseMessaging  
        .getInstance()
.subscribeToTopic(Collections.singletonList(favoriteRequestDto.getDeviceToken());
}catch (FirebaseMessagingException e) {  
    throw new RuntimeException(e);  
}

따라서 try-catch문으로 잡아 예외처리하도록 개발했습니다.

구독 취소 기능 개발

구독 취소도 공식 문서를 참고해서 개발했습니다.

try{
TopicManagementResponse res = FirebaseMessaging  
        .getInstance().unsubscribeFromTopic(Collections.singletonList(favoriteRequestDto.getDeviceToken())  
                ,(event.getId().toString()+ "-" + contest.getId().toString()));
}catch (FirebaseMessagingException e) {  
    throw new RuntimeException(e);  
}

메소드 명칭만 달라졌을 뿐, 구독 등록 메소드와 크게 달라진 것은 없습니다.

구독 알림 기능 변경

처음 알림 메세지 발송 기능을 개발한 방법은 테스트를 위해
디바이스 토큰으로 푸시알림 메세지를 FCM에 발송하는 방식이었습니다.

이제 Topic 구독 방식으로 진행하기 때문에
토큰이 아닌 토픽을 지정해서 FCM에 발송하도록 변경했습니다.

메세지 형식 개발

String title = "이벤트 시작";  
String body = event.getEventName() + " 행사가 지금 시작됩니다.";  
  
if (messageType.equals("10분 전 알림")) {  
    title = "이벤트 시작 10분 전";  
    body = event.getEventName() + " 행사가 10분 후에 시작됩니다.";  
}  

먼저 위와 같이 10분전 알림과 시작 알림을 구분하여
메세지의 title과 body를 설정했습니다

Message message = Message.builder()  
        .setNotification(  
                Notification.builder()  
                        .setTitle(title)  
                        .setBody(body)  
                        .build()  
        )  
        .setTopic(event.getEventId() + "-" + event.getContestId())  
        .build();

앞서 설정한 양식으로 메세지를 만들고, Topic을 전달하도록 개발했습니다.

try {  
    String response = fcm.send(message);  
    log.info("메세지 발송 성공: {}", response);  
}catch (FirebaseMessagingException e) {  
    throw new RuntimeException(e);  
}

완성한 Message를 발송하는 코드입니다.
구독 등록/취소과정처럼 FirebaseMessagingException 예외를 처리하도록 개발했습니다.

구독 기능 테스트

이어서 구독 등록/취소 과정이 정상적으로 동작하는지 테스트했습니다.

먼저 등록할 때, 정상적으로 등록했다는 로그를 확인할 수 있습니다.

이후 지정한 시간에 원하는 이벤트 웹 푸시알림이 오는 것을 확인했습니다

구독 취소의 경우도 정상적으로 취소되었다는 로그를 확인할 수 있습니다.
이어서 알림이 예정시간에 오지 않는 것도 확인했습니다.

FCM TOPIC 구독기능 성능 개선

앞선 코드를 다시 살펴보면 외부 API인 FCM과 통신하는 경우가 3가지 존재합니다.

  • FCM 알림 발송과정
	fcm.send(message);  
  • FCM Topic 구독 과정
FirebaseMessaging.getInstance()
.subscribeToTopic(Collections.singletonList(favoriteRequestDto.getDeviceToken())
,(event.getId().toString()+ "-" + contest.getId().toString()));
  • FCM Topic 구독 취소 과정
FirebaseMessaging .getInstance()
.unsubscribeFromTopic(Collections.singletonList(favoriteRequestDto.getDeviceToken())
                ,(event.getId().toString()+ "-" + contest.getId().toString()));

3가지 과정 모두 동기 방식으로 동작합니다.
외부 API의 응답을 받을 때까지 블로킹되며, 메인스레드를 점유하고 있고
이것은 사용자가 많은 환경에서 서버의 성능을 저하시킬 수 있습니다

따라서 해당 부분을 비동기 방식으로 전환하기로 결정했습니다.

비동기 방식으로의 전환이 합당한가?

진행하기 앞서 비동기 방식으로의 전환이 합당한가를 고민을 했습니다.

구독 등록 및 취소과정은 여러 사용자가 주체가 되어 요청하므로,
비동기 방식으로 전환하는 것이 합당하다고 생각합니다.

그러나 웹 푸시 알림 발송과정은 서버가 주체가 되어 토픽만 발송하기 때문에
성능 저하를 일으킬만한 요소가 없다고 생각합니다.

동일한 시간대에 발송하는 이벤트 수는 많아봐야 100개 정도로 예상되므로
비동기로 전환해도 성능의 변화가 크지 않을 것이라 판단했습니다.
또한 비동기로 전환할 경우, 발송 실패로 인한 재발송 로직 구현이 어렵습니다.

따라서 구독 등록/취소 과정은 비동기 방식으로 전환하기로 결정하였고,
웹 푸시 알림 발송 과정은 동기 방식을 유지하기로 결정했습니다

비동기 전환 과정

Firebase-admin SDK 라이브러리에서 비동기 메소드를 지원하기 때문에,
비동기로 전환하는 것은 큰 어려움이 없었습니다

  • 구독 등록
FirebaseMessaging  
        .getInstance()  
        .subscribeToTopicAsync(Collections.singletonList(favoriteRequestDto.getDeviceToken())  
        ,(event.getId().toString()+ "-" + contest.getId().toString()));
  • 구독 취소
FirebaseMessaging  
        .getInstance()  
        .unsubscribeFromTopicAsync(Collections.singletonList(deviceToken)  
                ,(favorite.getFavoriteEvent().getId().toString()  
                        +favorite.getFavoriteContest().getId().toString()));

Async가 붙은 메소드로 변경하면 비동기 방식으로 FCM에 요청 가능합니다.

비동기 응답 처리

Firebase의 비동기 결과를 응답받아, 로그나 예외처리로 추가 구현하기 위해서는
ApiFuture<> 타입으로 받은 다음 변수로 활용해야합니다.

ApiFuture<TopicManagementResponse> apiFuture = FirebaseMessaging  
        .getInstance()  
        .subscribeToTopicAsync(Collections.singletonList(favoriteRequestDto.getDeviceToken())  
                ,(event.getId().toString()+ "-" + contest.getId().toString()));

ApiFuture 타입 변수로 받는 것까지는 괜찮으나, 이것을 꺼낼때 문제가 발생합니다.

log.info("{}", apiFuture.get().getSuccessCount());

.get()으로 결과를 꺼낼때, 블로킹 방식으로 동작합니다.

ApiFuture은 Future를 상속했습니다.

Future의 get 메소드와 공식문서를 확인했을 때 블로킹된다고 명시되어 있습니다.
따라서 ApiFuture 타입의 변수에서 get()으로 꺼낼 때도 블로킹됩니다.

이렇게 되면 원래 의도했던 비동기 논블로킹 방식이 아니라
비동기 블로킹 방식이 되어 성능 개선 이점을 얻을 수 없습니다.

이 문제를 해결하기 위해서는 콜백 메소드를 사용해야합니다.

ApiFuture의 addListener

따라서 ApiFuture의 addListener 콜백 메소드를 구현하기로 결정했습니다.
Runnable 객체는 처리할 동작을 정의하고
Executor는 이것을 처리할 비동기 스레드를 정의합니다.

apiFuture.addListener(() ->{  
    try{  
        TopicManagementResponse response = apiFuture.get();  
        log.info("토픽 구독 성공: {}", response.getSuccessCount());  
    } catch (ExecutionException | InterruptedException e) {  
        log.error("토픽 구독 관련 예외 발생: {}", e.getMessage());  
        throw new RuntimeException(e);  
    }  
}, asyncConfig.getSubscribeFcmExecutor());

위와 같이 apiFuture의 리스너를 등록했습니다.
apiFuture의 결과를 읽어오는 과정을 비동기 스레드에서 처리하도록 설정했습니다

이제 리스너는 작업 완료 시에만 콜백을 실행하고, 비동기 스레드에서 동작하기 때문에 apiFuture.get()의 결과를 읽어오는 과정은 블로킹되지 않습니다!

@Bean(name = "subscribeFcmTopic")  
public Executor getSubscribeFcmExecutor() {  
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();  
    executor.setCorePoolSize(10);  
    executor.setMaxPoolSize(50);  
    executor.setQueueCapacity(100);  
    executor.setThreadNamePrefix("Async SubscribeFcmExecutor ");  
    executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());  
    executor.initialize();  
    return executor;  
}

AsyncConfig 클래스에 비동기 스레드풀을 하나 더 설정했습니다.
이 스레드는 구독 등록/취소과정에서 콜백함수가 실행될 때 사용됩니다.

구독 등록과 취소도 동일하게 많은 사람들이 이용할 것이라 예상했기 떄문에
인증 이메일 발송에서 사용하는 스레드 풀 크기와 동일하게 설정했습니다.

이제 ApiFuture의 Listener를 활용해서 비동기 논블로킹 방식으로 응답을 받을 수 있습니다!

성능개선 테스트

구독 등록/취소 로직을 동기 -> 비동기로 전환하면서
성능이 얼마나 개선되었는지 테스트를 진행하였습니다.

단일 요청

먼저 단일 요청에 대해 응답시간이 얼마나 개선되었는지 테스트했습니다.

동기 방식

구독 등록

응답시간: 919ms

구독 취소

응답시간: 596ms

비동기 방식

구독 등록

응답시간: 194ms

구독 취소

응답시간: 73ms

정리

구독 등록: 919ms -> 194ms
구독 취소: 596ms -> 73ms

동기방식에서 비동기 방식으로 전환했을 때, 응답시간이 훨씬 빠른 것을 확인할 수 있습니다!

동시 요청 테스트

이번에는 동시 요청 상황에서 평균 응답시간의 차이가 어느정도인지 테스트했습니다.
Apache Jmeter를 이용해서 테스트를 진행했습니다.
이번에는 구독 등록 과정만 테스트를 진행했습니다

기본 설정

  • 쓰레드 수: 50
  • Ramp-up: 1s
  • Loop-count: 10

50명의 사용자가 동시 요청하며,
이것이 10초동안 반복되는 상황으로 설정했습니다.

동기 방식

구독 등록

  • 평균: 2393ms
  • 최소: 99ms
  • 최대: 3744ms

비동기 방식

구독 등록

  • 평균: 146ms
  • 최소: 13ms
  • 최대: 728ms

동시요청 테스트에서도
비동기 방식이 동기 방식보다 평균 응답속도가 빠른 것을 확인할 수 있습니다.

결과

구독 기능을 동기 -> 비동기 방식으로 전환해서 평균 응답시간을
2393ms -> 146ms로 개선했습니다!
이제 동시 요청이 많은 환경에서도 사용자에게 빠르게 응답할 수 있습니다!

마무리

FCM TOPIC 구독과정을 통해,
즐겨찾기 한 이벤트 정보를 웹 푸시알림으로 전달할 수 있습니다.

이제 남은 과제는 웹 푸시알림 시간을 어떻게 판단할 것인지와
FCM TOPIC 구독과정에서 발생하는 예외처리를 어떻게 처리할 것인지입니다.

해당 내용은 4부에서 이어서 작성하겠습니다

참고:

profile
Software Developer

0개의 댓글