이전 글 에이전트 패턴 설계에서는 도구 호출, 병렬 실행, 계획, 사람의 승인, 재시도, 품질 개선 같은 제어 흐름 패턴을 정리했다. 패턴은 “한 실행 안에서 무엇을 할까”를 설명한다.
이 글의 목적은 다르다. 운영 환경의 에이전트 백엔드를 어떻게 구현하는가보다, 어떤 경계와 책임으로 나누어 생각해야 하는가를 먼저 설명한다.
1. 에이전트가 오래 실행되기 시작하면 생기는 문제
처음에는 FastAPI 핸들러 안에서 에이전트를 직접 실행해도 별 문제가 없다.
하지만 실행 시간이 30초가 아니라 3분이 되기 시작하면 이야기가 달라진다. 사용자가 브라우저를 닫을 수 있고, Pod가 재시작될 수 있으며, 같은 요청이 두 번 들어올 수도 있다. 이 순간부터 에이전트 백엔드는 단순한 LLM 애플리케이션이 아니라 작은 분산 시스템이 된다.
운영 환경에서는 곧바로 이런 질문이 따라온다.
- 실행(run)을 HTTP 핸들러가 끝까지 붙잡아도 되는가?
- 사용자가 탭을 닫으면 실행도 멈춰야 하는가?
- 진행률과 체크포인트는 어디에 두고, 브라우저에는 무엇을 보여주는가?
- API Pod가 재시작되면 진행 중인 실행을 어떻게 다시 찾는가?
아래의 역할 구분이 이 질문들에 대한 출발점이다.
| 구성 요소 | 맡는 역할 |
|---|---|
| API | 요청 접수 |
| queue(Queue) | 실행 의도 보존 |
| Worker(Worker) | 실제 실행 |
| PostgreSQL | 복구 가능한 기준 state |
| Redis | 실행 중 state 조정과 짧은 이벤트 기록 |
| SSE | 사용자에게 진행 state 전달 |
본문에서는 다음 질문을 차례로 답한다.
- 왜 queue가 필요한가?
- 왜 중복 실행이 생기는가?
- 왜 DB와 queue 사이가 위험한가?
- 왜 SSE 재연결을 고려해야 하는가?
| 레이어 | 이 글에서 잡는 질문 | 후속 글 |
|---|---|---|
| 동시성 | async/thread/process vs worker 역할 | Async, Thread, Process |
| 접수·실행 | 202, queue, worker 책임 | Worker는 Thread가 아니다 |
| state | PostgreSQL vs Redis | Agent State는 어디에, State는 Event가 아니다 |
| 전달 | SSE와 관측 경로 | Agent SSE streaming |
| 부수 효과 | 중복 실행 대비 | Agent Tool Idempotency |
2. 전체 구조 지도
실선은 사용자가 실행을 명령하는 경로다. 점선은 브라우저가 진행을 관측하는 경로다.
- 실선 화살표 — 명령: 사용자 → 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로 복구한다.
Worker는 LLM·도구·체크포인트 로직을 실행한다. API는 실행 접수, queue 게시, state 조회, SSE 중계에 집중한다. 이 분리 덕분에 API 복제본 수와 Worker 복제본 수를 서로 다른 자원에 맞게 조정할 수 있다.
명령 경로와 관측 경로
| 경로 | 흐름 | 기억할 점 |
|---|---|---|
| 명령 | 브라우저 → API → queue → Worker | HTTP 핸들러는 실행 수명을 소유하지 않는다 |
| 관측 | Worker → state·이벤트 저장소 → SSE → 브라우저 | 연결이 끊겨도 실행은 계속될 수 있다 |
3. API는 실행하지 않고 접수한다
데모에서는 종종 다음 한 줄로 run이 시작된다.
asyncio.create_task(run_agent(request))
return {"status": "started"}
패턴 관점에서는 루프가 돌아간다. 백엔드 관점에서는 실행 ID, 실패 후 조회 위치, 배포 중 생존이 비어 있다.
장시간 실행을 시작하는 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는 실행 수명 주기를 분리한다
queue의 역할은 작업을 빨리 실행하는 것이 아니라, API 요청 수명 주기와 에이전트 실행 수명 주기를 분리하는 것이다.
- API는 작업을 queue에 넣는다.
- Worker는 작업을 가져와 처리한다.
- Worker가 죽으면 작업이 재전달될 수 있다.
- 따라서 중복 실행은 정상적으로 발생할 수 있는 실패 상황이다.
부수 효과가 있는 단계(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가 에이전트 실행을 소유한다
Worker(역할) 는 queue에서 작업을 가져와 현재 실행 시도(attempt) 의 수명 주기를 책임진다. LLM 호출, 도구 호출 루프, 체크포인트 기록, 최종 state 기록이 여기서 일어난다.
Worker가 외부 LLM·업무 DB를 호출하는 선은 내부 state 전이와 다르다. timeout·재시도와 함께 부수 효과 idempotency가 별도 계약이다.
사용자가 탭을 닫거나 SSE가 끊기면 관측 경로만 사라진다. 이미 접수된 run은 계속 진행하는 것이 일반적이다. 실행 취소는 제품 정책이며, 기본 전제는 run lifetime ≠ HTTP connection lifetime이다.
6. state는 어디에 저장할까?
한 저장소에 모든 state를 넣지 않는다.
| 구성 요소 | 주된 책임 |
|---|---|
| PostgreSQL | 영속 실행 state, 체크포인트, 결과, 감사 기록 |
| Redis | 실행 중 state 조정, 짧은 이벤트 이력 |
| queue(Queue) | 실행 의도와 전달 |
| 객체 저장소 | 큰 출력과 산출물(필요할 때) |
PostgreSQL은 복구 가능한 기준 state를 저장한다. Redis는 UI 진행률이나 Worker 조정처럼 빠르게 변하는 state를 담당한다.
검색용 저장소(임베딩, RAG 색인)는 별도의 관심사이며 이 글에서는 실행 구조만 다룬다.
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는 그 state를 사용자에게 보여준다.
7. 실행과 관측을 분리한다 — SSE
SSE는 에이전트 실행과 독립적이다.
- 브라우저 연결이 끊겨도 Worker 실행은 계속되어야 한다.
- 사용자가 다시 접속하면 현재 snapshot 또는 최근 event를 다시 받아야 한다.
관측 경로는 Worker가 state·이벤트 저장소에 기록한 내용을 SSE 엔드포인트가 브라우저로 전달하는 방식이다.
관측 경로는 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. 같은 실행을 두 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 실행 환경, 에이전트 백엔드 용어가 한 문장에 섞이면 설계가 흔들린다. 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가 한 프로세스·한 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. 구조 다시 보기
| 구성 요소 | 기억할 점 |
|---|---|
| API | 접수(202와 실행 ID 반환) |
| queue | 실행 수명 주기를 분리하며 재전달될 수 있음 |
| Worker | 실행 시도를 소유하고 도구 호출 루프를 수행 |
| PostgreSQL | 복구 가능한 기준 state |
| Redis | 실행 중 조정과 짧은 관측 데이터 |
| SSE | 관측만 담당하며 실행 자체는 소유하지 않음 |
실패도 같은 축으로 본다.
| 실패 상황 | 기억할 점 |
|---|---|
| 중복 실행 | queue 재전달은 정상이며, 멱등 설계가 필요 |
| 이중 기록(DB ↔ queue) | 접수 기록과 게시를 하나의 트랜잭션으로 묶거나 outbox 사용 |
| 오래된 진행 state | attempt·version·fencing으로 늦은 쓰기 거부 |
| SSE 연결 끊김 | 실행은 계속하고, 재연결 시 state와 이벤트 복구 |
패턴 설계에서 정한 도구 호출·사람의 승인·재시도는 Worker 프로세스 안에서 돈다. 이 지도는 그 루프가 어떤 인프라 위에서 수명 주기를 갖는지를 정리한다.
- 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. 다음 글: 실제 구현
이 글에서는 구조의 책임 경계만 정의했다. 후속 글에서는 다음을 코드와 함께 구현한다.
- FastAPI 요청 접수 엔드포인트
- queue 게시기(또는 outbox relay)
- Worker 프로세스
- PostgreSQL 실행 state
- Redis 진행 state
- SSE 스트리밍 엔드포인트
- 재시도와 멱등성
바로 이어서 에이전트는 왜 Async가 필요한가에서 Worker 내부 I/O 대기를 겹치는 execution model부터 본다. 전체 경로는 Agent Engineering 시리즈에서 확인할 수 있다.
Discussion
토론
질문, 정정, 반례, 다른 해석이 있다면 남겨 주세요.