목록으로

서버가 죽어도 이어서 보내는 배치 처리

10

서버가 죽어도 이어서 보내는 배치 처리

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;

문제:

  1. OFFSET이 커질수록 느려진다. OFFSET 200000이면 MySQL이 200000건을 스캔한 후 버리고 그 다음 5000건을 반환한다.
  2. 중간에 데이터가 추가/삭제되면 순서가 밀린다. 어제 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. 활성 사용자 테이블
  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 >= 하나로 중복 발송 여부가 결정되므로 주의해야 한다.
서버가 죽어도 이어서 보내는 배치 처리 | KYUDORI