본문 바로가기

Spring

FCM의 구조와 이를 구현하기 위한 고민(Feat : FCM토큰관리와 전송 메서드)

FCM 구조

무료로 메시지를 보낼 수 있는 교차 플랫폼 메시징 클라우드 서버

 

장점

  • Firebase 플랫폼과 통합되어 있어 설정과 사용이 간편
  • Firebase 콘솔에서 설정을 쉽게 관리할 수 있음
  • 안드로이드, iOS, 웹을 포함한 여러 플랫폼에서 사용 가능
  • Google의 인프라를 기반으로 하여, 대량의 메시지를 안정적으로 처리할 수 있음
  • FCM은 무료로 제공되며, 추가 비용 없이 사용할 수 있음
  • 주제 기반 메시징, 메시지 우선순위 설정, 다양한 메시지 타입 지원(알림 메시지, 데이터 메시지) 등 다양한 기능을 제공

단점

  • 메시지 전송 및 전달 상태에 대한 세부적인 제어가 제한적 ex) 재시도 로직에 대한 상세한 제어가 어려움
  • FCM은 Google 서비스에 의존적이므로, Google 서비스가 제한된 지역에서는 사용이 어려울 수 있음
  • 사용자 기기에서 푸시 알림을 비활성화하면, 메시지를 전달할 수 없음
  • 고급 기능을 구현하기 위해 서버 측에서 추가적인 로직을 처리해야 할 수 있음

 

FCM 아키텍쳐

  1. Firebase용 Cloud Functions, App Engine 또는 자체 앱 서버(내가 만든 서버)에서 메시지가 작성되고 메시지 요청이 FCM 백엔드로 전송
  2. FCM 백엔드는 메시지 요청을 수신하고 메시지 ID와 기타 메타데이터를 생성하여 플랫폼별 전송 레이어로 보냄
  3. 기기가 온라인 상태면 메시지가 플랫폼별 전송 레이어를 통해서 기기로 전송됨
  4. 기기에서 클라이언트 앱이 메시지 또는 알림을 수신

 

알람 보내는 과정

 

  1. 토큰 : Client App을 구분하는 토큰, 어떤 디바이스에 정보를 보낼지 구분하는 토큰, 이 토큰을 파이어베이스로부터 발급받음
  2. 클라이언트는 해당 토큰을 서버에 DTO를 통해서 전달
  3. 서버는 DB나 Redis에 해당 FCM 토큰을 저장
  4. 메시지를 전달해야하는 상황이 오면 서버는 발급받은 토큰을 이용해 메시지를 만들어 파이어베이스에게 전달
  5. 파이어베이스는 전달받은 토큰이 정상인지 확인하고 맞다면 모바일 디바이스에 메시지 전달

 

고민점 1 : FCM 토큰을 어떤 데이터베이스에 저장하여 관리할 것인가?

도입배경

  • FCM을 사용하여 서버에서 사용자들에게 알림을 전송하도록 결정
  • 클라이언트가 먼저 FCM 서버로부터 토큰을 발급받고, 이를 로그인할 때 서버에 전송하는 식으로 관리하도록 구현을 진행함
  • FCM토큰은 특정 기기로 푸시 알림을 보내기 위해 Firebase가 발급하는 고유 식별자이므로 이를 어떤 데이터베이스에 넣어서 저장하고 관리할지 고민하게 됨

선택지

  • Redis
    • FCM 토큰은 결국 사용자를 식별하는 토큰이라고 생각이 들어서 Refresh 토큰처럼 사용자가 로그인을 할 때 저장하고, 사용자가 로그아웃을 할 때 삭제해주는 식으로 관리하면 좋을 것이라고 생각함
    • 또한 인메모리 데이터베이스이기 때문에 RDB보다 빠르고 TTL을 지정해 줄 수 있기 때문에 사용자가 로그아웃을 하지 않는다고 가정해도, 로그인 할 때마다 토큰을 받아오기 때문에 토큰의 최신화를 관리하기 쉽다고 판단함
    • Redis가 다운된다면 사용자의 모든 FCM토큰이 사라지게 되고, RDB에 비해서 적은 용량의 데이터를 저장할 수밖에 없음
    • 비용 측면에서 RDB보다 훨씬 비쌈
  • RDB
    • Redis보다 조회 속도가 느리지만 훨씬 많은 양의 데이터를 저장할 수 있음
    • 디스크 기반 저장이기 때문에 데이터베이스가 다운되더라도 안전하게 복구할 수 있음
    • FCM토큰의 최신화 관리의 경우 사용자가 로그인할 때 기존 토큰과 다르다면 업데이트하고 동일하다면 유지하면서 관리할 수 있음

최종결정

  • Redis는 인메모리 데이터베이스이기 때문에 용량이 적고 비용이 비싸기 때문에 유저의 모든 FCM 토큰을 저장하는 것은 불가능하다고 판단
  • 또한 Refresh 토큰과 다르게 FCM 토큰은 자주 호출되고 손실되어서는 안 되는 중요한 정보이기 때문에 RDB에 넣어서 관리하는 것이 적절하다고 판단
  • 이러한 이유로, FCM 토큰 관리 데이터베이스로 RDB 채택

 

고민점 2 : 어떤 메서드를 사용하여 FCM 서버에 전송 요청을 보낼 것인가?

현재 세팅

  • 최대 만건의 알림을 동시에 보낸다고 가정했을 때 설정한 값
@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(40);      // I/O 바운드 작업이므로 코어 수 * 10 가능
        executor.setMaxPoolSize(60);      // 버스트(폭발) 처리 여유
        executor.setQueueCapacity(10000);  // 큐를 넉넉하게 설정
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("FCM-Async-");
        executor.initialize();
        executor.setRejectedExecutionHandler((r, exec) -> {
            throw new IllegalArgumentException("더 이상 요청을 처리할 수 없습니다.");
        });
        executor.initialize();
        return executor;
    }
}

 

테스트 케이스 1 : send(message);

전체 10000건 메시지 전송 완료 시간: 41초

@Async
    public void sendAsyncTest(String fcmToken, String content, CountDownLatch latch, AtomicInteger counter, Stopwatch stopwatch) {
        try {
            Message message = Message.builder()
                    .setNotification(Notification.builder()
                            .setTitle("Docconneting")
                            .setBody(content)
                            .build())
                    .setToken(fcmToken)
                    .build();

            String response = FirebaseMessaging.getInstance().send(message); // 블로킹
            int done = counter.incrementAndGet();
            log.info("✅ [{}] 알림 전송 성공: {}", done, response);

        } catch (Exception e) {
            log.error("❌ 알림 전송 실패 (token: {})", fcmToken, e);
        } finally {
            latch.countDown(); // 성공/실패 상관없이 count 감소
        }
    }

 

 

테스트 케이스 2 : sendAsync(message).get()

전체 10000건 메시지 전송 완료 시간: 43초

@Async
    public void sendAsyncTest(String fcmToken, String content, CountDownLatch latch, AtomicInteger counter, Stopwatch stopwatch) {
        try {
            Message message = Message.builder()
                    .setNotification(Notification.builder()
                            .setTitle("Docconneting")
                            .setBody(content)
                            .build())
                    .setToken(fcmToken)
                    .build();

            String response = FirebaseMessaging.getInstance().sendAsync(message).get(); // 블로킹
            int done = counter.incrementAndGet();
            log.info("✅ [{}] 알림 전송 성공: {}", done, response);

        } catch (Exception e) {
            log.error("❌ 알림 전송 실패 (token: {})", fcmToken, e);
        } finally {
            latch.countDown(); // 성공/실패 상관없이 count 감소
        }

 

테스트 케이스 3 : .sendEachForMulticast(message);

BatchResponse response = FirebaseMessaging.getInstance().sendEachForMulticast(message);

 

java.lang.OutOfMemoryError: unable to create native thread: 에러 발생

관련 이슈 : https://github.com/firebase/firebase-admin-java/issues/950

  • 10000개의 토큰을 500개씩 잘라서 스레드 20개로 사용했더니 이렇게 됨
  • sendEachForMulticast ⇒ 토큰이 500개 있다면 최악의 경우 스레드가 500개가 생성됨
  • 현재 나는 1만개의 토큰을 500개씩 잘라서 비동기로 저 메서드를 호출하는 메서드를 20개 호출했고 그 내부에서 또 500개에 가깝게 스레드를 실행해서 거의 1만개가 된 거임

 

이를 해결하기 위해서 가장 적절한 컴퓨터가 감당할 수 있는 스레드 풀로 설정하고 다시 시도해봤다

약 1분 46.25초

@EnableAsync
@Configuration
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(4);     
        executor.setMaxPoolSize(6);      // 버스트(폭발) 처리 여유
        executor.setQueueCapacity(1000);  // 큐를 넉넉하게 설정
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("FCM-Async-");
        executor.initialize();
        executor.setRejectedExecutionHandler((r, exec) -> {
            throw new IllegalArgumentException("더 이상 요청을 처리할 수 없습니다.");
        });
        executor.initialize();
        return executor;
    }
}

 

 

1차 결론

  • sendAll, sendMulticast 같이 batch로 보내는 메서드는 지워짐
  • sendAsync를 사용하면 sendAsync를 호출하는 메서드(비동기) + 그 메서드 내부에서 또 비동기로 sendAsync가 스레드를 만듦 ⇒ 스레드 못 만들겠다는 오류가 남
    • 그래서 send를 사용해야 할 것 같다 ⇒ 어차피 이 메서드를 호출하는 메서드는 비동기로 동작하니까
  • sendEachForMulticast를 해주려면 서버 자원에 대해서 많이 신경을 써 줘야 함 → 만약 한번에 500개씩 보내면 내부적으로 최대 500개의 스레드가 비동기로 또 생성 될 수 있다고 한다
    • 이렇게 테스트를 했을 때 컴퓨터에서 감당한 결과는 100개씩 보내면서 core스레드는 4개
    • 1만건의 스레드를 전송했을 때 걸린 시간 약 1분 46.25초
  • 찾아보니 입출력의 경우 코어스레드 수를 코어 cpu의 3배-4배 정도까지는 해도 괜찮다고 해서, 코어 스레드를 40개로 하고 send(message);를 사용해봤음
    • 1만건의 스레드를 전송했을 때 걸린 시간 약 50초

⇒ 그래서 차라리 입출력 전용 스레드 풀을 설정해서 코어 스레드를 40개로 하고 send(message);를 사용하는게 낫다는 생각이 들었음

 

하지만 이건 바보같은 생각이었다!

 

2차 결론 (찐결론)

sendEachForMulticast 즉, batch는 서버의 안정성, 무결성, 정합성을 위해서 사용하는 것이기 때문에 저 send를 비동기로 호출해서 사용하는 것보다 당연히 속도가 느릴 수 밖에 없음 ⇒ 항상 최대 500개씩 잘라서 보내기 때문에 서버의 비용측면에서도 이점이 있음 안정적임

 

send(message);를 사용하면서 스레드 풀을 지정해주면 되긴 하지만, 너무 많은 요청이 반복되면 서버 트래픽, 로깅 비용 등의 부수 비용이 발생하기도 하고, 서버는 알람만 보내는게 아니라 다른 일을 하기 때문에 부담이 커짐, 또한 성공/실패 결과를 토큰 하나하나 추적하려면 따로 처리가 필요함

 

즉, sendEachForMulticast는 알람 속도를 빠르게 보내려고 batch로 보내는게 아니라, 최대 500개의 요청을 제한을 두고 fcm에 요청을 보내면서 서버의 안정성을 위해서 사용하는 것이다. 또한 토큰별 성공/실패 결과를 한 번에 반환해서 관리하기 좋다

 

그렇기 때문에 서버의 안정성을 위한다면 sendEachForMulticast를 사용하는 것이 맞고, 단지 성능과 속도를 위한다면 send(message);를 비동기로 호출하는 것이 맞다고 볼 수 있음!

 

⇒ 일단 우리 프로젝트에서는 안정성이 더 중요하다고 생각해서 sendEachForMulticast를 사용해서 보내는걸로 결정!

 


부가적인 개발관련 조언 (꼭 기억하기)😼

  1. gpt가 해줬는데요, 블로그에서 이렇게 해서 이렇게했는데 이게 맞나요 이런 말투 개발자적으로 안 좋음 고쳐야 함
  2. 차라리 이렇게 생각했고 이게 적절한 것 같아서 이런 방식으로 했습니다 라고 말해야 함
  3. 구조같은걸 알면서 코드를 짜야 한다, 코드를 알고 짜야 하고 설명도 구조와 관련되서 할 수 있어야 함 ⇒ 공식문서 보면서 공부
  4. 블로그 말도 믿지 말고 아무도 믿지 말아야 한다. 걍 자기가 스스로 정의내리고 결론지을 수 있어야 함 ⇒ 정답은 없음