infra
Platform

모듈 맵

[Infra Ops] Cron/Quartz 장애 분석과 배치 재처리 실무

0 / 52 완료

펼치기
0 / 52 완료0%

인프라 운영 & SRE · 32 / 52

[Infra Ops] Cron/Quartz 장애 분석과 배치 재처리 실무

crontab 스케줄링, Quartz 장애 패턴(misfire/lock 충돌), Spring Batch 재처리, 배치 실패 감지 및 알림까지

중급6032 / 52
🚨INCIDENT ALERT
HIGH

새벽 2시에 실행돼야 할 정산 배치가 안 돌았습니다. 원인을 찾아보니 Quartz가 "Unable to acquire lock"를 남기고 실행을 포기했습니다. 다음날 같은 파라미터로 Spring Batch를 수동 실행하려니 "already exists and is complete" 예외가 납니다. 배치 실패를 감지해 알림을 받는 구조도 없어서 새벽에 아무도 몰랐습니다.

cron 스케줄링 기본부터 Quartz 장애 패턴, Spring Batch 재처리, 배치 실패 감지까지 — 배치 운영 실무를 정리합니다.

이번 챕터에서 배울 것
  • 1crontab 표현식을 읽고 실행 스케줄을 설명할 수 있다
  • 2cron 실행 로그 확인 방법과 Quartz misfire 개념을 설명할 수 있다
  • 3Quartz Lock 충돌 장애를 진단하고 DB에서 수동으로 해제할 수 있다
  • 4Spring Batch 재실행이 안 될 때 run.id 파라미터 추가로 해결할 수 있다
  • 5배치 실패 감지 스크립트와 알림 구조를 설명할 수 있다

crontab 기본

💡개념

crontab 표현식과 실행 로그 확인

Linux 시스템에서 정기 실행 작업의 대부분은 cron을 씁니다. 표현식 읽는 법과 실행 결과를 로그에서 확인하는 방법을 알아야 배치 문제를 진단할 수 있습니다.

crontab 표현식과 배치 모니터링 — 분(0-59)·시(0-23)·일(1-31)·월(1-12)·요일(0-7) 5개 필드와 예시(0 2 * * *=매일 새벽 2시, */10=10분마다, 0 9 * * 1-5=평일 9시). 배치 잡은 실행→exit code 확인→로그 기록→성공·실패 알림→재실행 전략 흐름으로 모니터링확대

새벽 2시에 돌아야 할 정산 배치가 실행되지 않았습니다. 아침에 고객사 민원이 들어오고 나서야 알았습니다. crontab에 등록은 됐다고 하는데, 실행 여부를 확인하는 방법을 모릅니다. 배치 실패가 조용히 일어나고 아무도 모르는 상황이 반복되면 서비스 신뢰도에 직격탄이 됩니다. 배치 운영의 첫걸음은 "언제 실행됐는지, 실행됐는지 안 됐는지"를 로그에서 확인하는 능력입니다. crontab 표현식을 읽고 실행 이력을 추적하는 방법을 익히면 이런 장애를 빠르게 진단할 수 있습니다.

crontab 표현식 구조* * * * * 실행할 명령어의 다섯 자리는 각각:

위치필드범위
1번째0-59
2번째0-23
3번째1-31
4번째1-12
5번째요일0-7 (0과 7 모두 일요일)

자주 쓰는 cron 표현식:

로컬 터미널
# 매일 새벽 2시 정각
0 2 * * * /opt/batch/run-daily.sh

# 매 30분마다
*/30 * * * * /opt/batch/check-status.sh

# 매 2시간 정각
0 */2 * * * /opt/batch/sync-data.sh

# 평일(월-금) 오전 9시
0 9 * * 1-5 /opt/batch/business-report.sh

# 매월 1일 새벽 3시
0 3 1 * * /opt/batch/monthly-settle.sh

# 매일 자정, 새벽 6시, 정오, 오후 6시
0 0,6,12,18 * * * /opt/batch/refresh-cache.sh
로컬 터미널
# crontab 관리 명령어
crontab -l               # 현재 사용자의 crontab 목록
crontab -e               # crontab 편집 (기본 에디터)
crontab -l -u appuser    # 특정 사용자의 crontab 확인 (root 권한)
sudo crontab -l -u root  # root 사용자 crontab

# cron 실행 로그 확인
grep "CRON" /var/log/syslog | tail -20          # Ubuntu/Debian
grep "CRON" /var/log/cron | tail -20            # RHEL/CentOS
journalctl -u cron --since "today" --no-pager  # systemd 환경

# 특정 스크립트 실행 기록
grep "run-daily.sh" /var/log/syslog | tail -10
💡개념

예약한 배치가 실제로 도는 전체 흐름 — 시각 도래부터 실패 알림까지 6단계

0 2 * * * 한 줄을 등록해두면 새벽 2시에 배치가 돕니다. 그런데 그 사이에는 스케줄러가 시각 도래를 감지하고, 작업을 트리거하고, 선행 조건과 중복 실행을 점검하고, 실행하며 자원을 쓰고, 완료·실패를 처리해 알림까지 보내는 여러 단계가 있습니다. 배치 장애의 대부분은 "안 돌았다"거나 "두 번 돌았다"인데, 이 흐름의 어느 단계가 깨졌는지를 알면 원인을 빠르게 좁힐 수 있습니다. 각 단계가 하는 일과 막히면 나오는 증상을 나눠 봅니다.

TEXT
[스케줄 등록: 0 2 * * *]
   │
   ① 시각 도래 감지               (cron 데몬/Quartz가 매분 벽시계와 대조 · 타임존 기준)
   │
   ② 작업 트리거                  (도래한 잡의 명령/Job 발화 · 밀린 건 misfire로)
   │
   ③ 선행 조건 · 중복 실행 방지    (이전 실행 종료 확인 · DB Lock으로 단일 노드만 실행)
   │
   ④ 실행 · 자원 사용             (프로세스 기동 → CPU/메모리/DB 커넥션 점유)
   │
   ⑤ 완료 / 실패 처리             (exit code 판정 · 재시도 · misfire 정책 처리)
   │
   ⑥ 결과 기록 · 알림             (로그·실행 이력 DB · 실패 시 메일/슬랙)
   ▼
[다음 예정 시각까지 대기]

각 단계에서 하는 일과, 막히면 나오는 증상:

단계하는 일여기서 막히면
① 시각 감지데몬이 시스템(또는 CRON_TZ) 타임존 기준 벽시계로 도래를 판단타임존 전제가 어긋남 → UTC 서버에서 새벽 2시가 KST 오전 11시에 실행
② 트리거도래한 잡을 발화, 실행 못 한 건 misfire로 쌓임점검 후 재기동 시 밀린 misfire가 한꺼번에 발화 → 중복 실행
③ 중복 방지이전 실행 종료를 확인하고 클러스터 DB Lock으로 한 노드만 실행Lock 없이 다중화 → 양 노드 동시 실행으로 데이터 중복 / Lock 미해제 → acquire lock 실패
④ 실행프로세스가 자원을 점유하며 데이터를 처리장기 실행이 다음 주기와 겹침 → 두 실행이 같은 데이터를 경합
⑤ 완료/실패exit code로 성패를 판정하고 재시도·재실행COMPLETED된 잡 재실행 시 파라미터 충돌(run.id 필요)
⑥ 기록·알림결과를 로그·DB에 남기고 실패 시 알림 발송알림 체계 없음 → 실패해도 조용히 넘어가 며칠간 방치(실패 무시)

즉 **배치 운영의 핵심은 "돌리기"가 아니라 "제때, 한 번만, 실패를 알 수 있게 도는 것"**입니다. "배치가 안 돌았다"는 ①(타임존)·②(misfire)·③(Lock)에서, "돌긴 했는데 결과가 이상하다"는 ④(겹침)·⑥(실패 무시)에서 갈립니다. 그래서 스케줄을 옮기면 표현식보다 timedatectl(①)을, 스케줄러를 다중화하면 Cluster Lock 상태(③)를, 운영 배치라면 exit code 기반 알림(⑥)을 먼저 확인하는 것이 진단의 순서입니다.

Quartz 스케줄러

💡개념

Quartz 구성 요소와 misfire 처리

Quartz는 Java 애플리케이션에서 쓰는 스케줄링 라이브러리입니다. Spring Boot와 결합해 자주 사용합니다. 세 가지 핵심 개념만 이해하면 됩니다.

Quartz Misfire와 Spring Batch 재실행 — Misfire는 예정 시각에서 misfireThreshold(기본 60초) 초과 시 발생(GC 정지·스케줄러 다운). IgnoreMisfires/FireAndProceed 정책으로 처리. Spring Batch는 동일 JobParameters 재실행 시 "already complete" 오류를 run.id·JobIncrementer로 해결, FAILED는 중단 Step부터 재개확대

Quartz 핵심 구성요소:

구성요소역할
Job실제로 실행할 작업 (execute 메서드 구현)
TriggerJob을 언제 실행할지 스케줄 정의 (CronTrigger, SimpleTrigger)
SchedulerJob과 Trigger를 관리하고 실행 조율

Misfire 정책:

Java
// CronTrigger에서 misfire 정책 설정 예시
CronTrigger trigger = TriggerBuilder.newTrigger()
    .withIdentity("dailyJob", "batch")
    .withSchedule(CronScheduleBuilder
        .cronSchedule("0 2 * * * ?")
        // misfire 정책 선택
        .withMisfireHandlingInstructionFireAndProceed()  // 즉시 한 번 실행
        // .withMisfireHandlingInstructionDoNothing()    // 스킵 (다음 스케줄 대기)
        // .withMisfireHandlingInstructionIgnoreMisfires() // 밀린 모든 실행
    )
    .build();
Misfire 정책동작사용 상황
FIRE_AND_PROCEED즉시 한 번 실행 후 정상 스케줄 복귀정산, 집계 배치
DO_NOTHING밀린 실행 스킵주기적 상태 체크
IGNORE_MISFIRES밀린 횟수만큼 연속 실행각 실행이 독립적인 경우만
💡개념

Quartz Cluster 모드와 Lock 충돌

여러 WAS 인스턴스가 같은 Quartz Job을 중복 실행하지 않도록 DB Lock을 사용합니다. 이 Lock이 제대로 해제되지 않으면 장애가 납니다.

YAML
# Spring Boot + Quartz Cluster 설정 예시
spring:
  quartz:
    job-store-type: jdbc
    properties:
      org.quartz.scheduler.instanceId: AUTO
      org.quartz.jobStore.isClustered: true
      org.quartz.jobStore.clusterCheckinInterval: 20000
      org.quartz.jobStore.misfireThreshold: 60000

Quartz DB 테이블에서 Lock 확인:

SQL
-- QRTZ_LOCKS 테이블에서 현재 Lock 상태 확인 (MySQL)
SELECT * FROM QRTZ_LOCKS;

-- QRTZ_SCHEDULER_STATE로 클러스터 노드 상태 확인
SELECT SCHED_NAME, INSTANCE_NAME, LAST_CHECKIN_TIME, CHECKIN_INTERVAL
FROM QRTZ_SCHEDULER_STATE;

-- 오래된 노드 확인 (LAST_CHECKIN_TIME이 오래된 경우 죽은 노드)
SELECT *, FROM_UNIXTIME(LAST_CHECKIN_TIME/1000) AS last_seen
FROM QRTZ_SCHEDULER_STATE
ORDER BY LAST_CHECKIN_TIME ASC;

-- 죽은 노드의 Lock 수동 해제 (신중하게)
DELETE FROM QRTZ_FIRED_TRIGGERS WHERE SCHED_NAME='scheduler' AND INSTANCE_NAME='dead-node-id';
DELETE FROM QRTZ_SCHEDULER_STATE WHERE SCHED_NAME='scheduler' AND INSTANCE_NAME='dead-node-id';

배치 실행 관리

1crontab 설정 및 실행 확인

crontab을 조회하고 실행 이력을 로그에서 확인합니다.

로컬 터미널
# 현재 crontab 확인
crontab -l
# 출력 예시:
# 0 2 * * * /opt/batch/run-daily.sh >> /opt/batch/logs/daily.log 2>&1
# */30 * * * * /opt/batch/sync.sh >> /opt/batch/logs/sync.log 2>&1

# 오늘 실행된 cron 작업 확인
grep "CRON" /var/log/syslog | grep "$(date +%b %e)" | tail -20
# 출력 예시:
# May 30 02:00:01 server CRON[12345]: (appuser) CMD (/opt/batch/run-daily.sh)

# cron이 실행했는지 확인 (스크립트 이름으로 검색)
grep "run-daily" /var/log/syslog | tail -5

# 배치 스크립트 직접 로그 확인
tail -50 /opt/batch/logs/daily.log
crontab -l
🔍실행 후 확인할 것
  • crontab -l로 등록된 표현식을 먼저 확인 — "0 2 * * *"이면 매일 새벽 2시 실행. crontab guru(온라인 도구)로 표현식이 의도한 시각과 일치하는지 검증
  • /var/log/cron에서 오늘 날짜 실행 기록이 없으면 — cron 데몬이 실행 중인지 systemctl status crond 확인. cron이 실행 중인데 기록 없으면 실행 시각이 아직 안 된 것
  • 배치 로그 마지막 줄이 "성공"이 아닌 "오류" 또는 비어있으면 — 스크립트 자체가 exit 1로 종료됐거나 타임아웃. grep -c ERROR /opt/batch/logs/daily.log로 에러 발생 횟수 먼저 파악
2배치 프로세스 상태 확인

실행 중인 배치 프로세스를 확인하고, 실패 로그를 분석합니다.

로컬 터미널
# 실행 중인 배치 프로세스 확인
ps aux | grep batch
# 출력 예시:
# appuser  12345  95.0  2.1 2048000 345678 ?  R  02:00   5:23 java -jar batch.jar

# 프로세스가 없으면 완료됐거나 비정상 종료
# 최근 exit code 확인 (cron 스크립트에서 기록한 경우)
grep -E "exit code|FAILED|ERROR|SUCCESS" /opt/batch/logs/daily.log | tail -10

# Java 배치 실패 로그 확인
grep -E "ERROR|FAILED|Exception|caused by" /opt/batch/logs/batch.log | tail -20

# Spring Batch — 실행 이력 DB 확인
mysql -h db-server -u appuser -p -e "
  SELECT JOB_INSTANCE_ID, JOB_NAME, JOB_KEY, CREATE_TIME
  FROM BATCH_JOB_INSTANCE
  ORDER BY CREATE_TIME DESC LIMIT 10;
"

# 최근 실행 결과 확인
mysql -h db-server -u appuser -p -e "
  SELECT e.JOB_EXECUTION_ID, i.JOB_NAME,
         e.START_TIME, e.END_TIME,
         e.STATUS, e.EXIT_CODE, e.EXIT_MESSAGE
  FROM BATCH_JOB_EXECUTION e
  JOIN BATCH_JOB_INSTANCE i ON e.JOB_INSTANCE_ID = i.JOB_INSTANCE_ID
  ORDER BY e.START_TIME DESC LIMIT 5;
"
ps aux | grep batch
🔍실행 후 확인할 것
  • ps aux | grep batch로 프로세스 존재 여부를 먼저 확인 — 프로세스가 없으면 완료됐거나 시작 자체가 실패한 것. CPU 사용률이 90% 이상이면 현재 실행 중인 정상 상태
  • BATCH_JOB_EXECUTION의 STATUS가 STARTED인 채 오래됐으면 — DURATION을 계산해 예상 완료 시간의 2배 이상이면 hang 가능성. EXIT_CODE와 EXIT_MESSAGE 컬럼에 에러 내용 있으면 실패 확정
  • STATUS=FAILED이고 EXIT_MESSAGE에 예외 클래스명이 있으면 — 에러 유형으로 원인 분류 가능: DataAccessException은 DB 연결, ItemProcessException은 데이터 오류, JobExecutionAlreadyCompleteException은 재실행 파라미터 문제
3Spring Batch 재실행 — run.id 파라미터 추가

COMPLETED된 Spring Batch Job을 재실행할 때 고유한 파라미터를 추가합니다.

로컬 터미널
# 재실행 실패 에러 메시지
# org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException:
#   A job instance already exists and is complete for parameters={date=20260530}.
#   If you want to run this job again, change the parameters.

# 해결: run.id를 고유값으로 추가
java -jar /opt/batch/batch.jar \
  --spring.batch.job.names=dailySettleJob \
  date=20260530 \
  run.id=$(date +%s%N)

# run.id=$(date +%s%N) → 나노초 단위 timestamp (매번 고유)
# 또는
run.id=$(uuidgen)

# 특정 날짜 재처리 (파라미터 추가)
java -jar /opt/batch/batch.jar \
  --spring.batch.job.names=monthlyReportJob \
  targetMonth=202605 \
  run.id=$(date +%s%N)

# 재실행 후 결과 확인
mysql -h db-server -u appuser -p -e "
  SELECT STATUS, EXIT_CODE, EXIT_MESSAGE
  FROM BATCH_JOB_EXECUTION
  ORDER BY START_TIME DESC LIMIT 3;
"
java -jar batch.jar --spring.batch.job.names=dailyJob run.id=$(date +%s%N)
🔍실행 후 확인할 것
  • BATCH_JOB_EXECUTION에서 STATUS=COMPLETED인 최신 행을 먼저 확인 — COMPLETED가 없고 STARTED만 반복되면 이전 실행이 비정상 종료된 것으로 run.id 파라미터 추가 전에 FAILED 상태로 수동 업데이트 필요
  • run.id=$(date +%s%N)으로 재실행 후 새 EXECUTION_ID가 BATCH_JOB_EXECUTION에 추가됐는지 확인 — 추가되지 않으면 JobParametersInvalidException으로 파라미터 타입 불일치 가능성
  • cron 로그에 실행 기록이 있고 BATCH_JOB_EXECUTION에 COMPLETED가 있으면 성공 — 그러나 배치 로그 파일 마지막 줄도 반드시 확인: DB 커밋 전 프로세스가 종료되면 STATUS는 COMPLETED지만 실제 데이터 반영이 부분적일 수 있음

배치 실패 감지와 알림

💡개념

배치 실패 감지 — exit code와 로그 기반 알림

배치가 실패해도 아무도 모르는 구조는 운영 장애입니다. 최소한 exit code 기반 알림 스크립트를 crontab에 함께 등록해야 합니다.

로컬 터미널
#!/bin/bash
# /opt/batch/scripts/run-with-alert.sh
# 배치 실행 + 실패 시 알림 통합 스크립트

BATCH_JAR="/opt/batch/batch.jar"
JOB_NAME="dailySettleJob"
TARGET_DATE=$(date +%Y%m%d)
LOG_FILE="/opt/batch/logs/batch_${TARGET_DATE}.log"
SLACK_WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"

echo "[$(date)] 배치 시작: ${JOB_NAME} / ${TARGET_DATE}" >> "$LOG_FILE"

# 배치 실행
java -jar "$BATCH_JAR" \
  --spring.batch.job.names="$JOB_NAME" \
  targetDate="$TARGET_DATE" \
  run.id="$(date +%s%N)" \
  >> "$LOG_FILE" 2>&1

EXIT_CODE=$?

if [ $EXIT_CODE -eq 0 ]; then
    echo "[$(date)] 배치 성공 (exit code: 0)" >> "$LOG_FILE"
else
    echo "[$(date)] 배치 실패 (exit code: ${EXIT_CODE})" >> "$LOG_FILE"

    # Slack 알림 (curl로 Webhook 호출)
    LAST_ERROR=$(grep -E "ERROR|Exception" "$LOG_FILE" | tail -3 | tr '\n' ' ')
    curl -s -X POST "$SLACK_WEBHOOK" \
      -H "Content-Type: application/json" \
      -d "{
        \"text\": \":rotating_light: 배치 실패 알림\n*Job:* ${JOB_NAME}\n*날짜:* ${TARGET_DATE}\n*Exit Code:* ${EXIT_CODE}\n*마지막 오류:*\n${LAST_ERROR}\"
      }"

    exit 1
fi
로컬 터미널
# crontab 등록 (run-with-alert.sh 사용)
0 2 * * * /opt/batch/scripts/run-with-alert.sh

배치 실행 이력 모니터링 쿼리:

SQL
-- 최근 7일간 배치 실패 목록
SELECT i.JOB_NAME, e.START_TIME, e.END_TIME,
       e.STATUS, e.EXIT_CODE,
       LEFT(e.EXIT_MESSAGE, 200) AS EXIT_MESSAGE
FROM BATCH_JOB_EXECUTION e
JOIN BATCH_JOB_INSTANCE i ON e.JOB_INSTANCE_ID = i.JOB_INSTANCE_ID
WHERE e.STATUS != 'COMPLETED'
  AND e.START_TIME > DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY e.START_TIME DESC;

트러블슈팅

원인: 이전 WAS 인스턴스가 비정상 종료되면서 Quartz DB Lock을 해제하지 못했습니다. 다른 인스턴스가 같은 Job을 실행하려 하지만 Lock이 잠겨 있어 실행하지 못합니다.

로컬 터미널
# 1. 에러 로그 확인
grep -E "Unable to acquire lock|Couldn't rollback" /opt/app/logs/app.log | tail -10

# 2. Quartz DB Lock 테이블 확인
mysql -h db-server -u appuser -p quartz_db << 'SQL'
-- 현재 Lock 상태
SELECT * FROM QRTZ_LOCKS;

-- 죽은 인스턴스 확인 (마지막 체크인 시간이 오래된 경우)
SELECT INSTANCE_NAME,
       FROM_UNIXTIME(LAST_CHECKIN_TIME/1000) AS last_checkin,
       CHECKIN_INTERVAL
FROM QRTZ_SCHEDULER_STATE
ORDER BY LAST_CHECKIN_TIME ASC;
SQL

# 3. 죽은 인스턴스의 잔여 데이터 정리 (신중하게)
# 실행 중인 WAS 인스턴스 ID 먼저 확인 후 제거 대상 결정
mysql -h db-server -u appuser -p quartz_db << 'SQL'
-- 죽은 노드 정보 제거 (INSTANCE_NAME은 실제 죽은 노드 ID로 변경)
DELETE FROM QRTZ_FIRED_TRIGGERS
WHERE SCHED_NAME = 'MyScheduler' AND INSTANCE_NAME = 'dead-node-20260530';

DELETE FROM QRTZ_SCHEDULER_STATE
WHERE SCHED_NAME = 'MyScheduler' AND INSTANCE_NAME = 'dead-node-20260530';
SQL

# 4. Quartz 스케줄러 재시작
sudo systemctl restart app-server

# 5. Lock 재발 방지 — clusterCheckinInterval 확인
grep "clusterCheckinInterval" /opt/app/config/application.yml
# 20000 (20초) 권장 — 너무 길면 죽은 노드 감지가 늦음

원인: 동일한 JobParameters 조합으로 이미 COMPLETED된 JobInstance가 있어 Spring Batch가 중복 실행을 차단하고 있습니다. 재처리가 필요한 경우 파라미터를 다르게 만들어야 합니다.

DB 클라이언트
# 1. 어떤 파라미터로 실행됐는지 DB에서 확인
mysql -h db-server -u appuser -p -e "
  SELECT JIP.JOB_INSTANCE_ID, JI.JOB_NAME,
         JIP.KEY_NAME, JIP.STRING_VAL, JIP.DATE_VAL, JIP.LONG_VAL
  FROM BATCH_JOB_INSTANCE JI
  JOIN BATCH_JOB_EXECUTION_PARAMS JIP
    ON JI.JOB_INSTANCE_ID = (
      SELECT JOB_INSTANCE_ID FROM BATCH_JOB_EXECUTION
      ORDER BY START_TIME DESC LIMIT 1
    )
  WHERE JI.JOB_NAME = 'dailySettleJob';
"

# 2. run.id를 추가해 재실행
java -jar /opt/batch/batch.jar \
  --spring.batch.job.names=dailySettleJob \
  targetDate=20260530 \
  run.id=$(date +%s%N)

# 3. 코드 레벨 해결 — JobLauncher에서 run.id 자동 추가
# Spring Batch XML 설정:
# <bean id="jobLauncher" ...>
#   <property name="jobParametersIncrementer">
#     <bean class="org.springframework.batch.core.launch.support.RunIdIncrementer"/>
#   </property>
# </bean>

# Java Config:
# @Bean
# public Job dailyJob(JobBuilderFactory factory, Step step) {
#     return factory.get("dailySettleJob")
#         .incrementer(new RunIdIncrementer())  // run.id 자동 증가
#         .start(step)
#         .build();
# }

# 4. FAILED 상태의 Job은 같은 파라미터로 재시작 가능
# FAILED → 동일 파라미터로 재실행하면 중단 지점부터 재시작
mysql -h db-server -u appuser -p -e "
  SELECT STATUS, EXIT_CODE FROM BATCH_JOB_EXECUTION
  WHERE JOB_INSTANCE_ID = 12345 ORDER BY START_TIME DESC LIMIT 1;
"
# STATUS=FAILED → 동일 파라미터로 재실행 가능 (자동 재시작)
# STATUS=COMPLETED → run.id 필요

심화 — 시각과 밀린 실행, 스케줄의 두 함정

💡개념

심화: 스케줄은 '시각'이 아니라 '타임존 위의 시각'이다

0 2 * * *이 "새벽 2시"를 뜻한다고 믿지만, 그 2시는 어느 타임존의 2시인지에 달려 있습니다. 서버를 옮기거나 컨테이너로 감싸는 순간 이 전제가 조용히 깨집니다.

  • cron은 시스템(또는 CRON_TZ) 타임존을 따릅니다: cron은 벽시계 시각을 시스템 타임존 기준으로 해석합니다. 서버가 UTC인데 KST(UTC+9)로 생각하면, "새벽 2시" 배치는 실제로 한국 시각 오전 11시에 돕니다. crontab 상단에 CRON_TZ=Asia/Seoul을 두면 그 파일만 다른 타임존으로 해석하게 할 수 있습니다.
  • Quartz는 자기만의 타임존을 가집니다: CronTrigger는 따로 지정하지 않으면 스케줄러 기본(대개 JVM 기본 타임존)을 씁니다. JVM을 -Duser.timezone으로 바꾸거나 컨테이너 기본이 UTC면, 코드의 0 2 * * * ?가 다른 시각에 발화합니다. inTimeZone(...)으로 타임존을 못박아야 안전합니다.
  • DST(일광절약시간)의 함정: DST를 쓰는 지역에서는 시계를 앞당기는 날 존재하지 않는 1시간(예: 2:00~2:59)에 걸린 잡이 아예 안 돌고, 시계를 되돌리는 날에는 그 1시간이 두 번 와 잡이 두 번 돌 수 있습니다. 한국은 현재 DST가 없지만, 글로벌 서비스·해외 리전 서버에서는 반드시 고려해야 합니다.
  • 한계/원칙: 가장 안전한 방법은 인프라 전체를 UTC로 통일하고 표시 계층에서만 로컬 시각으로 변환하는 것입니다. 그럴 수 없으면 cron·Quartz·앱·DB의 타임존을 명시적으로 한 값에 못박습니다. "서버 시계가 알아서 맞겠지"라는 가정이 새벽 배치를 몇 시간씩 어긋나게 합니다.

그래서 스케줄을 옮길 때 첫 확인은 표현식이 아니라 timedatectl — 그 시각이 어느 타임존의 시각인지입니다.

상황: 배포·점검으로 Quartz 스케줄러를 몇 시간 내렸다가 올렸더니, 그 사이 지나간 트리거들이 재기동 순간 한꺼번에 발화했습니다. 매시간 도는 집계 배치가 재기동 직후 대여섯 번 연속 실행돼, 같은 구간을 여러 번 집계했습니다.

원인: 트리거의 misfire 정책이 밀린 실행을 모두 따라잡도록 돼 있었습니다(IGNORE_MISFIRES 성격). misfireThreshold(기본 60초)를 넘겨 실행되지 못한 트리거들이 misfire로 쌓였고, 정책이 "밀린 만큼 다 실행"이라 재기동과 동시에 폭발한 것입니다. 각 실행이 독립적이지 않고 같은 데이터를 만지는 배치라 중복이 났습니다.

진단: 로그에서 재기동 시각 직후 동일 잡이 짧은 간격으로 연속 실행됐는지 확인합니다. Quartz라면 misfire 관련 로그와 트리거의 MISFIRE_INSTR 설정, misfireThreshold 값을 봅니다. 각 실행의 대상 구간(파라미터)이 서로 겹치는지도 대조합니다.

해결: 잡 성격에 맞는 misfire 정책으로 바꿉니다 — 정산·집계처럼 "밀렸어도 한 번만 따라잡으면 되는" 잡은 FIRE_AND_PROCEED(한 번만 실행 후 정상 스케줄 복귀), 주기적 상태 체크처럼 밀린 건 버려도 되는 잡은 DO_NOTHING으로 둡니다. IGNORE_MISFIRES는 각 실행이 완전히 독립적일 때만 씁니다. 근본적으로는 배치를 멱등하게 설계해, 같은 구간이 두 번 돌아도 결과가 같도록 만듭니다.

💼
실무 맥락
현업 패턴

실제 업무에서 이 지식이 쓰이는 상황:

배치 운영은 야간 장애의 주요 원인입니다. 인프라 엔지니어가 반복적으로 만나는 세 가지 상황입니다.

1. "배치가 안 돌았어요" 신고 접수 시:

로컬 터미널
# 5분 안에 원인 파악 루틴
# 1) cron 실행 여부 확인
grep "run-daily.sh" /var/log/syslog | grep "$(date +%b %e)"

# 2) 배치 로그 확인
tail -100 /opt/batch/logs/batch_$(date +%Y%m%d).log

# 3) Spring Batch 실행 이력 DB 확인
# SELECT STATUS, EXIT_CODE FROM BATCH_JOB_EXECUTION ORDER BY START_TIME DESC LIMIT 5;

# 4) Quartz Lock 확인 (Quartz 사용 시)
# SELECT * FROM QRTZ_FIRED_TRIGGERS WHERE FIRED_TIME < (UNIX_TIMESTAMP()-3600)*1000;

2. 운영 배치 스케줄 변경 요청 처리:

로컬 터미널
# 현재 스케줄 확인
crontab -l -u appuser

# 스케줄 변경 (예: 2시 → 1시 30분)
crontab -e -u appuser
# 0 2 → 30 1

# 변경 후 다음 실행 시간 계산 확인
# 온라인 cron 표현식 검증: https://crontab.guru

3. 재처리 요청 처리 체크리스트:

로컬 터미널
# □ 재처리 대상 날짜/범위 확인
# □ DB에서 기존 실행 결과 확인 (COMPLETED vs FAILED)
# □ 재처리 시 기존 데이터 중복 여부 확인 (멱등성)
# □ run.id 파라미터 추가
# □ 재실행 후 BATCH_JOB_EXECUTION STATUS 확인
# □ 처리 결과 데이터 정합성 검증

명령어·단축키 빠른 참조

이 모듈에서 다룬 cron·Quartz·Spring Batch 운영 점검 명령을 실전 옵션과 함께 모았습니다.

명령어/단축키용도자주 쓰는 예
crontab -l등록된 스케줄 확인crontab -l -u appuser (특정 사용자)
crontab -e스케줄 편집0 2 * * * = 매일 새벽 2시
grep CRON /var/log/syslogcron 실행 여부 추적RHEL은 /var/log/cron
journalctl -u cronsystemd cron 실행 로그journalctl -u cron --since today
systemctl status crondcron 데몬 기동 확인기록 없으면 데몬·시각부터 확인
ps aux | grep batch실행 중 배치 프로세스 확인없으면 완료·비정상 종료
timedatectl서버 타임존 확인새 서버 스케줄 어긋남의 첫 점검
java -jar ... run.id=Spring Batch 재실행(고유 파라미터)run.id=$(date +%s%N) 또는 $(uuidgen)
SELECT * FROM QRTZ_LOCKSQuartz Lock 상태 확인QRTZ_SCHEDULER_STATE로 죽은 노드 탐지
DELETE FROM QRTZ_SCHEDULER_STATE죽은 노드 Lock 수동 해제(신중)INSTANCE_NAME='dead-node' 지정
SELECT ... BATCH_JOB_EXECUTION배치 실행 이력·결과 조회STATUS/EXIT_CODE로 성공·실패 판정

관련 모듈로 더 깊이:

다음 모듈에서는 dev/stg/prod 환경변수와 설정 파일 분리 전략 — 환경별 설정 관리 실무를 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

Quartz misfire가 발생하는 조건은?

Q2

cron 표현식 '0 */2 * * *'의 의미는?

Q3

Spring Batch에서 동일한 JobParameters로 재실행이 안 되는 이유는?

Q4

배치 프로세스가 실행 중인지 확인하는 가장 직접적인 방법은?

Q5

스케줄러 서버를 2대로 이중화했더니 같은 배치 잡이 양쪽에서 동시에 두 번 실행돼 데이터가 중복됐다. Quartz Cluster 모드는 이를 어떻게 막나?

Q6

cron에 등록한 배치가 어느 날 조용히 실패했는데 아무도 몰랐다. 실패를 제때 알아채려면?

Q7

[심화] 0 2 * * * 로 등록한 새벽 2시 배치를 새 서버로 옮겼더니 한국 시각 오전 11시에 실행된다. crontab 표현식은 그대로다. 가장 유력한 원인은?

Q8

[심화] 스케줄러를 몇 시간 점검하고 재기동하자, 매시간 도는 집계 배치가 재기동 직후 대여섯 번 연달아 실행돼 같은 구간을 중복 집계했다. 가장 유력한 원인은?

0 / 8 답변

🧪 실습으로 확인하기

배치 스케줄러 장애 — cron이 "등록은 됐는데 안 도는" 이유

중급

cron에 등록된 야간 배치가 실행되지 않았다. cron 미실행의 원인은 거의 정해져 있다: 환경변수/PATH 차이, 로그 미수집으로 실패를 모름, 시간대/시각 오해, 중복·과다 실행. 실행 여부를 로그로 확인하고, 환경 차이를 재현·교정하고, 멱등성·잠금(중복 방지)과 모니터링까지 더해 "조용한 실패"를 없앤다.

40📋 3단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요