LLMOps 35
SQLite로 Agent 결과와 평가 데이터를 저장하기
작은 프로젝트에서는 CSV나 DataFrame만 있으면 충분하다고 생각하기 쉽다.
근거 · 교안 p134-p140
1장: CSV/DataFrame, 정말 충분할까? 데이터 무결성의 균열
솔라는 노트북 화면에 떠 있는 터미널과 코드 에디터를 번갈아 보며 흐뭇한 미소를 지었다. 자신이 만든 작은 에이전트가 제법 그럴듯한 결과물을 내놓기 시작한 것이다. 솔라는 에이전트의 성능을 꾸준히 추적하기 위해, 실행 결과와 평가 점수를 꼬박꼬박 CSV 파일에 기록해두었다.
agent_evals.csv
파일에는 agent_id, input_text, output_text, score, timestamp 같은 열들이 차곡차곡 쌓여갔다. 처음에는 몇 줄에 불과했던 데이터가 어느새 수백 줄을 훌쩍 넘겼다.
“작은 프로젝트니까 이 정도면 충분하지.”
솔라는 혼잣말을 하며 판다스(Pandas)로 데이터를 불러와 간단한 통계를 확인하려 했다. 그런데 데이터를 살펴보던 솔라의 미간이 살짝 찌푸려졌다. 비슷한 입력 값에 대한 결과가 여러 번 기록된 것처럼 보이는 행들이 눈에 띄었다.
“어라? 왜 똑같은 게 또 들어갔지?”
마침 방에 들어온 언니 루나가 솔라의 노트북 화면을 흘긋 보았다.
“데이터 정리하는구나.”
“응. 그런데 좀 이상해. 에이전트 테스트하다가 같은 걸 몇 번 다시 실행했나 봐. 중복 데이터가 꽤 있네. 뭐, 판다스로 금방 정리할 수 있겠지.”
솔라는 대수롭지 않게 말하며 익숙하게 코드를 작성하기 시작했다. drop_duplicates() 함수 한 줄이면 해결될 문제라고 생각했다.
import pandas as pd
df = pd.read_csv('agent_evals.csv')
print(f"원본 데이터 개수: {len(df)}")
df_no_duplicates = df.drop_duplicates()
print(f"중복 제거 후 데이터 개수: {len(df_no_duplicates)}")
결과가 출력되었다.
원본 데이터 개수: 352
중복 제거 후 데이터 개수: 348
“에게? 겨우 4개 줄었네. 이상하다, 눈으로 볼 땐 훨씬 많아 보였는데.”
솔라는 고개를 갸웃거렸다. 루나는 화면을 가만히 들여다보더니, timestamp 열을 손가락으로 가리켰다.
“이것들, 다른 행으로 인식된 거 아닐까? 실행할 때마다 타임스탬프는 계속 바뀌었을 테니까.”
루나의 말대로였다. agent_id나 input_text가 완전히 같더라도, 실행 시각을 기록한 timestamp가 미세하게 달라 판다스는 이들을 별개의 데이터로 취급한 것이다.
“아! 맞네. 타임스탬프 때문에… 그럼 특정 열 기준으로 중복을 찾아야겠다.”
솔라는 재빨리 코드를 수정했다. input_text를 기준으로 중복된 데이터를 제거하기로 했다.
# input_text 열을 기준으로 중복 제거
df_no_duplicates_subset = df.drop_duplicates(subset=['input_text'])
print(f"기준 지정 후 데이터 개수: {len(df_no_duplicates_subset)}")
기준 지정 후 데이터 개수: 215
이번에는 꽤 많은 데이터가 걸러졌다. 만족스러운 표정의 솔라를 보며 루나가 조용히 물었다.
“좋아. 그런데 만약 input_text가 같은 데이터가 여러 개 있을 때, 어떤 걸 남긴 거야?”
“어? 그냥… 판다스가 알아서 제일 위에 있는 걸 남겼겠지?”
“그럼 제일 위에 있는 게 우리가 남기고 싶은 데이터가 맞을까? 가장 최근 결과일까? 아니면 평가 점수가 가장 높았던 결과일까?”
”…그러게.”
솔라의 표정이 다시 심각해졌다. 단순히 중복을 제거하는 문제가 아니었다. 어떤 데이터를 ‘진짜’로 인정하고 남길 것인지에 대한 기준이 없었다. CSV 파일에 무작정 데이터를 추가하는 방식은 이런 규칙을 적용할 방법이 없었다. 매번 파일을 전부 읽어 들여, 복잡한 논리를 담은 코드를 실행해서 ‘청소’해야만 했다.
솔라는 한숨을 쉬며 중얼거렸다.
“데이터를 그냥 파일에 계속 덧붙이기만 하니까 이런 문제가 생기는구나. 나중에 정리하려고 하니 뭘 기준으로 삼아야 할지도 헷갈리고. 애초에 데이터가 저장될 때부터 중복된 건 안 들어가게 막을 수는 없을까?”
그 말을 기다렸다는 듯 루나가 입을 열었다.
“바로 그게 핵심이야. 데이터를 파일에 쓰는 것과, ‘데이터베이스’에 저장하는 것의 근본적인 차이지.”
루나는 코드 에디터에 새 파일을 열고 간단히 정리해주었다.
파일(CSV)에 추가하기
- 규칙 없음
- 일단 쓰고 본다.
- 중복? 나중에 알아서 처리해야 함.
데이터베이스에 저장하기
- 규칙 있음 (예: 이 데이터는 유일해야 한다)
- 저장하기 전에 규칙을 검사한다.
- 규칙을 어기면 저장을 거부한다.
“데이터베이스는 데이터를 저장하기 전에 ‘이 데이터는 이미 있는가?’ 같은 규칙을 먼저 확인해. 예를 들어 솔라 네가 ‘input_text는 절대 중복되면 안 돼’라는 규칙을 정해두면, 똑같은 input_text를 가진 데이터를 또 저장하려고 할 때 데이터베이스가 그냥 막아버리는 거야. 이런 고유 식별 규칙을 ‘기본 키(Primary Key)‘라고 불러.”
솔라는 무릎을 탁 쳤다. 매번 판다스로 씨름하며 어떤 데이터를 지울지 고민하는 대신, 처음부터 잘못된 데이터가 들어올 수 없는 구조를 만드는 것. 훨씬 근본적인 해결책이었다. 데이터가 뒤죽박죽 섞인 채 쌓이는 것을 보고만 있다가 나중에 허둥지둥 청소하는 것과는 차원이 다른 문제였다.
“와… 그럼 매번 코드로 씨름할 필요가 없겠네. 그런데 데이터베이스는 작은 프로젝트에 쓰기엔 너무 무겁고 복잡하지 않아? 당장 어떻게 시작해야 할지 감도 안 오는데.”
솔라의 머릿속에는 거대한 서버와 복잡한 설치 과정이 떠올랐다. CSV 파일 하나로 시작했던 작은 프로젝트에 너무 거창한 해결책은 아닐까. 고민의 무게가 키보드 위에 놓인 솔라의 손으로 고스란히 전해졌다.
2장: Agent 결과 데이터, SQLite 테이블에 첫 발자국
솔라는 키보드 위에 손을 얹은 채 꼼짝도 하지 않았다. ‘데이터베이스’라는 단어가 머릿속을 맴돌며 무겁게 짓눌렀다. 중복 데이터 문제를 해결할 근본적인 방법이라는 건 알겠지만, 거대한 서버, 복잡한 설치, 어려운 관리 도구 같은 이미지가 먼저 떠올라 선뜻 시작할 엄두가 나지 않았다. 작은 에이전트 프로젝트에 너무 거창한 칼을 빼 드는 건 아닐까.
그때, 루나가 솔라의 옆에 다가와 새 코드 편집기 창을 하나 띄웠다. 텅 빈 화면이 깜빡였다. 루나는 CSV 파일이나 복잡한 서버 설정 화면 대신, 그저 하얀 편집기 창을 가리켰다.
“여기에 딱 한 줄만 써봐.”
루나가 나지막이 말했다.
import sqlite3
솔라는 영문을 모른 채 일단 따라 쳤다. 파이썬의 기본 라이브러리를 불러오는 흔한 코드였다.
“그리고 다음 줄에 이렇게.”
conn = sqlite3.connect("agent_evals.db")
솔라가 코드를 마저 입력하고 실행했다. 아무런 메시지도 출력되지 않았다. 하지만 파일 탐색기를 열어보자, 프로젝트 폴더 안에 agent_evals.csv 파일과 나란히 agent_evals.db 라는 생소한 파일이 만들어져 있었다. 크기는 0kb. 텅 빈 파일이었다.
솔라는 어리둥절한 표정으로 루나를 쳐다보았다. “이게… 다야? 데이터베이스를 만든 게?”
“응. 그게 다야. 솔라 네가 생각한 거대한 서버는 필요 없어. SQLite는 이렇게 파일 하나가 데이터베이스 그 자체거든. 가볍고, 별도 설치도 필요 없어서 우리 같은 작은 프로젝트에 쓰기 딱 좋아.”
솔라의 머릿속을 채우고 있던 복잡한 이미지가 단숨에 깨져나갔다. 데이터베이스가 그냥 파일 하나일 수 있다는 사실이 신선한 충격으로 다가왔다. CSV 파일처럼 가볍게 시작할 수 있다는 생각에 마음이 한결 놓였다.
“와… 그럼 이 파일 안에 데이터를 넣는 건 어떻게 해? CSV처럼 그냥 열어서 쓸 수는 없는 거잖아.”
“맞아. 이 파일은 정해진 약속(SQL)을 통해서만 내용을 바꿀 수 있어. 이제 그 약속을 보낼 ‘창구’를 만들고, 데이터가 들어갈 ‘틀’을 짜줘야 해.”
루나는 방금 작성한 코드 아래에 몇 줄을 더 추가하며 설명했다.
import sqlite3
# 1. DB에 연결 (파일이 없으면 새로 생성)
conn = sqlite3.connect("agent_evals.db")
# 2. SQL을 실행할 '커서' 생성
cursor = conn.cursor()
# 3. 테이블 생성
cursor.execute("""
CREATE TABLE IF NOT EXISTS evaluations (
id INTEGER PRIMARY KEY,
agent_id TEXT NOT NULL,
input_text TEXT NOT NULL UNIQUE,
output_text TEXT,
score REAL,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
# 4. 변경사항 저장
conn.commit()
# 5. 연결 종료
conn.close()
CREATE TABLE 이라는 낯선 구문이 나타나자 솔라는 다시 긴장했다. “코드가 갑자기 길어졌네. INTEGER PRIMARY KEY, NOT NULL, UNIQUE… 이건 또 다 뭐야?”
“어렵게 보지 마. 이게 바로 우리가 지난번에 이야기했던 ‘규칙’을 정하는 부분이야.”
루나는 UNIQUE 라는 단어를 콕 집었다.
“이 UNIQUE 규칙 덕분에, 우리는 input_text 열에 똑같은 값을 두 번 넣을 수 없게 돼. CSV 파일에서 중복을 막기 위해 판다스 코드로 씨름할 필요 없이, 데이터베이스가 알아서 막아주는 거지. PRIMARY KEY는 각 데이터 행을 식별하는 유일한 번호표 같은 거고.”
솔라는 비로소 고개를 끄덕였다. 복잡해 보이던 SQL 구문이 사실은 데이터의 ‘무결성’을 지키기 위한 약속이자 설계도라는 것을 깨달았다. 그냥 데이터를 쌓는 것이 아니라, 처음부터 구조와 규칙을 가진 공간을 만드는 과정이었다.
“좋아, 그럼 틀은 만들었고… 이제 진짜 데이터를 넣어보자.”
루나는 방금 작성했던 CREATE TABLE 코드를 지우고, 그 자리에 새로운 코드를 넣었다. 이미 테이블은 한 번 만들어졌으니, 이제 데이터를 넣는 코드만 있으면 됐다.
# ... (connect, cursor 부분은 동일)
# 새로운 데이터 준비
new_eval = (
"agent_v2",
"오늘 날씨 어때?",
"서울은 현재 맑고 기온은 25도입니다.",
9.5
)
# 데이터 삽입
cursor.execute("""
INSERT INTO evaluations (agent_id, input_text, output_text, score)
VALUES (?, ?, ?, ?)
""", new_eval)
conn.commit() # 변경사항 최종 저장
conn.close()
“INSERT INTO는 테이블에 데이터를 넣겠다는 뜻이고, 물음표(?)들은 나중에 채워 넣을 자리 표시자야. 이렇게 값을 분리해서 넘겨주면 더 안전하거든.”
솔라는 코드를 실행했다. 이번에도 역시 아무런 메시지가 없었다. 그저 프로그램이 조용히 끝날 뿐이었다. agent_evals.db 파일의 크기는 여전히 0kb로 보였다.
“성공한 거 맞아? 아무 변화가 없는 것 같은데.”
“응, 성공이야. 데이터는 이제 안전하게 파일 안에 기록됐어. commit이 우리가 한 작업을 파일에 완전히 저장하라는 명령어거든. 은행에서 송금 버튼을 누르는 것처럼, 최종 확정을 짓는 단계라고 생각하면 돼.”
솔라는 자신의 손으로 직접 데이터베이스 파일을 만들고, 규칙이 있는 테이블을 정의하고, 첫 데이터를 삽입했다는 사실이 신기했다. 복잡한 절차와 씨름할 것이라 예상했지만, 실제로는 몇 줄의 파이썬 코드로 모든 것이 끝났다. 두려움은 사라지고, 새로운 도구를 손에 넣었다는 자신감이 그 자리를 채웠다.
“생각보다 훨씬 간단하네! 그럼 이제 CSV 파일처럼 열어보지 않고도 데이터가 잘 들어갔는지 확인할 수 있어야 하잖아. 그리고 만약 점수를 잘못 입력했으면 수정도 해야 하고. 그건 어떻게 해?”
솔라의 눈이 반짝였다. 이제 막 데이터를 ‘만들고(Create)’ ‘넣었을(Insert)’ 뿐인데, 벌써부터 데이터를 ‘읽고(Read)’, ‘수정하는(Update)’ 다음 단계가 궁금해진 것이다.
3장: Agent 데이터의 생애 주기 관리: 조회, 수정, 삭제
솔라의 노트북 화면에는 agent_evals.db 파일 아이콘이 덩그러니 놓여 있었다. 방금 전 파이썬 스크립트를 실행해 첫 번째 평가 데이터를 성공적으로 ‘삽입’했지만, 정작 그 결과는 눈으로 확인할 수 없었다. CSV 파일처럼 더블클릭해서 열 수도, 메모장으로 열어볼 수도 없었다. 호기심에 텍스트 편집기로 파일을 열어보자, 의미를 알 수 없는 기호와 깨진 문자들만 화면을 가득 채울 뿐이었다. 데이터가 저 검은 상자 안에 갇혀버린 기분이었다.
답답한 마음에 솔라는 중얼거렸다. “결국 데이터를 보려면 다시 파이썬으로 불러와야 한다는 거잖아? 그냥 판다스로 이 파일을 통째로 읽어서 DataFrame으로 보면 훨씬 편하지 않을까?” 익숙하고 강력한 도구인 판다스를 사용하면 금방 해결될 문제처럼 보였다.
그 모습을 지켜보던 루나가 조용히 말했다. “물론 그것도 방법이야. 하지만 데이터가 수만 건, 수십만 건이 되면 어떨까? 점수가 9.5점 이상인 딱 하나의 결과만 보고 싶은데, 그때마다 수십만 건의 데이터를 전부 메모리로 불러오는 건 비효율적이지.”
“음… 그렇긴 하네.”
“데이터베이스는 ‘전부 다 줘’가 아니라, ‘이 조건에 맞는 것만 콕 집어서 줘’라고 요청할 수 있어. 우리가 데이터를 넣을 때 규칙을 정했던 것처럼, 꺼낼 때도 규칙을 사용할 수 있는 거지.”
루나는 솔라의 코드 편집기에서 이전 INSERT 구문을 지우고, 그 자리에 새로운 SQL 명령어를 입력하도록 안내했다. 데이터를 조회하는 가장 기본적인 명령어, SELECT였다.
# ... (connect, cursor 부분은 동일)
# 1. 데이터 조회하기 (모든 데이터)
cursor.execute("SELECT * FROM evaluations")
rows = cursor.fetchall()
print(rows)
# ... (close 부분은 동일)
솔라가 코드를 실행하자, 터미널에 익숙한 데이터가 담긴 리스트가 출력되었다.
[(1, 'agent_v2', '오늘 날씨 어때?', '서울은 현재 맑고 기온은 25도입니다.', 9.5, '2023-10-27 10:30:00')]
“오, 나온다! 상자 안에 뭐가 들었는지 드디어 봤네.”
솔라의 얼굴에 화색이 돌았다. 루나는 이어서 말했다. “좋아. 그럼 이번엔 ‘agent_v2’가 생성했고, 점수가 9점 이상인 결과만 가져와볼까?”
솔라는 잠시 고민하더니, SELECT 구문 뒤에 WHERE라는 조건을 덧붙여 코드를 수정했다.
cursor.execute("SELECT * FROM evaluations WHERE agent_id = ? AND score >= ?", ("agent_v2", 9.0))
rows = cursor.fetchall()
print(rows)
결과는 이전과 같았다. 현재 데이터가 한 건뿐이기 때문이다. 하지만 솔라는 중요한 차이를 깨달았다. 단순히 모든 데이터를 가져와 파이썬으로 필터링하는 것이 아니었다. 데이터베이스에 직접 원하는 조건을 알려주고, 그 결과만 정확히 건네받는 방식이었다. 데이터가 아무리 많아져도 필요한 부분만 효율적으로 가져올 수 있다는 뜻이었다. 판다스로 모든 걸 해결하려던 생각이 조금씩 바뀌기 시작했다.
출력된 결과를 가만히 보던 솔라가 아차 하는 표정을 지었다. “아, 맞다. 이 점수 9.5가 아니라 9.2였는데. 잘못 입력했네. 이거 수정하려면 어떻게 해야 해?”
“그것도 데이터베이스에 직접 명령하면 돼.”
루나의 도움을 받아 솔라는 이번엔 UPDATE라는 구문을 작성했다. 어떤 데이터를 수정할지 정확히 지정하기 위해, 각 행의 고유 번호표인 id를 WHERE 조건에 사용했다.
# 2. 데이터 수정하기
cursor.execute("UPDATE evaluations SET score = ? WHERE id = ?", (9.2, 1))
conn.commit() # 변경사항 최종 저장!
“수정이나 삭제처럼 데이터를 바꾸는 작업 뒤에는 꼭 commit을 해줘야 해. 그래야 변경사항이 파일에 영구적으로 기록되거든.”
솔라는 코드를 실행하고, 확인을 위해 아까 작성했던 SELECT 코드를 다시 실행했다. 화면에 출력된 결과는 분명히 달라져 있었다.
[(1, 'agent_v2', '오늘 날씨 어때?', '서울은 현재 맑고 기온은 25도입니다.', 9.2, '2023-10-27 10:30:00')]
점수가 9.5에서 9.2로 바뀌어 있었다. CSV 파일이었다면 파일을 열고, 해당 줄을 찾아서 숫자를 바꾸고 저장해야 했을 것이다. 판다스를 썼다면 전체 데이터를 불러와 특정 행의 값을 바꾼 뒤, 다시 전체 파일을 덮어써야 했다. 하지만 SQLite는 단 몇 줄의 명령으로 정확하게 원하는 데이터만 ‘수술’하듯 수정했다.
“그럼 이 평가 결과 자체가 잘못된 거라서 아예 지워버리고 싶을 땐?”
솔라는 데이터 관리의 마지막 퍼즐인 ‘삭제’에 대해 물었다. 루나는 익숙하게 DELETE 구문을 알려주었다.
# 3. 데이터 삭제하기
cursor.execute("DELETE FROM evaluations WHERE id = ?", (1,))
conn.commit() # 이것도 잊지 말고!
솔라는 마지막 코드를 실행했다. 그리고 다시 한번 모든 데이터를 조회하는 SELECT * 코드를 실행했다. 터미널에는 텅 빈 리스트 [] 만이 출력되었다. 데이터는 흔적도 없이 사라졌다.
솔라는 의자에 등을 기댔다. 데이터를 만들고(Create), 읽고(Read), 수정하고(Update), 삭제하는(Delete) 이른바 CRUD라 불리는 데이터의 전체 생애 주기를 자신의 손으로 직접 경험한 순간이었다.
이전에는 데이터를 ‘관리’한다는 것이 판다스로 CSV 파일을 불러와 이리저리 가공한 뒤 다시 저장하는, 메모리 위에서 벌어지는 임시 작업에 가까웠다. 하지만 이제는 달랐다. 데이터베이스에 명확한 명령을 보내 영구 저장소의 데이터를 직접 바꾸는, 훨씬 근본적이고 안정적인 방식이었다. 거대한 데이터를 모두 메모리에 올릴 필요도, 매번 파일을 통째로 덮어쓸 위험을 감수할 필요도 없었다.
솔라는 문득 화면 한쪽에 열어두었던 agent_evals.csv 파일을 떠올렸다. 중복과 오류로 뒤죽박죽이었던 바로 그 파일. 이제 저 파일을 어떻게 ‘구조화’해야 할지 길이 보였다.
솔라는 키보드에 손을 얹고 migrate_to_sqlite.py라는 새 파일을 만들었다. 그리고는 거침없이 주석으로 계획을 써 내려가기 시작했다.
# agent_evals.csv의 데이터를 agent_evals.db로 이전하는 스크립트
# 1. Pandas로 CSV 파일 읽기
# 2. 'input_text' 기준 중복 데이터 정리. 어떤 데이터를 남길지 기준 정하기 (예: score가 가장 높은 것)
# 3. SQLite DB에 연결
# 4. 정리된 DataFrame을 한 줄씩 읽으면서 DB에 INSERT
# - 테이블에 UNIQUE 제약 조건이 있으므로, 만약의 경우를 대비해 try-except 구문 사용하기
# 5. 모든 데이터 이전 후 commit
# 이 작업이 끝나면, 더 이상 CSV 파일은 필요 없다.
더 이상 막막하지 않았다. 복잡하고 무겁게만 느껴졌던 데이터베이스는 이제 에이전트의 결과를 영구적이고 일관성 있게 관리해 줄 든든한 운영 보조 저장소가 되어 있었다. 솔라는 비로소 자신의 작은 프로젝트를 위한 진짜 ‘데이터 관리’의 첫발을 내디뎠다.