Self-Correction: 왜 3번까지만 재시도하는가 [4]
생성된 SQL을 바로 실행하면 안 됩니다#
처음에는 SQL이 그럴듯하게 나오기만 하면 바로 실행해도 되는 거 아닌가 싶었다. 어차피 SELECT만 허용하고 있으니, 잘못되면 에러 한 번 나고 끝나는 문제처럼 보였기 때문이다.
근데 실제로 붙여보니 전혀 단순하지 않았다. 존재하지 않는 테이블을 참조하는 경우도 있었고, 문법은 맞는데 런타임에서 터지는 경우도 있었고, 제일 애매한 건 에러 없이 돌아가는데 결과가 묘하게 틀리는 경우였다. 사용자 입장에서는 이런 쪽이 오히려 더 불신을 만든다.
2편에서 SQL Agent가 SQL을 생성하고, Validation Agent가 검증하고, 실패하면 Self-Correction이 동작한다고 짧게만 썼는데, 여기서는 그 검증과 수정 루프를 조금 더 자세히 적어보려고 한다.
검증 없이 실행하면 크게 세 가지 문제가 생깁니다:
- 존재하지 않는 테이블/컬럼 참조 (Hallucination): LLM이 학습 데이터에서 본 테이블명을 만들어내는 경우
- 문법 오류: 방언(dialect) 차이, 예약어 충돌, 잘못된 함수 사용
- 논리적 오류: GROUP BY 누락, 잘못된 JOIN 조건, 집계 함수 실수
프로덕션에서 자주 발생하는 오류 유형은 1편에서 정리한 것과 일치했습니다. 특히 Hallucination이 가장 위험합니다. 실행하면 에러가 나니까 데이터가 망가지진 않지만, 사용자 신뢰가 떨어집니다.
3단계 검증#
Validation Agent는 이 세 단계를 순서대로 돕니다. 앞 단계에서 걸리면 그다음 단계로는 내려가지 않습니다.
1단계: Syntax Check#
sqlparse로 SQL을 파싱해서 구문이 유효한지 확인합니다. 이 단계에서 잡히는 것들:
- 닫히지 않은 괄호
- 잘못된 키워드 위치
- 누락된 세미콜론이나 구분자
가장 기본적인 검증이지만, LLM이 가끔 마크다운 코드블록 태그를 SQL에 포함시키거나, 설명 텍스트를 SQL 안에 넣어버리는 경우를 잡아냅니다.
2단계: Schema Check#
생성된 SQL에서 참조하는 테이블과 컬럼이 실제 스키마에 존재하는지 대조합니다. Hallucination을 잡는 핵심 단계입니다.
생성된 SQL: SELECT name FROM employees WHERE dept_id = 1
스키마 확인:
- employees 테이블 존재? → X (실제 테이블명은 tb_employee)
- name 컬럼 존재? → X (실제 컬럼명은 emp_name)
스키마에 없는 테이블이나 컬럼을 참조하면 여기서 걸립니다.
3단계: EXPLAIN#
DB에서 실제로 EXPLAIN을 실행해서 실행 계획이 생성되는지 확인합니다. 구문이 유효하고 스키마도 맞는데 런타임에 에러가 나는 케이스를 잡습니다.
- 타입 불일치 (문자열 컬럼에 숫자 비교)
- 함수 파라미터 오류
- 서브쿼리 관련 제약
EXPLAIN은 SQL을 실행하지 않고 실행 계획만 만들기 때문에 데이터에 영향을 주지 않습니다. 하지만 DB 커넥션을 사용하기 때문에 이전 2단계에서 확실히 걸러진 SQL만 여기까지 옵니다.
Self-Correction Loop#
여기서 하나라도 걸리면 자동 수정 루프로 넘어갑니다.
SQL 생성
↓
[Validate] → 문법 → 스키마 → EXPLAIN
↓ (실패)
[LLM Correction] → 오류 메시지 + 원래 질문 + 스키마를 SQL Agent에 다시 전달
↓
[Validate] → 재검증
↓ (실패, attempt < 3)
[LLM Correction] → 2차 수정
↓
[Validate] → 재검증
↓ (실패, attempt >= 3)
UNFIXABLE → 에스컬레이션
왜 3번인가#
무한 재시도는 말 그대로 재앙입니다. LLM API 비용은 계속 올라가고, 사용자는 빈 화면을 보면서 기다리고, 못 고치는 SQL은 다섯 번 돌려도 결국 못 고칩니다.
막상 운영에서 보니 3번이 꽤 현실적인 선이었습니다. 1번째 시도에서 대부분 고쳐지고, 2번째에서 추가로 잡히는 게 조금 있고, 3번째까지 가는 경우는 생각보다 드뭅니다. 3번째에서도 안 되면 프롬프트 한 줄 더 만진다고 해결될 문제가 아니라, 테이블 선택이 틀렸거나 메타데이터가 부족한 쪽일 때가 많았습니다.
설정값은 max_correction_attempts: 3으로 config에 박아놨습니다.
LangGraph Subgraph로 분리한 이유#
Self-Correction을 메인 그래프에 그냥 인라인으로 넣지 않고, LangGraph의 Subgraph로 따로 뺀 이유도 여기에 있습니다. 직접 굴려보니 두 가지가 특히 중요했습니다.
-
관측성: 각 수정 시도가 Langfuse에서 별도 span으로 추적됩니다. "3번째 시도에서 Schema Check 실패, 테이블명 tb_eval → tb_evaluation으로 수정"이라는 로그가 자동으로 남습니다.
-
스트리밍: SSE로 사용자에게 진행 상황을 보여줄 때, 수정 시도별로 이벤트를 보낼 수 있습니다. "검증 중... → 수정 시도 1... → 수정 시도 2... → 성공!" 같은 실시간 피드백입니다.
class CorrectionState(TypedDict):
sql: str
question: str
tables: list # 선택된 테이블 정보
dialect: str # MySQL / PostgreSQL
issues: list[str] # 발견된 문제들
attempt: int # 현재 시도 횟수
max_attempts: int # 최대 3
status: str # VALID / FIXED / UNFIXABLE
original_sql: str # 최초 생성 SQL (비교용)Salesforce와의 전략 차이#
1편에서 봤던 Salesforce의 Consensus Voting은 방향이 좀 다릅니다. SQL을 1개만 만드는 게 아니라 10개 후보를 뽑아놓고, 그중에서 가장 많이 겹치는 걸 고르는 식입니다.
| Consensus Voting (Salesforce) | Self-Correction (이 시스템) | |
|---|---|---|
| 생성 | 10개 병렬 생성 | 1개 생성 + 검증 |
| 수정 | 투표로 선택 | 오류 기반 재생성 |
| LLM 호출 수 | 10회 (고정) | 1~4회 (가변) |
| 레이턴시 | 1회 호출 시간 (병렬) | 최대 4회 호출 시간 (직렬) |
| 비용 | 10배 | 1~4배 |
이 방식도 장점은 분명한데, 지금처럼 레이턴시와 비용을 같이 봐야 하는 환경에서는 1개 생성 + 깊은 검증 쪽이 더 현실적이었습니다.
명확화 질문 이중 반복 문제#
Self-Correction 말고도, 명확화 질문(clarification) 쪽에서 은근히 오래 붙잡고 있던 버그가 하나 있었습니다.
문제 상황#
사용자: "월별 주문 건수는?"
시스템: "어떤 기간의 주문을 조회할까요?" ← clarification
사용자: "전체"
시스템: "어떤 기간을 원하시나요?" ← 또 clarification! (이중 반복)
사용자가 답변을 했는데 시스템이 같은 질문을 다시 합니다. 또 다른 문제로는 LLM이 SQL과 clarification을 동시에 반환하는 경우도 있었습니다.
원인#
LLM은 비결정적입니다. 같은 컨텍스트를 줘도 매번 판단이 조금씩 달라집니다. 사용자가 "전체"라고 답해도, 어떤 때는 이걸 충분한 답변으로 보고 SQL을 만들고, 어떤 때는 또 애매하다고 보고 같은 질문을 다시 던집니다.
해결: 3단계 방어 체계#
1단계: Intent Agent — 후속 답변 인식
대화 히스토리에서 마지막 시스템 메시지가 clarification이고, 현재 사용자 메시지가 답변이면 무조건 SQL_QUERY로 분류합니다. "ㄴㄴ", "아니", "전체", "2025년 상반기" 같은 짧은 답변도 SQL_QUERY로 인식하게 규칙을 넣었습니다.
if history[-1].get("message_type") == "clarification":
is_followup = True
# 이전 질문과 답변을 합쳐서 enriched question 생성
enriched_question = f"{현재_답변} {이전_질문}"2단계: 이중 반환 방어
LLM이 SQL과 clarification을 동시에 반환하는 경우의 판단 로직:
sql + clarification 동시 반환 시:
├─ 후속 답변(followup)이면 → SQL 우선, clarification 제거
├─ confidence >= 0.7이면 → SQL 우선, clarification 제거
└─ confidence < 0.7이면 → clarification 우선, SQL 비움
후속 답변 상황에서는 사용자가 이미 정보를 줬으니 SQL을 우선합니다. confidence가 높으면 LLM이 충분히 확신하는 거니까 SQL을 우선합니다. confidence가 낮을 때만 clarification을 보냅니다.
3단계: 상호 배타 규칙
SQL Agent의 프롬프트에 명시적 규칙을 넣었습니다:
- clarification을 반환하면 sql은 빈 문자열, confidence는 0.0
- sql을 반환하면 clarification은 반드시 null
- 동시에 값이 있는 상태로 반환 금지
LLM에게 명시적으로 "둘 중 하나만"이라고 못을 박아야 합니다. 암묵적으로 기대하면 매번 다른 결과가 나옵니다.
Safety Net#
모든 방어를 뚫고 SQL도 clarification도 없는 빈 상태가 반환될 때도 있습니다. 드물지만 실제로 나왔습니다. 이 경우 그냥 비정상 상태로 죽이지 않고, 기본 clarification을 반환하고 audit 로그를 남기게 했습니다.
if not result.sql and not result.clarification_question:
# 비정상 상태 로깅
return {"clarification_question": "질문을 더 구체적으로 해주시겠어요?"}SQL Agent 프롬프트 규칙#
Self-Correction과 clarification을 붙여놓고 끝난 건 아니었습니다. 막상 운영에서 부딪히다 보니 SQL Agent 프롬프트에도 따로 못을 박아둬야 하는 규칙들이 생겼습니다.
비율/퍼센트 예외#
"~비율", "퍼센트", "%", "~비중" 질문은 필수 필터 예외로 처리합니다. 전체 대비 비율을 구하는 거니까 전체 데이터가 필요합니다. 특정 조건을 명시하지 않는 한 clarification 없이 SQL을 바로 생성합니다.
"프리미엄 플랜을 쓰는 비율은?" → SQL 바로 생성 (clarification 안 함)
"가장 최근" / "최신" 정렬#
"가장 최근", "최신", "마지막" 키워드가 있으면 ORDER BY + LIMIT 1 패턴을 적용합니다. 정렬 기준은 PK 순번(_sn, _id)이나 create_datetime을 우선합니다.
주의: datetime 컬럼에 <= NOW() 필터를 추가하지 않도록 했습니다. NULL이나 미래 날짜가 있는 데이터가 실제로 있어서, 이 필터가 들어가면 정상 데이터가 제외되는 문제가 있었습니다.
대화 히스토리 개선#
clarification 문제를 해결하면서 대화 히스토리 저장 방식도 개선했습니다.
new_history.append({
"role": "assistant",
"content": assistant_content,
"message_type": "answer" | "clarification",
"selected_tables": state.get("selected_table_names", []),
})message_type으로 이 응답이 SQL 결과인지 clarification인지 구분하고, selected_tables를 저장해서 후속 질문에서 테이블 컨텍스트를 재활용합니다.
운영 사례: 다중 쿼리 보안 차단 버그#
이건 프로덕션에서 실제로 겪은 버그였습니다. "특정 사용자의 A 정보와 B 정보를 확인하고 싶어요" 같은 질문에서, 결과 대신 "보안 정책 위반(2개 이상의 쿼리 생성)" 에러만 덜렁 뜨는 문제가 있었습니다.
원인 분석#
질문이 파이프라인을 통과하는 과정에서 3곳의 결함이 복합적으로 작용했습니다.
사용자 질문
→ Decomposer: "단일 의도"로 판정 (분해 안 함) ← ① 근본 원인
→ SQL Generator: SELECT ...A...; SELECT ...B... 2개 생성 ← ② 방지 실패
→ Guardrail: 세미콜론 다중 쿼리 감지 → UNFIXABLE 차단
→ Validation: Guardrail 차단에도 불구하고 실행됨 ← ③ 불필요한 낭비
→ 사용자: "보안 정책 위반" 에러
| 단계 | 문제 | 심각도 |
|---|---|---|
| Decomposer | "A와 B"를 단일 의도로 판정하여 분해하지 않음 | 근본 원인 |
| SQL Generator | 단일 SELECT 강제 규칙이 없어 세미콜론 다중 쿼리 생성 | 방지 실패 |
| Graph (guardrail→validation) | 무조건 edge로 연결되어 Guardrail FAIL 후에도 Validation 실행 | 성능 낭비 |
Guardrail의 차단 자체는 정상 동작이었습니다. 문제는 차단 이후에도 불필요한 후속 처리가 계속된 것과, 애초에 분해되었어야 할 질문이 분해되지 않은 것이었습니다.
3-Layer 방어 구조로 수정#
Change 1: Guardrail → Validation 조건부 라우팅
Guardrail이 FAIL하면 Validation을 건너뛰고 즉시 에러 종료하도록 변경했습니다.
# before
graph.add_edge("guardrail", "validation")
# after
graph.add_conditional_edges(
"guardrail",
self._route_after_guardrail,
{"validation": "validation", "end_error": "save"},
)
def _route_after_guardrail(self, state: GraphState) -> str:
if state.get("validation_status") == "UNFIXABLE" or state.get("error"):
return "end_error"
return "validation"LangGraph의 무조건 edge를 조건부 edge로 바꾸는 단순한 변경이지만, 이게 없으면 Guardrail이 차단한 쿼리가 DB EXPLAIN까지 도달합니다.
Change 2: SQL Generator — 단일 SELECT 강제 규칙
2. 반드시 하나의 SELECT 문만 생성하세요.
세미콜론(;)으로 구분된 다중 쿼리를 절대 생성하지 마세요.
- 여러 테이블의 정보가 필요하지만 JOIN이 불가능한 경우:
→ sql은 빈 문자열, clarification_question으로 안내
- UNION ALL은 컬럼 구조가 동일한 경우에만 사용 가능
Change 3: Decomposer — 다른 테이블 데이터 분해 규칙
6. 서로 다른 종류의 데이터를 함께 요청하면 분해하세요.
- 같은 엔티티에 대한 질문이라도, 서로 다른 테이블에
저장되는 정보를 함께 요청하면 각각 별도의 SQL이 필요합니다.
- 예: "A 정보와 B 정보", "매출과 재고", "주문 현황과 고객 통계"
- 단, 같은 테이블의 여러 컬럼은 분해하지 마세요.
방어 구조의 계층#
수정 후 3-layer 방어가 동작합니다.
질문: "A 정보와 B 정보를 확인하고 싶어요"
→ [1층] Decomposer: A/B 분해 → 각각 별도 파이프라인 (근본 해결)
→ [2층] SQL Generator: 분해 실패해도 단일 SELECT 규칙으로 clarification (방지)
→ [3층] Guardrail 조건부 edge: 다중 쿼리가 생성되어도 즉시 차단 후 Validation 스킵 (안전망)
1층이 작동하면 2층, 3층은 관여하지 않습니다. 1층이 실패하면 2층이 잡고, 2층도 실패하면 3층이 잡습니다. 각 층이 독립적으로 작동하기 때문에 하나가 뚫려도 다음 층에서 방어됩니다.
테스트#
이 수정에 대해 4건의 테스트를 추가했습니다.
class TestRouteAfterGuardrail:
def test_unfixable_routes_to_end_error(self):
"""UNFIXABLE → end_error"""
state = {"validation_status": "UNFIXABLE"}
assert _route_after_guardrail(state) == "end_error"
def test_error_routes_to_end_error(self):
"""error 존재 → end_error"""
state = {"error": "some error"}
assert _route_after_guardrail(state) == "end_error"
def test_safe_routes_to_validation(self):
"""정상 → validation"""
state = {"validation_status": "VALID"}
assert _route_after_guardrail(state) == "validation"
class TestDecomposerMultiTable:
def test_multi_table_data_types_decomposed(self):
"""서로 다른 테이블 데이터 → 분해"""
result = decompose("A 정보와 B 정보를 보여줘")
assert len(result.sub_questions) == 2이건 단일 버그 하나를 잡으면서도 근본 원인 수정 + 2중 안전망 + 그래프 라우팅 최적화를 같이 넣은 케이스였습니다. 한 군데만 고치면 다른 경로로 같은 문제가 다시 나올 수 있어서, 파이프라인 각 레이어에 독립적인 방어를 두는 쪽이 더 낫다고 봤습니다.
보안 테스트 — 공격자의 관점으로#
LLM이 만든 SQL을 그대로 DB에 던지는 시스템이기 때문에, 보안은 조금 과하다 싶을 정도로 보는 편이 낫습니다. Guardrail도 정상 사용 흐름만 보는 게 아니라, 공격자 입장에서 하나씩 흔들어봐야 했습니다.
SQL 인젝션 공격 벡터#
SQL Filter가 차단해야 할 공격 패턴들을 테스트합니다. DML(INSERT, UPDATE, DELETE, DROP)은 기본이고, 더 교묘한 공격도 잡아야 합니다.
class TestDMLBlocked:
def test_insert_blocked(self):
result = check_sql_safety("INSERT INTO users (name) VALUES ('test')")
assert result.is_safe is False
def test_drop_blocked(self):
result = check_sql_safety("DROP TABLE users")
assert result.is_safe is False
def test_grant_blocked(self):
result = check_sql_safety("GRANT ALL ON users TO admin")
assert result.is_safe is False다중 쿼리 인젝션도 차단합니다. 세미콜론으로 쿼리를 이어붙여서 DROP을 실행하려는 시도입니다.
def test_semicolon_multi_query(self):
sql = "SELECT * FROM users; DROP TABLE users;"
result = check_sql_safety(sql)
assert result.is_safe is False
assert any("다중 쿼리" in v for v in result.violations)
def test_trailing_semicolon_ok(self):
result = check_sql_safety("SELECT * FROM users;")
assert result.is_safe is True # 끝의 세미콜론은 허용LIMIT 보존 — 한국어 수량 표현 대응#
LLM이 사용자 의도에 맞게 생성한 LIMIT을 guardrail이 덮어쓰면 안 됩니다. "가장 최근 1건"이라고 물었는데 LIMIT 1000이 붙으면 의미가 달라집니다.
class TestLLMGeneratedLimitPreservation:
def test_preserves_limit_1(self):
"""'가장 최근' → LIMIT 1 보존"""
result = check_sql_safety(
"SELECT * FROM tb_evaluation ORDER BY created_at DESC LIMIT 1"
)
assert "LIMIT 1" in result.filtered_sql
assert "LIMIT 1000" not in result.filtered_sql
def test_preserves_limit_5(self):
"""'상위 5개' → LIMIT 5 보존"""
result = check_sql_safety(
"SELECT dept_name, COUNT(*) cnt FROM departments "
"GROUP BY dept_name ORDER BY cnt DESC LIMIT 5"
)
assert "LIMIT 5" in result.filtered_sql
def test_caps_limit_exceeding_max(self):
"""LIMIT 5000 → 1000으로 캡"""
result = check_sql_safety("SELECT * FROM employees LIMIT 5000")
assert "LIMIT 1000" in result.filtered_sql
assert "5000" not in result.filtered_sqlLIMIT이 없으면 1000을 자동 추가하고, 1000 이하면 보존하고, 1000 초과면 강제로 1000으로 캡합니다. 이 3가지 경로를 각각 테스트합니다.
테이블 화이트리스트 — CTE와 크로스 스키마#
Table Agent가 선택한 테이블만 SQL에서 사용 가능합니다. 하지만 CTE(Common Table Expression) 이름과 크로스 스키마 테이블이 함정입니다.
def test_cte_name_not_blocked(self):
"""CTE 이름(ranked)이 화이트리스트에 없어도 차단되지 않음"""
sql = "WITH ranked AS (SELECT * FROM users) SELECT * FROM ranked"
result = check_sql_safety(sql, allowed_tables=["users"])
assert result.is_safe is True
def test_multiple_cte_not_blocked(self):
"""다중 CTE + JOIN에서도 실제 테이블만 검사"""
sql = ("WITH a AS (SELECT * FROM users), "
"b AS (SELECT * FROM orders) "
"SELECT * FROM a JOIN b ON a.id = b.user_id")
result = check_sql_safety(sql, allowed_tables=["users", "orders"])
assert result.is_safe is True
def test_recursive_cte_not_blocked(self):
"""WITH RECURSIVE도 처리"""
sql = ("WITH RECURSIVE cte AS (SELECT 1 UNION ALL SELECT * FROM users) "
"SELECT * FROM cte")
result = check_sql_safety(sql, allowed_tables=["users"])
assert result.is_safe is TrueCTE 이름을 테이블로 오인해서 차단하는 버그를 실제로 겪었습니다. WITH ranked AS (...) 에서 ranked가 화이트리스트에 없다고 차단되면, 복잡한 쿼리는 전부 실패합니다.
크로스 스키마 테이블도 특별 처리합니다:
def test_cross_schema_table_allowed_by_bare_name(self):
"""erp.memberorgrelation → bare name으로 매칭"""
result = check_sql_safety(
"SELECT * FROM erp.memberorgrelation",
allowed_tables=["memberorgrelation", "mit_common.member"],
)
assert result.is_safe is True
def test_cross_schema_table_allowed_by_qualified_name(self):
"""schema.table 전체 이름으로도 매칭"""
result = check_sql_safety(
"SELECT * FROM mit_common.member",
allowed_tables=["mit_common.member"],
)
assert result.is_safe is True크로스 스키마 테이블명 교정#
LLM이 테이블명에서 스키마 prefix를 빼먹는 경우가 있습니다. mit_common.member를 그냥 member로 생성하면 SQL이 실패합니다.
def test_qualify_cross_schema(self):
"""bare name → schema-qualified name 교정"""
tables = self._make_tables(["mit_common.member", "employee"])
sql = "SELECT * FROM member"
result = qualify_table_names(sql, tables)
assert "FROM mit_common.member" in result
def test_qualify_no_conflict(self):
"""primary에 같은 bare name 있으면 교정 스킵"""
tables = self._make_tables(["mit_common.member", "member"])
sql = "SELECT * FROM member"
result = qualify_table_names(sql, tables)
assert "mit_common.member" not in result # 충돌이므로 교정 안 함
def test_qualify_join(self):
"""JOIN 절의 bare name도 교정"""
tables = self._make_tables(["mit_common.member", "employee"])
sql = "SELECT * FROM employee JOIN member ON employee.id = member.emp_id"
result = qualify_table_names(sql, tables)
assert "JOIN mit_common.member" in result같은 bare name이 primary 스키마와 cross 스키마에 동시에 있으면 어느 쪽을 써야 할지 모르기 때문에 교정하지 않습니다. 이런 충돌 상황을 테스트로 명시해둬야 나중에 교정 로직을 수정할 때 안전합니다.
Self-Correction 테스트 — 상태 머신 검증#
Self-Correction 서브그래프는 결국 상태 머신입니다. 현재 상태와 시도 횟수에 따라 "계속 수정" / "성공 종료" / "포기"로 갈라집니다.
def test_valid_returns_end_valid(self):
state = {"status": "VALID", "attempt": 1, "max_attempts": 3,
"issues": [], "previous_issues": []}
assert _route_after_validate(state) == "end_valid"
def test_max_attempts_reached_returns_end_unfixable(self):
state = {"status": "PENDING", "attempt": 3, "max_attempts": 3,
"issues": ["error"], "previous_issues": []}
assert _route_after_validate(state) == "end_unfixable"
def test_pending_under_max_returns_correct(self):
state = {"status": "PENDING", "attempt": 1, "max_attempts": 3,
"issues": ["error A"], "previous_issues": []}
assert _route_after_validate(state) == "correct"동일 에러 조기 탈출#
3번까지 시도할 필요 없이, 같은 에러가 2회 연속 나오면 바로 포기합니다. LLM이 같은 실수를 반복하면 3번째도 같은 실수를 할 확률이 높습니다.
def test_same_error_twice_returns_end_unfixable(self):
"""동일 에러 2회 연속 → 조기 탈출"""
state = {"status": "PENDING", "attempt": 2, "max_attempts": 3,
"issues": ["column X not found"],
"previous_issues": ["column X not found"]}
assert _route_after_validate(state) == "end_unfixable"
def test_different_error_continues(self):
"""다른 에러면 계속 시도"""
state = {"status": "PENDING", "attempt": 2, "max_attempts": 3,
"issues": ["column Y not found"],
"previous_issues": ["column X not found"]}
assert _route_after_validate(state) == "correct"이전 에러(previous_issues)와 현재 에러(issues)를 비교해서, 동일하면 더 시도해도 의미 없다고 판단합니다. 다른 에러면 수정이 진전되고 있다는 뜻이므로 계속 시도합니다.
선택되지 않은 테이블 사용 감지#
Table Agent가 선택하지 않은 테이블을 SQL이 참조하면 잡아냅니다. LLM이 Hallucination으로 엉뚱한 테이블을 넣는 경우입니다.
def test_non_selected_table_detected(self):
sql = ("SELECT m.name FROM mit_common.member m "
"JOIN mit_common.memberdetail md ON m.id = md.member_id")
selected = ["mit_common.member"]
issues = _check_selected_tables_only(sql, selected)
assert len(issues) == 1
assert "memberdetail" in issues[0]
def test_cte_not_flagged(self):
"""CTE 이름은 false positive 아님"""
sql = ("WITH dept_stats AS (SELECT dept_id, COUNT(*) cnt "
"FROM mit_hr.department GROUP BY dept_id) "
"SELECT * FROM dept_stats")
selected = ["mit_hr.department"]
issues = _check_selected_tables_only(sql, selected)
assert issues == []여기서도 CTE가 함정입니다. dept_stats가 CTE 이름인데, 이걸 선택되지 않은 테이블로 판단하면 안 됩니다.
PII 마스킹 테스트 — 한국 개인정보#
SQL 실행 결과에는 생각보다 쉽게 개인정보가 섞여 나옵니다. 사용자가 그걸 노리고 물은 게 아니어도, 결과 안에 이름이나 전화번호, 주민번호가 같이 딸려 나오는 경우가 있습니다.
패턴 기반 감지#
def test_phone_with_dashes(self, masker):
rows = [{"phone": "010-1234-5678"}]
result = masker.mask_results(rows)
assert result.pii_found is True
assert "****" in result.masked_rows[0]["phone"]
assert "5678" in result.masked_rows[0]["phone"] # 뒤 4자리 보존
assert "010" in result.masked_rows[0]["phone"] # 앞 3자리 보존
def test_ssn_with_dash(self, masker):
rows = [{"data": "900101-1234567"}]
result = masker.mask_results(rows)
assert "900101" in masked # 생년월일 보존
assert "*******" in masked # 뒷자리 마스킹
def test_card_with_dashes(self, masker):
rows = [{"data": "1234-5678-9012-3456"}]
result = masker.mask_results(rows)
assert "3456" in masked # 마지막 4자리 보존
assert "****-****-****-" in masked # 나머지 마스킹
def test_email_pattern(self, masker):
rows = [{"data": "john@example.com"}]
result = masker.mask_results(rows)
assert "j***@example.com" == masked # 첫 글자만 보존마스킹 규칙이 단순히 ****로 가리는 게 아니라, 각 PII 유형별로 적절한 부분만 보존합니다. 전화번호는 앞뒤를 보존해서 "어떤 종류의 번호인지"는 알 수 있게, 카드번호는 마지막 4자리만 보존하는 식입니다.
컬럼명 기반 감지#
패턴 매칭으로 못 잡는 경우가 있습니다. "홍길동"이라는 텍스트만 봐서는 이름인지 알 수 없습니다. 하지만 컬럼명이 user_name이면 이름입니다.
def test_name_column(self, masker):
rows = [{"user_name": "홍길동"}]
result = masker.mask_results(rows)
assert result.pii_found is True
assert result.detections[0].pii_type == PIIType.NAME
assert masked[0] == "홍" # 성은 보존
assert "**" in masked # 이름은 마스킹
def test_address_column(self, masker):
rows = [{"address": "서울시 강남구 역삼동 123"}]
result = masker.mask_results(rows)
assert masked.startswith("서울시") # 시 단위 보존
assert "****" in masked # 상세 주소 마스킹
def test_phone_column_name(self, masker):
rows = [{"전화번호": "anything"}]
result = masker.mask_results(rows)
assert result.detections[0].pii_type == PIIType.PHONE한국어 컬럼명 "전화번호"도 인식합니다. 레거시 시스템에서 한국어 컬럼명을 쓰는 경우가 있어서, 영문과 한국어 양쪽을 다 커버해야 합니다.
enabled=False 바이패스#
개발/테스트 환경에서는 PII 마스킹을 끌 수 있어야 합니다. 마스킹된 데이터로는 디버깅이 안 되니까요.
def test_disabled_returns_original(self):
masker = PIIMasker(enabled=False)
rows = [{"phone": "010-1234-5678", "email": "test@test.com"}]
result = masker.mask_results(rows)
assert result.pii_found is False
assert result.masked_rows == rows # 원본 그대로정리하면 이 글의 핵심은 "LLM이 SQL을 한 번 잘 만들게 하자"보다 "틀린 SQL이 나와도 시스템이 어떻게 버틸 것인가"에 더 가까웠습니다. 검증, 수정, clarification, guardrail이 각각 따로 노는 기능이 아니라 한 묶음으로 움직여야 했고, 실제 장애나 버그도 대부분 그 경계에서 나왔습니다.
그래서 Self-Correction도 단순한 재시도 기능이라기보다, 이 시스템이 사용자를 너무 쉽게 실망시키지 않게 만드는 안전장치에 더 가까웠습니다.
다음 글에서#
5편에서는 개발 과정을 회고합니다. 타임라인, 수치, 잘한 것과 다르게 할 것, 그리고 도메인 지식이 코드보다 중요했다는 교훈을 다루겠습니다.