오늘은 약 90%의 MVP 기능 개발을 마무리했다!
오늘은 어제 못다한 알림 조회 API를 구현하고, 이전 API 구현에서 잊었던 모임 상태 확인 로직을 추가했다.
그리고 오늘은 새롭게 TaskScheduler라는 것을 사용해봤는데, 이건 이전에 사용하던 @Scheduled 어노테이션 기반으로 동작하던 스케줄러를 커스텀해서 사용할 수 있는 것이었다.
먼저, 모임이 시작되기 10분 전에 모임 상태가 'COMPLETED'로 변경되어야 하고, 모임이 시작되고 3시간 후에는 '후기 작성 안내' 알림이 생성되어야 했다.
이 내용을 구현하기 위해 @Scheduled를 기반으로 진행하는 스케줄러와 Quartz 스케줄러, 이벤트와 이벤트 리스너를 사용한 구현 방법을 찾아봤다.
@Scheduled 기반의 스케줄러는 일정 시간마다 반복하는 것이기 때문에, 이번 서비스에 적용하기에는 불필요한 동작이 많아질 것 같았다.
Quartz 스케줄러가 가장 적합해 보였지만, 이건 외부 라이브러리를 추가해야 하기도 하고, 처음에 이해하고, 적용하는 데에 시간이 많이 걸리게 된다.
그리고, 이벤트와 이벤트 리스너는 일정에 맞춰 특정한 내용을 처리하는 것보다는 메인 로직과 이 로직을 분리하는 데에 사용하는 것이라고 한다.
그래서 조금 더 조사를 해본 결과, 우리와 비슷한 서비스를 만드는 다른 팀에서 TaskScheduler라는 것을 사용했다고 하더라.
사실 @Scheduled 어노테이션은 내부적으로 TaskScheduler를 사용하고 있는데, 이걸 우리가 직접 커스텀해서 사용하게 되는 것이다.
@RequiredArgsConstructor
public class SchedulingTask<T> implements Runnable {
private final T target;
private final Consumer<T> task;
private final TransactionTemplate transactionTemplate;
@Override
public void run() {
transactionTemplate.execute(
status -> {
task.accept(target);
return null;
}
);
}
}
우선 Runnable의 구현체로 SchedulingTask 객체를 만들었는데, 여기에는 작업하고자 하는 객체(target)와 작업할 내용(task)을 담을 수 있다.
이때 실행되는 내용은 트랜잭션으로 관리되지 않기 때문에, TransactionTemplate을 통해 트랜잭션으로 관리할 것임을 명시적으로 보여주었다.
@Service
@RequiredArgsConstructor
public class SchedulingService {
private final TaskScheduler taskScheduler;
private final TransactionTemplate transactionTemplate;
private final MeetingRepository meetingRepository;
private <T> void scheduleTask(T target, Consumer<T> action, LocalDateTime executionDatetime) {
SchedulingTask<T> task = new SchedulingTask<>(target, action, transactionTemplate);
Instant execution = executionDatetime.atZone(ZoneId.systemDefault()).toInstant();
taskScheduler.schedule(task, execution);
}
}
그리고 위와 같이 SchedulingService에서 task 객체를 만들고, 이를 실행할 시간을 정해서 TaskScheduler에 등록하였다.
사실 기본적인 구현은 여기에서 마칠 수 있는데, 우리 서비스에는 모임을 수정하고, 삭제하는 기능이 있다.
사실 이런 작업이 흔하지는 않고, 등록된 스케줄러를 그냥 둬도 상관은 없겠지만, 그래도 수정과 삭제 시에는 등록된 스케줄을 수정할 수 있도록 구현하고 싶었다.
@Repository
@RequiredArgsConstructor
public class SchedulingRepository {
private static final String SCHEDULED_TASK_PREFIX = "ScheduledTask::";
public static final String MEETING_CHANGE_STATUS = "Meeting change status::";
public static final String NOTIFICATION_REVIEW_REQUEST = "Notification review request::";
private final ConcurrentHashMap<String, ScheduledFuture<?>> repository = new ConcurrentHashMap<>();
// task 저장
public void save(Long targetId, String actionName, ScheduledFuture<?> task) {
repository.put(SCHEDULED_TASK_PREFIX + actionName + targetId, task);
}
// task cancel
public void cancel(Long targetId) {
// 1. Meeting 상태 변경 task 취소
String changeStatusKey = SCHEDULED_TASK_PREFIX + MEETING_CHANGE_STATUS + targetId;
ScheduledFuture<?> changeStatusTask = repository.get(changeStatusKey);
if (changeStatusTask != null) {
changeStatusTask.cancel(true);
repository.remove(changeStatusKey);
}
// 2. 알림 생성 task 취소
String notificationKey = SCHEDULED_TASK_PREFIX + NOTIFICATION_REVIEW_REQUEST + targetId;
ScheduledFuture<?> notificationTask = repository.get(notificationKey);
if (notificationTask != null) {
notificationTask.cancel(true);
repository.remove(notificationKey);
}
}
}
그래서 이렇게 작업할 task를 저장하는 Map 레파지토리(?)를 만들고, 생성한 task를 여기에 저장하고, 삭제할 수 있도록 구현하였다.
하지만, 이 내용들은 실제 DB에 저장되는 것이 아니라 서버의 메모리에 저장되는 것 뿐이기에 서버가 재시작되면 저장된 내용들이 다 날아가게 된다.
@EventListener(ApplicationReadyEvent.class)
public void restoreSchedules() {
// COMPLETED 되지 않은 meeting 조회
List<Meeting> meetingList = meetingRepository.findActivateMeetingByStatusNot(MeetingStatus.COMPLETED);
// task 등록
meetingList.forEach(meeting -> {
schedulingService.scheduleMeetingStatusComplete(meeting);
schedulingService.scheduleNotification(meeting);
});
}
그래서 위와 같이 EventHanlder를 통해 서버가 시작되면, meetings 테이블을 조회고, 해당 meeting에 대한 task를 등록하도록 설정하였다.
TaskScheduler는 지금의 MVP 단계에서는 적합할 수 있지만, 나중에 다중 서버를 사용하게 되면 하나의 작업에 대해 여러 서버가 실행하는 등의 문제가 생길 수 있다.
그래서 고도화 작업 시에는 Quartz 스케줄러 도입에 대해서도 고민해보려고 한다.
우리 팀이 작성한 코드는 깃허브를 통해 업로드해두었다.
GitHub 보러가기
오늘은 왜인지.. 정말 너무 너무 하기가 싫었다!
사실 새로운 내용을 도입하는 만큼 어떤 방식이 더 나을지 자료 조사를 하고, 정리하는 과정이 너무 너무 귀찮았다.
그래서 실제로 개발하는 데에는 오랜 시간이 걸리지 않았지만, 하기 싫음과 싸우느라 시간이 오래 걸렸다.
내일은 마음을 다잡고 다시 집중해봐야겠다.