ScheduledExecutorService를 통해 일정 시간 후 작업 수행되는 기능 만들기 (시험 시간 후 종료 상태로 변경하는 기능 만들기)

2024. 4. 29. 18:30Backend/Spring

 

모두의 랜덤 디펜스에서 제한 시간이 존재하는 시험 기능이 있습니다.

 

이전 프로젝트까지는 Client에서 직접 시험을 종료 시키는 API를 호출한 뒤, 백엔드에서 종료를 반영하는 역할을 수행했지만, 

 

이 경우는 "사용자가 시험 도중에 강제 중단하는 경우" 를 완벽하게 해결할 수 없었습니다.

 

사용자가 중도에 시험을 강제 종료할 경우 client에서 시험 종료 API를 호출할 수 없었고, 시험 기록 일부가 몇 달이 지나도 IN_PROGRESS로 남아있는 현상이 발생했습니다.

 

따라서 이번에는 시험이 시작된 후 제한 시간이 지나면 종료시키는 로직을 추가하고자 했습니다.


Spring에서 일정 시간 후 DB 필드를 종료시키기 

아주 간단한 CompletableFuture

아주 간단하게 생각할 수 있는 방법은 CompletableFuture를 이용하여 비동기로 Thread.sleep(시험 시간) 이후 DB필드를 변경하는 방법입니다. 

 CompletableFuture.runAsync(() -> {
    try {
        Thread.sleep(3600000); // 1시간 동안 대기
        endSession(savedDefenseSession); // 시간이 지난 후 세션 종료 로직
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});

이러한 형태를 가지게 되는데

 

하지만 이 방식은 각 시험마다 별도의 스레드를 사용하기 때문에, 동시에 많은 시험이 진행될 경우 그만큼 많은 스레드를 소비하게 되어 스레드 스택으로 인해 메모리를 비효율적으로 사용하게 됩니다.

 

따라서 스레드를 적절한 수로 유지하면서 해당 작업을 수행할 수 있게 기능을 구현하기 위해  ScheduledExecutorService를 사용하기로 했습니다.

 

ScheduledExecutorService는 정해진 수의 스레드로, 종료 작업이 진행되는 순서대로 작업 큐에 정렬된 상태에서, 스레드는 wait상태를 유지하다 순차적으로 필요할 때만 스레드를 활성화시켜 실행하기 때문에 CompletableFuture를 사용한 것 보다 효과적으로 구현할 수 있게 됩니다.

 

ScheduledExecutorService의 작업 큐는 내부적으로 우선순위 큐를 사용하여 모든 스케줄된 작업을 관리합니다.

 

ScheduledExecutorService가 작동되는 원리는 아래 포스팅에 정리해보았습니다.

https://miiiinju.tistory.com/19

 

ScheduledThreadPoolExecutor가 작동하는 원리

ScheduledThreadPoolExecutor가 작동하는 원리를 이해하기 위해 먼저 ThreadPoolExecutor의 동작을 이해해보겠습니다. ThreadPoolExecutor가 작동되는 원리Worker: ThreadPoolExecutor에 의해 관리되는 각 스레드 인스턴

miiiinju.tistory.com

 

ScheduledExecutorService 사용 예시

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

scheduler.scheduleAtFixedRate(() ->{
    System.out.println("some task...");
} , 0, 5, TimeUnit.MINUTES);

 

 

적용한 코드 예시

@Service
@RequiredArgsConstructor
public class DefenseTimerService {

    private final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    private final SessionService sessionService;

    public void startDefenseTimer(DefenseSession defenseSession) {

        long delay = Duration.between(defenseSession.getStartDateTime(), defenseSession.getEndDateTime()).toMillis();

        scheduler.schedule(() -> {
            defenseSession.terminateDefense(sessionService);
        }, delay, TimeUnit.MILLISECONDS);

    }

}

Executors.newSingleThreadScheduledExecutor()

ScheduledExecutorService를 구현하는 ScheduledThreadPoolExecutor를 생성하여 단일 스레드를 사용하여 모든 예약된 작업을 순차적으로 실행합니다.


트랜잭션 문제

하지만 이런 방법에도 치명적인 문제점이 있었는데, 만약 특별한 이유로 타이머가 시작된 이후에 예외가 발생하여 

트랜잭션이 롤백 되는 경우 타이머는 계속 실행될 것이고, 의도하지 않은 동작을 수행하게 될 가능성이 존재했습니다.

 

 

기존 코드에서는 직접 DefenseTimerService를 의존하고, 이는 Transactional의 영향을 받지 않습니다.

 

 

이를 해결하기 위한 방법을 두 가지 생각해보았습니다.

 

AOP를 사용하는 방법

첫 번째는 AOP를 이용하여 특정 어노테이션으로 PointCut을 설정하고, 어노테이션 달려있는 메소드에서 pjp.proceed() 이후 예외가 발생하지 않으면 작동시키는 방법입니다.

위처럼 Timer라는 어노테이션에 pointcut을 설정한 후 

Timer를 사용하려는 메소드에 어노테이션을 추가하여 사용할 수 있습니다.

 

하지만 위 사진처럼 비즈니스 로직상 StartDateTime과 EndDateTime을 반환하지 않고 있기에 AOP를 이용하기 힘들었고, 
또한 프록시로 작동하는 점과,

 

시험이 시작되는 상황 중 일부에서만 타이머 동작을 AOP로 구현 설정하는 경우, 관련 비즈니스 로직이 여러 부분으로 흩어지게 되어 전체적인 흐름을 한눈에 파악하기 어렵게 만드는 문제가 발생합니다.

 

[타이머의 작동 조건이 보다 미세하게 조정되어야 할 때, AOP를 사용함으로써 구현의 복잡도가 더욱 증가할 수 있다고 보았습니다]

이벤트를 사용하는 방법

따라서 두 번째 이벤트를 활용하여 이벤트가 발행되고 트랜잭션이 커밋된 이후에 실행 될 수 있도록 (@TransactionalEventListener 사용) 하는 방법을 선택했습니다.

 

이벤트를 사용하면 시험을 시작하는 파사드에서 TimerService를 직접 의존하지 않아도 되어 결합도를 낮출 수 있습니다.

ApplicationEventPublisher를 통해 이벤트를 발행한 뒤 

 

어노테이션 기반으로 발행된 이벤트를 실행할 수 있는데, phase를 통해 실제 트랜잭션 단계 중 언제 핸들러를 수행할지 결정할 수 있습니다.

 

AFTER_COMMIT을 사용하면 이벤트를 받은 후 트랜잭션이 성공적으로 완료된 경우 핸들러를 실행합니다.

 

이렇게 시험이 시작하는 도중 트랜잭션이 롤백되더라도 타이머와의 불일치를 해소할 수 있었습니다.

 

[또한 이벤트 발행 코드가 비즈니스 로직 내에 자연스럽게 녹아들어가 코드의 가독성에도 도움이 되었고, AOP의 작동 조건이 보다 미세하게 조정될 때에도 구현의 복잡도를 낮출 수 있었습니다.]

 

 

시험 종료 시 의도치 않은 예외가 발생하는 경우

시험 종료 시 이미 종료되어 예외가 발생하는 경우를 제외하고, 의도치 않은 예외가 발생하는 경우를 고려하여 예외 처리를 해주었습니다.

만약 의도되는 예외가 아닐 경우에 retryTermination을 다시 스케줄링합니다.

스케줄링되는 재 종료 로직은 재귀로 구성하여 최대 3번까지 시도하도록 구성했습니다.

 

여전히 걱정되는 점

AWS의 Auto Scailing Group을 사용하게 되었을 때, scale-out된 인스턴스에 타이머가 설정된 후

scale-in되는 경우 타이머가 손실 될 것으로 보입니다.

 

 

이런 점을 염두에 둔 분산 타이머 인스턴스를 사용하거나 완전히 다른 방법으로 대체해야 할 수도 있을 것 같습니다.