새벽 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을 씁니다. 표현식 읽는 법과 실행 결과를 로그에서 확인하는 방법을 알아야 배치 문제를 진단할 수 있습니다.

새벽 2시에 돌아야 할 정산 배치가 실행되지 않았습니다. 아침에 고객사 민원이 들어오고 나서야 알았습니다. crontab에 등록은 됐다고 하는데, 실행 여부를 확인하는 방법을 모릅니다. 배치 실패가 조용히 일어나고 아무도 모르는 상황이 반복되면 서비스 신뢰도에 직격탄이 됩니다. 배치 운영의 첫걸음은 "언제 실행됐는지, 실행됐는지 안 됐는지"를 로그에서 확인하는 능력입니다. crontab 표현식을 읽고 실행 이력을 추적하는 방법을 익히면 이런 장애를 빠르게 진단할 수 있습니다.
crontab 표현식 구조:
┌──── 분 (0-59)
│ ┌─── 시 (0-23)
│ │ ┌── 일 (1-31)
│ │ │ ┌─ 월 (1-12)
│ │ │ │ ┌ 요일 (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
Quartz 스케줄러
Quartz 구성 요소와 misfire 처리
Quartz는 Java 애플리케이션에서 쓰는 스케줄링 라이브러리입니다. Spring Boot와 결합해 자주 사용합니다. 세 가지 핵심 개념만 이해하면 됩니다.

Quartz 핵심 구성요소:
| 구성요소 | 역할 |
|---|---|
| Job | 실제로 실행할 작업 (execute 메서드 구현) |
| Trigger | Job을 언제 실행할지 스케줄 정의 (CronTrigger, SimpleTrigger) |
| Scheduler | Job과 Trigger를 관리하고 실행 조율 |
Misfire 정책:
// 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이 제대로 해제되지 않으면 장애가 납니다.
# 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 확인:
-- 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';
배치 실행 관리
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 -lcrontab -l출력에 등록된 스케줄 표현식이 의도한 실행 시각과 일치하는지 확인/var/log/syslog또는/var/log/cron에서 오늘 날짜로 해당 스크립트 실행 기록이 있는지 확인- 배치 로그 파일 마지막 줄에 성공 또는 실패 메시지가 기록됐는지 확인
실행 중인 배치 프로세스를 확인하고, 실패 로그를 분석합니다.
# 실행 중인 배치 프로세스 확인
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 batchps aux | grep batch결과에 실행 중인 배치 프로세스가 표시되는지 또는 이미 종료됐는지 확인BATCH_JOB_EXECUTION테이블에서 최근 실행의STATUS컬럼이COMPLETED또는FAILED인지 확인EXIT_CODE와EXIT_MESSAGE컬럼에서 오류 내용이 있는지 확인
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)- crontab -l에 스케줄이 올바르게 등록됐는가
- cron 로그에 오늘 날짜로 실행 기록이 있는가
- BATCH_JOB_EXECUTION 테이블에서 최근 실행의 STATUS가 COMPLETED인가
- run.id 파라미터 추가 후 재실행 시 이미 존재 예외가 해소됐는가
- 배치 로그 파일에 오류 없이 정상 종료 메시지가 있는가
배치 실패 감지와 알림
배치 실패 감지 — 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
배치 실행 이력 모니터링 쿼리:
-- 최근 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가 중복 실행을 차단하고 있습니다. 재처리가 필요한 경우 파라미터를 다르게 만들어야 합니다.
# 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 필요
실제 업무에서 이 지식이 쓰이는 상황:
배치 운영은 야간 장애의 주요 원인입니다. 인프라 엔지니어가 반복적으로 만나는 세 가지 상황입니다.
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 확인
# □ 처리 결과 데이터 정합성 검증
다음 모듈에서는 dev/stg/prod 환경변수와 설정 파일 분리 전략 — 환경별 설정 관리 실무를 다룹니다.