들어가며
대학 축제 티켓팅 서비스 ‘페스타고’에서 입장 안내 푸시알림을 보내는 기능을 개발하게 되었다.
10분 후 입장 가능한 티켓을 소유한 사용자들에게 ‘잠시 후 입장 가능합니다’라는 내용의 푸시알림을 전송하는 기능이다.
여기서 주목할 점은 특정 시간(ex. 자정)이 아닌, 티켓 별로 상이한 입장 시간에 따라 푸시알림을 전송해야 한다.
이 기능을 개발하는 과정을 이번 글에서 공유하고자 한다.
고려한 방법들
푸시알림을 특정 시간에 전송하기 위해 여러 가지 방법들을 고려하였다.
1. Firebase의 함수 예약 기능 활용
Firebase는 특정 시간에 함수를 실행하도록 예약하는 기능을 제공한다.
해당 방법을 활용해 기능을 개발할 수 있었지만, 이를 사용하려면 Blaze 요금제로 업그레이드해야 했다.
추가적인 비용이 발생하는 것을 피하고자 이 방법은 선택하지 않았다.
2. 1분에 한 번씩 cron job 실행
Spring의 @Scheduled 어노테이션을 사용하여 1분에 한 번씩 cron job을 실행하는 방법을 고려했다.
이를 통해 (현재 시간 + 10분) == (입장시간)인 티켓들을 찾아 푸시알림을 전송할 수 있었다.
예를 들어, 아래와 같은 방식으로 스케줄링을 설정할 수 있었다.
@Scheduled(cron = "0 * * * * *")
public void scheduleEntryAlert() {
// 10분 후 입장인 티켓을 찾아 푸시알림을 전송하는 코드 작성
}
그러나 이 방법은 예정된 축제가 없을 때에도 불필요하게 스케줄링 작업을 실행하여 메모리를 낭비할 수 있다.
이러한 이유로 @Scheduled 어노테이션을 사용하는 것보다 TaskScheduler를 활용해 직접 작업을 예약하는 방법을 모색하기로 결정했다.
3. TaskScheduler를 활용한 동적 작업 예약
TaskScheduler의 schedule 메서드를 활용해 동적으로 작업을 예약할 수 있다.
(c.c: spring docs: TaskScheduler.schedule)
3-1. “ss mm HH dd MM * yyyy” 크론 표현식을 활용한 작업 예약
“ss mm HH dd MM * yyyy” 크론 표현식은 특정 시점(yyyy년 MM월 dd일 HH시 mm분 ss초)에 작업을 실행한다.
티켓 생성 시 동적으로 크론 표현식을 생성해 cron job을 등록하는 아이디어를 검토했다.
하지만 Spring에서 7자리 크론 표현식을 지원하지 않아 실패했다. (연도 지원 X)
Quartz라는 외부 라이브러리를 활용해야 할 것 같은데, 가능한 한 스프링이 제공하는 기능만을 활용하고자 했다.
예를 들어 “0 30 18 2 10 ?”와 같은 크론 표현식의 경우, 10월 9일 18시 30분에 실행되지만, 1년에 한 번씩 주기적으로 반복된다. (2023년 10월 9일, 2024년 10월 9일 등)
조건문을 활용해 특정 연도에만 작업이 실행되도록 제어할 수 있지만, 이는 좋은 방법은 아닌 것 같다.
(c.c: stackoverflow: How to run @Scheduled job at exact time for once in Spring?)
3-2. 특정 시간에 작업 예약
TaskScheduler의 schedule 메서드를 활용하면 CronTrigger 이외에도 특정 시간을 파라미터로 전달하여 해당 시간에 작업을 실행할 수 있다.
이를 활용하여 티켓 생성 시에 동적으로 작업을 예약하는 방법을 선택했다.
이 방법을 통해 외부 라이브러리를 활용하지 않고 간결하게 작업을 예약할 수 있었다.
예상되는 문제점과 그에 대한 해결책
특정 시간에 작업을 예약하는 방법을 사용했을 때 예상되는 문제점과 그에 대한 해결책 및 나의 생각은 아래와 같다.
1. 작업이 실행될 때까지 Scheduler 스레드가 백그라운드에서 대기한다.
차선책인 1분에 한 번씩 cron job을 실행하는 방식도 결국 스레드 1개는 무한히 백그라운드에서 대기를 한다.
나는 특정 시간에 작업을 예약하는 방법이 오히려 축제가 없는 시즌에는 불필요한 스레드를 낭비하지 않아 리소스를 아끼는 방법이라 생각했다.
2. 입장 예정 티켓이 n개 있을 때, n개의 작업이 예약되니 n개의 스레드가 백그라운드에서 대기하는 것이 아닌가?
SpringBoot 기본 설정의 TaskScheduler 스레드풀 사이즈는 1로 고정되어 있다.
즉, 하나의 스레드에서 여러 작업이 대기한다.
위와 같이 작업을 10개 예약하고, 예약 전후로 스레드 사이즈를 확인했을 때 정확히 1개 차이가 났다. (26 → 27)
3. 하나의 스레드에서 여러 작업이 대기하면 들어온 순서대로 실행되는 게 아닌가?
Task1(5초 뒤 실행 예약) → Task2(3초 뒤 실행 예약) 순으로 작업이 추가되었을 때, 들어온 순서에 상관없이 본인의 startTime에 실행된다.
즉, 3초 뒤에 Task2가 먼저 실행되고, 5초 뒤 Task1이 실행된다.
4. 하나의 스레드에서 모든 작업을 수행하면 처리 속도가 늦어진다.
여러 작업이 동일한 시간에 예약되었을 때, 하나의 스레드에서 순차적으로 작업을 수행하면 처리 속도가 늦어질 수 있다. 이를 해결하기 위해 각 작업을 비동기적으로 실행하도록 개선했다.
즉, 여러 작업들은 하나의 TaskScheduler 스레드에서 대기하다가, 실행 시간에는 별도의 Async 스레드 풀에서 병렬적으로 작업이 수행되도록 하였다.
5. WAS를 재실행하면 예약된 작업이 사라진다.
TaskScheduler는 메모리 기반으로 작업 예약 및 관리를 수행하기 때문에, WAS 재실행시 기존에 예약된 작업이 모두 사라진다.
이를 해결하기 위해 스케줄 정보를 DB에 저장하고, WAS를 재실행할 때마다 스케줄을 등록하는 방법을 채택했다.
DB 테이블의 구조는 아래 사진과 같다.
티켓 등록 시, 작업을 예약하며 예약 정보를 PENDING
상태로 DB에 저장한다.
알림을 전송할 때, 해당 예약 정보의 상태를 REQUESTED
로 수정한다.
WAS 재실행시, @EventListener(ApplicationReadyEvent.class)
를 활용해 이벤트를 수신한다. 이때, DB에서 PENDING
상태의 예약 정보를 모두 가져와 스케줄로 등록한다.
이렇게 함으로써 스케줄 정보를 영속적으로 관리할 수 있으며, WAS를 재실행해도 예약된 작업이 유지된다.
6. WAS가 여러 대일 때 알림이 중복으로 전송된다.
무중단 배포 및 부하 분산을 위해 여러 대의 WAS가 동작하는 환경에서는 알림이 중복으로 전송될 수 있다.
이 문제를 해결하기 위해 별도의 알림 전송 WAS를 구축할 수 있지만, 이는 다소 복잡한 방법이다. (멀티 모듈까지 고려했다가 현재는 WAS 분리까지는 불필요한 것 같아 진행하지 않았다.)
현재로서는 기존 WAS에서 문제를 해결하기로 결정했고, 나는 해결 방법으로 X-lock을 채택했다.
알림 전송 로직을 시작함과 동시에, PENDING상태의 EntryAlert에 X-lock을 걸어준다. 이로써 한 대의 WAS만 해당 작업을 처리한다.
알림을 전송하고, 해당 데이터의 상태를 REQUESTED로 변경한다.
이때 X-lock이 해제되며, 상태가 변경되었기 때문에 다른 WAS에서는 알림 전송 로직이 실행되지 않고 메서드가 종료된다.
이 방법을 통해 알림이 중복으로 전송되는 문제를 해결하고, 복잡한 구조 변경 없이 기존 WAS에서 처리할 수 있다.
비동기로 X-lock 범위 최소화하기
X-lock의 범위 안에 실제로 푸시알림을 전송하는 과정이 포함된다면, X-lock을 점유하고 있는 시간이 길어진다.
그러면 다른 WAS의 Lock 점유를 위한 대기 시간이 늘어남은 물론, 한정된 HikariCP의 커넥션을 오랜 기간 점유하게 되어 커넥션 부족 현상을 일으키기 쉽다.
이를 해결하기 위해 실제 푸시알림 전송 로직은 별도의 스레드에서 비동기로 처리하도록 분리했다.
따라서 X-lock의 범위에서는 전송 요청만 보내고 즉시 상태를 변경하고 X-lock을 해제시켜 X-lock의 범위를 최소화했다.
7. 푸시알림 전송 중 WAS가 종료되면 알림 전송이 실패한다.
푸시알림을 전송하는 도중에 WAS가 종료되면 알림 전송이 실패할 수 있다. 푸시알림 전송 시간을 피해 배포를 진행하는데도 한계가 존재한다.
이를 해결하기 위해 아래와 같이 GracefulShutdown을 설정해 주었다. GracefulShutdown은 WAS 종료 시에 현재 진행 중인 작업을 완료한 후 WAS를 종료하는 메커니즘이다.
// application.yml
server:
shutdown: graceful
하지만 문제는 우리 서비스의 구조는 Async에서 또 다른 Async를 호출한다는 것이다.
여러 Async 작업이 중첩되어 호출될 때 GracefulShutdown이 예상대로 작동하지 않을 수 있다.
Async에서 또 다른 Async를 호출하기 전 WAS가 종료되면 작업 종료 중에는 스레드를 할당하지 않아 예외가 발생한다. (즉, 실제 FCM이 전송되지 않는다.)
하지만 이 상황에선 EntryAlert의 상태를 바꿔주는 entryAlert.changeRequested();
메서드가 호출되기 전 예외와 함께 WAS가 종료된다.
즉, 해당 EntryAlert의 상태는 여전히 PENDING으로 WAS 재실행시 스케줄로 등록되어 푸시알림이 성공적으로 전송된다.
마무리
팀원들과 수많은 코멘트를 주고받으며 여러 시도를 해 본 기능이었다.
이번 기능을 개발하며 TaskExecutor와 TaskScheduler에 대한 이해도가 높아졌다.
CompletableFuture를 활용해 Async + Blocking 으로 개발하는 방법도 시도해 봤는데, 효율적이지 않다고 판단해 채택되진 못했다. 개발자는 가장 효율적인 방법으로 문제를 해결하는 사람이란 걸 다시 한번 깨달았다. (관련 내용)
오래간만에 재미있게 코딩했다! 병렬프로그래밍 공부를 더 열심히 하고 싶어 졌다!
적용 코드는 아래 PR에서 확인할 수 있다.
'프로그래밍' 카테고리의 다른 글
[Spring] REQUIRES_NEW와 데드락 위험성 (6) | 2023.10.13 |
---|---|
[Spring] Mockito 테스트의 중복 given절 줄이기: lenient를 활용한 유틸리티 클래스 (1) | 2023.10.08 |
Spring Event란? (0) | 2023.09.17 |
[Java] synchronized란? (1) | 2023.09.13 |
[Java] Thread란? (0) | 2023.09.13 |