Query Spec 이후의 문제: Expression Agent 설계

정성연·2026년 6월 16일
post-thumbnail

이전 글에서는 자연어를 SQL로 직접 변환하는 방식의 한계를 해결하기 위해 중간 추상화 계층인 Query Spec(JSON)을 도입했었다.

User Query ──> LLM ──> Query Spec (JSON) ──> Interpreter ──> DB

위와 같이 조회 조건이 구조화된 JSON으로 캡슐화되면서 데이터베이스 조회 자체에 대한 제어권을 확보했다.

그러나 이를 실제 서비스에 적용하면서 또 다른 문제가 발생했다. 사용자의 질문은 단순 조회가 아닌 조회 결과 데이터를 기반으로 한 2차 가공 및 추가 계산을 포함하는 경우가 많았다.

  • "지난달 대비 이번 달 총매출액 차이가 얼마야?"
  • "전체 가입자 수 대비 활성 유저 비율을 계산해줘."
  • "A 상품의 평균 주문 금액은 얼마야?"

이러한 질문은 데이터베이스에서 레코드를 가져오는 것만으로 해결되지 않으며, 조회된 데이터 위에 수학적 연산이 실행되어야 한다.

처음에는 Query Spec에 계산 필드를 추가하는 방식으로 접근했으나, Query(조회)와 Expression(계산)은 완전히 서로 다른 책임을 가져야 한다는 결론에 도달했다.


Query와 Expression의 차이

두 개념의 역할은 다음과 같이 분리된다.

  • Query의 역할: 어떤 데이터를 가져올 것인가 (Data Acquisition)
  • Expression의 역할: 가져온 데이터를 어떻게 계산할 것인가 (Data Computation)

1. Query의 영역

"최근 완료된 주문 데이터 5개 조회" 라는 요청은 Query의 영역이다. 스토리지에서 특정 조건의 레코드를 인덱싱하여 메모리로 올리는 행위다. Query Spec은 다음과 같이 표현된다.

{
  "where": {
    "status": "COMPLETED"
  },
  "order_by": [
    {
      "column": "ordered_at",
      "direction": "desc"
    }
  ],
  "limit": 5
}

2. Expression의 영역

반면, "지난달 매출과 이번 달 매출의 차이" 라는 요청은 데이터 획득 이후의 연산이다. Query Agent가 조건에 맞춰 아래 데이터를 뽑아왔다고 가정한다.

[
  { "sales_month": "2026-05", "total_amount": 1000000 },
  { "sales_month": "2026-06", "total_amount": 1500000 }
]

이때 필요한 계산은 1,500,000 - 1,000,000 연산이다. 즉, Query는 데이터라는 재료를 준비하고, Expression은 그 재료를 연산하여 최종 값을 도출한다.


첫 번째 접근: SQL Expression 생성 방식의 한계

초기에는 Query Spec과 유사하게 LLM이 SQL 계산식을 생성하도록 유도했다.

SELECT MAX(total_amount) - MIN(total_amount) FROM monthly_sales

그러나 이 방식은 기존 Text-to-SQL의 문제를 답습했다.

1. DB 종속성 발생

계산 로직이 고도화될수록 SQL Expression에는 데이터베이스 엔진마다 상이한 내장 함수(DATE_TRUNC, INTERVAL, WINDOW FUNCTION 등)가 섞여 들어간다. 이는 RDB 외에 NoSQL이나 VectorDB 검색 결과, 외부 API 호출 결과에 동일한 계산을 적용할 수 없게 만들어 재사용성을 떨어뜨린다.

2. 검증의 어려움

LLM이 동적으로 생성한 수식 문자열을 실행하려면 파싱과 검증을 다시 거쳐야 한다. 컬럼의 실제 존재 여부, 데이터 타입의 연산 가능 여부, 허용되지 않은 시스템 함수 포함 여부를 런타임에 확인해야 하므로 보안과 에러 리스크가 다시 발생한다.


두 번째 접근: Aggregate 중심의 Expression DSL 구축

SQL 문자열 생성을 중단하고, 계산 구조를 정의하는 전용 Expression DSL(Domain Specific Language)을 설계했다. 수식이 아닌 파이썬 객체로 컴파일될 수 있는 트리 구조(AST)를 만드는 것이 목적이었다.

Expression = AggregateExpr | BinaryExpr | ConstantExpr

특정 상품 테이블의 가격 총합을 구하는 의도는 아래와 같이 구조화된다.

{
  "function": "sum",
  "table_name": "products",
  "field": "price"
}

이진 연산(BinaryExpr)을 활용하면 복잡한 수식도 트리 형태로 조립 가능하다. 아래는 "총 주문 금액에서 총 환불 금액을 제외" 하는 계산 구조다.

{
  "operator": "-",
  "left": {
    "function": "sum",
    "table_name": "orders",
    "field": "pay_amount"
  },
  "right": {
    "function": "sum",
    "table_name": "refunds",
    "field": "refund_amount"
  }
}

구조화 측면에서는 올바른 방향이었으나, 프로덕션 환경에서 새로운 문제가 발생했다.


문제: Expression의 기준은 DB Schema가 아니다

Aggregate Expression은 철저히 DB 스키마 기준으로 동작하고 있었다. 수식 내부에 table_name과 field가 직접 명시되는 점이 이를 방증한다.

그러나 계산을 수행하는 시점에 Expression Agent가 바라보아야 할 대상은 DB 전체 스키마가 아니라, 이미 Query Agent가 필터링하여 가져온 조회 결과 데이터(Result Context)다.

만약 유저가 "지난달 매출과 이번 달 매출의 차이" 를 물었을 때 Expression Agent가 DB 스키마 기준으로 동작하면 다음과 같은 문제가 생긴다.

  1. Expression Agent가 날짜 조건을 분기하기 위해 스키마를 다시 탐색한다.
  2. where 절에 준하는 필터링 로직을 Expression 내부에서 다시 구현하려고 시도한다.
  3. 결과적으로 Query Agent의 필터링 책임과 Expression Agent의 계산 책임이 다시 뒤섞인다.

이를 통해 "Expression은 데이터베이스가 아니라, 이미 메모리에 로드된 조회 결과(Result Context)만을 기준으로 동작해야 한다"는 결론을 얻었다.


최종 구조: Result Context 기반 Expression과 ColumnSelector

Expression Agent의 프롬프트와 입력 컨텍스트를 다음과 같이 변경했다.

  • 기존: DB Schema + 사용자 질문
  • 변경: Query Agent가 반환한 결과 데이터(Row 리스트) + 사용자 질문

이 구조에서 Expression Agent는 테이블이나 SQL, WHERE 조건을 고려하지 않는다. 오직 이미 조회된 데이터 중 어떤 값을 선택하고 어떤 계산을 수행할 것인가만 결정한다.

ColumnSelector 도입

이를 위해 단일 필드를 가리키던 구조를 인덱스 기반의 ColumnSelector로 전환했다.

class ColumnSelector(BaseModel):
    table_name: str
    field_name: str
    indexes: Optional[list[int]] = None

이 구조는 특정 테이블 결과물의 몇 번째 레코드(들)에 있는 특정 컬럼을 선택하겠다는 의미다.

인덱스(Index) 사용 이유

데이터를 구분하고 정렬하는 것은 Query 영역이다. Query Agent가 데이터를 정제하여 넘겨줄 때 가상으로 생성된 index 메타데이터를 함께 부여한다.

{
  "monthly_sales": [
    { "index": 0, "sales_month": "2026-05", "total_amount": 1000000 },
    { "index": 1, "sales_month": "2026-06", "total_amount": 1500000 }
  ]
}

데이터가 정렬 및 인덱싱되어 제공되므로, Expression Agent는 날짜 계산 없이 인덱스를 지정해 뺄셈 연산 구조를 생성한다.

{
  "operator": "-",
  "left": {
    "table_name": "monthly_sales",
    "field_name": "total_amount",
    "indexes": [1]
  },
  "right": {
    "table_name": "monthly_sales",
    "field_name": "total_amount",
    "indexes": [0]
  }
}

이 JSON은 백엔드의 Expression Interpreter로 전달되어 안전한 파이썬 연산(1500000 - 1000000)으로 실행된다. 컴파일 에러나 타입 불일치, DB 부하를 유발할 여지가 차단된다.

집계(Aggregate) 연산의 확장

전체 데이터를 대상으로 하는 집계 역시 이 아키텍처에 포함된다. 상품 리스트 전체의 가격 합을 구할 때는 indexes를 null로 지정하여 전체 범위를 바라보게 한다.

{
  "function": "sum",
  "column": {
    "table_name": "products",
    "field_name": "price",
    "indexes": null
  }
}

특정 로우(예: 상위 3개 상품)에 대해서만 집계를 수행하고 싶다면 indexes: [0, 1, 2]와 같이 타겟팅된 인덱스 리스트를 명시하면 된다.


데이터 처리 파이프라인 최종 구조

전체 시스템의 아키텍처 흐름은 다음과 같다.

User Query (자연어 질문)
       │
       ▼
  Query Agent (의도 파악 및 조회 스펙 빌드)
       │
       ▼
  Query Spec (구조화된 JSON)
       │
       ▼
Database / VectorDB (데이터 엑세스)
       │
       ▼
 Result Context (인덱스가 포함된 데이터 셋)
       │
       ▼
Expression Agent (결과 컨텍스트 기반 계산 설계)
       │
       ▼
 Expression AST (추상 구문 트리 JSON)
       │
       ▼
Expression Interpreter (런타임 계산 실행)
       │
       ▼
 Calculated Result (최종 연산 결과 및 응답)

Query는 데이터를 찾고, Expression은 찾은 데이터를 계산하며 각자의 책임이 명확히 분리된다.


마무리

LLM에게 자유도를 높여 SQL 생성 권한을 통째로 부여하는 방식은 프로덕션 환경에서 리스크를 발생시킨다.

LLM의 역할을 자연어를 구조화된 의도(DSL)로 변환하는 것까지로 제한하고, 이를 실행하고 검증하는 것은 예측 가능한 Interpreter 환경에 위임해야 시스템이 견고해진다.

Query Spec에서 시작해 Result Context 기반의 Expression 모델로 확장된 설계는 자연어 기반 데이터 처리 시스템을 안전하고 격리된 가상 DSL 실행 환경으로 진화시켰다. 이 분리를 통해 모델 업그레이드나 DB 스키마 변경에 대응 가능한 데이터 에이전트 구축이 가능해진다.

0개의 댓글