인식한 상황
최소한의 기능이며 메인 기능인 방 생성/삭제/참여/퇴장, Code Editor, 질문/답변 작성을 할 수 있는 MVP 1을 이틀간 실습 강의에서 테스트하며 실시간으로 로그를 관찰한 결과 1분마다 DB에 접근하는 것을 확인했다.
다른 백앤드 팀원의 기능 중 하나인 모들락이 사용자가 설정한 종료 시각이 되면 자동으로 종료되는 부분에서 발생하고 있었다…ㅠㅠ
모들락이라는 특정 기능을 일정 시간이 지난 후 자동으로 종료시키는 로직을 구현하려는 상황에서, 기존에는 @Scheduled
애노테이션을 활용하여 1분 단위로 DB 접근을 통해 해당 모듈의 상태를 확인한 뒤 종료 시점을 판단했다. 그러나 이러한 방식은 다음과 같은 비효율성을 발생했다.
- 리소스 낭비: 주기적(DB에 1분 단위) 접근은 많은 경우 불필요한 연산을 수행하게 된다. 예를 들어, 실제 종료가 필요한 시점은 하루에 한두 번이지만, 매 분마다 DB를 확인한다면 이로 인한 시스템 부하가 누적될 수 있다.
- 정확성 문제: 1분 단위로 폴링(polling)하는 방식은 정확히 원하는 시점에 작업을 종료하기 어렵다. 필요 시점보다 약간 지연되거나, 불필요하게 먼저 접근하는 상황이 발생할 수 있다.
해결 과정
이러한 문제 인식을 바탕으로, 정확한 시점에만 종료 로직을 실행하고 시스템 자원을 효율적으로 활용할 수 있는 방법을 찾게 되었다.
1. ThreadPoolTaskScheduler 도입
기존 @Scheduled 기반의 주기적 접근 대신, ThreadPoolTaskScheduler를 활용하여 특정 시점에만 작업을 수행하도록 전환했다. ThreadPoolTaskScheduler를 통해 미래의 특정 시각을 지정하여 작업을 예약할 수 있다. 이로써 모들락 종료 시점을 정확히 설정 가능해므로 필요한 시점에만 DB나 로직에 접근하니 불필요한 반복적인 연산을 줄여 시스템 부하를 낮출 수 있었다.
이 과정에서 ThreadPoolTaskScheduler를 이용해 작업 예약을 진행하며, 특정 모들락의 종료 시점을 파라미터로 받아 해당 시간에 실제 종료 로직을 수행하도록 개발했다.
@TaskScheduler
@RequiredArgsConstructor
public class UpdaterScheduler {
private final ThreadPoolTaskScheduler threadPoolTaskScheduler;
public void addModeullakTask(Long modeullakId, Runnable task, Instant instant) {
ScheduledFuture<?> scheduledFuture = threadPoolTaskScheduler.schedule(task, instant);
}
}
2. 예약 객체 미관리로 인한 취소 불능 문제 발생
ThreadPoolTaskScheduler를 사용하는 과정에서 새로운 문제가 대두되었다. 예약된 작업에 대한 취소나 변경이 필요할 때, 해당 작업을 식별하고 제어할 수 있는 방법이 명확하지 않았던 것이다… (비동기 예약은 어렵구나…)
작업 예약 시 별도의 객체를 반환받지 않고 단순히 시간만 지정한다면, 중간에 모들락이 이미 종료되었거나 다른 사유로 인해 예약 취소가 필요할 때 이를 처리할 수 없다. 이는 운영 환경에서 예기치 않은 상황(모들락이 조기 종료되거나 시나리오 변경)이 발생할 때 유연하게 대응하기 어렵다는 단점을 알았다.
추가로 발생한 문제를 해결하기 위해, ThreadPoolTaskScheduler가 제공하는 작업 예약 함수는 예약된 작업을 식별할 수 있는 ScheduledFuture 객체를 반환한다는 점에 주목했고, 작업 예약 시 반환되는 ScheduledFuture 객체를 Map이나 다른 저장소에 [모들락 식별자 -> ScheduledFuture]
형태로 매핑하여 관리하기로 했다.
@TaskScheduler
@RequiredArgsConstructor
public class UpdaterScheduler implements InitializingBean {
private final ThreadPoolTaskScheduler threadPoolTaskScheduler;
private static Map<Long, ScheduledFuture<?>> modeullakTasks;
@Override
public void afterPropertiesSet() {
modeullakTasks = new ConcurrentHashMap<>();
}
public void addModeullakTask(Long modeullakId, Runnable task, Instant instant) {
ScheduledFuture<?> scheduledFuture = threadPoolTaskScheduler.schedule(task, instant);
modeullakTasks.put(modeullakId, scheduledFuture);
}
public void removeModeullakTask(Long modeullakId) {
ScheduledFuture<?> scheduledFuture = modeullakTasks.get(modeullakId);
if (scheduledFuture != null) {
scheduledFuture.cancel(false);
modeullakTasks.remove(modeullakId);
}
}
}
결과
로직을 변경하니 모들락 종료 요청이 별도로 들어올 경우, 해당 모들락 식별자를 통해 Map에서 ScheduledFuture 객체를 찾아내고, 이를 이용해 예약된 작업을 취소할 수 있다. 필요하다면 예약 시간 변경이나 추가 작업 예약도 유연하게 처리할 수 있었다. 또한, 모들락이 예상보다 일찍 종료될 경우같은 예기치 않은 상황에도 이미 예약된 작업을 무효화할 수 있으므로 불필요한 로직 실행을 막고 시스템 자원 낭비를 없앨 수 있었다.
사실 시간이 부족해서 API 서버 내부의 ConcurrentHashMap
을 사용했지만, 이후 설계를 한다면 다음과 같다.
API Server에서 모들락에 대한 생성, 삭제, 수정이 일어난다면 kafka, rabbitMQ와 같은 Message 큐를 도입하여 Worker Node에서 처리하게 하는 것이 어떨까라는 생각을 하였다.