한결과 레지아이스

[42GG] 스케쥴러 구현기 - 나의 첫 추상 클래스 본문

42GG/개발

[42GG] 스케쥴러 구현기 - 나의 첫 추상 클래스

miniwho 2022. 8. 7. 14:24
42GG 서비스를 개발하며 스케쥴러를 사용하고, 그 과정에서 추상 클래스를 처음 만들어본 경험을 적은 글입니다.

 

 

  우리 서비스엔 총 4개의 스케쥴러가 존재한다. 우리 서비스를 제공하는 2시에서 5시 사이에 각 5분, 10분 간격으로 돌아가는 스케쥴러 하나씩, 그리고 매일 한 번씩 실행되는 스케쥴러가 두 개 해서 총 네 개이다.

  이런 스케쥴러를 어떤 방법으로 만들었는지, 그리고 처음에는 하나였던 스케쥴러가 네 개로 늘어나며 어떻게 추상 클래스를 구성하게 되었는지 설명해보려 한다.

 

스케쥴러

 

  우리 서비스는 매일 일정 숫자의 게임 슬롯을 생성한다. 그리고 매 5분 간격으로 슬롯이 꽉 찼는지 여부에 따라 잠시후 매치가 시작된다는 알림을 보낸다. 10분 간격으로는 게임이 성사되었는지 여부에 따라 취소되었다는 알림, 혹은 슬롯에 들어온 유저들로 구성된 게임을 생성한다. 그리고 하루의 끝에 그날 동안 레디스에 저장된 정보를 DB에 백업한다.

  이렇게 시간 기반으로 돌아가야하는 기능들이 있다보니, 쉘에서 사용하는 Cron과 같은 기능을 사용할 수 있다면 어떨까? 라는 생각을 했는데 역시나 스프링엔 없는게 없었다.

  가장 처음 스케쥴러 기반 기능들을 구현할 때 사용했던 방법은 @Scheduled라는 어노테이션을 사용하는 것이다.

 

출처: 스프링 공식 문서   https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html

 

  fixedDelay로 매 5000밀리초, 즉 5초마다 실행되게 하는 스케쥴러의 예시이다. 이렇게 고정된 delay 값을 줄 수도 있지만, 우리는 2시-5시라는 서비스 시간 중에만 돌아갈 스케쥴러도 필요했으므로, cron 표현식을 사용했다.

 

출처: 스프링 공식 문서   https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html

 

  단순히 @Scheduled라는 어노테이션을 쓰는 것만으로는 부족하고, 추가해줘야할 것이 있었다. App의 Configuration 클래스에 @EnableScheduling 이라는 어노테이션을 붙이는 것이었다.

 

출처: 스프링 공식 문서   https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html

 

  밑에 보면 SchedulingConfigurer 인터페이스의 구현체로 스케쥴러의 풀 사이즈를 설정해 줄 수 있었는데, 우리는 기본 설정값인 1인 그대로 사용했다. 시간이 겹치는 스케쥴러가 2개 뿐이었고, 실행속도가 빨라 동기적으로 처리돼도 괜찮다고 생각했기 때문이다.

 

ThreadPoolTaskScheduler

 

  어드민 페이지를 구현하면서 문제가 생겼다. 우리의 서비스 제공시간이 현재는 2시에서 5시지만, 언제 시간이 늘어나거나 바뀔지 모른다. 현재는 게임이 10분간격으로 존재하지만, 간격을 조정하게 될 수도 있다. 이런 설정들을 어드민 페이지를 통해 조정할 수 있도록 하는 과정에서, @Scheduled를 통해 만들어진 스케쥴링 시간을 바꾸는데 실패했다. 그리고 구글링 중에 굉장히 감사한 블로그 포스팅을 발견했다(https://myhappyman.tistory.com/235). 내가 해야하는 것과 완벽히 같아 많이 참고할 수 있었다.

  스프링에는 독립적인 스레드를 만들어 Task를 실행시켜주는 TaskExecutor라는 인터페이스가 있다. 여기서 가장 많이 사용되는 구현체로는 ThreadPoolTaskExecutor가 있다. 스레드풀을 만들고 해당 풀의 스레드로 Task를 실행하는 이름 그대로의 구현체이다.

  그리고 TaskExecutor와 같이 Task를 실행시켜주지만, 원하는 시간적 조건에 따라 반복하게 만들 수 있는 TaskScheduler 인터페이스가 존재한다. 여기서 가장 많이 사용되는 구현체가 바로 ThreadPoolTaskScheduler이고, 우리 서비스에 사용하게 되었다.

  그리고 알게 된게, @Scheduled 어노테이션도 똑같이 TaskScheduler 기반이지만, 코드로 임의로 끄고 시작하는 등 조작을 할 수 없다는 게 문제였다는 것이다. 이제 나는 스케쥴러 클래스들에 ThreadPoolTaskScheduler 객체를 만들고, 객체를 통해 스케쥴러를 시작하고 종료하고 크론 세팅을 수정하는 등의 작업을 할 수 있게 되었다.

  TaskScheduler 구현체의 객체는 schedule이라는 메서드를 가지고, Runnable 함수형 인터페이스를 따르는 메서드와 Trigger를 인자로 받는다. Trigger에는 CronTrigger를 사용했고, Cron 표현식 스트링을 이용할 수 있었다.

//기존 코드 예시
//...
public class CurrentMatchUpdater {
		//...
		@Scheduled(cron = "0 */10 14-17 * * *") // 초 분 시 일 월 년 요일
    public void updateIsImminent() {
	        //do something...
        }
    }
		//...
}

//변경 이후
public class CurrentMatchUpdater {
		private String cron;
		public void updateIsImminent() {
	        //do something...
        }
    }

		public Runnable runnable() {
        return () -> {
                updateIsImminent();           
        };
    }
		public void renewScheduler() { // cron을 수정한 뒤에 스케쥴러를 종료하고 새 cron으로 재시작
		        scheduler.shutdown();
		        startScheduler();
    }

    @PostConstruct
    public void init() {
        startScheduler();
    }

    @PreDestroy
    public void destroy() {
        scheduler.shutdown();
    }

    private void startScheduler() {
        scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(1);
        scheduler.initialize();
        scheduler.schedule(this.runnable(), new CronTrigger(cron));
    }
}

  근데 하나의 클래스를 이렇게 바꿔주면서, ‘어차피 나머지 세 개의 스케쥴러 클래스에 중복된 코드들을 넣을 거면 좀 더 효율적으로 코드를 작성해보자!’ 라는 생각이 들었다. 그때 추상 클래스를 떠올리게 되었다.

 

추상 클래스

 

  결과적으로 만들어진 추상 클래스의 생김새는 다음과 같다.

public abstract class AbstractScheduler {
    private ThreadPoolTaskScheduler scheduler;
    @Getter @Setter
    protected String cron;
    @Getter @Setter
    protected Integer interval;

    public abstract Runnable runnable();

    public void renewScheduler() {
        scheduler.shutdown();
        startScheduler();
    }

    @PostConstruct
    public void init() {
        startScheduler();
    }

    @PreDestroy
    public void destroy() {
        scheduler.shutdown();
    }

    private void startScheduler() {
        scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(1);
        scheduler.initialize();
        scheduler.schedule(this.runnable(), new CronTrigger(cron));
    }
}

  모든 스케쥴러에 공통으로 들어가야할 부분들을 구현하고, 각 스케쥴러가 달라야할 부분인 runnable() 메서드만 추상 메서드로 놔뒀다. 여기서 Runnable이 나오는데, Runnable은 함수형 인터페이스다. Thread를 만들 때 실행될 메서드는 Runnable의 구현체여야 한다. 이렇게 추상 클래스를 만들었고, 이를 확장해 네 개의 스케쥴러 클래스들을 만들었다.

//생략된 내용이 많습니다. 얼개만 봐주세요
public class SlotGenerator extends AbstractScheduler {
    private final SlotService slotService;

    public SlotGenerator(SlotService slotService) {
        this.slotService = slotService;
        this.setCron("30 0 0 * * *");
    }

    public void dailyGenerate() {
        //생략
    }

    @Override
    public Runnable runnable() {
        return () -> {
            dailyGenerate();
        };
    }
} 

// 에러를 반환하는 함수로 runnable()을 만들때
		@Override
    public Runnable runnable() {
        return () -> {
            try {
                updateIsImminent();
            } catch (MessagingException e) {
                e.printStackTrace();
            }
        };
    }

  Runnable 인터페이스를 구현하는 runnable() 메서드는 람다식을 통해 구현했다. Exception을 던져야 하는 클래스의 경우, Runnable은 Callable과 다르게 Exception을 낼 수 없어서 람다식 내부에 try/catch문을 통해 해결했다.

 

스케쥴러가 작동하지 않을 때를 위한 대비

 

  스케쥴러가 모두 완성되고 테스트 서버에 올려 테스트를 하는 도중, 스케쥴러가 돌아가지 않아 에러가 발생하는 일이 종종 생겼다. 이유는 테스트 서버를 새로 올리는 주기가 잦아서 업데이트 도중 스케쥴러가 작동을 했어야 하는 시간이 지나가면 스케쥴러가 동작하지 않기 때문이다.

  서비스가 시작되고 나면 스케쥴러 시간을 피해서 업데이트판을 배포할 수 있겠지만, 그래도 클라우드에 문제가 생긴다거나 하는 스케쥴러가 동작하지 않는 케이스에 대비하고 싶었다.

  예컨대 스케쥴러 중 하나는 game의 isImminent라는 bool 변수를 True로 바꿔준다. 처음 구현할 때는 스케쥴러가 아니고 유저가 새로고침을 했을 때 isImminent가 True여야 하는 상황이 되면 값을 바꿔주는 방향을 생각했다. 하지만 우리는 소켓통신을 하지 않기 때문에 유저가 서비스에서 아무런 행동도 하지 않을 때는 값이 갱신이 되지 않는다. 바로 이 방식을 백업으로 사용하게 되었다. 스케쥴러가 돌지 않으면, 유저의 요청이 들어왔을 때 시간을 확인하고 값을 갱신해 주는 것이다.

 

 

  추상클래스와 함꼐한 스케쥴러 개발기였습니다. 난생 처음 보는 것들을 가지고 개발하려니 참 어려웠지만, 구글과 함께하여 겨우겨우 원하는 아웃풋을 낼 수 있었습니다. 하지만 구글링을 할 때 공부를 제대로 해야된다는 깨달음을 얻기도 했습니다. Runnable 메서드를 구현할 때, Exception을 던지는 메서드가 왜 대체 안들어갈까 한참 낑낑대며 고민하던게 결국 다 학습의 부족에서 온 기술 부채였습니다.
  하지만 생소한 것들을 접하고 사용해 보는 것은 재밌고 신선한 경험이기도 했습니다. 책에서만 보던 추상 클래스를 내 입맛에 맞게 잘 사용한 건 정말 뿌듯한 경험이었습니다. 다음 목표는 인터페이스를 내 입맛대로 내 의지로 만들어 사용해보는 것입니다. 그날까지 화이팅!

 

Comments