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