
AI 에이전트 개발과 간과되기 쉬운 리소스
요약
AI 에이전트를 웹 애플리케이션에 통합할 때, 많은 팀이 LLM 토큰 비용에만 집중하지만 실제로는 에이전트의 긴 처리 시간으로 인해 발생하는 외부 리소스 문제가 더 치명적일 수 있습니다. 에이전트의 장기 실행 특성이 기존 웹 스택의 '짧고 예측 가능한 요청'이라는 전제를 깨뜨리면서 DB 커넥션 고갈, API 레이트 리미트, 메모리 부족 등의 문제를 야기합니다.
핵심 포인트
- LLM 토큰 비용 외에도 에이전트의 실행 시간으로 인한 시스템 리소스 문제를 반드시 고려해야 함
- 에이전트 세션이 DB 커넥션을 장시간 점유하는 것은 커넥션 풀 고갈을 일으키는 대표적인 안티 패턴임
- HTTP 클라이언트, Redis, gRPC, WebSocket 등 모든 커넥션 레이어에서 유사한 문제가 발생할 수 있음
- 커넥션 관리는 '사용 직전에 빌리고 사용 직후 즉시 반환'하는 원칙을 준수해야 함
소프트웨어나 Web 애플리케이션에 AI 에이전트를 통합하는 것이 확산되고 있다고 생각합니다. 여러분의 회사에서도 애플리케이션에 AI 에이전트를 추가하여 편의성이나 경험을 개선하려는 노력을 하고 계시지 않나요?
AI 에이전트의 침투에 따라, 각 시스템에 LLM이나 AI 에이전트의 Observability(관측 가능성)를 통합하는 모니터링 설계도 늘어나고 있을 것입니다. 그 메트릭(Metric)으로서 많은 팀이 가장 먼저 의식하는 것은 'LLM API의 토큰 과금'이라고 생각합니다. LangFuse나 LangSmith와 같은 LLM용 모니터링 도구를 설치하고, 대시보드를 주시하며, 프롬프트를 다듬고, 모델을 구분하여 사용하고, 캐시(Cache)를 검토하는 것——이것은 나름대로 올바른 노력입니다.
하지만 실제 운용에 올려보면, 다른 곳에서 문제가 분출되는 경우가 매우 많습니다. 예를 들어 다음과 같은 케이스는 종종 발견되며, 저 또한 경험한 바 있습니다.
- 병렬로 동작시키면 외부 API에서 레이트 리미트(Rate Limit)로 거부당한다.
- 타임아웃(Timeout)이 작동하지 않아 프로세스가 행(Hang) 상태가 된다.
- RDB 커넥션(Connection)이 고갈된다.
- 메모리가 팽창하여 OOM(Out of Memory)으로 종료된다.
이 모두 LLM의 외측에서 발생하는, 간과되기 쉬운 리소스 문제입니다.
많은 경우, 이러한 근본 원인은 공통적입니다. 기존의 Web 앱은 전제로 '요청이 짧은 시간 동안 지속되며, 스테이트리스(Stateless)하고, 예측 가능하다'는 성질을 가집니다. Web 스택의 각 레이어(Layer)는 그 전제 위에서 수십 년 동안 튜닝되어 왔습니다. 하지만 AI 에이전트를 통합한 소프트웨어에서는 이 전제가 적용된다고 단정할 수 없습니다. 전제가 무너지면 당연히 어딘가에서 왜곡이 발생합니다.
이 기사에서는 Web 애플리케이션에 AI 에이전트를 도입했을 때 간과되기 쉬운 리소스 문제를 레이어별로 정리합니다. 마지막으로, 이들에 관통하는 안티 패턴(Anti-pattern)과 처방전을 정리하겠습니다.
전형적이고 현상화되기 쉬운 것이 커넥션 문제입니다.
기존의 Web 요청은 수백 밀리초(ms) 내에 완료된다는 전제로 커넥션 풀(Connection Pool)의 크기가 산정되어 있습니다. 그런데 AI 에이전트 처리는 수 초에서 수십 분이 걸릴 가능성이 있습니다. 나이브(Naive)하게 구현하면, 그동안 커넥션을 계속 붙잡고 있게 될 것입니다.
# 안티 패턴: 에이전트 세션이 DB 커넥션을 계속 보유함
class AgentSession:
def __init__(self, user_id: str):
...
이 코드는 에이전트 세션의 전체 수명 동안 커넥션을 점유합니다. 동시에 실행되는 에이전트가 10개라면, 그것만으로 풀에서 10개의 커넥션이 소비됩니다. LLM 응답을 기다리는 동안 다른 Web 요청은 커넥션 대기로 인해 막히게 될 것입니다.
문제는 RDB뿐만이 아닙니다. 동일한 구조의 문제가 Web 앱의 모든 커넥션 레이어에서 동시에 발생할 것입니다.
- HTTP 클라이언트의 커넥션: 외부 API·LLM 프로바이더로의 keep-alive가 병렬 도구 호출 증가와 함께 증대
- Redis / Memcached의 접속 수: 워커(Worker) 수 × 에이전트 병렬도로 선형 증가
- gRPC / 메시지 브로커의 채널: 장수명(Long-lived) 에이전트가 단절을 감지하지 못해 반개방(Half-open) 상태로 체류
- WebSocket / SSE: 클라이언트에 대한 스트리밍 배포용 소켓이 에이전트 처리 시간만큼 점유
- 파일 디스크립터(File Descriptor) 상한: 위의 모든 것이 합산되어 OS의 ulimit에 도달
커넥션은 "사용 직전에 빌리고, 사용이 끝나면 즉시 반환"할 것을 권장합니다. 애초에 라이프사이클(Lifecycle)이 다른 에이전트 세션과 RDB 커넥션의 수명을 분리하는 것이 중요합니다. 트랜잭션(Transaction)도 마찬가지로, LLM 호출이나 외부 API를 가로지르는 것은 피해야 합니다.
# 권장: 커넥션은 개별 DB 작업 단위로 빌리고 반환함
async def run_agent(user_id: str, query: str):
# 커넥션을 빌리지 않음
...
풀 사이즈(Pool size)도 '에이전트 동시 실행 수'뿐만 아니라 '동시에 액티브한 DB 작업 수'로 추정하는 것이 좋습니다. 에이전트 병렬도가 100이라도, 실제로 DB를 치고 있는 순간이 상시 10 정도라면, 풀은 10 + α 정도면 충분합니다.
이어서 메모리에 대해 생각해보겠습니다. 여기서 말하는 메모리는 소위 에이전트 메모리가 아니라, 컴퓨터 기반의 메모리입니다.
에이전트는 「대화 이력 (Conversation History)」, 「중간 상태 (Intermediate State)」, 「도구 출력 (Tool Output)」과 같은 컨텍스트 (Context)를 프로세스 내에 유지하려는 경향이 있으며, 나이브 (Naive)한 구현에서는 컨텍스트가 무제한으로 증대됩니다.
# 안티 패턴: 대화 이력과 도구 결과를 전부 메모리에 축적
class Agent:
def __init__(self):
...
이 구현은 스텝을 거듭할수록 messages와 tool_results의 분량이 단조 증가합니다. 도구의 결과가 크다면 1스텝 만에 수십 MB도 불어날 수 있으며, 결과적으로 LLM으로의 입력 토큰 (Input Token) 수도 $O(n^2)$로 늘어납니다. Python의 경우, asyncio 태스크 (Task)가 서로 참조를 가지게 되면 GC (Garbage Collection)로 회수되지 않아 메모리 누수 (Memory Leak) 조사가 어려워집니다.
대화 이력의 누적: 메시지 배열을 들고 다니는 동안 거대해지며 GC 압박 유발 -
도구 결과의 생데이터 (Raw Data) 유지: DB 쿼리 결과나 스크레이핑 (Scraping) 결과를 요약하지 않고 통째로 메모리에 전개 -
첨부 파일·이미지의 디코딩된 버퍼: 멀티모달 (Multimodal) 입력이 해제되지 않고 체류 -
벡터 임베딩 (Vector Embedding)의 온메모리 (On-memory) 캐시: RAG에서 전 건을 프로세스 메모리에 올리는 단순한 설계 -
스트리밍 응답 (Streaming Response)의 버퍼링: 순차적으로 반환하지 않고 전체 문장을 축적한 뒤 반환하는 구현
특히 까다로운 것은 " 중간 결과물을 그대로 다음 서브 태스크 (Sub-task)로 넘겨버리는 " 패턴입니다. 예를 들어 「데이터베이스에서 1만 행 취득 → LLM에게 요약시키기 → 결과를 사용자에게 반환」이라는 플로우 (Flow)를 생각해 봅시다. 취득한 1만 행의 객체를 그대로 LLM에 보내려다가 컨텍스트 제한 (Context Limit)에 걸린 후에야 비로소 문제를 깨닫는 상황이 발생할 수 있습니다.
중간 데이터는 조기에 요약하거나 참조화 (Referencing)할 것을 권장합니다.
# 안티 패턴
rows = await db.fetch_all("SELECT * FROM orders WHERE ...") # 1만 행
summary = await llm.complete(f"다음 내용을 요약: {rows}") # X 컨텍스트 증대
...
요약할지, 아니면 참조화할지는 AI 에이전트의 로직이나 유스케이스 (Use Case)에 따라 검토합니다. 대략적인 선택 방법을 나누자면, 항상 정보가 필요하다면 요약하고, 필요할 때만 떠올리면 되는 경우에는 참조화합니다.
에이전트의 상태는 **프로세스 메모리가 아니라 외부 스토어 (Storage 등)**에 두는 것도 중요합니다. 이는 후술할 라이프사이클 (Lifecycle) 관리나 스케일링 (Scaling) 논의와도 직결됩니다.
에이전트가 「여러 도구를 병렬 실행」할 수 있다는 것은 강력한 기능이지만, 제어 없이 병렬화하면 후속 처리에서 장애가 될 가능성이 있습니다.
# 안티 패턴: 병렬도 상한 없음
async def search_all_sources(query: str):
sources = await db.fetch_all("SELECT * FROM sources") # 수백 건
...
이는 내부 API든 외부 API든 관계없이, 상대 측에서 쉽게 레이트 리밋 (Rate Limit)이나 서킷 브레이커 (Circuit Breaker)에 도달하게 만듭니다. 설상가상으로 에이전트가 「실패하면 재시도 (Retry)」하도록 설정되어 있다면, 다시 전 건을 병렬로 재시도하는 이른바 **리트라이 스톰 (Retry Storm)**이 발생하여 자기 자신의 서비스를 DoS 공격하게 됩니다.
그 외에도 다음과 같은 문제들이 있습니다.
백프레셔 (Backpressure)의 결여: 에이전트가 처리할 수 없는 속도로 이벤트를 생성 -
워커 풀 (Worker Pool)의 잘못된 사이징: I/O 대기 중심의 에이전트에 CPU 코어 수 기준으로 워커 수를 할당하여 실질적으로 직렬 실행됨 -
스레드 / 파이버 누수 (Thread / Fiber Leak): 중단된 에이전트의 자식 태스크가 잔류 -
데드락 (Deadlock): 여러 에이전트가 동일한 행 잠금 (Row Lock)이나 분산 잠금 (Distributed Lock)을 두고 다툼
원칙적으로 병렬도에는 명시적인 상한을 두는 것이 좋습니다. 세마포어 (Semaphore), 큐 (Queue), 전용 레이트 리미터 (Rate Limiter) 등 적절한 수단을 선정하여, "작성하지 않으면 무제한이 될 수 있다"는 점에 대비하는 것이 중요합니다.
# 권장: 병렬도에 상한 설정
sem = asyncio.Semaphore(10)
async def fetch_with_limit(source, query):
...
재시도 역시 마찬가지로, 지수 백오프 (Exponential Backoff) + 지터 (Jitter) + 최대 시도 횟수를 반드시 세트로 설계해야 합니다. 에이전트가 「똑똑하게」 재시도를 판단하기 전에, 기계적으로 멈추는 장치를 두는 것이 철칙입니다.
에이전트 처리는 기존 소프트웨어에 비해 비결정적(Non-deterministic)이며 재시도(Retry)가 빈번하게 발생합니다. 그럼에도 불구하고 캐시(Cache)나 멱등성(Idempotency)이 고려되지 않은 구현을 자주 목격하게 됩니다.
# 안티 패턴: 멱등성 없는 재시도로 인해 이중 실행이 발생하는 경우
async def send_invoice_email(user_id: str, amount: int):
for attempt in range(3):
...
특히 위의 이메일 전송은, 에이전트가 "도구 실행이 실패한 것처럼 보이니 재실행하자"라고 판단하는 것만으로도 사용자에게 동일한 청구 메일이 2통 전달되는 사고로 이어집니다. 에이전트의 비결정성과 재시도의 조합은 부작용(Side effect)이 있는 도구에게 매우 위험합니다.
동일 프롬프트의 재실행: 결정적인 쿼리임에도 매번 LLM 호출 발생 -
프롬프트 캐시 미활용: 프로바이더(Provider) 측의 프롬프트 캐시 기능을 사용하지 않고, 긴 시스템 프롬프트(System prompt)를 매번 전송 -
멱등성 키(Idempotency key) 없는 재시도: 네트워크 에러로 재시도 → 동일한 쓰기 작업이 이중 적용됨 -
RAG 검색 결과 캐시 없음: 동일한 질문에 대해 매번 벡터 검색 수행
특히 심각한 것은 **이중 쓰기(Double write)**일 것입니다. 예를 들어, 에이전트가 "메일 전송 도구"를 호출했으나 네트워크 타임아웃으로 실패를 감지하여 재시도했는데, 사실 첫 번째 호출은 성공하여 메일이 2통 발송되는 경우입니다. 돈이 움직이는 처리라면 대형 사고입니다.
에이전트가 호출하는 외부 부작용이 있는 도구에는 반드시 멱등성 키를 설계하는 것이 좋습니다.
# 권장: 멱등성 키로 이중 실행을 방지함
async def send_email(to: str, body: str, idempotency_key: str):
existing = await db.fetch_one(
...
캐시 또한 결정적으로 동일한 결과가 나오는 처리에는 적극적으로 적용하는 것이 좋습니다. 동일한 텍스트의 임베딩 벡터(Embedding vector), 동일한 쿼리의 RAG 결과, 안정적인 시스템 프롬프트의 접두사(Prefix) 등은 캐시 후보라고 할 수 있습니다.
마지막으로, 눈에 띄지는 않지만 운영 환경의 장애와 직결되기 쉬운 것이 타임아웃(Timeout)과 라이프사이클(Lifecycle) 관리입니다.
# 안티 패턴: HTTP 핸들러 내에서 동기적으로 에이전트를 실행
@app.post("/agent/run")
async def run_agent_endpoint(request: Request):
...
이 엔드포인트의 경우, 사용자가 30초 만에 포기하고 브라우저를 닫더라도 서버 측의 에이전트는 멈추지 않습니다. LLM에 토큰을 계속 보내고, DB를 계속 쓰며, 외부 API를 계속 호출합니다. 즉, 아무도 보고 있지 않은 곳에서 비용만 계속 발생하는 것입니다. 게다가 각 계층의 타임아웃이 일치하지 않으면, HTTP 연결은 끊겼지만 LLM 호출은 계속되고 있는 뒤틀린 상태도 발생합니다.
타임아웃 미설정: 각 계층(HTTP·LLM·도구·DB)의 타임아웃이 제각각이거나 설정되지 않아 행(Hang) 상태가 누적됨 -
취소 전파(Cancellation propagation) 결여: 사용자가 화면을 닫아도 백엔드의 에이전트가 계속 실행됨 -
좀비 세션(Zombie session): 크래시된 에이전트의 중간 락(Lock)이나 세마포어(Semaphore)가 잔류함 -
리소스 정리 누락: 임시 파일, 임시 테이블, 샌드박스(Sandbox) 환경이 남음 -
로드 밸런서의 유휴 타임아웃(Idle timeout): ALB/NLB의 상한보다 에이전트 처리가 길어져 임의로 연결이 끊김
특히 까다로운 것은 "취소된 에이전트"의 체류입니다. 사용자는 수십 초를 기다리다 포기하고 브라우저를 닫지만, 백그라운드에서는 에이전트가 처리를 계속하며 LLM에 토큰을 보내고, DB를 쓰고, 외부 API를 호출합니다. 사용자가 없는 곳에서 비용만 계속 발생하는 셈입니다.
타임아웃과 취소는 설계 단계에서 가장 먼저 결정할 것을 권장합니다.
- 각 계층의 타임아웃은 "안쪽 < 바깥쪽" 순서로 짧게 설정 (DB < 도구 < LLM < 에이전트 전체 < HTTP)
- 비동기 작업(Asynchronous job)으로 실행하고, 취소 API를 마련할 것
- 상태는 외부 저장소에 잘게 나누어 저장하여, 중간에 실패하거나 정지하더라도 중간부터 재개할 수 있도록 할 것
- 임시 리소스는 **명시적인 TTL(Time To Live)**을 부여하여, 잊히더라도 자동으로 삭제되는 설계로 만들 것
에이전트를 "언제든 안전하게 취소할 수 있는" 상태로 유지하는 것이 운영상의 유연성과 안전성의 전제 조건이 됩니다.
위의 내용에 더해, 다음 사항들도 짧게 언급해 두겠습니다. 실제 프로젝트에서는 이 문제들도 빈번하게 발생할 것입니다.
CPU: 토크나이저 (Tokenizer)의 반복 실행, JSON Schema의 재컴파일, 임베딩 (Embedding) 재계산 등 LLM 외에서 의외로 많이 소비됨
네트워크 대역폭 (Network Bandwidth): 대화 이력 전체를 매 턴마다 보내는 중복성, 압축되지 않은 툴 (Tool) 결과 처리, TLS 핸드셰이크 (Handshake)의 반복
스토리지 (Storage): 모든 사고 과정의 생 로그 (Raw Log) 저장, 대화 이력의 $O(n^2)$ 축적, 벡터 DB (Vector DB)의 고아 엔트리 (Orphan Entry), 무기한 캐시 (Cache)
옵저버빌리티 (Observability) 자체의 비용: 모든 LLM 입출력 텍스트의 동기식 로깅 (≒대용량 로그), Span 생성 과다, 메트릭 (Metric)의 카디널리티 (Cardinality) 폭발 - 옵저버빌리티는 중요하지만, 관측을 너무 많이 넣어서 성능을 떨어뜨리는 것은 전형적인 실패 사례입니다
보안 기인 비용: 툴 권한 체크를 위한 인가 서버 (Authorization Server) 왕복, 코드 실행 샌드박스 (Sandbox)의 콜드 스타트 (Cold Start), 감사 로그 (Audit Log)의 동기식 I/O
인프라 계층: CPU 기준의 오토스케일링 (Auto-scaling)이 I/O 대기 상태인 에이전트를 제대로 평가하지 못함, 컨테이너의 콜드 스타트, CDN/프록시 (Proxy)의 버퍼링으로 인한 스트리밍 TTFB (Time To First Byte) 악화
이들 모두 " 기존 웹 애플리케이션의 상식적인 튜닝이 에이전트의 장시간·비결정적(Non-deterministic) 특성에 반드시 효과적이지는 않다 "는 공통점이 있습니다.
지금까지 개별적인 문제들을 살펴보았지만, 이러한 문제들을 만들어내는 근본적인 설계 습관은 사실 몇 가지 되지 않습니다.
동기적 에이전트 실행 (Synchronous Agent Execution): HTTP 요청-응답 과정 내에서 에이전트 처리를 완결시키려는 설계입니다. 이렇게 하면 커넥션 점유, 타임아웃, 취소 불가능, 로드 밸런서 (Load Balancer) 단절 등의 문제가 발생합니다. 회피책 중 하나로 "비동기 작업(Asynchronous Job)으로 시작하여, 진행 상황을 SSE(Server-Sent Events)나 폴링(Polling)으로 반환하기"를 검토하십시오.
스테이트풀한 에이전트 인스턴스 (Stateful Agent Instance): 프로세스 메모리에 상태를 유지하고 특정 노드에 고정하는 설계입니다. 이렇게 하면 수평 확장(Horizontal Scale) 불가능, 장애 복구 불가능, 배포 시 상태 소실 등 운영상의 유연성을 잃게 됩니다. 상태는 외부 저장소(External Store)로, 프로세스는 스테이트리스(Stateless)로 설계하는 것을 권장합니다.
"일단 LLM에 던지기" 설계: 규칙 기반(Rule-based)으로 끝낼 수 있는 판단, 결정적인 계산, 구조화된 분기 ―― 이들까지 LLM에 맡기면 비용, 레이턴시 (Latency), 비결정성이 모두 악화됩니다. LLM은 "판단이 모호하고, 구조화하기 어려우며, 자연어 이해가 필요한 부분"에만 한정하여 사용하고, 나머지는 일반적인 코드로 작성하는 것이 철칙입니다.
에이전트를 블랙박스(Black Box)로 취급하기: 내부의 서브태스크 (Sub-task), 툴 호출, LLM 호출을 가시화하지 않고, 리소스 문제가 발생한 후에야 원인 파악에 나서는 설계입니다. 에이전트는 처음부터 내부를 관측 가능하게(Observable) 만드는 것을 원칙으로 해야 합니다. 트레이싱 (Tracing), 구조화된 로그, 서브태스크 단위의 메트릭 등을 사후에 추가하는 것은 많은 공수가 듭니다.
내용이 길어졌으므로, 마지막으로 처방전을 3줄로 요약하겠습니다.
첫째, 에이전트의 시간축과 리소스의 시간축을 분리하십시오: 에이전트는 수 초에서 수십 분 동안 동작합니다. 반면 커넥션, 락 (Lock), 메모리 버퍼는 수 밀리초에서 수 초 내에 완료되어야 하는 리소스입니다. 양자를 묶어두지 않도록 설계하고, 수명이 짧아야 하는 것을 수명이 긴 에이전트 세션에 종속시키지 않도록 구현하십시오.
둘째, 경계마다 명시적인 상한을 설정하십시오: 병렬도, 메모리, 시간, 토큰, 재시도 횟수, 커넥션 수 ―― 이 모든 것은 "명시하지 않으면 무제한이 될 수 있습니다". 에이전트는 똑똑하게 행동하지만, 그 똑똑함이 상한선을 대신할 수는 없습니다. 기계적으로 중단시키는 메커니즘을 마련해야 합니다.
셋째, 상태는 외부화하고 처리는 멱등(Idempotent)하게 만드십시오: 이를 통해 확장성, 장애 복구, 취소, 관측이 용이해집니다. 프로세스는 언제든 취소할 수 있고, 언제든 재개할 수 있으며, 몇 번을 실행해도 동일한 결과가 나와야 합니다. 이 성질이 에이전트 운영의 모든 유연성의 토대가 됩니다.
물론 LLM 자체의 비결정성(동일한 프롬프트에 대해 다른 응답이 나오는 현상)은 피할 수 없지만, 불확실성에 대처하는 원칙은 "불확실한 요소를 국소화하여 제한하고, 이를 평가하는 것"입니다. LLM의 비결정성이 LLM 외부로 번져나가는 설계는 반드시 피해야 합니다.
AI 에이전트 (AI Agent)를 "똑똑한 컴포넌트"가 아니라 "장시간·비결정적·리소스 소비 패턴이 기존과 다른 서브시스템 (Subsystem)"으로 취급하는 것이 중요합니다. 이 관점으로 전환하는 것만으로도, 그동안 간과되었던 리소스 문제의 상당 부분을 설계 단계에서 예견할 수 있을 것입니다.
LLM의 비용은 대시보드 (Dashboard)를 통해 확인할 수 있습니다. 하지만 대시보드에 나타나지 않는 곳에서 조용히 불어나고 있는 리소스에도 눈을 돌리는 것이 중요합니다.
이러한 에이전트 개발의 과제에 대처하기 위해, AI 에이전트를 통합할 때 간과하기 쉬운 리소스 문제를 판별하는 Claude code skill을 개발했습니다.
한번 사용해 보신다면 감사하겠습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Qiita AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기