알림 서비스 로직 개선
알림 서비스 설명
캡스톤 디자인으로 진행중인 Sprinter 프로젝트의 알림 서비스는 개략적으로 다음과 같이 구성되어 있다.
- 사용자 혹은 다른 사용자가 사용자와 연관된
알림을 발생시키는 행위
를 한다. - 알림이 Notification 테이블에 저장된다.
- 사용자가 사이드바에서 네비게이트 할 때마다 서버에서 알림 개수만 가져온다.
- 알림 모달을 열면 그때서야 전체 알림 리스트를 조회해서 가져온다.
알림을 발생시키는 행위
는 다음과 같다.
- 사용자가 속한 Backlog에 댓글/답글을 작성했을 때
- 사용자가 속한 Backlog에 Issue가 등록되었을 때
- 사용자가 속한 DailyScrum이 생성되었을 때
- 사용자가 속한 Project의 채팅방에 새로운 채팅이 작성되었을 때
- 사용자가 속한 Schedule의 알림 설정이 켜져있고, 설정한
알림 시각
일 때
위에서, 알림 시각
은 다음과 같이 계산된다.
- Schedule의 시작 시각 - Schedule의 희망 미리 알림 시간
- 예) 스케줄 A가 2025년 4월 27일 15시에 시작하고, 희망 미리 알림 시간이 ‘2시간 전’이라면, 알림은 정확히 13시에 스케줄 A에 속한 모든 사용자들에게 전달되어야 한다.
Schedule을 제외한 도메인에서 알림을 발생시키는 것은 비교적 쉽다.
생성/수정/삭제 시 신경쓰면 되기 때문이다.그러나, Schedule 도메인에서 발생하는 알림은 미리 알려줘야 하므로 특별한 처리가 필요하다.
Schedule에서 발생하는 알림 처리 로직
기존 로직
private static final long SCHEDULE_CHECK_INTERVAL = 30 * 1000;
@Scheduled(fixedRate = SCHEDULE_CHECK_INTERVAL)
@Transactional
public void checkScheduleNotifications() {
// 현재 시간
LocalDateTime now = LocalDateTime.now();
// 알림 설정된 모든 스케줄 조회 (notify=true)
List<Schedule> schedules = scheduleRepository.findAllByNotifyTrue();
for (Schedule schedule : schedules) {
// 알림 시간 계산 (시작 시간 - 사전 알림 시간)
LocalDateTime notificationTime = schedule.getStartDateTime()
.minusHours(schedule.getPreNotificationHours());
// 현재 시간이 알림 시간 이후이고, 시작 시간 이전인 경우에만 알림 생성
if (now.isAfter(notificationTime) && now.isBefore(schedule.getStartDateTime())) {
// 이미 알림을 보냈는지 확인 (중복 방지)
boolean alreadySent = notificationRepository.existsByScheduleIdAndNotificationType(
schedule.getScheduleId(), NotificationType.SCHEDULE);
if (!alreadySent) {
// 알림 생성 및 저장
String content = makeScheduleContent(schedule.getScheduleId());
String url = makeScheduleUrl(schedule.getProject().getProjectId(), schedule.getScheduleId());
// 프로젝트의 모든 사용자에게 알림 생성
createNotification(NotificationType.SCHEDULE,makeScheduleContent(schedule.getScheduleId()),schedule.getProject().getProjectId(),makeScheduleUrl(schedule.getProject().getProjectId(),schedule.getScheduleId()),schedule.getScheduleId());
}
}
}
}
상당히 원시적인 로직이다. 개략적인 설명은 다음과 같다.
- 30초마다 …
- Schedule 테이블애서 알림이 설정된 스케줄을 조회한다.
- 조회된 스케줄마다 …
- 해당 스케줄이 Notification 테이블에 이미 등록되었는지 확인한다.
- 등록이 되어 있다면 알람이 이미 사용자에게 간 것이니 스킵하고,
- 등록이 안되어 있다면 알림을 생성해서 저장한다.
쉽게 말해 일정 시간마다 polling을 해서 알림을 줘야 할 것들을 골라내고, 알림을 준다는 것이다.
잘 작동하고, 직관적인 코드이다. 이 코드를 작성한 팀원의 의도를 명확히 알 수 있다.
그러나, 이 로직은 Schedule 테이블을 다 뒤져서 찾고, 찾아낸 각 알림이 Notification 테이블에 이미 존재하는지 알아내기 위해 Notification 테이블을 또 뒤져야 한다.
만약 Schedule 테이블에 데이터가 1억건이고,
Notification 테이블에 데이터도 1억건이며,
알림 타입이 모두 Schedule인 알림만 저장되어 있다는 최악의 경우를 생각한다면…서버는 30초마다 이 끔찍하게 오래 걸리는 작업을 수행해야 하고, 이것이 서버 가용성을 현저히 떨어뜨릴 수 밖에 없다!
나는 이 문제를 동아리방에 구축한 On-premise 서버에 배포하고 난 후 로그를 검토할 때, 무수히 많이 찍혀 있었던 select문 실행 로그를 보고 알게 되었다 (…)
따라서 이 로직을 개선하지 않을 수 없었다.
개선된 로직
public class ScheduleNotificationService {
private final NotificationService notificationService;
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
private final Map<Long, ScheduledFuture<?>> scheduledTaskMap = new ConcurrentHashMap<>();
@PreDestroy
public void cleanup() {
scheduler.shutdown();
}
public void scheduleNotification(Schedule schedule) {
// 실제 알림이 갈 시간
LocalDateTime notificationTime = schedule.getStartDateTime()
.minusHours(schedule.getPreNotificationHours());
// 이미 지난 시간이면 쳐냄
if (notificationTime.isBefore(LocalDateTime.now())) {
return;
}
// 스케줄러에 집어넣기
ScheduledFuture<?> scheduledTask = scheduler.schedule(
() -> sendScheduleNotification(schedule),
Duration.between(LocalDateTime.now(), notificationTime).toMillis(),
TimeUnit.MILLISECONDS
);
scheduledTaskMap.put(schedule.getScheduleId(), scheduledTask);
log.info("스케줄 알림이 schedule 되었습니다. scheduleId={}", schedule.getScheduleId());
}
@Transactional
public void sendScheduleNotification(Schedule schedule) {
Long projectId = schedule.getProject().getProjectId();
Long scheduleId = schedule.getScheduleId();
notificationService.createNotification(
NotificationType.SCHEDULE,
makeScheduleContent(schedule),
projectId,
makeScheduleUrl(projectId, scheduleId),
scheduleId
);
scheduledTaskMap.remove(scheduleId);
}
public void cancelScheduleNotification(Long scheduleId) {
ScheduledFuture<?> removedScheduledTask = scheduledTaskMap.remove(scheduleId);
if (removedScheduledTask != null) {
removedScheduledTask.cancel(false);
}
}
scheduleNotification(), sendScheduleNotification(), cancelScheduleNotification() 메서드를 생성한 후,
- 스케줄 생성 시 scheduleNotification()을,
알림 시각
이 되었을 때 sendScheduleNotification()을,- 스케줄 수정 시 cancelScheduleNotification()과 scheduleNotification()을,
- 스케줄 삭제 시 cancelScheduleNotification()을
호출하도록 하였다.
이렇게 하면 일정 시간마다 전체 Schedule과 Notification을 polling 할 필요 없이, 하나의 스레드를 잡고 있는 ScheduledExecutorService가 알림 시간이 되면 자동으로 가지고 있는 future를 실행시켜서 알림을 생성해 준다.
또한 어떤 스케줄이 예약되어 있는지를 알 수 있는 map을 하나 생성하여, 스케줄 수정 및 삭제 시 관리를 할 수 있도록 하였다. 이 map이 있어야 삭제할 future를 schedule id로 검색하여 예정된 작업을 없앨 수 있기 때문이다.
여기서 ScheduledExecutorService란, 일정 시간 뒤에 혹은 주기적으로 어떤 Runnable을 자동으로 수행해주는 java.util의 스케줄링 전용 스레드풀이다. 작업이 등록되면 별도의 스레드가 내부적으로 타이머를 관리하여 등록된 작업을 수행해 준다.
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.schedule(() -> {
System.out.println("hello world");
}, 5, TimeUnit.SECONDS);
위 코드는 5초 후에 hello world를 출력한다.
더 개선해야 할 것
위의 개선된 로직은 성능 최적화를 달성했지만, 단점이 하나 존재한다.
ScheduledExecutorService에서 예약된 작업은 JVM 힙 메모리에서 관리되는 객체들이기 때문에 서버를 끄면 당연하게도 메모리는 모두 날아간다.
따라서 작업을 영속적으로 저장하기 위해서는 DB나 파일 시스템을 활용해서 따로 저장하는 로직을 구현해야 할 것이다.
또는 Quartz와 같은 외부 시스템을 활용한다면, 더욱 간편하면서도 안정적인 작업 예약 로직을 구현할 수 있다고 한다.
댓글남기기