들어가면서
사용자가 PDF를 업로드하면 서버에서는 파일 저장 이후 여러 후처리 작업을 수행해야 했습니다. 썸네일 추출, PDF 텍스트 분석, AI 요약 생성, RAG 검색을 위한 chunking 및 embedding 작업이 대표적이었습니다.
이 작업들은 처리 시간이 길고, 외부 API 호출이 포함될 수 있으며, 중간에 실패할 가능성도 있었습니다. 따라서 단순히 요청 흐름 안에서 순차적으로 처리하기보다는, 작업 상태를 관리하면서 별도의 worker가 비동기적으로 처리하는 구조가 필요했습니다.
이번 글에서는 이 과정에서 고민했던 두 가지 설계 포인트를 정리했습니다.
1. 여러 worker가 같은 job을 중복으로 가져가지 않게 하는 방법
2. Scheduler와 ThreadPool의 역할을 어떻게 나눌 것인지
여러 worker가 같은 Job을 가져가는 문제
worker가 하나라면 PENDING 상태의 job을 조회해서 처리하면 됩니다.
SELECT *
FROM processing_job
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 1;
하지만 worker가 여러 개라면 문제가 생길 수 있습니다. 두 worker가 거의 동시에 같은 PENDING job을 조회하면, 같은 작업을 중복으로 처리할 수 있기 때문입니다.
worker-1: job 1 조회
worker-2: job 1 조회
따라서 job을 가져오는 claimJob() 단계에서 동시성 제어가 필요했습니다.
이를 위해 SELECT FOR UPDATE SKIP LOCKED를 사용했습니다.
SELECT *
FROM processing_job
WHERE status = 'PENDING'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
FOR UPDATE는 조회한 row에 배타적 lock을 잡습니다. 즉, 조회한 job을 곧 수정할 예정이므로 다른 트랜잭션이 동시에 해당 row를 수정하지 못하게 막습니다.
여기에 SKIP LOCKED를 함께 사용하면, 이미 다른 worker가 lock을 잡은 row는 기다리지 않고 건너뜁니다.
job 1: worker-1이 lock 획득
job 2: worker-2가 lock 획득
job 3: worker-3이 lock 획득
이를 통해 여러 worker가 동시에 polling하더라도 서로 다른 job을 가져가도록 만들었습니다.
DB Lock은 짧게 사용했습니다
중요한 점은 DB row lock을 실제 작업이 끝날 때까지 잡고 있지 않았다는 점입니다.
예를 들어 다음과 같은 구조는 피해야 했습니다.
BEGIN
SELECT FOR UPDATE
PDF 분석
LLM 호출
Embedding 생성
UPDATE status = DONE
COMMIT
이렇게 하면 PDF 분석이나 LLM 호출이 끝날 때까지 DB connection과 row lock을 계속 점유하게 됩니다. 작업이 오래 걸릴수록 DB에 부담을 줄 수 있습니다.
그래서 DB lock은 job을 선점하는 짧은 순간에만 사용했습니다.
1. SELECT FOR UPDATE SKIP LOCKED로 처리할 job 조회
2. status를 RUNNING으로 변경
3. COMMIT
4. 실제 PDF 분석 작업은 트랜잭션 밖에서 수행
5. 성공 또는 실패 결과만 다시 DB에 반영
즉, DB row lock은 작업 전체를 보호하기 위한 장치가 아니라, 여러 worker 중 하나가 job을 안전하게 선점하기 위한 장치로 사용했습니다.
작업 처리 중의 소유권은 DB lock이 아니라 status, locked_by, locked_until 같은 컬럼으로 표현했습니다.
status = RUNNING
locked_by = worker-1
locked_until = 현재 시간 + lease 시간
이를 통해 worker가 작업을 처리 중임을 DB에 남기고, worker 장애 시에는 locked_until 만료 여부를 기준으로 다른 worker가 작업을 다시 가져갈 수 있도록 했습니다.
Scheduler와 ThreadPool의 역할 분리
작업 실행 구조는 @Scheduled와 ThreadPool을 분리해서 설계했습니다.
@Scheduled는 주기적으로 DB를 polling하고, 처리 가능한 job을 claim하는 역할만 담당했습니다. 실제 오래 걸리는 PDF 분석, AI 요약, embedding 작업은 ThreadPool에 위임했습니다.
@Scheduled(fixedDelay = 3000)
public void poll() {
List<ProcessingJob> jobs = jobService.claimJobs(10);
for (ProcessingJob job : jobs) {
taskExecutor.submit(() -> worker.process(job));
}
}
이렇게 나눈 이유는 Scheduler가 오래 걸리는 작업을 직접 수행하면 다음 polling이 지연될 수 있기 때문입니다.
Scheduler는 작업을 가져오는 역할에 집중하고, 실제 처리는 worker thread가 담당하도록 분리했습니다.
Scheduler
= 주기적으로 DB를 polling하고 job을 claim
ThreadPool
= claim된 job을 병렬로 처리
Worker Thread
= PDF 분석, LLM 호출, embedding 수행
전체 흐름은 다음과 같습니다.

정리
이번 설계에서 핵심적으로 고민한 부분은 단순히 작업을 비동기로 실행하는 것이 아니었습니다.
여러 worker가 동시에 실행될 때 같은 job을 중복으로 가져가지 않도록 해야 했고, 긴 작업을 처리하는 동안 DB lock과 connection을 오래 점유하지 않도록 해야 했습니다.
이를 위해 job을 가져오는 짧은 구간에서는 SELECT FOR UPDATE SKIP LOCKED를 사용했습니다. 조회 시점부터 row lock을 잡아 중복 claim을 막고, 이미 다른 worker가 가져간 row는 건너뛰어 worker 간 대기 시간을 줄였습니다.
또한 Scheduler와 ThreadPool의 역할을 분리했습니다. Scheduler는 DB polling과 job claim만 담당하고, 실제 오래 걸리는 작업은 ThreadPool의 worker thread에서 처리하도록 했습니다.
결과적으로 다음과 같은 구조가 되었습니다.
@Scheduled
→ SELECT FOR UPDATE SKIP LOCKED
→ status = RUNNING
→ COMMIT
→ ThreadPool에 작업 위임
→ Worker Thread에서 실제 처리
→ DONE 또는 RETRY_WAITING 상태 반영
회고
이번 기회를 통해서 job을 처리하기 위한 여러 아키텍처를 고민해보고, 그 중 rdb의 lock을 기반으로 job의 동시성을 제어하는 방향을 고민해볼 수 있었습니다.
'Project > SW Maestro' 카테고리의 다른 글
| [SW Maestro 3편] AI활용을 잘하는 개발자가 되기 위한 고민 (0) | 2026.06.30 |
|---|---|
| [SW Maestro 1편] 작업 큐 아키텍처에 대한 고민 (0) | 2026.06.19 |