SQL AI 데이터베이스 솔루션: Streamlit과 Hugging Face를 사용하여 안전한 Text-to-SQL 앱 구축하기
요약
Streamlit과 Hugging Face를 활용하여 안전한 Text-to-SQL 애플리케이션을 구축하는 방법을 소개합니다. LLM의 출력을 그대로 실행하지 않고 가드레일 계층을 통해 검증함으로써 보안 위협을 방지하는 실전적인 접근법을 다룹니다.
핵심 포인트
- LLM의 SQL 출력은 신뢰할 수 없는 입력으로 간주하고 검증해야 함
- 가드레일 계층을 통해 SELECT 문 외의 위험한 SQL 명령어를 차단
- 정확한 SQL 생성을 위해 데이터베이스 스키마를 텍스트로 추출하여 프롬프트에 포함
- Streamlit과 SQLite를 사용하여 데이터베이스와 대화하는 앱 구현
요약 (TL;DR): 저는 작동 가능한 "데이터베이스와 대화하는" 앱을 구축했습니다. 사용자가 일상적인 영어로 질문하면, LLM (무료 Hugging Face Inference API를 통해)이 SQL을 작성하고, 가드레일 계층 (guardrail layer)이 이를 검증하며, SQLite가 Streamlit 테이블에 결과를 반환합니다. 대부분의 튜토리얼이 생략하는 부분인 "LLM의 원시 출력(raw output)을 절대 실행하지 않는 것"이 이 글의 핵심이며, 이는 17개의 단위 테스트 (unit tests)로 다뤄집니다. 저장소: github.com/Dayan-18/sql-ai-demo →
약속과 문제점
Text-to-SQL은 LLM의 가장 유용한 응용 분야 중 하나입니다. 전 세계 비즈니스 데이터의 대부분은 관계형 데이터베이스 (relational databases)에 저장되어 있으며, 그 데이터로부터 답을 얻어야 하는 대부분의 사람들은 SQL을 작성하지 못합니다. 현대의 모델들은 "어느 국가의 총 매출이 가장 높은가?"라는 질문을 신뢰할 수 있는 정확한 JOIN + GROUP BY 쿼리로 변환할 수 있을 만큼 충분히 뛰어납니다.
하지만 대부분의 튜토리얼이 무시하는 문제가 있습니다: LLM의 출력은 신뢰할 수 없는 입력 (untrusted input)이라는 점입니다. 모델이 반환하는 무엇이든 그대로 가져와 데이터베이스에 전달한다면, 당신은 자연어 주입 (natural-language injection) 인터페이스를 구축한 셈입니다. 악의적이거나(또는 단순히 혼란스러운) 프롬프트는 DROP TABLE, UPDATE, 또는 엉뚱한 곳을 가리키는 ATTACH DATABASE를 생성할 수 있습니다. 따라서 이 데모는 세 개의 계층을 가지며, 그중 중간 계층이 핵심입니다:
질문 ──▶ LLM (SQL 작성) ──▶ 가드레일 (검증) ──▶ SQLite (실행) ──▶ 테이블
데모 데이터베이스
현실적인 데이터가 포함된 소규모 이커머스 SQLite 데이터베이스 — customers, products, orders — 를 사용합니다. 핵심 기능은 스키마 (schema)를 텍스트로 추출하는 것인데, 왜냐하면 모델에게 스키마를 보여주는 것이 Text-to-SQL에서 정확도를 결정하는 가장 큰 요인이기 때문입니다:
def get_schema_text(conn):
"""CREATE TABLE 문을 반환합니다 — 이것이 LLM이 보는 내용입니다."""
rows = conn.execute(
...
프롬프트
def build_prompt(schema: str, question: str) -> str:
return f"""당신은 전문 SQLite 분석가입니다. 다음 데이터베이스 스키마가 주어졌을 때:
...
sql 코드 블록.
- 읽기 전용 (Read-only): SELECT 문만 허용.
- 스키마에 정의된 정확한 테이블 및 컬럼 이름을 사용할 것.
"""
{% raw %}
우리는 모델에게 읽기 전용으로 동작할 것을 _요청_하지만, 그것에만 _의존_하지는 않습니다. LLM에게 정중하게 행동을 요청하는 것은 UX 최적화이지, 보안 제어 수단이 아닙니다.
...
FORBIDDEN = re.compile(
r"\b(INSERT|UPDATE|DELETE|DROP|ALTER|CREATE|REPLACE|TRUNCATE|"
r"ATTACH|DETACH|PRAGMA|VACUUM|GRANT|REVOKE)\b",
re.IGNORECASE,
)
def extract_sql(llm_output: str) -> str:
"""LLM은 SQL을 마크다운 펜스(markdown fences)와 설명 문구로 감싸는 것을 좋아합니다. 이를 모두 제거합니다."""
match = re.search(r"```(?:sql)?\s*(.*?)```", llm_output, re.DOTALL)
sql = match.group(1) if match else llm_output
sql = sql.split(";")[0].strip() # 첫 번째 문장만 유지
return sql
def validate_sql(sql: str) -> str:
"""정확히 하나의 읽기 전용 SELECT 문만 허용합니다. 그렇지 않으면 예외를 발생시킵니다."""
stripped = sql.strip().rstrip(";").strip()
if not re.match(r"^\s*(SELECT|WITH)\b", stripped, re.IGNORECASE):
raise UnsafeSQLError("SELECT 쿼리만 허용됩니다")
if FORBIDDEN.search(stripped):
raise UnsafeSQLError("쿼리에 금지된 키워드가 포함되어 있습니다")
if ";" in stripped:
raise UnsafeSQLError("여러 개의 문장은 허용되지 않습니다")
return stripped
허용 목록(Allow-list)을 먼저 적용하고 (SELECT/WITH만 허용), 그 다음 거부 목록(deny-list)을 적용하여 (금지된 키워드), 전형적인 SELECT 1; DROP TABLE 연쇄 공격을 차단하기 위해 단일 문장 강제 규칙을 적용합니다. 실제 운영 환경(production)에서는 읽기 전용 데이터베이스 연결과 행 제한(row limit)을 추가하여 심층 방어(defense in depth)를 구축해야 합니다.
...
```python
question = st.text_input("데이터에 대해 질문하세요:")
if question:
raw = ask_llm(build_prompt(schema, question))
sql = validate_sql(extract_sql(raw))
st.code(sql, language="sql")
df = pd.read_sql_query(sql, conn)
st.dataframe(df)
LLM 호출에는 Hugging Face Inference API를 사용하며 (무료 토큰, GPU 불필요), 창의성보다는 결정론적인(deterministic) SQL을 원하기 때문에 temperature=0.1을 설정합니다:
python
resp = requests.post(
HF_URL,
headers={"Authorization": f"Bearer {token}"},
json={
"model": "Qwen/Qwen2.5-Coder-32B-Instruct",
"messages": [{"role": "user", "content": prompt}],
"max_tokens": 300,
"temperature": 0.1,
},
timeout=60,
)
Ask "Which country has the highest total sales?" and the app shows the generated SQL —
sql
SELECT c.country, SUM(p.price * o.quantity) AS total
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN products p ON p.id = o.product_id
GROUP BY c.country
ORDER BY total DESC
LIMIT 1
— and the result table (Peru, 1645.99, if you're curious). Ask it to "delete all customers" instead, and the guardrail answers: 🛡️ Blocked by guardrails: Only SELECT queries are allowed.
...
python
@pytest.mark.parametrize("evil", [
"DROP TABLE customers",
"DELETE FROM orders",
"UPDATE products SET price = 0",
"PRAGMA writable_schema = ON",
"ATTACH DATABASE '/etc/passwd' AS pwn",
"SELECT * FROM customers; DROP TABLE customers",
])
def test_rejects_dangerous_sql(evil):
with pytest.raises(UnsafeSQLError):
validate_sql(evil)
text
test_core.py::test_schema_has_three_tables 통과 (PASSED) [ 5%]
test_core.py::test_seed_data_loaded 통과 (PASSED) [ 11%]
test_core.py::test_analytical_query_runs 통과 (PASSED) [ 17%]
test_core.py::test_extracts_sql_from_markdown_fence 통과 (PASSED) [ 23%]
test_core.py::test_extracts_plain_sql_without_fence 통과 (PASSED) [ 29%]
test_core.py::test_keeps_only_first_statement 통과 (PASSED) [ 35%]
test_core.py::test_accepts_select 통과 (PASSED) [ 41%]
test_core.py::test_accepts_cte 통과 (PASSED) [ 47%]
test_core.py::test_rejects_dangerous_sql[DROP TABLE customers] 통과 (PASSED) [ 52%]
test_core.py::test_rejects_dangerous_sql[DELETE FROM orders] 통과 (PASSED) [ 58%]
test_core.py::test_rejects_dangerous_sql[INSERT INTO customers...] 통과 (PASSED) [ 64%]
test_core.py::test_rejects_dangerous_sql[UPDATE products SET price = 0] 통과 (PASSED) [ 70%]
test_core.py::test_rejects_dangerous_sql[PRAGMA writable_schema = ON] 통과 (PASSED) [ 76%]
test_core.py::test_rejects_dangerous_sql[ATTACH DATABASE...] 통과 (PASSED) [ 82%]
test_core.py::test_rejects_dangerous_sql[SELECT 1; DROP TABLE...] 통과 (PASSED) [ 88%]
test_core.py::test_rejects_empty 통과 (PASSED) [ 94%]
test_core.py::test_prompt_contains_schema_and_question 통과 (PASSED) [100%]
============================== 17개 테스트 통과 =============================
이 17개의 테스트는 매 푸시(push)마다 GitHub Actions에서 실행됩니다. 이는 제 이전 글들에서 사용한 것과 동일한 CI 패턴입니다. 테스트되지 않은 부분에 주목하세요. 바로 LLM 호출 자체입니다. 확률적 모델(probabilistic model)에 대한 네트워크 호출은 단위 테스트(unit tests)에 포함되어서는 안 됩니다. 가드레일(guardrails)이 존재하는 이유는 정확성이 모델을 신뢰하는 것에 의존하지 않도록 하기 위함입니다.
실제 환경에서의 적용
이 4개의 파일로 구성된 데모는 개념적으로 실제 솔루션으로 확장 가능합니다. SQLite를 **읽기 전용 역할 (read-only role)**을 가진 Postgres로 교체하고, 반복되는 질문이 LLM을 호출하지 않도록 시맨틱 캐싱 (semantic caching)을 추가하며, 감사를 위해 생성된 모든 쿼리를 로그로 남기고, 지연 시간(latency)이나 비용이 중요한 경우에는 (Hugging Face text-to-SQL 페이지에 있는 것과 같은) 미세 조정된 (fine-tuned) SQL 모델을 고려하십시오. 스키마 입력, 검증된 SQL 출력, 샌드박스화된 실행이라는 아키텍처 (architecture)는 동일하게 유지됩니다.
결론
Text-to-SQL은 오늘날 어떤 개발자라도 진정으로 손에 닿을 수 있는 기술입니다. 무료 HF 토큰, Streamlit, 그리고 약 150줄의 Python 코드만 있으면 됩니다. 데모와 솔루션의 차이는 중간 계층에 있습니다. LLM의 출력을 사용자 입력처럼 취급하고, 허용 목록 (allow-list)으로 검증하며, 공격 사례를 테스트하고, CI가 이를 영구적으로 강제하도록 하십시오.
전체 코드: github.com/Dayan-18/sql-ai-demo
여러분은 LLM이 운영 데이터베이스(production database)를 쿼리하도록 허용하시겠습니까? 댓글로 알려주세요! 👇
이 시리즈의 이전 글들: Bandit을 사용한 SAST · Checkov를 사용한 IaC 스캐닝 · pytest를 사용한 API 테스트 · CI/CD 도구 비교
참고 자료: Hugging Face의 Text-to-SQL · Streamlit 문서 · HF Inference API
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기