Multi-Agent 13
State 스키마 정의: TypedDict와 Pydantic 선택 가이드
State 타입 힌트를 쓰면 런타임에서도 자동 검증된다고 오해하기 쉽다.
근거 · 교안 p36-p40
1장: 런타임 검증: 타입 힌트의 함정
솔라의 손가락이 키보드 위에서 멈칫했다. 화면에 떠 있는 코드 조각과 터미널의 실행 결과 사이에서 시선이 몇 번이고 오갔다. 무언가 단단히 잘못되었다는 느낌이 들었다. 분명 에이전트들이 주고받을 데이터, 즉 State의 구조를 꼼꼼하게 정의해 두었다. 메시지는 반드시 한 글자 이상이어야 한다는 조건까지 달아두었다.
from typing import TypedDict
from typing_extensions import Annotated
from langchain_core.pydantic_v1 import Field
class MessageState(TypedDict):
message: Annotated[str, Field(min_length=1)]
솔라는 MessageState가 비어있는 메시지를 받으면 당연히 오류를 일으킬 거라고 예상했다. 하지만 에이전트는 아무런 불평 없이 빈 문자열이 담긴 message를 다음 단계로 넘겨버렸다. 마치 솔라가 애써 붙여놓은 min_length=1이라는 제약 조건이 투명 딱지라도 되는 것처럼.
“언니, 이거 좀 이상해.”
솔라가 노트북을 들고 거실에 있는 루나에게 다가갔다. 루나는 읽고 있던 책에서 눈을 떼고 솔라의 화면을 들여다보았다.
“State 스키마를 TypedDict로 만들었는데, 내가 정한 규칙이 전혀 동작하지 않아. message 필드는 최소 한 글자 이상이어야 한다고 Field로 명시했거든. 그런데 빈 문자열을 넣어도 그냥 통과돼.”
루나는 솔라의 코드를 잠시 살피더니, 고개를 끄덕였다. “어떤 걸 기대했고, 실제로는 어떻게 동작했는지 작은 실험으로 다시 한번 확인해 볼까?”
루나는 솔라의 복잡한 에이전트 코드 대신, 문제의 핵심만 남긴 짧은 파이썬 스크립트를 제안했다.
from typing import TypedDict
from typing_extensions import Annotated
from langchain_core.pydantic_v1 import Field
# 솔라가 정의한 State 스키마
class MessageState(TypedDict):
message: Annotated[str, Field(min_length=1)]
# 제약 조건을 위반하는 데이터
invalid_data = {"message": ""}
# 데이터를 MessageState 타입 변수에 할당
state: MessageState = invalid_data
# 결과 확인
print(state)
“이 코드를 실행하면 어떻게 될 것 같아?” 루나가 물었다.
“음… invalid_data의 message는 빈 문자열이니까 min_length=1 조건을 위반하잖아. 그러니까 state: MessageState = invalid_data 이 줄에서 에러가 나야 정상이지.” 솔라가 자신 있게 대답했다. 자신의 예측이 이번에야말로 맞기를 바라면서.
솔라는 스크립트를 실행했다. 하지만 터미널에 나타난 것은 오류 메시지가 아니었다.
{'message': ''}
예상치 못한 깔끔한 실행 결과에 솔라는 말문이 막혔다. 아무 일도 일어나지 않았다. 파이썬 인터프리터는 min_length=1이라는 주석을 못 본 척했다.
“정말이네… 아무 일도 안 일어나. 그럼 내가 쓴 Annotated나 Field는 도대체 무슨 의미가 있는 거야? 그냥 코드에 붙이는 장식품 같은 건가?”
허탈한 솔라의 목소리에 루나가 조용히 입을 열었다. “장식품은 아니야. 다만 그걸 보는 대상이 달라. 솔라, 네가 지금 확인한 건 아주 중요한 원칙이야. 타입 힌트와 런타임 검증은 별개라는 것.”
루나는 화면의 class MessageState(TypedDict): 부분을 가리켰다.
“TypedDict는 파이썬에게 ‘이런이런 모양의 딕셔너리가 있을 거야’라고 알려주는 안내판, 즉 타입 힌트일 뿐이야. 코드를 실행하는 파이썬 인터프리터가 아니라, 코드를 읽는 개발자나 MyPy 같은 정적 분석 도구를 위한 거지.”
“정적 분석 도구?”
“응. 코드를 실행하기 전에, 코드 자체만 보고 ‘어, 이 부분은 MessageState 타입이 들어와야 하는데, 약속이랑 다른 타입이 들어올 가능성이 있겠는데?’ 하고 미리 알려주는 도구들 말이야. 하지만 일단 코드가 실행되는 시점, 즉 런타임에는 TypedDict나 Annotated에 적힌 내용이 데이터의 유효성을 강제로 검사하지 않아.”
그제야 솔라는 자신이 무엇을 오해했는지 깨달았다. TypedDict에 타입을 명시하는 행위가 곧 런타임에 데이터의 값까지 일일이 확인해주는 마법 같은 역할을 할 것이라고 막연히 기대했던 것이다. 마치 식당 메뉴판에 ‘맵기 조절 가능’이라고 적어놓으면, 주문하지 않아도 주방장이 알아서 입맛에 맞게 요리해줄 거라고 믿은 셈이다.
솔라는 자신의 머릿속에 흩어져 있던 개념들을 다시 정리했다.
“아… 알겠다. TypedDict는 설계도 같은 거구나. ‘이 데이터는 message라는 키를 갖고, 그 값은 문자열이어야 해’라고 알려주는 역할만 하는 거지. 실제로 공사 현장(런타임)에서 자재(데이터)가 설계도대로 들어왔는지 일일이 검사(유효성 검증)하는 기능은 없는 거였어.”
솔라의 얼굴에 씁쓸한 깨달음이 스쳤다. TypedDict는 런타임에 데이터 검사를 하지 않는다. 이것이 오늘 솔라가 몸소 부딪혀 얻어낸 첫 번째 규칙이었다. 하지만 이내 새로운 궁금증이 고개를 들었다.
“그런데 언니, 분명 Pydantic을 쓰면 런타임에도 데이터 검증이 된다고 들었어. 그렇다면 Pydantic은 어떻게 이걸 확인하는 거지? TypedDict랑은 뭔가 근본적으로 다른 원리로 동작하는 거야?“
2장: TypedDict vs. Pydantic: 본질적 차이
솔라의 질문이 끝나자, 루나는 대답 대신 조용히 솔라의 노트북을 다시 자기 쪽으로 돌렸다. 이전 장에서 함께 작성했던 실험 코드가 화면에 그대로 남아 있었다. 루나는 MessageState 클래스 정의 아래에 몇 줄의 코드를 더 추가하기 시작했다. 솔라는 말없이 언니의 손가락 움직임을 지켜보았다.
from typing import TypedDict
from typing_extensions import Annotated
from langchain_core.pydantic_v1 import Field, BaseModel
# 1. TypedDict로 정의한 스키마
class MessageStateTypedDict(TypedDict):
message: Annotated[str, Field(min_length=1)]
# 2. Pydantic으로 정의한 스키마
class MessageStatePydantic(BaseModel):
message: Annotated[str, Field(min_length=1)]
루나는 방금 전까지 MessageState였던 클래스의 이름을 MessageStateTypedDict로 바꾸고, 그 아래에 아주 비슷해 보이지만 BaseModel을 상속받는 MessageStatePydantic 클래스를 새로 만들었다. 두 개의 설계도가 나란히 놓인 셈이었다. 하나는 TypedDict를, 다른 하나는 Pydantic의 BaseModel을 기반으로 했다.
“솔라, 네 질문에 대한 답은 이미 이 코드 안에 있어.” 루나가 말했다. “Pydantic이 왜 TypedDict와 다르게 동작하는지 알려면, 둘이 런타임에 어떤 ‘정체성’을 갖는지 확인해 보면 돼.”
루나는 새로운 실험 코드를 작성했다. 이번에는 두 스키마를 모두 사용했다.
# 유효하지 않은 데이터
invalid_data = {"message": ""}
# 각 스키마로 데이터 생성 시도
# TypedDict
state_dict = invalid_data
print(f"TypedDict 결과물: {state_dict}")
print(f"TypedDict 결과물의 타입: {type(state_dict)}")
print("-" * 20)
# Pydantic
try:
state_pydantic = MessageStatePydantic(**invalid_data)
print(f"Pydantic 결과물: {state_pydantic}")
print(f"Pydantic 결과물의 타입: {type(state_pydantic)}")
except Exception as e:
print(f"Pydantic 생성 중 에러 발생: {e}")
“이전 실험과 비슷해. 하지만 이번엔 Pydantic 버전도 추가했고, 각 결과물의 type()을 출력하도록 했어. 어떻게 될 것 같아?”
솔라는 코드를 찬찬히 뜯어보았다. “음… TypedDict는 아까처럼 아무 일 없이 그냥 딕셔너리를 출력하겠지. 타입은 당연히 <class 'dict'>일 거고. Pydantic은 min_length=1 조건을 검사할 테니까, 빈 문자열을 받으면 바로 에러를 낼 거야.”
“좋은 예측이야. 그런데 만약 Pydantic이 에러를 내지 않도록 유효한 데이터를 넣는다면, 그 결과물의 타입은 뭐라고 나올까?” 루나가 질문을 살짝 비틀었다.
“글쎄… 그것도 결국엔 딕셔너리랑 비슷한 무언가 아닐까? MessageStatePydantic이라는 특별한 타입이 나오려나?” 솔라는 고개를 갸웃하며 확신 없는 추측을 내놓았다. TypedDict와 Pydantic 모두 데이터를 담는 그릇을 정의하는 도구일 뿐, 그 본질은 비슷할 것이라는 생각이 머릿속을 떠나지 않았다.
먼저, 솔라는 루나가 작성한 코드 그대로 실행했다. 예상대로 Pydantic 부분에서 오류가 발생했다.
TypedDict 결과물: {'message': ''}
TypedDict 결과물의 타입: <class 'dict'>
--------------------
Pydantic 생성 중 에러 발생: 1 validation error for MessageStatePydantic
message
ensure this value has at least 1 characters (type=value_error.any_str.min_length; limit_value=1)
“역시 Pydantic은 규칙을 어기니까 바로 잡아내네.”
다음으로 솔라는 루나의 질문에 답하기 위해 invalid_data를 valid_data = {"message": "hello"}로 수정하고 다시 실행했다.
TypedDict 결과물: {'message': 'hello'}
TypedDict 결과물의 타입: <class 'dict'>
--------------------
Pydantic 결과물: message='hello'
Pydantic 결과물의 타입: <class '__main__.MessageStatePydantic'>
결과를 본 솔라의 눈이 동그래졌다. TypedDict의 결과물은 여전히 평범한 딕셔너리(dict)였다. 하지만 Pydantic의 결과물은 완전히 달랐다. 타입이 <class '__main__.MessageStatePydantic'>라고 찍혔다. 이것은 단순한 딕셔너리가 아니라, MessageStatePydantic이라는 독립된 클래스의 인스턴스, 즉 고유한 ‘객체’라는 뜻이었다.
순간 솔라의 머릿속에 흩어져 있던 조각들이 맞춰졌다.
“아! 알겠다! TypedDict는 그냥 꼬리표 같은 거였어! ‘이 딕셔너리는 이런 모양일 겁니다’라고 알려주는 주석이나 마찬가지인 거지. 런타임에 파이썬은 이 꼬리표를 못 봐. 그냥 평범한 딕셔너리로 취급하는 거야. 그러니 유효성 검사 같은 걸 할 리가 없지.”
솔라는 흥분하며 말을 이었다. “그런데 Pydantic은 완전 다르네. BaseModel을 상속받는 순간, 이건 단순한 데이터 구조에 대한 안내가 아니라, 실제로 동작하는 클래스를 만드는 거였어. 우리가 MessageStatePydantic(...)를 호출할 때, 이건 그냥 딕셔너리를 만드는 게 아니라 클래스의 생성자(__init__)를 부르는 거구나! 그리고 그 생성자 안에 Field에 정의된 모든 검증 로직이 숨어 있었던 거고.”
루나는 조용히 고개를 끄덕였다. 솔라가 스스로 핵심에 도달한 것을 확인한 것이다.
솔라는 방금 얻은 깨달음을 자기만의 언어로 정리했다.
“그러니까 TypedDict는 데이터의 ‘모양’에 대한 약속일 뿐, 그 자체가 힘을 갖진 않아. 반면에 Pydantic은 약속을 지키도록 강제하는 ‘감시관’이 내장된 특별한 상자를 직접 만드는 거구나.”
스키마의 런타임 역할이 다르다는 것. 하나는 단순한 ‘힌트’이고, 다른 하나는 ‘실제 클래스’라는 본질적인 차이가 런타임 동작의 모든 것을 결정하고 있었다. TypedDict가 가볍다고 표현되던 이유도 이제 명확해졌다. 런타임에 아무런 추가 동작도 하지 않으니 가벼울 수밖에 없었다.
이제 TypedDict와 Pydantic이 어떻게 다른지는 명확히 이해했다. 하지만 솔라의 마음속에는 새로운 질문이 떠올랐다. 더 강력한 도구가 있는데, 굳이 약한 도구를 쓸 이유가 있을까?
“언니, 이제 둘의 차이는 알겠어. Pydantic이 런타임 검증까지 해주니 훨씬 안전하고 좋아 보이는데… 그럼 State 스키마를 만들 땐 항상 Pydantic을 쓰는 게 정답인 거야? TypedDict는 언제 쓰는 거지?“
3장: 스키마 선택의 기준: 검증의 필요성
솔라의 질문이 거실의 조용한 공기 속에 잠시 머물렀다. “항상 Pydantic을 쓰는 게 정답일까?” 더 안전하고 강력해 보이는 도구를 발견한 이상, 굳이 다른 것을 쓸 이유가 있는지에 대한 순수한 궁금증이었다. 우열을 가리고 싶은 마음이기도 했다.
루나는 솔라의 질문에 바로 답하는 대신, 노트북 화면에 떠 있던 이전 코드를 지우고 새로운 시나리오 두 개를 간결한 주석으로 적어 내려갔다. 복잡한 로직은 없었다. 데이터가 어디서 와서 어디로 흘러가는지만 명확히 보여주는 두 개의 작은 흐름도 같았다.
시나리오 A: 외부에서 내부로
# 사용자가 웹사이트의 폼을 통해 메시지를 전송한다.
# 이 데이터는 에이전트 시스템의 가장 첫 번째 노드로 전달된다.
user_input = {"message": "안녕하세요! 챗봇 가격이 궁금해요."}
# graph.invoke(user_input)
시나리오 B: 내부에서 내부로
# '요약' 에이전트가 긴 글을 처리한 후, 결과를 '번역' 에이전트로 넘긴다.
# 두 에이전트는 모두 우리 시스템 내부에 존재한다.
summary_output = {"summary": "The chatbot pricing is based on usage."}
# translator_agent.invoke(summary_output)
루나는 노트북을 솔라 쪽으로 돌렸다. “솔라, 방금 네 질문을 이 두 상황에 적용해 보자. State 스키마를 정의해야 하는데, 각각 어떤 도구를 선택하겠어? TypedDict? 아니면 Pydantic?”
솔라는 두 시나리오를 번갈아 보며 잠시 생각에 잠겼다. Pydantic의 강력한 런타임 검증 능력에 깊은 인상을 받은 참이었다.
“음… 나라면 둘 다 Pydantic을 쓸 것 같아.” 솔라가 조심스럽게 입을 열었다. “시나리오 A는 사용자가 뭘 입력할지 전혀 예측할 수 없으니까, 당연히 Pydantic으로 꼼꼼하게 검사해야지. 빈 문자열을 보내거나, 아예 message 키를 빼먹을 수도 있잖아. 그런데 시나리오 B도… 혹시 요약 에이전트에 버그가 있어서 비정상적인 결과를 만들 수도 있으니까. 다음 단계인 번역 에이전트를 보호하려면 여기서도 Pydantic으로 한 번 더 확인해주는 게 안전하지 않을까?”
솔라의 논리는 명확했다. 안전을 최우선으로 고려한 합리적인 선택처럼 보였다.
“혹시 모를 상황을 대비하는 건 좋은 습관이지.” 루나가 동의했다. “하지만 Pydantic이 수행하는 ‘검증’은 공짜가 아니야. 아주 작은 비용이지만, 데이터를 객체로 만들고, 정의된 규칙과 하나하나 비교하는 코드를 실행하는 거니까. 시나리오 B에서 그 비용을 치를 가치가 정말 있을까?”
루나는 화면의 두 시나리오를 다시 가리켰다. “데이터가 어디서 왔는지에 초점을 맞춰서 다시 생각해 봐. 첫 번째 데이터의 ‘출처’는 어디지? 두 번째 데이터의 ‘출처’는?”
“첫 번째는… 우리 시스템 외부, 즉 사용자에게서 왔고. 두 번째는 우리 시스템 내부, 다른 에이전트에게서 왔어.” 솔라가 대답했다.
“바로 그거야. 우리가 전혀 통제할 수 없는 ‘외부 세계’와, 우리가 직접 규칙을 만들고 통제하는 ‘내부 세계’의 근본적인 차이야.”
그 순간, 솔라의 머릿속에서 무언가 ‘탁’ 하고 연결되는 느낌이 들었다. 안전이라는 하나의 잣대로 모든 것을 재단하려 했던 자신의 생각이 얼마나 단편적이었는지 깨달았다.
“아! 알겠다! ‘검증’이라는 행위가 필요한 진짜 이유를 생각해야 하는구나. 시나리오 A처럼 외부에서 오는 데이터는 어떤 형태로 들어올지 전혀 신뢰할 수 없으니까, Pydantic이라는 단단한 ‘검문소’를 세워서 반드시 통과시켜야만 하는 거야. 여기서 잘못된 데이터가 걸러지지 않으면 시스템 전체가 위험해질 수 있으니까.”
솔라는 흥분하며 말을 이었다.
“하지만 시나리오 B는 달라. 일단 검문소를 통과해 우리 시스템 안으로 들어온 데이터는 이미 우리의 규칙을 따르도록 ‘정제’된 상태잖아. 내부 에이전트들은 서로를 신뢰할 수 있는 동료 같은 거야. 동료에게 일을 넘길 때마다 매번 신분증을 검사할 필요는 없으니까… 이미 신뢰할 수 있는 데이터라면 굳이 Pydantic으로 또 검사하는 건 불필요한 절차였던 거네! 이럴 땐 그냥 ‘이런 모양의 데이터가 갈 거야’라고 알려주는 가벼운 TypedDict만 써도 충분했던 거야!”
선택의 기준은 도구의 우열이 아니었다. ‘복잡해서 Pydantic, 간단해서 TypedDict’ 같은 단순한 이분법도 아니었다. 핵심은 검증의 필요성 그 자체에 있었다. 데이터의 출처를 보고, 그것이 신뢰할 수 있는 경계선 안쪽에 있는지 바깥쪽에 있는지를 판단하는 것이 우선이었다.
솔라는 비로소 자신만의 명확한 가이드라인을 세울 수 있게 되었다. 그녀는 노트북 옆에 놓인 메모장을 가져와 방금 깨달은 원칙을 두 줄로 정리했다.
State 스키마 선택 가이드라인
-
외부와의 경계 ➞
Pydantic- 언제? 사용자 입력, 외부 API 응답 등 신뢰할 수 없는 데이터가 시스템에 처음 들어올 때.
- 왜? 예상치 못한 데이터로부터 시스템 전체를 보호하기 위해, 런타임 유효성 검사가 필수이기 때문.
-
내부에서의 흐름 ➞
TypedDict- 언제? 이미 검증된 데이터를 시스템 내부의 다른 에이전트나 컴포넌트로 전달할 때.
- 왜? 이미 신뢰할 수 있는 데이터이므로, 불필요한 검증 오버헤드를 줄이고 코드를 간결하게 유지하기 위해. 타입 힌트만으로 충분.
솔라는 자신이 적은 메모를 만족스럽게 바라보았다. 더 이상 TypedDict와 Pydantic 사이에서 막연하게 고민하지 않을 수 있었다. 이제는 ‘이 데이터는 검증이 필요한가?‘라는 명확한 질문을 먼저 던지고, 그에 맞는 도구를 자신 있게 선택할 수 있게 된 것이다. 그것이야말로 오늘 솔라가 얻은 가장 큰 수확이었다.