티스토리 뷰

(이 글은 외로운 우테코 5기 취준생 “김동욱”, “이건회” aka “그레이”, “하마드”가 작성했습니다.)

 

현재 비동기 호출과 FCM의 sendAsync를 통해 FCM 알림 호출까지 비동기로 처리하고 있다.

 

하지만…

String response = FirebaseMessaging.getInstance().sendAsync(message).get();

 

여기서 get() 메서드가 자꾸 눈에 걸렸다.

 

자바 5에서 비동기 함수의 리턴 값을 받을 수 있도록 Future 객체가 등장했고 Future 객체에 값을 꺼내기 위해 get 메서드를 사용한다. FirebaseMessaging의 sendAsync의 반환 타입인 ApiFuture에서 get 꺼내는 동작을 찾아보니 블록킹 방식으로 동작하고 있었다. 추가적으로 자바와 스프링 공식문서에 따르면 비동기 메서드 중 get 메서드는 블록킹 메서드라고 경고하고 있다.

 

그러면… 지금까지 비동기 & 논블록킹이라고 믿고 있었던 알림 전송 로직이 비동기 & 블록킹 방식으로 동작하고 있던 것인가?

현재 동작하는 방식을 위 그림과 함께 설명해보겠다.

 

1. 알림 이벤트가 생성되고 알림 이벤트 리스너가 비동기 스레드로 분리

2. 분리된 2024-pium-thread-n 스레드가 FcmMessageSender의 sendAsync 메서드 호출

3. sendAsync로 분리된 firebase-default-n 스레드가 외부 Firebase Message Server 호출

4. Firebase Message Server로 부터 응답을 firebase-default-n 스레드가 받아옴

5. 2024-pium-thread-n 스레드는 응답을 꺼내 결과를 로그로 출력

 

이전에 스레드 덤프를 했을 때 2024-pium-thread-n과 firebase-default-n의 개수는 일대일 대응된다는 것을 확인했다.

 

지금까지 비동기 & 논블록킹 방식으로 동작한다고 생각했었는데 다시 생각해보면 스레드 덤프 결과가 비동기 & 블록킹 방식이라고 알려주고 있었다.

 

만약, 비동기 - 논블락킹 방식이라고 하면 2024-pium-thread-n 스레드가 firebase-default-n 스레드로 분리되자 마자 새로운 알림 요청을 처리하게 될 것이고 그에따라 firebase-default-n 스레드가 추가로 생성될 것이다.

 

결과적으로 비동기 - 논블록킹 방식이었다고 하면 40개의 비동기 스레드 & 40개 보다 더 많은 firebase 스레드가 생성되어야 맞다. 하지만 일대일로 대응된 스레드 개수를 유지했기 때문에 비동기 - 블록킹 방식으로 동작하고 있었다.


비동기 - 논블락킹 방식으로 처리하려면?

ApiFuture의 get 메서드로 응답 값을 반환 받는 것이 아닌, 콜백 메서드를 이용해 별도의 스레드가 해당 콜백 메서드를 실행하도록 만들면 된다.

public interface ApiFuture<V> extends Future<V> {
    void addListener(Runnable var1, Executor var2);
}

 

ApiFuture는 함수형 인터페이스로 사용할 수 있으며 Runnable 객체를 받아 Executor로 해당 Runnable을 실행한다.

Runnable을 익명 클래스로 생성하여 해당 응답으로 어떤 동작을 처리할지 정의할 수 있다.

Runnable task = () -> {
      try {
          String response = apiFuture.get();
          log.info("알림 전송 성공 : " + response);
          log.info("현재 스레드 NAME: " + Thread.currentThread().getName());
      } catch (InterruptedException e) {
          throw new RuntimeException(e);
      } catch (ExecutionException e) {
          throw new RuntimeException(e);
      }
  };

우리의 예시에 대입해 설명해보면, Firebase-default-thread-n 스레드가 Firebase Message Server에게 받은 응답을 2024-pium-thread-n 스레드에 반환하지 않고 별도의 저장소에 담아놓는다고 생각할 수 있다.

apiFuture.addListener(task, callBackTaskExecutor);

이 저장소에 담긴 Runnable 객체를 처리하는 스레드가 2번째 인자로 넘어오는 Executor 객체이다. Executor 객체의 스레드 설정에 따라서 작동한다.

 

CallBackTaskExecutor의 경우 thread core pool size를 많이 할당하지 않도록 했다. 콜백 메서드 내에서는 결국 로그를 남기고 있기 때문에 빠르게 처리할 필요가 없다고 판단했기 때문이다. 앞선 포스팅에서 비동기 스레드를 빈 등록한 것과 마찬가지로 CallBackTaskExecutor를 다음과 같이 등록했다.


FCM SDK는 내부적으로 어떤 스레드 풀을 사용할까?

FirebaseMessaging 객체는 Executors.newCachedThreadPool() 을 기본적으로 사용한다. 즉 요청이 100개이면 100개의 스레드, 10000개이면 10000개의 스레드, 100000개이면 100000개의 스레드를 생성해 사용한 후 일정 시간이 지나도록 사용되지 않으면 소멸된다.

 

현재 상황에서 비동기 & 논블록킹 방식을 사용하여 10만개 알림 요청을 보내면 outOfMemory 에러가 발생한다. 당연스럽게도 10만개에 가까운 스레드가 거의 동시에 생성되어 에러가 발생하는 것이다.

 

우리 시스템에서 outOfMemory가 발생하지 않으려면 FirebaseMessaging의 최대 스레드 수를 제한해야된다. 하지만 현재 FCM SDK에서 스레드 풀을 커스텀 하는 설정은 제공하지 않는다.

 

강한 친구 GPT에게 물어보니 아래와 같이 말해줬다.

..안된다고?

 

어떻게든 방법을 찾아내야 한다..

 

그래서 현재 애플리케이션에서 빈 후처리로 FirebaseApp 설정을 하고 있는데 이 부분을 유심히 살펴봤다.

@PostConstruct
public void initialize() {
    try {
        ClassPathResource resource = new ClassPathResource(fcmJsonPath);
        FirebaseOptions options = FirebaseOptions.builder()
                .setCredentials(GoogleCredentials.fromStream(resource.getInputStream()))
                .build();

        if (FirebaseApp.getApps().isEmpty()) {
            FirebaseApp.initializeApp(options);
        }
    } catch (FileNotFoundException e) {
        log.error("파일을 찾을 수 없습니다. ", e);
    } catch (IOException e) {
        log.error("FCM 인증이 실패했습니다. ", e);
    }
}

지피티가 스레드 풀 설정이 없다고 했는데.. 제공하는 메서드를 하나씩 찍어보니 setThreadManager를 제공하고 있었다.

 

setThreadManager 는 ThreadManager 타입을 인자로 받고 이 타입은 위에서 보았던 FirebaseThreadManagers 내부에 있는 globalThreadManager, defaultThreadManager 타입들이다.

 

그래서 CustomThreadManager 객체를 생성한 후 ThreadManager를 상속받게 했고 아래와 같이 오버라이드해 정의할 수 있었다.

public class CustomThreadManager extends ThreadManager {

    @Override
    protected ExecutorService getExecutor(FirebaseApp firebaseApp) {
        return Executors.newFixedThreadPool(60);
    }

    @Override
    protected void releaseExecutor(FirebaseApp firebaseApp, ExecutorService executorService) {
        executorService.shutdownNow();
    }

    @Override
    protected ThreadFactory getThreadFactory() {
        return Executors.defaultThreadFactory();
    }
}

FCM SDK에서 내부적으로 사용하고 있는 newCachedThreadPool 을 제거하고 newFixedThreadPool 을 사용하도록 설정했다. newFixedThreadPool은 maxThread를 설정할 수 있으므로 무한정으로 스레드가 생성되는 상황을 막을 수 있다.

@PostConstruct
public void initialize() {
    try {
        ClassPathResource resource = new ClassPathResource(fcmJsonPath);
        FirebaseOptions options = FirebaseOptions.builder()
                .setThreadManager(new CustomThreadManager())
                .setCredentials(GoogleCredentials.fromStream(resource.getInputStream()))
                .build();

        if (FirebaseApp.getApps().isEmpty()) {
            FirebaseApp.initializeApp(options);
        }
    } catch (FileNotFoundException e) {
        log.error("파일을 찾을 수 없습니다. ", e);
    } catch (IOException e) {
        log.error("FCM 인증이 실패했습니다. ", e);
    }
}

최종적으로 FirebaseApp 설정하는 FirebaseOptions 빌더에 setThreadManger로 스레드 풀을 설정하면 된다.

 

 

해당 방식으로 전환 후 스레드 덤프와 로그를 확인해보니 비동기 논블로킹 방식으로 동작하며, 제한한 스레드 갯수만큼 스레드가 생성됨을 확인할 수 있었다. 현행 스레드 갯수에서 CPU 사용량이 65%를 초과하지 않는 것을 함께 확인했다. 여기서 관건은 비동기로 동작하는 FCM 스레드를 얼마나 늘렸을 때 최적의 스레드인지 확인하면 될 것이다.

 

그 이후는 각자 상황에 맞게 찾아가면 된다.

 

다음 포스팅에서는 알림 발송이 실패하는 경우 어떻게 대처할 수 있는지에 대해서 간략하게 남겨보겠습니다.

댓글