LLMOps 34

파이썬 dict와 JSON, 헷갈리지 않고 Agent 시스템에 적용하기

Python dict와 JSON 문자열을 같은 것으로 생각해 작은따옴표나 파일 저장에서 막히기 쉽다.

근거 · 교안 p129-p133

파이썬 dict와 JSON, 헷갈리지 않고 Agent 시스템에 적용하기 대표 이미지

1장: 파이썬 dict와 JSON, 정말 같은 걸까요?

1. 파이썬 dict와 JSON, 정말 같은 걸까요?

솔라의 손가락이 키보드 위에서 잠시 멈칫했다. 화면에는 에이전트의 현재 상태를 저장하려는 파이썬 코드가 떠 있었다. 몇 줄 안 되는 코드였지만, 터미널에는 벌써 세 번째 보는 익숙한 에러 메시지가 찍혀 있었다.

json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes

“아, 또야.”

솔라는 작게 투덜거리며 의자에 등을 기댔다. 분명 키와 값으로 이루어진, 눈에 익은 딕셔너리 형태인데 왜 자꾸 JSON이 아니라고 하는지 알 수 없었다. ‘상태’라는 이름의 변수에 작은따옴표로 감싼 문자열을 할당한 게 전부였다. 파이썬에서는 아무 문제 없이 쓰던 방식이었다.

마침 거실을 지나던 언니 루나가 솔라의 굳은 표정을 보고 다가왔다.

“뭐가 또 솔라를 막아섰어?”

“언니, 이거 좀 봐. Agent의 대화 기록을 저장하려고 하는데, 자꾸 에러가 나. 분명히 딕셔너리처럼 생겼는데 왜 자꾸 JSON 형식이 아니라고 하는지 모르겠어.”

솔라가 가리킨 코드는 이랬다.

import json

# Agent의 현재 상태를 문자열로 정의
agent_state_string = "{'user_message': '오늘 날씨 어때?', 'agent_response': '서울은 현재 맑음입니다.'}"

# 문자열을 파이썬 딕셔너리로 변환 시도
state_dict = json.loads(agent_state_string)

print(state_dict)

“파이썬에서 딕셔너리 만들 때랑 똑같이 작은따옴표로 썼는데, 이게 왜 안 되는 거지? 파이썬 딕셔너리랑 JSON이랑 거의 같은 거 아니었어?” 솔라의 목소리에는 진심 어린 답답함이 묻어났다.

루나는 고개를 끄덕이며 잠시 솔라의 코드를 들여다보았다. 그리고는 말없이 솔라의 코드 옆에 새 편집기 창을 하나 더 열었다.

“거의 같아 보이지. 그럼 한번 나란히 놓고 볼까?”

루나는 먼저 새 창에 익숙한 파이썬 딕셔너리를 입력했다.

# 파이썬의 '딕셔너리' 자료형
python_dict = {'name': '솔라', 'task': 'debugging'}

“이건 파이썬의 딕셔너리야. 그 자체로 데이터 구조를 가진 하나의 객체지. 타입을 찍어보면 바로 알 수 있어.”

루나의 말에 솔라가 print(type(python_dict))를 입력하자, 터미널에는 <class 'dict'>라고 명확하게 표시되었다.

“그럼 이제 솔라가 만들려던 ‘JSON 형식의 문자열’을 옆에 만들어 보자.”

루나는 솔라가 쓴 것과 비슷하게, 하지만 큰따옴표를 사용해서 문자열을 만들었다.

# JSON 형식의 '문자열'
json_string = '{"name": "솔라", "task": "debugging"}'

솔라는 두 코드를 번갈아 보았다. 언뜻 보기에는 그저 따옴표 종류만 다른 것 같았다.

“어? 안에는 왜 전부 큰따옴표를 쓴 거야? 파이썬에서는 작은따옴표, 큰따옴표 둘 다 되는데.”

“바로 그게 첫 번째 단서야. 이제 이 문자열들이 정말 ‘JSON’이라는 규칙을 잘 지켰는지 확인해 주는 문지기에게 검사를 맡겨 보자.”

루나는 솔라가 아까 썼던 json.loads() 함수를 가리켰다. 솔라는 먼저 자신이 만들었던 작은따옴표가 섞인 문자열부터 문지기에게 넘겨주었다.

# 솔라의 문자열 (작은따옴표 사용)
invalid_string = "{'name': '솔라', 'task': 'debugging'}"

try:
    json.loads(invalid_string)
except Exception as e:
    print(e)

엔터 키를 누르자마자, 솔라를 괴롭히던 JSONDecodeError가 어김없이 나타났다. 문지기가 단호하게 입장을 거부한 것이다.

이번에는 루나가 제안한, 모든 키와 문자열 값을 큰따옴표로 감싼 문자열을 넘겨줄 차례였다.

# 규칙을 지킨 문자열 (큰따옴표 사용)
valid_string = '{"name": "솔라", "task": "debugging"}'
parsed_dict = json.loads(valid_string)

print(parsed_dict)
print(type(parsed_dict))

결과는 달랐다. 에러 없이 깔끔하게 딕셔너리가 출력되었고, 타입은 <class 'dict'>였다.

솔라는 잠시 화면을 응시했다. 에러가 났던 코드와 성공한 코드, 그리고 그 둘의 미묘한 차이. 그제야 조각들이 맞춰지는 느낌이 들었다.

“아…! 알겠다. 내가 착각했네.”

솔라가 무릎을 탁 쳤다.

“파이썬 코드에서 {'key': 'value'} 이렇게 쓰는 건, 파이썬 프로그램 안에서 살아 움직이는 ‘딕셔너리’라는 자료형 그 자체였구나. 메모리에 바로 만들어지는 진짜 객체. 그런데 JSON은… 생긴 건 비슷해도, 근본적으로는 그냥 ‘문자열’인 거네. 다른 시스템과 데이터를 주고받기 위한 약속이 담긴 글자 뭉치. 그리고 그 약속에는 ‘모든 키와 문자열은 큰따옴표로 감싼다’는 엄격한 규칙이 있었던 거고.”

루나는 조용히 고개를 끄덕였다.

“맞아. 하나는 파이썬이라는 나라 안에서만 통용되는 신분증 같은 거야. 아주 유연하고 편리하지. 다른 하나는 어느 나라에서나 통용되는 여권 같은 거고. 여권에는 사진 크기부터 서명 위치까지 정해진 양식이 있듯이, JSON에도 데이터를 표현하는 엄격한 문자열 형식이 있는 거야.”

이제 솔라는 딕셔너리와 JSON 문자열이 왜 다른지, 시각적 유사함에 어떻게 속았는지 명확히 이해했다. 하나는 파이썬의 ‘타입’이었고, 다른 하나는 약속된 ‘형식’의 문자열이었다. 타입과 형식을 구분하자 모든 혼란이 사라졌다.

하지만 안개가 걷히자 새로운 길이 보였다. 솔라는 자신이 맨 처음 만들었던 python_dict 변수를 바라보았다.

“좋아. 둘이 다르다는 건 확실히 알겠어. 그럼, 내가 파이썬에서 열심히 작업한 이 딕셔너리를 다른 시스템으로 보내려면, 그 ‘여권 형식’에 맞는 JSON 문자열로 바꿔야 한다는 거잖아? 이걸 일일이 손으로 따옴표를 바꿔줄 순 없을 텐데. 반대로 JSON 문자열을 받아서 다시 파이썬 딕셔너리로 편하게 쓰는 방법도 분명 있겠지?“

2장: JSON 문자열, dict 사이를 자유롭게 오가는 방법

솔라는 이전의 깨달음으로 한껏 자신감이 붙었다. 파이썬의 ‘딕셔너리’와 데이터 교환용 ‘JSON 문자열 형식’이 다르다는 것을 이해하자, 다음 단계는 명확해 보였다. 파이썬에서 만든 딕셔너리 객체를 ‘여권 형식’의 문자열로 변환하는 것.

“방법이 분명 있을 거야.”

솔라는 스스로 중얼거리며 키보드를 두드렸다. 그녀의 생각은 단순하고 명쾌했다. ‘파이썬 객체를 문자열로 만들려면 str() 함수를 쓰면 되지!’ 아주 자연스러운 파이썬 사고방식이었다.

그녀는 지난번에 사용했던 딕셔너리 객체를 그대로 가져와 str() 함수에 넣어보았다.

import json

# 파이썬 딕셔너리 객체
python_dict = {'user_message': '오늘 날씨 어때?', 'agent_response': '서울은 현재 맑음입니다.'}

# str() 함수로 문자열 변환 시도
agent_state_string = str(python_dict)

print(agent_state_string)
print(type(agent_state_string))

터미널에 결과가 출력되었다.

{'user_message': '오늘 날씨 어때?', 'agent_response': '서울은 현재 맑음입니다.'}
<class 'str'>

“됐네!”

솔라는 짧게 외쳤다. 눈으로 보기엔 완벽한 문자열로 변환된 것 같았다. 타입도 <class 'str'>로 정확히 찍혔다. 이제 이 문자열이 정말 ‘여권’으로서 유효한지, 지난번의 ‘문지기’였던 json.loads()에게 확인받을 차례였다. 자신만만하게 다음 코드를 추가했다.

# 변환된 문자열이 유효한 JSON인지 검사
state_dict = json.loads(agent_state_string)

엔터. 그러나 화면에 나타난 것은 성공 메시지가 아닌, 그녀를 괴롭혔던 익숙한 에러였다.

json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes

솔라의 미간이 다시 좁혀졌다. “아니, 왜? 분명 문자열인데… 아!” 그녀는 출력된 문자열과 에러 메시지를 번갈아 보다가 이내 이유를 깨달았다. str() 함수가 만들어낸 문자열은 작은따옴표를 그대로 유지하고 있었다. 파이썬에겐 익숙한 모습이지만, 국제 표준인 JSON 형식에는 맞지 않는, ‘규격 미달 여권’이었던 것이다.

그때, 솔라의 짧은 탄식을 들은 루나가 다가와 모니터를 들여다보았다.

“여권 사진을 셀카로 찍어서 제출한 셈이네.”

루나의 비유에 솔라는 피식 웃으며 고개를 끄덕였다. “그러게. 여권 발급 창구에 가서 공식 사진사가 찍어줘야 하는 거였는데.”

“바로 그거야.” 루나는 import json 부분을 가리켰다. “여권 발급처(json 라이브러리)는 자체 규격에 맞는 사진을 찍어주는 공식 사진사를 데리고 있어. 파이썬 객체를 JSON 형식 문자열로 변환하는 전문가.”

루나는 솔라의 코드 한 줄을 수정했다. str() 함수 대신 새로운 함수를 알려주었다.

# json.dumps()를 사용하여 파이썬 객체를 JSON 문자열로 변환
json_string = json.dumps(python_dict, ensure_ascii=False)

print(json_string)
print(type(json_string))

솔라가 코드를 실행하자, 이전과는 다른 결과가 나타났다.

{"user_message": "오늘 날씨 어때?", "agent_response": "서울은 현재 맑음입니다."}
<class 'str'>

“와.” 솔라의 눈이 커졌다. “따옴표가 전부 큰따옴표로 바뀌었어! 한글도 그대로 나오고.”

“그래. json.dumps()는 파이썬 객체(dictionary)를 받아서, JSON 표준 규칙에 맞는 문자열(string)로 ‘덤프’해주는 함수야. 이름에 s가 붙어있는 게 그 증거지. ‘dump to string’이라고 기억하면 편해.”

루나는 말을 이었다. “이제 이 문자열은 어느 나라에 내밀어도 통과되는 완벽한 여권이 된 거야. 그럼, 다른 나라 입국 심사대에서 이 여권을 다시 신분증으로 바꾸는 과정도 필요하겠지?”

솔라는 즉시 루나의 의도를 알아차렸다. dumps로 내보냈으니, 이번엔 다시 들여올 차례였다. 그녀는 이전 장에서 문지기 역할을 했던 json.loads() 함수를 떠올렸다.

# json.loads()를 사용하여 JSON 문자열을 다시 파이썬 객체로 변환
back_to_dict = json.loads(json_string)

print(back_to_dict)
print(type(back_to_dict))

결과는 명확했다.

{'user_message': '오늘 날씨 어때?', 'agent_response': '서울은 현재 맑음입니다.'}
<class 'dict'>

에러 없이 완벽하게 파이썬 딕셔너리로 돌아왔다. 솔라는 두 개의 함수가 짝을 이루어 움직이는 모습을 보며 고개를 끄덕였다.

“알겠다! 완벽히 이해했어. 파이썬 세상에서 데이터를 밖으로 내보낼 땐 dumps로 포장해서 ‘문자열’로 만들고, 외부에서 받은 ‘문자열’ 데이터는 loads로 풀어서 파이썬 세상의 ‘딕셔너리’로 쓰는 거구나. dumpsloads, 둘 다 끝에 s가 붙는 건 ‘string’을 다룬다는 뜻이고.”

솔라는 이제 파이썬 메모리 안에서 딕셔너리와 JSON 문자열을 자솔라재로 변환할 수 있게 되었다. 에이전트의 상태를 다른 시스템과 주고받거나, LLM의 구조화된 출력을 처리하는 과정이 머릿속에 선명하게 그려졌다.

자신감이 붙은 솔라는 신나게 코드를 정리하기 시작했다. “좋아, 그럼 에이전트 대화가 끝날 때마다 현재 상태 딕셔너리를 json.dumps()로 문자열로 만들어서… 어? 잠깐만.”

솔라의 손이 다시 멈췄다. “이렇게 만든 문자열을 어디에 둬야 하지? 프로그램이 꺼지면 이 변수도 그냥 사라지잖아. 에이전트의 상태를 나중에 다시 불러오려면 어딘가에 영구적으로 저장을 해야 하는데…”

메모리 위를 떠다니던 데이터는 이제 땅에 발을 붙여야 할 때가 온 것이다. 솔라의 시선은 자연스럽게 파일 탐색기로 향했다.

3장: Agent 데이터를 파일로 저장하고 불러오기

파이썬 메모리 위에서 데이터를 자솔라재로 변환하는 법을 깨우친 솔라. 하지만 그 데이터는 프로그램이 끝나면 사라질 휘발성 존재였다. 에이전트의 상태를 영원히 기억하게 하려면, 이 데이터를 어딘가에 ‘저장’해야만 했다. 솔라의 시선이 향했던 파일 탐색기 창이 마침내 활성화되었다.

솔라는 망설이지 않고 agent_state.json이라는 빈 파일을 생성했다. 이제 막 배운 json.dumps() 함수를 사용하면 되겠다는 확신이 있었다. 먼저 파이썬 딕셔너리를 완벽한 JSON 형식의 문자열로 변환하고, 그 다음 그 문자열을 파일에 쓰면 될 일이었다. 아주 논리적인 두 단계였다.

import json

# 저장할 Agent의 현재 상태
agent_state_dict = {
    'last_user_message': '오늘 날씨 어때?',
    'last_agent_response': '서울은 현재 맑음입니다.',
    'conversation_turns': 3
}

# 1단계: 딕셔너리를 JSON 문자열로 변환
agent_state_string = json.dumps(agent_state_dict, ensure_ascii=False)

# 2단계: 변환된 문자열을 파일에 쓰기
with open('agent_state.json', 'w', encoding='utf-8') as f:
    f.write(agent_state_string)

코드를 실행하자, 아무런 오류 없이 agent_state.json 파일에 내용이 채워졌다. 솔라는 파일을 열어보고 만족스럽게 웃었다. {"last_user_message": "오늘 날씨 어때?", ...} 완벽한 JSON 문자열이 그대로 담겨 있었다. 이제 반대로 파일에서 읽어와 다시 딕셔너리로 되살릴 차례였다. 이것 역시 두 단계로 가능했다. 파일에서 문자열을 읽고, 그 문자열을 json.loads()로 파싱하는 것이다.

# 1단계: 파일에서 JSON 문자열 읽기
with open('agent_state.json', 'r', encoding='utf-8') as f:
    read_string = f.read()

# 2단계: 읽어온 문자열을 딕셔너리로 변환
restored_dict = json.loads(read_string)

print(restored_dict == agent_state_dict)

터미널에 True가 찍혔다. 원래의 딕셔너리와 정확히 일치했다. “해냈어!” 스스로의 힘으로 문제를 해결했다는 뿌듯함에 솔라의 목소리가 들떴다.

그때, 솔라의 화면을 지켜보던 루나가 조용히 입을 열었다.

“우체국에 가서 직접 소포를 부치는 데 성공했네. 물론 그것도 좋은 방법이야. 그런데 혹시, 우체국에서 집까지 와서 소포를 받아가는 ‘방문 수령’ 서비스가 있다면 어떨 것 같아?”

“어? 훨씬 편하지. 포장만 해두면 알아서 가져가는 거니까.”

솔라는 루나의 비유를 곱씹으며 자신의 코드를 다시 보았다. 딕셔너리를 문자열로 변환하고(dumps), 그 문자열을 파일에 쓰고(write). 파일에서 문자열을 읽고(read), 그 문자열을 딕셔너리로 변환하고(loads). 각각 두 단계의 과정. 루나의 말은 이 두 단계를 하나로 합칠 수 있다는 뜻 같았다.

루나는 솔라의 코드를 복사해 그 아래에 새로운 코드를 작성하기 시작했다.

“JSON 라이브러리는 이미 파일과 직접 소통하는 방법을 알고 있어. 파이썬 객체를 굳이 문자열로 바꿨다가 파일에 쓸 필요 없이, 객체와 파일만 알려주면 알아서 처리해 주는 거지.”

루나가 보여준 코드는 놀랍도록 간결했다.

# '방문 수령' 서비스처럼 한번에 처리하기
with open('agent_state_direct.json', 'w', encoding='utf-8') as f:
    json.dump(agent_state_dict, f, ensure_ascii=False, indent=2)

솔라의 눈이 커졌다. dumps가 아니라 dump였다. s가 빠진 함수는 두 개의 인자를 받았다. 하나는 저장할 파이썬 딕셔너리, 다른 하나는 파일 객체 f였다. 코드를 실행하자 agent_state_direct.json 파일이 생겼고, 열어보니 내용은 더욱 놀라웠다.

{
  "last_user_message": "오늘 날씨 어때?",
  "last_agent_response": "서울은 현재 맑음입니다.",
  "conversation_turns": 3
}

“와, 줄 바꿈까지 예쁘게 정리해서 저장해 주네!”

indent 옵션 덕분이야. 사람이 읽기 좋게 만들어주지. 그럼 파일을 읽어올 때도 ‘방문 수령’처럼 할 수 있겠지?”

루나의 말에 솔라는 망설임 없이 코드를 작성했다. dump의 짝은 load일 것이었다.

# 파일에서 직접 객체로 불러오기
with open('agent_state_direct.json', 'r', encoding='utf-8') as f:
    restored_direct_dict = json.load(f)

print(restored_direct_dict == agent_state_dict)

결과는 역시 True. 두 단계로 나누어 했던 작업이 각각 한 줄의 코드로 깔끔하게 정리되었다. 솔라는 두 쌍의 함수 이름을 나란히 바라보았다.

  • dumps / loads
  • dump / load

그제야 명확한 규칙이 보였다. 솔라는 손가락으로 허공에 s를 그리며 중얼거렸다.

“아하! s가 붙으면 ‘string’을 다루는 거고, s가 없으면 파일 객체를 직접 다루는 거구나! dumps는 객체를 문자열로, loads는 문자열을 객체로. 그리고 dump는 객체를 파일로, load는 파일에서 객체로. 간단한 규칙이었네!”

모든 조각이 맞춰졌다. 이제 솔라는 파이썬 딕셔너리와 JSON 사이의 관계, 메모리 내 변환, 그리고 파일 입출력까지 완벽하게 꿰뚫게 되었다.

솔라는 다시 자신의 원래 목표였던 에이전트 코드 창으로 돌아갔다. 더 이상 망설임은 없었다. 에이전트의 대화가 끝나는 지점에 상태를 저장하는 함수를, 그리고 에이전트가 처음 시작되는 지점에 상태를 불러오는 함수를 자신 있게 추가했다.

# agent.py - 에이전트 코드의 일부

def save_state(state_dict, filepath='agent_state.json'):
    """에이전트의 현재 상태(dict)를 JSON 파일로 저장합니다."""
    with open(filepath, 'w', encoding='utf-8') as f:
        json.dump(state_dict, f, ensure_ascii=False, indent=2)
    print(f"상태가 {filepath}에 저장되었습니다.")

def load_state(filepath='agent_state.json'):
    """JSON 파일에서 상태를 불러와 dict로 반환합니다."""
    try:
        with open(filepath, 'r', encoding='utf-8') as f:
            return json.load(f)
    except FileNotFoundError:
        return None # 저장된 파일이 없으면 초기 상태로 시작

코드를 실행하고 에이전트와 몇 마디 대화를 나눈 뒤 프로그램을 종료했다. 프로젝트 폴더에는 사람이 읽기 좋은 형태로 에이전트의 마지막 기억이 담긴 agent_state.json 파일이 얌전히 놓여 있었다. 다시 프로그램을 실행하자, 에이전트는 방금 전의 대화를 기억하고 있었다. 데이터는 더 이상 휘발되지 않았다. 명확한 규칙을 따르는 파일 ↔ 객체 변환 덕분이었다. 솔라는 화면 속에서 되살아난 에이전트의 응답을 보며 조용히 미소 지었다. 이제 그녀의 에이전트는 기억을 가질 수 있게 되었다.