한 줄 요약: 작업 스케줄러의 핵심은 Kafka 파티셔닝으로 처리량을 수평 확장하고, Redis Sorted Set으로 지연 실행을 마이크로초 단위로 제어하며, 분산 락으로 크론 중복 실행을 원천 차단하는 것이다.

실제 문제: 비동기 작업이 터지는 순간

국내 커머스 플랫폼이 블랙프라이데이 자정에 “전 회원 쿠폰 발행” 배치를 실행했습니다. 단일 스케줄러 프로세스가 5,000만 건의 작업을 순차 큐에 넣으려다 메모리가 고갈됐고, 24시간 뒤에야 마지막 쿠폰이 발행됐습니다. 더 심각한 건 스케줄러가 두 노드에서 동시에 기동돼 동일 작업이 두 번 실행되며 쿠폰이 이중 발급됐다는 점입니다.

같은 날 정기 결제 시스템은 재시도 폭풍에 빠졌습니다. 외부 PG사 API가 30초간 503을 반환하자, 재시도 로직이 지수 백오프 없이 즉시 재시도를 반복하다 초당 4만 건의 요청을 PG사로 쏟아냈습니다. PG사가 해당 플랫폼 IP를 차단하면서 결제 전체가 중단됐습니다.

작업 스케줄러 시스템이 해결해야 할 핵심 문제:

  • 중복 실행 방지: 크론 작업이 여러 인스턴스에서 동시에 실행되지 않게 하는 것
  • 지연 실행 정밀도: “5분 후 실행”이 실제로 5분±1초 내에 시작되는 것
  • 재시도 안전성: 외부 시스템 장애 시 재시도 폭풍 없이 안정적으로 복구하는 것
  • 우선순위 처리: 결제 작업이 마케팅 이메일보다 먼저 처리되는 것
  • 작업 유실 방지: 워커 프로세스가 죽어도 진행 중이던 작업이 손실되지 않는 것

설계 의사결정 로드맵

결정 1: 작업 큐 백본 — DB 큐 vs Redis 리스트 vs Kafka

후보 장점 단점 언제 적합
DB 큐 (상태 컬럼 폴링) 구현 단순, 트랜잭션 보장 폴링 오버헤드, 10만 TPS 이상 불가, 인덱스 경합 일 10만 건 이하 단순 배치
Redis LIST + BRPOP 인메모리 고속, 블로킹 팝 영속성 없음, 단일 컨슈머 집중, 파티셔닝 없음 유실 허용, 단순 큐, 소규모
Kafka 파티션 병렬성, 재처리 가능, 영속성 설정 복잡, 지연 실행 기본 미지원 대규모, 감사 추적 필요, 재처리

우리의 선택: Kafka (일반 작업) + Redis Sorted Set (지연 작업)

  • 일 1억 건은 초당 평균 1,160건, 피크 시 1만 건을 넘는다. DB 폴링은 이 수준에서 인덱스 핫스팟으로 인해 쿼리 지연이 기하급수적으로 늘어난다. Kafka는 파티션 수만큼 컨슈머를 추가하면 선형 확장이 가능하다. Redis Sorted Set은 지연 실행 전용으로, “몇 초 뒤 실행”처럼 정밀한 타이밍이 필요한 작업에만 쓴다.

결정 2: 지연 실행 저장소 — Redis ZSET vs 전용 DB vs 메시지 스케줄링

후보 장점 단점 언제 적합
Redis Sorted Set O(log N) 범위 조회, Lua 원자 실행 인메모리 용량 한계, AOF/RDB 필요 수천만 건 이하 지연 작업
DB (scheduled_at 컬럼) 영속성 자동 보장 폴링 비용, 인덱스 스캔 부하 작업 수 적고 지연 정밀도 낮을 때
Kafka + 타임스탬프 필터링 Kafka 인프라 재활용 미래 메시지 저장 비효율, 즉시 consume 강제 지연이 초 단위 이상이고 영속성 필요

우리의 선택: Redis Sorted Set + AOF 영속성

  • score에 Unix 타임스탬프(밀리초)를 넣으면 ZRANGEBYSCORE 0 now 한 줄로 “지금 실행해야 할 모든 작업”을 가져올 수 있다. B-tree 계열 DB의 인덱스 스캔과 달리 Sorted Set은 범위 쿼리가 O(log N + K)로 고정된다. AOF everysec 설정으로 최대 1초 데이터 손실만 감수하면서 영속성을 확보한다.

결정 3: 크론 리더 선출 — 단일 서버 vs Zookeeper vs Redis NX

후보 장점 단점 언제 적합
단일 크론 서버 구현 제로 단일 장애점, 재기동 시 스킵 가용성 요구사항 없는 내부 배치
Zookeeper 선출 강력한 분산 합의 운영 복잡도, 별도 클러스터 필요 금융·의료 등 강한 일관성 필수
Redis SET NX + TTL 구현 단순, 기존 Redis 재활용 Redis 장애 시 리더 없음 대부분의 웹 서비스

우리의 선택: Redis SET NX + TTL + 주기적 갱신

  • SET scheduler:lock:{job_name} {node_id} NX PX 30000으로 30초 락을 잡고, 28초마다 TTL을 갱신한다. 리더 노드가 죽으면 30초 후 TTL이 만료되고 다른 노드가 락을 획득한다. 구현이 단 두 줄이면서 대부분의 장애 시나리오를 커버한다.

결정 4: 재시도 전략 — 즉시 재시도 vs 지수 백오프 vs DLQ

후보 장점 단점 언제 적합
즉시 재시도 회복 빠름 장애 서버에 폭풍 유발, 증폭 장애 절대 금지 (멱등성 보장도 안됨)
고정 간격 재시도 예측 가능 여전히 동시 재시도 집중 단순 내부 작업
지수 백오프 + 지터 재시도 분산, 장애 서버 회복 시간 확보 지연 증가 외부 API 호출, 네트워크 의존 작업
DLQ 포기 후 추적, 수동 개입 가능 즉각 처리 불가 최대 재시도 초과, 비즈니스 판단 필요

우리의 선택: 지수 백오프 + 지터 + DLQ 조합

  • 재시도 간격을 min(baseDelay * 2^attempt + random(0, baseDelay), maxDelay)로 계산하면 동시에 실패한 수천 개의 작업이 제각각 다른 시간에 재시도해 재시도 폭풍을 방지한다. 5회 재시도 후에도 실패하면 DLQ로 이동해 운영자가 원인을 분석하고 수동으로 재처리한다.

1. 요구사항 분석 및 규모 추정

기능 요구사항

  1. 즉시 실행: API 호출 또는 이벤트로 작업을 큐에 넣고 워커가 즉시 처리
  2. 지연 실행: “N초/분/시간 후 실행” — 알림 예약, 결제 재시도 스케줄링
  3. 크론 실행: “매일 02:00”, “매월 1일” 등 주기적 반복 작업
  4. 우선순위 큐: 결제 작업은 마케팅 이메일보다 먼저 처리
  5. 재시도 엔진: 실패 작업 자동 재시도, 횟수 초과 시 DLQ 이동
  6. 작업 상태 추적: PENDING → RUNNING → SUCCESS/FAILED/DEAD 상태 전환 및 이력 조회
  7. 멱등성 보장: 같은 작업이 두 번 실행돼도 결과가 동일해야 함

비기능 요구사항

항목 목표
처리량 일 1억 건, 피크 10,000 TPS
지연 실행 정밀도 목표 시각 ±2초 이내
작업 유실 0건 (최소 1회 실행 보장)
크론 중복 실행 0건 (정확히 1회 실행 보장)
가용성 99.9%
작업 처리 지연 평균 < 500ms, p99 < 2초

규모 추정

일 처리 작업:
  - 즉시 실행 작업:   6,000만 건/일 →   694 TPS 평균, 5,000 TPS 피크
  - 지연 실행 작업:   2,000만 건/일 →   231 TPS 평균
  - 크론 실행 작업:     500만 건/일 →    58 TPS 평균
  - 재시도 작업 (5%):  1,500만 건/일 →   174 TPS 평균
합계:               1억 건/일 → 1,157 TPS 평균, 10,000 TPS 피크

작업 메타데이터 저장 (30일 보관):
  1억 건/일 × 2KB/건 × 30일 = 6TB → MySQL 파티셔닝 (날짜별)

지연 큐 크기:
  동시 대기 최대 5,000만 건 × 200B = 10GB → Redis Sorted Set (AOF 영속성)

Kafka 파티션 계산:
  10,000 TPS / 파티션당 500 TPS = 파티션 20개 (우선순위별 토픽 × 파티션)

2. 고수준 아키텍처

비유: 대형 병원의 응급·외래·예약 접수 시스템과 같습니다. 응급 환자(결제 실패)는 즉시 처치실로 보내고, 예약 환자(이메일 발송)는 정해진 시간에 호출하며, 정기 건강검진(배치 집계)은 새벽에 모아서 처리합니다. 접수창구(API)가 과부하가 걸려도 이미 큐에 들어간 환자는 놓치지 않습니다.

graph LR
    A[작업 제출 API] --> B[Kafka 토픽]
    A --> C[Redis 지연 큐]
    B --> D[워커 풀]
    C --> D
    E[크론 스케줄러] --> B
    D --> F[작업 상태 DB]
컴포넌트 역할
작업 제출 API 작업 수신, 유효성 검증, Kafka/Redis 라우팅
Kafka 토픽 우선순위별 분리, 파티션 병렬 처리, 영속성
Redis 지연 큐 Sorted Set으로 실행 시각 인덱싱, Lua 원자 팝
워커 풀 작업 실행, 결과 기록, 재시도 스케줄링
크론 스케줄러 리더 노드가 크론 표현식 평가, Kafka에 작업 투입
작업 상태 DB PENDING/RUNNING/SUCCESS/FAILED/DEAD 이력

3. Kafka 파티셔닝과 쓰로틀링 설계

3-1. 토픽 설계 — 우선순위별 분리

비유: 항공사 수하물 컨베이어 벨트와 같습니다. 비즈니스석 수하물(결제)과 이코노미석 수하물(마케팅)을 같은 벨트에 올리면 뒤섞입니다. 분리된 벨트(토픽)가 있어야 비즈니스석 수하물이 항상 먼저 나옵니다.

task.priority.critical  — 파티션 10개 (결제, 인증, 환불)
task.priority.high      — 파티션 8개  (주문 상태 변경, 재고 차감)
task.priority.normal    — 파티션 5개  (알림, 포인트 적립)
task.priority.low       — 파티션 3개  (이메일, 리포트 생성)
task.dlq                — 파티션 3개  (DLQ, 수동 재처리 대기)

우선순위를 단일 토픽 내 priority 필드로 구현하면 컨슈머가 모든 메시지를 읽은 뒤 필터링해야 한다. 토픽을 분리하면 critical 워커는 critical 토픽만 구독해 low 토픽의 백로그가 아무리 쌓여도 영향을 받지 않는다.

3-2. Producer 쓰로틀링 — 6가지 핵심 설정

Producer가 메시지를 너무 빠르게 보내면 Broker가 과부하되고, 너무 느리게 보내면 작업 지연이 발생한다. 6가지 설정이 이 균형을 잡는다.

Properties props = new Properties();

// 1. acks=all: 모든 ISR(In-Sync Replica)이 쓰기를 확인해야 성공 응답
//    acks=0이면 유실 위험, acks=1이면 리더만 확인 → 리더 장애 시 유실
props.put("acks", "all");

// 2. linger.ms=5: 메시지를 5ms 모아 배치 전송
//    linger.ms=0이면 메시지마다 즉시 전송 → 네트워크 왕복 횟수 급증
//    5ms 동안 대기하면 수백 개 메시지가 하나의 배치가 되어 처리량 10배 향상
props.put("linger.ms", 5);

// 3. batch.size=65536: 배치 최대 크기 64KB
//    배치가 64KB에 도달하면 linger.ms 만료 전에도 즉시 전송
//    너무 작으면 배치 효과 감소, 너무 크면 메모리 낭비 + 지연 증가
props.put("batch.size", 65536);

// 4. buffer.memory=134217728: Producer 내부 버퍼 128MB
//    Broker가 느릴 때 메시지를 버퍼에 쌓아 Producer 스레드가 블록되지 않게 함
//    버퍼가 가득 차면 max.block.ms 동안 대기 후 예외 발생
props.put("buffer.memory", 134217728);

// 5. max.block.ms=5000: 버퍼가 가득 찼을 때 최대 5초 대기
//    5초 후에도 여유가 생기지 않으면 BufferExhaustedException 발생
//    트래픽 폭주 시 자연스러운 backpressure로 작동 — 작업 제출 API가 느려짐으로써
//    업스트림(클라이언트)에게 "지금 과부하 상태"를 알린다
props.put("max.block.ms", 5000);

// 6. compression.type=snappy: 네트워크 대역폭 절감
//    JSON 작업 페이로드는 snappy로 40~60% 압축 → Broker 디스크 I/O 절감
props.put("compression.type", "snappy");

트래픽 폭주 시 흐름: Broker 처리 지연 → 응답 지연 → Producer 버퍼(buffer.memory) 채워짐 → max.block.ms 만큼 작업 제출 API 블록 → API 응답 지연 → 클라이언트가 자연스럽게 재요청 속도를 줄임. 이것이 Kafka가 제공하는 자연스러운 backpressure 메커니즘이다.

3-3. Consumer 쓰로틀링 — 4가지 핵심 설정

컨슈머가 너무 빨리 메시지를 가져오면 워커가 처리하지 못한 채 메모리에 쌓이고, 너무 느리게 가져오면 처리 지연이 발생한다.

// 1. max.poll.records=200: 한 번의 poll()에서 최대 200개 메시지만 가져옴
//    워커가 처리할 수 있는 양보다 많이 가져오면 처리 완료 전에 다음 poll()을 해야 해서
//    max.poll.interval.ms 초과로 리밸런싱이 발생한다
props.put("max.poll.records", 200);

// 2. max.poll.interval.ms=300000: 연속된 poll() 호출 사이 최대 5분
//    이 시간 안에 poll()을 호출하지 않으면 Broker가 해당 컨슈머를 죽었다고 판단 → 리밸런싱
//    긴 작업(DB 배치 등)을 처리할 때는 이 값을 작업 예상 시간 * 1.5로 설정
props.put("max.poll.interval.ms", 300000);

// 3. fetch.min.bytes=1024: Broker가 최소 1KB를 모아야 응답
//    메시지가 적을 때 빈 응답을 반복하는 불필요한 네트워크 왕복을 방지
//    처리량보다 지연이 중요한 critical 토픽은 1로 설정해 즉시 응답 유도
props.put("fetch.min.bytes", 1024);

// 4. fetch.max.wait.ms=500: fetch.min.bytes를 못 채워도 500ms 후 강제 응답
//    fetch.min.bytes만 설정하면 트래픽이 없을 때 무한 대기 가능
//    500ms = 지연 허용치와 CPU 낭비 방지 사이의 균형점
props.put("fetch.max.wait.ms", 500);

3-4. Broker 쓰로틀링 — 클라이언트별 할당량 제어

Broker는 특정 Producer/Consumer가 대역폭을 독점하지 못하도록 할당량(quota)을 강제한다.

# Broker 설정 (server.properties)

# 모든 Producer의 초당 최대 쓰기 속도: 50MB/s
quota.producer.default=52428800

# 모든 Consumer의 초당 최대 읽기 속도: 50MB/s
quota.consumer.default=52428800

# I/O 스레드 수: CPU 코어 수 기준, 디스크 I/O 병목 해소
num.io.threads=8

# 네트워크 스레드 수: 클라이언트 연결 처리, 보통 3~5
num.network.threads=5

특정 Producer가 할당량을 초과하면 Broker는 해당 클라이언트에게 throttle_time_ms 값을 응답해 “이 시간 동안 대기하라”고 알린다. 클라이언트는 강제로 속도를 줄이게 되고, 다른 클라이언트는 정상 속도를 유지할 수 있다.

3-5. Consumer Group 리밸런싱 문제와 대응

비유: 음식 배달팀에서 배달원 한 명이 오래 걸리면 팀장이 “이 배달원 퇴장”으로 판단하고 구역을 재배분합니다. 재배분되는 동안 다른 배달원도 잠깐 멈춥니다. 작업이 긴 배달원이 많을수록 팀 전체가 자주 멈춥니다.

리밸런싱이 발생하는 주요 원인:

  1. 긴 작업 처리로 max.poll.interval.ms 초과
  2. 컨슈머 인스턴스 추가/제거 (스케일 아웃/인, 배포)
  3. Broker가 컨슈머를 죽었다고 오판 (GC pause, 네트워크 순간 단절)

기본 Eager 리밸런싱의 문제: 파티션 재배분 시 모든 컨슈머가 처리를 멈추고, 전체 파티션 소유권이 해제된 뒤 재배분된다. 컨슈머 100개 클러스터에서 인스턴스 1개가 추가될 때 100개 컨슈머 전부가 수초간 중단된다.

CooperativeStickyAssignor 적용:

// Consumer 설정
props.put("partition.assignment.strategy",
    "org.apache.kafka.clients.consumer.CooperativeStickyAssignor");

Cooperative 방식은 재배분이 필요한 파티션만 이동하고, 유지되는 파티션은 계속 처리한다. “1개 추가 배포” 시 전체가 멈추는 대신 영향받는 파티션만 재배분된다. Sticky 속성은 재배분 후에도 기존 파티션 할당을 최대한 유지해 캐시 locality를 보존한다.

3-6. Consumer Lag 모니터링과 Auto-Scaling

Consumer lag = Kafka 파티션의 최신 offset - 컨슈머가 처리한 offset. Lag이 증가한다는 것은 컨슈머가 생산 속도를 따라가지 못한다는 신호다.

@Scheduled(fixedRate = 10000)  // 10초마다 lag 체크
public void monitorConsumerLag() {
    Map<TopicPartition, Long> lagMap = kafkaConsumerLagChecker.getLag("task-worker-group");

    long totalLag = lagMap.values().stream().mapToLong(Long::longValue).sum();
    long maxPartitionLag = lagMap.values().stream().mapToLong(Long::longValue).max().orElse(0);

    // Prometheus 메트릭 전송
    meterRegistry.gauge("kafka.consumer.lag.total", totalLag);
    meterRegistry.gauge("kafka.consumer.lag.max_partition", maxPartitionLag);

    // Auto-scaling 트리거: lag이 50,000 초과 시 워커 인스턴스 2배 증설
    if (totalLag > 50_000 && !scalingInProgress) {
        log.warn("Consumer lag 임계치 초과: {} → 워커 스케일 아웃 트리거", totalLag);
        autoScaler.scaleOut("task-worker", currentInstances * 2);
        scalingInProgress = true;
    }

    // lag이 10,000 미만으로 회복 시 스케일 인
    if (totalLag < 10_000 && scalingInProgress) {
        autoScaler.scaleIn("task-worker", originalInstances);
        scalingInProgress = false;
    }
}

lag 기반 스케일 아웃은 CPU/메모리 기반 스케일 아웃보다 작업 큐에 적합하다. CPU가 0%여도 Kafka에 작업이 100만 개 쌓여 있을 수 있고, CPU가 90%라도 Kafka lag이 0이라면 추가 인스턴스가 필요 없다.

3-7. Dead Letter Queue 구현

최대 재시도 횟수를 초과한 작업은 task.dlq 토픽으로 이동한다. 즉시 버리지 않고 DLQ에 보관하는 이유는 두 가지다: 운영자가 실패 원인을 분석하고, 원인 수정 후 수동으로 재처리할 수 있어야 하기 때문이다.

@KafkaListener(topics = "task.priority.normal", groupId = "task-worker-group")
public void consume(ConsumerRecord<String, TaskPayload> record) {
    TaskPayload task = record.value();

    try {
        taskExecutor.execute(task);
        taskRepository.updateStatus(task.getId(), TaskStatus.SUCCESS);

    } catch (Exception e) {
        int retryCount = task.getRetryCount() + 1;

        if (retryCount >= MAX_RETRY_COUNT) {
            // 최대 재시도 초과 → DLQ로 이동
            log.error("작업 최대 재시도 초과, DLQ 이동: taskId={}, error={}",
                task.getId(), e.getMessage());

            TaskPayload dlqTask = task.toBuilder()
                .retryCount(retryCount)
                .failureReason(e.getMessage())
                .deadAt(Instant.now())
                .build();

            kafkaTemplate.send("task.dlq", task.getId().toString(), dlqTask);
            taskRepository.updateStatus(task.getId(), TaskStatus.DEAD);

        } else {
            // 지수 백오프 재시도: Redis 지연 큐에 예약
            long delayMs = Math.min(
                BASE_DELAY_MS * (long) Math.pow(2, retryCount) + ThreadLocalRandom.current().nextLong(BASE_DELAY_MS),
                MAX_DELAY_MS
            );

            TaskPayload retryTask = task.toBuilder().retryCount(retryCount).build();
            delayedQueue.schedule(retryTask, Instant.now().plusMillis(delayMs));

            log.warn("작업 재시도 예약: taskId={}, attempt={}, delayMs={}",
                task.getId(), retryCount, delayMs);
        }
    }
}

4. Redis 지연 큐 — Sorted Set + Lua 스크립트

4-1. 왜 Sorted Set인가

비유: 비행기 탑승 대기열은 순서표 번호(score) 순으로 호출합니다. 새 승객이 아무리 많이 와도 번호표만 뽑으면 되고, “지금 호출할 번호 범위”를 찾는 것은 번호판을 한 번 훑으면 됩니다. Sorted Set의 ZRANGEBYSCORE가 정확히 이 방식입니다.

Redis Sorted Set은 각 멤버(작업 ID)에 부동소수점 score를 부여하고, score 순으로 정렬된 상태를 O(log N)으로 유지한다. 지연 큐에서 score = 실행할 Unix 타임스탬프(밀리초)를 사용하면:

  • 작업 등록: ZADD delayed_queue {실행시각} {작업ID} — O(log N)
  • 실행 대상 조회: ZRANGEBYSCORE delayed_queue 0 {현재시각} — O(log N + K)
  • 처리 후 제거: ZREM delayed_queue {작업ID} — O(log N)

LIST의 RPUSH/LPOP은 O(1)이지만 실행 시각 기반 정렬을 지원하지 않는다. BRPOP은 블로킹이지만 특정 시각 이전의 항목만 꺼내는 것이 불가능하다. Keyspace Notification은 TTL 만료 이벤트를 활용하지만 만료 이벤트 전달이 최선 노력(best-effort)이라 유실 가능성이 있고 TTL 키 수백만 개 관리가 복잡하다.

4-2. 폴링 vs BRPOP vs Keyspace Notification 비교

방식 구현 정밀도 유실 위험 언제 적합
폴링 (1초 주기) ZRANGEBYSCORE + ZREM ±1초 없음 (원자 처리 시) 대부분의 지연 큐
BRPOP 블로킹 팝 즉시 없음 일반 즉시 실행 큐
Keyspace Notification TTL 만료 이벤트 구독 at-best 있음 (이벤트 드롭) 단순 만료 알림

우리의 선택: Sorted Set + 1초 폴링. 실행 시각 ±2초 이내 정밀도 요구사항을 만족하면서 구현이 단순하다. BRPOP은 Sorted Set에 직접 적용할 수 없고, Keyspace Notification은 at-least-once를 보장하지 않는다.

4-3. Lua 스크립트 — 원자적 팝

“지금 실행해야 할 작업 조회 → 가져가기”를 두 단계로 나누면 여러 워커가 동시에 같은 작업을 가져갈 수 있다. Lua 스크립트로 두 작업을 단일 원자 단위로 묶는다.

-- KEYS[1]: Sorted Set 키 (delayed_queue)
-- KEYS[2]: 처리 중 작업 추적 Set (processing_queue)
-- ARGV[1]: 현재 Unix 타임스탬프 (밀리초)
-- ARGV[2]: 한 번에 가져올 최대 개수

local now = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])

-- 1단계: 현재 시각 이전에 실행해야 할 작업 ID 목록 조회
--        score 0부터 now까지, 최대 limit개
--        ZRANGEBYSCORE: O(log N + K), 정렬된 범위 조회
local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, now, 'LIMIT', 0, limit)

if #tasks == 0 then
    return {}
end

-- 2단계: 조회된 작업을 delayed_queue에서 제거
--        ZREM: O(M log N), M=제거할 항목 수
redis.call('ZREM', KEYS[1], unpack(tasks))

-- 3단계: 처리 중 Set에 추가 (워커 죽을 때 복구를 위해)
--        SADD: O(M), M=추가할 항목 수
for _, taskId in ipairs(tasks) do
    redis.call('SADD', KEYS[2], taskId)
end

-- 가져온 작업 ID 목록 반환
return tasks

각 명령어 역할 요약:

  • ZRANGEBYSCORE key 0 now LIMIT 0 N: score 0~now 범위의 멤버 최대 N개 조회. “지금 실행 가능한 작업”을 시간 순서대로 가져옴
  • ZREM key member...: 가져간 작업을 큐에서 즉시 제거. 다른 워커가 같은 작업을 가져가지 못하게 함
  • SADD processing_queue member...: 처리 중 상태로 이동. 워커 장애 시 이 Set을 스캔해 미완료 작업을 복구
private static final String POP_SCRIPT = """
    local now = tonumber(ARGV[1])
    local limit = tonumber(ARGV[2])
    local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, now, 'LIMIT', 0, limit)
    if #tasks == 0 then return {} end
    redis.call('ZREM', KEYS[1], unpack(tasks))
    for _, taskId in ipairs(tasks) do
        redis.call('SADD', KEYS[2], taskId)
    end
    return tasks
    """;

@Scheduled(fixedRate = 1000)  // 1초마다 폴링
public void pollDelayedQueue() {
    long now = Instant.now().toEpochMilli();

    List<String> taskIds = redisTemplate.execute(
        new DefaultRedisScript<>(POP_SCRIPT, List.class),
        List.of("delayed_queue", "processing_queue"),
        String.valueOf(now),
        "100"  // 한 번에 최대 100개
    );

    if (taskIds == null || taskIds.isEmpty()) return;

    for (String taskId : taskIds) {
        TaskPayload task = taskRepository.findById(Long.parseLong(taskId))
            .orElseThrow(() -> new TaskNotFoundException(taskId));

        // 우선순위에 따라 적합한 Kafka 토픽으로 라우팅
        String topic = topicRouter.route(task.getPriority());
        kafkaTemplate.send(topic, taskId, task);
    }
}

4-4. 작업 등록 — 지연 시각 계산

public void scheduleDelayed(TaskPayload task, Duration delay) {
    long executeAt = Instant.now().plus(delay).toEpochMilli();

    // score = 실행할 Unix 타임스탬프 (밀리초)
    // Sorted Set은 score 오름차순 정렬 → 가장 빨리 실행할 작업이 앞에 위치
    redisTemplate.opsForZSet().add("delayed_queue", String.valueOf(task.getId()), executeAt);

    // DB에 PENDING 상태로 저장 (Redis 장애 시 복구 기준점)
    task.setStatus(TaskStatus.PENDING);
    task.setScheduledAt(Instant.ofEpochMilli(executeAt));
    taskRepository.save(task);
}

5. 분산 락 — 크론 중복 실행 방지

5-1. 문제: 복수 인스턴스에서 크론 동시 실행

3개 서버에 배포된 스케줄러는 모두 동일한 크론 표현식(0 2 * * *)을 가진다. 자정 2시가 되면 세 인스턴스 모두 “실행할 시각”을 감지한다. 분산 락 없이는 3개 서버가 동일한 배치 작업을 3번 실행한다.

5-2. Redis SET NX로 리더 선출

비유: 화장실 문의 열쇠고리와 같습니다. 열쇠고리가 걸려 있으면(락이 있으면) 들어갈 수 없습니다. 먼저 도착한 사람이 열쇠를 가져가고(SET NX 성공), 나올 때 열쇠를 돌려놓습니다(DEL). 안에 있는 사람이 오래 머물면 열쇠가 자동으로 돌아오도록(TTL) 타이머를 설정해뒀습니다.

@Component
public class DistributedCronScheduler {

    private static final String LEADER_KEY_PREFIX = "scheduler:lock:";
    private static final long LOCK_TTL_MS = 30_000;    // 30초 TTL
    private static final long RENEW_INTERVAL_MS = 25_000;  // 25초마다 갱신

    @Scheduled(cron = "0 2 * * *")  // 매일 새벽 2시
    public void dailyAggregation() {
        String lockKey = LEADER_KEY_PREFIX + "daily_aggregation";
        String nodeId = System.getenv("POD_NAME");  // 쿠버네티스 Pod 이름

        // SET NX: 키가 없을 때만 설정 (Not eXists)
        // PX 30000: 30초 TTL (밀리초 단위)
        // 원자적 연산 — SETNX + EXPIRE 분리 시 SETNX 성공 후 프로세스 죽으면 TTL 없이 락 영구 점유
        Boolean acquired = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, nodeId, Duration.ofMillis(LOCK_TTL_MS));

        if (!Boolean.TRUE.equals(acquired)) {
            log.debug("다른 노드가 리더 — 크론 스킵: job=daily_aggregation");
            return;
        }

        log.info("리더 선출 성공, 크론 실행 시작: node={}", nodeId);

        // 락 갱신 스레드 시작 (작업이 30초를 넘을 경우를 대비)
        ScheduledFuture<?> renewTask = renewExecutor.scheduleAtFixedRate(
            () -> renewLock(lockKey, nodeId),
            RENEW_INTERVAL_MS, RENEW_INTERVAL_MS, TimeUnit.MILLISECONDS
        );

        try {
            executeDailyAggregation();
        } finally {
            renewTask.cancel(false);
            releaseLock(lockKey, nodeId);
        }
    }

    private void renewLock(String lockKey, String nodeId) {
        // 내가 소유한 락만 갱신 (다른 노드가 재획득한 락을 실수로 갱신하지 않도록)
        String currentOwner = redisTemplate.opsForValue().get(lockKey);
        if (nodeId.equals(currentOwner)) {
            redisTemplate.expire(lockKey, Duration.ofMillis(LOCK_TTL_MS));
            log.debug("락 TTL 갱신: key={}", lockKey);
        }
    }

    private void releaseLock(String lockKey, String nodeId) {
        // Lua 스크립트로 원자적 확인 후 삭제
        // "확인 후 삭제" 두 단계를 분리하면 사이에 다른 노드가 락 획득 후 삭제될 수 있음
        String releaseScript = """
            if redis.call('GET', KEYS[1]) == ARGV[1] then
                return redis.call('DEL', KEYS[1])
            else
                return 0
            end
            """;

        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(releaseScript, Long.class),
            List.of(lockKey),
            nodeId
        );

        log.info("락 해제: key={}, result={}", lockKey, result);
    }
}

5-3. 트래픽 폭주 시 Rate Limiting과 Backpressure

워커 풀이 감당할 수 있는 것보다 많은 작업이 한꺼번에 쏟아질 때 두 가지 문제가 생긴다: 메모리 고갈과 처리 지연 누적. Rate Limiting은 입구에서 속도를 조절하고, Backpressure는 과부하 신호를 업스트림으로 전달한다.

@Component
public class WorkerRateLimiter {

    // Redis 기반 슬라이딩 윈도우 Rate Limiter
    // 초당 최대 처리 건수를 초과하면 작업 제출 거부
    private static final String RATE_LIMIT_SCRIPT = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local window = tonumber(ARGV[2])   -- 윈도우 크기 (밀리초)
        local limit = tonumber(ARGV[3])    -- 최대 허용 건수

        -- 윈도우 밖의 오래된 항목 제거
        redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

        -- 현재 윈도우 내 요청 수 확인
        local count = redis.call('ZCARD', key)

        if count < limit then
            -- 현재 요청 기록 (score = 현재 시각, member = 유니크 ID)
            redis.call('ZADD', key, now, now .. math.random())
            redis.call('PEXPIRE', key, window)
            return 1  -- 허용
        else
            return 0  -- 거부
        end
        """;

    public boolean tryAcquire(String workerId, int limitPerSecond) {
        long now = Instant.now().toEpochMilli();
        String key = "ratelimit:worker:" + workerId;

        Long result = redisTemplate.execute(
            new DefaultRedisScript<>(RATE_LIMIT_SCRIPT, Long.class),
            List.of(key),
            String.valueOf(now),
            "1000",                          // 1초 윈도우
            String.valueOf(limitPerSecond)
        );

        return Long.valueOf(1L).equals(result);
    }
}

Backpressure 전략: 워커가 처리 용량의 80%에 도달하면 Kafka Consumer의 pause()를 호출해 메시지 소비를 일시 중단한다. Kafka 파티션은 계속 메시지를 쌓고, 워커가 회복되면 resume()으로 재개한다. 이렇게 하면 워커 메모리가 보호되고 Kafka가 자연스러운 버퍼 역할을 한다.

@Component
public class BackpressureController {

    private final KafkaListenerEndpointRegistry registry;
    private final AtomicBoolean paused = new AtomicBoolean(false);

    @Scheduled(fixedRate = 5000)
    public void checkBackpressure() {
        int activeWorkers = workerPool.getActiveCount();
        int maxWorkers = workerPool.getMaximumPoolSize();
        double utilization = (double) activeWorkers / maxWorkers;

        MessageListenerContainer container = registry.getListenerContainer("task-worker-group");

        if (utilization > 0.8 && !paused.get()) {
            // 워커 80% 이상 사용 중 → 새 메시지 소비 일시 중단
            container.pause();
            paused.set(true);
            log.warn("Backpressure 활성화: 워커 사용률 {}%", (int)(utilization * 100));

        } else if (utilization < 0.5 && paused.get()) {
            // 워커 50% 이하로 회복 → 메시지 소비 재개
            container.resume();
            paused.set(false);
            log.info("Backpressure 해제: 워커 사용률 {}%", (int)(utilization * 100));
        }
    }
}

6. 워커 풀 관리 — Heartbeat와 Auto-Scaling

6-1. Heartbeat 기반 워커 상태 추적

워커가 작업을 처리 중인지, 죽었는지를 구분해야 처리 중 작업을 올바르게 복구할 수 있다. 워커는 30초마다 Redis에 “나 살아있음” 신호를 보내고, Supervisor는 90초 이상 신호가 없는 워커를 “장애”로 판정한다.

@Component
public class WorkerHeartbeat {

    private final String workerId = UUID.randomUUID().toString();

    @Scheduled(fixedRate = 30_000)  // 30초마다
    public void sendHeartbeat() {
        String key = "worker:heartbeat:" + workerId;
        // TTL 90초 — 90초 안에 갱신이 없으면 워커 장애로 판정
        redisTemplate.opsForValue().set(key, Instant.now().toString(), Duration.ofSeconds(90));
    }

    @PreDestroy
    public void onShutdown() {
        // 워커 정상 종료 시 명시적 heartbeat 삭제
        redisTemplate.delete("worker:heartbeat:" + workerId);
        log.info("워커 정상 종료: workerId={}", workerId);
    }
}

@Component
public class WorkerSupervisor {

    @Scheduled(fixedRate = 60_000)  // 1분마다 장애 워커 확인
    public void detectDeadWorkers() {
        Set<String> activeHeartbeats = redisTemplate.keys("worker:heartbeat:*");
        Set<String> processingWorkers = getWorkersWithActiveTasks();

        // 처리 중 작업이 있지만 heartbeat가 없는 워커 = 장애 워커
        Set<String> deadWorkers = processingWorkers.stream()
            .filter(w -> !activeHeartbeats.contains("worker:heartbeat:" + w))
            .collect(Collectors.toSet());

        for (String deadWorkerId : deadWorkers) {
            log.error("장애 워커 감지, 작업 복구 시작: workerId={}", deadWorkerId);
            recoverTasksFromDeadWorker(deadWorkerId);
        }
    }

    private void recoverTasksFromDeadWorker(String workerId) {
        // 장애 워커가 처리 중이던 작업 ID 목록 조회
        Set<String> stuckTasks = redisTemplate.opsForSet()
            .members("worker:processing:" + workerId);

        if (stuckTasks == null || stuckTasks.isEmpty()) return;

        for (String taskId : stuckTasks) {
            TaskPayload task = taskRepository.findById(Long.parseLong(taskId)).orElse(null);
            if (task == null) continue;

            // 작업을 다시 Kafka 큐에 넣어 다른 워커가 처리하도록
            String topic = topicRouter.route(task.getPriority());
            kafkaTemplate.send(topic, taskId, task);
            log.info("작업 재큐잉 완료: taskId={}, workerId={}", taskId, workerId);
        }

        // 장애 워커 처리 중 Set 정리
        redisTemplate.delete("worker:processing:" + workerId);
    }
}

7. 재시도 엔진 — 지수 백오프와 DLQ

7-1. 지수 백오프 + 지터 계산

public class RetryEngine {

    private static final long BASE_DELAY_MS = 1_000;    // 1초 기본 대기
    private static final long MAX_DELAY_MS = 300_000;   // 5분 최대 대기
    private static final int MAX_RETRY_COUNT = 5;

    /**
     * 재시도 지연 계산:
     *   기본: baseDelay * 2^attempt (1초, 2초, 4초, 8초, 16초)
     *   지터: 0 ~ baseDelay 범위의 난수 추가
     *   상한: 5분 초과하지 않음
     *
     * 지터를 추가하는 이유:
     *   1,000개 작업이 동시에 실패하면 모두 같은 시각(예: 4초 후)에 재시도
     *   → 4초 후 1,000개 동시 요청 → 재시도 폭풍
     *   지터로 각각 3.2초, 4.7초, 5.1초 등으로 분산 → 폭풍 방지
     */
    public long calculateDelay(int attempt) {
        long exponentialDelay = BASE_DELAY_MS * (1L << attempt);  // 2^attempt
        long jitter = ThreadLocalRandom.current().nextLong(BASE_DELAY_MS);
        return Math.min(exponentialDelay + jitter, MAX_DELAY_MS);
    }

    public RetryDecision decide(TaskPayload task, Exception failure) {
        int nextAttempt = task.getRetryCount() + 1;

        // 멱등성 없는 작업 유형은 재시도 없이 즉시 DLQ
        if (!task.isIdempotent()) {
            return RetryDecision.sendToDlq("비멱등 작업, 재시도 불가: " + failure.getMessage());
        }

        // 클라이언트 오류(4xx)는 재시도해도 항상 실패 → DLQ
        if (failure instanceof ClientErrorException) {
            return RetryDecision.sendToDlq("클라이언트 오류, 재시도 불가: " + failure.getMessage());
        }

        if (nextAttempt > MAX_RETRY_COUNT) {
            return RetryDecision.sendToDlq("최대 재시도 횟수 초과");
        }

        long delayMs = calculateDelay(nextAttempt);
        return RetryDecision.retryAfter(delayMs, nextAttempt);
    }
}

7-2. 재시도 시도별 예상 지연

시도 지수 부분 지터 범위 실제 대기 (평균)
1회 (즉시 실패 후) 2초 0~1초 약 2.5초
2회 4초 0~1초 약 4.5초
3회 8초 0~1초 약 8.5초
4회 16초 0~1초 약 16.5초
5회 (최종) 32초 0~1초 약 32.5초
5회 초과 DLQ 이동

8. 크론 스케줄러 — 분산 환경 설계

8-1. 크론 표현식 평가와 다음 실행 시각 계산

@Service
public class CronSchedulerService {

    @Scheduled(fixedRate = 60_000)  // 1분마다 실행 예정 크론 확인
    public void tick() {
        List<CronJob> dueJobs = cronJobRepository.findDueJobs(Instant.now());

        for (CronJob job : dueJobs) {
            String lockKey = "scheduler:lock:" + job.getName();
            String nodeId = System.getenv("POD_NAME");

            Boolean acquired = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, nodeId, Duration.ofSeconds(30));

            if (!Boolean.TRUE.equals(acquired)) continue;

            try {
                // 크론 작업을 Kafka에 투입
                TaskPayload task = TaskPayload.builder()
                    .type(job.getTaskType())
                    .payload(job.getDefaultPayload())
                    .priority(job.getPriority())
                    .idempotencyKey(job.getName() + ":" + Instant.now().truncatedTo(ChronoUnit.MINUTES))
                    .build();

                kafkaTemplate.send(topicRouter.route(job.getPriority()),
                    task.getIdempotencyKey(), task);

                // 다음 실행 시각 계산 및 업데이트
                CronExpression cronExpr = CronExpression.parse(job.getCronExpression());
                LocalDateTime nextRun = cronExpr.next(LocalDateTime.now());
                job.setNextRunAt(nextRun.toInstant(ZoneOffset.UTC));
                job.setLastRunAt(Instant.now());
                cronJobRepository.save(job);

            } finally {
                releaseLock(lockKey, nodeId);
            }
        }
    }
}

8-2. 멱등성 키로 크론 중복 실행 방어

리더 선출에 실패해 두 노드가 동시에 크론을 실행하는 엣지 케이스를 대비해, 작업에 멱등성 키(job_name:minute)를 부여한다. 워커가 같은 멱등성 키를 두 번 받으면 두 번째는 스킵한다.

@Component
public class IdempotencyGuard {

    public boolean isDuplicate(String idempotencyKey) {
        // SET NX로 멱등성 키 등록 시도
        // 이미 존재하면 중복 → false 반환
        Boolean isNew = redisTemplate.opsForValue()
            .setIfAbsent("idempotency:" + idempotencyKey, "1", Duration.ofHours(24));
        return !Boolean.TRUE.equals(isNew);
    }
}

9. 전체 데이터 흐름

graph LR
    A[작업 제출 API] --> B[Kafka 토픽]
    A --> C[Redis 지연 큐]
    C -->|1초 폴링 후 라우팅| B
    B --> D[워커 실행]
    D -->|성공| E[상태 DB]
    D -->|실패 재시도| C

즉시 실행 흐름:

  1. API가 작업을 수신하고 멱등성 키 중복 확인
  2. 우선순위에 따라 Kafka 토픽(task.priority.critical 등)으로 발행
  3. 워커 그룹이 컨슈머로 Kafka를 구독, 파티션별로 병렬 처리
  4. 작업 실행 성공 시 processing_queue에서 제거, DB에 SUCCESS 기록
  5. 실패 시 지수 백오프 후 Redis 지연 큐에 재등록

지연 실행 흐름:

  1. API가 execute_at 타임스탬프와 함께 작업 수신
  2. Redis Sorted Set에 score=execute_at으로 등록
  3. Delay Poller가 1초마다 ZRANGEBYSCORE 0 now 실행
  4. 실행 시각이 된 작업을 Lua 스크립트로 원자적으로 팝
  5. 적합한 Kafka 토픽으로 라우팅, 이후 즉시 실행과 동일

10. DB 스키마 설계

-- 작업 메타데이터 (날짜별 파티셔닝)
CREATE TABLE tasks (
    id              BIGINT          NOT NULL AUTO_INCREMENT,
    idempotency_key VARCHAR(255)    NOT NULL,
    task_type       VARCHAR(100)    NOT NULL,
    priority        ENUM('CRITICAL','HIGH','NORMAL','LOW') NOT NULL DEFAULT 'NORMAL',
    status          ENUM('PENDING','RUNNING','SUCCESS','FAILED','DEAD') NOT NULL DEFAULT 'PENDING',
    payload         JSON            NOT NULL,
    retry_count     INT             NOT NULL DEFAULT 0,
    max_retry       INT             NOT NULL DEFAULT 5,
    scheduled_at    DATETIME(3)     NOT NULL,    -- 실행 예정 시각 (밀리초 정밀도)
    started_at      DATETIME(3),
    completed_at    DATETIME(3),
    worker_id       VARCHAR(100),
    failure_reason  TEXT,
    created_at      DATETIME(3)     NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    PRIMARY KEY (id, created_at),
    UNIQUE KEY uq_idempotency (idempotency_key),
    KEY idx_status_scheduled (status, scheduled_at),
    KEY idx_worker_status (worker_id, status)
) PARTITION BY RANGE (TO_DAYS(created_at)) (
    PARTITION p_2026_05 VALUES LESS THAN (TO_DAYS('2026-06-01')),
    PARTITION p_2026_06 VALUES LESS THAN (TO_DAYS('2026-07-01')),
    PARTITION p_future  VALUES LESS THAN MAXVALUE
);

-- 크론 작업 정의
CREATE TABLE cron_jobs (
    id               BIGINT       NOT NULL AUTO_INCREMENT,
    name             VARCHAR(100) NOT NULL UNIQUE,
    task_type        VARCHAR(100) NOT NULL,
    cron_expression  VARCHAR(100) NOT NULL,    -- "0 2 * * *"
    priority         ENUM('CRITICAL','HIGH','NORMAL','LOW') NOT NULL DEFAULT 'NORMAL',
    default_payload  JSON,
    enabled          BOOLEAN      NOT NULL DEFAULT TRUE,
    last_run_at      DATETIME(3),
    next_run_at      DATETIME(3)  NOT NULL,
    created_at       DATETIME(3)  NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
    PRIMARY KEY (id),
    KEY idx_next_run (next_run_at, enabled)
);

-- DLQ 항목
CREATE TABLE dead_letter_queue (
    id             BIGINT       NOT NULL AUTO_INCREMENT,
    task_id        BIGINT       NOT NULL,
    failure_reason TEXT         NOT NULL,
    original_payload JSON       NOT NULL,
    dead_at        DATETIME(3)  NOT NULL,
    resolved_at    DATETIME(3),           -- 운영자 수동 처리 시각
    resolved_by    VARCHAR(100),
    PRIMARY KEY (id),
    KEY idx_dead_unresolved (dead_at, resolved_at)
);

11. 모니터링 지표

지표 경보 조건 의미
kafka_consumer_lag_total > 50,000 워커 처리 능력 부족, 스케일 아웃 필요
task_processing_p99_ms > 2,000ms 워커 성능 저하 또는 외부 API 지연
task_failed_rate > 5% 외부 시스템 장애 또는 코드 버그
dlq_size > 100 수동 검토 필요 작업 누적
cron_duplicate_runs > 0 리더 선출 실패 (즉시 조사 필요)
delayed_queue_size > 5,000,000 지연 큐 포화, Poller 성능 검토
worker_heartbeat_missing > 0 워커 장애, 작업 복구 필요

12. 이 설계의 한계와 대안

비유: 고속도로 설계도가 아무리 훌륭해도 교통량이 10배 늘면 병목이 생깁니다. 지금 설계한 아키텍처도 “어느 부품이 먼저 터질까?”를 미리 알고 대안 경로를 준비해야 합니다.

12-1. Kafka가 단일 장애점이 되면

Kafka 브로커 클러스터 전체가 다운되면 즉시 실행 큐와 크론 작업 투입 경로가 동시에 막힌다. “Kafka 없이도 작업을 받을 수 있는가?”가 핵심 질문이다.

graph LR
    A[작업 제출 API] --> B{Kafka 정상?}
    B -->|정상| C[Kafka 토픽]
    B -->|장애| D[Fallback 선택]
    D --> E[DB 폴링 큐]
    D --> F[인메모리 큐 + WAL]
    D --> G[RabbitMQ]
Fallback 전략 적합한 상황 주의사항
DB 폴링 큐 처리량이 일 10만 건 이하로 떨어져도 되는 경우 인덱스 핫스팟, TPS 한계 명확히 이해 후 사용
인메모리 큐 + WAL 단기 장애(수십 분), 유실 허용 불가 서버 재기동 시 WAL 재생 로직 필수, 메모리 한계 있음
RabbitMQ Kafka 대체 메시지 브로커가 이미 운영 중인 경우 파티션 병렬성이 Kafka보다 약함, 대규모 재처리 불리

현실적인 조언: Kafka 자체의 가용성은 99.95% 이상으로 실제 장애보다 “Kafka 설정 오류”나 “토픽 권한 문제”가 훨씬 빈번하다. Fallback을 복잡하게 구현하기 전에 Kafka 클러스터 모니터링과 알림부터 완성하는 것이 실용적이다.

12-2. Redis 지연 큐 데이터 유실 — AOF/RDB 영속성의 한계

AOF everysec 설정은 “최대 1초 유실”을 약속하지만, 실제로는 더 많이 잃을 수 있다. Redis가 AOF rewrite 중에 디스크가 가득 차거나, Sentinel 자동 페일오버 타이밍이 맞지 않으면 수십 초치 데이터가 날아간다.

AOF 모드별 트레이드오프:

AOF 설정 유실 범위 디스크 I/O 추천 상황
always 0 (이론상) 3~5배 증가 금융·결제 등 유실 절대 불가
everysec 최대 1~수십 초 기본 대부분의 웹 서비스
no 수분 이상 최소 유실 허용, 성능 최우선

DB 기반 지연 큐 대안: Redis가 부담스럽다면 DB의 scheduled_at 컬럼 폴링도 현실적인 선택이다.

-- 지연 큐를 DB로 구현할 때
-- 핵심은 FOR UPDATE SKIP LOCKED — 다른 워커가 이미 잡은 행은 건너뜀
SELECT id, payload, scheduled_at
FROM tasks
WHERE status = 'PENDING'
  AND scheduled_at <= NOW()
ORDER BY priority DESC, scheduled_at ASC
LIMIT 100
FOR UPDATE SKIP LOCKED;

FOR UPDATE SKIP LOCKED는 MySQL 8.0+, PostgreSQL 9.5+에서 지원한다. 여러 워커가 동시에 실행해도 같은 작업을 중복으로 가져가지 않는다. 처리량이 초당 수백 건 이하라면 Redis 없이 이것만으로도 충분하다.

DB 지연 큐의 한계: 초당 1,000건 이상에서는 scheduled_at 인덱스 핫스팟이 심화된다. 이 시점이 Redis 도입을 진지하게 고려해야 할 임계점이다.

12-3. 워커가 OOM으로 죽으면 — 미완료 작업 복구

워커가 OutOfMemoryError로 비정상 종료되면 세 가지 문제가 동시에 발생한다:

  1. Heartbeat가 더 이상 갱신되지 않음 → Supervisor가 90초 후 감지
  2. processing_queue에 작업 ID가 남아 있음 → 복구 대상
  3. Kafka 오프셋 커밋이 안 됐을 수 있음 → 재배달 가능
graph LR
    A[워커 OOM 사망] --> B[90초 후 Supervisor 감지]
    B --> C[processing_queue 스캔]
    C --> D[미완료 작업 재큐잉]
    D --> E[다른 워커가 재처리]
    A --> F[Kafka 오프셋 미커밋]
    F --> G[Kafka rebalance 후 재배달]
    G --> E

작업 타임아웃 강제 설정의 중요성: 워커가 죽지 않더라도 단일 작업이 무한정 실행되면 스레드가 고갈된다. 모든 작업에 명시적 타임아웃을 설정해야 한다.

// 작업 실행에 명시적 타임아웃 강제
Future<?> future = workerExecutor.submit(() -> taskExecutor.execute(task));
try {
    future.get(task.getTimeoutSeconds(), TimeUnit.SECONDS);
} catch (TimeoutException e) {
    future.cancel(true);
    // 타임아웃된 작업은 재시도 큐로 → 다음 번엔 다른 워커가 처리
    scheduleRetry(task, "작업 타임아웃 초과: " + task.getTimeoutSeconds() + "초");
}

주의: 재큐잉된 작업이 멱등하지 않으면 “작업이 절반만 실행된 상태”에서 처음부터 다시 실행되는 문제가 생긴다. 작업 타임아웃 설계와 멱등성 설계는 반드시 함께 고민해야 한다.

12-4. 크론 스케줄러 Split-Brain — 리더가 2명이 되는 순간

Redis SET NX는 단일 Redis 인스턴스에서는 원자적이다. 그러나 두 가지 상황에서 리더가 2명 생길 수 있다.

상황 1 — GC pause: 리더 노드 A가 SET NX로 락을 잡은 뒤 30초 Full GC가 발생했다. TTL이 만료되고 노드 B가 락을 획득해 작업을 시작했다. GC에서 깨어난 A는 자신이 아직 리더라고 믿고 계속 실행한다. 이 순간 A와 B가 동시에 실행 중이다.

상황 2 — Redis Sentinel 페일오버: 마스터가 죽고 Sentinel이 복제본을 마스터로 승격하는 수십 초 사이에 두 노드가 각각 다른 Redis 인스턴스에 SET NX를 시도해 둘 다 성공한다.

Fencing Token으로 방어:

// 락 획득 시 단조 증가 토큰(버전 번호)을 함께 받음
public LockResult acquireLock(String lockKey, String nodeId) {
    // INCR로 단조 증가 토큰 생성
    Long fencingToken = redisTemplate.opsForValue().increment("fence:" + lockKey);

    Boolean acquired = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, nodeId + ":" + fencingToken,
                     Duration.ofMillis(LOCK_TTL_MS));

    return new LockResult(acquired, fencingToken);
}

// 작업 실행 전 DB에 "내가 최신 리더인가?" 확인
public boolean isCurrentLeader(String lockKey, long myToken) {
    // DB에 현재 승인된 토큰 기록
    // 내 토큰이 DB의 토큰보다 작으면 → 더 최신 리더가 생긴 것 → 즉시 중단
    Long approvedToken = leaderTokenRepository.getApprovedToken(lockKey);
    return approvedToken == null || myToken >= approvedToken;
}

더 현실적인 방어책: 크론 작업 자체를 멱등하게 설계하는 것이 Fencing Token보다 실용적이다. “정산 집계를 두 번 실행해도 결과가 같은가?”라는 질문에 “예”라고 답할 수 있다면, Split-Brain은 성능 문제일 뿐 정확성 문제가 아니다.

12-5. DLQ에 쌓인 작업 — 자동 재시도의 한계와 수동 개입

DLQ는 “포기한 작업의 무덤”이 아니라 “원인 분석 후 재처리 대기소”다. 운영자가 DLQ를 방치하면 비즈니스 손실이 조용히 누적된다.

graph LR
    A[DLQ 쌓임] --> B{원인 분류}
    B -->|일시적 장애| C[자동 재처리 배치]
    B -->|코드 버그| D[핫픽스 배포 후 재처리]
    B -->|데이터 오류| E[수동 수정 후 재처리]
    B -->|비즈니스 판단 필요| F[운영자 검토 큐]

DLQ 운영 정책 예시:

실패 원인 유형 자동 재처리 가능? 대기 시간 알림 수준
외부 API 일시 장애 (5xx) 가능 장애 복구 후 즉시 Slack 알림
외부 API 영구 오류 (4xx) 불가 PagerDuty
DB 유니크 위반 불가 (중복 작업) Slack 알림 (낮음)
코드 NPE/예외 핫픽스 후 가능 배포 완료 대기 PagerDuty
알 수 없는 원인 수동 판단 필요 PagerDuty
// DLQ 자동 재처리 배치 — 외부 API 회복 후 실행
@Scheduled(cron = "0 */30 * * * *")  // 30분마다
public void retryDlqTasks() {
    List<DeadLetterTask> retryable = dlqRepository.findRetryable(
        // 재처리 가능한 유형만 조회 (4xx 오류 제외)
        List.of("TIMEOUT", "SERVER_ERROR", "NETWORK_ERROR"),
        // 최근 1시간 이내 DLQ 진입 작업만
        Instant.now().minus(Duration.ofHours(1))
    );

    if (retryable.isEmpty()) return;

    log.info("DLQ 자동 재처리 시작: {}건", retryable.size());
    for (DeadLetterTask dlqTask : retryable) {
        // 재처리 시 retryCount 초기화 — 새 시도로 간주
        TaskPayload fresh = dlqTask.toTaskPayload().toBuilder()
            .retryCount(0)
            .build();
        kafkaTemplate.send(topicRouter.route(fresh.getPriority()),
            fresh.getIdempotencyKey(), fresh);
        dlqRepository.markRequeued(dlqTask.getId());
    }
}

13. 동시성과 락 심층 분석

13-1. Redis SET NX의 한계 — GC pause와 Fencing Token

앞서 Split-Brain 시나리오에서 GC pause 문제를 언급했다. 더 정확히 말하면, Redis SET NX락 획득 시점의 원자성은 보장하지만 락 보유 기간 동안의 독점성은 보장하지 않는다.

비유: 화장실 열쇠를 받았지만 안에서 기절했습니다. 30초 후 열쇠가 자동으로 반납되고 다른 사람이 들어왔습니다. 기절에서 깨어난 첫 번째 사람은 “내가 아직 화장실 안에 있다”고 믿습니다. 두 사람이 동시에 화장실 안에 있는 상황입니다.

타임라인:
T+0s   : 노드 A가 SET NX 성공, 락 획득 (TTL 30초)
T+5s   : 노드 A가 Full GC 시작 (STW pause)
T+35s  : 노드 A의 락 TTL 만료
T+36s  : 노드 B가 SET NX 성공, 락 획득 → 크론 실행 시작
T+40s  : 노드 A의 GC 종료, 자신이 리더라고 믿고 크론 실행 시작
         → 두 노드 동시 실행!

Fencing Token이 이를 막는 방법: 락 획득 시마다 단조 증가하는 토큰(버전 번호)을 부여하고, 실제 작업을 실행하는 시스템(DB, 외부 API)이 “이전에 본 토큰보다 큰 토큰만 허용”하도록 설계한다. 노드 A의 토큰이 1이고 노드 B의 토큰이 2라면, A가 아무리 늦게 깨어나도 토큰 1은 이미 거절된다.

13-2. 작업 중복 실행 방지 — 멱등키 + DB UNIQUE vs Redis SET NX

두 가지 방어 수단은 서로 다른 레이어를 보호한다. 하나만 쓰면 구멍이 생긴다.

방어 수단 보호 대상 한계
Redis SET NX (멱등키) 동일 멱등키 중복 처리 방지 Redis 재시작/장애 시 캐시 소실 → 중복 통과 가능
DB UNIQUE constraint 영속적 중복 방지 최후 방어선 트랜잭션 범위 내에서만 보호, 분산 트랜잭션 불가
// 두 가지를 조합한 이중 방어
public boolean tryStartTask(String idempotencyKey, String taskId) {

    // 1차 방어: Redis (빠른 경로, 99% 케이스 처리)
    Boolean isNew = redisTemplate.opsForValue()
        .setIfAbsent("idempotency:" + idempotencyKey, taskId, Duration.ofHours(24));
    if (!Boolean.TRUE.equals(isNew)) {
        log.debug("Redis 멱등키 중복 감지: {}", idempotencyKey);
        return false;
    }

    // 2차 방어: DB UNIQUE constraint (Redis 장애 후 캐시 미스 시 최후 방어)
    try {
        taskRepository.insertIdempotencyRecord(idempotencyKey, taskId);
        return true;
    } catch (DuplicateKeyException e) {
        // DB가 잡은 중복 — Redis가 놓친 케이스
        log.warn("DB 멱등키 중복 감지 (Redis 캐시 미스): {}", idempotencyKey);
        // Redis 캐시 복구
        redisTemplate.opsForValue()
            .set("idempotency:" + idempotencyKey, taskId, Duration.ofHours(24));
        return false;
    }
}

13-3. Kafka Consumer Rebalance 중 작업 중복 할당

Rebalance가 발생하면 컨슈머가 처리 중이던 파티션 소유권이 박탈될 수 있다. 오프셋을 커밋하기 전이라면 같은 메시지가 다른 컨슈머에게 재배달된다.

문제 시나리오:

T+0s: 컨슈머 A가 파티션 3의 offset 1000~1010 메시지 가져옴
T+3s: 컨슈머 B 신규 투입 → Rebalance 시작
T+3s: 컨슈머 A의 파티션 3 소유권 박탈 (오프셋 1000~1010 미커밋 상태)
T+4s: 컨슈머 B가 파티션 3 획득, offset 1000부터 재배달
      → 1000~1010이 두 번 처리될 가능성

CooperativeStickyAssignor + 수동 오프셋 커밋 조합:

// 각 메시지 처리 후 즉시 오프셋 커밋 (auto.commit.enable=false)
@KafkaListener(topics = "task.priority.normal", groupId = "task-worker-group")
public void consume(ConsumerRecord<String, TaskPayload> record,
                    Acknowledgment ack) {
    try {
        // 멱등성 키로 중복 확인 후 실행
        if (!idempotencyGuard.isDuplicate(record.value().getIdempotencyKey())) {
            taskExecutor.execute(record.value());
        }
        // 처리 성공 후 즉시 커밋 → Rebalance 후 재배달 방지
        ack.acknowledge();
    } catch (Exception e) {
        // 실패 시 커밋하지 않음 → 재배달 허용 (재시도 의도)
        scheduleRetry(record.value(), e);
        ack.acknowledge();  // 재시도는 별도 큐에 등록했으므로 오프셋은 커밋
    }
}

핵심 원칙: Rebalance는 막을 수 없다. “Rebalance가 일어나도 안전한 작업 설계”가 목표여야 한다. 모든 작업이 멱등하면 중복 실행은 성능 문제일 뿐 정확성 문제가 되지 않는다.


14. 오버엔지니어링 경고 — 규모별 적정 설계

비유: 동네 빵집에 스타벅스 키오스크 시스템을 붙이면 오히려 느려집니다. 작업 스케줄러도 “얼마나 복잡한 시스템이 필요한가?”를 먼저 물어야 합니다.

14-1. 규모별 설계 적정선

graph LR
    A[일 1,000건] --> B[cron + DB 테이블]
    C[일 10만건] --> D[Kafka 도입]
    E[일 100만건+] --> F[풀 분산 아키텍처]
    D -.->|과함| G[Redis 지연 큐]
규모 적정 기술 스택 이 글의 아키텍처 필요성
일 1,000건 이하 OS cron + DB 상태 컬럼 불필요. Kafka·Redis 운영 비용이 개발 비용보다 높음
일 1만~10만건 Kafka만 도입 (Redis 지연 큐 없이) Redis 지연 큐는 과함. DB FOR UPDATE SKIP LOCKED로 충분
일 10만~1,000만건 Kafka + Redis 지연 큐 점진적 도입 권장. Redis 먼저 검토 후 Kafka 추가
일 1,000만건 이상 이 글의 풀 아키텍처 합당한 복잡도

14-2. “Spring @Scheduled로 충분한 경우”

단일 인스턴스 또는 액티브-스탠바이 2대 구성이라면 분산 스케줄러가 필요 없다.

// 이것만으로 충분한 경우가 많다
@Scheduled(cron = "0 2 * * *")
public void dailyReport() {
    reportService.generateDailyReport();
}

분산 스케줄러가 필요한 조건 체크리스트:

  • 인스턴스가 3개 이상 수평 확장되는가?
  • 크론 작업 중복 실행 시 비즈니스 손실이 발생하는가? (단순 집계 재실행은 무해할 수 있음)
  • 처리량이 단일 인스턴스 한계를 넘는가?
  • 작업 실행 이력 감사 추적이 필요한가?

이 중 2개 이상 해당될 때 분산 스케줄러를 도입한다. 아니라면 @Scheduled + DB 상태 컬럼이 더 단순하고 안정적인 선택이다.

14-3. Kafka vs RabbitMQ vs SQS — 작업 큐 용도 비교

작업 큐 용도로만 본다면 Kafka가 항상 최선이 아니다.

항목 Kafka RabbitMQ AWS SQS
메시지 순서 보장 파티션 내 보장 큐 내 보장 표준 큐 미보장, FIFO 큐 보장
지연 메시지 기본 미지원 (별도 구현 필요) 플러그인으로 지원 메시지 지연 최대 15분
DLQ 별도 토픽으로 구현 네이티브 지원 네이티브 지원
재처리 (Replay) 강점 (오프셋 되감기) 불가 (소비된 메시지 삭제) 불가
운영 복잡도 높음 (ZooKeeper/KRaft, 모니터링) 중간 낮음 (완전 관리형)
처리량 초당 수백만 건 초당 수만 건 초당 수천~수만 건
적합한 용도 대규모, 감사 추적, 재처리 필수 라우팅 복잡, 지연 메시지 클라우드 네이티브, 운영 부담 최소화

결론: 일 100만 건 미만이고 AWS 환경이라면 SQS + Lambda가 Kafka보다 훨씬 단순하다. 재처리(Replay)가 핵심 요구사항일 때만 Kafka가 진가를 발휘한다.

14-4. Kafka Streams로 작업 상태 집계

Kafka를 이미 사용 중이라면 Kafka Streams로 실시간 작업 상태 대시보드를 구축할 수 있다. 별도 DB 집계 쿼리 없이 스트림 처리로 진행 중/완료/실패 카운터를 실시간으로 유지한다.

@Configuration
public class TaskStatusAggregationStream {

    @Bean
    public KStream<String, TaskEvent> taskStatusStream(StreamsBuilder builder) {
        // task-events 토픽에서 상태 변경 이벤트 소비
        KStream<String, TaskEvent> events = builder.stream("task-events");

        // 상태별 카운터 집계 (텀블링 윈도우: 1분)
        KTable<Windowed<String>, Long> statusCounts = events
            .groupBy((key, event) -> event.getStatus().name())
            .windowedBy(TimeWindows.ofSizeWithNoGrace(Duration.ofMinutes(1)))
            .count(Materialized.as("task-status-counts"));

        // 집계 결과를 모니터링 토픽으로 발행
        statusCounts.toStream()
            .map((windowedStatus, count) -> KeyValue.pair(
                windowedStatus.key(),
                new StatusMetric(windowedStatus.key(), count,
                    windowedStatus.window().startTime())
            ))
            .to("task-status-metrics");

        return events;
    }
}

이 집계 스트림이 제공하는 실시간 지표:

  • 분당 성공/실패/DLQ 진입 건수 추이
  • 특정 시간대 실패율 급증 즉시 감지
  • 우선순위별 처리량 불균형 파악

15. 극한 시나리오

극한 시나리오 1: Redis 장애 — 지연 큐 5,000만 건 전부 소실 위험

오전 11시 32분, Redis 주 노드의 디스크가 가득 차서 AOF 쓰기가 실패했습니다. 프로세스가 비정상 종료되면서 Sentinel이 복제본을 주 노드로 승격했지만, 마지막 AOF 동기화 시점 이후 12초치 데이터가 소실됐습니다. 12초 동안 Redis에 등록된 지연 작업 약 14만 건이 사라졌습니다.

문제점:

  • AOF everysec 설정으로 최대 1초 데이터 손실이 예상이었으나, 디스크 포화로 인한 강제 종료는 12초 지연 후 발생
  • DB의 PENDING 상태 작업은 남아 있지만 스케줄러가 DB를 보조 복구 수단으로만 사용하도록 설계되어 자동 복구 경로 없음

대응 전략:

  1. AOF always 모드로 전환 검토 (디스크 I/O 2~3배 증가 감수)
  2. DB PENDING 작업 기반 주기적 복구 스캐너 추가 — 1시간마다 DB에서 scheduled_at < now AND status = PENDING인 작업을 조회해 Redis에 재등록
  3. Redis 디스크 사용량 모니터링 강화 — 80% 초과 시 즉시 경보
@Scheduled(cron = "0 */1 * * *")  // 1시간마다 DB-Redis 정합성 복구
public void reconcileDelayedQueue() {
    // DB에서 실행 예정이지만 Redis에 없는 PENDING 작업 조회
    List<TaskPayload> pendingTasks = taskRepository.findMissingFromRedis(
        Instant.now().minus(Duration.ofHours(2)),
        Instant.now().plus(Duration.ofHours(1))
    );

    if (pendingTasks.isEmpty()) return;

    log.warn("Redis-DB 불일치 감지, 복구 시작: {}건", pendingTasks.size());

    for (TaskPayload task : pendingTasks) {
        redisTemplate.opsForZSet().addIfAbsent(
            "delayed_queue",
            String.valueOf(task.getId()),
            task.getScheduledAt().toEpochMilli()
        );
    }
}

극한 시나리오 2: 재시도 폭풍 — PG사 30초 장애가 워커 클러스터 다운으로

오후 3시 17분, 외부 PG사 API가 503을 반환하기 시작했습니다. 결제 워커 100개가 동시에 실패하고, 지수 백오프 없이 5초 후 일제히 재시도했습니다. 5초 후 100개 × 5회 재시도 = 500개 동시 요청이 PG사로 향했습니다. PG사는 해당 IP를 차단했고, 재시도 작업이 DLQ로 쏟아지면서 Kafka 토픽이 포화됐습니다. 결제 처리가 47분간 전면 중단됐습니다.

문제점:

  • 외부 API 실패 시 Circuit Breaker 없이 즉시 재시도
  • 재시도 간격이 고정 5초로 동시 집중
  • DLQ 크기 제한 없음 → Kafka 토픽 저장 용량 초과

대응 전략:

  1. Circuit Breaker 적용 (Resilience4j): PG사 API 실패율 50% 초과 시 30초 동안 모든 호출 차단, 워커를 불필요한 재시도에서 보호
  2. 지수 백오프 + 지터 의무화: 모든 외부 API 호출에 RetryEngine.calculateDelay()를 필수 적용
  3. DLQ 크기 제한 + 알림: DLQ 100건 초과 시 Slack 알림, 1,000건 초과 시 PagerDuty
@Bean
public CircuitBreaker pgApiCircuitBreaker() {
    CircuitBreakerConfig config = CircuitBreakerConfig.custom()
        .failureRateThreshold(50)           // 실패율 50% 초과 시 OPEN
        .waitDurationInOpenState(Duration.ofSeconds(30))  // 30초 후 HALF_OPEN 시도
        .slidingWindowSize(10)              // 최근 10회 호출 기준
        .build();

    return CircuitBreakerRegistry.of(config).circuitBreaker("pg-api");
}

극한 시나리오 3: 크론 중복 실행 — 정산 배치 3중 실행으로 이중 출금

매일 새벽 2시에 실행되는 정산 배치가 3개 노드에서 동시에 실행됐습니다. Redis 클러스터 네트워크 파티션으로 인해 3개 노드가 각자 SET NX에 성공했다고 판단했고(split-brain), 정산 로직이 3번 실행됐습니다. 일부 가맹점에 3배 정산이 이루어졌고 복구에 4시간이 걸렸습니다.

문제점:

  • Redis 클러스터의 네트워크 파티션 상황에서 SET NX가 분할된 복제본에 각각 성공
  • 정산 작업 자체에 멱등성 설계 없음 — 두 번 실행하면 두 번 출금

대응 전략:

  1. DB 유니크 제약 기반 2중 방어: 정산 작업 시작 시 INSERT INTO settlement_locks(job_name, run_date) VALUES(?, ?) — UNIQUE KEY 위반으로 중복 실행 시 즉시 예외 발생
  2. Redlock 알고리즘 적용: Redis 5개 독립 노드 중 과반(3개) 이상에서 락 획득 성공 시에만 리더로 인정
  3. 정산 로직 멱등성 보장: UPDATE account SET balance = target_balance WHERE balance = previous_balance AND version = current_version처럼 낙관적 잠금으로 중복 적용 방지

16. 성능 최적화

16-1. Kafka 배치 컨슈밍

워커가 메시지를 1건씩 처리하면 DB 왕복이 1건당 1회 발생한다. 100건을 배치로 처리하면 DB 왕복이 1회로 줄어든다.

@KafkaListener(
    topics = "task.priority.normal",
    groupId = "task-worker-group",
    containerFactory = "batchListenerContainerFactory"
)
public void consumeBatch(List<ConsumerRecord<String, TaskPayload>> records) {
    log.debug("배치 처리 시작: {}건", records.size());

    // 배치 내 멱등성 키 중복 제거
    Map<String, TaskPayload> deduped = records.stream()
        .collect(Collectors.toMap(
            r -> r.value().getIdempotencyKey(),
            r -> r.value(),
            (existing, duplicate) -> existing  // 중복 시 첫 번째 유지
        ));

    List<TaskPayload> tasks = new ArrayList<>(deduped.values());

    // 작업 타입별 그룹화 후 병렬 실행
    tasks.parallelStream()
        .collect(Collectors.groupingBy(TaskPayload::getType))
        .forEach((type, typedTasks) -> {
            TaskHandler handler = handlerRegistry.get(type);
            handler.executeBatch(typedTasks);
        });

    // 배치 성공 결과 DB에 일괄 업데이트 (개별 UPDATE 100회 → 배치 UPDATE 1회)
    List<Long> successIds = tasks.stream().map(TaskPayload::getId).collect(Collectors.toList());
    taskRepository.bulkUpdateStatus(successIds, TaskStatus.SUCCESS);
}

16-2. 지연 큐 샤딩

단일 Redis Sorted Set에 수천만 건이 쌓이면 메모리 압력과 잠금 경합이 발생한다. delayed_queue:{shard_id} 형태로 16개 샤드로 분산한다.

public void scheduleDelayed(TaskPayload task, Duration delay) {
    long executeAt = Instant.now().plus(delay).toEpochMilli();
    // 작업 ID 기반 샤딩 — 동일 작업은 항상 같은 샤드
    int shardId = (int)(task.getId() % 16);
    String shardKey = "delayed_queue:" + shardId;
    redisTemplate.opsForZSet().add(shardKey, String.valueOf(task.getId()), executeAt);
}

17. 요약 — 설계 결정 한눈에 보기

컴포넌트 선택 기술 핵심 이유
즉시 실행 큐 Kafka (우선순위별 토픽 분리) 파티션 병렬성, 영속성, Consumer Lag 기반 스케일 아웃
지연 실행 큐 Redis Sorted Set + Lua score=타임스탬프 범위 조회 O(log N), 원자적 팝
리더 선출 Redis SET NX + TTL + 갱신 구현 단순, 기존 Redis 재활용, 30초 자동 장애 복구
재시도 전략 지수 백오프 + 지터 + DLQ 재시도 폭풍 방지, 실패 작업 추적 및 수동 재처리
워커 상태 추적 Redis Heartbeat (30초 갱신) 장애 워커 90초 내 감지, 미완료 작업 자동 복구
크론 중복 방지 DB UNIQUE + Redis NX 이중 방어 Redis split-brain 시에도 DB가 최후 방어선 역할
Backpressure Kafka pause/resume + 워커 사용률 워커 80% 도달 시 자동 소비 중단, 메모리 보호

한 줄 정리: 하루 1억 건의 비동기 작업은 Kafka가 처리량을 수평 확장하고, Redis Sorted Set이 지연 실행 타이밍을 정밀하게 제어하며, 분산 락과 멱등성 키가 중복 실행을 이중으로 방어할 때 비로소 안정적으로 동작한다.

댓글

이 글이 도움이 됐다면?

같은 카테고리의 다른 글도 확인해보세요

더 많은 글 보기 →