[Spring] 사용자별 출발 시간 알림 예약 전송 기능 구현

현재 진행하고 있는 "원만한 친구 사이를 위한 약속 지킴이 서비스" 오디(ody) 프로젝트는

친구들이 약속에 지각하지 않도록 돕기 위해 친구들의 출발 위치와 만나기로 약속한 장소까지 걸리는 소요 시간을 외부 API를

이용하여 계산해 출발 시간 알림을 보내준다.

 

알림의 경우에는 안드로이드에 푸쉬 알림을 보내기 위해 FCM을 사용한다.

(FCM 연결 및 알림 전송에 대한 부분은 아래 포스팅에서 설명한다.)

2024.07.20 - [◼ JAVA/Spring] - [Spring] Firebase Cloud Messaging(FCM)으로 푸시 알림 전송 기능 구현

 

그렇다면 스프링에서 사용자별 특정 시간에 FCM에 알림을 전송하기 위해서는 어떻게 해야 할까?


정적 스케줄링과 동적 스케줄링이란?

그렇다면 정적,  동적 스케줄링이란 뭘까 ? 정적과 동적에 대한 차이부터 알 필요가 있다.

정적이란 말 그대로 정적인, 고정적인 것이고 애플리케이션 시작 시 설정한 값이 종료까지 고정된다.

 

동적이란 말 그대로 계속 변화하는 즉, 애플리케이션 런타임 시점에 원하는 데로 바뀌는 것이다.

 

정적 스케줄링과 동적 스케줄링은 위 개념대로 스케줄링, 작업을 “예약"하는 것이다.


@Scheduled를 사용한 정적 스케줄링은...

사용자별로 다른 시간에 출발 알림을 전송하는 기능을 구현하기 위해 스케줄링을 적용하려 했다.

하지만 처음에는 @Scheduled로 적용하는 스케줄링이 밖에 알지 못했고

다른 스케줄링 기술이 있는지는 알지 못해 @Scheduled를 사용한 스케줄링은 정적 스케줄링으로

기능을 구현할 생각을 갖고 있었다.

 

아래 코드는 @Scheduled의 간단한 예시로 cron 표현식을 사용해

1초 마다 "Hyun / Log"가 출력되는 코드이다.

@Scheduled(cron = "1 * * * * *") // 초 분 시간 일 월 요일
public void run() {    
	System.out.println("Hyun / Log");
}

 

 

필자가 원하는 사용자별로 다른 출발 알림을 보내기 위해서는 

사용자마다 각자 다른 시간에 알림을 보낼 수 있도록 작업 예약을 “한 번”만 하면 된다.

하지만 @Scheduled를 사용한 정적 스케줄링은 cron 표현식으로 작성하게 되면 문자열로 작성하게 되어 

알림 요청이 없는 상황에서도 설정한 시간마다 스케줄링이 동작하기 때문에 

서버 리소스 낭비가 너무 심하다는 단점이 있었다.

 

그렇게 필자가 원하는 니즈에 맞는 기술을 찾기 위해 열심히 검색하다.

동적 스케줄링을 추가로 알아보던 와중 운 좋게 우아한 테크 코스 5기 선배님들의 "페스타고"에서 사용한

동적 스케줄링 포스팅을 볼 수 있었고 필자가 원하는 바와 일치하는 TaskScheduler를 사용해 사용자별로 알림을 보내기로 하였다.

 

TaskScheduler를 사용한 동적 스케줄링은

TaskScheduler는 스프링에서 제공하는 스케줄링 인터페이스로

복잡한 스케줄링 요구사항이 있거나 런타임에 스케줄을 조정해야 하는 경우에  코드를 통해 동적으로 스케줄링 할 수 있다.

 

@Scheduler와 TaskScheduler의 차이

@Scheduler는 기본적으로 단일 스레드에서 동작하는 반면 TaskScheduler는 멀티스레드 환경에서 동작한다.

그래서 높은 동시성을 가져 여러 작업을 병렬로 처리할 경우 처리량이 향상 될 수 있고,

복잡하고 시간이 오래 걸리는 작업에 더 적합할 수 있다.

 

기본적으로 askScheduler 스레드풀 사이즈는 1로 고정되어 있는데, 아래와 같이 설정도 가능하다.

 


TaskScheduler를 사용해 사용자별 출발 시간에 맞춰 알림 전송 코드 작성하기

우선 스케줄링을 사용하기 위해선 아래와 같이 Scheduling을 빈 등록해주어야 한다. 

스레드 풀에 대한 설정도 필요하다면 이전 이미지와 같이 

ThreadPollTaskScheduler를 설정해주면 된다.

 

이제 TashScheduler를 사용해 동적 스케줄링 코드를 작성하는 법에 대해 보자.

 

(위 코드에 있는 FcmPushSender는 FCM으로 메시지를 전달하는 객체로 이 관련 코드는 크게 관련 없으므로 생략한다.)

 

해당 서비스에서 동적 스케줄링을 사용하기 위해 TaskScheduler를 의존성 주입해주자.

그리고 이 TaskScheduler의 schedule() 메서드를 사용해 원하는 작업을 예약할 수 있다.

1번째 인자는 실행할 작어, 2번째 인자에는 이벤트를 처리할 시간을 추가해준다.

2번 째 인자는 Instant 타입이기 때문에 LocalDateTime 타입인 sendAt을 한국 시간을 가진 Instant 객체로 변환하였다.

 

이제 NotificationService의 saveAndSendDepartureReminder() 메서드가 호출되면

현재 날짜 시간에서 10분전에 알림을 보내도록 TaskScheduler에 스케줄링을 하고

TaskScheduler는 10분 뒤에 Runnable의 인자로 들어간 작업을 처리하게 된다.

 

실행 흐름 정리


TaskScheduler로 예약된 이벤트 테스트하기

의도한 시간에 작업이 실행되는지를 테스트하기 위해 아래 방법을 사용했다.

2024.09.24 - [◼ JAVA/Spring] - ArgumentCaptor를 사용해 method 인자 값 검증하기

 

ArgumentCaptor를 사용해 method 인자 값 검증하기

ArgumentCaptor란?ArgumentCaptor는 Mockito 프레임워크에서 클래스로, Mock 객체의 메소드가 호출될 때 전달되는 인자를 이름 그대로 "캡처"하고 검증하는 데 사용된다.ArgumentCaptor는 복잡한 객체나 람다 함

hstory0208.tistory.com

 


참고로 이 밑의 설명은 이전에 비동기 처리를 적용해 작성한 테스트 코드로 이 포스팅에 대한 테스트로 적합하지 않다.

하지만 기록용으로 일단 남겨놓았다.

 

이제 TaskScheduler로 예약한 이벤트가 설정한 시간 이후에 처리되는지 한번 테스트해보자.

스케줄링을 테스트하도록 도와주는 객체는 대표적으로 2가지 CountDownLatch, Awaitility가 있었다.

 

  CountDownLatch Awaitility
공통점 비동기 작업을 테스트하기 위해 사용.
차이점 특정 개수의 스레드가 작업을 완료할 때까지 대기한다. 사용자는 원하는 조건을 정의하고, 그 조건이 충족될 때까지 대기한다.

 

특징을 보면 Awaitility는 @Scheduled를 사용해 주기적으로 작업이 수행되는지를 확인할 때 용이할 것 같고,

TaskScheduler를 사용한 테스트예약된 작업이 완료될 때 까지 기다린 후 메서드가 호출되었는지 확인해야 하므로

CountDownLatch가 적절하다고 판단해 아래와 같이 테스트 코드를 작성하였다.

 

테스트 코드
@SpringBootTest(webEnvironment = WebEnvironment.NONE)
class FcmEventSchedulerTest {

    @MockBean
    private FcmPushSender fcmPushSender;

    @DisplayName("예약 알림이 2초 후에 전송된다")
    @Test
    void testScheduledNotificationIsSentAtCorrectTime() throws InterruptedException {
        LocalDateTime sendAt = LocalDateTime.now().plusSeconds(2);
        FcmSendRequest fcmSendRequest = new FcmSendRequest(
                "testToken",
                NotificationType.DEPARTURE_REMINDER,
                sendAt
        );

        // 비동기 작업을 동기화 시키기 위한 클래스
        // 파라미터 인자에 비동기 작업의 개수를 입력해준다.
        // 입력된 개수의 비동기 작업이 종료될 때 까지 스레드는 대기 한다.
        CountDownLatch latch = new CountDownLatch(1);

        // Mokito의 doAnswer()는 특정 메서드가 호출될 때 수행할 작업을 정의한다.
        // fcmPushSender의 sendPushNotification 메서드가 호출될 때, latch.countDown()을 호출하여 카운트를 감소시킨다.
        // latch가 현재 1로 설정되어 있기 때문에 카운트가 감소되어 0개가 되면 대기하고 있던 스레드가 계속 작업을 진행할 수 있게 된다.
        doAnswer(invocation -> {
            latch.countDown();
            return null;
        }).when(fcmPushSender).sendPushNotification(fcmSendRequest);

        // 2초후에 메세지가 가도록 설정한 fcmSendRequest를 인자로 넣어 실제 sendPushNotification()를 호출한다.
        fcmPushSender.sendPushNotification(fcmSendRequest);

        // latch의 카운트가 0이될 때까지 대기할 시간을 정의한다.
        assertThat(latch.await(2, TimeUnit.SECONDS)).isTrue();

        // sendPushNotification() 메서드가 호출되었는지 검증한다.
        verify(fcmPushSender).sendPushNotification(fcmSendRequest);
    }
}

 


참고자료

https://xxeol.tistory.com/53