LangGraph 체크포인팅 (Checkpointing): 이해하기까지 세 번의 프로덕션 재작성
요약
LangGraph의 SqliteSaver를 사용하여 에이전트를 프로덕션에 배포하며 겪은 스키마 불일치와 데이터 손상 문제를 다룹니다. 데이터 직렬화 과정에서의 조용한 오류와 서버리스 환경에서의 데이터베이스 잠금 문제를 해결하는 과정을 공유합니다.
핵심 포인트
- SqliteSaver의 관용적인 직렬화로 인한 조용한 데이터 손상 주의
- AgentState 정의 시 데이터 타입을 명시적으로 선언하여 스키마 불일치 방지
- 서버리스 환경에서 SQLite 사용 시 발생할 수 있는 경합 조건(Race Condition) 인지
원문은 AIdeazz에 게시되었습니다 — 정식 링크와 함께 이곳에 교차 게시되었습니다.
저의 첫 번째 LangGraph 에이전트는 몇 주 동안 모든 작업을 조용히 폐기했습니다. 제 AgentState의 memory 필드는 list[str]로 정의되어 있었지만, 에이전트는 list[dict]를 쓰고 있었습니다. LangGraph의 SqliteSaver는 에러를 발생시키지 않았고, 그저 상태를 잘라내어 빈 리스트를 남겨두었습니다. 저는 고객이 다단계 요청이 첫 번째 턴을 넘어 진행되지 않는다고 불평할 때서야 이 사실을 알게 되었습니다. 해결책은 한 줄의 스키마(schema) 변경이었지만, 그 대가는 몇 시간의 디버깅과 고객 한 명의 상실이었습니다. 이것이 체크포인팅(checkpointing)의 유일한 함정은 아니었습니다.
조용한 스키마 불일치: SqliteSaver의 관용성
저의 초기 LangGraph 설정은 체크포인팅을 위해 SqliteSaver를 사용했습니다. 이는 단순하고 임베디드(embedded) 방식이며, 초기 프로덕션 단계에서는 충분히 견고해 보였습니다. 에이전트의 목적은 Telegram에서 들어오는 요청을 처리하고, 이를 분해하며, 다단계 대화를 관리하는 것이었습니다. AgentState는 다음과 같은 모습이었습니다:
class AgentState(TypedDict):
chat_id: str
thread_id: str
...
에이전트의 memory 필드는 대화 조각들의 이력을 저장하도록 의도되었습니다. 하지만 제 에이전트 코드는 {"role": "user", "content": "..."}와 같이 더 구조화된 데이터를 저장하도록 설계되어 있었습니다. 그래서 list[str] 대신 list[dict]를 memory에 밀어 넣고 있었습니다.
SqliteSaver는 상태를 BLOB으로 직렬화(serialize)하기 위해 json.dumps를 사용합니다. json.dumps가 (추론되었거나 주어진 초기 스키마에 따라) list[str]를 예상하는 상황에서 list[dict]를 마주했을 때, 오류가 발생하지 않았습니다. 대신, list[dict]를 문자열로 조용히 직렬화해 버렸습니다. 문제는 역직렬화 (deserialization) 단계였습니다. SqliteSaver가 상태를 로드할 때, 해당 문자열을 다시 list[str]로 역직렬화하려고 시도했습니다. list[dict]의 문자열 표현은 유효한 list[str]가 아니기 때문에, 이는 종종 빈 리스트나 잘못된 객체로 이어졌고, 결과적으로 해당 필드의 대화 기록을 사실상 모두 지워버리는 결과를 초래했습니다.
해결책은 AgentState에서 memory 필드를 list[dict]로 명시적으로 정의하는 것이었습니다. 이는 중요한 교훈을 남겼습니다. LangGraph의 SqliteSaver는 지나칠 정도로 관대합니다. 직렬화 과정에서 엄격한 스키마 검증보다는 무언가라도 저장하는 것을 우선시하며, 이는 역직렬화 시 조용한 데이터 손상(silent data corruption)으로 이어집니다. 프로덕션 환경에서는 명시적인 검증이나 더 견고한 ORM이 필요합니다.
체크포인트 손상: SqliteSaver와의 경합 조건 (Race Condition)
스키마를 수정한 후, sqlite3.OperationalError: database is locked 오류가 나타나기 시작했습니다. 제 에이전트들은 Oracle Cloud Infrastructure (OCI)의 서버리스 함수(OCI Functions) 환경에서 실행됩니다. 들어오는 각 메시지는 새로운 함수 호출을 트리거합니다. OCI Functions는 상태가 없는(stateless) 방식이지만, 제 LangGraph 에이전트들은 상태(state)가 필요했습니다. SqliteSaver는 파일에 기록합니다. 서버리스 환경에서는 이 파일을 외부화해야 합니다. 저는 SQLite 데이터베이스 파일을 저장하기 위해 OCI Object Storage를 사용했고, 이를 FUSE를 통해 마운트했습니다.
문제는 다음과 같았습니다: 여러 개의 함수 호출(function invocations)이 동시에 동일한 SQLite 파일에 쓰기를 시도할 수 있다는 점이었습니다. FUSE를 사용하더라도, 기본 sqlite3 라이브러리는 적절한 잠금 메커니즘(locking mechanisms) 없이 별도의 프로세스에서 발생하는 동시 쓰기를 처리하도록 설계되지 않았으며, FUSE로 마운트된 오브젝트 스토리지(object storage)는 데이터베이스 수준에서 이를 기본적으로 제공하지 않습니다. 이로 인해 체크포인트(checkpoint) 손상이 발생했습니다. 데이터베이스 잠금(database lock) 오류가 발생하면 SQLite 파일이 일관되지 않은 상태(inconsistent state)로 남게 되어, 이후의 호출에서 파일을 읽을 수 없게 되었습니다.
저의 해결책은 과감한 전환(hard pivot)이었습니다: 바로 RedisSaver를 사용하는 것이었습니다. Redis는 동시 접속(concurrent access)을 위해 설계되었으며 원자적 연산(atomic operations)을 제공합니다. 저는 Redis가 포함된 OCI Cache를 배포하고 CheckpointSaver 구현을 교체했습니다.
from langgraph.checkpoint.redis import RedisSaver
import redis
...
이 조치는 database is locked 오류와 체크포인트 손상 문제를 즉시 해결했습니다. Redis의 원자적 SET 연산은 여러 호출이 동일한 체크포인트를 업데이트하려고 시도하더라도, 하나는 성공하고 나머지는 다음 읽기 시점에 최신 상태를 가져올 수 있도록 보장합니다. 비용 측면에서는 기본 인스턴스 기준 월 25달러의 관리형 서비스(OCI Cache)가 추가되었지만, 그만한 안정성을 얻을 가치가 있었습니다.
다단계 파이프라인을 안정화시킨 단 하나의 패턴: 명시적 상태 전이 (Explicit State Transitions)
RedisSaver를 사용했음에도 불구하고, 저의 다단계 에이전트(multi-step agents)들은 여전히 가끔씩 멈추는 현상이 발생했습니다. 사용자가 메시지를 보내면 에이전트가 이를 처리하지만, 다음 단계가 트리거되지 않거나 에이전트가 이전 단계를 반복하는 식이었습니다. 이는 체크포인팅 자체의 문제라기보다는 LangGraph의 그래프 실행(graph execution) 내에서의 상태 관리(state management) 문제였습니다.
저의 초기 그래프 설계는 user_input의 _내용(content)_이나 상태(state) 내 특정 필드의 _존재 여부(presence)_를 확인하는 조건부 엣지(conditional edges)에 크게 의존했습니다. 예를 들면 다음과 같습니다:
def route_next_step(state: AgentState):
if state.get("user_input") == "confirm":
return "confirm_action"
...
이러한 접근 방식은 취약했습니다. 만약 user_input이 정확히 "confirm"이 아니거나, task_completed가 설정되었지만 다른 조건도 동시에 일치하는 경우, 에이전트가 루프에 빠지거나 잘못된 노드로 점프할 수 있었습니다. 문제는 상태 (state) 자체가 _전이 (transition)_를 명시적으로 안내하지 못했다는 점이었습니다.
돌파구는 AgentState에 명시적인 next_action 필드를 도입하고, 모든 노드가 이를 설정할 책임을 갖도록 만들었을 때 찾아왔습니다.
class AgentState(TypedDict):
chat_id: str
thread_id: str
...
이제 저의 조건부 엣지 (conditional edges)는 훨씬 더 단순하고 견고해졌습니다:
def route_next_action(state: AgentState):
return state["next_action"]
...
이제 각 노드의 책임에는 단순히 데이터를 처리하는 것뿐만 아니라, 결과에 따라 next_action을 명시적으로 설정하는 것도 포함되었습니다. 예를 들어, process_input_node는 다음과 같이 결정할 수 있습니다:
def process_input_node(state: AgentState):
# ... 복잡도에 따라 LLM (Groq/Claude 라우팅)으로 입력 처리
if requires_confirmation:
...
이 패턴은 저의 에이전트들을 취약하고 암시적인 상태 머신 (state machines)에서 견고하고 명시적인 상태 머신으로 변화시켰습니다. 그래프의 흐름은 next_action 필드에 의해 구동되는 결정론적 (deterministic)인 방식이 되었습니다. 또한 디버깅도 훨씬 쉬워졌습니다. 체크포인트 (checkpoint)에서 next_action을 검사함으로써 에이전트가 왜 특정 노드로 전이(또는 전이하지 못함)하고 있는지 즉시 이해할 수 있었습니다. 이는 프로덕션 환경의 LangGraph 상태 유지 에이전트 (stateful agents) 체크포인팅 (checkpointing)에 있어 매우 중요합니다.
이 접근 방식은 에이전트가 사용자의 입력을 기다려야 하는 멀티 턴 대화 (multi-turn conversations)도 자연스럽게 지원합니다. wait_for_user 액션은 단순히 상태를 반환하는 것 외에는 아무것도 하지 않는 노드로 라우팅하며, 이를 통해 새로운 user_input이 도착할 때까지 그래프를 효과적으로 일시 중지시킵니다.
견고함의 비용
VC 펀딩 없이 이러한 에이전트들을 구축한다는 것은 모든 달러가 소중하다는 것을 의미합니다.
- OCI Functions: GB-초당 $0.000015, 호출당 $0.0000002. 내 에이전트들은 밀리초(milliseconds) 단위로 실행되므로, 컴퓨팅 비용은 무시할 수 있는 수준입니다 (수천 명의 사용자 기준 월 $5 미만).
- OCI Cache (Redis): 1GB 인스턴스 기준 월 $25. 이것이 상태(state) 유지를 위한 주요 인프라 비용입니다.
- LLM APIs: 빠르고 간단한 작업에는 Groq (Llama 3 8B)를, 복잡한 추론에는 Claude 3 Opus를 사용합니다. Groq 비용은 백만 토큰당 몇 센트 수준입니다. Claude 3 Opus는 입력 100만 토큰당 $15, 출력 100만 토큰당 $75입니다. 나의 라우팅 로직(routing logic)은 Opus의 사용을 최소화합니다. 총 LLM 비용은 보통 월 $50~$150입니다.
- OCI Object Storage: GB당 월 $0.0255. 로그 및 가끔 발생하는 대용량 데이터 블롭(data blobs) 용도로 사용되며, 주요 상태 저장용은 아닙니다. 무시할 수 있는 수준입니다.
실제 사용자를 서비스하는 여러 개의 프로덕션급 LangGraph 에이전트를 실행하기 위한 총 인프라 비용은 월 $200 미만입니다. 가장 큰 비용은 초기 단계의 취약한 구현을 디버깅하는 데 들어간 나의 시간이었습니다. RedisSaver와 명시적인 상태 전이(state transitions)로 전환하면서 해당 디버깅 시간을 크게 줄일 수 있었습니다.
자주 묻는 질문 (Frequently Asked Questions)
Q: 체크포인팅(checkpointing)을 위해 Redis 대신 PostgreSQL과 같이 더 견고한 데이터베이스를 사용하지 않는 이유는 무엇인가요?
A: PostgreSQL은 더 강력한 ACID 보장과 복잡한 쿼리 기능을 제공하지만, Redis는 키-값(key-value) 조회와 원자적 업데이트(atomic updates)에 대해 더 낮은 지연 시간(latency)을 제공합니다. 이는 LangGraph 체크포인트의 빈번하고 작은 상태 변화에 이상적입니다. 나의 사용 사례에서는 Redis의 단순함과 속도가 전체 관계형 데이터베이스(relational database)의 필요성보다 더 중요했습니다.
Q: RedisSaver를 사용하는 프로덕션 환경에서 AgentState의 스키마 마이그레이션(schema migrations)은 어떻게 처리하나요?
A: RedisSaver는 상태를 JSON 문자열로 저장합니다. 스키마 변경을 위해 나는 AgentState에 버전 필드(versioning field)를 구현하고, 로드 시 실행되는 마이그레이션 함수를 구현합니다. 만약 로드된 상태의 버전이 현재 에이전트의 버전보다 낮으면, 마이그레이션 함수가 에이전트가 이를 처리하기 전에 상태를 새로운 스키마로 변환합니다.
Q: 작업 복잡도에 따라 서로 다른 LLM(Groq, Claude) 사이를 라우팅하는 전략은 무엇인가요?
A: 저는 작고 빠른 LLM(예: Groq 상의 Llama 3 8B)을 라우터 (Router)로 사용합니다. 라우터는 사용자 입력과 현재의 AgentState를 분석하여, 작업이 복잡한 추론(Claude 3 Opus로 라우팅)을 필요로 하는지, 아니면 더 저렴하고 빠른 모델(Groq로 라우팅)로 처리할 수 있는지를 결정합니다. 이 결정은 process_input_node 로직의 일부입니다.
Q: 단일 에이전트 인스턴스에서 발생하는 동시 사용자 상호작용을 어떻게 관리하나요?
A: 각 사용자 상호작용(예: Telegram 채팅 ID)은 LangGraph 내의 고유한 thread_id에 매핑됩니다. RedisSaver는 thread_id별로 체크포인트 (Checkpoint)를 저장합니다. 기존 thread_id에 대해 새로운 메시지가 들어오면, LangGraph는 해당 스레드에 대한 특정 상태를 로드하여 대화가 격리되도록 보장합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기