Backend · Data

AI가 쓴 SQL을 안전하게 쓰는 법

자연어 질문을 SQL로 바꾸고, 고객 데이터에 실행해도 되는 답으로 만들기까지

Yehee Choi (예희)Yehee Choi (예희)
·
# LLM# SQL# Text-to-SQL
AI가 쓴 SQL을 안전하게 쓰는 법
date
slug
author
status
tags(최대 3개)
summary
type
thumbnail
category
updatedAt
 
💡
TL;DR 아임웹 판매자는 SQL을 몰라도 자연어로 매출·고객·상품 데이터를 묻습니다. 문제는 그 답을 만들 SQL을 사람이 아니라 LLM이 쓴다는 점입니다. 우리는 모델을 믿는 대신, 판단과 실행을 나누고, 도메인 지식을 DB에 담고, 쿼리를 실행 전 여러 단계로 검증하는 구조를 만들었습니다. 이 글은 LLM이 만든 SQL을 실제 고객 데이터 앞에서 안전하게 다루기까지의 이야기입니다.

판매자는 SQL을 몰라도 데이터를 묻습니다

"지난달 재구매율이 어땠지?"
아임웹에서 쇼핑몰을 운영하는 판매자라면 이런 질문을 자주 던집니다. 답은 데이터에 있지만, 판매자 대부분은 SQL을 모릅니다. 그래서 지금까지는 데이터 담당자가 쿼리를 대신 작성했습니다. 질문이 쌓일수록 대기 줄이 길어졌고, 데이터가 궁금한 사람과 데이터를 꺼낼 줄 아는 사람이 따로였습니다. "이 숫자 좀 뽑아 주세요"가 슬랙에 쌓이는 만큼, 판매자의 의사결정도 늦어졌습니다.
그래서 어드민에 챗을 하나 띄웠습니다. 운영자가 자연어로 물으면 시스템이 SQL을 만들어 실행하고, 표와 분석 코멘트를 함께 돌려줍니다.
notion image
겉보기엔 익숙한 그림입니다. 입력을 받고, 쿼리를 만들고, 결과를 돌려줍니다. 다만 이 한 줄짜리 챗 뒤에는 일반적인 백엔드와 다른 문제가 있었습니다. 그 쿼리를 사람이 아니라 LLM이 쓴다는 것입니다.
더 안전한 길도 있었습니다. 재고 조회, 매출 조회, 재구매율 조회처럼 질문 종류마다 미리 짜 둔 API나 MCP를 하나씩 만드는 방식입니다. 쿼리가 고정되니 안전하지만, 운영자가 묻고 싶은 게 늘어날 때마다 기능을 새로 개발해야 하고, 그 기능이 쌓일수록 유지보수도 새로 합류한 팀이 익혀야 할 것도 함께 불어납니다. 그래서 재고 MCP, 매출 MCP처럼 끝없이 쪼개 만들기보다, 자연어를 그때그때 SQL로 바꾸는 한 경로로 모으기로 했습니다.

문제는 입력값이 아니라 쿼리 전체였습니다

일반적인 SQL 보안은 입력값을 파라미터로 묶는 데서 시작합니다. 쿼리 골격은 개발자가 고정하고, 사용자가 건드리는 값만 막는 전제입니다. 자연어 통계 조회에서는 이 전제가 깨집니다. 조회할 테이블, 집계 방식, 조건절까지 LLM이 통째로 만들기 때문입니다. 더 이상 값 하나만 비신뢰로 보면 안 되고, 쿼리 구조 전체를 검증해야 합니다.
notion image
스키마만 주고 SQL을 받아 그대로 실행하면 곧 깨집니다. 모델은 없는 컬럼을 그럴듯하게 만들고, 지표 개념어를 컬럼처럼 쓰고, 0으로 나누는 쿼리를 만들기도 합니다. 더 위험한 경우는 문법도 맞고 컬럼도 실재하지만 답만 틀린 쿼리입니다.
그래서 설계의 출발점을 "모델을 믿는 법"이 아니라 "안 믿어도 되는 구조"로 잡았습니다.

판단과 실행을 나눴습니다

가장 먼저 한 일은 시스템을 둘로 나눈 것입니다. 판단하는 두뇌와, 안전하게 실행하는 도구함입니다. 비결정적인 판단과 결정론적인 실행을 한 덩어리에 두면 안전 검증이 사방에 흩어지기 때문입니다. 그 경계를 어디에 그을지가 전체 구조를 결정했습니다.
notion image
두뇌는 무엇을 할지 판단합니다. 질문의 의도를 먼저 읽어 주제에서 벗어난 질문이나 답할 데이터가 없는 질문을 거르고, 통계가 필요하면 분석 담당에게 넘긴 뒤, 돌아온 결과를 사람이 읽을 문장으로 풀어 줍니다. 도구함은 안전하게 실행합니다. 자연어를 SQL로 바꾸고, 검증하고, 데이터 웨어하우스에 실행합니다.
이렇게 나눈 이유는 분명합니다. 안전 경계를 데이터에 가장 가까운 쪽이 소유하게 만들기 위해서입니다. 챗에서 오든, 내부 평가에서 오든, 어떤 경로로 요청이 들어와도 테넌트 격리·테이블 허용목록·SQL 검증은 반드시 도구함을 통과합니다. 두뇌가 아무리 헷갈려도, 마지막에 거르는 건 데이터 바로 앞을 지키는 도구함입니다. 경계를 두뇌에 두면 새 진입로가 생길 때마다 검증을 다시 챙겨야 하지만, 데이터 앞에 두면 한 번만 단단히 지키면 됩니다.

스키마만으로는 도메인을 알 수 없습니다

구조를 나눴어도, 자연어를 정확한 SQL로 바꾸는 일 자체는 남습니다. 스키마만 주면 모델은 컬럼 이름은 맞춰도 도메인을 모릅니다. 아임웹에서 매출이 무엇인지가 대표적입니다. 결제 총액을 그냥 더하면 환불이 섞여 실제 매출이 아닙니다. 올바른 정의는 결제액에서 취소·환불액을 뺀 순매출(pg_amount - pg_cancel_price)인데, 이건 스키마 어디에도 적혀 있지 않습니다.
그래서 이런 도메인 규칙을 백과사전이라 부르는 DB 테이블(query_knowledge_base, PostgreSQL + pgvector)에 행으로 쌓아 두고, 질문이 들어올 때마다 관련 규칙을 꺼내 프롬프트에 붙입니다. 매출 정의도 그중 한 행입니다. 규칙 한 줄이 행 하나이고, 각 행은 그 규칙의 의미를 담은 임베딩 벡터를 함께 갖습니다.
 
notion image
백과사전은 두 갈래로 주입됩니다. 자주 쓰이는 핵심 규칙은 고정 표시를 달아 메모리에 캐시해 두고 매번 넣습니다. 매출 정의나 ‘행동 데이터에서 회원 식별자는 회원 여부가 아니라 로그인 여부를 뜻한다’ 같은 것들입니다. 반면 동종업계 비교나 퍼널 전환율처럼 특정 질문에만 필요한 규칙은 미리 다 넣을 수 없으니, 질문을 같은 임베딩 공간의 벡터로 바꿔 코사인 거리가 가까운 행만 그때그때 검색해 가져옵니다.
 
실제로 매출 관련 질문이 들어오면, 도구함이 백과사전에서 어떤 행을 꺼내 썼는지를 로그로 남깁니다.
[query-generation] 참조 백과사전 stage=knowledge_reference pinned(캐시·고정): 매출 워딩 disambiguation — 기본은 순매출(pg_amount - pg_cancel_price) gold.order 회원 식별 — member_code 는 m 시작만 실제 회원 … (24건) retrieved(질문 임베딩 검색): 주문 금액(배송비 제외) / 상품 구매 금액 / ROAS(아임웹) …
위 로그처럼 매출 정의가 고정 규칙으로 들어가면, 모델은 그제야 매출을 순매출로 계산합니다. 이 규칙이 코드가 아니라 테이블 행에 있다는 게 핵심입니다. 모델이 매출을 잘못 집계하는 환각이 반복돼도, 코드는 그대로 두고 그 행의 문구만 고쳐 임베딩을 다시 계산하면 다음 질문부터 잡힙니다.
이 로그는 백과사전을 개선하는 단서이기도 합니다. 어떤 질문에 필요한 규칙이 안 딸려왔다면, 사후에 그 행의 문구나 임베딩을 손봐 다음부터 제대로 딸려오게 만듭니다. LLM 자체는 비결정적이지만, 어떤 지식을 보여줄지는 이렇게 조금씩 결정론에 가깝게 길들여 갑니다.

실행 전에는 모양·존재·정책을 봅니다

지식을 잘 주입해도 LLM은 비결정적입니다. 그래서 생성된 SQL은 실행 전에 결정론적 검증을 반드시 통과해야 합니다.
이 검증을 정규식이 아니라 파서로 한 데는 이유가 있습니다. 모델이 지시를 잘 따랐을 거라고 기대해선 안 되기 때문입니다. 정규식은 패턴을 우회하는 변종 앞에서 늘 한 발 늦지만, 파서는 쿼리의 실제 구조를 봅니다. 검증기는 SQL을 구문 트리(AST)로 파싱한 뒤 트리를 직접 순회하며 세 가지를 봅니다.
notion image
  1. 존재: 참조한 테이블과 컬럼이 진짜 있는지를 봅니다. 허용한 테이블 목록에 한해, 웨어하우스 카탈로그에서 미리 읽어 둔 컬럼과 쿼리가 참조한 컬럼을 맞춰 봅니다. 위 그림의 member_order_rank처럼 모델이 지어낸 컬럼은 여기서 막힙니다. 다만 그냥 거절하지 않고 비슷한 실제 컬럼을 함께 돌려줍니다.
  1. 정책: 결과가 안전한지를 봅니다. 자기 가게 데이터만 보도록 모든 쿼리에 테넌트 격리 필터를 강제하고, 거대한 테이블을 통째로 훑는 풀스캔을 막습니다. 결과를 조용히 망치는 산술 함정도 잡습니다. 환불이 없던 달의 매출을 "매출 − 환불"로 계산하면 환불이 NULL이라 매출까지 통째로 NULL이 되는데, 검증기는 "COALESCE(환불, 0)으로 감싸세요"라고 돌려줍니다.
  1. 모양: 이 쿼리가 형태부터 올바른지를 봅니다. 조회 구문만 허용하고, 데이터를 지우거나 바꾸는 파괴적 키워드는 막습니다. 한글을 따옴표 없이 식별자로 쓰는 것도 여기서 걸립니다. 한글로 된 물리 컬럼은 존재하지 않으니 그건 환각이고, 한글은 결과 출력 라벨에서만 허용합니다.
 
이 세 층의 차단 효과는 프로덕션 차단 로그에 고스란히 남습니다. 테넌트 격리, 풀스캔, 0 나눗셈, 한글 식별자 환각이 전부 실행 한 발 앞에서 멈춰 선 흔적입니다. 이 차단을 한 달간 모아 보면, 없는 컬럼을 지어내는 환각이 가장 잦습니다.
 
notion image
이 게이트들은 처음부터 한꺼번에 설계한 게 아닙니다. 대부분은 틀린 답을 발견하고 거꾸로 추가한 것들입니다. NULL 산술 게이트가 대표적입니다. 매출이 0으로 찍힌 표를 받아 쿼리를 뜯어 보니, 환불이 없던 기간이라 매출 − 환불의 환불이 NULL이었고 그게 매출까지 NULL로 만들었습니다. SQL은 정상 실행됐고 에러도 없었습니다. 이런 조용한 실패를 하나 찾을 때마다 같은 부류를 막는 게이트를 한 겹씩 늘려 왔습니다.
다만 파서만으로는 이 쿼리가 이름을 다 풀 수 있는지 알기 어렵습니다. 여러 CTE와 JOIN이 얽히면 별칭이 어느 컬럼을 가리키는지는 실행기 수준에서만 확정되기 때문입니다. 그래서 이름 해석은 웨어하우스에 떠넘겼습니다. 실행 직전, 같은 세션에서 쿼리 앞에 DESCRIBE QUERY를 붙여 실행합니다. 컬럼 매칭 실패 시 웨어하우스가 비슷한 후보를 돌려주고, 별칭만 틀린 경우라면 그 후보로 바꿔 다시 확인합니다.
이 검증기가 틀린 적도 있습니다. 파서가 ‘결제완료’ 같은 결과 라벨을 컬럼으로 잘못 읽어 멀쩡한 쿼리를 오차단한 것입니다. 막은 게 환각이 아니라 우리 버그였습니다. 결과 라벨로 쓰인 문자열만 예외 처리하도록 좁게 고치고 테스트로 잠가 뒀습니다. 게이트가 멀쩡한 쿼리를 막으면 안전장치가 아니라 장애입니다. 게이트를 강하게 만들수록 그 자신의 오탐도 함께 관리해야 한다는 걸 여기서 배웠습니다.

결과는 가리고 막힌 쿼리는 고칩니다

검증을 통과한 쿼리의 결과가 곧 사람에게 보여도 되는 결과는 아니었습니다. 운영자에게 아무 의미 없는 내부 식별 코드가 표에 그대로 노출돼 혼란을 준 적이 있고, 고객 이름,전화,이메일 같은 개인정보가 결과에 섞여 나오기도 합니다. 그래서 출력 단계에서 한 번 더 거릅니다. 운영자에게 의미 없는 내부 식별 코드나 고객의 개인정보는 기본적으로 가린 뒤 내보냅니다. 검증이 "이 쿼리가 안전한가"를 봤다면, 출력 가드는 "이 결과를 그대로 보여도 되는가"를 봅니다.
notion image
막힌 쿼리는 그대로 버리지 않습니다. 게이트가 낸 위반 이유를 모델에 다시 넘겨 고쳐 보게 합니다. 자가 수정(repair) 루프입니다. 이게 도는 이유는 게이트가 ‘틀렸다’가 아니라 ‘어디가 어떻게 틀렸고 무엇을 쓰면 되는지’를 돌려주기 때문입니다. 앞 장의 친절한 에러가 여기서 그대로 다음 생성의 입력이 됩니다.
물론 무한히 고치게 둘 수는 없습니다. 재생성은 최대 두 번까지만 돌리고, 같은 위반이 또 나오면 멈춰 사용자에게 되묻습니다. 헛돌지 않고 "조건을 조금 더 구체적으로 알려달라"며 판단을 넘기는 것입니다. CBT 열세 개 사이트 한 달 기준, 이 되묻기 전환은 전체 질문의 한 자릿수 % 비율로 일어났습니다.
그래도 끝내 못 막는 게 하나 있습니다. 문법도 맞고 컬럼도 실재하는데 답이 틀린 경우입니다. 재구매율 분모에 게스트 주문이 섞여 값이 절반이 된다든가 하는 식으로, SQL은 완벽히 실행되고 표도 정상으로 보입니다. 이런 조용한 실패는 실행 전 게이트로는 못 잡고, 사후에 발견해 게이트를 한 겹 더 늘리거나 백과사전 규칙을 고쳐 해결합니다.

모델이 바뀌어도 품질을 지키는 법

지금까지는 한 건의 쿼리를 어떻게 막고 고치는지에 대한 이야기였습니다. 그런데 문제가 하나 더 있습니다. 게이트도, 백과사전도, 그 아래 모델과 프롬프트도 수시로 바뀐다는 겁니다. 백과사전 규칙 하나를 고치면 다른 질문의 답이 같이 흔들릴 수 있고, 모델을 올리면 어제 잘 풀던 질문이 오늘 틀릴 수 있습니다. 실행 전 게이트가 이 쿼리를 지킨다면, 이 변화 속에서 전체 품질이 어제보다 나빠지지 않았는지는 따로 지켜봐야 합니다.
같은 질문에도 매번 다른 SQL과 다른 문장이 나오니 정답을 글자로 고정할 수는 없습니다. 그래서 골든셋을 두고, 대표 질문 100여개를 실제로 실행한 다음 그 답을 또 다른 LLM이 채점하게 하는 회귀 평가를 돌립니다. 한 답을 통째로 잘했는지 묻지 않고, 파이프라인 단계별로 다른 judge가 서로 다른 두 대상을 대조합니다.
notion image
종합 점수는 judge가 내지 않습니다. judge는 항목별 점수만 내고, 통과와 실패를 가르는 합격선은 코드가 들고 있습니다.
이렇게 나누면 똑같아 보이는 답도 맥락에 따라 다르게 채점됩니다. 예를 들어 "어느 기간으로 볼까요?"라고 되묻는 답이 있다고 합시다. 사용자가 "매출 알려줘"처럼 기간 없이 물었다면 이 되물음은 꼭 필요한 확인이니 좋은 점수를 받습니다. 하지만 "이번 달 매출 알려줘"처럼 이미 기간이 있는 질문이었다면, 바로 답할 수 있는데도 답을 피한 것으로 보고 점수를 낮춥니다.
 
notion image
6월 초 67%였던 통과율은, 까다로운 케이스를 골든셋에 계속 추가하며 프롬프트와 백과사전을 손본 끝에 중순 80% 안팎에서 안정됐습니다. 정기적으로 돌리고 배포할 때마다 다시 돌리니, 어떤 변경이 통과율을 떨어뜨렸는지가 바로 드러납니다.

AI를 믿는 대신 검증합니다

결국 우리가 만든 것은 똑똑한 AI가 아니라, AI를 믿지 않는 시스템입니다. 모델은 매번 다르게, 때로는 그럴싸하게 틀립니다. 그 출력이 실제 고객에게 닿기 전에 결정론적 게이트가 거르고, 그 게이트가 무너지지 않았는지는 회귀 배치를 통해 모니터링합니다.
핵심은 두 가지 결정이었습니다. 판단과 실행을 나눠 안전 경계를 데이터 바로 앞에 둔 것, 그리고 도메인 지식을 코드가 아니라 데이터에 둔 것입니다. 덕분에 새 진입로가 생겨도 검증을 따로 챙길 필요가 없었고, 규칙이 틀려도 배포 없이 데이터 행 하나만 고치면 됐습니다.
목표는 더 멀리 있습니다. 지금의 백과사전은 사람이 손으로 고치지만, 앞으로는 실패와 피드백을 보고 지식이 스스로 갱신되게 만들 생각입니다. 다음 단계는 질문을 어떤 데이터와 도구로 보낼지 더 똑똑하게 판단하는 라우팅입니다. 그리고 이 에이전트의 최종 모습은 데이터를 조회만 하는 게 아니라, 마케터의 일을 액션까지 대신하는 에이전트입니다. ‘지난달 재구매율’을 답하던 시스템이 "이탈 위험 고객에게 캠페인을 돌려 줘"를 직접 수행하는 단계로 가는 것입니다.
지금의 게이트들은 그 시작입니다. 광고를 집행하고 고객에게 메시지를 보내는 단계에서는 검증의 무게가 지금과 비교할 수 없이 커집니다. 데이터를 읽기만 하는데도 이만큼 막아야 한다면, 쓰기 단계의 기준은 그보다 훨씬 높아야 합니다. 읽기에서 못 막는 시스템이 쓰기에서 안전할 수는 없으니까요.
 
최예희 Backend Engineer
Analytics Squad에서 통계 제품과 분석 에이전트를 만들고 있습니다. 복잡한 문제를 단순한 구조로 푸는 일을 즐깁니다.
 

댓글