LLM에게 SQL을 맡겨도 되나요?

자유도는 LLM에게, 정확도는 시스템에게

이
이승재 · NHN AD
플랫폼서비스랩 · 2026년 10월 · 약 6분

들어가며

안녕하세요, LLM을 통제하고 싶은 불안형 이승재입니다. 이번 글은 LLM이 쓰는 SQL을 결정론적으로 통제하기까지의 기록입니다.

저는 대행사 마케터들이 쓰는 광고 관리 서비스를 만들고 있습니다. 이번에 맡은 일은 성과를 채팅으로 물어보는 기능인데, 마케터가 성과 데이터를 내려받아 엑셀 수식으로 집계하던 일을 질문 하나로 끝내는 것이었습니다.

이거 외않됀데?

처음에는 SQL을 코드에서 만들었습니다. 이전 글에서 소개한 SQL 빌더인데, AI가 문장에서 차원·지표·기간·필터를 뽑아 주면 빌더가 그 값으로 SQL을 절 단위로 조립했습니다. 같은 입력에는 늘 같은 SQL이 나와 결정론적이었고 테스트하기도 쉬웠습니다.

다만 손이 너무 많이 갔습니다. 새 요구가 생길 때마다 파라미터와 조립 규칙을 크게 고쳐야 했습니다. 무엇보다 사용자의 질문은 끝이 없는데 빌더는 미리 만들어 둔 분기까지만 답할 수 있었습니다. “지난주보다 전환이 줄어든 키워드들 중 예산을 15% 증가시킨 다음에 광고비 큰 순으로 10개 보여줘” 같은 질문이 들어오면 그때마다 새 분기를 짜야 했죠.

그래서 SQL을 LLM이 직접 쓰도록 바꿨습니다.

이전 · 코드가 SQL을 조립자연어 질문AI가 파라미터 추출차원·지표·기간·필터빌더가 절 단위 조립미리 만든 분기만SQL분기에 없는 질문은 막힌다지금 · LLM이 SQL을 작성자연어 질문LLM이 SQL 작성분기 없이 질문 그대로코드가 검사규칙 · EXPLAIN · 승인실행자유도는 LLM, 정확도는 코드
코드가 정해진 분기로 SQL을 조립하던 구조에서, LLM이 쓰고 코드가 검사하는 구조로

자유도는 높지만 결정론적이고 정확도는 99%면 좋겠고 결과 파일도 떨궈주면 좋겠어요

그런데 요구사항은 그대로였습니다. 광고주가 보는 숫자라 SQL은 결정론적이어야 했고, 정확도는 99% 이상이어야 했습니다. 하지만 LLM은 같은 질문에도 매번 다른 SQL을 씁니다. 같은 입력에 같은 출력을 내는 함수가 아니니 당연한 일이고, 늘 올바른 SQL을 쓴다는 보장도 없습니다.

가장 어려운 건 문법은 맞는데 SQL이 우리 의도와 다를 때, 그리고 SQL은 맞았는데 LLM의 답변이 부정확할 때입니다. 예를 들어 성과와 전환을 각각 집계하지 않고 행 단위로 바로 붙이면 전환 유형 수만큼 광고비 행이 복제되는데, 쿼리는 멀쩡히 돌고 표도 그럴듯하게 채워집니다.

성과캠페인광고비A10,000전환캠페인전환 유형A구매A장바구니A가입행 단위JOIN조인 결과캠페인전환 유형광고비A구매10,000A장바구니10,000A가입10,000SUM(광고비)30,000실제는 10,000 · 3배
문법은 맞고 쿼리도 돌지만, 전환 유형 수만큼 광고비가 부푼다

여러 광고주가 한 테이블을 같이 쓰니 계정 조건 하나만 틀려도 남의 데이터가 섞이고, “최근 7일”을 6일로 잡는 날짜 실수도 나왔습니다.

계정 조건이 빠지면광고주광고비A10,000B20,000C5,000A10,000A의 광고비를 물었는데35,000 · 남의 데이터가 섞임「최근 7일」24일25일26일27일28일29일30일31일오늘 · 데이터 없음정답 · 어제까지 7일LLM · 6일시작은 오늘에서 세고, 끝은 어제로 당겨 하루가 빠짐
계정 조건 하나, 날짜 하루가 틀려도 에러는 나지 않는다

답변도 틀렸습니다. SQL이 맞게 돌아도 모델이 결과를 옮기다 실수해, 30개 행만 더한 값을 계정 전체 합계라고 하거나 35, 114, 81처럼 들쭉날쭉한 값을 “월 100회 안팎”으로 뭉뚱그렸습니다.

같은 모델에게 다시 검토를 맡겨도 같은 실수가 반복됐습니다. 그래서 질문을 해석하고 SQL을 쓰는 일은 LLM에게 최대한 맡기고, 그 SQL이 실행되기까지는 코드로 엄격하게 관리하기로 했습니다.

LLM자유도코드결정론질문 해석유형 판단예시 선택골든셋 BM25SQL 작성자리표만계정·기간서버가 채움검문sqlglotEXPLAIN승인 카드사용자 확인실행요약 값답변 서술서버 값 인용LLM에게 끝없는 질문을 해석하고, SQL을 쓰고, 결과를 말로 풀기코드에게 예시를 고르고, 값을 채우고, 검사하고, 확인받고, 실행하고, 숫자를 세기
해석하고 쓰는 일은 LLM에게, 고르고 채우고 검사하고 세는 일은 코드에게

SQL 통제하기

이 절에서는 LLM이 SQL을 다 쓴 뒤부터 실행되기까지의 과정만 다룹니다. SQL을 쓰기 전 이야기는 다음 글에서 하겠습니다.

LLM은 SQL에 계정 ID와 기간을 직접 쓰지 않고 :id, :start_date, :end_date로 자리만 남깁니다. SQL 문법이 아니라 서버가 채울 자리라는 우리 시스템 안의 약속입니다. ID는 사용자가 화면에서 고른 계정을 백엔드가 세션으로 넘겨준 값으로 채우고, 기간은 LLM이 period_days=7 같은 파라미터로 넘기면 서버가 날짜를 계산해 채웁니다.

사용자 질의「최근 7일 캠페인별 광고비 보여줘」 LLM이 쓰는 SQLSELECT camp_no, SUM(spend)FROM ad_spendWHERE id = :id -- 숫자 대신 이 글자를 그대로 씀 AND event_day BETWEEN :start_date AND :end_date:id ← 세션 계정사용자가 화면에서 고른 계정 · 백엔드가 넘겨줌:start_date · :end_date ← 서버 계산LLM이 넘긴 period_days=7 → 20261024~20261030실제로 실행되는 SQLSELECT camp_no, SUM(spend)FROM ad_spendWHERE id = '1234' AND event_day BETWEEN '20261024' AND '20261030'
LLM은 자리만 표시하고, 값은 서버가 채운다

완성된 SQL은 sqlglot으로 파싱해 구문 트리에서 검사합니다. 쓰기 쿼리와 파티션 조건이 빠진 SQL, 세션과 다른 ID를 읽는 SQL은 거부합니다. 가장 신경 쓴 건 JOIN입니다. 성과와 전환을 각자 집계하지 않고 행 단위로 붙이면 grain이 맞지 않는 JOIN으로 보고 거부하며, LLM이 CTE도 자주 쓰기 때문에 이 검사는 CTE마다 따로 합니다.

사용자 질의「최근 7일 캠페인별 광고비랑 전환수 같이 보여줘」 LLM이 쓴 SQL 예시WITH s AS ( SELECT camp_no, spend FROM ad_spend WHERE id = '1234' AND event_day BETWEEN '20261024' AND '20261030'),c AS ( SELECT camp_no, action_kind, action_cnt FROM ad_actions WHERE event_day BETWEEN '20261024' AND '20261030')SELECT s.camp_no, SUM(s.spend) AS spend, SUM(c.action_cnt) AS convFROM s JOIN c ON s.camp_no = c.camp_noGROUP BY s.camp_no파싱구문 트리 검사SELECTCTE sad_spend✓ ID · 기간 조건 있음CTE cad_actions✗ ID 조건 없음JOINs ⋈ c✗ 광고비가 전환 유형GROUP BYcamp_no✓ 문제 없음수만큼 복제 → 부풂거부 · 사유를 LLM에게사유를 보고 다시 씀LLM이 고친 CTE cc AS (SELECT camp_no, SUM(action_cnt) AS action_cnt FROM ad_actions WHERE id = '1234' … GROUP BY camp_no) -- 캠페인 1개 = 1행✓ 다시 검사 통과 → 실행 단계로CTE마다 ID · 기간 조건을 따로 보고, JOIN은 양쪽 grain이 맞는지 본다. 거부되면 사유가 LLM에게 가고, LLM이 고친 SQL을 다시 검사한다예시 데이터 · ID 1234 · 10/24~10/30ad_spendcamp_nospendA10,000B5,000ad_actionscamp_noaction_kindaction_cntA구매3A장바구니5A가입2B구매1틀린 SQL · c를 그대로 JOINJOIN 결과 · 광고비가 유형 수만큼 복제camp_nospendaction_kindaction_cntA10,000구매3A10,000장바구니5A10,000가입2B5,000구매1A 광고비 30,000 ✗ 전환 10B 광고비 5,000 전환 1A의 10,000이 전환 유형 수만큼 세 번 더해짐고친 SQL · c를 캠페인 단위로 먼저 집계JOIN 결과 · 캠페인 1개 = 1행camp_nospendaction_cntA10,00010B5,0001A 광고비 10,000 ✓ 전환 10B 광고비 5,000 ✓ 전환 1두 SQL 모두 에러 없이 실행된다
구문 트리로 CTE와 JOIN을 판정하고, 같은 데이터에 돌리면 틀린 쪽은 광고비가 부푼다

캠페인 A의 전환을 유형별로 볼 때도 비슷합니다. 유형별 행마다 광고비를 붙이면 같은 광고비가 되풀이돼, 결과 파일이나 엑셀에서 합계를 내는 순간 부풉니다. 그래서 검사기는 이런 SQL을 거부하고 유형을 열로 펼치라는 사유를 돌려줍니다. 고친 SQL은 캠페인 하나를 한 행으로 두고 구매·장바구니·가입을 각각 열로 냅니다.

사용자 질의「최근 7일 캠페인별 광고비랑 전환을 유형별로 보여줘」 LLM이 쓴 SQL 예시 · 유형을 행으로 펼침SELECT s.camp_no, s.spend, c.action_kind, c.action_cntFROM s JOIN ( SELECT camp_no, action_kind, SUM(action_cnt) AS action_cnt FROM ad_actions WHERE id = '1234' AND … GROUP BY camp_no, action_kind -- 캠페인 × 유형 = 여러 행) c ON s.camp_no = c.camp_no -- s.spend가 유형 행마다 붙음고친 SQL · 유형을 열로 펼침SELECT s.camp_no, s.spend, c.purchase, c.cart, c.signupFROM s JOIN ( SELECT camp_no, SUM(CASE WHEN action_kind = '구매' THEN action_cnt ELSE 0 END) AS purchase, SUM(CASE WHEN action_kind = '장바구니' THEN action_cnt ELSE 0 END) AS cart, SUM(CASE WHEN action_kind = '가입' THEN action_cnt ELSE 0 END) AS signup FROM ad_actions WHERE id = '1234' AND … GROUP BY camp_no) c ON s.camp_no = c.camp_no예시 데이터 · ID 1234 · 10/24~10/30ad_spendcamp_nospendA10,000B5,000ad_actionscamp_noaction_kindaction_cntA구매3A장바구니5A가입2B구매1틀린 SQL 결과camp_nospendaction_kindaction_cntA10,000구매3A10,000장바구니5A10,000가입2B5,000구매1광고비 합계 35,000 ✗ 실제 15,000유형 행마다 광고비가 되풀이돼 합계가 부풂 → 거부고친 SQL 결과 · 유형을 열로 펼침camp_nospend구매장바구니가입A10,000352B5,000100광고비 합계 15,000 ✓캠페인 1개 = 1행, 유형은 열마다 하나
유형별로 볼 때는 유형을 행이 아니라 열로 펼친다

규칙을 통과한 SQL은 Athena의 EXPLAIN으로 한 번 더 확인합니다. 데이터는 읽지 않고 실행 계획만 만드니 비용이나 시간 같은 실행 오버헤드가 없고, 없는 컬럼이나 타입이 맞지 않는 비교 같은 엔진 오류를 미리 잡습니다.

SQL 실행 도구 호출LLM · 자리표만ID · 기간 채우기ID 세션 · 기간 계산sqlglot 규칙구문 트리 검사EXPLAIN스캔 0바이트실행 단계로모두 통과한 SQL만거부 사유 · 엔진 에러 메시지를 돌려줌 → LLM이 고쳐 다시 호출 · 최대 3번
SQL이 만들어진 뒤의 검문. 거부되면 사유와 함께 LLM에게 돌아간다

아 생각해보니까 답변에 인사이트까지 같이 내주면 좋겠어요

검사를 통과한 SQL도 바로 실행하지 않습니다. 조회 기간과 컬럼, 범위를 승인 카드로 먼저 보여 주면 사용자가 승인하거나 고치거나 취소합니다. 승인하면 그때 Athena에서 SQL이 실행되고, 결과는 CSV 파일과 LLM의 답변으로 함께 나갑니다.

0행이 나오거나 음수, NULL이 많은 특이한 결과는 따로 처리합니다. 이때 LLM은 추측하지 않고 “이 기간에는 조건에 맞는 데이터가 없어 판단할 근거가 부족해요.”처럼 근거가 부족하다고 답합니다.

답변에는 가벼운 인사이트까지 담아 달라는 요구도 있었습니다. LLM이 풍부한 답변을 낼 수 있도록, 서버가 LLM의 SQL을 감싼 요약 쿼리를 만들어 행 수와 합계, 분포, 상위·하위 행을 함께 계산해 넘깁니다. LLM은 이 값을 근거로 어디가 크고 어디가 비었는지를 짚습니다.

요약 쿼리는 sqlglot으로 읽은 출력 컬럼의 종류에 따라 규칙대로 만듭니다. 합산 지표는 합계와 분포, 비율은 분자 합 ÷ 분모 합, 이름과 ID는 고유 개수를 냅니다. 행끼리 비교하는 판단은 동료가 만든 계산 도구의 값으로만 쓰고, 답변 속 숫자는 정규식으로 한 번 더 대조합니다.

LLM이 쓴 SQL의 출력 컬럼 · sqlglot으로 파싱camp_nospendclicksimprctr컬럼 종류 판정 · 이름 사전 → 이름 끝 토큰(_ctr 등) → 출력 식(SUM · COUNT) 순서이름 · IDcamp_no· 고유 개수· 빈 값 수· 상위·하위 행의 라벨합산 지표spend · clicks · impr· SUM 합계· 분포 p25 · p50 · p75 · p90· 0인 행 수· 상위 · 하위 5행비율ctr· SQL 복사본에 분자 · 분모 열을 덧붙여· SUM(clicks) ÷ SUM(impr)로 다시 계산· 행 비율의 평균은 쓰지 않음규칙대로 조립서버가 만든 요약 쿼리 · 본 쿼리와 함께 실행SELECT COUNT(*) AS 행수, SUM(spend) AS 광고비_합계, SUM(clicks) * 1.0 / NULLIF(SUM(impr), 0) AS 전체_CTR, approx_percentile(spend, 0.5) AS 광고비_중앙값, max_by(camp_no || '=' || spend, spend, 5) AS 상위5, min_by(camp_no || '=' || spend, spend, 5) AS 하위5FROM ( <LLM이 쓴 SQL 그대로> ) AS r행 수 · 합계 · 통계 · 상위 · 하위 행LLM에게 결과와 함께 전달 → 답변에서 그대로 인용
컬럼 종류를 규칙으로 판정해 요약 쿼리를 만들고, 그 값을 LLM에게 넘긴다
실행 전 · 승인 카드조회 확인기간2026-10-24 ~ 2026-10-30컬럼캠페인 · 광고비 · 전환수범위선택한 광고 계정미리보기 · 캠페인 A 10,000 · 10 …승인수정취소승인 뒤실행 후 · 결과 신호0행조건에 맞는 데이터 없음음수있을 수 없는 값NULL 다수값이 비어 있음추측하지 않고근거 부족을알림답변 예「이 기간에는 조건에 맞는 데이터가 없어 판단할 근거가 부족해요.」승인Athena가 SQL 실행결과 CSV 파일CSV + LLM 답변
사람이 승인한 SQL만 실행하고, 결과는 CSV와 답변으로 나간다

마치며

이 작업을 하며 LLM을 바라보는 관점이 바뀌었습니다. 저는 LLM이 사용자의 요청을 우리 시스템의 말로 옮기는 번역기라고 생각합니다. 시스템의 기획 의도와 목적, 누가 쓰는지, 무엇을 할 수 있는지를 알려 주면, LLM은 그걸 바탕으로 요청을 해석해 답을 냅니다. 어떻게 보면 서비스를 컴파일하는 컴파일러라고도 할 수 있을 것 같습니다.

읽어주셔서 감사합니다.

참조