Query Spec LLM 구현기

정성연·2026년 3월 8일
post-thumbnail

최근에 챗봇 멀티모달과 관련하여 작업할 내용이 있었다.
어째 하다보니 단순 일회성으로 파일이나 이미지를 입출력하는게 아니라 과거 특정 날짜에 올렸거나 특정 내용을 담은 파일에 대한 검색이 가능해야한다는 요구사항이 추가되었다.
때문에 가장 필요로 했던 것이 바로 자연어로 파일을 찾는 기능이었다.

예를 들어 이런 질문이다.

  • "저번에 올린 회의록 중 최신 5개 보여줘"
  • "pdf 파일 중에서 계약서만 보여줘"
  • "A파일 말고 다른 파일 뭐 있었지?"

처음에는 LLM에게 SQL을 생성하게 하면 되겠지. 하고 단순하게 생각했다.

하지만 실제 서비스에 붙이면서 생각보다 많은 문제가 터졌고 새로운 설계가 필요하게 되었다.

핵심은 하나이다.

SQL을 생성하게 하지 말고, 조회 스펙(Query Spec)을 생성하게 하자.

이번 글은 자연어 → SQL 조회 조건을 만드는 에이전트와 그 결과를 RDB / VectorDB에서 공통으로 해석하는 구조를 만들게 된 과정에 대한 기록이다.


자연어 데이터 조회

서비스에는 파일(assets)이 꽤 많이 쌓인다.

유저는 파일명을 정확히 기억하지 않는다.
그래서 보통 이렇게 묻는다.

  • "회의록 파일 보여줘"
  • "pdf 파일 뭐 있었지?"
  • "어제 올린 파일"

맨 처음 이런 요구사항을 처리하기 위해서 두 가지 정도의 방법을 생각했다.

방법 1. 검색용 API를 여러 개 만든다

예를 들면 이런 식이다.

  • /files?type=pdf
  • /files?name=회의록
  • /files?created_after=...

문제는 자연어 질문은 이런 식으로 정형화되어 있지 않다는 것이다.

"저번에 올린 회의록 중 최신 5개"

이 질문은 사실 아래 조건을 동시에 요구한다.

  • 파일 이름 필터
  • 정렬
  • limit

하지만 API만으로 처리하면 예측할 수 없는 수 많은 조합이 생기게 되고 이 방법으로는 지속가능한 서비스를 제공할 수 없을 것이라는 생각이 들었다.

방법 2. LLM이 SQL을 생성한다

그래서 다음으로 LLM에게 SQL을 생성하게 했다.

SELECT *
FROM assets
WHERE asset_type = 'pdf'
ORDER BY created_at DESC
LIMIT 5

처음에는 꽤 잘 되는 것처럼 보였지만 QA단계에서 바로 문제가 생겼다.


SQL 생성 방식이 가진 문제

SQL을 그대로 생성하게 하면 생각보다 문제가 많다.

1. 존재하지 않는 컬럼 생성

LLM은 이런 걸 아무렇지 않게 만든다.

WHERE file_type = 'pdf'

하지만 실제 컬럼은

asset_type

이다.

아무리 테이블 정보를 준다 한들, 확률은 0이 아니라는게 문제다.

2. LIKE 남발

사용자가 파일명을 말하면 LLM은 높은 확률로 이런 sql문을 출력한다.

WHERE file_name LIKE '%회의록%'

하지만 실제로는 이미 파일 ID 목록이 주어져 있고 LIKE문 보다는 ID 접근이 훨씬 정확하다.

3. SQL 표현식 폭주

이 문제가 가장 큰 문제인데 바로 다음과 같은 것들이 튀어나온다는 것이다.

NOW()
INTERVAL
DATE_TRUNC
SUBQUERY

이런 것들은 DBMS에 과하게 종속적이기도 하고 검증하기도 어려울 뿐더러 통제하기도 힘들다.

4. VectorDB와 재사용이 불가능하다

현재 챗봇 서비스는 RDB + VectorDB를 같이 사용한다.

  • 메타데이터 조회 → RDB
  • 문서 검색 → VectorDB

문제는 SQL 문자열은 VectorDB에서 재사용할 수 없다는 것이다.

여기에 예상치 못한 프롬프트로 인한 SQL Injection 가능성까지 고려해보면 문자열로 생성된 sql문은 서비스에서 바로 사용하기에 부적합하다는 것이 결론이었다.

결국 통제 가능성과 재사용성을 고려한 sql 생성 에이전트를 만들어야하는 상황이 되었다.


LLM 역할 변경

여기서 생각을 바꿨다.

SQL을 생성하게 하지 말자.

대신 조회 스펙(Query Spec) 을 생성하게 하자.

즉 이런 구조다.

User Query
   ↓
LLM
   ↓
Query Spec(JSON)
   ↓
Interpreter
   ↓
SQLAlchemy / Qdrant Filter

LLM은 더 이상 SQL을 생성하지 않는다.
대신 조회 조건을 구조화된 JSON으로 반환한다.


Query Spec DSL 정의

먼저 조회 DSL을 정의했다.
기본적으로 sql에서 사용할 수 있는 대부분의 연산자와 데이터타입을 구성하는걸 목표로 했다.

Operator = Literal[
    "eq", "neq", "gt", "gte", "lt", "lte",
    "like", "ilike",
    "in", "not_in",
    "is_null", "is_not_null",
    "between"
]

Direction = Literal["asc", "desc"]

Scalar = Union[str, int, float, bool, datetime, date]
Value = Union[Scalar, List[Scalar]]

Where 조건은 트리 구조로 만든다.
타겟 컬럼과 연산, 비교값 및 구간을 설정할 수 있다.
우선순위는 value에 있고 만약 없을 경우 구간의 시작과 끝인 startend를 확인한다.

class WhereCondition(BaseModel):
    column: str
    operator: Operator
    value: Optional[Value] = None
    start: Optional[Scalar] = None
    end: Optional[Scalar] = None

논리 그룹도 지원한다.
이제 WhereConditionorand로 묶을 수 있게 되었다.

class WhereGroup(BaseModel):
    op: Literal["and", "or"] = "and"
    conditions: List[Union["WhereGroup", WhereCondition]] = Field(default_factory=list)

정렬은 단순하게 유지한다.

class OrderBy(BaseModel):
    column: str
    direction: Direction = "desc"

최종 조회 스펙은 다음과 같다.

class Statement(BaseModel):
    where: Optional[WhereGroup] = None
    order_by: List[OrderBy] = Field(default_factory=list)
    limit: int = Field(20, ge=1, le=200)
    offset: int = Field(0, ge=0)
    allowed_columns: Optional[Iterable[str]] = None

SQL 에이전트 입력과 출력

LLM이 받는 입력은 다음과 같다.

class SqlInput(BaseModel):
    user_id: str
    query: str
    table_info: str
    data_list: str

여기서 중요한 건 data_list다.
파일 목록을 같이 주기 때문에 LLM은 파일명을 보고 ID 필터로 변환할 수 있다.

출력은 단순하다.

class SqlOutput(BaseModel):
    where: Optional[WhereGroup] = None
    order_by: List[OrderBy] = Field(default_factory=list)
    limit: int = Field(20, ge=1, le=200)
    offset: int = Field(0, ge=0)

LLM은 이제 문자열로 된 SQL을 절대 생성하지 않는다. 오직 JSON만 반환한다.


Query Spec 번역기

이제 이 스펙을 실제 DB 쿼리로 변환해야 한다.

그래서 번역기 를 만들었다.

예외 타입 분리

class QuerySpecError(ValueError):
    pass

쿼리 생성 과정에서 발생하는 오류를 명확히 분리하기 위한 예외 타입이다.

예를 들어 다음 상황에서 사용된다.

  • 허용되지 않은 컬럼
  • 존재하지 않는 컬럼
  • 빈 IN 리스트
  • 너무 깊은 WHERE 트리
  • 지원하지 않는 operator

컬럼 변환 함수

def _get_col(entity, col_name, allowed_cols=None):

이 함수는 문자열 컬럼 이름을 실제 SQLAlchemy 컬럼 객체로 변환한다.

핵심 역할은 두 가지다.

1. 허용 컬럼 검증

if allowed_cols and col_name not in allowed_cols:
    raise QuerySpecError

외부 입력이 임의의 컬럼을 건드리지 못하게 막는다.

예를 들어 내부 컬럼

  • tenant_id
  • internal_score
  • is_deleted

같은 컬럼은 외부에서 직접 접근하게 하고 싶지 않을 수 있다.

이때 whitelist로 제어한다.

2. ORM / Core 지원

if hasattr(entity, col_name)
if hasattr(entity.c, col_name)
  • ORM model → entity.column
  • Core table → entity.c.column

둘 다 지원한다.

WHERE 조건 생성

가장 중요한 함수는 build_where_expr이다.

def build_where_expr(...)

이 함수는 다음을 수행한다.

  • WhereCondition
  • WhereGroup

을 받아서 SQLAlchemy boolean expression으로 변환한다.

트리 복잡도 방어

AI나 외부 시스템이 생성한 조건은 예상보다 복잡해질 수 있다.

그래서 다음 제한을 둔다.

max_nodes
max_depth
  • OR 조건 수백 개
  • 과도한 중첩

이런 상황을 방지한다.
이는 성능 보호와 악의적 입력 방어 역할을 한다.

재귀 구조

핵심 구현은 재귀 순회다.

def _walk(node, depth):

노드를 순회하면서

  • WhereGroup → 논리 연산 조합
  • WhereCondition → 실제 연산 해석

을 수행한다.

WhereGroup은 SQLAlchemy의

and_(...)
or_(...)

같은 논리 표현식으로 변환되고, 실제 중요한 로직은 WhereCondition을 해석하는 부분이다.

Operator 해석

위에서 언급했지만 간단하게 다시 정리하자면 WhereCondition의 구조는 다음과 같다.

WhereCondition
 - column
 - operator
 - value

해석 과정은 단순하다.

  1. 컬럼을 가져온다
col = _get_col(entity, node.column)
  1. operator를 SQLAlchemy 연산으로 변환한다.

이 부분이 사실상 Query Spec DSL을 해석하는 인터프리터 역할을 한다.

비교 연산

지원하는 비교 연산은 다음과 같다.

eq
neq
gt
gte
lt
lte

이에 대한 SQLAlchemy 매핑은 다음과 같다.

col == value
col != value
col > value
col >= value
col < value
col <= value

LLM이 만든 Query Spec은 결국 이 연산들로 변환된다.


문자열 검색

문자열 검색 연산과 그 매핑은 다음과 같다.

like
ilike


col.like(value)
col.ilike(value)

검색 패턴 %keyword% 생성은 해석기가 아니라 에이전트 단계에서
처리한다.

즉 Query Spec에는 이미 패턴 문자열이 들어 있어야 한다.

IN 연산

IN 연산은 다음과 같이 매핑된다.

in
not_in

col.in_(value)
not_(col.in_(value))

여기에 중요한 방어 로직이 들어가는데 다음과 같은 경우다.

id in []

sql 에이전트에서 위와 같은 경우가 나올 수 있는데 이 경우 SQL 의미가 없기 때문에 해석 단계에서 바로 예외 처리한다.

NULL 체크

SQL에서는 NULL 비교가 일반 비교와 다르기 때문에 별도 연산이 필요하다.

is_null
is_not_null

col.is_(None)
col.is_not(None)

BETWEEN

BETWEEN은 두 가지 입력 방식을 지원한다.

value=[start, end]

또는

start / end 필드

해석기는 두 방식을 모두 처리한다.

col.between(start, end)

이 설계는 LLM 출력이 약간 흔들리더라도 복구가 가능하도록 하기 위한 것이다.

Statement 조립

마지막 단계는 build_statement이다.

stmt = select(entity)

여기에 다음 요소를 붙인다.

WHERE

stmt.where(where_clause)

ORDER BY

stmt.order_by(col.asc())

여기서도 _get_col을 사용한다.

즉, 정렬 컬럼도 검증된다.

LIMIT / OFFSET

stmt.limit()
stmt.offset()

구조적 장점

이 설계의 가장 큰 특징은

SQL 문자열을 만들지 않는다

는 점이다.

대신 쿼리 스펙 트리를 생성한다.

설계를 하면서 가장 고려했던 부분은 아래와 같다.
다시 말해서 아래와 같은 장점을 가질 수 있다는 말이다.

  • SQL injection 위험 감소
  • 타입 안정성
  • 복잡한 논리 처리 쉬움
  • ORM 친화적

정리

아직 완전히 운영단계로 넘어간 설계는 아니다.
그래서 지금도 추가 보완이 이뤄지고 있는 영역이긴 하다.

하지만 기존 자연어 -> sql변환이 필요한 영역의 구현을 온전히 llm에 맡기는 구조에서 통제 가능한 구조로 변형해본 경험은 꽤 재밌었다.
부디 운영단에서 문제없이 돌아갔으면 하는 바람과 함께 글을 마무리해보겠다.

0개의 댓글