본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 27. · 4 Views
Database 도구 완벽 가이드
LLM과 데이터베이스를 연결하여 자연어로 SQL을 실행하는 방법을 배웁니다. SQL 쿼리 실행부터 보안까지 실무에서 바로 활용할 수 있는 내용을 담았습니다.
목차
1. SQL 쿼리 실행
김개발 씨는 오늘 새로운 프로젝트를 맡았습니다. LLM 챗봇이 회사 데이터베이스에서 직접 정보를 조회해 답변하는 시스템을 만들어야 합니다.
"파이썬에서 데이터베이스에 어떻게 연결하고 쿼리를 실행하지?" 김개발 씨는 문서를 뒤적이기 시작했습니다.
SQL 쿼리 실행은 프로그램에서 데이터베이스와 대화하는 가장 기본적인 방법입니다. 마치 도서관 사서에게 "파이썬 책 있나요?"라고 묻는 것처럼, 프로그램이 데이터베이스에 질문을 던지고 답을 받아오는 과정입니다.
이것을 제대로 이해하면 어떤 데이터든 자유자재로 다룰 수 있게 됩니다.
다음 코드를 살펴봅시다.
import sqlite3
# 데이터베이스 연결 생성
conn = sqlite3.connect('company.db')
cursor = conn.cursor()
# SQL 쿼리 실행
query = "SELECT name, department FROM employees WHERE salary > 50000"
cursor.execute(query)
# 결과 가져오기
results = cursor.fetchall()
for row in results:
print(f"이름: {row[0]}, 부서: {row[1]}")
# 연결 종료
conn.close()
김개발 씨는 입사 3개월 차 주니어 개발자입니다. 오늘 받은 미션은 LLM 챗봇이 데이터베이스에서 직원 정보를 조회하도록 만드는 것이었습니다.
하지만 막상 코드를 작성하려니 어디서부터 시작해야 할지 막막했습니다. 선배 개발자 박시니어 씨가 다가와 말했습니다.
"데이터베이스 연동은 생각보다 간단해요. 세 단계만 기억하면 됩니다." 그렇다면 SQL 쿼리 실행이란 정확히 무엇일까요?
쉽게 비유하자면, SQL 쿼리 실행은 마치 레스토랑에서 주문하는 것과 같습니다. 먼저 레스토랑에 들어가고(연결), 메뉴를 주문하고(쿼리 실행), 음식을 받아오는(결과 조회) 과정을 거칩니다.
데이터베이스도 마찬가지로 연결-실행-조회의 세 단계를 따릅니다. 첫 번째 단계는 데이터베이스 연결입니다.
sqlite3.connect() 함수로 데이터베이스 파일에 연결합니다. 이때 반환되는 connection 객체가 바로 데이터베이스와의 통신 채널이 됩니다.
마치 전화를 걸어 상대방과 연결되는 것과 같습니다. 두 번째 단계는 커서 생성입니다.
cursor()는 데이터베이스에서 결과를 한 줄씩 가리키는 포인터 역할을 합니다. 도서관에서 책장 사이를 오가며 책을 찾아주는 사서와 비슷합니다.
이 커서를 통해 쿼리를 실행하고 결과를 받아옵니다. 세 번째 단계는 쿼리 실행입니다.
cursor.execute()에 SQL 문장을 전달하면 데이터베이스가 해당 명령을 처리합니다. 위 코드에서는 연봉이 50000 이상인 직원의 이름과 부서를 조회하고 있습니다.
네 번째 단계는 결과 가져오기입니다. fetchall()은 쿼리 결과 전체를 리스트로 반환합니다.
각 행은 튜플 형태로 저장되어 인덱스로 접근할 수 있습니다. 만약 결과가 많다면 fetchone()으로 한 줄씩 가져올 수도 있습니다.
마지막으로 연결 종료가 필요합니다. conn.close()를 호출하지 않으면 데이터베이스 연결이 계속 유지되어 리소스가 낭비됩니다.
전화 통화가 끝나면 끊어야 하는 것처럼, 데이터베이스 작업이 끝나면 반드시 연결을 닫아야 합니다. 실제 현업에서는 with 문을 사용하여 연결을 자동으로 관리하는 경우가 많습니다.
with sqlite3.connect('company.db') as conn: 형태로 작성하면 블록이 끝날 때 자동으로 연결이 닫힙니다. 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 커넥션을 닫지 않는 것입니다. 이렇게 하면 동시 접속 제한에 걸리거나 메모리 누수가 발생할 수 있습니다.
다시 김개발 씨의 이야기로 돌아가 봅시다. 박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다.
"아, 연결-실행-조회-종료 네 단계군요!" 이제 첫 번째 관문을 통과했습니다.
실전 팁
💡 - 항상 try-finally 또는 with 문으로 연결 종료를 보장하세요
- 대량의 데이터는 fetchall() 대신 fetchone()이나 fetchmany()를 사용하세요
2. 결과 포맷팅
김개발 씨가 쿼리 실행에 성공했습니다. 하지만 결과가 [(1, '김철수', 'IT'), (2, '이영희', 'HR')] 같은 튜플 리스트로 나왔습니다.
"이걸 LLM에게 어떻게 전달하지? 사람이 읽기 좋은 형태로 바꿔야 할 것 같은데..." 김개발 씨는 결과 포맷팅의 필요성을 깨달았습니다.
결과 포맷팅은 데이터베이스에서 가져온 원시 데이터를 사람이나 LLM이 이해하기 쉬운 형태로 변환하는 과정입니다. 마치 외국어 문서를 번역하는 것처럼, 기계적인 데이터를 의미 있는 정보로 바꿔주는 역할을 합니다.
특히 LLM 도구에서는 포맷팅이 응답 품질에 직접적인 영향을 미칩니다.
다음 코드를 살펴봅시다.
import sqlite3
def format_results(cursor, rows):
# 컬럼명 추출
columns = [desc[0] for desc in cursor.description]
# 딕셔너리 형태로 변환
results = []
for row in rows:
results.append(dict(zip(columns, row)))
# 텍스트 테이블 형식으로 출력
header = " | ".join(columns)
separator = "-" * len(header)
print(header)
print(separator)
for r in results:
print(" | ".join(str(v) for v in r.values()))
김개발 씨는 쿼리 실행까지는 성공했지만, 새로운 문제에 봉착했습니다. 데이터베이스에서 반환된 결과가 (1, '김철수', 50000000) 같은 형태였는데, 이게 무슨 의미인지 알 수가 없었습니다.
첫 번째 값이 사원번호인지 부서코드인지도 불분명했습니다. 박시니어 씨가 설명을 이어갔습니다.
"원시 데이터는 맥락이 없어요. 컬럼명과 함께 포맷팅해야 의미가 살아납니다." 결과 포맷팅이 중요한 이유는 무엇일까요?
첫째, LLM의 이해도를 높입니다. LLM에게 (1, '김철수', 50000000)만 던져주면 해석하기 어렵습니다.
하지만 {"사원번호": 1, "이름": "김철수", "연봉": 50000000} 형태로 전달하면 LLM이 정확하게 이해하고 답변할 수 있습니다. 둘째, 디버깅이 쉬워집니다.
개발 중에 데이터를 확인할 때 컬럼명이 있으면 어떤 값이 잘못되었는지 바로 파악할 수 있습니다. 코드를 자세히 살펴보겠습니다.
가장 핵심은 cursor.description 속성입니다. 이 속성에는 쿼리 결과의 메타데이터가 담겨 있습니다.
각 컬럼의 이름, 타입, 크기 등의 정보를 포함하고 있으며, description[0]이 컬럼명입니다. 다음으로 dict(zip()) 패턴을 주목하세요.
zip(columns, row)는 컬럼명과 값을 쌍으로 묶어줍니다. 예를 들어 zip(['id', 'name'], [1, '김철수'])는 [('id', 1), ('name', '김철수')]가 됩니다.
이것을 dict()로 감싸면 {'id': 1, 'name': '김철수'}가 완성됩니다. 실무에서는 여러 가지 포맷을 상황에 맞게 사용합니다.
JSON 형식은 API 응답에 적합하고, 테이블 형식은 사람이 직접 읽을 때 좋습니다. 마크다운 테이블은 LLM이 보기에도, 사람이 보기에도 좋은 중간 형태입니다.
LLM 도구에서 특히 효과적인 포맷은 구조화된 텍스트입니다. 예를 들어 "직원 정보:\n- 이름: 김철수\n- 부서: IT\n- 연봉: 5000만원" 형태로 전달하면 LLM이 자연스럽게 이 정보를 활용하여 답변을 생성합니다.
주의할 점도 있습니다. 결과가 너무 많으면 토큰 제한에 걸릴 수 있습니다.
이럴 때는 상위 N개만 보여주거나, 요약 정보를 함께 제공하는 것이 좋습니다. 김개발 씨는 이제 깔끔하게 포맷된 결과를 보며 미소 지었습니다.
"이제 LLM이 이 데이터를 제대로 이해할 수 있겠네요!"
실전 팁
💡 - 대량 데이터는 상위 N개 + 총 개수 요약으로 토큰을 절약하세요
- 숫자는 천 단위 구분자나 원화 표시 등 읽기 쉬운 형식으로 변환하세요
3. SQL 주입 공격 방지
김개발 씨가 만든 챗봇이 잘 동작했습니다. 그런데 보안팀에서 긴급 연락이 왔습니다.
"지금 당장 서비스 중단하세요! SQL 주입 취약점이 발견됐습니다." 김개발 씨는 식은땀을 흘리며 물었습니다.
"SQL 주입이 뭔가요?"
SQL 주입 공격은 악의적인 사용자가 입력값에 SQL 코드를 삽입하여 데이터베이스를 조작하는 해킹 기법입니다. 마치 건물 출입증에 "AND 관리자 권한 부여"라고 적어서 보안을 뚫는 것과 같습니다.
이를 방지하지 않으면 데이터 유출, 삭제, 심지어 서버 장악까지 당할 수 있습니다.
다음 코드를 살펴봅시다.
import sqlite3
# 위험한 방법 - 절대 사용 금지!
user_input = "'; DROP TABLE employees; --"
dangerous_query = f"SELECT * FROM users WHERE name = '{user_input}'"
# 안전한 방법 - 파라미터 바인딩 사용
conn = sqlite3.connect('company.db')
cursor = conn.cursor()
# 물음표 플레이스홀더 사용
safe_query = "SELECT * FROM users WHERE name = ? AND department = ?"
cursor.execute(safe_query, (user_input, "IT"))
# 이름 있는 플레이스홀더 사용
named_query = "SELECT * FROM users WHERE name = :name"
cursor.execute(named_query, {"name": user_input})
김개발 씨는 보안팀의 연락을 받고 당황했습니다. 분명히 잘 동작하는 코드였는데, 무엇이 문제라는 걸까요?
박시니어 씨가 급히 달려와 화면을 보여줬습니다. 누군가 검색창에 이상한 문자열을 입력했더니, 데이터베이스의 모든 직원 정보가 노출된 것이었습니다.
SQL 주입 공격은 어떻게 작동할까요? 쉽게 비유하자면, 이것은 마치 도서관 검색 시스템의 허점을 이용하는 것과 같습니다.
정상적으로는 "파이썬"을 검색하면 파이썬 책만 나와야 합니다. 하지만 공격자가 "파이썬' OR '1'='1"을 입력하면 모든 책이 검색됩니다.
왜냐하면 '1'='1'은 항상 참이기 때문입니다. 위험한 코드를 살펴보겠습니다.
f"SELECT * FROM users WHERE name = '{user_input}'" 형태로 문자열을 직접 조합하면 위험합니다. 만약 user_input이 "'; DROP TABLE employees; --"라면, 최종 쿼리는 이렇게 됩니다: SELECT * FROM users WHERE name = ''; DROP TABLE employees; --' 세미콜론으로 쿼리가 끊기고, DROP TABLE 명령이 실행되어 직원 테이블 전체가 삭제됩니다.
마지막 --는 뒤의 내용을 주석 처리하여 문법 오류를 피합니다. 이를 방지하는 방법은 파라미터 바인딩입니다.
cursor.execute(query, params) 형태로 쿼리와 파라미터를 분리하면, 데이터베이스 드라이버가 자동으로 특수문자를 이스케이프 처리합니다. 물음표(?) 플레이스홀더는 순서대로 값을 바인딩합니다.
튜플로 전달하면 첫 번째 물음표에 첫 번째 값, 두 번째 물음표에 두 번째 값이 들어갑니다. 이름 있는 플레이스홀더(:name)는 딕셔너리로 값을 전달합니다.
파라미터가 많을 때 순서를 신경 쓰지 않아도 되어 더 안전합니다. LLM 도구에서는 특히 주의가 필요합니다.
사용자가 자연어로 "모든 직원 삭제해줘"라고 요청할 수도 있기 때문입니다. 따라서 쿼리 화이트리스트나 읽기 전용 연결을 사용하는 것이 좋습니다.
김개발 씨는 서둘러 모든 코드를 파라미터 바인딩 방식으로 수정했습니다. 보안팀의 재점검 후, 서비스는 다시 정상화되었습니다.
"앞으로는 절대 문자열 조합으로 쿼리를 만들지 않겠습니다!"
실전 팁
💡 - 문자열 포맷팅(f-string, format, %)으로 SQL을 만들지 마세요
- ORM(SQLAlchemy 등)을 사용하면 파라미터 바인딩이 자동 적용됩니다
4. 실습 DB 도구 구축
이론은 충분합니다. 김개발 씨는 이제 실제로 LLM이 사용할 수 있는 데이터베이스 도구를 만들어야 합니다.
"함수 하나로 연결부터 결과 반환까지 깔끔하게 정리해볼까?" 본격적인 실습이 시작됩니다.
DB 도구는 LLM이 데이터베이스와 상호작용할 수 있게 해주는 함수입니다. 마치 통역사가 두 나라 사람 사이에서 대화를 중개하듯, DB 도구는 LLM의 요청을 SQL로 변환하고 결과를 다시 LLM이 이해할 수 있는 형태로 전달합니다.
안전성과 사용성을 모두 갖춘 도구를 만들어 봅시다.
다음 코드를 살펴봅시다.
import sqlite3
from typing import List, Dict, Any
def execute_sql_tool(query: str, params: tuple = ()) -> Dict[str, Any]:
"""LLM이 사용할 SQL 실행 도구"""
# 읽기 전용 쿼리만 허용
allowed = ["SELECT"]
if not any(query.strip().upper().startswith(a) for a in allowed):
return {"error": "SELECT 쿼리만 허용됩니다"}
try:
conn = sqlite3.connect('company.db')
cursor = conn.cursor()
cursor.execute(query, params)
columns = [desc[0] for desc in cursor.description]
rows = cursor.fetchall()
results = [dict(zip(columns, row)) for row in rows]
return {"success": True, "data": results, "count": len(results)}
except Exception as e:
return {"error": str(e)}
finally:
conn.close()
김개발 씨는 앞서 배운 내용을 종합하여 실제 사용 가능한 도구를 만들기로 했습니다. 단순히 동작하는 것을 넘어, 안전하고 확장 가능한 구조가 필요했습니다.
먼저 함수 시그니처를 설계했습니다. query와 params를 받아서 결과를 딕셔너리로 반환하는 구조입니다.
타입 힌트를 추가하면 코드 가독성이 높아지고, IDE의 자동완성 지원도 받을 수 있습니다. 가장 중요한 것은 쿼리 화이트리스트입니다.
allowed 리스트에 "SELECT"만 포함시켜, INSERT, UPDATE, DELETE, DROP 같은 위험한 명령을 원천 차단합니다. LLM 도구에서는 데이터 조회만 허용하고 수정은 별도의 검증 과정을 거치는 것이 일반적입니다.
query.strip().upper().startswith() 체인을 주목하세요. strip()으로 앞뒤 공백을 제거하고, upper()로 대문자 변환하여 "select"나 "Select"도 처리합니다.
startswith()로 쿼리가 허용된 키워드로 시작하는지 확인합니다. try-except-finally 구조도 핵심입니다.
try 블록에서 정상 로직을 실행하고, 오류 발생 시 except에서 에러 메시지를 반환합니다. finally는 성공이든 실패든 항상 실행되어 연결을 확실히 닫습니다.
결과 포맷은 일관된 딕셔너리 구조를 유지합니다. 성공 시 {"success": True, "data": [...], "count": N} 형태로, 실패 시 {"error": "메시지"} 형태로 반환합니다.
LLM은 이 구조를 학습하여 결과를 안정적으로 처리할 수 있습니다. 실무에서는 여기에 로깅을 추가합니다.
어떤 쿼리가 언제 실행되었는지 기록하면 문제 발생 시 추적이 가능합니다. 또한 결과 제한도 중요합니다.
LIMIT 절이 없는 쿼리에 자동으로 LIMIT 100을 추가하는 것이 좋습니다. 더 발전시키면 커넥션 풀을 사용할 수 있습니다.
매번 연결을 새로 맺는 것보다 미리 만들어둔 연결을 재사용하면 성능이 크게 향상됩니다. 김개발 씨는 완성된 도구를 테스트해봤습니다.
SELECT 쿼리는 잘 동작하고, DELETE 쿼리를 시도하면 "SELECT 쿼리만 허용됩니다" 메시지가 반환되었습니다. "이제 LLM에게 이 도구를 연결하면 되겠군요!"
실전 팁
💡 - 프로덕션에서는 커넥션 풀과 비동기 처리를 고려하세요
- 쿼리 실행 시간 제한(timeout)을 설정하여 무한 대기를 방지하세요
5. 실습 자연어에서 SQL로
마지막 단계입니다. 사용자가 "IT 부서 직원 중 연봉이 가장 높은 사람은 누구야?"라고 물으면, LLM이 이를 SQL로 변환해야 합니다.
김개발 씨는 자연어와 SQL 사이의 다리를 놓는 방법을 고민하기 시작했습니다.
자연어-SQL 변환은 사람의 질문을 데이터베이스 쿼리로 바꾸는 과정입니다. 마치 외국어 통역사가 문맥과 의도를 파악하여 번역하듯, LLM은 자연어의 의미를 이해하고 적절한 SQL로 변환합니다.
이를 위해서는 데이터베이스 스키마 정보를 LLM에게 제공해야 합니다.
다음 코드를 살펴봅시다.
def get_schema_info() -> str:
"""데이터베이스 스키마 정보 추출"""
conn = sqlite3.connect('company.db')
cursor = conn.cursor()
# 테이블 목록 조회
cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
tables = cursor.fetchall()
schema_info = []
for (table_name,) in tables:
cursor.execute(f"PRAGMA table_info({table_name})")
columns = cursor.fetchall()
cols = [f"{c[1]} ({c[2]})" for c in columns]
schema_info.append(f"테이블 {table_name}: {', '.join(cols)}")
conn.close()
return "\n".join(schema_info)
# LLM 프롬프트 예시
prompt = f"""다음 스키마를 참고하여 SQL 쿼리를 작성하세요:
{get_schema_info()}
질문: IT 부서에서 연봉이 가장 높은 직원은?
SELECT만 사용하고, 쿼리만 출력하세요."""
김개발 씨의 마지막 과제는 자연어를 SQL로 변환하는 것이었습니다. LLM이 아무리 똑똑해도, 데이터베이스 구조를 모르면 올바른 쿼리를 만들 수 없습니다.
박시니어 씨가 핵심을 짚어줬습니다. "LLM에게 스키마 정보를 주어야 해요.
어떤 테이블이 있고, 각 테이블에 어떤 컬럼이 있는지 알려주는 거죠." 스키마 정보란 무엇일까요? 쉽게 말해 데이터베이스의 설계도입니다.
도서관으로 비유하면, "1층에는 문학 서가, 2층에는 과학 서가가 있고, 각 책에는 제목, 저자, 출판년도가 기록되어 있다"는 정보입니다. 코드의 get_schema_info() 함수를 살펴보겠습니다.
먼저 sqlite_master 테이블을 조회합니다. 이 테이블은 SQLite의 메타데이터를 담고 있어, 모든 테이블 이름을 알 수 있습니다.
다음으로 PRAGMA table_info() 명령을 사용합니다. PRAGMA는 SQLite의 특수 명령으로, 테이블의 컬럼 정보를 반환합니다.
각 컬럼의 이름과 데이터 타입을 추출하여 "컬럼명 (타입)" 형식으로 정리합니다. 최종 결과는 이런 형태가 됩니다: - 테이블 employees: id (INTEGER), name (TEXT), department (TEXT), salary (INTEGER) - 테이블 departments: id (INTEGER), name (TEXT), location (TEXT) 이 정보를 LLM 프롬프트에 포함시키면, LLM은 실제 테이블명과 컬럼명을 정확히 사용할 수 있습니다.
"연봉이 가장 높은"이라는 표현은 ORDER BY salary DESC LIMIT 1로 변환되고, "IT 부서"는 WHERE department = 'IT'로 변환됩니다. 프롬프트 설계도 중요합니다.
"SELECT만 사용하고, 쿼리만 출력하세요"라는 지시를 추가하면 LLM이 불필요한 설명 없이 순수 SQL만 반환합니다. 이렇게 하면 반환된 쿼리를 바로 execute_sql_tool()에 전달할 수 있습니다.
실무에서는 Few-shot 예시를 추가하면 정확도가 높아집니다. "질문: 전체 직원 수는?
SQL: SELECT COUNT(*) FROM employees" 같은 예시를 몇 개 포함시키면 LLM이 패턴을 학습합니다. 주의할 점은 **환각(hallucination)**입니다.
LLM이 존재하지 않는 테이블이나 컬럼을 만들어낼 수 있습니다. 생성된 SQL을 실행하기 전에 문법 검증을 추가하는 것이 좋습니다.
김개발 씨는 모든 조각을 맞췄습니다. 스키마 정보 제공, SQL 생성, 안전한 실행, 결과 포맷팅.
이제 사용자는 자연어로 질문하고, 챗봇은 데이터베이스에서 답을 찾아 알려줍니다. "드디어 완성이네요!" 김개발 씨는 뿌듯한 미소를 지었습니다.
처음에는 막막했던 데이터베이스 연동이 이제는 손에 잡히는 것 같았습니다.
실전 팁
💡 - 스키마가 복잡하면 관련 테이블만 선별하여 제공하세요
- 자주 묻는 질문은 SQL 템플릿으로 미리 준비해두면 정확도가 높아집니다
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
Phase 2 공격 기법 이해와 방어 실전 가이드
웹 애플리케이션 보안의 핵심인 공격 기법과 방어 전략을 실습 중심으로 배웁니다. 인증 우회부터 SQL Injection, XSS, CSRF까지 실제 공격 시나리오를 이해하고 방어 코드를 직접 작성해봅니다.
Context Fundamentals - AI 컨텍스트의 기본 원리
AI 에이전트 개발의 핵심인 컨텍스트 관리를 다룹니다. 시스템 프롬프트 구조부터 Attention Budget, Progressive Disclosure까지 실무에서 바로 적용할 수 있는 컨텍스트 최적화 전략을 배웁니다.
Phase 1 보안 사고방식 구축 완벽 가이드
초급 개발자가 보안 전문가로 성장하기 위한 첫걸음입니다. 해커의 관점에서 시스템을 바라보는 방법부터 OWASP Top 10, 포트 스캐너 구현, 실제 침해사고 분석까지 보안의 기초 체력을 다집니다.
프로덕션 워크플로 배포 완벽 가이드
LLM 기반 애플리케이션을 실제 운영 환경에 배포하기 위한 워크플로 최적화, 캐싱 전략, 비용 관리 방법을 다룹니다. Airflow와 서버리스 아키텍처를 활용한 실습까지 포함하여 초급 개발자도 프로덕션 수준의 배포를 할 수 있도록 안내합니다.
워크플로 모니터링과 디버깅 완벽 가이드
LLM 기반 워크플로의 실행 상태를 추적하고, 문제를 진단하며, 성능을 최적화하는 방법을 다룹니다. LangSmith 통합부터 커스텀 모니터링 시스템 구축까지 실무에서 바로 적용할 수 있는 내용을 담았습니다.