LLMOps 22
LangGraph Snapshot: 최신 상태와 과거 이력 들여다보기
Snapshot이 개념은 알겠는데 실제로 어디서 꺼내 보는지 헷갈린다.
근거 · 교안 p65-p69
1장: MemorySaver: Snapshot이 어디에 ‘담기는’ 걸까?
솔라의 책상 위, 모니터 한구석에 떠 있는 코드 조각과 그 아래에 적어둔 한 줄짜리 메모가 반짝였다. 솔라는 턱을 괸 채 그 문장을 노려보았다.
MemorySaver: State를 저장하기 위한 메모리
몇 번을 다시 읽어도 답답함은 가시지 않았다. ‘저장소’라는 단어는 너무나 당연해서 오히려 아무런 정보도 주지 못하는 것 같았다. LangGraph 코드를 실행할 때마다 상태(State)가 변하고, 그 순간들이 ‘스냅샷’으로 찍힌다는 것까지는 알겠다. 하지만 그 스냅샷들은 대체 어디에, 어떤 모양으로 쌓여가는 걸까? 눈에 보이지 않는 데이터의 흐름을 상상하려니 머릿속이 뿌옇게 흐려졌다.
“언니.”
나란히 앉아 자신의 노트북 화면을 보던 루나를 향해 솔라가 불쑥 말을 걸었다.
“LangGraph에서 스냅샷을 보려면 get_state나 get_state_history를 쓰잖아. 근데 그 정보들이 원래 어디에 들어있는 건지 모르겠어. 강의 노트에는 그냥 ‘MemorySaver가 State를 저장한다’고만 되어 있는데, 그럼 이게 그냥 모든 걸 담아두는 커다란 바구니 같은 건가? 바구니라면 어디서 어떻게 꺼내야 할지 알아야 할 텐데.”
솔라는 마치 보이지 않는 벽에 부딪힌 사람처럼 손바닥으로 허공을 더듬었다.
“스냅샷이라는 개념은 알겠는데, 막상 그걸 꺼내 보려고 하니 어디에 손을 뻗어야 할지 모르겠어. 그냥 ‘전체 기록’이라서 뭘 쓰든 비슷한 게 나올 것 같기도 하고….”
루나는 잠시 솔라의 화면을 들여다보더니, 말없이 책상 위에 놓여있던 투명한 아크릴 상자를 집어 들었다. 안에는 각양각색의 클립들이 들어 있었다. 루나는 상자를 가볍게 흔들어 내용물을 뒤섞더니 솔라 앞에 내려놓았다.
“이게 MemorySaver라고 해보자, 솔라.”
“메모리 세이버?”
“응. 그리고 네가 LangGraph에서 노드를 하나 실행할 때마다, 그 결과로 생긴 스냅샷이 이 안에 들어가는 클립 하나라고 상상해봐.”
루나는 빨간색 클립 하나를 집어 상자 안에 툭, 떨어뜨렸다. 곧이어 파란색 클립도 하나 넣었다. 솔라는 루나의 손과 상자를 번갈아 보았다.
“그럼 노드를 실행할 때마다 클립이 하나씩 계속 쌓이는 거네? 간단하잖아.”
솔라가 상자를 들어 흔들어 보이며 말했다.
“그럼 그냥 이 안에 있는 클립들을 시간 순서대로 보면 그게 전체 이력이겠네. 마지막에 넣은 게 맨 위에 있을 거고.”
“거의 맞아.”
루나가 고개를 끄덕였다.
“하지만 중요한 차이점이 하나 있어. 이 상자는 그냥 아무렇게나 열 수 있는 상자가 아니야. 내용물을 꺼내 볼 수 있는 창구가 딱 두 개만 정해져 있어.”
루나는 아크릴 상자의 한쪽 면을 손가락으로 가리켰다.
“이쪽 창구는 아주 작아서, 상자 맨 위에 있는 클립 딱 하나만 보여줘. 방금 마지막으로 넣은 것. 이게 그래프의 ‘최신 상태’를 보는 창구야.”
그리고는 상자를 빙글 돌려 반대편을 가리켰다.
“그리고 이쪽 창구는 더 커. 상자 안의 모든 클립을 전부 보여주지. 그런데 그냥 보여주는 게 아니라, 가장 최근에 들어온 것부터 순서대로 줄을 세워서 보여줘. 이게 ‘과거 이력’ 전체를 훑어보는 창구고.”
솔라의 눈이 동그래졌다. 상자 안에 뒤죽박죽 섞여 있는 클립들과, 그것을 정해진 방식으로만 볼 수 있다는 루나의 설명이 머릿속에서 연결되기 시작했다.
“아…!”
솔라는 작은 탄성을 내뱉었다.
“그러니까 MemorySaver는 그냥 ‘저장소’라는 사실보다, 그걸 ‘어떻게 꺼내 보는지’가 훨씬 중요한 거구나! 나는 스냅샷들이 그냥 한곳에 쌓여있으니 get_state든 get_state_history든 비슷한 걸 꺼내 오는 줄 알았어.”
솔라는 고개를 끄덕이며 아크릴 상자를 다시 바라보았다. 이제는 그냥 투명한 플라스틱 통이 아니라, 정교하게 설계된 데이터 보관함처럼 보였다.
“알겠다. MemorySaver는 스냅샷들이 담기는 기본 ‘통’이고, 우리는 그 통에 뚫린 각기 다른 창구, 즉 get_state나 get_state_history 같은 메서드를 통해 서로 다른 관점의 정보를 얻는 거네.”
흐릿했던 그림이 선명해지는 느낌이었다. 스냅샷을 ‘꺼내 본다’는 행위의 첫 단추가 끼워졌다. 이제 저장소가 어떻게 생겼는지 알았으니, 다음은 실제로 창구 안을 들여다볼 차례였다.
솔라는 루나가 가리켰던 ‘맨 위 클립 하나만 보여주는 작은 창구’를 떠올렸다.
“좋아. 그럼 첫 번째 창구부터. ‘최신 상태’만 보여준다는 그 창구로 보면 정확히 뭐가 어떻게 보인다는 거지? 그래프 실행이 딱 끝났을 때의 마지막 스냅샷, 그걸로 뭘 알 수 있는 거야?“
2장: get_state: LangGraph의 ‘최신 상태’를 들여다보기
솔라의 손가락이 키보드 위에서 잠시 멈칫했다. 엔터 키를 누르자, 화면의 검은 터미널 창에 몇 줄의 새로운 텍스트가 나타났다. 그래프 실행이 끝났음을 알리는 로그 아래, graph.get_state(config)의 결과값이 출력된 것이다. 그러나 솔라가 기대했던 깔끔한 최종 메시지 대신, 낯선 형태의 객체 정보가 그녀를 맞았다.
<StateSnapshot values={'messages': [AIMessage(...)]} next=('__end__',)>
솔라는 화면을 손가락으로 톡톡 건드렸다. 이전 장에서 루나와 나누었던 대화, 즉 MemorySaver라는 투명 상자와 그 위에 달린 ‘최신 클립 하나만 보여주는 작은 창구’에 대한 비유를 떠올렸다. 분명히 그 창구를 통해 ‘최신 상태’를 들여다본 것인데, 눈앞에 나타난 결과물은 예상과 사뭇 달랐다. ‘최신 상태’라기보다는, 해독이 필요한 암호문처럼 보였다.
“언니, 이게 바로 그 ‘작은 창구’로 들여다본 결과야.”
솔라가 노트북을 루나 쪽으로 살짝 돌리며 말했다.
“분명 ‘최신 상태’를 보여달라고 했는데, 나온 건 그냥 마지막 메시지가 아니네. StateSnapshot이라는 객체라는데… 강의 노트에는 .values에 최종 정보가 들어있다고만 했지, next는 뭐고, 또 이 전체 덩어리는 대체 뭘 의미하는 건지 모르겠어. 이게 어떻게 ‘최신’이라는 걸 증명하는 거지?”
루나는 화면에 뜬 텍스트를 잠시 들여다보더니, 고개를 끄덕였다.
“네가 방금 사진을 찍은 거야, 솔라.”
“사진?”
“응. get_state는 그래프 실행의 마지막 순간을 ‘찰칵’하고 찍은 스냅샷 사진 같은 거야. 그래서 그냥 결과물(사진 속 풍경)만 툭 던져주는 게 아니라, 그 사진에 대한 정보가 담긴 ‘사진 객체’를 통째로 주는 거지.”
루나는 솔라의 화면을 손가락으로 가리켰다.
“자, 그럼 그 사진을 한번 자세히 들여다볼까? 먼저 사진의 가장 중요한 내용물부터 확인해보자. 네가 찍은 코드 바로 아래에 print(snapshot.values)라고 입력해봐. 여기서 snapshot은 방금 get_state로 얻은 결과고.”
솔라는 고개를 갸웃하며 시키는 대로 코드를 입력했다. 엔터를 치자, 아까와는 다른 결과가 나타났다.
{'messages': [AIMessage(content='안녕하세요! 무엇을 도와드릴까요?')]}
“아!”
솔라의 입에서 작은 탄성이 터져 나왔다.
“이제야 내가 원했던 결과가 나왔네. .values가 진짜 ‘최종 상태 데이터’가 들어있는 곳이구나.”
“맞아. 그게 바로 네가 찍은 사진의 핵심 내용물이야. 그래프가 모든 작업을 마치고 State에 마지막으로 남겨둔 결과물이지.”
루나는 말을 이었다. “그럼 이제 그 사진의 다른 정보들도 확인해볼까? 이번엔 snapshot.next를 출력해봐.”
솔라가 다시 키보드를 두드리자, ('__end__',) 라는 튜플이 화면에 나타났다.
“이건 이 스냅샷이 찍힌 시점에서, 그래프가 ‘다음에’ 어디로 가려고 했는지를 알려주는 이정표야. __end__는 ‘이제 끝났습니다’라는 뜻이고. 즉, 이 스냅샷이 정말로 모든 과정이 끝난 마지막 상태에서 찍혔다는 걸 보여주는 거지.”
“그렇구나! 그럼 .values는 결과물, .next는 ‘이게 마지막임’이라는 확인 도장이네.”
“정확해. 마지막으로 하나 더. snapshot.metadata를 출력해봐. 사진에 붙어있는 상세 정보 태그 같은 거야.”
솔라가 코드를 실행하자, step, source 같은 키를 가진 딕셔너리 정보가 출력되었다. 복잡해 보였지만, 루나의 설명을 듣고 나니 그 구조가 눈에 들어오기 시작했다.
“이 메타데이터는 이 스냅샷이 몇 번째 단계(step)에서, 어떤 노드(source)의 실행 결과로 만들어졌는지 알려줘. 디버깅할 때 아주 유용하지.”
솔라는 고개를 끄덕였다. 이제 터미널에 떠 있는 StateSnapshot 객체가 더 이상 암호문처럼 보이지 않았다. 그것은 잘 정리된 하나의 보고서였다. 최종 결과(values)와, 그 결과가 마지막임을 증명하는 이정표(next), 그리고 그 결과가 언제 어떻게 만들어졌는지에 대한 상세 정보(metadata)까지 담고 있는 완결된 패키지.
“알겠다! get_state는 그냥 마지막 데이터 조각을 툭 던져주는 게 아니었어. 그래프 실행의 ‘가장 마지막 순간’을 모든 정황 증거와 함께 포착한 ‘결정적인 한 장의 사진’을 주는 거였네. 그래서 이름이 StateSnapshot이구나.”
흐릿했던 개념이 명확한 구조로 잡히는 순간이었다. 솔라는 이제 get_state를 통해 얻은 최신 상태 스냅샷을 자신 있게 판독할 수 있게 되었다. 하지만 하나의 의문이 풀리자, 자연스럽게 다음 질문이 꼬리를 물고 이어졌다.
“좋아, 마지막 사진 한 장을 제대로 보는 법은 이제 알겠어. 그런데… 그 사진이 찍히기 전까지의 과정은? get_state_history는 ‘과거 이력’을 전부 보여준다고 했잖아. 그럼 그건 사진이 여러 장 들어있는 앨범 같은 건가? 앨범이라면 어떤 순서로 정리되어 있는 거지?“
3장: get_state_history: ‘과거 이력’을 시간 순으로 재구성하기
솔라는 이전 챕터에서 얻은 StateSnapshot 객체 정보를 화면 한쪽에 띄워두었다. 결정적인 순간을 포착한 한 장의 사진. 그 구조를 이해하고 나니, 이제 get_state 명령어는 더 이상 낯설지 않았다. 하지만 솔라의 시선은 이내 비어있는 다음 코드 줄로 향했다. ‘과거 이력’이라는, 아직 열어보지 않은 사진 앨범이 그곳에 있었다.
솔라는 키보드에 손을 얹고 graph.get_state_history(config)를 입력했다. 엔터 키를 누르자, 터미널 화면이 빠르게 스크롤되며 길고 복잡한 텍스트 덩어리를 뱉어냈다. get_state가 단정한 보고서 한 장이었다면, 이것은 여러 장의 보고서를 아무렇게나 겹쳐놓은 뭉치처럼 보였다. 대괄호([])로 감싸인 리스트 안에는 이전에 봤던 <StateSnapshot ...> 객체들이 여러 개 들어있었다.
“이게… 사진 앨범이라고?”
솔라는 혼잣말을 하며 화면을 들여다보았다. 분명 get_state로 봤던 마지막 스냅샷과 비슷해 보이는 것도 있었고, 중간 과정으로 보이는 다른 스냅샷들도 섞여 있었다. 하지만 어느 것이 먼저고 어느 것이 나중인지, 어떤 순서로 읽어야 할지 전혀 감이 잡히지 않았다.
“언니, get_state_history를 실행해 봤는데, 이건 그냥 스냅샷들을 마구잡이로 모아놓은 리스트 같아. 강의 노트에는 ‘최신 순서대로 가져온다’고 되어 있었는데, 그럼 리스트의 첫 번째 항목이 가장 최근 거라는 뜻인가? 시간의 흐름을 따라가려면 리스트를 뒤에서부터 읽어야 하는 거야?”
솔라의 목소리에는 이제 막 새로운 규칙을 발견한 탐정의 혼란과 기대가 섞여 있었다.
루나는 솔라의 화면을 흘깃 보더니, 아크릴 상자에서 클립 몇 개를 꺼내 책상 위에 나란히 늘어놓았다. 맨 처음 넣었던 빨간색, 다음에 넣은 파란색, 그리고 마지막으로 넣은 노란색 클립이었다.
“사진 앨범을 펼쳤는데, 사진들이 순서대로 꽂혀있지 않고 뒤죽박죽 섞여 있다면 어떻게 할래?”
“음… 사진 뒷면에 적힌 날짜나 메모를 봐야지.”
“맞아. 우리도 방금 그걸 배웠잖아. 모든 스냅샷 사진에는 metadata라는 정보 태그가 붙어있다는 걸.”
루나는 솔라의 화면을 가리켰다.
“저 리스트에 있는 스냅샷 하나하나가 전부 독립된 사진이야. 각각의 사진 뒷면에 적힌 ‘촬영 순서’ 번호를 확인해보면, 이 앨범이 어떤 순서로 정리되어 있는지 알 수 있지 않을까?”
그 말에 솔라의 눈이 반짝였다. 막막하게 느껴졌던 텍스트 덩어리 속에서 탐색할 단서를 찾은 것이다. 그녀는 곧장 키보드를 두드려 짧은 파이썬 코드를 작성했다. get_state_history로 얻은 스냅샷 리스트를 반복문으로 돌면서, 각 스냅샷의 리스트 인덱스와 metadata에 들어있는 step 번호를 함께 출력하도록 했다.
엔터 키를 누르자, 결과가 화면에 가지런히 정리되어 나타났다.
History[0] -> Step: 3, Values: {'messages': [AIMessage(content='최종 답변입니다.')]}
History[1] -> Step: 2, Values: {'messages': [AIMessage(content='생각 중...')]}
History[2] -> Step: 1, Values: {'messages': [HumanMessage(content='질문입니다.')]}
History[3] -> Step: 0, Values: {'messages': []}
“아…!”
결과를 본 솔라의 입에서 나지막한 탄성이 터져 나왔다. 패턴은 명확했다.
“리스트의 인덱스는 0, 1, 2, 3 순서로 올라가는데, 스냅샷의 step 번호는 3, 2, 1, 0 순서로 내려가고 있어!”
솔라는 마침내 퍼즐 조각을 맞춘 사람처럼 외쳤다.
“정말이네! ‘최신 순서’라는 게 바로 이 뜻이었구나. 가장 최근에 찍힌 스냅샷이 리스트의 맨 앞에 오는 거야. 시간 순으로 그래프의 흐름을 보려면, 이 리스트를 거꾸로 읽어야 했던 거네.”
그녀는 한 가지 사실을 더 발견하고는 화면을 손가락으로 가리켰다.
“그리고 봐, 언니. 여기 History[0]번 스냅샷은 아까 get_state로 확인했던 최종 스냅샷이랑 내용이 완전히 똑같아. 결국 get_state는 get_state_history의 첫 번째 항목을 그냥 꺼내주는 거였구나!”
get_state와 get_state_history. 비슷해 보였던 두 개의 창구가 이제는 ‘결정적인 한 장의 사진’과 ‘사건의 전말이 담긴 사진 앨범’으로 명확히 구분되었다.
솔라는 이제 스냅샷을 활용하여 LangGraph의 실행 과정을 추적할 완전한 도구를 손에 넣은 기분이었다. 그녀는 자신이 작성하던 더 복잡한 그래프 코드를 화면에 띄웠다. 조건에 따라 분기하는 로직이 들어있는 코드였다.
“이제 알겠다. 만약 내 에이전트가 예상과 다른 답변을 내놓는다면, get_state로 최종 결과만 보고 ‘왜 틀렸지?’ 하고 고민할 필요가 없는 거야.”
솔라는 방금 얻은 깨달음을 자신의 코드에 바로 적용하며 말했다.
“그냥 get_state_history를 열어서, 분기점이 되는 노드가 실행된 직후의 스냅샷, 예를 들어 step 2번 스냅샷을 찾아보는 거지. 그리고 그 스냅샷의 .values를 확인하면, 에이전트가 어떤 상태 값을 보고 그런 결정을 내렸는지 정확히 알 수 있겠네. 범인을 현장에서 잡는 것처럼!”
이제 솔라에게 스냅샷은 더 이상 막연한 ‘기록’이 아니었다. 그래프의 모든 상태 변화를 포착하고, 특정 시점으로 정확히 돌아가 원인을 분석할 수 있게 해주는 강력한 디버깅 타임머신이었다. 그녀는 자신감 넘치는 표정으로 키보드를 두드려 다음 코드를 써 내려가기 시작했다.