실패, 중복, 장애를 전제로 설계한 JOB 실행 및 운영 플랫폼
실무에서 병렬/비동기 작업 시스템은 다음 문제를 반복적으로 겪음
- 동일 작업이 중복 실행된다
- 실패가 발생하면 작업이 유실되거나 RUNNING 상태로 고착된다
- 워커가 죽으면 누가 어디까지 실행했는지 알 수 없다
- 재시도 기준이 불명확해 운영자가 수동 개입하게 된다
- 상태 변경이 유실되어 현재 상황을 한눈에 파악할 수 없다
- 장애를 재현, 측정, 검증할 방법이 없다
이전 실무에서는 로컬 실행기에서 브라우저/디바이스 기반 작업을 무인으로 실행하고
중앙 서버가 실행 결과와 실패 원인을 수집/저장하는 구조를 운영했다. (중앙 서버와 로컬 실행기(Agent)가 분리된 구조)
이 과정에서 다음과 같은 한계를 반복적으로 경험했다.
- 실패 원인이 네트워크, 세션, 외부 의존성 등으로 매우 다양함
- 실패 처리/재시도/복구 로직이 실행기와 서버에 분산되어 유지보수 비용이 누적됨
- 문제 발생 시 로그를 확인하고 재현을 시도해야만 원인을 추적할 수 있음
- 전체 시스템 상태를 중앙에서 요약해 관측하기 어려움
TaskFlow는 이러한 경험을 바탕으로,
분산 실행 환경에서 발생하는 실패를 안정적으로 관측하고,
실행과 운영 책임을 분리해 유지보수 비용을 줄이는 실행 플랫폼을 목표로 한다.
- 실패를 예외가 아닌 정상 흐름으로 처리
- 동시 요청/병렬 실행 환경에서도 정합성 보장
- 워커/인프라 장애 상황에서도 자동 복구 가능
- 운영자가 시스템 상태를 실시간으로 관측
- 설계를 부하/장애 실험으로 검증 가능
[Client / Internal Service / Admin]
|
| REST API
v
+---------------------+
| API Server |
| (Spring Boot) |
|---------------------|
| - Idempotency |
| - State Machine |
| - Outbox Write |
+----------+----------+
|
v
+---------------------+ +---------------------+
| PostgreSQL | | Redis |
|---------------------| |---------------------|
| jobs | | lock:job:{id} |
| job_attempts | | hb:worker:{id} |
| job_events(outbox) | | |
+----------+----------+ +----------+----------+
| |
| poll | heartbeat
v v
+---------------------+ +---------------------+
| Outbox Publisher | | Worker |
|---------------------| |---------------------|
| - poll outbox | | - pick PENDING |
| - SSE push | | - CAS RUNNING |
| - mark published | | - execute job |
+----------+----------+ | - retry / backoff |
| | - heartbeat 갱신 |
v +----------+----------+
+---------------------+ |
| Admin UI (SSE) | v
+---------------------+ External Targets
JOBS {
UUID id PK
STRING job_key "UNIQUE"
STRING type
JSON payload
STRING status
TIMESTAMP scheduled_at
TIMESTAMP next_run_at
INT attempt_count
INT max_attempts
STRING worker_id
INT version
TIMESTAMP created_at
TIMESTAMP updated_at
}
JOB_ATTEMPTS {
UUID id PK
UUID job_id FK
INT attempt_no
STRING status
STRING worker_id
STRING error_code
STRING error_message
TIMESTAMP started_at
TIMESTAMP ended_at
}
JOB_EVENTS {
UUID id PK
UUID job_id FK
STRING event_type
JSON payload
TIMESTAMP published_at
TIMESTAMP created_at
}
PENDING
|
| (worker DB CAS)
v
RUNNING
|
| success
v
SUCCESS
--------------------------------------
RUNNING
|
| failure (retryable)
v
RETRY_WAIT --(scheduler)--> PENDING
--------------------------------------
RUNNING
|
| failure (non-retryable or max attempts)
v
FAILED
--------------------------------------
PENDING / RUNNING
|
| cancel request
v
CANCELED
- 상태 전이는 서비스 계층 단일 지점에서만 수행
- RUNNING 전환은 DB CAS (UPDATE … WHERE status = PENDING) 로 강제
- 모든 상태 변경은 Outbox 이벤트로 기록
- Job 생성 / 조회 / 취소 API
- job_key 기반 멱등성 보장
- 병렬 Worker 실행
- 재시도 정책 (exponential backoff + maxAttempts)
- heartbeat 기반 stale job 회수
- Outbox + SSE 실시간 상태 스트리밍
- Job 생성 시 job_key UNIQUE 제약으로 중복 생성 방지
- Job 실행 시 DB CAS로 RUNNING 상태 획득
- Redis Lock으로 실행 중복 방지
- Redis 장애 시에도 DB CAS 기반 정합성 유지
- Worker는 주기적으로 heartbeat를 Redis에 갱신
- heartbeat age + RUNNING duration 기준으로 stale job 판단
- stale job을 RETRY_WAIT 또는 FAILED 상태로 회수
- 워커 재기동 시 작업 자동 복구
- 모든 상태 변경을 Outbox 테이블에 이벤트로 저장
- Publisher가 Outbox를 polling하여 SSE로 전송
- published_at 컬럼으로 이벤트 중복 전송 방지
- 운영자는 Admin UI에서 실시간 상태 관측 가능
-
부하 테스트
- k6 기반 동시 Job 생성 시나리오
- 실패 + 재시도 혼합 시나리오
-
장애 실험
- 워커 강제 종료 → stale job 회수
- Redis 일시 중단 → DB CAS 기반 실행 유지
- DB 지연 → 재시도 및 상태 안정성 유지
접기/펼치기
- k6를 이용해 짧은 시간 동안 다수의 Job 생성 요청을 동시에 발생시켰다.
- 동일한
job_key를 포함한 요청을 병렬로 전송하여, 동시 요청 환경에서도 Job이 중복 생성되지 않는지 확인했다. - 테스트 결과,
job_key기반 멱등성 제약으로 Job은 단일로 생성되었으며, 상태는PENDING → RUNNING → 종료 상태로 정상 전이됨을 확인했다.
동시 요청 환경에서도 중복 실행 없이 정합성이 유지됨
- 일부 Job은 의도적으로 실패하도록 설정하여 성공 Job과 실패 Job이 혼합된 상황을 만들었다.
- 실패한 Job 중 retryable error로 분류된 작업은
RETRY_WAIT상태로 전환되고, 설정된 backoff 이후 재시도되는지 확인했다. - 재시도 횟수를 초과한 Job은
FAILED상태로 확정되었고, 성공 Job은 실패 Job의 영향 없이 정상 종료되었다.
실패가 발생하더라도 시스템 전체 흐름이 중단되지 않고, 재시도 정책과 상태 머신이 정상 동작함
- Job이
RUNNING상태인 동안, 해당 작업을 수행 중인 워커 프로세스를 강제로 종료했다. - 워커의 heartbeat 갱신이 중단된 이후, 일정 시간이 지나자 해당 Job이 stale 상태로 판단되었다.
- stale Job은 자동으로 회수되어
RETRY_WAIT또는FAILED상태로 전환됨을 확인했다.
워커 장애 상황에서도 RUNNING 상태 고착 없이 작업이 자동 복구됨
- 실행 중 Redis를 일시적으로 중단시켜, 락 및 heartbeat 기능이 동작하지 않는 상황을 만들었다.
- Redis 중단 상태에서도 DB CAS 기반 상태 전이는 정상적으로 수행되었으며, 중복 실행이나 상태 불일치가 발생하지 않음을 확인했다.
- DB에 인위적인 지연을 발생시켜 일부 Job 실행이 timeout 또는 실패하도록 유도했다.
- 실패한 Job은 error code 기준으로 분류되었고, retryable error의 경우 재시도 흐름으로 정상 진입했다.
- 상태 전이가 꼬이거나 RUNNING 상태로 고착되는 현상은 발생하지 않았다.
- 동시 요청 환경에서도 멱등성과 정합성이 유지됨
- 실패가 발생해도 시스템 흐름이 중단되지 않음
- 워커 및 보조 인프라 장애 상황에서도 자동 복구 가능
- 상태 전이와 재시도 정책이 운영 상황에서도 유효함
- 로컬
docker compose up -d
./gradlew bootRun
- 운영
- local → develop → main PR merge
- GitHub Actions 자동 배포
- Docker 이미지 빌드 → GHCR push → AWS EC2 배포
- /admin/stream 접속 (SSE 실시간 상태 확인)
- 정상 Job 생성 → RUNNING → SUCCESS
- 실패 Job 생성 → RETRY_WAIT → 재시도 → SUCCESS
- 워커 강제 종료 → stale job 회수 확인
- /actuator/prometheus 메트릭 확인