LangGraph interrupt()로 구현하는 Human-in-the-Loop: 에이전트가 실수하기 전에 사람이 개입하는 인터럽트 설계
AI 에이전트가 알아서 뭔가를 "척척" 해주는 게 멋있어 보이지만, 실무에서 그 에이전트가 프로덕션 DB에 DELETE 쿼리를 날리려 한다면 이야기가 달라집니다. 저도 처음 에이전트를 팀 서비스에 붙였을 때, "설마 이 단계까지 자동으로 실행하겠어?" 했다가 식은땀을 흘린 적이 있거든요. LangGraph HITL이나 langgraph human approval을 검색해서 이 글에 도달했다면, 아마 비슷한 상황을 경험하셨거나 예방하고 싶으신 거겠죠. AI 에이전트 승인 게이트를 코드 레벨에서 어떻게 정밀하게 제어하는지, 그게 이 글의 핵심입니다.
그때부터 인간 감독(Human Oversight)이라는 개념이 단순한 UX 선택이 아닌 생존의 문제로 느껴졌습니다. EU AI Act Article 14가 고위험 AI 시스템에 인간 감독을 법으로 강제하기 시작한 것도 우연이 아닙니다. AI가 중요한 결정을 내리기 직전에 사람이 개입할 수 있는 구조를 만드는 것, 그게 LangGraph의 interrupt()가 해결하는 문제입니다. 그 개입 지점을 코드로 정밀하게 제어하는 법을 지금부터 보겠습니다.
이 글은 Python 기반 LangGraph 예시를 기준으로 합니다. JavaScript SDK도 별도로 존재하지만, 코드 예시는 모두 Python입니다. LangGraph 기본 개념이 처음이라면 공식 문서를 먼저 훑어보시면 더 수월하게 따라오실 수 있습니다.
핵심 개념
Human-in-the-Loop란 무엇인가
HITL은 AI 에이전트가 민감한 액션을 실행하기 전에 실행 흐름을 일시 정지하고, 사람의 승인·수정·피드백을 기다리는 설계 패턴입니다. 단순히 "중간에 확인창 띄우는 것"이라고 생각할 수 있는데, LangGraph에서는 그것보다 훨씬 정교합니다.
LangGraph는 interrupt() 함수를 통해 이를 네이티브로 지원합니다. 그래프의 임의 노드 안에서 interrupt(value)를 호출하면 다음 네 가지 일이 순서대로 일어납니다.
- 그래프 실행이 해당 지점에서 멈춥니다
- 전달한
value(JSON)가 체크포인터(Checkpointer)에 저장됩니다 - 호출자는 응답 객체의
["__interrupt__"]키로 중단 정보를 받습니다 - 사람이 판단을 내리면
Command(resume=<값>)으로 재개됩니다
여기서 한 가지 중요한 내부 동작이 있습니다. interrupt()는 내부적으로 예외(Exception) 메커니즘을 활용해서 실행을 멈춥니다. 이 사실을 모르고 interrupt() 호출을 try/except로 감싸면 인터럽트 자체가 동작하지 않습니다. 실무에서 가장 흔한 실수 중 하나라 핵심 개념 단계에서 미리 짚고 넘어가는 게 좋을 것 같습니다.
체크포인터(Checkpointer)란? 그래프 실행 상태 전체를 특정 시점에 스냅샷으로 저장하는 레이어입니다. 덕분에 사람이 승인을 고민하는 동안 서버가 재시작돼도 중단 지점에서 정확히 이어서 실행할 수 있습니다.
정적 Breakpoint vs 동적 interrupt()
이전에는 특정 노드 앞뒤에 항상 멈추는 정적 breakpoint 방식을 썼습니다. 모든 실행에서 같은 지점에 무조건 멈추니까, 실제로는 검토가 필요 없는 상황에서도 사람이 눌러줘야 하는 불편함이 있었죠.
interrupt()는 동적이어서 조건부 로직과 조합할 수 있습니다. "DELETE 쿼리일 때만 멈추고, SELECT는 그냥 통과" 같은 식의 선택적 개입이 가능해집니다.
from langgraph.types import interrupt, Command
def sql_review_node(state):
query = state["generated_query"]
# 위험한 쿼리일 때만 인터럽트 — 조건부 동적 개입
if any(keyword in query.upper() for keyword in ["DELETE", "DROP", "UPDATE"]):
decision = interrupt({
"query": query,
"risk_level": "HIGH",
"message": "위험한 쿼리입니다. 실행하시겠습니까?"
})
return {"approved": decision == "approved", "query": query}
# SELECT 같은 안전한 쿼리는 auto-approve
return {"approved": True, "query": query}체크포인터 선택 가이드
로컬과 프로덕션에서 체크포인터를 어떻게 고를지가 실무에서 꽤 자주 헷갈리는 지점입니다.
| 체크포인터 | 패키지 | 특징 | 적합한 환경 |
|---|---|---|---|
SqliteSaver |
langgraph-checkpoint-sqlite |
동기, 단일 프로세스, 파일 또는 메모리 | 로컬 개발·테스트 |
AsyncPostgresSaver |
langgraph-checkpoint-postgres |
비동기, 분산 환경 지원, 영속 저장 | 프로덕션 서비스 |
SQLite는 :memory:로 DB 파일 없이 바로 쓸 수 있어 프로토타이핑에 편리하고, PostgreSQL은 서버 재시작·수평 확장이 필요한 실서비스에서 사실상 필수입니다.
Command(resume=)에 넣을 수 있는 값
Command(resume=<값>)의 값은 문자열, 딕셔너리, 리스트, None 등 JSON-safe 값이면 모두 가능합니다. "approved" 같은 단순 문자열뿐만 아니라 {"action": "approve", "comment": "LGTM"} 같은 구조화된 딕셔너리도 넘길 수 있습니다. 이 값이 interrupt() 호출의 반환값이 되어 노드 내부 로직에서 사용됩니다.
세 가지 핵심 HITL 패턴
실무에서 자주 등장하는 패턴을 정리하면 다음과 같습니다. 이 세 패턴 중 실무에서 제일 자주 쓰는 건 단연 첫 번째입니다.
| 패턴 | 설명 | 대표 사례 | 아래 예시 |
|---|---|---|---|
| Action Approval | 실행 전 승인 요청 | API 호출, 파일 쓰기, DB 수정 | 예시 1 |
| State Editing | 사람이 그래프 중간 상태를 직접 수정 | 여행 플랜 수정, 보고서 내용 편집 | 예시 2 |
| LLM Output Review | LLM 응답을 검수 후 다음 단계 진행 | 법률 문서 검토, 특허 조사 결과 확인 | — |
실전 적용
예시 1: SQL 실행 에이전트 — 위험한 쿼리만 골라서 멈추기 (Action Approval 패턴)
솔직히 이 케이스가 HITL을 도입하게 된 가장 현실적인 계기인 팀이 많을 거라 생각합니다. LLM이 생성한 쿼리를 그대로 실행했다가 데이터를 날린 사례는 생각보다 흔하거든요. 저도 처음에 thread_id를 매 요청마다 새로 만들었다가 재개가 전혀 안 돼서 30분을 날린 적이 있습니다. 재개할 때는 반드시 같은 thread_id를 써야 한다는 게 이 예시의 핵심 포인트입니다.
from langgraph.graph import StateGraph, END
from langgraph.types import interrupt, Command
from langgraph.checkpoint.sqlite import SqliteSaver
from langchain_anthropic import ChatAnthropic
from typing import TypedDict, Optional
class SQLAgentState(TypedDict):
user_request: str
generated_query: str
approved: bool
result: Optional[str]
llm = ChatAnthropic(model="claude-sonnet-4-6")
def generate_query_node(state: SQLAgentState):
"""LLM으로 SQL 쿼리 생성"""
response = llm.invoke(
f"다음 요청에 맞는 SQL 쿼리를 생성해주세요: {state['user_request']}"
)
return {"generated_query": response.content.strip()}
def review_node(state: SQLAgentState):
"""위험한 쿼리는 DBA 검토 요청"""
query = state["generated_query"]
dangerous_keywords = ["DELETE", "DROP", "UPDATE", "TRUNCATE"]
if any(kw in query.upper() for kw in dangerous_keywords):
# interrupt()를 절대 try/except로 감싸면 안 됩니다
# 내부적으로 예외를 던져서 실행을 멈추는 방식이라 잡아버리면 동작하지 않습니다
decision = interrupt({
"query": query,
"risk": "HIGH",
"tip": "영향받는 행 수를 먼저 SELECT로 확인해보시면 좋습니다"
})
return {"approved": decision == "approved"}
return {"approved": True}
def execute_node(state: SQLAgentState):
"""승인된 쿼리만 실행"""
if not state["approved"]:
return {"result": "쿼리가 거부되었습니다. 수정 후 다시 시도해주세요."}
return {"result": f"실행 완료: {state['generated_query']}"}
# 그래프 구성
builder = StateGraph(SQLAgentState)
builder.add_node("generate", generate_query_node)
builder.add_node("review", review_node)
builder.add_node("execute", execute_node)
builder.set_entry_point("generate")
builder.add_edge("generate", "review")
builder.add_edge("review", "execute")
builder.add_edge("execute", END)
# SQLite 체크포인터 (로컬 개발용 — 프로덕션은 AsyncPostgresSaver로 교체)
checkpointer = SqliteSaver.from_conn_string(":memory:")
graph = builder.compile(checkpointer=checkpointer)
# thread_id는 UUID로 생성하고 사용자 세션과 1:1 매핑하는 것을 권장합니다
# 재개 시 반드시 동일한 thread_id를 사용해야 합니다
import uuid
thread_id = str(uuid.uuid4())
config = {"configurable": {"thread_id": thread_id}}
result = graph.invoke({"user_request": "오래된 유저 정리"}, config=config)
# __interrupt__ 키가 있으면 사람의 판단이 필요한 상태
if "__interrupt__" in result:
print("승인 대기 중:", result["__interrupt__"])
# DBA가 승인하면 동일한 config(같은 thread_id!)로 재개합니다
final = graph.invoke(Command(resume="approved"), config=config)
print("최종 결과:", final["result"])| 코드 포인트 | 설명 |
|---|---|
interrupt({...}) |
실행을 멈추고 DBA에게 전달할 데이터를 JSON으로 전달 |
thread_id |
UUID 생성 후 사용자 세션과 매핑. 재개 시 반드시 동일한 값 사용 |
Command(resume="approved") |
문자열, dict, None 등 JSON-safe 값이면 모두 가능 |
SqliteSaver |
로컬 개발용. 프로덕션에서는 AsyncPostgresSaver로 전환 필요 |
예시 2: 여행 예약 에이전트 — 계획 확인 후에만 실제 예약 (State Editing 패턴)
에이전트가 항공편과 호텔을 조사한 뒤 사용자에게 플랜을 보여주고, 승인이 나야 실제 예약 API를 호출하는 패턴입니다. 승인 전에는 어떤 외부 호출도 발생하지 않는다는 게 핵심이에요.
from langgraph.types import interrupt, Command
from typing import TypedDict, Optional
class TravelState(TypedDict):
destination: str
travel_plan: Optional[dict]
booking_confirmed: bool
def research_node(state: TravelState):
"""항공편·호텔 조사 (읽기 전용 — 안전)"""
plan = {
"flight": "ICN → NRT, 2026-05-10, KRW 280,000",
"hotel": "신주쿠 그랜드 호텔, 3박 KRW 450,000",
"total": "KRW 730,000"
}
return {"travel_plan": plan}
def approval_node(state: TravelState):
"""사용자에게 플랜을 보여주고 승인·수정 대기"""
response = interrupt({
"message": "여행 플랜을 확인해 주세요. 수정하거나 승인해 주세요.",
"plan": state["travel_plan"],
"options": ["approve", "edit", "cancel"]
})
if response["action"] == "edit":
return {
"travel_plan": {**state["travel_plan"], **response["edits"]},
"booking_confirmed": False
}
elif response["action"] == "approve":
return {"booking_confirmed": True}
else:
return {"booking_confirmed": False}
def booking_node(state: TravelState):
"""승인된 플랜만 실제 예약 API 호출"""
if not state["booking_confirmed"]:
return {"result": "예약이 취소되었습니다."}
return {"result": f"예약 완료! {state['travel_plan']}"}예시 1과 달리 response가 딕셔너리 구조입니다. 재개 시에는 이렇게 Command를 넘깁니다.
# 사용자가 승인을 선택한 경우
final = graph.invoke(
Command(resume={"action": "approve"}),
config=config
)
# 사용자가 수정을 선택한 경우
final = graph.invoke(
Command(resume={"action": "edit", "edits": {"hotel": "시부야 호텔, 3박 KRW 380,000"}}),
config=config
)State Editing 패턴의 포인트:
interrupt()의 반환값으로 사용자 수정 내용을 받아서 상태에 직접 반영할 수 있습니다. 단순 승인/거부를 넘어 사람이 데이터를 편집하는 흐름도 자연스럽게 구현됩니다.
예시 3: 프로덕션 체크포인터 설정 (PostgreSQL)
로컬 개발은 SQLite로 충분하지만, 실제 서비스에서는 서버 재시작에도 끊기지 않는 영속 저장소가 필요합니다.
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
import os
import asyncio
async def setup_production_graph():
# 자격증명은 반드시 환경변수로 관리하세요. 코드에 하드코딩하면 안 됩니다
conn_string = os.environ.get("DATABASE_URL")
async with AsyncPostgresSaver.from_conn_string(conn_string) as checkpointer:
# 처음 한 번 테이블 생성
await checkpointer.setup()
graph = builder.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": "prod-task-42"}}
result = await graph.ainvoke(
{"user_request": "..."},
config=config
)
return result장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 안전성 향상 | 민감한 액션 실행 전 인간이 검증하므로 오류 비용을 크게 낮출 수 있습니다 |
| 동적 인터럽트 | 조건에 따라 선택적으로 개입 가능해 불필요한 승인 부담을 최소화합니다 |
| 상태 보존 | 체크포인트 덕분에 승인 대기 중 서버가 재시작돼도 중단 지점에서 재개됩니다 |
| 규제 대응 | EU AI Act Article 14 등 인간 감독 의무를 충족하는 구현 경로를 제공합니다 |
| 디버깅 용이 | 중간 상태를 체크포인트로 확인할 수 있어 에이전트 동작 추적이 쉬워집니다 |
단점 및 주의사항
이 표 안의 항목들은 실제로 운영 중에 마주쳤을 때 꽤 골치 아픈 것들입니다. 미리 알아두면 나중에 후회할 일이 훨씬 줄어듭니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 타임아웃 미지원 | 인터럽트된 스레드가 체크포인터에 무기한 잔류합니다 | 별도 스케줄러로 만료 로직을 구현하는 것을 강력히 추천합니다 |
| 감사 로그 부재 | 누가 언제 왜 승인/거부했는지 구조화된 레코드가 없습니다 | 규제 환경이라면 별도 audit log 시스템을 반드시 추가하세요 |
| 인프라 복잡도 | 프로덕션에서는 영속 체크포인터 설정과 분산 상태 관리 이해가 필요합니다 | 로컬은 SQLite, 프로덕션은 PostgreSQL로 단계적 도입이 가능합니다 |
| UX 설계 부담 | 승인 UI/채널(Slack, 웹 대시보드, 이메일)을 별도로 구축해야 합니다 | FastAPI + Next.js 템플릿이나 Streamlit으로 시작하는 것을 권장합니다 |
감사 로그(Audit Log)란? 누가 언제 어떤 판단을 내렸는지 시간순으로 기록한 이력 데이터입니다. 금융·의료·법률 등 규제 산업에서는 이 기록 자체가 컴플라이언스 요건입니다.
실무에서 가장 흔한 실수
-
interrupt()를try/except로 감싸는 것.interrupt()는 내부적으로 예외를 던져서 실행을 멈추는 메커니즘을 활용합니다.try/except로 잡아버리면 인터럽트 자체가 동작하지 않아서, 멈춰야 할 시점에 그냥 통과해버립니다. -
thread_id를 매 요청마다 새로 생성하는 것. 중단된 그래프를 재개하려면 반드시 동일한thread_id를 사용해야 합니다. 저도 처음에 이걸 몰라서 재개 시 새 ID를 만들었다가 처음부터 다시 실행되는 상황을 반복했습니다. UUID로 생성 후 사용자 세션과 1:1 매핑해서 관리하는 것을 권장합니다. -
interrupt()에 JSON-safe하지 않은 값을 전달하는 것.datetime, custom 객체,numpy배열 등은 직렬화에 실패합니다. 전달 전에dict/list/str/int/float/bool로 변환하는 과정이 필요합니다.
마치며
AI 에이전트가 강력해질수록, "어디서 사람이 개입할 것인가"를 설계하는 능력이 개발자의 핵심 역량이 됩니다.
처음엔 "그냥 에이전트가 다 알아서 하면 되지 않나?" 싶었는데, 실제로 민감한 액션이 자동 실행되는 상황을 한 번 경험하고 나면 HITL 설계의 중요성이 피부로 와닿습니다. LangGraph의 interrupt()는 그 개입 지점을 코드 레벨에서 정밀하게 제어할 수 있는 도구입니다.
지금 바로 시작해볼 수 있는 3단계:
-
pip install langgraph langgraph-checkpoint-sqlite로 설치하고 이 글의 SQL 에이전트 예시 코드를 로컬에서 직접 실행해보세요.SqliteSaver.from_conn_string(":memory:")로 별도 DB 없이 바로 테스트할 수 있습니다. -
기존 에이전트 코드에서 가장 불안했던 노드 하나를 골라
interrupt()를 붙여보시면 충분합니다. 전체를 바꾸지 않아도 됩니다. -
프로덕션을 고려한다면
AsyncPostgresSaver로 전환이 필수입니다. FastAPI + Next.js 기반의 승인 대시보드 템플릿도 참고해보시면 훨씬 빠르게 시작할 수 있습니다.
다음 글: LangGraph 멀티에이전트 아키텍처 — Supervisor 패턴으로 여러 에이전트를 오케스트레이션하고 책임을 추적하는 방법
참고 자료
- LangGraph 공식 문서 — Interrupts
- LangChain 공식 블로그 — Making it easier to build human-in-the-loop agents with interrupt
- LangChain Changelog — LangGraph v0.4: Working with Interrupts
- DEV Community — Interrupts and Commands in LangGraph: Building Human-in-the-Loop Workflows
- Towards Data Science — LangGraph 201: Adding Human Oversight to Your Deep Research Agent
- Towards Data Science — Building Human-In-The-Loop Agentic Workflows
- MarkTechPost — How to Build Human-in-the-Loop Plan-and-Execute AI Agents
- The Handover Blog — LangGraph Human in the Loop: A Complete Tutorial
- Elastic Search Labs — Human in the loop AI Agents with LangGraph & Elastic
- IBM Think — Oversee a prior art search AI agent with human-in-the-loop
- GitHub — langgraph-interrupt-workflow-template (FastAPI + Next.js)
- Towards AI — Human-in-the-Loop (HITL) with LangGraph: A Practical Guide
- Medium — LangGraph Breakpoints or Interrupt: How to Add Human-in-the-Loop Control
- SparkCo — Mastering LangGraph Checkpointing: Best Practices for 2025