서비스를 이중화(HA)하면 가용성은 올라가지만, 스케줄러가 붙은 배치는 골치가 생긴다.
같은 @Scheduled 잡이 두 인스턴스에서 동시에 깨어나 같은 일을 두 번 해버리는 것이다.
정산이 두 번 돌거나, 같은 푸시가 두 번 나가거나, 집계가 중복으로 쌓인다.
동시성 제어를 위한 DB 락 실전에서 다룬 낙관/비관/Named Lock/Redis 분산 락은 여러 요청이 같은 데이터(행)를 동시에 갱신하는 문제를 풀었다.
배치 중복 실행은 결이 다르다 — 보호 대상이 “행”이 아니라 “잡 실행 그 자체” 이고,
“한 클러스터에서 한 번만 돌게 한다”는 leader election에 가까운 문제다.
그래서 행 락(FOR UPDATE)이 아니라 다른 도구가 필요하다.
1. 무엇이 문제인가
인스턴스 A, B가 똑같은 코드를 들고 떠 있다고 하자.
@Scheduled(cron = "0 0 2 * * *") // 매일 새벽 2시
public void runSettlement() {
// A도 B도 새벽 2시에 동시에 이 코드를 실행한다
settlementService.settleYesterday();
}
@Scheduled는 각 인스턴스의 로컬 스케줄러가 돌리는 것이라, 클러스터를 인식하지 못한다.
A와 B가 같은 시각에 깨어나 둘 다 정산을 돌리면 중복이 난다.
JVM 락(synchronized)으로는 못 막는다 — 서로 다른 프로세스이기 때문이다.
2. ShedLock — 클러스터에서 한 번만 실행
가장 흔한 해법은 ShedLock이다. “이 잡은 클러스터 전체에서 한 번만”을 보장한다. 먼저 락을 잡은 인스턴스만 실행하고, 나머지는 그냥 조용히 스킵한다.
설정
// build.gradle
implementation 'net.javacrumbs.shedlock:shedlock-spring:5.10.0'
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:5.10.0'
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "10m") // 안전장치 기본값
public class SchedulerConfig {
@Bean
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime() // DB 시간 기준(인스턴스 간 시계 오차 회피) — 권장
.build()
);
}
}
락 상태를 저장할 테이블 하나가 필요하다.
CREATE TABLE shedlock (
name VARCHAR(64) NOT NULL,
lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL,
locked_by VARCHAR(255) NOT NULL,
PRIMARY KEY (name)
);
사용
@Scheduled(cron = "0 0 2 * * *")
@SchedulerLock(
name = "settlementBatch", // 락 이름 (= shedlock 테이블의 PK)
lockAtMostFor = "30m", // 최대 점유: 인스턴스가 죽어도 30분 뒤 락 해제
lockAtLeastFor = "1m" // 최소 점유: 너무 빨리 끝나도 1분은 유지
)
public void runSettlement() {
settlementService.settleYesterday();
}
두 옵션이 핵심이다.
lockAtMostFor: 락을 잡은 인스턴스가 다운돼unlock을 못 해도, 이 시간이 지나면 자동으로 풀려 다음 실행이 가능하다. 데드락 방지용 안전장치다. 실제 작업 시간보다 넉넉히 잡아야 한다(작업이 더 길면 락이 먼저 풀려 중복 실행될 수 있다). Redis 분산 락의leaseTime과 같은 개념이다.lockAtLeastFor: 작업이 순식간에 끝나도 최소 이 시간은 락을 유지한다. 인스턴스 간 시계 오차로 거의 동시에 두 번 트리거되는 것을 막는다.
동작 원리 — Named Lock이 아니다
ShedLock을 처음 보면 “MySQL Named Lock(GET_LOCK)이랑 같은 거 아냐?”라는 생각이 든다.
이름(key)에 락을 건다는 발상은 같지만 메커니즘이 다르다.
JDBC 방식 ShedLock은 GET_LOCK을 쓰지 않고, shedlock 테이블의 row 하나로 락을 표현한다.
-- 락 획득 시도: 먼저 INSERT, 이미 있으면 만료된 경우에만 UPDATE
INSERT INTO shedlock(name, lock_until, locked_at, locked_by)
VALUES ('settlementBatch', now() + interval 30 minute, now(), 'instance-A');
-- (row가 이미 있을 때) 만료됐으면 내가 가져온다
UPDATE shedlock
SET lock_until = now() + interval 30 minute, locked_at = now(), locked_by = 'instance-A'
WHERE name = 'settlementBatch' AND lock_until <= now();
MySQL Named Lock(GET_LOCK) |
ShedLock(JDBC) | |
|---|---|---|
| 락 저장 위치 | MySQL 세션 메모리 | DB 테이블 row |
| 해제 시점 | 세션(커넥션) 종료 시 | lock_until 만료 / 작업 완료 |
| TTL(만료) | 없음 | 있음 (lockAtMostFor) |
| 적합 | 짧은 임계구역 | 장시간·HA 배치 |
이 차이가 배치에서 결정적이다. Named Lock은 세션에 묶여 있어 커넥션을 작업 내내 붙들고 있어야 하고, “죽으면 30분 뒤 자동 해제” 같은 TTL을 표현할 수 없다. ShedLock은 row + 만료시간이라 그게 자연스럽다.
ShedLock은 JDBC 외에 Redis, MongoDB, ZooKeeper 등 다른 백엔드 provider도 지원한다. 이미 Redis를 쓰고 있다면
shedlock-provider-redis-spring으로 같은 일을 할 수 있다.
3. Spring Batch를 쓴다면
Spring Batch를 이미 쓰고 있다면 JobRepository가 막아주는 범위부터 정확히 구분해야 한다.
Spring Batch는 JobRepository로 같은 JobInstance(잡 이름 + 잡 파라미터)의 중복 완료 실행을 막아준다.
이미 성공한 파라미터로 다시 실행하면 JobInstanceAlreadyCompleteException이 난다.
JobParameters params = new JobParametersBuilder()
.addLocalDate("targetDate", LocalDate.now().minusDays(1)) // 날짜를 파라미터로
.toJobParameters();
jobLauncher.run(settlementJob, params);
다만 이건 “같은 파라미터의 재실행“을 막는 것이지, 두 인스턴스가 동시에 기동하는 경쟁을 막아주진 않는다. 그래서 Spring Batch를 써도 트리거 단계의 단일 실행은 ShedLock 등으로 따로 보장해야 한다.
4. 반대로, 일부러 나눠 돌리고 싶다면 — SKIP LOCKED
지금까지는 “한 번만 돌게” 막는 이야기였다. 그런데 처리할 물량이 많아 여러 인스턴스가 나눠 돌리길 원하는 경우도 있다. 이때는 중복 실행을 막는 게 아니라, 인스턴스끼리 같은 행을 잡지 않도록 분배하는 게 목표다.
MySQL 8.0+ / PostgreSQL의 SELECT ... FOR UPDATE SKIP LOCKED가 여기에 맞다.
다른 트랜잭션이 이미 잠근 행은 기다리지 않고 건너뛰어 다음 행을 가져온다.
-- 인스턴스 A, B가 동시에 실행해도 서로 다른 행을 가져간다
SELECT * FROM job_queue
WHERE status = 'READY'
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED;
@Transactional
public List<Job> pollBatch() {
List<Job> jobs = jobQueueRepository.findReadyBatchSkipLocked(100); // 위 쿼리
jobs.forEach(j -> j.markProcessing());
return jobs;
}
A가 1~100번을 잡으면 B는 그 행들을 건너뛰고 101~200번을 가져간다. 락 대기 없이 워커들이 큐를 나눠 소비하므로, 작업 큐를 여러 인스턴스로 병렬 처리할 때 유용하다.
단순
FOR UPDATE(SKIP LOCKED 없이)였다면 B는 A가 커밋할 때까지 대기한다. “나눠 처리”가 목적이면SKIP LOCKED, “한 번에 하나씩 처리”가 목적이면 그냥FOR UPDATE다.
5. 락은 보조일 뿐 — 멱등 배치가 본질
가장 중요한 이야기다. 분산 락(ShedLock 포함)도 “정확히 한 번”은 보장하지 못한다.
락이 lockAtMostFor로 만료된 직후, 원래 인스턴스가 GC stop-the-world에서 깨어나 작업을 이어가면
두 인스턴스가 잠깐 겹칠 수 있다. 락은 중복 빈도를 크게 줄여줄 뿐, 0으로 만들지는 못한다.
그래서 배치 작업 자체를 멱등(idempotent)하게 — 두 번 돌아도 결과가 같게 — 설계하는 것이 최후의 안전망이다.
- 처리 단위에 마커를 남긴다 : “이 정산 건 처리 완료” 플래그나 UNIQUE 제약. 두 번째 처리는 흡수된다.
-- 이미 정산된 주문이면 UNIQUE 위반으로 두 번째 INSERT가 실패 → 중복 차단
ALTER TABLE settlement ADD CONSTRAINT uk_order UNIQUE (order_id);
- 조건부 업데이트로 상태를 전이한다 : 이미 처리됐으면 0 rows라 두 번 실행돼도 안전하다.
UPDATE settlement
SET status = 'DONE'
WHERE order_id = ? AND status = 'READY'; -- READY일 때만 한 번 적용
이 멱등 패턴은 DB 락 실전 글의 “중복 결제 막기”에서 쓴 것과 같은 원리다. 락으로 빈도를 줄이고, 멱등성으로 최종 정합성을 보장하는 두 겹 방어가 정석이다.
6. 정리
| 목적 | 방법 |
|---|---|
| 클러스터에서 한 번만 실행 | ShedLock (@SchedulerLock) |
| 같은 파라미터 재실행 방지 | Spring Batch JobRepository |
| 물량을 나눠 병렬 처리 | SELECT ... FOR UPDATE SKIP LOCKED |
| 어떤 경우든 최종 안전망 | 멱등 설계 (UNIQUE 제약 · 조건부 업데이트) |
판단은 단순하다.
- 한 번만 돌면 되는 배치(정산·집계·리포트) → ShedLock으로 단일 실행 보장. 잡 단위 락이다.
- Spring Batch를 쓴다 →
JobRepository는 같은 파라미터의 재실행 방지용이다. 트리거 중복은 ShedLock 등으로 따로 막는다. - 물량이 많아 나눠 돌려야 한다 → 동시 실행을 의도하고
SKIP LOCKED로 분배. - 그리고 무조건 → 작업을 멱등하게 짜둔다.
핵심은 — 이중화 배치엔 락이 맞지만, 행 락이 아니라 잡 단위 락(ShedLock) 이고, 그마저도 멱등 설계의 보조라는 점이다. 공유 데이터 자체의 동시 갱신 제어가 궁금하다면 동시성 제어를 위한 DB 락 실전을 참고하자.
