목록으로

서버가 죽으면 발송은 어디서부터 다시 해야 하나

11

서버가 죽으면 발송은 어디서부터 다시 해야 하나

2026-02-09


발단

대량 푸시 발송 중에 서버가 죽었다. 10만 명 중 6만 명까지 보냈는데 서버가 재시작되면서 상태가 PROCESSING에 멈춰버렸다.

다시 시작하려니 고민이 됐다.

  • 처음부터? → 6만 명에게 중복 발송
  • 그냥 실패 처리? → 4만 명 누락
  • 이어서? → 어디까지 보냈는지 모름

"어디까지 보냈는지"를 저장하고 있지 않았다.


체크포인팅(Checkpointing)이 뭔데?

체크포인팅은 게임 세이브 포인트 같은 거다. 오래 걸리는 작업의 진행 상황을 중간중간 저장해두는 기법이다. 작업 중간중간에 "여기까지 했다"를 기록해두면, 중단됐을 때 마지막 세이브 포인트부터 이어서 할 수 있다.

발송 시작 (10만 명)
  ├── 5,000명 처리 → 세이브: "5,000번째 사용자까지 완료"
  ├── 10,000명 처리 → 세이브: "10,000번째까지 완료"
  ├── ...
  ├── 60,000명 처리 → 세이브: "60,000번째까지 완료"
  └── 💥 서버 다운

재시작 → 세이브 확인 → 60,001번째부터 이어서 발송

구현

5,000명 단위로 배치 처리(대량의 데이터를 묶음 단위로 나눠서 처리하는 방식)하면서, 매 배치마다 마지막으로 처리한 사용자 번호를 DB에 기록한다.

while (hasMoreUsers) {
    List<User> batch = queryNextBatch(lastProcessedSeqNo, BATCH_SIZE);

    sendPushToAll(batch);

    // 체크포인트 저장
    lastProcessedSeqNo = batch.get(batch.size() - 1).getSeqNo();
    repository.saveCheckpoint(requestId, lastProcessedSeqNo);
}

장애 복구 시:

public void retryFromCheckpoint(Long requestId) {
    PushRequest request = repository.findById(requestId);
    Long checkpoint = request.getLastProcessedSeqNo();  // 저장된 위치

    // 체크포인트 이후부터 재개
    while (hasMoreUsers) {
        List<User> batch = queryNextBatch(checkpoint, BATCH_SIZE);
        sendPushToAll(batch);
        checkpoint = batch.get(batch.size() - 1).getSeqNo();
        repository.saveCheckpoint(requestId, checkpoint);
    }
}

2대 서버 운영도 해결해야 했다

서버를 2대로 늘리니까 새로운 문제가 생겼다. 스케줄러가 양쪽에서 동시에 도는 것이다.

서버 A (Active)  — 스케줄러 ON
서버 B (Standby) — 스케줄러 OFF

→ Active-Standby 구조는 하나의 서버가 주로 일하고(Active), 다른 서버는 대기하다가(Standby) 장애 시 대체하는 이중화 방식이다.

인스턴스별로 설정 파일을 분리하고, 스케줄러 실행 여부를 제어하는 플래그를 뒀다.

# 서버 A
scheduler:
  enabled: true

# 서버 B
scheduler:
  enabled: false
@Scheduled(cron = "0 0 13 * * ?")
public void dailyReminder() {
    if (!schedulerEnabled) return;  // Standby면 스킵
    // ...
}

고아(Orphan) 요청 정리

→ 고아 요청이란, 처리 중이던 서버가 죽어버려서 아무도 돌보지 않는 채로 남은 요청을 말한다.

서버가 죽으면 PROCESSING 상태로 영원히 남는 요청이 생긴다. 이걸 자동으로 정리하는 스케줄러를 추가했다.

// 48시간 넘게 PROCESSING인 요청을 FAILED로 변경
@Scheduled(fixedDelay = 3600000)  // 1시간마다
public void cleanupOrphanedRequests() {
    int cleaned = repository.failStaleProcessing(
        Duration.ofHours(48)
    );
    if (cleaned > 0) {
        sendAlert("고아 요청 " + cleaned + "건 정리됨");
    }
}

서버 시작 시에도 한 번 돌린다 (이건 24시간 기준):

@EventListener(ApplicationReadyEvent.class)
public void onStartup() {
    repository.failStaleProcessing(Duration.ofHours(24));
}

전체 구조

서버 시작
  └── 24시간+ PROCESSING 요청 정리

매 1시간
  └── 48시간+ PROCESSING 요청 정리

발송 중
  └── 5,000명마다 체크포인트 저장

장애 발생
  └── 상태: PROCESSING에 멈춤
  └── 체크포인트: "60,000번째까지 완료" 기록됨

복구
  ├── 자동: 고아 정리 스케줄러가 FAILED 처리
  └── 수동: /retry API로 체크포인트부터 재개

배운 것

  1. 배치 처리에는 반드시 체크포인팅을 넣자. "어디까지 했는지" 모르면 장애 복구가 불가능하다.
  2. Active-Standby 구조에서 스케줄러는 한쪽만 돌아야 한다. 인스턴스별 설정 분리가 가장 단순한 해법.
  3. 고아 요청 정리는 운영 필수 기능이다. 서버가 언제 죽을지 모르니까.
  4. @PostConstruct(빈이 생성된 직후 실행되는 초기화 메서드를 지정하는 어노테이션) 대신 @EventListener(ApplicationReadyEvent.class)를 쓰면 모든 빈 초기화가 끝난 후 실행되어 더 안전하다.
서버가 죽으면 발송은 어디서부터 다시 해야 하나 | KYUDORI