LLM 에이전트 백엔드 설계 패턴: ReAct, LangGraph, Temporal로 프로덕션 버티기
솔직히 말하면, 처음 AI 에이전트 백엔드를 설계할 때 기존 REST API 방식으로 그대로 접근했다가 꽤 고생했습니다. "그냥 LLM 호출 엔드포인트 하나 만들면 되는 거 아냐?"라고 생각했는데, 막상 프로덕션에 올려보니 타임아웃, 상태 관리 오류, 예측 불가능한 실행 흐름이 한꺼번에 터졌습니다. 구체적으로는 이런 상황이었습니다. 30초짜리 에이전트 루프가 API 게이트웨이 타임아웃에 걸려 클라이언트는 에러를 받았는데 Worker는 여전히 돌고 있었고, Redis에 저장해둔 세션 컨텍스트가 Worker 재시작 한 번에 날아가면서 사용자 입장에선 에이전트가 앞에 한 대화를 전부 잊어버린 것처럼 보였습니다. 원인을 찾는 것도 문제였는데, LLM이 어떤 경로로 실행됐는지 추적할 방법이 없어서 디버깅이 극도로 어려웠죠.
이 글에서는 에이전트 백엔드의 네 가지 핵심 구성 요소(LLM Core, Tool Calling, Memory Layer, Orchestration)가 어떻게 맞물려야 프로덕션에서 버티는 시스템이 되는지, 그리고 어떤 도구를 언제 선택해야 하는지를 실제 코드와 함께 살펴봅니다. 이 글을 다 읽으면 타임아웃 없이 에이전트를 실행하는 구조, 중간에 실패해도 복구되는 상태 관리, 그리고 실행 흐름을 눈으로 볼 수 있는 환경을 직접 구성할 수 있게 됩니다.
기존 백엔드 경험이 있다면 충분히 따라올 수 있습니다. 오히려 그 경험이 "어디서 기존 방식이 깨지는가"를 이해하는 데 좋은 앵커가 됩니다.
핵심 개념
에이전트 백엔드가 기존 백엔드와 다른 이유
일반적인 백엔드는 요청이 들어오면 고정된 비즈니스 로직을 실행하고 응답을 반환합니다. 실행 경로가 사전에 정해져 있고, 같은 입력엔 같은 출력이 나옵니다. 이 특성 덕분에 유닛 테스트가 쉽고, 모니터링도 단순합니다.
에이전트 백엔드는 근본적으로 다릅니다. LLM이 런타임에 직접 "다음에 뭘 할지"를 결정합니다. 어떤 도구를 호출할지, 몇 번 반복할지, 언제 멈출지가 모두 LLM의 추론 결과에 달려 있습니다. 이걸 받아들이는 순간, 설계 철학이 바뀌어야 한다는 걸 느끼게 됩니다. 예를 들어 "이 함수가 실패하면 어떻게 되는가"라는 질문이 "이 실행 경로가 예상치 못한 방향으로 흘렀을 때 어떻게 복구하는가"로 바뀝니다.
| 구성 요소 | 역할 |
|---|---|
| LLM Core | 추론, 계획, 다음 행동 결정 |
| Tool Calling | 외부 API·DB·코드 실행 등 현실 세계 연결 |
| Memory Layer | 단기(컨텍스트 윈도우)·장기(벡터 DB, KV Store) 기억 |
| Orchestration | 멀티 에이전트 라우팅, 상태 머신, 실패 복구 |
ReAct 패턴: 사실상 표준이 된 실행 루프
에이전트의 실행 흐름을 이야기할 때 ReAct 패턴을 빼놓을 수 없습니다. LLM이 생각(Thought) → 행동(Action) → 관찰(Observation) 사이클을 반복하는 구조인데, 현재 프로덕션 시스템 대부분이 이 패턴을 기반으로 합니다.
처음 이 패턴을 코드로 구현할 때 제가 빠진 함정이 있었는데, finish_reason == "stop" 체크만 믿고 반복 횟수 제한을 안 뒀다가 LLM이 루프를 빠져나오지 못하는 상황을 경험했습니다. 실무에서는 max_iterations를 반드시 걸어두는 것이 좋습니다.
# agent/core.py — ReAct 루프의 단순화된 개념 구조
class MaxIterationsExceeded(Exception):
pass
async def react_loop(
user_input: str,
tools: list[Tool],
max_iterations: int = 10
) -> str:
messages = [{"role": "user", "content": user_input}]
for _ in range(max_iterations):
# Thought: LLM이 다음 행동을 추론
response = await llm.invoke(messages, tools=tools)
if response.finish_reason == "stop":
return response.content
# Action: 도구 호출
for call in response.tool_calls:
# Observation: 도구 실행 결과를 컨텍스트에 추가
result = await execute_tool(call.name, call.args)
messages.append({"role": "tool", "content": result})
messages.append(response)
raise MaxIterationsExceeded(f"{max_iterations}회 반복 초과, 강제 종료")ReAct (Reasoning + Acting): LLM이 단순히 텍스트를 생성하는 게 아니라, "생각 → 행동 → 결과 관찰"을 반복하며 목표에 도달하는 에이전트 실행 패턴. 2022년 Google 연구팀이 제안했고, 현재 대부분의 에이전트 프레임워크가 이를 기반으로 구현되어 있습니다.
Memory Layer: 단기와 장기를 분리해서 설계해야 합니다
저도 처음엔 헷갈렸는데, 에이전트의 메모리는 두 층위로 분리해서 설계하는 것이 실무 표준입니다.
- 단기 메모리: LLM의 컨텍스트 윈도우 안에 들어 있는 현재 대화 내역. Redis로 세션 단위로 관리하면 sub-ms 속도로 읽을 수 있습니다.
- 장기 메모리: 세션이 끊겨도 유지되어야 하는 도메인 지식, 사용자 선호, 과거 결정 등. pgvector, Pinecone 같은 벡터 DB에 임베딩해서 유사도 검색으로 꺼내 씁니다. 실무에서는 임베딩 모델 선택(text-embedding-3-small 등), 문서 청킹 전략(512토큰 단위 슬라이딩 윈도우가 일반적), 그리고 검색된 결과를 컨텍스트 윈도우 앞부분에 주입하는 위치까지 함께 설계해야 합니다.
# agent/memory.py
class AgentMemory:
def __init__(self):
# 실제 환경에서는 연결 설정 필요:
# Redis(host="localhost", port=6379, decode_responses=True)
self.short_term = Redis() # 세션 컨텍스트, 빠른 읽기
self.long_term = PGVector() # 임베딩 기반 장기 기억
self.checkpoint = Postgres() # 실패 복구용 내구성 저장소
async def retrieve_context(self, query: str, session_id: str) -> dict:
# 단기: 현재 세션의 최근 메시지
recent = await self.short_term.get(f"session:{session_id}")
# 장기: 쿼리와 의미적으로 유사한 과거 기억 k=5개 검색
# 검색 결과는 시스템 프롬프트 직후, 사용자 메시지 앞에 주입
relevant = await self.long_term.similarity_search(query, k=5)
return {"recent": recent, "relevant": relevant}MCP: 도구 연동의 새로운 표준
2024년 11월 Anthropic이 공개한 Model Context Protocol(MCP)이 2025년부터 에이전트 도구 연동 표준으로 빠르게 자리 잡았습니다. 현재는 Linux Foundation 산하 AAIF로 기증되어 오픈 표준으로 발전하고 있습니다.
기존 function calling과의 핵심 차이는 도구 정의가 LLM 호스트 코드 안에 하드코딩되지 않는다는 점입니다. MCP 서버는 독립적인 프로세스로 실행되며 tools/list, tools/call 같은 표준 엔드포인트를 통해 LLM 호스트와 통신합니다. 덕분에 동일한 MCP 서버를 Claude, GPT-4o, Gemini 어느 모델과도 연결할 수 있고, 도구 구현을 LLM 로직과 완전히 분리해서 관리할 수 있습니다.
# mcp_server/search_server.py — FastMCP로 간단한 MCP 서버 구현 예시
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("search-server")
@mcp.tool()
async def web_search(query: str) -> str:
"""웹에서 최신 정보를 검색합니다."""
results = await search_api.search(query)
return results.format_as_text()MCP (Model Context Protocol): LLM이 외부 도구·데이터 소스와 상호작용하는 방식을 표준화한 프로토콜. REST API가 웹 서비스 간 통신을 표준화한 것처럼, MCP는 LLM과 도구 사이의 연결을 표준화합니다.
실전 적용
세 가지 예시를 살펴보기 전에, 어떤 상황에서 어떤 도구를 선택하는지 먼저 이야기하는 것이 도움이 됩니다.
| 상황 | 권장 선택 |
|---|---|
| 단순 태스크, 빠른 프로토타입 | FastAPI + asyncio.Queue (또는 BullMQ) |
| 조건 분기·재시도·멀티 에이전트 | LangGraph |
| 수십 단계, 며칠짜리 실행, 절대 유실 불가 | Temporal |
비동기 큐로 HTTP 타임아웃 탈출하기
에이전트 태스크는 수십 초에서 수 분까지 걸릴 수 있습니다. 이걸 동기 HTTP 핸들러에서 처리하면 API 게이트웨이 타임아웃, 클라이언트 연결 끊김 문제가 반드시 생깁니다. 실무에서 안착된 패턴은 큐 기반 비동기 분리입니다. HTTP 엔드포인트는 task_id만 즉시 반환하고, 실제 에이전트 루프는 별도 Worker 프로세스에서 실행하는 구조입니다.
# agent/api.py — FastAPI 엔드포인트
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from redis.asyncio import Redis
import asyncio, json
app = FastAPI()
redis = Redis(host="localhost", port=6379, decode_responses=True)
@app.post("/agent/task")
async def submit_task(request: TaskRequest) -> dict:
task_id = generate_task_id()
# 즉시 큐에 푸시하고 task_id만 반환 — 에이전트 실행은 Worker가 담당
await redis.lpush("agent:queue", json.dumps({
"task_id": task_id,
"input": request.input,
"tools": request.tools
}))
return {"task_id": task_id, "status": "queued"}
@app.get("/agent/task/{task_id}/stream")
async def stream_result(task_id: str):
# SSE로 에이전트 진행 상황 스트리밍
# 프로덕션에서는 타임아웃(30s 등)과 연결 종료 처리도 추가 필요
async def event_generator():
pubsub = redis.pubsub()
await pubsub.subscribe(f"agent:result:{task_id}")
try:
async for message in pubsub.listen():
if message["type"] == "message":
yield f"data: {message['data']}\n\n"
if json.loads(message["data"]).get("done"):
break
finally:
await pubsub.unsubscribe(f"agent:result:{task_id}")
return StreamingResponse(event_generator(), media_type="text/event-stream")# agent/worker.py — 실제 에이전트 루프 실행 프로세스
async def agent_worker():
while True:
# brpop: 큐가 비어있으면 블로킹, 태스크 도착 시 즉시 처리
_, task_data = await redis.brpop("agent:queue")
task = json.loads(task_data)
async for step_result in run_agent_loop(task):
await redis.publish(
f"agent:result:{task['task_id']}",
json.dumps(step_result)
)| 구성 요소 | 역할 | 기술 선택 |
|---|---|---|
| HTTP 엔드포인트 | 태스크 접수, task_id 반환 | FastAPI / NestJS |
| Job Queue | 비동기 태스크 버퍼링 | Redis / SQS |
| Worker | 실제 에이전트 루프 실행 | 별도 프로세스/컨테이너 |
| 스트리밍 | 클라이언트에 진행 상황 전달 | SSE / WebSocket |
LangGraph로 조건 분기 멀티 에이전트 오케스트레이션 구현하기
단순 큐 패턴으로 기반을 잡으면, 다음에 맞닥뜨리는 벽은 복잡한 분기 로직입니다. "A가 끝나야 B를 시작"이 아니라 조건에 따라 다른 에이전트로 라우팅하고, 품질이 기준 미달이면 재시도하는 흐름이 필요한 경우가 많거든요. LangGraph의 그래프 상태 머신이 이 문제를 깔끔하게 해결해줍니다.
# agent/graph.py — LangGraph 멀티 에이전트 파이프라인
from langgraph.graph import StateGraph, END
from typing import TypedDict, Annotated
import operator
# TypedDict: 상태 타입 명시, Annotated[list, operator.add]는 각 노드의 반환값을 리스트에 추가(append)하는 리듀서 선언
class AgentState(TypedDict):
query: str
retrieved_docs: list[str]
draft_answer: str
quality_score: float
messages: Annotated[list, operator.add]
async def retriever_node(state: AgentState) -> AgentState:
docs = await vector_db.similarity_search(state["query"])
return {"retrieved_docs": docs}
async def summarizer_node(state: AgentState) -> AgentState:
draft = await llm.invoke(
f"다음 문서를 요약해 답변을 작성하세요:\n{state['retrieved_docs']}"
)
return {"draft_answer": draft}
async def critic_node(state: AgentState) -> AgentState:
# LLM-as-judge 패턴: 별도 LLM 호출로 답변 품질을 0~1 점수로 평가
# 프롬프트에 평가 기준(관련성, 완성도, 사실 정확성)을 명시하고 JSON으로 파싱
score = await evaluate_quality(state["draft_answer"], state["query"])
return {"quality_score": score}
def route_after_critic(state: AgentState) -> str:
if state["quality_score"] < 0.7:
return "retriever" # 품질 미달 → 재질의
return END
workflow = StateGraph(AgentState)
workflow.add_node("retriever", retriever_node)
workflow.add_node("summarizer", summarizer_node)
workflow.add_node("critic", critic_node)
workflow.set_entry_point("retriever")
workflow.add_edge("retriever", "summarizer")
workflow.add_edge("summarizer", "critic")
workflow.add_conditional_edges("critic", route_after_critic)
# redis_checkpointer: 각 노드 실행 결과를 Redis에 저장해 중간 실패 시 재개 가능
agent = workflow.compile(checkpointer=redis_checkpointer)체크포인팅 (Checkpointing): 에이전트 실행의 각 단계를 저장소에 기록해두는 것. 중간에 실패해도 마지막 체크포인트부터 재개할 수 있어, 긴 태스크를 처음부터 다시 시작하는 비용을 피할 수 있습니다.
Temporal로 절대 유실되지 않는 에이전트 실행 보장하기
BullMQ 같은 단순 큐는 Worker 프로세스가 죽으면 진행 중인 태스크가 유실될 수 있습니다. 수십 단계로 구성되거나, 며칠에 걸쳐 실행되거나, 중간 결과가 절대 사라지면 안 되는 에이전트 태스크라면 Temporal 같은 내구성 실행 엔진이 적합합니다. LangGraph가 "로직의 복잡도"를 해결한다면, Temporal은 "실행의 신뢰성"을 해결하는 도구입니다.
# agent/temporal_workflow.py
from temporalio import workflow, activity
from temporalio.common import RetryPolicy
from datetime import timedelta
from dataclasses import dataclass
@dataclass
class LLMResponse:
content: str
is_final: bool
tool_name: str | None = None
tool_args: dict | None = None
@activity.defn
async def call_llm(prompt: str) -> LLMResponse:
raw = await llm.invoke(prompt)
# LLM 응답을 파싱해 구조화된 LLMResponse로 변환
return parse_llm_response(raw)
@activity.defn
async def execute_tool(tool_name: str, args: dict) -> str:
return await tool_registry.execute(tool_name, args)
@workflow.defn
class AgentWorkflow:
@workflow.run
async def run(self, user_input: str) -> str:
messages = [user_input]
for _ in range(10): # 무한 루프 방지
# 각 Activity는 실패 시 자동 재시도, 결과는 영속적으로 저장
response: LLMResponse = await workflow.execute_activity(
call_llm,
args=[str(messages)],
start_to_close_timeout=timedelta(seconds=30),
retry_policy=RetryPolicy(maximum_attempts=3)
)
if response.is_final:
return response.content
tool_result = await workflow.execute_activity(
execute_tool,
args=[response.tool_name, response.tool_args],
start_to_close_timeout=timedelta(seconds=60)
)
messages.append(tool_result)
return "최대 반복 횟수 초과"장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 유연성 | 고정 로직 없이 LLM이 런타임에 도구 조합과 계획을 동적으로 수립 |
| 확장성 | 멀티 에이전트로 서브태스크를 병렬 처리해 복잡한 워크플로우 소화 |
| 지식 활용 | RAG + 장기 메모리로 도메인 전문성을 세션을 넘어 지속적으로 축적 |
| 복잡 워크플로우 | 수백 단계의 의존성 있는 작업을 자율적으로 처리 가능 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 비결정론적 동작 | 같은 입력이 다른 실행 경로를 생성, 전통적 유닛 테스트 불가 | LangSmith·Arize Phoenix 등으로 Eval 파이프라인 별도 구축 |
| Observability 부족 | LangChain 조사 기준, 구현자 중 만족하는 비율이 3명 중 1명 미만 | OpenTelemetry + 전문 플랫폼(LangSmith, Maxim AI) 조합 |
| 오케스트레이션 복잡도 | 멀티 에이전트 조율 오버헤드가 LLM 호출보다 큰 병목이 되기도 함 | 단계별 성능 측정 후 에이전트 수 최소화, 필요한 곳만 분리 |
| 자율 완료율 한계 | Carnegie Mellon 벤치마크 기준 최고 성능 에이전트도 멀티스텝 태스크의 30~35%만 자율 완료 | Human-in-the-loop 설계를 기본 전제로 포함 |
| 상태 관리 복잡성 | 세션 간 메모리 일관성, 실패 시 체크포인트 복구 | Redis(속도) + Postgres(내구성) 하이브리드 설계 |
| 보안 | 프롬프트 인젝션, 툴 오남용 위험 | ABAC 권한 제어, Zero Retention 기법, 가드레일 레이어 추가 |
테이블만 보면 추상적으로 느껴질 수 있는데, 제가 실제로 가장 뼈아프게 경험한 건 "Observability 부족"이었습니다. 에이전트가 왜 엉뚱한 도구를 호출했는지, 어떤 컨텍스트에서 잘못된 판단을 내렸는지 전혀 볼 수 없는 상태에서 디버깅하는 것은 블랙박스와 씨름하는 것과 같았습니다. LANGCHAIN_TRACING_V2=true 환경변수 하나를 설정하는 것이 코드 한 줄보다 더 가치 있는 순간이 있습니다.
ABAC (Attribute-Based Access Control): 사용자·리소스·환경의 속성을 조합해 접근 권한을 결정하는 방식. 에이전트가 어떤 도구에 어떤 범위로 접근할 수 있는지를 세밀하게 제어할 수 있어, 에이전트 백엔드의 보안 레이어로 적합합니다.
Zero Retention: LLM API 호출 시 입력/출력 데이터를 제공자 서버에 저장하지 않도록 설정하는 기법. 민감한 기업 데이터를 에이전트에 통과시킬 때 필수적으로 고려해야 합니다.
실무에서 가장 흔한 실수
- 동기 HTTP로 에이전트 루프를 처리하는 것 — 에이전트 태스크는 수 분까지 걸릴 수 있습니다. 큐 기반 비동기 분리 없이는 API 게이트웨이 타임아웃과 반드시 만나게 됩니다.
- 메모리 설계를 나중으로 미루는 것 — 단기/장기 메모리 구조는 초기 설계 단계에서 결정해야 합니다. 나중에 붙이면 세션 상태와 벡터 인덱스 간 일관성 문제가 매우 복잡해집니다.
- 에이전트 수를 과도하게 늘리는 것 — "멀티 에이전트니까 더 잘 되겠지"라는 직관과 달리, 에이전트 간 조율 오버헤드가 병목이 되는 경우가 많습니다. 단일 에이전트로 시작해서 실제로 병렬 처리가 필요한 지점에서만 분리하는 것이 낫습니다.
마치며
에이전트 백엔드 설계의 핵심은 LLM의 비결정론적 특성을 인정하고, 그 위에 관측 가능하고 복구 가능한 인프라를 쌓는 것입니다.
처음부터 완벽한 시스템을 만들려 하면 과설계에 빠지기 쉽습니다. 아래 순서로 단계적으로 쌓아가는 것이 실무에서 검증된 경로입니다.
-
단일 에이전트 + 비동기 큐부터 구성해보시면 좋습니다 —
pip install langgraph redis후, 간단한 ReAct 루프를 Python의asyncio.Queue와 연결해 HTTP 엔드포인트에서 분리하는 것부터 시작할 수 있습니다. 복잡한 오케스트레이션은 이 기반이 안정되고 나서 고민해도 전혀 늦지 않습니다. -
Observability를 코드보다 먼저 설정하시면 됩니다 —
LANGCHAIN_TRACING_V2=true환경변수 하나로 LangSmith 트레이싱을 켤 수 있습니다. 에이전트가 어떤 경로로 실행되는지 눈으로 확인하는 환경이 없으면 디버깅이 극도로 어려워집니다. 이건 선택이 아니라 필수입니다. -
메모리 계층을 Redis + pgvector 조합으로 설계해보시면 도움이 됩니다 —
docker compose up redis postgres로 로컬 환경을 띄우고, 세션 컨텍스트는 Redis에, 임베딩된 장기 기억은 pgvector에 저장하는 구조를 직접 구현해보시면 메모리 설계의 감각을 익히는 데 큰 도움이 됩니다.
참고 자료
- AI Agent Architecture: Build Systems That Work in 2026 | Redis
- The Architectural Shift: AI Agents Become Execution Engines | InfoQ
- Architecture overview — Model Context Protocol 공식 문서
- AI Engineering Trends in 2025: Agents, MCP and Vibe Coding | The New Stack
- Designing AI-Native Backends: Architecture Patterns for Production LLMs | Medium
- Event-Driven Architecture for AI Agents: Production Patterns | Sandipan Haldar
- State Management Patterns for AI Agents | AI Fluens
- 5 Production Scaling Challenges for Agentic AI in 2026 | MachineLearningMastery
- Design Patterns for Long-Term Memory in LLM-Powered Architectures | Serokell
- State of Agent Engineering | LangChain
- AI Agent Orchestration Patterns | Azure Architecture Center
- Spring AI Agentic Patterns Part 6: AutoMemoryTools | Spring.io
- Top 5 AI Agent Observability Platforms in 2026 | Maxim AI
- Best AI Agent Frameworks 2025: LangGraph, CrewAI, OpenAI, LlamaIndex, AutoGen | Maxim AI
다음 글: LLM-as-Judge부터 레이턴시 트레이싱까지 — 에이전트 Eval 파이프라인 구축 실전 가이드 (비결정론적 시스템을 어떻게 테스트하고 품질 기준을 만드는지 다룹니다)