본문 바로가기
팀 프로젝트/최종 프로젝트

DB에 데이터 랜덤하게 삽입하기 (1) 커스텀 스케줄 어노테이션 구현

by pon9 2025. 2. 21.

현재 진행중인 프로젝트는 '채용 공고를 한 곳에서 모아보는 사이트' 이다.
최대한 실제 사용환경처럼 구축해보고싶어서 DB에 랜덤한 채용공고 데이터를 삽입하는 기믹이 필요했고

@Scheduled 어노테이션으로는 한계가 존재했다.

 

왜냐하면 딱 일정한 시간 간격 또는 고정된 시간에만 삽입이 가능했기 때문이다.

랜덤한 시간에 넣을 순 없을까? 라는 고민을 하다가 커스텀 어노테이션을 만들기로 했다.

커스텀 어노테이션을 만든다면, 채용 공고 외의 다른 데이터들도 편하게 랜덤한 시간대에 삽입할 수 있다!

 

 

BeanPostProcessor

BeanPostProcessor는 스프링 빈의 생명주기 중, 빈이 생성되고 나서 빈을 등록하기 전에 개발자가 원하는 후처리를 할 수 있게 해주는 인터페이스이다.

스프링 컨테이너가 모든 빈을 생성한 뒤, 내가 만든 커스텀 어노테이션이 달린 메서드를 찾아서 스케줄러에 등록하게 할 수 있다.

BeanPostProcessor 인터페이스에는 두 개의 디폴트 메서드가 정의되어있다.

하나는 postProcessBeforeInitialization, 다른 하나는 postProcessAfterInitialization이다. 디폴트기 때문에 상속 후 무조건 구현하지 않아도 된다.

이 두 메서드의 차이점은 '후처리 시점' 에 있다.

before은 객체 생성 후, 초기화 작업 이전에 후처리를 진행한다.(before any bean initialization callbacks)

초기화 작업 이전의 후처리작업을 통해 얻을 수 있는 이점은 뭐가 있을까? 일단 스케줄러에도 못 쓸 듯 하고.. 찾아보니 거의 쓰이지 않는다고 한다.

after은 객체 생성 후, 초기화 작업 이후에 후처리를 진행한다.(after any bean initialization callbacks)

스케줄링은 어플리케이션 실행 중에 태스크를 동적으로 예약하는 작업이다.

내가 만들 어노테이션은 랜덤한 시간 간격을 동적으로 계산해야하므로, 빈이 초기화 된 이후에 안전한 상태에서 어노테이션을 등록하는게 맞다.

 

 

TaskScheduler

TaskScheduler는 스프링의 스케줄링 기능을 확장한 것으로, 비동기적이거나 주기적인 task를 예약하고 실행할 수 있게 해준다.

대표적인 구현체로는

1. 스레드 풀을 기반으로 다중 태스크를 병렬로 처리할 수 있게 하는 ThreadPoolTaskScheduler

2. 단일 스레드를 기반으로 태스크를 순차적으로 처리할 수 있게 하는 ConcurrentTaskScheduler

가 있다.

여기서 내가 구현해야 될 스케줄러는,

1. 랜덤한 시간 간격이 필요하다

2. 여러 태스크를 동시에 처리해야 한다(여러개의 채용공고를 동시 업데이트 할 수 있게끔, 채용공고 뿐만 아니라 다른 데이터를 삽입할 때에도 효율적으로 사용 가능해야 하니)

 

따라서 ThreadPoolTaskScheduler를 사용하자.

 

 

구현 코드

public DynamicScheduledTaskRegistrar() {
    ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
    scheduler.setPoolSize(3);
    scheduler.initialize();
    this.taskScheduler = scheduler;
}

우선 클래스의 생성자에 스케줄러를 정의해준다.

ThreadPoolTaskScheduler의 기본 스레드 개수는 1개로, setPoolSize()로 개수를 지정할 수 있다.

나는 한 번에 최대 3개의 채용공고를 올릴 예정이라 우선 3으로 설정했다.

@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
    for (Method method : bean.getClass().getDeclaredMethods()) {
        if (method.isAnnotationPresent(ScheduledDynamic.class)) {
            ScheduledDynamic annotation = method.getAnnotation(ScheduledDynamic.class);
            scheduleDynamicTask(bean, method, annotation);
        }
    }
    return bean;
}

private void scheduleDynamicTask(Object bean, Method method, ScheduledDynamic annotation) {
    Runnable task = new ScheduledMethodRunnable(bean, method);
    scheduleNextRun(task, annotation);
}

BeanPostProcessor를 상속받고, postProcessAfterInitialization을 오버라이딩해서 내 @ScheduledDynamic 어노테이션을 스프링에서 인식할 수 있게 해준다.

빈 객체들이 초기화 된 후 빈 인스턴스들에 접근해 해당 어노테이션이 붙어있는 메서드를 찾으면,

그 메서드를 new ScheduledMethodRunnable()로 Runnable타입의 태스크로 등록해준다.

빈은 따로 수정하지 않고 그대로 반환해서 해당 빈에 부가적인 스케줄링 기능만 추가해줬다.

private void scheduleNextRun(Runnable task, ScheduledDynamic annotation) {
    long interval = getRandomInterval(annotation.minMinutes(), annotation.maxMinutes());

    taskScheduler.schedule(() -> {
        try {
            task.run();
        } finally {
            scheduleNextRun(task, annotation);
        }
    }, triggerContext -> {
        long nextExecutionTime = System.currentTimeMillis() + interval;
        return new Date(nextExecutionTime).toInstant();
    });
}

private long getRandomInterval(int min, int max) {
    return (random.nextInt(max - min + 1) + min) * 60 * 1000L;
}

태스크로 등록된 메서드는 scheduleNextRun 메서드에 파라미터로 전달된다.

taskScheduler.schedule(Runnable, Trigger)는 동적인 Trigger를 이용해 Runnable태스크를 예약하고 실행시킬 수 있는 메서드다.

여기서 나는 어노테이션에서 파라미터로 받은 minMinutes, maxMinutes를 이용해 최대치와 최소치 사이에서 랜덤으로 트리거가 발동되도록 했고, 

태스크가 try문에서 실행된 후 finally블록에서 다시 재귀적으로 호출해 다음 실행을 예약하여 동적 주기로 태스크가 반복되도록 구현했다.

 

요약하자면,

1. postProcessAfterInitialization에서 @ScheduledDynamic(min=3, max=40)가 붙은 메서드를 감지하여 빈으로 등록한다.

2. scheduleDynamicTask로 해당 메서드를 Runnable객체로 만들고,

3. scheduleNextRun에서 랜덤 간격(min - max 사이의 랜덤값 ex 여기서는 5분) 을 계산 후 첫 실행을 예약한다.

4. 5분 후 메서드를 실행하고 다시 랜덤 간격(33분)으로 다음 실행을 예약한다.

5. 3-4의 과정이 무한히 반복된다

 

 

사용 & 트러블

실험하느라 간격을 짧게 잡았는데 원래는 한 20분에서 40분 사이로 기획중이다 ㅎㅎ

정상적으로 데이터가 랜덤한 시간대에 삽입되는지 확인해보자

 

로그를 만들기는 조금 귀찮기 때문에 DB로 확인했다

이럴수가!!!!!!!!

왜 최소값인 1분간격으로만 데이터가 생성되는것일까?

로그를 찍어보자..ㅠㅠㅠ

단순히 1분과 2분이라서 생긴 일 일수도 있다. 

2025-02-22T00:11:36.798+09:00  JobOpeningInsertScheduler    : 랜덤 채용공고 데이터 삽입 시작
2025-02-22T00:11:36.799+09:00 JobOpeningInsertScheduler    : 3개의 채용 공고 삽입 예정

2025-02-22T00:11:36.858+09:00  JobOpeningInsertScheduler    : 총 3개의 채용 공고가 삽입되었습니다.
2025-02-22T00:11:36.858+09:00  DynamicScheduledTaskRegistrar  : 다음 스케줄링 시간 : Sat Feb 22 00:12:36 KST 2025
2025-02-22T00:11:36.858+09:00  DynamicScheduledTaskRegistrar  : 다음 스케줄링 시간 : Sat Feb 22 00:13:36 KST 2025
2025-02-22T00:12:36.861+09:00  JobOpeningInsertScheduler    : 랜덤 채용공고 데이터 삽입 시작
2025-02-22T00:12:36.861+09:00  JobOpeningInsertScheduler    : 1개의 채용 공고 삽입 예정

2025-02-22T00:12:36.876+09:00  JobOpeningInsertScheduler    : 총 1개의 채용 공고가 삽입되었습니다.
2025-02-22T00:12:36.876+09:00  DynamicScheduledTaskRegistrar  : 다음 스케줄링 시간 : Sat Feb 22 00:14:36 KST 2025
2025-02-22T00:12:36.876+09:00  DynamicScheduledTaskRegistrar  : 다음 스케줄링 시간 : Sat Feb 22 00:13:36 KST 2025

2025-02-22T00:13:36.877+09:00  JobOpeningInsertScheduler    : 랜덤 채용공고 데이터 삽입 시작
2025-02-22T00:13:36.877+09:00  JobOpeningInsertScheduler    : 2개의 채용 공고 삽입 예정

2025-02-22T00:13:36.912+09:00  JobOpeningInsertScheduler    : 총 3개의 채용 공고가 삽입되었습니다.
2025-02-22T00:13:36.912+09:00  DynamicScheduledTaskRegistrar  : 다음 스케줄링 시간 : Sat Feb 22 00:15:36 KST 2025
2025-02-22T00:13:36.912+09:00  DynamicScheduledTaskRegistrar  : 다음 스케줄링 시간 : Sat Feb 22 00:15:36 KST 2025

 

로그를 확인해보니 문제는 다른 곳에 있었다. 태스크가 중복으로 예약된다! 이 때문에 1분간격으로만 동작하는 걸로 보인 것이다.

예약 작업(트리거)가 두번씩 호출된 걸 봐서는 재귀호출에서 로직상 오류가 있다는거다.

트리거를 어떻게 한 번만 발동되도록 제어할 수 있을까?

 

스레드가 여러 개인 것이 문제인가?라는 생각이 들어, 한 번에 하나의 채용공고만 올리도록 수정해서 로그를 살펴보자.

2025-02-22T00:35:35.823+09:00  JobOpeningInsertScheduler    : 랜덤 채용공고 데이터 삽입 시작
2025-02-22T00:35:35.823+09:00  JobOpeningInsertScheduler    : 1개의 채용 공고 삽입 예정
2025-02-22T00:35:35.877+09:00  JobOpeningInsertScheduler    : 총 1개의 채용 공고가 삽입되었습니다.
2025-02-22T00:35:35.877+09:00  DynamicScheduledTaskRegistrar  : 다음 스케줄링 시간 : Sat Feb 22 00:36:35 KST 2025
2025-02-22T00:35:35.878+09:00  DynamicScheduledTaskRegistrar  : 다음 스케줄링 시간 : Sat Feb 22 00:37:35 KST 2025

 

여전히 두번씩 실행되고,, 스레드 문제는 아닌 것 같다.

 

이번엔 아예 재귀 호출을 지워보자. 

private void scheduleNextRun(Runnable task, ScheduledDynamic annotation) {
    long interval = getRandomInterval(annotation.minMinutes(), annotation.maxMinutes());
    taskScheduler.schedule(task, triggerContext -> {
        long nextExecutionTime = System.currentTimeMillis() + interval;
        return new Date(nextExecutionTime).toInstant();
    });
}

 

2025-02-22T00:58:09.569+09:00  JobOpeningInsertScheduler    : 랜덤 채용공고 데이터 삽입 시작
2025-02-22T00:58:09.570+09:00  JobOpeningInsertScheduler    : 1개의 채용 공고 삽입 예정
2025-02-22T00:58:09.644+09:00  JobOpeningInsertScheduler    : 총 1개의 채용 공고가 삽입되었습니다.
2025-02-22T00:58:09.644+09:00  DynamicScheduledTaskRegistrar  : 다음 스케줄링 시간 : Sat Feb 22 01:00:09 KST 2025

 

잘된다 헤헿

schedule(Runnable, Trigger)메서드를 잘 모른 채로 사용한 것이 화근이었다 ㅎㅎ

원래 scheduleNextRun메서드에서 태스크 실행 후에 다음 실행을 예약하기 위해 재귀로 설계했는데, 애초에 Trigger를 통해 자동실행을 관리한다고 설명한다.

 

아무튼 커스텀 어노테이션은 여기서 마무리! 아직 개선해야 될 문제가 남았는데 그건 바로 동기화 문제다.

롤링 업데이트 시 서버가 두 개가 된다면? 그리고 여러 개의 스레드가 동시에 DB에 접근할 때 어떤 일이 발생할까?
에 대해서는 다음 글에서 살펴보자.