이전 글 에이전트 패턴 설계에서는 Tool Use, Parallel, Planner, HITL, Retry, Reflection 같은 제어 흐름 패턴을 정리했다. 패턴은 “한 run 안에서 무엇을 할까”를 설명한다.
이 글의 목적은 다르다. Production Agent Backend를 어떻게 구현하는가가 아니라, 어떤 경계와 책임으로 나누어 생각해야 하는가를 설명한다.
1. Agent가 길게 실행되기 시작하면 생기는 문제
처음에는 FastAPI handler 안에서 Agent를 직접 실행해도 별 문제가 없다.
하지만 실행 시간이 30초가 아니라 3분이 되기 시작하면 이야기가 달라진다. 사용자가 브라우저를 닫을 수 있고, Pod가 재시작될 수 있으며, 같은 요청이 두 번 들어올 수도 있다. 이 순간부터 Agent backend는 단순한 LLM application이 아니라 작은 distributed system이 된다.
운영 환경에서는 곧바로 이런 질문이 따라온다.
- run을 HTTP handler가 끝까지 붙잡아도 되는가?
- 사용자가 탭을 닫으면 실행도 멈춰야 하는가?
- 진행률과 checkpoint는 어디에 두고, 브라우저에는 무엇을 보여주는가?
- API Pod가 재시작되면 in-flight run은 어떻게 다시 찾는가?
아래 mental model이 이 질문들에 대한 출발점이다.
API = 요청 접수
Queue = 실행 의도 보존
Worker = 실제 실행
PostgreSQL = durable state / source of truth
Redis = runtime state / coordination / short-lived events
SSE = 사용자에게 실행 상태 전달
그리고 본문에서 이어서 답하려는 질문은 다음과 같다.
왜 queue가 필요한가?
왜 duplicate execution이 생기는가?
왜 DB와 queue 사이가 위험한가?
왜 SSE reconnect를 고려해야 하는가?
| 레이어 | 이 글에서 잡는 질문 | 후속 글 |
|---|---|---|
| 동시성 | async/thread/process vs worker 역할 | Async, Thread, Process |
| 접수·실행 | 202, queue, worker 책임 | Worker는 Thread가 아니다 |
| 상태 | PostgreSQL vs Redis | Agent State는 어디에, State는 Event가 아니다 |
| 전달 | SSE와 관측 경로 | Agent SSE streaming |
| 부수 효과 | 중복 실행 대비 | Agent Tool Idempotency |
2. 전체 Architecture Map
실선은 사용자가 실행을 명령하는 경로다. 점선은 브라우저가 진행을 관측하는 경로다.
Browser → API → Queue → Worker
↓
PostgreSQL · Redis → SSE → Browser
- 실선 화살표 — 명령: 사용자 → API → Queue → Worker
- 점선 화살표 — 관측: Worker → 저장소 → SSE → 사용자
- POST 인증·권한·입력 검증, run 생성
- GET PostgreSQL snapshot 조회
- SSE cursor 이후 event 전달
- attempt_id · lease로 이번 실행 시도 소유
- Agent graph · LLM · Tool 실행
- checkpoint·결과 저장 후 ack
연결이 끊겨도 run은 계속되고, 재연결은 snapshot + last event cursor로 복구한다.
명령: Browser ──POST──> API ──publish──> Queue ──claim──> Worker
관측: Browser <──SSE── API <──read──── Redis / PostgreSQL <──write── Worker
Worker는 LLM·도구·checkpoint 로직을 실행한다. API는 run 접수, queue 게시, 상태 조회, SSE 중계에 집중한다. 이 분리 덕분에 API replica 수와 worker replica 수를 서로 다른 자원에 맞게 조정할 수 있다.
명령 경로와 관측 경로
| 경로 | 흐름 | 기억할 점 |
|---|---|---|
| Command | Browser → API → Queue → Worker | HTTP handler는 run 수명을 소유하지 않는다 |
| Observation | Worker → State/Event store → SSE → Browser | 연결이 끊겨도 run은 계속될 수 있다 |
3. API는 실행하지 않고 접수한다
데모에서는 종종 다음 한 줄로 run이 시작된다.
asyncio.create_task(run_agent(request))
return {"status": "started"}
패턴 관점에서는 loop가 돌아간다. Backend 관점에서는 run_id, 실패 후 조회 위치, 배포 중 생존이 비어 있다.
장시간 run을 시작하는 API 계약은 보통 다음과 같다.
- 요청 검증·권한 확인
- PostgreSQL에 run row 생성
- queue에 실행 메시지 게시
202 Accepted와run_id반환
@app.post("/runs", status_code=202)
async def create_run(request: RunRequest, user: User = Depends(current_user)):
authorize(user, request)
run_id = new_run_id()
await run_store.create(run_id=run_id, user_id=user.id, status="submitting", input=request)
await job_queue.publish({"run_id": run_id, "input": request.model_dump()})
await run_store.mark_queued(run_id)
return {"run_id": run_id, "status": "queued"}
이 코드는 책임 분리를 설명하기 위한 최소 예시다. 실제 운영 환경에서는 DB write와 queue publish 사이의 dual-write 문제를 해결해야 한다.
202는 “요청을 접수했고 처리는 비동기로 진행된다”는 HTTP 의미다. 브라우저 연결과 실행 수명은 별개다. 후자는 queue, worker, durable state가 담당한다.
FastAPI Background Tasks는 짧은 후처리에는 유용하지만, Agent run은 대개 queue 쪽에 가깝다.
4. Queue는 실행 lifecycle을 분리한다
Queue의 역할은 작업을 빨리 실행하는 것이 아니라, API request lifecycle과 Agent execution lifecycle을 분리하는 것이다.
- API는 job을 submit한다.
- Worker는 job을 소비한다.
- Worker가 죽으면 job이 재전달될 수 있다.
- 따라서 duplicate execution은 정상적인 failure mode다.
부수 효과가 있는 단계(Tool 호출, 티켓 생성 등)는 “이 메시지를 처음 처리하는가?”를 run row·attempt·idempotency key로 판단해야 한다. Agent Tool Idempotency에서 이어서 다룬다.
Deep dive — Queue가 있으면 duplicate execution이 생길 수 있을까?
일반적인 broker는 at-least-once delivery를 제공한다.
- consumer가 ack 전에 죽으면 메시지가 다시 보인다
- lease·visibility timeout 만료 후 다른 worker가 같은 job을 claim할 수 있다
- 네트워크 재시도로 publish가 두 번 일어날 수 있다
exactly-once illusion을 queue에 기대하지 말고, 멱등한 단계 + 중복 감지로 다룬다. ack timing, idempotency key, lease expiration은 구현 글에서 코드로 연결한다.
5. Worker가 Agent 실행을 소유한다
Worker(역할) 는 queue에서 job을 가져와 현재 attempt의 run 수명 주기를 책임진다. LLM 호출, Tool loop, checkpoint 기록, 최종 status commit이 여기서 일어난다.
Worker가 외부 LLM·업무 DB를 호출하는 선은 내부 state 전이와 다르다. timeout·재시도와 함께 부수 효과 idempotency가 별도 계약이다.
사용자가 탭을 닫거나 SSE가 끊기면 관측 경로만 사라진다. 이미 접수된 run은 계속 진행하는 것이 일반적이다. 실행 취소는 제품 정책이며, 기본 전제는 run lifetime ≠ HTTP connection lifetime이다.
6. State는 어디에 저장할까?
한 저장소에 모든 state를 넣지 않는다.
| Component | Main responsibility |
|---|---|
| PostgreSQL | durable run state, checkpoint, result, audit |
| Redis | runtime state, coordination, short-lived event history |
| Queue | execution intent / delivery |
| Object storage | large outputs and artifacts (필요 시) |
PostgreSQL은 복구 가능한 기준 상태를 저장한다. Redis는 UI progress나 worker coordination처럼 빠르게 변하는 상태를 담당한다.
Retrieval storage(embedding, RAG index)는 별도 concern이며 이 글에서는 execution architecture만 다룬다.
Deep dive — DB에 저장한 뒤 Queue publish가 실패하면 어떻게 될까?
run_store.create와 job_queue.publish를 별도 호출하면 gap이 생긴다.
- DB commit 직후 process가 죽으면 run row만 있고 queue 메시지는 없음
- 반대로 queue에는 publish됐는데 DB transaction이 실패할 수도 있음
즉 DB와 queue를 별도로 쓰는 순간 consistency 문제가 생긴다. Production에서는 transactional outbox 같은 패턴으로 해결할 수 있다.
BEGIN;
INSERT INTO agent_runs (...);
INSERT INTO outbox (topic, payload) VALUES ('run.execute', ...);
COMMIT;
──> publisher worker가 outbox를 읽어 queue에 publish, processed_at 기록202를 반환하기 전에 “나중에 다시 발견 가능한 실행 의도”가 남아야 한다.
Deep dive — Redis에서 progress와 event stream을 어떻게 원자적으로 갱신할까?
본문에서는 Redis를 runtime coordination에 쓴다고만 두고, 구현에서는 Hash(현재 snapshot), Stream(제한된 event 기록), Lua script로 한 번에 갱신하는 패턴이 흔하다. TTL·Stream maxlen·consumer offset은 Agent SSE streaming과 State는 Event가 아니다에서 연결한다.
한 문장 recap: API는 실행을 접수하고, Queue는 이를 Worker에게 전달하며, Worker의 결과는 durable state에 기록되고 SSE는 그 상태를 사용자에게 보여준다.
7. 실행과 관측을 분리한다 — SSE
SSE는 Agent 실행과 독립적이다.
- 브라우저 연결이 끊겨도 Worker 실행은 계속되어야 한다.
- 사용자가 다시 접속하면 현재 snapshot 또는 최근 event를 다시 받아야 한다.
Worker → State / Event Store → SSE endpoint → Browser
관측 경로는 WHATWG SSE 형식으로 event id·type·data를 보낸다. Generator가 disconnect를 감지하면 HTTP chunk 전송만 멈춘다. 스트리밍 연결 실패 ≠ run 롤백이다.
Deep dive — SSE 연결이 끊겼다가 다시 붙으면 event는 어떻게 복구할까?
클라이언트는 Last-Event-ID로 재연결하고, 서버는 event store cursor부터 이어 읽는다. Redis Stream·sequence number·replay 구간 설계, transport vs domain idempotency(같은 event_id를 UI가 두 번 받아도 무해하게)는 Agent SSE streaming에서 다룬다.
8. 같은 run을 두 Worker가 실행하면?
Queue와 lease만으로는 “이전 worker가 정말 죽었다”를 완벽히 증명하기 어렵다. 여러 Worker가 같은 run을 동시에 처리할 가능성에 대비해야 한다.
Deep dive — Worker A가 죽은 줄 알고 Worker B가 이어받았는데, A가 늦게 다시 살아나면?
Worker A → fencing token 41
Worker B → fencing token 42
Storage accepts only token >= current token
A write rejected · B write acceptedFencing token과 monotonic version, queue lease를 함께 쓰면 stale writer의 progress가 최신 state를 덮는 일을 줄일 수 있다. updated_at만으로는 “이 쓰기가 현재 attempt 것인가”를 보장하지 못한다. Agent State는 어디에에서 더 깊게 다룬다.
9. Pod, Process, Worker, Thread
Kubernetes, Python runtime, Agent backend 용어가 한 문장에 섞이면 설계가 흔들린다. Worker와 Pod는 같은 개념이 아니다.
| 용어 | 무엇인가 |
|---|---|
| Pod | Kubernetes scheduling / deployment unit |
| Process | OS execution unit |
| Worker | job execution unit (queue consumer 역할) |
| Thread / async task | process 안의 concurrency primitive |
Pod가 재시작되면 그 안의 process와 asyncio task도 사라진다. Worker 역할은 queue와 PostgreSQL에 남은 실행 의도·run 기록으로 다른 Pod가 이어받게 설계한다. “thread pool worker”는 CPU 병렬용 도구 이름일 뿐, queue consumer Worker와 같지 않다.
10. async, thread, process — Worker 안에서
| 도구 | 언제 쓰는가 |
|---|---|
| async | 많은 I/O wait를 한 process에서 겹칠 때 |
| thread | blocking SDK 호출을 격리할 때 |
| process / separate worker | CPU-heavy 또는 격리된 execution |
Python GIL·executor 구현 세부는 에이전트는 왜 Async가 필요한가와 Async, Thread, Process에서 코드로 이어진다.
11. API와 Worker를 따로 배포해야 하는 이유
로컬에서는 API와 worker가 한 process·한 Pod에 있어도 학습에는 충분하다. 운영에서는 Deployment를 나눈다.
개발
API와 worker를 한 process 또는 한 Pod에서 실행할 수 있음
- 코드 책임은 분리
- 배포·장애 수명은 공유
운영
별도 Deployment · Pod 집합 — 확장·배포·장애 수명이 분리됨
| API | Worker | |
|---|---|---|
| Scale 기준 | HTTP request volume | execution workload, queue depth |
| Pod | agent-api | agent-worker |
| 공유 | Queue · PostgreSQL · Redis |
하나의 Pod에 모두 넣으면 독립 scaling이 어렵다. API replica는 짧은 latency, worker replica는 GPU·메모리·동시 run 수에 맞춘다.
12. Graceful shutdown
Pod termination은 “process kill” 문제가 아니라 in-flight run ownership 문제다.
- 새 job 수신 중단
- 현재 실행 중 job 정리
- checkpoint 저장
- ack / requeue 결정
- 종료
SIGTERM 이후 lease 반납·ack 순서는 Pod Lifecycle과 맞춘다. Worker는 중요한 결과를 PostgreSQL에 commit하기 전에 queue 메시지를 ack하지 않는다.
13. Architecture recap
API → 접수 (202 + run_id)
Queue → 실행 lifecycle 분리, 재전달 가능
Worker → run attempt 소유, Tool loop 실행
PostgreSQL → durable truth
Redis → runtime / coordination / 짧은 관측
SSE → 관측만, 실행 소유 아님
실패도 같은 축으로 본다.
| Failure mode | 기억할 점 |
|---|---|
| Duplicate execution | queue 재전달은 정상; 멱등 설계 필요 |
| Dual write (DB ↔ queue) | 접수 기록과 publish를 한 transaction으로 묶거나 outbox |
| Stale progress | attempt/version/fencing으로 late write 거부 |
| SSE disconnect | run은 계속; reconnect로 snapshot·event 복구 |
패턴 Design에서 정한 Tool loop·HITL·Retry는 Worker process 안에서 돈다. 이 지도는 그 loop가 어떤 인프라 위에서 수명 주기를 갖는지를 고정한다.
- 1Browser → APIPOST /runs인증·권한·입력 검증
- 2API → PostgreSQLsubmittingrun_id와 복구 가능한 실행 의도 기록
- 3API → Queuequeued실행 의도를 API process 밖에 보관
- 4API → Browser202 + run_id완료가 아니라 안전한 접수 결과
- 5Queue → Workerclaim → runningattempt_id·lease로 이번 시도 시작
- 6Worker → Storescheckpoint · progress · eventPostgreSQL은 내구성, Redis는 짧은 관측 데이터
- 7Worker → PostgreSQLcompleted / failed최종 결과 commit 뒤 queue ack
- 8API · SSE → Browsersnapshot + cursor조회·재연결·중복 event 무시
14. 다음 글: 실제 구현
이 글에서는 architecture boundary만 정의했다. 후속 글에서는 다음을 코드와 함께 구현한다.
- FastAPI submit endpoint
- Queue publisher (또는 outbox relay)
- Worker process
- PostgreSQL run state
- Redis progress state
- SSE streaming endpoint
- retry / idempotency
바로 이어서 에이전트는 왜 Async가 필요한가에서 Worker 내부 I/O 대기를 겹치는 execution model부터 본다. 전체 경로는 Agent Engineering 시리즈에서 확인할 수 있다.
Discussion
토론
질문, 정정, 반례, 다른 해석이 있다면 남겨 주세요.