0Pricing
AI Agents · 강의

SQL 쿼리 생성 및 검증

안전한 SQL을 위한 프롬프트 패턴: SELECT 전용 모드, 매개변수화된 쿼리를 알아봅니다.

SQL 쿼리 생성 및 검증은(는) CoddyKit의 무료 AI Agents 강의입니다. 이것은 4개 중 3번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 AI Agents 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. AI Agents 강의에는 총 4개의 강의가 포함되어 있습니다.

SQL 생성 목표

SQL 쿼리를 생성하는 것은 작업의 절반에 불과합니다. 실제 데이터베이스에서 실행하기 전에 쿼리가 안전하고 구문적으로 올바르며 사용자가 의도한 작업을 정확히 수행하는지 검증해야 합니다.

이 단원에서는 SELECT 전용 적용, 구문 분석, 안전한 실행, EXPLAIN 계획 검증을 다룹니다.

SELECT 전용 모드 적용

NL-to-SQL 에이전트가 수행할 수 있는 가장 위험한 작업은 데이터를 파괴하는 문을 실행하는 것입니다. LLM이 무엇을 반환하든 항상 SELECT 전용 모드를 적용하십시오.

단순한 문자열 검사만으로는 충분하지 않습니다. 적절한 SQL 구문 분석기를 사용하십시오.

import sqlparse

def is_select_only(sql):
    parsed = sqlparse.parse(sql)
    if not parsed:
        return False
    for statement in parsed:
        stmt_type = statement.get_type()
        if stmt_type != 'SELECT':
            print(f'Blocked statement type: {stmt_type}')
            return False
    return True

# Test
print(is_select_only('SELECT * FROM users'))  # True
print(is_select_only('DROP TABLE users'))      # False — Blocked

심층 방어를 위한 키워드 차단 목록

sqlparse를 사용하더라도 보조 방어 수단으로 키워드 차단 목록을 추가하십시오. 일부 SQL 주입은 구문 분석기를 속일 수 있습니다. 실행 전에 위험한 키워드를 검사하면 안전 계층을 한 겹 더 추가할 수 있습니다.

DANGEROUS_KEYWORDS = [
    'INSERT', 'UPDATE', 'DELETE', 'DROP', 'CREATE',
    'ALTER', 'TRUNCATE', 'GRANT', 'REVOKE', 'EXEC',
    'EXECUTE', 'CALL', 'MERGE'
]

def passes_blocklist(sql):
    sql_upper = sql.upper()
    for keyword in DANGEROUS_KEYWORDS:
        # Check as whole word to avoid false positives like 'CREATED_AT'
        import re
        if re.search(r'\b' + keyword + r'\b', sql_upper):
            raise ValueError(f'Blocked keyword detected: {keyword}')
    return True

def validate_sql(sql):
    if not is_select_only(sql):
        raise ValueError('Only SELECT statements are allowed')
    passes_blocklist(sql)
    return True

sqlparse로 SQL 구문 분석

sqlparse는 SQL 문자열을 실행하지 않고 토큰화하고 구문 분석합니다. 쿼리 구조를 검사하고 테이블 이름을 추출하며 구문 문제를 확인할 수 있습니다.

pip install sqlparse로 설치하십시오.

import sqlparse
from sqlparse.sql import IdentifierList, Identifier
from sqlparse.tokens import Keyword, DML

def extract_table_names(sql):
    parsed = sqlparse.parse(sql)[0]
    tables = []
    from_seen = False
    for token in parsed.tokens:
        if token.ttype is DML and token.value.upper() == 'SELECT':
            continue
        if token.ttype is Keyword and token.value.upper() in ('FROM', 'JOIN'):
            from_seen = True
            continue
        if from_seen:
            if isinstance(token, Identifier):
                tables.append(token.get_name())
            elif isinstance(token, IdentifierList):
                for item in token.get_identifiers():
                    tables.append(item.get_name())
            from_seen = False
    return tables

print(extract_table_names('SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id'))
# ['users', 'orders']

스키마에 테이블이 존재하는지 확인

생성된 SQL에서 테이블 이름을 추출한 후, 이를 확인된 스키마와 대조하십시오. LLM이 테이블 이름을 환각하여 생성했다면, 이해하기 어려운 데이터베이스 오류가 발생할 때까지 기다리지 말고 실행 전에 쿼리를 거부하십시오.

def validate_tables_exist(sql, known_tables):
    used_tables = extract_table_names(sql)
    invalid = [t for t in used_tables if t and t not in known_tables]
    if invalid:
        raise ValueError(
            f'Query references non-existent tables: {invalid}. '
            f'Available tables: {list(known_tables)[:10]}...'
        )
    return True

# Usage
known = set(build_schema_dict(conn).keys())
try:
    validate_tables_exist(generated_sql, known)
except ValueError as e:
    # Send error back to LLM for correction
    corrected_sql = llm_fix_sql(generated_sql, str(e))
    print('Corrected SQL:', corrected_sql)

매개변수화된 실행

사용자가 제공한 값을 SQL에 삽입하기 위해 문자열 형식 지정을 사용하지 마십시오. LLM이 쿼리를 생성하더라도 사용자가 제공한 필터 값은 SQL 주입을 방지하도록 매개변수로 전달해야 합니다.

import sqlite3

conn = sqlite3.connect(':memory:')
conn.execute('CREATE TABLE orders (status TEXT, user_id INTEGER)')
conn.execute("INSERT INTO orders VALUES ('pending', 42)")

def safe_execute(conn, sql_template, params=()):
    """Execute with parameterized values."""
    cur = conn.cursor()
    cur.execute(sql_template, params)  # driver handles escaping
    columns = [d[0] for d in cur.description]
    rows = cur.fetchmany(200)
    return {'columns': columns, 'rows': rows}

sql = 'SELECT * FROM orders WHERE status = ? AND user_id = ?'
result = safe_execute(conn, sql, params=('pending', 42))
print(result)

실행 전 EXPLAIN 계획

대규모 테이블을 대상으로 비용이 많이 드는 쿼리를 실행할 때는 실제 쿼리를 실행하기 전에 EXPLAIN을 실행하십시오. 실행 계획 수립기가 백만 행 테이블 전체를 스캔한다고 표시하면 사용자에게 경고하거나 쿼리를 거부하십시오.

def check_explain_plan(conn, sql):
    explain_sql = f'EXPLAIN {sql}'
    with conn.cursor() as cur:
        cur.execute(explain_sql)
        plan = '\n'.join(row[0] for row in cur.fetchall())

    # Check for sequential scans on large tables
    if 'Seq Scan' in plan:
        print('WARNING: Query involves a sequential scan')
        print(plan)
        return {'safe': False, 'plan': plan, 'warning': 'Sequential scan detected'}

    return {'safe': True, 'plan': plan}

# Use before executing
plan_result = check_explain_plan(conn, generated_sql)
if not plan_result['safe']:
    print(f'Optimization hint: {plan_result["warning"]}')

행 수 제한 적용

LLM은 LIMIT 없이 SELECT * FROM logs를 생성하여 잠재적으로 수백만 개의 행을 반환할 수 있습니다. 쿼리에 LIMIT을 덧붙이거나 제한된 결과 집합을 가져오는 방식으로 항상 최대 행 수를 적용하십시오.

import re

MAX_ROWS = 500

def enforce_row_limit(sql, max_rows=MAX_ROWS):
    sql_upper = sql.upper().rstrip().rstrip(';')

    # Check if LIMIT already present
    if re.search(r'\bLIMIT\b', sql_upper):
        # Extract current limit and enforce maximum
        match = re.search(r'LIMIT\s+(\d+)', sql_upper)
        if match:
            current = int(match.group(1))
            if current > max_rows:
                sql = re.sub(r'LIMIT\s+\d+', f'LIMIT {max_rows}', sql, flags=re.IGNORECASE)
    else:
        sql = sql.rstrip(';') + f' LIMIT {max_rows}'

    return sql

print(enforce_row_limit('SELECT * FROM users'))
# SELECT * FROM users LIMIT 500

LLM 출력에서 정제된 SQL 추출

LLM은 SQL을 마크다운 코드 블록(```sql ... ```)으로 감싸거나 설명 텍스트와 함께 반환하는 경우가 많습니다. 구문 분석하거나 실행하기 전에 원시 SQL을 추출해야 합니다.

import re

CODE_FENCE = chr(96) * 3  # three backticks, built at runtime to avoid template issues

def extract_sql(llm_response):
    # Remove markdown code blocks ('''sql ... ''' or ''' ... ''')
    pattern = CODE_FENCE + r'(?:sql)?\s*([\s\S]+?)' + CODE_FENCE
    match = re.search(pattern, llm_response, re.IGNORECASE)
    if match:
        return match.group(1).strip()

    # If no code block, look for SELECT statement
    match = re.search(r'(SELECT\s+[\s\S]+?;)', llm_response, re.IGNORECASE)
    if match:
        return match.group(1).strip()

    # Fallback: strip common preamble phrases
    cleaned = re.sub(r'^(Here is|The SQL query is|Query:)[^\n]*\n', '',
                     llm_response, flags=re.IGNORECASE).strip()
    return cleaned

if __name__ == '__main__':
    demo_response = 'Here is the SQL query:\n' + CODE_FENCE + 'sql\nSELECT * FROM users;\n' + CODE_FENCE
    print(extract_sql(demo_response))

전체 검증 처리 흐름

모든 검증 단계를 하나의 함수로 연결하십시오. 이 함수는 원시 LLM 출력을 받아 안전하게 실행할 수 있는 SQL 문자열을 반환하거나, 복구에 사용할 수 있도록 설명이 포함된 오류를 발생시켜야 합니다.

def validate_and_prepare_sql(llm_output, known_tables, max_rows=500):
    # Step 1: extract raw SQL
    sql = extract_sql(llm_output)
    if not sql:
        raise ValueError('No SQL found in LLM response')

    # Step 2: type check
    if not is_select_only(sql):
        raise ValueError('Only SELECT queries allowed')

    # Step 3: keyword blocklist
    passes_blocklist(sql)

    # Step 4: table existence check
    validate_tables_exist(sql, known_tables)

    # Step 5: row limit
    sql = enforce_row_limit(sql, max_rows)

    return sql

# Full flow
try:
    safe_sql = validate_and_prepare_sql(llm_output, known_tables)
    result = safe_execute(conn, safe_sql)
except ValueError as e:
    corrected = llm_fix_sql(llm_output, str(e))
    safe_sql = validate_and_prepare_sql(corrected, known_tables)
    result = safe_execute(conn, safe_sql)

읽기 전용 데이터베이스 사용자

코드 수준의 검증은 중요하지만 충분하지 않습니다. 최종 방어 계층으로 읽기 전용 사용자 계정을 사용해 데이터베이스에 연결하고 SELECT 권한만 부여하십시오. 악의적인 쿼리가 모든 검사를 우회하더라도 데이터베이스가 이를 거부합니다.

# Create read-only user in PostgreSQL:
# CREATE USER nl_to_sql_reader WITH PASSWORD 'secure_password';
# GRANT CONNECT ON DATABASE yourdb TO nl_to_sql_reader;
# GRANT USAGE ON SCHEMA public TO nl_to_sql_reader;
# GRANT SELECT ON ALL TABLES IN SCHEMA public TO nl_to_sql_reader;

import os
import psycopg2

def get_readonly_connection():
    return psycopg2.connect(
        host=os.getenv('DB_HOST'),
        database=os.getenv('DB_NAME'),
        user='nl_to_sql_reader',       # read-only account
        password=os.getenv('DB_READER_PASS')
    )

지식 확인

NL-to-SQL 에이전트에서 SQL 검증을 수행할 때 올바른 심층 방어 방식은 무엇입니까?

복습: SQL 생성 및 검증

안전한 SQL 생성을 위해서는 전체 검증 처리 흐름이 필요합니다. LLM 출력에서 정제된 SQL을 추출하고, sqlparse를 사용해 SELECT 전용을 적용하며, 키워드 차단 목록을 사용하고, 실제 스키마를 기준으로 테이블 이름을 확인하고, 행 수 제한을 적용하며, 최종 보호 장치로 읽기 전용 데이터베이스 사용자를 사용해야 합니다.

사용자가 제공한 값이 포함되는 경우 매개변수화된 쿼리가 주입 공격을 방지합니다. EXPLAIN 계획 검사는 예상보다 비용이 많이 드는 쿼리가 운영 데이터에 실행되는 것을 막습니다.

자주 묻는 질문

“SQL 쿼리 생성 및 검증” 강의는 무료인가요?

네 — “SQL 쿼리 생성 및 검증” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 AI Agents 강의 전체를 잠금 해제할 수 있습니다. AI Agents 강의에는 총 4개의 강의가 포함되어 있습니다.

“SQL 쿼리 생성 및 검증”에서 뭘 배우나요?

안전한 SQL을 위한 프롬프트 패턴: SELECT 전용 모드, 매개변수화된 쿼리를 알아봅니다. 브라우저에서 직접 실행하는 실습 코드로 AI Agents을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

AI Agents을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 AI Agents은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 3번째 강의입니다.

“SQL 쿼리 생성 및 검증” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 AI Agents 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 AI Agents 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. NL-to-SQL 에이전트 작동 원리
  2. 스키마 이해 및 주입
  3. SQL 쿼리 생성 및 검증
  4. 모호한 데이터베이스 질문 처리
← AI Agents(으)로 돌아가기