이번 시간에는 LangGraph 에이전트를 승인 대기 중과 메일 전송 직후에 일부러 끝냈다가 다시 시작해 보면서, 메일이 몇 번 나가는지를 SQLite 체크포인터와 가짜 메일 서버로 측정하며 배워가는 시간입니다.
예를 들어서 글이 좋아요 42개를 넘기면 작성자에게 알림 메일을 보내는 에이전트가 있다고 해볼게요. 에이전트는 글 정보를 조회하고, LLM으로 메일 초안을 쓰고, 담당자가 초안을 확인해 승인하면 메일을 보냅니다.
그런데 승인을 기다리는 동안 배포가 나가서 프로세스가 재시작됐다면 어떻게 될까요? 다시 띄운 프로세스가 이어서 메일을 보낼 때, 작성자가 같은 메일을 두 번 받지는 않을까요?
LangGraph는 실행 상태를 체크포인트로 저장해 두고, interrupt로 멈춘 지점에서 Command(resume=...)로 다시 시작하는 방식을 제공합니다. 메일이 몇 번 나갔는지는 가짜 메일 서버의 수신 로그로 셉니다.
그래프 모양: 조회, 초안, 승인 대기, 전송
그래프는 노드 다섯 개로 이뤄져 있어요.
fetch: 글 정보와 좋아요 수를 읽습니다.draft: Ollama의 로컬 모델로 메일 초안을 씁니다. 같은 메일인지 알아보는 멱등 키도 여기서 만들어요.approve:interrupt()를 불러 그래프를 멈추고 사람 승인을 기다립니다.send: 가짜 메일 서버로 메일을 보냅니다. 이 그래프에서 되돌릴 수 없는 단계는 이것 하나뿐이에요.audit: 발송 기록을 남깁니다.
그래프를 실행할 때 config에 thread_id를 넣으면, 체크포인터는 단계가 끝날 때마다 그 thread의 상태를 저장합니다.
interrupt가 불리면 그래프는 그 자리에서 멈추고 호출한 쪽으로 돌아와요. 나중에 같은 thread_id로 Command(resume=값)을 넘기면, 멈췄던 interrupt()가 그 값을 돌려주면서 이어집니다.
실습 준비
이 글의 명령은 모두 실습 코드의 agent-approval-resume/ 디렉터리에서 실행합니다.
requirements.txt: 그래프 이미지에 설치할 패키지 버전 3개Dockerfile:python:3.12-slim에 패키지를 설치한 그래프 실행 이미지compose.yaml:ollama·mail·agent서비스 3개와 볼륨 3개mail_server.py: 받은 메일을 수신 로그에 남기는 가짜 메일 서버(IDEMPOTENT=1이면 멱등 모드)agent.py: 조회·초안·승인 대기·전송·발송 기록 그래프와 시작·승인·재개 명령inbox.py: 수신 로그를 한 줄에 한 건씩 찍고 전송 요청 수와 전달 수를 세는 스크립트run.sh: 그래프 실행·수신 로그 확인·초기화를 묶은 셸 함수 5개
compose 파일(compose.yaml)에는 서비스가 세 개 들어 있어요. 프로젝트 이름은 ahitl입니다.
ollama: 초안을 쓰는 로컬 모델 서버입니다. Docker Desktop(macOS) 안에서는 GPU를 쓸 수 없어서 CPU로만 추론합니다.mail: 메일 서버를 흉내 낸 서비스예요. 실제 메일은 보내지 않고 받은 요청을 수신 로그에 남깁니다.agent: 그래프를 돌리는 프로세스입니다. 실행할 때마다 새 컨테이너로 띄우고 끝나면 지워요. 체크포인트 SQLite 파일은state볼륨의/state/checkpoints.sqlite에 있어서 컨테이너가 바뀌어도 남습니다.
초안 모델은 qwen3:1.7b를 씁니다. 이 그래프에서 LLM은 초안을 한 번 쓰는 데만 쓰이고, 메일이 몇 번 나가는지는 모델 성능과 상관이 없어요.
메일 서버(mail_server.py)는 멱등 모드가 아니면 받은 메일을 모두 delivered로 기록해요. 멱등 모드에서는 이미 전달한 멱등 키가 다시 오면 duplicate_ignored로 기록만 하고, 전달한 것으로 치지 않습니다.
그래프 코드는 agent.py예요. 환경 변수 네 개(GRAPH, SAVER, DURABILITY, LAB_DIE_AT)로 그래프 구성과 저장 방식, 죽는 지점을 바꿀 수 있습니다. 이 글에서는 LAB_DIE_AT만 바꾸고, 나머지는 출력 첫 줄에 찍히는 기본값(GRAPH=g1 SAVER=sqlite DURABILITY=async)으로 둡니다.
바깥에서 kill -9를 보내면 신호가 도착하는 순간이 매번 달라서 같은 지점에서 죽었는지 확신할 수가 없어요. 그래서 죽을 지점을 코드에 박아 두고, LAB_DIE_AT에 그 이름을 주면 그 줄에서 os._exit(137)로 끝나게 했습니다. os._exit는 finally 블록이나 종료 처리기를 실행하지 않아서, 프로세스가 강제로 끝날 때와 같은 조건이죠.
waiting:interrupt로 멈추고 승인을 기다리는 중after_send:send노드가 메일을 보낸 직후, 노드가 끝나기 전
멱등 키는 수신자, 글 slug, 한국 시간 기준 날짜를 이어 붙여 SHA-256으로 만든 값의 앞 16자입니다. 입력이 같으면 몇 번을 다시 만들어도 같은 키가 나와요.
명령은 세 가지예요.
run <thread>: 새로 시작합니다.interrupt로 멈추면 승인 파일(/state/approvals/<thread>)이 생길 때까지 기다렸다가, 그 내용을Command(resume=...)로 넘겨 이어가요.approve <thread>: 승인 파일을 씁니다. 사람이 승인 버튼을 누르는 동작을 흉내 낸 거예요.resume <thread>: 새 프로세스에서 체크포인트를 읽어 이어갑니다. 대기 중인interrupt가 있으면 승인 값을 넘기고, 멈춘 단계가 있으면 입력 없이(None) 다시 실행해요.
셸 함수는 run.sh에 모아 두었어요. zsh에서 source run.sh로 불러 쓰시면 됩니다.
ag는agent서비스를 새 컨테이너로 띄워python agent.py에 인자를 넘기고, 끝나면 지워요.inbox는 메일 서버의GET /inbox응답을inbox.py로 넘기고,clear_inbox는DELETE /inbox로 수신 로그를 비웁니다.reset_state는/state아래의 체크포인트 DB와 승인 기록을 지워요.run_approve는 그래프를 뒤에서 띄우고, 로그에 "승인 대기 중"이 찍히면 다른 컨테이너에서 승인을 기록합니다. 그래서 출력에는 승인 기록이 먼저 찍히고, 그래프 로그는 끝난 뒤 한꺼번에 나와요.
환경 변수는 LAB_DIE_AT=waiting ag run t2처럼 함수 앞에 붙여 주시면 됩니다.
이제 서비스를 띄우고 모델을 받아 볼게요.
source run.sh
mkdir -p out
docker compose up -d ollama mail
docker compose exec ollama ollama pull qwen3:1.7b
docker compose build agent
실습 1: 승인 대기 중에 죽으면
승인을 기다리는 동안 프로세스가 죽는 경우예요. 전송을 승인 뒤 별도 노드(send)에 둔 그래프입니다.
clear_inbox
LAB_DIE_AT=waiting ag run t2
ag approve t2
ag resume t2
inbox
[a9c4] run thread=t2 GRAPH=g1 SAVER=sqlite DURABILITY=async LAB_DIE_AT=waiting
[a9c4] fetch: agent-code-sandbox 좋아요 42
[a9c4] 단계 완료: fetch
[a9c4] draft: 4.7초, 출력 36토큰, 멱등 키 1f9ea28075e08a29
[a9c4] 단계 완료: draft
[a9c4] approve: 노드 시작(interrupt 앞 코드)
[a9c4] 그래프 멈춤: interrupt (수신자 hong@example.com)
[a9c4] 초안: LLM이 만든 코드를 컨테이너에서 돌리면 안전할까? 글의 좋아요 수는 42개가 되었습니다.
[a9c4] 승인 대기 중
[a9c4] LAB_DIE_AT=waiting: 프로세스 종료
t2: 승인 기록
[e770] resume thread=t2 GRAPH=g1 SAVER=sqlite DURABILITY=async LAB_DIE_AT=-
[e770] 저장된 상태: 다음 단계=('approve',), sent=None, 대기 중인 interrupt 1개
[e770] 승인 대기 중
[e770] 승인 확인: approve
[e770] approve: 노드 시작(interrupt 앞 코드)
[e770] approve: interrupt가 'approve'를 돌려줌
[e770] 단계 완료: approve
[e770] send: 메일 전송 -> delivered (수신 로그 1번)
[e770] 단계 완료: send
[e770] audit: 발송 기록 저장
[e770] 단계 완료: audit
[e770] 끝: sent=True
1 1f9ea28075e08a29 hong@example.com 좋아요 알림 delivered
전송 요청 1 건, 실제 전달 1 건
[a9c4] 프로세스는 승인을 기다리다 죽었고, 새 컨테이너의 [e770] 프로세스가 SQLite 파일에서 상태를 읽었어요. 다음 단계는 approve, 대기 중인 interrupt는 1개입니다.
승인 값을 넘기자 send로 넘어갔고, 메일은 1건만 나갔어요. 승인 대기 중에는 아직 아무것도 보내지 않았으니까 여기서 죽어도 잃을 게 없는 거죠.
눈여겨볼 건 approve: 노드 시작(interrupt 앞 코드)가 두 번 찍혔다는 점이에요. 재개는 interrupt()를 부른 줄에서 이어지지 않고, approve 노드의 첫 줄부터 다시 시작하기 때문이에요.
반대로 이미 끝난 노드는 다시 돌지 않았습니다. 재개 뒤에 fetch와 draft 로그는 다시 나오지 않았고 LLM도 다시 부르지 않았죠. 그래서 LangGraph 문서는 interrupt 앞에 두는 부수 효과는 멱등이어야 한다고 경고하고 있어요.
실습 2: 전송 직후, 체크포인트 전에 죽으면
send는 메일을 보내고 {"sent": True}를 돌려줍니다. 그런데 메일을 보낸 줄과 그 결과가 체크포인트에 저장되는 순간 사이에는 틈이 있어요.
메일을 보낸 직후, send 노드가 끝나기 전에 죽여 볼게요.
clear_inbox
LAB_DIE_AT=after_send run_approve t5
ag resume t5
inbox
t5: 승인 기록
[5f88] run thread=t5 GRAPH=g1 SAVER=sqlite DURABILITY=async LAB_DIE_AT=after_send
[5f88] fetch: agent-code-sandbox 좋아요 42
[5f88] 단계 완료: fetch
[5f88] draft: 3.7초, 출력 36토큰, 멱등 키 1f9ea28075e08a29
[5f88] 단계 완료: draft
[5f88] approve: 노드 시작(interrupt 앞 코드)
[5f88] 그래프 멈춤: interrupt (수신자 hong@example.com)
[5f88] 초안: LLM이 만든 코드를 컨테이너에서 돌리면 안전할까? 글의 좋아요 수는 42개가 되었습니다.
[5f88] 승인 대기 중
[5f88] 승인 확인: approve
[5f88] approve: 노드 시작(interrupt 앞 코드)
[5f88] approve: interrupt가 'approve'를 돌려줌
[5f88] 단계 완료: approve
[5f88] send: 메일 전송 -> delivered (수신 로그 1번)
[5f88] LAB_DIE_AT=after_send: 프로세스 종료
[84b2] resume thread=t5 GRAPH=g1 SAVER=sqlite DURABILITY=async LAB_DIE_AT=-
[84b2] 저장된 상태: 다음 단계=('send',), sent=False, 대기 중인 interrupt 0개
[84b2] 멈춘 단계부터 다시 실행
[84b2] send: 메일 전송 -> delivered (수신 로그 2번)
[84b2] 단계 완료: send
[84b2] audit: 발송 기록 저장
[84b2] 단계 완료: audit
[84b2] 끝: sent=True
1 1f9ea28075e08a29 hong@example.com 좋아요 알림 delivered
2 1f9ea28075e08a29 hong@example.com 좋아요 알림 delivered
전송 요청 2 건, 실제 전달 2 건
메일은 이미 나갔지만 send 노드는 값을 돌려주기 전에 죽었어요. 그러면 LangGraph가 아는 마지막 상태는 "send를 실행할 차례, sent=False"입니다. 새 프로세스는 send를 다시 실행했고, 작성자는 같은 메일을 두 번 받았어요.
메일 전송 바로 다음 줄에서 상태를 바꿔도 소용이 없어요. 상태는 노드가 값을 돌려준 뒤에야 저장되기 때문입니다.
체크포인트를 저장하는 시점은 durability 인자로 고를 수 있어요. sync는 한 단계의 결과를 저장한 뒤에 다음 단계를 시작하고, 기본값 async는 저장을 백그라운드 스레드에 맡기고 바로 다음 단계를 시작하고, exit는 그래프가 끝나거나 interrupt로 멈춰 빠져나갈 때만 저장합니다.
DURABILITY만 바꿔 같은 실행을 반복한 기록(명령은 싣지 않았어요)에서도 결과는 같았습니다(방식마다 3번 모두 2건). 노드 안에서 메일을 보낸 줄과 노드가 값을 돌려주는 줄 사이의 틈은 어떤 저장 방식으로도 없앨 수 없습니다.
실습 3: 멱등 키로 막기
이 틈은 그래프 바깥에서 막아야 해요. 같은 메일에는 늘 같은 멱등 키를 붙이고, 받는 쪽이 이미 처리한 키는 거르게 하는 거죠.
메일 서버를 멱등 모드로 다시 띄우고, 실습 2를 그대로 반복해 볼게요. 수신 로그만 옮겼어요(발췌).
IDEMPOTENT=1 docker compose up -d mail
clear_inbox
LAB_DIE_AT=after_send run_approve t11
ag resume t11
inbox
1 1f9ea28075e08a29 hong@example.com 좋아요 알림 delivered
2 1f9ea28075e08a29 hong@example.com 좋아요 알림 duplicate_ignored
전송 요청 2 건, 실제 전달 1 건
재개가 send를 다시 실행해 전송 요청은 2건이 됐지만, 두 번째 요청은 draft가 처음에 만든 키 1f9ea28075e08a29를 그대로 들고 왔어요. 메일 서버는 그 키를 이미 전달했으니 duplicate_ignored로 기록만 했고, 실제로 전달된 메일은 1건입니다.
키는 체크포인트에 저장된 상태에서 꺼내 쓰기 때문에, 재개한 프로세스가 키를 새로 만들지 않아요.
체크포인터는 끝난 노드를 다시 돌리지 않게 해서 재실행 횟수를 줄여 줘요. 멱등 키는 그래도 생기는 재실행이 결과를 바꾸지 못하게 해 주고요. 그래서 둘을 함께 씁니다.
정리
범위: langgraph 1.2.12, langgraph-checkpoint-sqlite 3.1.1, 초안 모델 qwen3:1.7b(Ollama 0.34.4), 시나리오마다 3번 반복.
- 전송을 승인 뒤 별도 노드에 둔 그래프는 승인 대기 중에 죽어도 끝난 노드를 다시 실행하지 않았고, 메일은 1건이었습니다. 되돌릴 수 없는 작업은 승인 뒤 별도 노드로 빼 주세요.
- 재개하면
interrupt를 부른 노드가 처음부터 다시 실행돼서approve: 노드 시작(interrupt 앞 코드)가 두 번 찍혔습니다. 되돌릴 수 없는 작업은interrupt를 부르는 노드에 두지 마세요. - 메일을 보낸 직후 노드 안에서 죽으면
sync,async,exit모두 재개가send를 다시 실행해 2건이 됐습니다. 이 틈은 저장 방식으로 막을 수 없으니 그래프 바깥에서 막아 주세요. - 수신 쪽이 같은 멱등 키를 거르게 하자 전송 요청이 2건이어도 실제 전달은 1건이었습니다. 멱등 키는 수신자, 글, 날짜처럼 입력에서 정해지는 값으로 만들어 상태에 저장하고, 전송 때마다 UUID를 새로 만들지 마세요.