Redis 기반 비동기 Job의 실행권 선점과 상태 전이 검증
장시간 LLM 작업과 Job 상태 분리#
코멘트 여러 건을 LLM으로 순화하는 작업은 한 번의 HTTP 요청에서 끝내기 어려웠습니다. 시작 API는 jobId를 먼저 반환하고, @Async Worker가 각 코멘트를 처리하는 동안 Redis Hash에 RUNNING, 전체 건수와 처리 건수를 기록하도록 구성했습니다. 결과도 바로 DB에 쓰지 않고 Redis에 보관했다가 Job이 SUCCESS일 때만 별도의 적용 API를 통해 반영했습니다.
작업 시작 → jobId 반환 → @Async 실행
↓
Redis에 상태·진행률·결과 저장
↓
SUCCESS 확인 후 DB 반영LLM 호출 중 하나라도 실패하면 Job 전체를 FAILED로 끝내고, 앞에서 생성한 결과도 적용하지 않았습니다. 일부 코멘트만 바뀐 상태를 사용자에게 보여주기보다 다시 실행할 수 있는 명확한 실패로 남기는 편이 이 기능의 사용 방식에 맞았습니다.
기능 흐름은 단순했지만, 구현을 다시 검토하면서 Redis에 상태를 저장하는 것과 하나의 Worker만 실행권을 얻도록 만드는 것은 별개의 문제라는 점을 확인했습니다.
실행 중 Job 조회의 원자성#
같은 평가에 대한 요청이 다시 들어오면 먼저 실행 중인 jobId를 조회하고, 해당 Job이 RUNNING이면 기존 값을 반환했습니다. 화면에서 버튼을 연속으로 누르거나 요청이 재전송되더라도 같은 Job을 바라보게 하려는 처리였습니다.
String existingJobId = redisStore.findRunningJobId(targetId);
if (existingJobId != null && findStatus(existingJobId).isRunning()) {
return existingJobId;
}
String jobId = redisStore.createJob(total, targetId);
worker.processAsync(jobId, items);
return jobId;문제는 실행 중 Job을 확인하는 부분과 새 Job을 만드는 부분이 나뉘어 있다는 점입니다. 요청 A와 B가 같은 시점에 runningKey를 조회하면 둘 다 빈 값을 받을 수 있고, 이후 각자의 Job Hash를 만든 뒤 같은 runningKey에 서로 다른 jobId를 기록합니다. Redis에는 마지막에 쓴 jobId만 남지만 두 Worker는 이미 실행을 시작한 뒤입니다.
GET과 SET이 각각 원자적이라는 사실만으로는 그 사이의 판단까지 보호되지 않습니다. 이 구조에서 runningKey는 현재 Job을 찾기 위한 포인터였고, 실행권을 하나의 요청에만 부여하는 선점 장치는 아니었습니다.
종단 상태의 불변성#
Job 생성 시각을 기준으로 10분이 지난 RUNNING Job은 상태 조회 과정에서 FAILED로 변경했습니다. 이 값은 처리 건수가 늘어날 때 갱신되지 않으므로 활동이 멈춘 시간을 재는 heartbeat가 아니라, Job 전체에 적용되는 절대 처리 기한입니다.
상태를 FAILED로 바꿔도 이미 실행 중인 Worker는 중단되지 않습니다. Worker가 나중에 모든 작업을 마치면 markSuccess()를 호출하는데, 기존 구현은 현재 상태를 확인하지 않고 SUCCESS를 기록했습니다. 상태 조회가 먼저 타임아웃을 처리하면 다음 전이가 가능했습니다.
RUNNING → FAILED → SUCCESSJob 상태는 RUNNING에서 SUCCESS 또는 FAILED로 한 번만 이동하고, 두 종단 상태에서는 더 이상 바뀌지 않아야 합니다. 실행권 선점과 별개로 상태 변경에도 비교 조건이 필요한 이유입니다.
Lua로 Job 선점과 상태 전이 묶기#
이 경쟁 조건을 제거할 수 있는지 확인하기 위해 재현 코드에서는 실행 중 Job 조회와 생성을 하나의 Lua 스크립트로 묶었습니다. runningKey가 이미 있으면 기존 jobId를 반환하고, 비어 있을 때만 runningKey와 새 Job Hash를 함께 생성합니다. SET NX로 포인터만 선점할 수도 있지만, 이 경우 포인터 생성과 Job Hash 초기화 사이의 실패를 별도로 복구해야 하므로 두 키의 변경을 한 번에 실행하는 쪽을 택했습니다.
상태 전이는 현재 값이 RUNNING인지 먼저 확인하고, SUCCESS로 바꿀 때는 runningKey가 자신의 jobId를 가리키는지도 함께 검사했습니다. 실제 적용 API는 Job의 SUCCESS 여부만 확인하므로, 실행권을 잃은 이전 Job이 뒤늦게 성공 상태가 되지 않도록 전이 단계에서 차단해야 했습니다. FAILED는 실행권을 잃은 Job에도 기록할 수 있지만, 전이가 끝난 뒤 runningKey를 삭제하는 것은 자신의 jobId를 가리킬 때만 허용했습니다.
- GET runningKey → 애플리케이션에서 판단 → HSET jobKey → SET runningKey
+ EVAL acquireJob(runningKey, jobKey)
- HSET jobKey status SUCCESS
+ EVAL transition(jobKey, runningKey, RUNNING, SUCCESS, jobId)고정된 실행 순서로 검증#
async-job-running-race.mjs는 임시 Redis를 실행하고, 두 요청이 모두 조회를 마친 뒤 Job을 생성하도록 순서를 고정합니다. 기존 방식에서는 시나리오마다 두 개의 Job Hash가 만들어졌고, 그중 하나는 runningKey로 찾을 수 없는 상태가 됐습니다.
개선안에서는 같은 요청 두 개 중 하나만 새 Job을 만들고 다른 요청은 기존 jobId를 재사용하는지 확인했습니다. 이어서 FAILED 뒤에 도착한 SUCCESS가 거부되는지, 실행권을 잃은 이전 Job의 SUCCESS가 차단되는지, 해당 Job이 현재 Job의 runningKey를 삭제하지 않는지도 assertion으로 검증했습니다.
100 forced interleaving scenarios
GET -> SET: job hashes=200, unreferenced jobs=100
Lua acquire: acquired=100, reused=100, job hashes=100
Terminal transition: late SUCCESS before=100, after=0
Pointer ownership: late SUCCESS rejected=100, current pointers preserved=100
All invariants passed100개 시나리오는 서로 다른 Redis key에 같은 실행 순서를 반복한 결과입니다. 두 요청이 runningKey를 먼저 조회한 뒤 각각 Job을 만들도록 순서를 고정했기 때문에, 이 결과는 발생 빈도보다 해당 경쟁 상황에서 상태가 어떻게 바뀌는지를 보여줍니다.
Worker 시작 실패와 Job 정리#
runningKey를 기록한 직후 프로세스가 종료되면 @Async Worker가 시작되지 않은 채 Job이 RUNNING으로 남습니다. 이후 상태 조회에서 생성 후 10분이 지난 Job을 FAILED로 변경하고, Redis key는 TTL이 끝나면 제거됩니다. 종료된 프로세스가 맡았던 작업을 다른 Worker가 이어서 실행하지는 않습니다.
이 기능에서는 실패한 작업을 사용자가 다시 요청할 수 있고, SUCCESS가 되기 전에는 생성된 결과를 DB에 반영하지 않습니다. 반면 프로세스가 종료되더라도 모든 Job을 이어서 실행해야 하는 기능이라면 작업을 메모리에만 두는 @Async 대신, 서버 재시작 후에도 작업을 보관하고 다시 꺼낼 수 있는 Queue가 필요합니다.
마무리하며#
Redis에 RUNNING을 기록하는 것만으로는 하나의 요청만 실행권을 얻는 것이 보장되지 않았습니다. 실행 중 Job을 조회한 뒤 새 Job을 만드는 사이에 다른 요청이 들어올 수 있었고, FAILED 이후에도 늦게 끝난 Worker가 SUCCESS를 기록할 수 있었습니다.
재현 코드에서는 실행 중 Job의 조회와 생성을 하나의 Lua 스크립트에서 처리하고, SUCCESS 전이에 현재 상태와 runningKey 소유권 조건을 추가했습니다. 같은 실행 순서를 다시 만들었을 때 중복 선점과 FAILED → SUCCESS, 실행권을 잃은 Job의 늦은 SUCCESS가 모두 차단되는 것을 확인했습니다.