AI 에이전트가 작업 도중 재시작될 때: Spring Boot를 이용한 내구성 있는 워크플로우(Durable Workflows) 구축
요약
AI 에이전트의 프로덕션 환경에서 발생하는 상태 유실 및 중복 실행 문제를 해결하기 위해 Spring Boot와 PostgreSQL을 활용한 내구성 있는 워크플로우 구축 방법을 다룹니다. 인메모리 방식의 한계를 지적하며 데이터베이스 영속화와 멱등성 보장의 중요성을 강조합니다.
핵심 포인트
- 인메모리 상태 관리의 위험성: 포드 재시작 시 에이전트의 작업 상태가 소멸됨
- 중복 부작용 방지: 네트워크 오류 시 재시도로 인한 중복 호출을 막기 위한 멱등성 키 필요
- 영속성 있는 워크플로우: 실행 상태를 DB에 저장하여 재시작 후에도 작업 재개 가능
- Human-in-the-loop 구현: 스레드 대기 방식이 아닌 데이터 기반의 승인 프로세스 구축
제가 처음 출시한 에이전트 기능은 데모에서는 아주 훌륭해 보였습니다. LLM이 도구(tool)를 선택하고, 호출하고, 결과를 확인한 뒤, 다음에 무엇을 할지 결정했습니다. 세 번의 도구 호출, 깔끔한 출력, 만족스러운 이해관계자들까지 완벽했습니다.
하지만 이를 실제 사용자 앞에 놓았을 때 상황이 달라졌습니다.
일주일 만에 세 건의 장애가 발생했습니다. 한 사용자가 요청이 느리다고 느껴 재시도했고, 우리는 하나의 티켓 대신 두 개의 지원 티켓을 생성하게 되었습니다. 배포 과정에서 워크플로우(workflow) 도중 포드(pod)가 재시작되었고, 에이전트는 자신이 무엇을 하고 있었는지 단순히 잊어버렸습니다. 사용자는 오류 메시지도 없이 절반만 완성된 응답을 받았습니다. 누군가 에이전트가 권장한 환불을 승인해야 했는데, 우리의 "승인" 방식은 Redis 플래그를 기다리는 Thread.sleep이었고, 잠자고 있던 포드가 재스케줄링되었습니다. 승인 요청은 그대로 사라져 버렸습니다.
돌이켜보면, 이 모든 버그는 동일한 근본 원인에서 비롯되었습니다. 우리는 도구 호출(tool-calling)을 인메모리 상태(in-memory state)를 가진 채팅 완성 루프(chat completion loop)로 취급했습니다. 이는 노트북(notebook) 환경에서는 작동하지만, 프로덕션(production) 환경에서는 작동하지 않습니다.
이 포스트에서는 에이전트 런타임(runtime)을 Spring Boot와 PostgreSQL 기반의 제대로 된 워크플로우 엔진으로 재구축했을 때 무엇이 변했는지 살펴봅니다. 실행(run)은 데이터베이스에 영속화(persisted)되며, 지수 백오프(backoff)를 적용한 재시도, 멱등성 키(idempotency keys), 그리고 실제 대기 상태로서의 Human-in-the-loop 체크포인트가 포함됩니다. 전체 실행 가능한 코드, Docker Compose 설정 및 실행 증거는 exesolution.com에서 확인할 수 있습니다. 이 포스트는 중요한 설계 선택 사항과 이를 로컬에서 실행하는 방법을 다룹니다.
명시할 가치가 있는 세 가지 실패 사례
대부분의 에이전트 데모는 적은 토큰 사용량과 해피 패스(happy paths) 뒤에 세 가지 실제 문제를 숨기고 있습니다.
중복된 부작용(Duplicate side effects). 에이전트가 "create_jira_ticket"을 호출합니다. Jira로의 HTTP 호출은 성공했지만, 응답 패킷이 유실되었습니다. 이때 재시도 로직(retry logic)이 작동합니다. 이제 티켓이 두 개가 생겼습니다. 이것은 이론적인 이야기가 아닙니다. 네트워크가 관여하는 모든 순간, 즉 매 순간 발생하는 일입니다.
재시작 망각 (Restart amnesia). 워크플로우 중간에 포드(pod)가 재스케줄링(rescheduled)됩니다. 만약 에이전트의 상태(state)가 힙(heap) 위의 Java 객체로 존재한다면, 그것은 사라집니다. 결과를 폴링(polling)하는 클라이언트는 영원히 대기 상태에 빠지거나 혼란스러운 에러를 받게 됩니다. 모니터링 시스템은 "워크플로우가 사라짐"을 알림(alert)을 보낼 수 없는 지표(metric)로 인식하게 됩니다.
데이터가 아닌 코드로 관리되는 승인 상태 (Approval state as code, not data). 에이전트가 민감한 작업을 수행하려 할 때, 먼저 사람의 승인을 거치도록 하고 싶을 것입니다. 단순한 구현 방식은 실행을 일시 중지(pause execution)하고 웹훅(webhook)을 기다리는 것입니다. 문제는 "실행 일시 중지"가 메모리 내에서 스레드(thread)가 잠드는 것을 의미하며, 이는 재시작 시 소멸된다는 점입니다. 사람은 더 이상 아무도 기다리지 않는 무언가에 대해 승인을 내리게 됩니다.
이 세 가지 문제는 모두 해결 가능합니다. 하지만 @Retryable을 추가하고 운에 맡기는 방식으로는 해결되지 않습니다.
해결책의 형태
이 문제를 채팅 루프(chat loop)가 아닌 워크플로우(workflow)로 바라보기 시작하면 아키텍처는 명확해집니다.
**실행 (run)**은 PostgreSQL의 한 행(row)입니다. 여기에는 상태(queued, running, waiting_for_approval, completed, failed), 입력값(input), 테넌트 ID(tenant ID), 클라이언트로부터 받은 멱등성 키(idempotency key)가 포함됩니다. 메모리 상의 Java 객체로 존재하지 않습니다.
**단계 (step)**는 작업의 단위입니다. 에이전트의 다음 결정, 도구 호출(tool call), 승인 게이트(approval gate), 최종 응답 등이 이에 해당합니다. 각 단계 또한 자체적인 상태 머신(state machine)을 가진 하나의 행입니다. 재시도(retry)가 필요한 경우, 단계 행에는 attempt, next_attempt_at, last_error가 기록됩니다. 프로세스가 재시작되어도 단계에 대해서는 변하는 것이 없으며, 디스패처(dispatcher)가 다시 가져갈 때까지 그 자리에 그대로 머물러 있습니다.
**디스패처 (dispatcher)**는 Spring의 @Scheduled 틱(tick)에 따라 실행됩니다. 몇 초마다 QUEUED 또는 RETRY_PENDING 상태이면서 next_attempt_at <= now()인 단계들을 찾습니다. 디스패처는 데이터베이스 임대(database leases)를 사용하여 해당 단계들을 점유하고(이를 통해 두 인스턴스가 동일한 단계를 동시에 가져가지 않도록 합니다), 실행한 뒤, 결과를 다시 기록하고 다음 단계로 넘어갑니다.
도구 호출(tool calls)은 tool_execution 테이블에 별도의 행으로 기록되며, 실제 요청(request)과 응답(response)은 JSON 형태로 저장됩니다. 6시간 뒤에 문제가 발생하여 고객이 무슨 일이 있었는지 알고 싶어 할 때, 여러분은 로그를 파싱(parse)하는 대신 이 행들을 읽으면 됩니다.
그것이 전체적인 멘탈 모델(mental model)입니다. 그 외의 모든 것은 구현 세부 사항(implementation detail)일 뿐입니다.
큐(Queue) 대신 PostgreSQL을 사용하는 이유
우리는 Redis Streams와 RabbitMQ를 고려했습니다. 하지만 PostgreSQL을 선택한 이유는 이미 기술 스택에 포함되어 있었고, 단계 상태(step state)를 진행시키는 것과 동일한 트랜잭션 내에서 도구 실행(tool execution)을 기록해야 했기 때문입니다. 별도의 큐와 데이터베이스를 사용하면 "아웃박스 문제(outbox problem)\
이 지점이 대부분의 에이전트 구현이 무너지는 부분입니다. 단순한(naive) 버전은 다음과 같습니다:
if (action.requiresApproval()) {
waitForApproval(action);
executeAction(action);
...
해결책은 승인(approval)을 다른 단계와 마찬가지로 하나의 단계(step)로 모델링하는 것입니다:
Step type=APPROVAL, status=WAITING_FOR_APPROVAL, request_json={action: ...}
디스패처(dispatcher)는 WAITING_FOR_APPROVAL을 확인하고 단순히 건너뜁니다. 할 일이 없기 때문입니다. 승인자가 POST /api/runs/{id}/approve를 호출하면, API 엔드포인트가 해당 단계의 상태를 QUEUED로 업데이트합니다. 다음 디스패처 틱(tick)에서 해당 단계가 선택되어 실행이 계속됩니다.
어떠한 프로세스도 무언가를 기다리며 대기하지 않습니다. 인메모리 상태(in-memory state)도 존재하지 않습니다. 승인 대기 시간 동안 포드(pod)가 50번 재시작되어도 아무런 차이가 없습니다. 사람이 마침내 결정을 내리면, 작업은 정확히 멈췄던 지점부터 다시 시작됩니다.
이것은 대부분의 에이전트 프레임워크가 완전히 놓치고 있는 디자인 패턴입니다.
로컬에서 실행하기
docker compose up -d --build
이 명령은 Spring Boot 서비스, 스키마가 마이그레이션된 PostgreSQL, 그리고 워크플로우 실행을 확인하기 위해 API 키가 필요 없는 작은 모의(mock) LLM 엔드포인트를 시작합니다.
서비스가 정상 작동하는지 확인하세요:
curl -s http://localhost:8080/actuator/health
예상되는 응답은 status: UP입니다.
멱등성 키(idempotency key)를 사용하여 실행(run)을 생성하세요:
curl -s -X POST http://localhost:8080/api/runs \
-H "Authorization: Bearer <TOKEN>" \
-H "Idempotency-Key: run-demo-001" \
...
응답에 runId가 포함됩니다. 이를 폴링(poll)하세요:
curl -s http://localhost:8080/api/runs/<RUN_ID> \
-H "Authorization: Bearer <TOKEN>"
상태가 QUEUED, RUNNING을 거쳐, 에이전트가 인간의 승인이 필요하다고 판단하면 WAITING_FOR_APPROVAL로 진행되는 것을 볼 수 있습니다. 승인하세요:
curl -s -X POST http://localhost:8080/api/runs/<RUN_ID>/approve \
-H "Authorization: Bearer <APPROVER_TOKEN>" \
-H "Content-Type: application/json" \
...
다음 디스패처 틱에서 실행이 계속되어 완료됩니다.
멱등성(Idempotency) 테스트: 동일한 Idempotency-Key를 사용하여 원래의 생성 호출을 반복합니다. 새로운 ID가 아닌 동일한 runId를 반환받게 됩니다. 데이터베이스를 확인해 보세요. 해당 키에 대해 workflow_run 테이블에 정확히 하나의 행만 존재합니다.
그다음, 팀을 가장 놀라게 했던 테스트입니다. 실행 상태가 WAITING_FOR_APPROVAL인 동안 컨테이너를 종료하고 재시작합니다:
docker compose restart app
실행 상태는 PostgreSQL에 저장되어 있습니다. 승인(approval) 호출은 여전히 작동합니다. 실행도 그대로 완료됩니다. 중요한 정보는 메모리에 저장된 적이 없으므로, 메모리 내(in-memory)에서 손실된 데이터는 아무것도 없습니다.
전체 솔루션 구성 요소
exesolution.com에서 검증된 솔루션에는 다음이 포함되어 있습니다:
- 전체 Spring Boot 프로젝트: REST API, 워크플로우 엔진(workflow engine), 디스패처(dispatcher), 도구 레지스트리(tool registry), 플래너 어댑터(planner adapter)
- Flyway 마이그레이션이 포함된 PostgreSQL 스키마 (총 6개 테이블):
workflow_run,workflow_step,tool_execution,idempotency_record,approval_checkpoint,run_event - 향후 여러 인스턴스에서 실행해도 안전한 리스 기반(Lease-based) 디스패처 구현
- API 크레딧을 소모하지 않고 전체 워크플로우를 테스트할 수 있는 모의 LLM (Mock LLM)
- 연동된 OpenTelemetry 트레이싱: HTTP 요청 → 실행(run) → 단계(step) → 도구 호출(tool call)이 단일 트레이스(trace)로 나타남
- 앱, PostgreSQL 및 선택 사항인 OTel 컬렉터(collector)가 포함된 Docker Compose 스택
- 7개의 증거 스크린샷: 코드 구조, 빌드, 상태 확인(health check), 실행 생성, 폴링(polling), 히스토리 엔드포인트, 승인 흐름
전체 솔루션 + 실행 가능한 코드 + 증거 자료 확인하기 (exesolution.com)
코드 번들과 증거 이미지를 이용하려면 무료 등록이 필요합니다.
과거의 나에게 해주고 싶은 말
만약 당신이 LLM(Large Language Model)이 실제 세상에 영향을 미치는 도구들을 호출하는 것—티켓 생성, 이메일 발송, 카드 결제, 재고 업데이트 등—을 수행하는 무언가를 구축하고 있다면, 당신이 인지하든 못하든 당신은 워크플로우 시스템(workflow system)을 구축하고 있는 것입니다. 문제는 당신이 첫날부터 내구성 있는 상태(durable state)와 멱등성(idempotency)을 갖추고 의도적으로 이를 설계하느냐, 아니면 한 번에 하나의 운영 장애(production incident)를 겪으며 그 요구사항들을 뒤늦게 깨닫느냐의 차이입니다.
이 솔루션의 설정은 Java 코드 약 천 줄과 스키마(schema) 정도로 구성됩니다. 그리 많은 양의 코드는 아닙니다. 하지만 에이전트 실행(agent run)이 진행 중인 상태에서 배포(deploy)가 이루어지더라도 당신이 발 뻗고 잘 수 있게 해주는 최소한의 코드입니다.
논의하고 싶은 구체적인 시나리오가 있으신가요? 장기 실행 승인(long-running approvals), 멀티 테넌트 격리(multi-tenant isolation), 디스패처(dispatcher) 확장 등 무엇이든 좋습니다. 댓글을 남겨주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기