목록으로

서버 2대를 운영할 때 스케줄러가 2번 도는 문제

6

서버 2대를 운영할 때 스케줄러가 2번 도는 문제

2025-01-13 ~ 2026-02-20

문제 상황

알림 서비스를 2대의 서버로 운영하고 있다. 하나는 Active, 하나는 Standby. → Active-Standby 구성이란 평소에는 Active 서버가 모든 작업을 처리하고, Standby 서버는 대기하다가 Active에 장애가 생기면 바로 대체하는 이중화 방식이다. 둘 다 같은 코드가 배포되어 있고, 같은 DB를 바라본다.

여기서 문제가 생겼다.

"매일 오전 9시에 일시정지된 푸시를 재개한다"는 스케줄러가 있는데, 두 서버에서 동시에 실행되어서 재개 처리가 2번 일어났다.

09:00  [Active]  "일시정지 푸시 3건 재개합니다"
09:00  [Standby] "일시정지 푸시 3건 재개합니다"

Telegram 알림도 2번 오고, 로그도 2배로 쌓였다.


왜 이런 일이 일어나는가

Spring의 @Scheduled는 어플리케이션 인스턴스 단위로 실행된다. 2대의 서버에 같은 코드가 올라가 있으면, 각 서버가 독립적으로 스케줄러를 돌린다.

Active  서버: @Scheduled(cron = "0 0 9 * * ?") → 실행!
Standby 서버: @Scheduled(cron = "0 0 9 * * ?") → 실행!

Spring은 "다른 서버에서도 이 스케줄러가 돌고 있다"는 걸 모른다. 분산 환경에서의 스케줄러 중복 실행은 Spring이 알아서 해결해주지 않는다.


해결 방법 1: 프로파일로 스케줄러 on/off

가장 단순한 방법. Active 서버에서만 스케줄러를 켠다.

@Component
@ConditionalOnProperty(name = "scheduler.enabled", havingValue = "true")
public class PushResumeScheduler {

    @Scheduled(cron = "0 0 9 * * ?", zone = "Asia/Seoul")
    public void resumePausedRequests() {
        // 일시정지된 푸시 재개
    }
}

프로파일별 설정:

# Active 서버 설정
scheduler:
  enabled: true

# Standby 서버 설정
scheduler:
  enabled: false

@ConditionalOnProperty는 Spring Bean을 아예 생성하지 않는다. → @ConditionalOnProperty는 설정 파일(application.yml)의 특정 값에 따라 해당 Bean을 만들지 말지 결정하는 어노테이션이다. Bean이 만들어지지 않으면 그 안의 스케줄러도 동작하지 않는다. Standby 서버에서는 스케줄러 Bean 자체가 없으니 실행될 일이 없다.


해결 방법 2: DB 레벨 CAS로 이중 실행 방지

프로파일 방식만으로는 부족한 경우가 있다. 예를 들어, Active 서버가 죽어서 Standby를 급하게 Active로 전환했는데, 프로파일 변경을 깜빡하면?

그래서 DB 레벨에서도 안전장치를 둔다.

@Scheduled(cron = "0 0 9 * * ?")
public void resumePausedRequests() {
    List<PushRequest> pausedRequests = repository.findPausedRequests();

    for (PushRequest request : pausedRequests) {
        // CAS: "PAUSED 상태일 때만 PROCESSING으로 바꿔라"
        int updated = repository.resumeFromPaused(request.getId());

        if (updated == 0) {
            // 다른 인스턴스가 이미 처리함 → 스킵
            log.info("이미 다른 서버에서 재개됨: {}", request.getId());
            continue;
        }

        // updated > 0이면 내가 잡은 것 → 후속 처리
        startProcessing(request);
    }
}

CAS(Compare-And-Swap)는 UPDATE의 WHERE 절에 현재 상태를 조건으로 넣는 패턴이다. (CAS 패턴 상세 설명)

두 서버가 동시에 실행해도, DB의 행 잠금 덕분에 하나만 성공한다.


Standby 서버에서도 돌아야 하는 것

모든 걸 Standby에서 끄면 안 된다. 서버 시작 시 비정상 상태 정리는 양쪽 다 실행해야 한다.

@EventListener(ApplicationReadyEvent.class)  // 서버 시작 시 항상 실행 (ApplicationReadyEvent는 Spring 애플리케이션이 완전히 초기화된 직후 발생하는 이벤트다. 이 이벤트를 수신하면 서버가 뜰 때 자동으로 특정 로직을 실행할 수 있다)
public void cleanupOnStartup() {
    // 24시간 이상 PROCESSING 상태인 요청 → FAILED로 전환
    List<PushRequest> staleRequests = repository.findStaleProcessing(24);
    for (PushRequest request : staleRequests) {
        repository.failFromProcessing(request.getId());
    }
}

이건 @ConditionalOnProperty를 안 건다. Active 서버가 갑자기 죽었을 때, Standby가 올라오면서 방치된 요청을 정리해야 하기 때문이다.


운영 시나리오

정상 운영

Active:   스케줄러 ON  → 09:00 재개, 13:00 출석 알림, 03:00 정리
Standby:  스케줄러 OFF → Kafka Consumer만 동작 (메시지 처리는 양쪽 모두)

Active 서버 장애

1. Active 서버 장애 감지
2. Standby 서버를 Active로 전환 (프로파일 변경 + 재시작)
3. 서버 시작 → 비정상 상태 정리 자동 실행
4. 스케줄러 활성화 → 정상 운영 복귀
5. 원래 Active 서버 복구 → Standby로 전환

이 방식의 한계와 대안

한계:

  • 수동 전환이 필요하다 (프로파일 변경 + 재시작)
  • 전환 시간 동안 스케줄러가 안 돈다

대안:

  • ShedLock: DB에 잠금 레코드를 만들어서, 스케줄러 실행 시 잠금을 잡은 인스턴스만 실행 (ShedLock은 분산 환경에서 스케줄러 중복 실행을 방지하는 라이브러리다. 여러 서버가 있어도 DB 잠금을 통해 딱 하나의 서버에서만 스케줄러가 돌도록 보장한다)
  • Spring Batch: 배치 작업 프레임워크, 분산 실행 지원
  • Kubernetes CronJob: 인프라 레벨에서 단일 Pod만 스케줄 실행

우리는 서버가 2대뿐이고, 프로파일 전환이 빠르고, CAS로 이중 실행을 방지하고 있어서 현재 방식으로 충분했다.


배운 것

  • @Scheduled는 인스턴스마다 독립적으로 실행된다. 2대면 2번, 3대면 3번 돈다. Spring이 알아서 조율해주지 않는다.
  • 프로파일 기반 on/off가 가장 단순한 해결책이다. @ConditionalOnProperty로 Bean 자체를 안 만들면 된다.
  • 프로파일만으로는 100% 안전하지 않으므로, DB 레벨 CAS를 2차 안전장치로 두는 게 좋다.
  • 서버 시작 시 비정상 상태 정리는 양쪽 모두에서 실행해야 장애 복구가 가능하다.