[Java] 스케줄링 작업 구현과 cron 표현식

관리자 페이지를 운영하다 보면 시스템 로그, 로그인 로그, 사용자 활동 로그, 접근 기록 등 다양한 로그 데이터가 DB에 지속적으로 쌓이게 된다. 이러한 로그 데이터는 시스템 모니터링, 보안 감사, 사용자 행동 분석 등 중요한 용도로 활용되지만 시간이 지날수록 DB 용량을 차지하고 조회 성능에 영향을 미칠 수 있다.

특히 로그 테이블의 경우, 데이터가 계속 누적되면서 아래와 같은 문제가 발생할 수 있다:

(1) DB 저장 공간 부족

  • 로그 데이터가 지속적으로 증가하며 상당한 저장 공간을 차지
  • 불필요한 과거 데이터로 인한 비용 증가

(2) 조회 성능 저하

  • 대용량 테이블로 인한 쿼리 실행 시간 증가
  • 인덱스 효율성 감소

(3) 백업 및 유지보수 어려움

  • 대용량 데이터로 인한 백업 시간 증가
  • 테이블 유지보수 작업의 복잡도 증가

이러한 문제를 해결하기 위해서는 일정 기간(예: 1년)이 지난 로그 데이터를 계획적으로 삭제하는 것이 필요하다. 그러나 한 번의 대규모 DELETE 쿼리로 데이터를 삭제하는 것에도 위험성이 있다:

(1) DB 성능 영향

  • 대량의 DELETE 작업은 DB 서버에 큰 부하를 줄 수 있음
  • 트랜잭션 로그의 급격한 증가

(2) 서비스 응답 시간 저하

  • 대규모 삭제 작업 중 다른 쿼리의 지연 발생
  • 전체 시스템 성능에 영향

(3) 락(Lock) 경합

  • 긴 시간 동안의 테이블 락으로 인한 다른 작업 블로킹
  • 데드락 발생 가능성 증가

따라서, 로그 데이터의 효율적인 관리를 위해서는 체계적인 삭제 전략이 필요하다. 이러한 문제를 해결하기 위해 스케줄러에 대해 알아보려고 한다.

스케줄러는 특정 시간에 작업을 자동으로 실행하게 해주는 도구이다. 자바에서는 여러 가지 방법으로 스케줄러를 구현할 수 있는데, 각각의 방법을 자세히 정리해보겠다.


1. Timer와 TimerTask

가장 기본적인 방법으로, Java의 기본 API를 사용하는 방식이다. Timer 클래스는 백그라운드 스레드로 실행되며 TimerTask는 실제 실행될 작업을 정의한다.

public class BasicScheduler {
    public static void main(String[] args) {
				// Timer 객체 생성
        Timer timer = new Timer();

				// 실행할 작업 정의
        TimerTask task = new TimerTask() {
            @Override
            public void run() {
                System.out.println("작업 실행: " + new Date());
						// Todo: 여기에 실제 작업 내용을 작성한다.
            }
        };

				// 5초 후에 시작해서 3초마다 실행
        timer.scheduleAtFixedRate(task, 5000, 3000);
    }
}

이 방법은 간단하지만 예외 처리나 스레드 관리가 제한적이라는 단점이 있다.

2. ScheduledExecutorService

Java 5부터 도입된 방법으로, Timer보다 더 많은 기능을 제공하며 스레드 풀을 사용하여 여러 작업을 동시에 처리할 수 있다.

public class ExecutorScheduler {
    public static void main(String[] args) {
					// 스레드 풀 생성 (여기서는 1개의 스레드만 사용)
	        ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);

				// 실행할 작업 정의
        Runnable task = () -> {
            try {
                System.out.println("작업 실행: " + new Date());
							// Todo: 실제 작업 내용
            } catch (Exception e) {
                System.err.println("작업 실행 중 오류 발생: " + e.getMessage());
            }
        };

				// 1분마다 실행
        executor.scheduleAtFixedRate(task, 0, 1, TimeUnit.MINUTES);
    }
}

이 방법은 예외 처리가 용이하고 작업 취소나 상태 확인이 쉽다는 장점이 있다.

3. Spring Framework의 @Scheduled

Spring Framework를 사용한다면(현재 진행중인 프로젝트에서 쓰는 방식), @Scheduled 어노테이션으로 간단하게 스케줄링을 구현할 수 있다.

@Component
public class SpringScheduler {
    private static final Logger logger = LoggerFactory.getLogger(SpringScheduler.class);

		// 매일 오전 9시에 실행
		// 순서대로: 초 분 시 일 월 요일 [년도]
    @Scheduled(cron = "0 0 9 * * ?")
    public void dailyMorningTask() {
        logger.info("아침 9시 작업 시작");
        try {
						// Todo: 작업 내용
            performMorningTask();
        } catch (Exception e) {
            logger.error("작업 실행 중 오류: ", e);
        }
    }

		// 5초마다 실행
    @Scheduled(fixedRate = 5000)
    public void periodicTask() {
        logger.info("주기적 작업 실행");
				// Todo: 작업 내용
    }

    private void performMorningTask() {
		// Todo: 작업 내용
    }
}

4. Cron 표현식 자세히 이해하기

Cron 표현식은 작업 실행 시간을 정의하는 문자열로 총 6-7개의 필드로 구성된다.

초 분 시 일 월 요일 [년도]

각 필드별 가능한 값:

  • 초: 0-59
  • 분: 0-59
  • 시: 0-23
  • 일: 1-31
  • 월: 1-12 또는 JAN-DEC
  • 요일: 0-7 또는 SUN-SAT (0과 7은 일요일)
  • 년도: 생략 가능

5. 자주 사용하는 Cron 표현식 예시

// 매일 특정 시간에 실행
"0 0 12 * * ?"    // 매일 12시 정각
"0 15 10 * * ?"   // 매일 10시 15분

// 주기적 실행
"0 0/30 * * * ?"  // 30분마다
"0 0 */2 * * ?"   // 2시간마다

// 특정 요일/시간 실행
"0 0 9 ? * MON-FRI"   // 평일 9시
"0 0 0 1 * ?"         // 매월 1일 0시 

6. 특수 문자 별 의미

* : 모든 값
? : 특정 값 없음 (일, 요일에서만 사용)
- : 범위 지정 (예: MON-FRI)
, : 여러 값 지정 (예: MON,WED,FRI)
/ : 증분값 (예: 0/15는 0분부터 15분마다)
L : 마지막 (예: 달의 마지막 날)
W : 가장 가까운 평일 (예: 15W는 15일에서 가장 가까운 평일)
# : N번째 요일 (예: 6#3는 3번째 금요일)

7. 실제 활용 예제

(1) 일일 데이터 백업 스케줄러

@Component
@EnableScheduling
public class BackupScheduler {
    private final Logger logger = LoggerFactory.getLogger(BackupScheduler.class);

    @Scheduled(cron = "0 0 2 * * ?")// 매일 새벽 2시
    public void dailyBackup() {
        logger.info("일일 백업 시작: {}", LocalDateTime.now());
        try {
						// 1. 백업할 데이터 수집
            List<Data> dataToBackup = collectData();

						// 2. 백업 실행
            boolean success = performBackup(dataToBackup);

						// 3. 결과 기록
            logger.info("백업 완료 - 성공 여부: {}", success);

        } catch (Exception e) {
            logger.error("백업 중 오류 발생", e);
						// 관리자에게 알림 발송
            notifyAdmin(e);
        }
    }

    private List<Data> collectData() {
				// 백업할 데이터 수집 로직
        return new ArrayList<>();
    }

    private boolean performBackup(List<Data> data) {
				// 실제 백업 수행 로직
        return true;
    }

    private void notifyAdmin(Exception e) {
		// Todo: 관리자 알림 로직
		... 
    }
}

(2) 캐시 갱신 스케줄러

@Component
public class CacheRefreshScheduler {
    private final CacheManager cacheManager;
    private final DataService dataService;

    public CacheRefreshScheduler(CacheManager cacheManager, DataService dataService) {
        this.cacheManager = cacheManager;
        this.dataService = dataService;
    }

    @Scheduled(fixedDelay = 300000)// 5분마다 실행
    public void refreshCache() {
        try {
						// 1. 새로운 데이터 조회
            Map<String, Object> newData = dataService.fetchLatestData();

						// 2. 캐시 갱신
            Cache cache = cacheManager.getCache("mainCache");
            if (cache != null) {
                cache.clear();
                newData.forEach(cache::put);
            }

        } catch (Exception e) {
						// Todo: 오류 처리
            logger.error("캐시 갱신 실패", e);
        }
    }
}

8. 스케줄러 사용 시 주의사항

(1) 동시성 처리

  • fixedRate와 fixedDelay의 차이를 이해하고 적절히 사용
  • fixedRate: 이전 작업 완료 여부와 관계없이 일정 간격으로 실행
  • fixedDelay: 이전 작업 완료 후 지정된 시간만큼 대기 후 실행

(2) 예외 처리

  • 모든 예외 상황에 대한 처리 로직 구현
  • 중요 작업의 경우 실패 시 알림 기능 구현

(3) 모니터링과 로깅

  • 작업 시작/종료 시간, 소요 시간 등을 로그로 기록
  • 주요 작업의 경우 모니터링 시스템 연동

(4) 리소스 관리

  • 메모리 누수 방지를 위한 리소스 해제
  • 장시간 실행되는 작업의 경우 타임아웃 설정

이렇게 구현된 스케줄러는 시스템의 자동화된 작업을 안정적으로 수행할 수 있게 해주며, 특히 정기적인 데이터 처리, 알림 발송, 리포트 생성 등의 업무에서 유용하게 활용될 수 있다.