이 문서를 통해 아래 질문들에 답할 수 있게 됩니다.
- batch는 왜 같은 작업이 두 번 실행될 수 있다고 가정해야 하는가?
- lock, lease, fencing token, idempotent writer는 각각 어떤 문제를 막는가?
- Java/Spring batch에서 DB 기반 lock을 어떻게 설계할 수 있는가?
- lock이 있어도 writer 멱등성이 필요한 이유는 무엇인가?
개요
batch는 같은 시간에 두 번 실행될 수 있다.
서버가 두 대일 수 있고, 이전 실행이 끝나기 전에 다음 trigger가 올 수 있고, 운영자가 수동 재실행을 누를 수도 있다.
따라서 중복 실행 방지는 선택 기능이 아니다.
lock은 “동시에 실행하지 않기”를 돕고, idempotent writer는 “실행이 겹치거나 재실행되어도 결과가 깨지지 않기”를 돕는다.
둘 중 하나만으로 충분하지 않다.
원리
batch 중복 방지는 세 겹으로 본다.
- 실행 lock: 같은 job과 target range가 동시에 시작되지 않게 한다.
- lease: lock을 잡은 프로세스가 죽어도 영원히 잠기지 않게 한다.
- idempotent writer: 같은 입력을 다시 써도 target 결과가 안전하게 만든다.
lock은 실행 경쟁을 줄인다.
idempotency는 실패와 재실행의 결과를 안전하게 만든다.
Lock Table 예시
CREATE TABLE batch_lock (
lock_name varchar(100) PRIMARY KEY,
owner_id varchar(100) NOT NULL,
fencing_token bigint NOT NULL,
locked_until timestamp NOT NULL,
updated_at timestamp NOT NULL
);locked_until은 lease 만료 시각이다.
fencing_token은 오래된 lock owner가 늦게 write하는 것을 방어할 때 사용한다.
Lock 획득 예시
INSERT INTO batch_lock (lock_name, owner_id, fencing_token, locked_until, updated_at)
VALUES ('settlement:2026-06-30', 'worker-1', 1, now() + interval '10 minutes', now())
ON CONFLICT (lock_name)
DO UPDATE SET
owner_id = EXCLUDED.owner_id,
fencing_token = batch_lock.fencing_token + 1,
locked_until = EXCLUDED.locked_until,
updated_at = now()
WHERE batch_lock.locked_until < now()
RETURNING fencing_token;이 쿼리는 lock이 없거나 lease가 만료된 경우에만 새 owner가 잡는다.
반환된 fencing token을 job run에 저장하면 오래된 worker의 write를 막는 근거가 된다.
Spring 코드 예시
public void runDailySettlement(LocalDate targetDate) {
String lockName = "settlement:" + targetDate;
Optional<Long> token = lockRepository.tryAcquire(lockName, workerId, Duration.ofMinutes(10));
if (token.isEmpty()) {
log.info("skip settlement. lock already held. targetDate={}", targetDate);
return;
}
settlementJob.run(targetDate, token.get());
}이 코드는 중복 실행을 건너뛰지만, writer가 멱등하지 않으면 재실행에는 여전히 취약하다.
Idempotent Writer
INSERT INTO settlement_daily (settlement_date, seller_id, amount, job_run_id)
VALUES (:settlementDate, :sellerId, :amount, :jobRunId)
ON CONFLICT (settlement_date, seller_id)
DO UPDATE SET
amount = EXCLUDED.amount,
job_run_id = EXCLUDED.job_run_id;target key를 기준으로 upsert하면 같은 날짜와 seller의 결과를 덮어쓸 수 있다.
append-only target이라면 event id나 source id로 중복 insert를 막아야 한다.
Lease와 Heartbeat
긴 batch는 lock lease를 갱신해야 한다.
worker가 살아 있으면 heartbeat로 locked_until을 연장한다.
worker가 죽으면 lease가 만료되어 다른 worker가 이어받을 수 있다.
lease 시간이 너무 짧으면 정상 worker가 처리 중인데 lock을 빼앗길 수 있다.
lease 시간이 너무 길면 죽은 worker 때문에 복구가 늦어진다.
판단 축
- lock key는 job name과 target range를 포함한다.
- lease 만료와 heartbeat 정책을 둔다.
- 수동 재실행도 같은 lock 경로를 사용한다.
- writer는 target key 기준으로 멱등하게 만든다.
- lock 획득 실패는 장애인지 정상 skip인지 구분해 기록한다.
실패 모델
- 서버 두 대가 같은 cron을 실행해 같은 정산 row를 두 번 쓴다.
- worker가 죽었는데 lock이 영구적으로 남아 다음 실행이 막힌다.
- lease가 만료된 오래된 worker가 뒤늦게 target을 덮어쓴다.
- lock은 있지만 writer가 append-only라 중복 row가 쌓인다.
- 수동 재실행 API가 lock을 우회한다.
운영 지표
lock_acquire_success_count: lock 획득 성공 수다.lock_acquire_failed_count: 이미 실행 중이어서 건너뛴 수다.lock_lease_expired_count: lease 만료로 회수된 lock 수다.heartbeat_failure_count: lock 연장 실패 수다.duplicate_target_write_count: writer 멱등성 실패 신호다.manual_rerun_lock_bypass_count: lock 우회 위험 신호다.
개인 프로젝트 기준
개인 프로젝트에서는 DB table 기반 lock이면 충분하다.
Redis나 ZooKeeper를 먼저 도입할 필요는 없다.
다만 lock key에 target date를 포함하고, 실패 시 lock을 어떻게 풀지 정해 둔다.
writer는 upsert 또는 처리 이력 table로 중복을 막는다.
기업 운영 기준
기업 운영에서는 lock owner, lease, fencing token, 수동 unlock 절차가 필요하다.
운영자가 lock을 강제로 풀 수 있다면 그 절차와 영향 범위를 runbook에 적는다.
분산 scheduler나 Airflow를 쓰더라도 target writer의 멱등성은 별도로 검토한다.
실전 팁
- lock 이름에는 job과 target range를 넣는다.
- lock 획득 실패를 무조건 error로 알리지 말고 skip인지 장애인지 구분한다.
- 오래 실행되는 job은 heartbeat와 lease 갱신 로그를 남긴다.
- target table에는 unique key를 두어 중복 write를 DB가 막게 한다.
- lock 설계와 재처리 설계를 같은 리뷰에서 본다.
위험 신호!
@Scheduled가 여러 인스턴스에서 동시에 실행될 수 있는데 lock이 없다.- lock table은 있지만 lease가 없어 죽은 worker가 lock을 영원히 잡는다.
- 수동 재실행 endpoint가 lock 없이 바로 job을 실행한다.
- target table에 중복을 막는 unique key가 없다.
- “lock이 있으니까 중복 처리는 필요 없다”고 판단한다.
확인 질문
확인 질문
- lock과 idempotent writer는 각각 무엇을 막는가?
- lock은 동시 실행을 줄이고, idempotent writer는 재실행이나 중복 입력에도 결과가 깨지지 않게 한다.
- lease가 필요한 이유는 무엇인가?
- lock owner가 죽었을 때 lock이 영구적으로 남지 않게 하기 위해서다.
- lock key에는 무엇이 들어가야 하는가?
- job name과 target date 또는 target range처럼 중복 실행을 막아야 할 단위가 들어가야 한다.
참고 문서
- Spring Batch Reference
- PostgreSQL Explicit Locking
- Redis Distributed Locks
- Martin Kleppmann, Designing Data-Intensive Applications