AWS 기술 블로그

티오더의 Text2SQL 에이전트 티스푼 구현 사례

1. 기업 소개

티오더는 ‘테이블 오더’ 서비스를 시작으로 F&B 시장의 새로운 패러다임을 만들어나가고 있는 기업입니다. 누적 30만 대 이상의 태블릿 설치 대수와 매월 2,500만 명 이상의 사용자를 보유하며 태블릿 메뉴판 시장에서 업계를 선도하고 있습니다. 대규모의 주문/매장/광고 데이터를 수집/가공하여 F&B 시장에 꼭 필요한 인사이트를 추출하고, 이를 다시 매장에 제공하는 선순환 구조를 확립하고 있습니다.

[그림 1. 티오더 서비스 대표 이미지]

2. 솔루션 개요

티오더 데브옵스팀은 주문/매장/광고 데이터를 기반으로 비즈니스 현황을 모니터링하고 운영 의사결정을 지원하기 위해, AWS QuickSight, Redash 대시보드, 데이터 오케스트레이션 툴을 활용한 스프레드시트 자동화 등 데이터 셀프서비스 환경을 구축해 왔습니다. 그러나 이 환경은 사전에 정의된 지표 범위 안에서만 유효했고, 현업 실무자들이 즉흥적으로 떠오르는 질문을 직접 데이터로 확인하기까지는 여전히 데브옵스팀을 경유해야 하는 병목이 존재했습니다.

티오더는 이 간극을 해소하고, 전사 구성원 누구나 데이터를 직접 조회하고 의사결정에 활용할 수 있도록 자연어 기반의 대화형 데이터 조회 에이전트를 구축했습니다.

티오더가 구축한 대화형 데이터 조회 에이전트의 주요 목표는 다음과 같습니다.

  • 자연어 질문으로 사내 데이터를 조회
  • Human-in-the-Loop 이중 승인으로 안전한 SQL 실행 (1차: 사용자 질문 의도 확인, 2차: SQL 검증)
  • 멀티턴 대화에서 후속 질문을 자연스럽게 이어가는 세션 관리
  • 처리할 수 없는 요청은 실무자에게 자동 에스컬레이션

3. 아키텍처

[그림 2. 티스푼 아키텍처]

대화형 데이터 조회 에이전트는 LangGraph의 StateGraph를 기반으로 구성된 워크플로우로 동작합니다. 각 판단 단계가 독립적인 노드로 분리되어 있어, 중간 단계의 결과가 명시적으로 기록되고 디버깅이 용이한 구조입니다.

에이전트의 핵심 인프라는 아래 AWS 서비스로 구성됩니다.

  • Amazon Bedrock은 LLM 추론과 임베딩을 담당하며, Claude 모델을 사용해 의도 분류, SQL 생성, 결과 요약 등 자연어 처리 전반을 수행합니다.
  • Amazon Athena는 생성된 SQL의 구문 검증(EXPLAIN)과 실제 실행 엔진으로 활용됩니다.
  • Amazon S3 Vectors는 RAG 파이프라인의 벡터 스토어로, 스키마/쿼리 패턴/도메인 용어 문서를 저장하고 유사도 검색을 지원합니다. 벡터 스토어로 Amazon S3 Vectors를 선택한 이유는, 메타데이터 필터 기반 검색 기능을 활용해 추가적인 Hybrid Search 없이도 충분한 검색 정밀도를 달성할 수 있었고, 그 결과 월 $5 미만의 낮은 비용으로 운영이 가능했기 때문입니다.
  • Amazon S3는 Athena 쿼리 결과를 저장하고, 대용량 결과를 Google Sheets로 내보낼 때 전체 데이터를 로드하는 용도로 사용됩니다.

4. 에이전트 워크플로우

 

[그림 3. LangGraph StateGraph 기반 에이전트 워크플로우]

에이전트 워크플로우 실행 예시

실무자는 Slack 채널에서 티스푼 봇을 @멘션하여 자연어로 데이터 조회 요구사항을 입력합니다.

예: @티스푼 지난 달 XXX 광고 상품을 노출한 매장들의 주문 수 알려줘

에이전트는 사용자의 요청사항을 바탕으로 사내 데이터 조회 관련 요청인지 의도를 파악하고, 도메인 용어 검증 과정을 거치게 됩니다.

  1. 데이터 조회 요구사항이 명확해 바로 데이터 조회가 가능한 경우라면, 이해한 내용을 사용자에게 제시합니다.
  2. 데이터 조회 관련 요청은 맞지만, 기준과 정의가 모호할 경우 사용자에게 추가 정보를 요청합니다.

[그림 4. 모호한 기준의 데이터 요청일 경우 추가 정보 요청 플로우 예시]

이 과정에서 발생하는 Human-in-the-Loop (HITL) 는 Slack Block Kit을 통해 버튼으로 제어하도록 구성했습니다.

HITL은 한 번의 데이터 조회 요청에서 총 두 단계로 이뤄집니다.

  • 1차 승인 (이해 확인): 에이전트가 사용자의 요청을 어떻게 이해했는지 자연어로 데이터 조회 계획을 제시합니다. 사용자는 Slack 버튼(진행 / 취소 / 수정)으로 응답합니다. 사용자가 수정을 요청할 경우 @멘션으로 수정 내용을 입력하면 SQL 생성 전에 반영됩니다.
  • 2차 승인 (SQL 실행 확인): 생성된 SQL을 사용자에게 공개하고 실행 여부를 다시 묻습니다. 사용자는 1차 승인 때와 동일한 버튼 인터페이스로 제어합니다.

HITL 과정에서 사용자의 액션이 발생하면 해당 메시지의 버튼을 비활성화함으로써, 동일 요청이 중복 처리되거나 다른 사용자가 버튼을 클릭하는 것을 방지합니다.

사용자가 1차 Human-in-the-Loop에서 승인할 경우, 에이전트는 쿼리를 작성한 뒤 Athena에서 EXPLAIN 실행을 통해 구문에 오류가 없는지 검증합니다. EXPLAIN 검증은 SQL의 구문 오류만 감지할 수 있습니다.

사용자가 2차 HITL 과정에서 SQL을 직접 확인하는 이유는, 구문이 올바르더라도 비즈니스 로직 오류나 의도치 않은 전체 테이블 스캔이 발생할 수 있기 때문입니다.

만약 2차 HITL (SQL 실행 요청) 전, 구문 검증(EXPLAIN)에서 에러가 발생했다면, 에이전트가 자동으로 에러 원인 분석을 수행합니다.

  1. 추가 RAG 검색 (누락된 스키마/컬럼 정보 보강)
  2. 보강된 컨텍스트로 SQL 재생성
  3. 다시 EXPLAIN 검증 (최대 3회 반복, 동일 에러 반복 시 에스컬레이션)

조회를 정상적으로 수행했다면, 내용 요약과 함께 사용자에게 후속 진행 여부를 물어봅니다.

  1. 스프레드시트 내보내기
  2. 문의 종료

만약 추가 문의사항이 있거나 관련해 또 다른 데이터 조회가 필요할 경우 슬랙에서 봇을 @멘션하여 이어서 대화를 진행할 수 있습니다.

5. 구현 방법

에이전트 설계에서 가장 먼저 고려한 것은, LLM이 사내 데이터 구조를 알지 못한다는 점입니다. 모델이 일반적인 SQL 문법을 잘 안다는 것과, 사내 테이블에서 올바른 쿼리를 작성할 수 있다는 것은 다른 문제로 보고, 이 전제에서 세 가지 설계 원칙을 도출했습니다.

  1. 모델이 필요로 하는 지식을 체계적으로 검색 가능하게 만든다.
  2. 모델에게 맡길 영역과 코드로 강제할 영역을 명확히 나눈다.
  3. 모델이 무엇을 보고 판단했는지 사람이 확인할 수 있어야 한다.

5-1. RAG 설계

문서 설계

일반적인 RAG 시스템은 문서를 청크로 분할하지만, 이 프로젝트에서는 문서 생성 단계에서 이미 최적의 의미 단위로 설계하는 접근을 택했습니다. 스키마 문서는 하나의 테이블 구조를, 쿼리 패턴 문서는 하나의 SQL 예시를, JSON 필드 문서는 하나의 중첩 필드 파싱 방법을 담습니다. 각 문서가 완결된 의미 단위이므로 별도 청킹이 불필요합니다.

각 문서에는 문서 타입(스키마/쿼리 패턴/JSON 필드/용어 사전), 테이블 이름, 연관 테이블 등의 구조화된 메타데이터를 부여하여, 단순한 필터로도 정밀한 검색이 가능하도록 구성했습니다.

아래는 각 문서 타입의 실제 구조 예시입니다.

스키마 문서 샘플:

---
tables:
  - ad_event_logs_v2
---

# 테이블 정의
티오더의 광고 송출, 인터랙션 로그를 저장하는 테이블

## 컬럼
### ad system field
- log_type (string): 로그 유형
- event_type (string): 이벤트 유형
    - impression: 광고 노출
    - view: 광고 조회 (fully exposed)
    - partialview: 광고 이탈
    - impression_failed: 광고 송출 실패
    - click: 이벤트 클릭
- object_type (string):
    - ...
- object_id (string): 오브젝트 ID
    - ...
- ad_version (string): 광고 모듈 버전
    - 저장 형식: {major}.{minor}.{patch}
    - 파싱: SPLIT_PART(ad_version, '.', N)
- ad_category (string): 광고 유형
    - rolling: 롤링 광고
    - ...
- ad_provider (string): 광고주 구분
    - store: 매장/프랜차이즈 광고
    - provider: 광고주 광고
- ad_kind (string): 광고 유형
    - image
    - video
    - ...
    - survey
- ad_group_id (string): 광고 그룹 ID
- ad_company_name (string): 광고주 구분
- ad_campaign_name (string): 광고 캠페인 이름
- ad_creative_name (string): 광고 소재 이름
- exposure_duration (int): 광고가 노출되어야 하는 시간
- img_url (string): 광고 소재 이미지 주소
...
- event_ts (timestamp): 이벤트 발생 시간 (UTC)

쿼리 패턴 문서 샘플:

---
tables:
  - ad_event_logs_v2
---

<intent>
공식 광고주 롤링 광고의 일 단위 지표 집계
</intent>

<user_queries>
2026년 1월 14일부터 15일까지 노출된 롤링 광고 소재별로 노출 수, 조회 수, 이탈 수, 노출 실패 수 알려줘
</user_queries>

<technical_constraints>
- PARTITION_OPTIMIZATION: event_dt 파티션을 반드시 WHERE 절에 설정
- ROLLING_AD_REQUIRED: 광고 노출 수 집계는 롤링 광고(screen_name='rolling__screen')만을 대상으로 해야 함.
  사용자가 "노출 수", "impression", "조회 수" 등을 언급할 때는 이 필터를 항상 적용할 것
</technical_constraints>

<sql>
```SQL
WITH provider_rolling_ad_raw AS (
    SELECT 
        event_dt,
        ad_company_name,
        ad_creative_name,
        event_type
    FROM kr_prd_layer_stage_iceberg_database.ad_event_logs_v2
    WHERE event_dt between date '2026-01-14' and date '2026-01-15'
        -- 공식 광고주 광고만
        AND ad_provider = 'provider'
        -- 롤링 광고에 대해서만 집계
        AND screen_name = 'rolling__screen'
        -- 노출, 이탈, 조회에 대해서만 집계
        AND event_type IN ('impression', 'partialview', 'view', 'impression_failed')
)
SELECT 
    event_dt, ad_company_name, ad_creative_name,
    sum(case when event_type = 'impression' then 1 else 0 end) as impression_count,
    sum(case when event_type = 'view' then 1 else 0 end) as view_count,
    sum(case when event_type = 'impression_failed' then 1 else 0 end) impression_failed_count,
    sum(case when event_type = 'partialview' then 1 else 0 end) as partialview_count
FROM provider_rolling_ad_raw
GROUP BY event_dt, ad_company_name, ad_creative_name
ORDER BY event_dt, ad_company_name, ad_creative_name;
```
</sql>

4-Stage 검색 파이프라인

단순한 유사도 검색은 SQL 생성에 충분하지 않습니다. 사용자 질문과 가장 유사한 문서를 가져오더라도, 해당 쿼리 패턴이 참조하는 다른 테이블의 스키마가 누락되어 있으면 모델이 SQL을 올바르게 생성하지 못합니다. 이 문제를 해결하기 위해 단계별로 검색 범위를 좁히고 의존성을 채우는 4-Stage 파이프라인을 설계했습니다.

Stage 동작 목적
Stage 1 사용자 질문 기반 스키마 검색 (k=5) 관련 테이블 확정
Stage 2 – hit_tables 범위 내 쿼리 패턴 (k=4)
– JSON 필드 (k=2)
– 크로스 테이블 패턴 (k=2)
테이블 패턴 수집
Stage 3 – 수집된 쿼리 패턴이 참조하는 누락 테이블의 스키마
– 쿼리 패턴 병렬
크로스 테이블 의존성 해소
Stage 4 비즈니스 용어 사전 검색 (k=3) 도메인 정의 및 enum 값 보강

[그림 5. 4-Stage 검색 구조 예시]

Stage 1 스키마 검색으로 관련 테이블 확정

사용자 질문과 유사한 스키마 문서를 검색하고, 검색된 테이블 목록(hit_tables)을 이후 단계의 필터 기준으로 사용합니다.

Stage 1에서는 사용자 질문과 유사한 스키마 문서를 검색합니다. 검색된 테이블 목록(hit_tables)은 이후 단계의 검색 필터 기준으로 사용됩니다.

# tools/retrievers/knowledge_base.py

# doc_type="schema" 필터로 관련 테이블만 검색
schema_docs = vector_store.similarity_search(query, k=5, filter={"doc_type": "schema"})
hit_tables = [doc.metadata["table_name"] for doc in schema_docs]

Stage 2 hit_tables 범위 내 쿼리 패턴 + JSON 필드 검색

Stage 2에서는 Stage 1에서 확정된 테이블 범위로 필터를 좁혀, 관련 쿼리 패턴과 JSON 필드 파싱 방법을 검색합니다. 불필요한 테이블의 문서가 컨텍스트에 섞이는 것을 방지합니다. 크로스 테이블 쿼리 패턴은 k=4 슬롯이 단일 테이블 문서로 독점될 수 있어, 별도 슬롯으로 반드시 컨텍스트에 포함시킵니다.

# tools/retrievers/knowledge_base.py

few_shot_docs = vector_store.similarity_search(query, k=4, filter={"doc_type": "few_shot", ...})
json_field_docs = vector_store.similarity_search(query, k=2, filter={"doc_type": "json_field", ...})

# 크로스 테이블 패턴은 별도 슬롯으로 보장 (hit_tables 존재 시)
if hit_tables:
    cross_docs = vector_store.similarity_search(
        query, k=2, filter={"doc_type": "few_shot", "is_cross_table": True, ...}
    )

Stage 3 누락 테이블 스키마 + 쿼리 패턴 백필

Stage 2에서 수집한 쿼리 패턴은 종종 2개 이상의 테이블을 JOIN하는 패턴을 포함합니다. 이때 해당 테이블의 스키마가 Stage 1 결과에 없으면, 모델은 그 쿼리 패턴을 활용할 수 없습니다. SQL 생성 프롬프트에 “참고 자료에 명시된 컬럼명만 사용하라”는 지시가 있기 때문입니다.

Stage 3는 이 의존성 문제를 해결하는 단계로, 수집된 쿼리 패턴이 참조하는 테이블 중 Stage 1에서 누락된 테이블의 스키마와 쿼리 패턴을 모두 추가 검색하여 컨텍스트를 보완합니다. k값을 무작정 늘리는 대신 의존성을 직접 추적하는 방식입니다.

# tools/retrievers/knowledge_base.py

# 쿼리 패턴이 참조하는 테이블 중 Stage 1 누락분 추출
all_few_shots = few_shot_docs + cross_docs
referenced_tables = {t for doc in all_few_shots for t in doc.metadata.get("tables", [])}
missing_tables = referenced_tables - set(hit_tables)

for table in missing_tables:
    schema_docs += vector_store.similarity_search(
        f"{table} 테이블 스키마", k=1,
        filter={"$and": [{"doc_type": "schema"}, {"table_name": {"$eq": table}}]}
    )
    few_shot_docs += vector_store.similarity_search(
        query, k=4, filter={"doc_type": "few_shot", ...}
    )

Stage 4 비즈니스 용어 사전으로 도메인 정의 보강

Stage 4에서는 도메인 용어의 정의와 enum 항목들을 바탕으로 컨텍스트를 추가 보강합니다. 이를 통해 스키마/쿼리 패턴만으로는 포함되지 않는 비즈니스 규칙과 용어 정의를 모델에게 주입합니다.

# tools/retrievers/knowledge_base.py

glossary_docs = vector_store.similarity_search(query, k=3, filter={"doc_type": "glossary"})

5-2. SQL 파이프라인

LLM이 RAG로 수집한 스키마와 쿼리 패턴을 컨텍스트로 활용하여 SQL을 생성하도록 구성했습니다. Amazon Athena의 EXPLAIN을 통해 생성된 SQL의 구문 오류를 검증합니다. SQL 구문 검증 실패 시, 에러를 분석하고 추가 RAG 검색 과정을 통해 컨텍스트를 보강하여 SQL을 다시 생성합니다. 이 과정을 최대 3회까지 자동 반복하도록 구성하였습니다.

에러 기반 재시도

SQL 검증이 실패하면 단순 재생성 대신, 에러 원인을 분석하고 추가 RAG 검색을 수행한 뒤 보강된 컨텍스트로 재생성합니다.

# agents/nodes.py -- generate_sql

if error and retry > 0 and failed_sql:
    # 에러 원인 분석
    result: ErrorAnalysis = analysis_llm.invoke(
        ERROR_ANALYSIS_PROMPT.format(failed_sql=failed_sql, error_message=error)
    )
    # 추가 검색 (누락 스키마/컬럼 보강)
    additional_info = tools["search_knowledge"].invoke(result.search_query)
    enriched_context = knowledge_context + "\n\n" + additional_info

    # 피드백 섹션 구성 (프롬프트 인젝션 방지)
    feedback_section = (
        "\n<system_error>\n"
        f"{error}\n"
        "</system_error>\n"
        "위 시스템 오류를 분석하여 수정된 SQL을 작성하세요.\n"
    )

5-3. 가드레일

SQL 생성 및 실행 과정에서 예외 상황이 발생하더라도 무한 루프나 잘못된 데이터 반환이 발생하지 않도록 다음의 가드레일을 코드 레벨에서 강제합니다.

  • 재시도 3회 제한: retry_count >= 3이면 루프를 끊고 에스컬레이션
  • 동일 에러 반복 감지: 같은 에러가 error_history에 존재하면 즉시 에스컬레이션 (retry 횟수 무관)
  • HITL 이중 승인: 이해 확인(1차)과 SQL 실행 확인(2차)은 interrupt() 메커니즘으로 강제
  • 이메일 도메인 검증: 스프레드시트 내보내기 시 @torder.com 도메인만 허용

5-4. 상태 관리

멀티턴 대화에서 이전 턴의 중간 산출물(SQL, 에러, 검색 결과)이 다음 턴에 그대로 남아있으면, 모델이 무관한 정보를 참조하여 잘못된 판단을 내릴 수 있습니다.

예를 들어 1턴에서 실패했던 SQL이 2턴에도 컨텍스트에 남아있으면 재생성 시 같은 패턴을 반복할 수 있습니다. 이를 방지하기 위해 각 필드에 수명 계층을 선언하고, 새로운 처리 단위가 시작될 때 해당 범위의 필드를 일괄 초기화합니다.

수명 초기화 시점 대표 필드
RETRY(1) SQL 재시도마다
  • retry_count
  • validation_error
TURN(2) 새 턴 시작 시
  • intent
  • knowledge_context
QUERY(3) 새 쿼리 확정 시
  • sql_query
  • execution_result
SESSION(4) 자동 초기화 없음
  • session_summary
# agents/lifecycle.py

class Lifecycle(IntEnum):
    RETRY = 1    # SQL 재시도마다 초기화
    TURN = 2     # 새 턴 시작 시 초기화
    QUERY = 3    # 새 torder_query 확정 시 초기화
    SESSION = 4  # 자동 초기화 없음 (세션 유지)

FIELD_LIFECYCLE = {
    "retry_count":       Lifecycle.RETRY,
    "validation_error":  Lifecycle.RETRY,
    "intent":            Lifecycle.TURN,
    "knowledge_context": Lifecycle.TURN,
    "sql_query":         Lifecycle.QUERY,
    "execution_result":  Lifecycle.QUERY,
    "session_summary":   Lifecycle.SESSION,
    "previous_sql":      Lifecycle.SESSION,
    # ... 
}

def reset_fields(trigger: Lifecycle) -> dict:
    """trigger 이하 수명의 모든 필드를 None으로 초기화"""
    return {
        field: _FIELD_DEFAULTS.get(field)
        for field, lifecycle in FIELD_LIFECYCLE.items()
        if lifecycle <= trigger
        and field not in _EXCLUDE_FROM_RESET
    }

# classify_intent에서 새 torder_query 확정 시:
updates.update(reset_fields(Lifecycle.QUERY))

6. 시행착오

티오더는 Amazon Bedrock Claude 기반의 LangGraph 에이전트로 Text-to-SQL 시스템을 구축하면서 여러 기술적 시행착오를 경험했습니다. 특히 LLM의 날짜 추론 오류, RAG 검색 결과의 의존성 누락, 프롬프트의 분류 일관성 문제가 실운영에서 실제로 발생했고, 각각 코드 레벨의 강제 처리와 파이프라인 구조 개선으로 해결했습니다.

6-1. 프롬프트 엔지니어링

의도 분류 프롬프트에서 추상적인 기준만으로는 일관된 분류가 어려웠습니다. 동일 질문 10회 테스트 시 일관된 분류 비율이 70%였으나, 올바른 분류와 잘못된 분류의 구체적 예시를 few-shot으로 제공하여 일관된 분류 비율을 90%로 개선하였습니다.

INTENT_PROMPT = """당신은 티오더 데이터 추출 에이전트 티스푼 입니다.
...

## 분류 기준:
### "torder_query" (티오더 데이터 조회)
다음 조건을 모두 충족해야 함:
- 티오더 관련 데이터 언급 (주문, 매장, 광고, 포스 정보, 태블릿 등)
- 구체적인 조회 조건 1개 이상 명시 (기간, 필터, 집계 방식, 정렬, 제한 등)
- 무엇을 알고 싶은지 명확함

예시 (torder_query):
- "어제 주문이 가장 많은 매장 5개" -> 기간(어제), 집계(주문수), 정렬(많은순), 제한(5개) 명확
- "최근 7일 광고주별 클릭 수 집계" -> 기간(최근 7일), 대상(광고주별), 집계(클릭수) 명확

### "unclear" (불명확) - 아래 중 하나라도 해당되면 unclear
- 기간이 명시되지 않음 ("숫자를 포함하지 않은 '최근", "요즘" 같은 모호한 표현 포함)
- 구체적으로 어떤 지표/데이터를 원하는지 불명확
- "통계", "현황", "데이터", "정보" 등 포괄적 표현만 사용

...

[올바른 분류 예시]
입력: "지난달 경기도 지역의 시/군 단위로 등록 매장 수 알려줘"
INTENT: torder_query (데이터 조회 요청, 명확한 조건)
입력: "자리에서 결제하기랑 멀티오더 차이가 뭐야?"
INTENT: domain_knowledge (티오더 도메인 개념 질문)
...

입력: "오늘 날씨 어때?"
INTENT: out_of_scope (데이터 조회와 무관)
...

입력: "광고 성과 좋은 매장 알려줘"
INTENT: unclear (기준/기간/대상 불명확)
...

[잘못된 분류 예시]
...
"""

6-2. 날짜 처리

멀티턴 대화에서 “똑같은 기간으로 다시 뽑아줘”라는 후속 질문이 들어왔을 때, 모델이 잘못된 연도로 SQL을 생성하는 문제가 발생했습니다. EXPLAIN 검증은 valid를 반환했지만(구문 오류가 아니므로), 실행 결과는 0건이었고 겉으로는 “조회 결과 없음”처럼 보였습니다.

[턴 3] 최근 3일 주문 많은 매장 조회
→ SQL: date_add('day', -3, CURRENT_DATE) -- 동적 표현, 실행 시점 기준으로 정상 계산

[턴 4] "똑같은 기간으로 다시 뽑아줘"
→ merged_request: "2월 9일부터 2월 11일까지 ..." (연도 생략)
→ SQL 생성 시 할루시네이션 발생:
   WHERE DATE(order_time) BETWEEN DATE '2025-02-09' AND DATE '2025-02-11'
                                          ^^^^
                                    2026이 아닌 2025년으로 생성

이전 턴의 SQL이 CURRENT_DATE – INTERVAL 7 DAY와 같은 동적 날짜 표현을 사용했기 때문에, 대화 히스토리에 확정된 날짜값이 남지 않았습니다. 모델이 “똑같은 기간”을 추론해야 하는 상황에서 학습 데이터 기반의 시간 감각(예: 2023년)이 개입하면서 잘못된 연도가 생성된 것입니다.

문제를 해결하기 위해 AgentState에 current_date 필드를 추가하고, 그래프 진입 시 KST 기준으로 단 한 번 고정했습니다. 날짜가 개입하는 모든 노드에서 “지난주”, “이번 달” 같은 상대적 표현 대신 2026-03-15와 같은 YYYY-MM-DD 절댓값으로 변환하도록 프롬프트를 구성했습니다.

current_date처럼 실행 컨텍스트에 종속된 값은 모델이 추론하도록 두는 대신, 시스템이 명시적으로 주입하는 방식으로 처리해야 합니다.

6-3. RAG 검색 의존성

부산에 있는 매장 중 최근 7일간 주문 100건 이상 매장의 시군구별 체류시간 통계”라는 테스트 질문에서 조용한 실패가 발생했습니다. 구문 오류도 없고 결과도 그럴듯해 보여서, 도메인을 모르는 사용자는 이 결과가 과소 추정됐다는 것을 알 수 없습니다. 이런 종류의 실패가 가장 위험합니다.

[그림 6. 체류시간 계산 예시]

체류시간은 도메인 특성상 주문 로그와 광고 로그를 FULL OUTER JOIN 해야 정확히 계산됩니다. 단건 주문의 경우 주문 로그만으로는 첫 이벤트와 마지막 이벤트가 동일 타임스탬프가 되어 체류시간이 0분으로 집계되기 때문입니다. RAG 검색 결과에는 두 테이블을 결합하는 cross-table few-shot이 포함돼 있었습니다. 그러나 모델은 이를 무시하고 주문 로그만으로 SQL을 생성했습니다.

Stage 1 hit_tables: [주문 로그, 매장 정보, 태블릿 정보]
Stage 2 few_shot[2]: [주문 로그 + 광고 로그] / 체류시간 계산 (cross) <- 검색됨
         ❌ 광고 로그 테이블 스키마: 없음

실행 결과:

시/군/구 체류시간 p25 체류시간 중앙값
서구 0분 27.9분
동래구 0분 20.9분

구문 오류도 없고 결과도 그럴듯해 보여서, 도메인을 모르는 사용자는 이 결과가 과소 추정됐다는 것을 알 수 없습니다. 이런 종류의 실패가 가장 위험합니다.

원인은 단순했습니다. SQL 생성 프롬프트에 “스키마에 명시된 컬럼만 사용하라”는 규칙이 있었고, 광고 로그 테이블의 스키마가 컨텍스트에 없었기 때문입니다. 모델은 프롬프트의 지시를 정확히 따른 것뿐이었습니다. k값을 높이는 것은 해결책이 되지 못했습니다. 질문에 “광고”라는 단어가 없기 때문에 광고 로그 테이블 스키마와의 의미 유사성이 낮아, k를 올려도 검색되지 않을 가능성이 컸습니다. 필요한 문서를 정확히 찾아야지, 더 많은 문서를 가져오는 것은 해결책이 아니었습니다.

이를 해결하기 위해 Stage 3를 도입했습니다. Stage 2에서 수집된 쿼리 패턴의 메타데이터(‘tables’ 배열)를 순회하여 Stage 1 결과에 없는 테이블을 식별하고, 해당 테이블의 스키마와 쿼리 패턴을 추가로 채우는 방식입니다.

Stage 3 누락 백필: [광고 로그 테이블]
  backfill[1] 광고 로그 테이블 스키마
  backfill[2] 광고 로그 테이블 쿼리 예시

이 2건의 추가로 모델은 광고 로그 테이블의 컬럼을 사용할 수 있게 됐고, cross-table few-shot의 FULL OUTER JOIN 패턴을 참고하여 올바른 체류시간 SQL을 생성했습니다.

Stage 3는 더 많은 문서를 가져오는 단계가 아니라, 이미 가져온 문서가 제대로 작동하기 위해 빠진 조각을 채우는 단계입니다.

7. 마무리

티오더가 본 프로젝트에서 배운 핵심은, 모델에게 제공하는 컨텍스트 자체가 시스템의 입력 데이터라는 것입니다.

프롬프트에 모호한 규칙을 넣으면 모호한 판단이 나오고, RAG에 불완전한 스키마를 넣으면 불완전한 SQL이 생성됩니다. 완전 자동화 대신 이해 확인 -> SQL 생성 -> 실행 확인이라는 단계적 승인 루프를 선택한 것은 조직 안에서 신뢰받고 사용될 수 있는 에이전트를 만들기 위한 접근이었습니다. 티오더는 충분한 운영 데이터가 쌓이면 특정 유형의 요청부터 점진적으로 자동화를 확대할 수 있을 것으로 기대합니다.

추후에는 사용자 피드백 수집 루프를 통한 프롬프트 few-shot 개선 자동화, 쿼리 등록 반자동화, 그리고 세션 내 불변정보 캐싱을 통한 토큰 비용 최적화를 진행할 계획입니다. 티오더는 Amazon Bedrock, Amazon Athena, Amazon S3 Vectors 등 AWS 서비스를 활용하여 데이터 민주화를 실현하는 AI 기반 솔루션을 지속적으로 발전시켜 나가고 있습니다.

저자 소개

Alex

Gyuhaeng Lee (Alex)

티오더 R&D 그룹의 데이터 분석가로 데이터 파이프라인 운영과 분석 업무를 담당하고 있습니다. 구성원 누구나 신뢰할 수 있는 데이터를 활용할 수 있는 환경을 만들기 위해 노력하고 있습니다.

Jinah Kim

Jinah Kim

김진아 솔루션즈 아키텍트는 스타트업 고객이 효율적이고 안정적인 서비스를 운영할 수 있도록 아키텍처 설계 가이드를 드리고 기술을 지원하는 역할을 수행하고 있습니다.

Hyungu Lee

Hyungu Lee

이현구 Account Manager는 AWS 스타트업팀에서 생성형 AI, 클라우드 네이티브 아키텍처 등 최신 기술을 활용해 스타트업이 제품·서비스 혁신에 집중할 수 있도록 최적의 솔루션을 제안합니다. AWS 스타트업팀은 초기 아이디어 검증부터 글로벌 스케일업까지, 스타트업의 빠른 성장을 전방위로 지원하고 있습니다.