1311 words in content
14 minutes for read
Scheduled Annotation에서 ThreadPoolTaskScheduler를 사용한 시스템 최적화

인식한 상황#

최소한의 기능이며 메인 기능인 방 생성/삭제/참여/퇴장, 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);
        }
    }
}

결과#

Image

로직을 변경하니 모들락 종료 요청이 별도로 들어올 경우, 해당 모들락 식별자를 통해 Map에서 ScheduledFuture 객체를 찾아내고, 이를 이용해 예약된 작업을 취소할 수 있다. 필요하다면 예약 시간 변경이나 추가 작업 예약도 유연하게 처리할 수 있었다. 또한, 모들락이 예상보다 일찍 종료될 경우같은 예기치 않은 상황에도 이미 예약된 작업을 무효화할 수 있으므로 불필요한 로직 실행을 막고 시스템 자원 낭비를 없앨 수 있었다.

사실 시간이 부족해서 API 서버 내부의 ConcurrentHashMap을 사용했지만, 이후 설계를 한다면 다음과 같다.

API Server에서 모들락에 대한 생성, 삭제, 수정이 일어난다면 kafka, rabbitMQ와 같은 Message 큐를 도입하여 Worker Node에서 처리하게 하는 것이 어떨까라는 생각을 하였다.