서버가 죽어도 이어서 보내는 배치 처리
서버가 죽어도 이어서 보내는 배치 처리
2026-01-30 ~ 2026-02-06
문제 상황
30만 명에게 푸시를 보내는 데 약 2시간이 걸린다. 1시간 지나서 15만 명까지 보냈는데 서버가 재시작되면? 처음부터 다시 15만 명에게 보내야 하나?
그건 안 된다. 이미 받은 사용자에게 중복 발송이 된다.
또 하나, 밤 7시가 되면 야간 발송 제한으로 멈추고, 다음날 아침 9시에 이어서 보내야 한다. 어제 15만 명까지 보냈는데 오늘은 15만 1번째부터 보내야 한다.
OFFSET 기반 페이지네이션의 문제
가장 먼저 떠오르는 건 OFFSET이다. → OFFSET은 SQL에서 조회 결과의 앞부분을 건너뛰는 기능이다. "OFFSET 100"이면 앞의 100건을 건너뛰고 그 다음부터 반환한다. 페이지네이션(목록을 페이지 단위로 나눠서 보여주는 것)에서 흔히 사용된다.
-- 1페이지
SELECT * FROM users ORDER BY seq LIMIT 5000 OFFSET 0;
-- 2페이지
SELECT * FROM users ORDER BY seq LIMIT 5000 OFFSET 5000;
-- 3페이지
SELECT * FROM users ORDER BY seq LIMIT 5000 OFFSET 10000;
문제:
- OFFSET이 커질수록 느려진다. OFFSET 200000이면 MySQL이 200000건을 스캔한 후 버리고 그 다음 5000건을 반환한다.
- 중간에 데이터가 추가/삭제되면 순서가 밀린다. 어제 OFFSET 30000이었던 사용자가 오늘은 OFFSET 29998일 수 있다.
30만 명 규모에서 OFFSET은 비효율적이고 불안정하다.
커서(Cursor) 기반 페이지네이션
커서 방식은 "마지막으로 처리한 행의 고유 값"을 기억하고, 그 다음부터 조회한다. → 커서(Cursor)는 "현재 읽고 있는 위치"를 가리키는 포인터라고 생각하면 된다. 책에 끼워둔 책갈피처럼, 다음에 어디서부터 읽으면 되는지 알려주는 역할이다.
-- 첫 배치
SELECT seq, user_code, push_token
FROM users
WHERE seq > 0 -- 처음부터
ORDER BY seq ASC
LIMIT 5000;
-- 두 번째 배치 (첫 배치의 마지막 seq가 5000이었다면)
SELECT seq, user_code, push_token
FROM users
WHERE seq > 5000 -- 5000번 이후부터
ORDER BY seq ASC
LIMIT 5000;
OFFSET과의 차이:
- OFFSET: "앞에서 N개 건너뛰어" → 건너뛰는 행을 전부 스캔해야 함
- 커서: "seq > 5000인 것만 줘" → 인덱스로 바로 점프
seq에 인덱스가 있으면 (보통 PK) 아무리 뒤쪽이어도 일정한 속도다. → PK(Primary Key, 기본키)는 테이블에서 각 행을 유일하게 식별하는 컬럼이다. PK에는 자동으로 인덱스가 생성되어 빠른 검색이 가능하다.
DB에 커서 위치 저장
핵심은 매 배치 처리 후 "어디까지 했는지"를 DB에 기록하는 것이다.
발송 요청 테이블:
┌──────────────┬──────────┬────────────────┬──────────────────┬─────────────┐
│ requestId │ status │ lastUserSeq │ lastDormantSeq │ dormantDone │
├──────────────┼──────────┼────────────────┼──────────────────┼─────────────┤
│ REQ_0207... │ PAUSED │ 150000 │ NULL │ false │
└──────────────┴──────────┴────────────────┴──────────────────┴─────────────┘
lastUserSeq = 150000: 활성 사용자 테이블에서 seq 150000까지 처리했다lastDormantSeq = NULL: 휴면 사용자 테이블은 아직 시작 안 했다dormantDone = false: 휴면 사용자 처리 미완료
처리 루프
long lastSeq = request.getLastUserSeq(); // DB에서 커서 복원
while (true) {
// 1. 중단 체크 (취소, 야간 제한 등)
if (shouldStop()) break;
// 2. 커서 기반으로 다음 5000명 조회
List<UserTokenInfo> batch = userQuery.getUsersBatch(
audience, osType,
lastSeq, // ← 여기서부터
category);
if (batch.isEmpty()) break; // 더 이상 없음
// 3. 알림함 저장 + FCM 발송
insertNotifications(batch);
fcmMulticastService.send(batch);
// 4. 커서 업데이트 — 매 배치마다 DB에 저장
lastSeq = batch.get(batch.size() - 1).seqno();
repository.saveLastUserSeq(requestId, lastSeq);
}
매 배치(5000명)가 끝날 때마다 lastSeq를 DB에 업데이트한다.
서버가 그 사이에 죽어도, 다음에 재시작하면 DB에서 커서를 읽어서 이어서 보낸다.
2개 테이블 순차 처리
전체 사용자에게 보내려면 두 개의 테이블을 처리해야 한다.
- 활성 사용자 테이블
- 휴면 사용자 테이블
// 1. 활성 사용자 처리
if (!request.isDormantDone()) {
result = processActiveUsers(lastUserSeq);
if (result == PAUSED || result == CANCELLED) {
saveProgress(lastSeq, lastDormantSeq, false);
return;
}
}
// 2. 휴면 사용자 처리
if (audience == ALL_USERS) {
result = processDormantUsers(lastDormantSeq);
if (result == PAUSED || result == CANCELLED) {
saveProgress(lastSeq, lastDormantSeq, false);
return;
}
}
// 둘 다 완료
markCompleted(requestId);
활성 사용자가 다 끝나면 dormantDone 플래그를 세워서, 재개 시 활성 사용자를 건너뛰고 휴면 사용자부터 시작한다.
재개 시나리오
시나리오: 30만 명 발송 중 밤 7시 도달
18:55 배치 처리 중... lastSeq = 148000
19:00 야간 정책 체크 → PAUSE
DB 저장: status=PAUSED, lastUserSeq=150000
(다음날)
09:00 재개 스케줄러 실행
DB 조회: PAUSED 요청 발견, lastUserSeq=150000
PAUSED → PROCESSING 전이
→ SELECT ... WHERE seq > 150000 ...
→ 150001번 사용자부터 이어서 발송
중복 발송 없이, 정확히 멈춘 지점부터 재개된다.
seq > lastSeq에서 왜 >=가 아니라 >인가?
>=를 쓰면 마지막으로 처리한 사용자가 다시 조회된다.
이미 FCM을 보냈는데 또 보내면 중복이다.
>를 쓰면 마지막 처리한 seq의 다음 사용자부터 조회한다.
seq가 150000이면, 150001부터 시작한다.
배운 것
- OFFSET은 대량 데이터에서 느리고, 데이터 변경에 취약하다. 커서(seq > ?) 방식이 더 안전하고 빠르다.
- "어디까지 했는지"를 매 배치마다 DB에 기록하면, 서버 재시작/일시정지/장애 후에도 정확히 이어서 처리할 수 있다.
- 2개 이상의 테이블을 순차 처리할 때는 테이블별 커서 + 완료 플래그로 진행 상태를 추적한다.
>vs>=하나로 중복 발송 여부가 결정되므로 주의해야 한다.