LLMOps 23

Snapshot으로 실패 지점부터 재실행하기

Snapshot을 단순 로그로만 보면 재실행과 branch의 의미를 놓친다.

근거 · 교안 p70-p75

Snapshot으로 실패 지점부터 재실행하기 대표 이미지

1장: 로그 파일 vs. 저장 파일: 스냅샷의 진짜 정체

솔라는 노트북 화면에 떠 있는 긴 텍스트를 노려보며 미간을 찌푸렸다. 여러 에이전트가 협업하는 코드를 실행하다가 중간에 멈춘 참이었다. 실패 지점을 찾으려고 스냅샷(Snapshot)을 출력했는데, 화면에 가득한 건 그저 단계별 실행 기록의 나열처럼 보였다.

[
  ("action", {"messages": [...]}),
  ("tools", {"messages": [...]}),
  ("action", {"messages": [...]}),
  ...
]

“언니, 나 이거 도저히 모르겠어.”

거실 테이블에서 자신의 노트북으로 무언가 작업하던 루나가 고개를 들었다. 솔라가 노트북을 들고 와 테이블 한쪽에 내려놓았다.

“LangGraph 스냅샷이라는 건데, 이걸로 실패한 지점부터 다시 실행할 수 있대. 그런데 아무리 봐도 그냥 순서대로 뭐가 실행됐는지 보여주는 로그 파일이잖아. ‘1번 노드 실행, 결과는 이거. 2번 노드 실행, 결과는 저거.’ 이렇게.”

솔라는 화면을 손가락으로 가리켰다.

“디버깅할 때 변수 값 찍어보는 거랑 뭐가 달라? 이걸로 어떻게 ‘다시 시작’한다는 거야? 결국 그 단계에 썼던 입력값 가져와서 처음부터 다시 돌리는 거랑 똑같은 거 아니야?”

솔라의 말에는 그럴듯한 논리가 있었다. 실행 기록을 보고 문제가 된 단계를 찾아서, 그 단계의 입력으로 다시 코드를 실행하면 될 것 같았다. 하지만 그게 ‘이어하기’나 ‘복원’과는 거리가 멀어 보였다. 루나는 솔라의 화면을 잠시 들여다본 뒤, 시선을 들어 거실 한쪽의 책장을 향했다.

“솔라, 저기 선반에 있는 요리책 한 번 가져와볼래? 크림색 표지.”

뜬금없는 요청이었지만, 솔라는 잠시 머뭇거리다 자리에서 일어나 두꺼운 베이킹 책을 가져왔다. 루나는 책을 받아들고 아무 페이지나 펼쳤다. 복잡한 케이크 레시피였다.

“우리가 이 케이크를 만든다고 상상해 보자. 반죽하고, 크림 만들고, 오븐 예열하고… 딱 절반쯤 진행했는데, 결정적인 실수를 한 거야. 설탕 대신 소금을 넣었어.”

루나는 자신의 스마트폰을 들어 올렸다.

“우리한테 두 가지 기록 방식이 있어. 첫째, 이 요리책. 우리는 우리가 막 끝낸 단계, ‘가루류와 설탕을 섞는다’에 손가락을 짚고 있지. 이게 우리의 ‘로그’야.”

루나는 펼쳐진 책 페이지에 손가락을 올려놓았다.

“둘째, 이 스마트폰으로 찍은 사진. 우리가 실수한 바로 그 순간의 부엌 전체를 아주 상세하게 찍은 사진이야. 반쯤 섞인 반죽이 담긴 그릇, 그 옆에 열려 있는 소금통, 남은 재료들, 예열 중인 오븐의 온도까지 전부. 이게 ‘스냅샷’이고.”

루나는 솔라를 바라보며 물었다.

“자, 이제 실수를 바로잡고 이어서 케이크를 완성하고 싶어. 둘 중에 어느 쪽이 더 유용할까? 손가락으로 짚어둔 레시피? 아니면 부엌 전체를 찍은 사진?”

솔라는 잠시 생각에 잠겼다. 처음에는 레시피라고 생각했다. 다음 단계를 알려주니까.

“음… 레시피는 다음에 뭘 할지 알려주긴 하는데…”

솔라는 곧 자신의 생각의 허점을 발견했다.

“아니지. 레시피는 내가 소금을 넣었는지 설탕을 넣었는지 모르잖아. 그냥 다음 단계로 ‘계란을 넣고 섞으세요’라고만 하겠지. 이미 소금 범벅이 된 반죽에 계란을 넣는 건 아무 의미가 없어.”

그녀의 시선이 루나가 들고 있는 스마트폰으로 옮겨갔다.

“하지만 사진이라면… 사진에는 모든 게 다 담겨 있네. 어떤 그릇에 망친 반죽이 있는지, 소금통이 왜 열려 있는지, 다른 재료들은 어떤 상태인지 전부. 다시 시작하려면 망친 반죽이 든 그릇만 버리고, 사진 속 상태 바로 직전까지—깨끗한 그릇에 밀가루랑 다른 재료들을 준비한 상태—를 재현한 다음에, 이번엔 설탕을 제대로 넣고 다음 단계로 가면 되겠다.”

그 순간, 솔라의 얼굴에 깨달음이 스쳤다. 그녀는 다시 자신의 노트북 화면을 바라보았다. 아까는 무의미한 기록의 나열로 보였던 것들이 다르게 읽히기 시작했다.

“아! 그럼 스냅샷은 단순히 ‘이 함수가 실행됐다’는 텍스트 기록이 아니구나. 그 순간의 모든 변수, 주고받은 메시지, 중간 결과값, 다음에 어느 노드로 가야 할지에 대한 정보까지, 시스템 전체를 통째로 ‘얼려놓은’ 거구나! 마치 게임 저장 파일처럼!”

요리책의 지시사항(로그)은 과거에 일어난 사건의 목록일 뿐, 그 사건들이 만들어낸 현재 상태를 담고 있지 않았다. 반면, 부엌 사진(스냅샷)은 특정 시점의 상태 그 자체였다. 그래서 사진을 보고는 중간부터 ‘복원’하는 게 가능했다.

“그래. 스냅샷은 단순한 로그가 아니라, 복원 가능한 ‘상태’인 거지.”

루나가 솔라의 깨달음을 확인해주었다. 이제 솔라는 스냅샷이 단순한 기록이 아니라, 언제든 돌아가서 이어할 수 있는 체크포인트라는 개념을 확실히 이해했다. 하지만 새로운 의문이 곧바로 고개를 들었다.

“언니 말대로 스냅샷이 게임 저장 파일 같은 거라는 건 알겠어. 그런데 게임에선 ‘불러오기’ 버튼을 누르면 되잖아. 코드에서는… graph.invoke(None, config=...) 이게 그 ‘불러오기’ 버튼이라는 건데. 여기서 None은 대체 무슨 뜻이지? 새로운 입력을 안 준다는 건가? 그럼 아무것도 실행이 안 돼야 하는 거 아니야?“

2장: None의 비밀: 체크포인트에서 이어하기

다음 날 오후, 솔라는 루나가 조용히 손짓하는 소리에 거실 테이블로 다가갔다. 어제 펼쳐두었던 요리책은 얌전히 닫혀 있었고, 그 자리에는 루나의 노트북이 놓여 있었다. 화면에는 짧은 파이썬 코드가 떠 있었다. 복잡한 에이전트 코드가 아닌, A, B, C 세 개의 간단한 노드로 이루어진 그래프였다.

루나는 이미 무언가 작업을 마친 듯했다. 화면 한쪽에는 ‘A 노드 실행’, ‘B 노드 실행’이라는 두 줄의 로그가 찍혀 있었고, 그 아래 커서는 깜빡이고 있었다. 루나는 코드의 특정 부분을 손가락으로 가리켰다.

# A -> B -> C 순서로 실행되는 그래프
# 중간에 실행을 멈추고 B 노드까지의 상태를 체크포인트로 저장함
resume_config = {"configurable": {"thread_id": "1", "checkpoint_id": "some_checkpoint_id"}}

# 이제 이 체크포인트로 다시 실행을 시작하려고 함
final_result = app.invoke(None, config=resume_config)

어제 솔라가 마지막에 던졌던 질문의 핵심, app.invoke(None, ...) 구문이 화면 위에서 그녀를 기다리고 있었다.

“어제 네가 한 질문 말이야. 게임에선 ‘불러오기’ 버튼이 있는데 코드에선 None이 무슨 역할을 하냐고.”

루나가 말했다. 솔라는 고개를 끄덕이며 화면을 들여다봤다.

“응. invoke 함수는 보통 첫 번째 자리에 실행에 필요한 입력값을 넣어주잖아. 그런데 저렇게 None을 넣으면… 입력을 안 주겠다는 뜻 아닌가? 그럼 그래프가 뭘 가지고 일을 시작해야 할지 모르니까, 아무것도 실행 안 되거나 에러가 날 것 같아. 아니면 혹시… 처음부터 다시 실행되려나?”

솔라는 가능한 시나리오를 머릿속에 그려보았다. None이라는 단어가 주는 ‘없음’의 이미지는 긍정적인 결과로 이어지지 않았다.

“좋은 추측이야. 그럼 우리, 저 마지막 줄을 직접 실행해 보고 어떤 일이 일어나는지 관찰해 볼까?”

루나는 솔라에게 키보드를 살짝 밀어주었다.

“대신 그냥 결과만 보지 말고, 예상과 다른 점이 있는지, 특히 출력 화면에 무엇이 나타나지 않는지 집중해서 봐줘.”

솔라는 잠시 망설이다 엔터 키를 눌렀다. 잠시 후, 화면에 새로운 출력이 나타났다. 하지만 그건 솔라가 예상했던 긴 실행 로그가 아니었다.

C 노드 실행

달랑 한 줄이었다. 솔라는 눈을 동그랗게 떴다. 에러도 없었고, 아무 일도 일어나지 않은 것도 아니었다. 그렇다고 A, B, C가 모두 실행된 것도 아니었다. 그래프의 시작인 ‘A 노드’도, 중간인 ‘B 노드’도 보이지 않았다. 오직 마지막 ‘C 노드’만 실행된 것이다.

“어…?”

솔라는 혼란스러운 표정으로 화면과 코드를 번갈아 보았다. ‘A 노드 실행’, ‘B 노드 실행’은 이전에 실행했던 기록이고, 방금 엔터 키를 눌러 실행된 것은 오직 ‘C 노드 실행’ 뿐이었다. 마치 누군가 A와 B 단계를 건너뛰고 C 단계의 플레이 버튼만 누른 것 같았다.

그 순간, ‘무엇이 나타나지 않는지’ 보라던 루나의 말이 머릿속을 스쳤다. 나타나지 않은 것: A와 B. 나타난 것: C. 그리고 그들을 연결하는 단서, resume_config.

“아!”

솔라의 입에서 짧은 탄성이 터져 나왔다.

“알겠다! invoke 함수는 두 개의 시작 버튼을 가진 거나 마찬가지구나! 첫 번째 버튼은 ‘새로운 입력값’이고, 두 번째 버튼은 config에 담긴 ‘체크포인트’인 거야.”

그녀는 흥분하며 말을 이었다.

“평소에는 첫 번째 버튼, 즉 입력값을 사용해서 그래프를 처음부터 실행시키지만… 만약 거길 None으로 비워두면, 시스템은 ‘아, 여긴 비었네. 그럼 두 번째 버튼을 확인해볼까?’ 하고 config를 확인하는 거지. 그리고 거기에 유효한 체크포인트가 있으면, 새로운 입력으로 시작하는 대신 그 저장된 상태를 그대로 불러와서 다음 단계부터 실행을 이어가는 거였어! None은 ‘실행하지 마’가 아니라 ‘새로운 시작이 아니니, 저장된 곳에서 이어가’라는 신호였던 거야!”

None은 무(無)가 아니라, 실행의 제어권을 입력값이 아닌 체크포인트에게 넘기는 스위치였다. 이제 invoke(None, config=...) 구문은 더 이상 미스터리가 아니었다. 그것은 LangGraph 세계의 ‘이어하기(Continue)’ 버튼이었다.

“정확해.”

루나가 미소 지으며 고개를 끄덕였다. 솔라는 자신이 방금 깨달은 사실에 감탄하며 화면을 바라보았다. 정말 게임 저장 파일을 불러온 것처럼, 멈췄던 바로 그 지점에서 실행이 깔끔하게 재개되었다.

“신기하다. 진짜 게임 불러오기 같네.”

잠시 감탄하던 솔라의 표정에 다시 새로운 궁금증이 떠올랐다.

“그런데 언니, 이상한 점이 있어. 이렇게 똑같은 저장 지점에서 불러오면, 그 뒤에 일어나는 일도 계속 똑같을 거 아니야? B 다음에는 항상 C가 실행되는 것처럼. 이걸로 어떻게 버그를 잡거나 여러 가지 다른 가능성을 분석한다는 거야? 하나의 과거에서는 하나의 미래만 나오는 거 아니었어?“

3장: 하나의 과거, 여러 개의 미래: 브랜칭 분석

루나의 노트북 화면에는 어제 솔라가 마지막으로 보았던 그 코드가 그대로 떠 있었다. A, B, C 세 개의 노드로 이어진 간단한 그래프. 화면 한쪽의 터미널에는 이미 실행이 끝난 결과가 보였다.

C 노드 실행

루나는 그 상태에서 아무것도 바꾸지 않고, 어제 솔라가 마지막으로 눌렀던 app.invoke(None, config=resume_config) 코드를 다시 실행했다. 결과는 예상 그대로였다.

C 노드 실행
C 노드 실행

터미널에 똑같은 결과가 한 줄 더 찍혔다. 솔라는 그 모습을 보며 팔짱을 꼈다. 바로 이것이 그녀가 가진 의문의 핵심이었다.

“봐, 언니. 내 말이 맞지? 똑같은 저장 지점(checkpoint_id)에서 invoke를 실행하면, 결과는 계속 똑같을 수밖에 없어. B 노드까지의 상태를 불러왔으니, 그 다음은 무조건 C 노드가 실행되는 거잖아. 이걸로는 아무것도 분석할 수 없어. 하나의 과거에서는 하나의 미래만 나올 뿐이야.”

솔라의 지적은 타당했다. 동일한 원인에서는 동일한 결과가 나오는 것이 당연했다. 버그를 잡으려면 무언가 다른 시도를 해야 하는데, 체크포인트는 그저 과거를 똑같이 반복할 뿐인 것처럼 보였다.

루나는 솔라의 말에 대답하는 대신, 키보드에 손을 가져갔다. 그녀는 invoke를 실행하는 코드는 그대로 둔 채, 스크롤을 위로 올려 그래프의 노드를 정의하는 부분을 수정하기 시작했다. C 노드의 내용을 살짝 바꾸는 것이었다.

# 기존 C 노드
# def c_node(state):
#     print("C 노드 실행")
#     return {"result": state["result"] + "C"}

# 수정된 C 노드
def c_node(state):
    print("C-대안 노드 실행") # 출력 메시지 변경
    # 다른 로직을 추가하거나 변경할 수 있음
    return {"result": state["result"] + "C-alternative"}

루나는 C 노드의 출력 메시지와 반환값을 약간 바꿨을 뿐, 그래프의 구조(A → B → C)나 어제 저장해 둔 체크포인트(resume_config)는 전혀 건드리지 않았다. 그리고는 다시 스크롤을 내려 app.invoke(None, config=resume_config) 라인에 커서를 옮겼다.

“자, 이제 다시 한번 실행해 볼게. 똑같은 체크포인트에서.”

루나가 엔터 키를 눌렀다. 잠시 후, 터미널 화면에 새로운 결과가 나타났다. 솔라는 자신의 눈을 의심했다.

C 노드 실행
C 노드 실행
C-대안 노드 실행

세 번째 실행 결과는 앞선 두 번과 명백히 달랐다. 똑같은 B 노드 시점의 체크포인트를 불러왔는데도, 완전히 다른 결과가 출력된 것이다. 솔라는 혼란스러운 표정으로 터미널의 세 줄짜리 출력과 루나가 방금 수정한 코드를 번갈아 쳐다봤다.

분명히 세 번의 실행 모두 동일한 resume_config를 사용했다. 즉, ‘과거’는 완전히 같았다. 하지만 세 번째 실행의 ‘미래’는 달랐다. 왜?

그 순간, 솔라의 머릿속에서 모든 조각이 맞춰졌다.

“아…! 알겠다!”

솔라는 손뼉을 탁 쳤다.

“체크포인트는 게임 저장 파일처럼 과거의 ‘상태’를 불러오는 게 맞아. 하지만 그 상태를 이어받아서 앞으로 무슨 일이 일어날지는, 지금 현재의 ‘게임 규칙’, 즉 현재 실행되는 ‘코드’에 따라 결정되는 거였어!”

그녀의 목소리가 흥분으로 살짝 높아졌다.

“우리가 같은 저장 지점에서 게임을 불러와도, 그 사이에 게임 제작사가 패치를 해서 캐릭터의 스킬이 바뀌었다면 다른 플레이를 할 수 있잖아. 이것도 똑같은 거네! 과거의 상태(B 노드까지의 결과)는 고정되어 있지만, 그 상태를 넘겨받을 C 노드의 코드를 내가 지금 바꿨으니까, 당연히 다른 결과가 나오는 거야. 이게 바로 ‘브랜치(branch)‘구나! 하나의 과거에서 여러 갈래의 미래를 만들어내는 것!”

같은 지점에서 이어하면 같은 결과만 반복될 거라는 생각은, ‘실행 환경’, 즉 코드 자체가 바뀔 수 있다는 사실을 놓친 착각이었다. 체크포인트는 과거를 불러오는 타임머신이지만, 그 타임머신이 도착한 현재는 얼마든지 바꿀 수 있었다.

“이제야 알겠어. 스냅샷은 단순히 과거를 재현하는 게 아니라, ‘만약 그때 다른 선택을 했다면?‘이라는 질문을 던질 수 있는 실험대였네.”

솔라는 더 이상 루나의 노트북을 보고 있지 않았다. 그녀의 시선은 자신의 노트북, 복잡한 에이전트 코드의 에러 메시지가 떠 있는 화면으로 향해 있었다. 막막하게만 보였던 문제가 이제는 풀어볼 만한 퍼즐로 보였다.

“좋았어. 그럼 내 코드에서 에러가 나기 직전 단계의 체크포인트를 딱 잡고, 첫 번째 실행에서는 의심스러운 프롬프트를 조금 수정해서 돌려보고, 그래도 안 되면… 똑같은 체크포인트에서 다시 시작해서 아예 다른 도구를 쓰도록 코드를 바꿔서 실행해보는 거야. 어떤 길이 올바른 결과로 이어지는지, 두 개의 다른 미래를 비교해보면 되겠다.”

솔라는 자신의 노트북을 끌어당기며 중얼거렸다. 더 이상 스냅샷은 단순한 기록의 나열이 아니었다. 그것은 실패한 과거를 디버깅하고 더 나은 미래를 설계할 수 있는, 강력한 분기점이자 분석 도구였다.