dev notes

자연어 데이터 질의 시스템 개발 회고 [5]

2026-04-2618 min read
공유

처음에는 자연어 질문을 SQL로 변환하는 기능을 검증하는 정도로 시작했습니다. 실제 레거시 데이터베이스에 연결하려고 하자 테이블을 고르고 SQL을 만드는 일 외에도 테넌트 격리, 실행 권한, 도메인 용어, 오류 보정, 운영 관측이 필요했습니다.

이 글에서는 초기 리서치부터 v0.4.0까지 자연어 기반 데이터 질의 시스템을 만들며 구현 범위가 어떻게 달라졌고, 각 문제를 어떤 구조로 나눴는지 정리합니다.

자연어 질의에서 운영 기능까지#

Loading diagram...

구현은 다음 네 단계로 진행했습니다.

Phase 1: 리서치 + 설계
├── 엔터프라이즈 사례 연구 (14개 기업)
├── 아키텍처 설계 + 기술 스택 결정
└── 프로젝트 셋업

Phase 2: 코어 구현
├── Multi-Agent 파이프라인 (Intent, Table, SQL, Validation)
├── RAG (Embedder, Indexer, Retriever + Rerank)
├── Self-Correction Subgraph + Guardrail
└── 멀티테넌트 + 온보딩 자동화

Phase 3: 학습 + 관측성
├── 피드백/학습 시스템 (Feedback Learner, Active Learner)
├── 관측성 (Langfuse 연동, 비용 추적)
├── Hybrid Search (Dense + Sparse)
└── 보안 강화 (PII Masker, API Security)

Phase 4: 안정화 + 테스트
├── 통합 테스트 + 보안 테스트
├── 크로스 스키마 대응 + CTE 화이트리스트
├── HR 도메인 에이전트 추가
└── v0.4.0 태깅

구현을 진행하면서 SQL 생성만으로는 실제 서비스에 연결할 수 없다는 점이 분명해졌습니다. 그래서 테넌트 정보와 SQL 검증, 읽기 전용 실행기를 별도 후처리가 아니라 질의 처리 흐름 안에 포함했습니다.

숫자로 보는 결과#

항목수치
총 코드량약 24,500줄
프로덕션 코드 (src/)15,884줄
테스트 코드 (tests/)8,659줄 (42개 파일)
소스 모듈15개 도메인
Agent 노드7개 코어 + 1개 선택적 + 서브그래프
라우팅 함수7개
GraphState 필드44개
외부 서비스6개 (Claude, OpenAI, Cohere, Qdrant, Redis, PostgreSQL)
테스트 케이스315개 (전부 통과)
리서치 대상14개 기업
문서12개 (아키텍처, 검색, 기획서 등)

모듈별 코드량#

모듈파일 수줄 수역할
agents/123,917Multi-Agent 파이프라인
interface/112,350API, SSE, Slack Bot
onboarding/72,053테넌트 온보딩 자동화
learning/71,026피드백 학습, Active Learning
tenant/7926멀티테넌트 관리
store/3832대화 저장소, 캐시, SQL 실행기
metadata/2764Workspace, Sample Query
observability/5705Langfuse, 비용 추적
dictionary/5701비즈니스 용어 사전
guardrail/4673보안 (SQL Filter, PII, Audit)
rag/3583임베딩, 인덱싱, 검색
evaluation/2447평가 프레임워크
schema/4376DB 스키마 추출, Dialect

코드량 자체가 품질을 설명하지는 않습니다. 다만 자연어를 SQL로 바꾸는 기능보다 API, 온보딩, 테넌트 격리, 관측처럼 실제 연결 과정에서 필요한 코드가 더 넓은 범위를 차지했습니다.

리서치에서 정한 구현 방향#

리서치 글에서 14개 사례를 살펴본 뒤, 단일 프롬프트에 전체 스키마를 전달하는 방식은 사용하지 않기로 했습니다. 대신 스키마 검색, 테이블 선택, SQL 생성, 실행 전 검증을 각각 나눴습니다. 각 단계의 결과를 따로 확인하고 실패한 지점부터 다시 실행하기 위해서였습니다.

멀티테넌트 격리와 SQL 보정도 나중에 덧붙일 기능으로 두지 않았습니다. 어느 테넌트의 메타데이터를 검색하고 어느 DB에 접속할지가 처음부터 정해져야 했고, 생성된 SQL이 검증을 통과하지 못하면 실행 전에 다시 생성해야 했습니다.

멀티테넌트를 처음부터 설계한 이유#

이 기능은 여러 고객사가 사용하는 B2B SaaS에 연결해야 했습니다. DB 커넥션뿐 아니라 벡터 DB 컬렉션, Redis 키, 용어 사전, 호출량과 비용도 테넌트별로 구분해야 했습니다. 질의 처리 기능을 먼저 만든 뒤 멀티테넌트를 추가하면 거의 모든 저장소와 실행 경로를 다시 수정해야 하는 구조였습니다.

그래서 TenantResourceRegistry에서 공통으로 사용하는 실행 로직과 테넌트별 리소스를 분리했습니다.

공유 리소스:
  - LLM 에이전트 (로직은 동일)
  - API 서버
  - LangGraph 그래프 구조

테넌트별 격리:
  - DB Engine (커넥션 풀)
  - Qdrant 컬렉션 (tables_{id}, columns_{id}, sample_queries_{id})
  - Redis 네임스페이스
  - Dictionary (비즈니스 용어)
  - Rate Limit (할당량)
  - Cost Tracker (비용 귀속)

코드만으로 MVP가 되지 않았던 이유#

코드가 완성되어도 진짜 MVP로 사용자에게 가치를 전달하려면 채워야 할 것들이 있습니다:

  1. 도메인 용어 사전 (30~50개 용어): "활성 사용자"가 뭔지, "매출"이 어떤 테이블의 어떤 컬럼인지. 이건 도메인 전문가가 정의해야 합니다.

  2. Sample Query 수집 (20~30개 패턴): 자주 묻는 질문과 그에 대한 정답 SQL. 기존 쿼리 로그에서 마이닝하거나, 분석가가 직접 작성해야 합니다.

  3. Workspace 설정: 수백 개 테이블을 도메인별로 클러스터링하는 작업. 자동 클러스터링 도구를 만들어놨지만, 결과를 검수하고 조정하는 건 사람 몫입니다.

  4. 테이블/컬럼 설명 검수: Auto Descriptor가 LLM으로 자동 생성해주지만, 도메인 맥락이 빠지거나 부정확한 경우가 있습니다. 검수가 필요합니다.

  5. 평가 테스트셋: 시스템이 잘 동작하는지 확인하려면 "질문 -> 정답 SQL" 쌍이 필요합니다. 이것도 도메인 전문가가 만들어야 합니다.

이 데이터는 코드만으로 만들 수 없었습니다. 도메인 담당자가 실제 업무에서 사용하는 표현과 정답 쿼리를 제공하고, 개발자는 이를 검색과 평가에 사용할 수 있는 형태로 구성해야 했습니다. 모델을 바꾸는 것보다 용어 사전과 Sample Query를 보완했을 때 결과가 더 직접적으로 달라진 이유도 여기에 있었습니다.

315개 테스트가 만들어진 과정#

프로덕션 코드 15,884줄에 테스트 코드 8,659줄. 비율이 1:0.55입니다. 처음부터 이 규모를 계획한 건 아닙니다. 기능을 만들고, 버그를 만나고, 그 버그를 테스트로 고정하는 과정에서 자연스럽게 쌓였습니다.

테스트의 3가지 계층#

Tier 1: 보안 테스트 (최우선)

LLM이 생성한 SQL을 DB에 실행하는 시스템이라, 보안이 뚫리면 데이터가 날아갑니다. SQL 인젝션 10종 공격 벡터, DML 차단, 다중 쿼리 차단, PII 마스킹, 테넌트 격리 — 이 영역은 가장 먼저, 가장 꼼꼼하게 테스트를 작성했습니다.

Tier 2: 파이프라인 통합 테스트

7+1개 에이전트가 올바르게 협력하는지, GraphState 44개 필드가 정확하게 변환되는지, SSE 스트리밍 이벤트 순서가 맞는지. 전체 흐름이 깨지면 개별 에이전트가 아무리 잘 동작해도 의미 없습니다.

Tier 3: 단위 테스트

각 에이전트, 각 유틸리티 함수의 개별 동작. FK 관계 추론, Hybrid Search 경로 분기, 피드백 통계 계산 등.

정적 검사가 필요한 항목도 테스트로 고정했습니다#

실행 결과만으로 확인하기 어려운 보안 규칙은 소스 파일을 읽어 검사하는 테스트로 고정했습니다.

python
class TestSourceCodeInspection:
    def _read_source(self, filename: str) -> str:
        path = os.path.join(_SRC_DIR, filename)
        with open(path) as f:
            return f.read()
 
    def test_timing_safe_comparison(self):
        """api_deps.py에 hmac.compare_digest 사용 확인"""
        source = self._read_source("api_deps.py")
        assert "compare_digest" in source
 
    def test_admin_no_str_e_in_detail(self):
        """api_admin.py HTTPException detail에 str(e) 미포함"""
        source = self._read_source("api_admin.py")
        for line in source.split("\n"):
            if "HTTPException" in line and "detail=" in line and "str(e)" in line:
                pytest.fail(f"admin exposes internal error: {line.strip()}")
 
    def test_api_no_str_e_in_error_response(self):
        """api.py 에러 응답에 str(e) 미포함"""
        source = self._read_source("api.py")
        for line in source.split("\n"):
            if "error" in line.lower() and "str(e)" in line:
                pytest.fail(f"api exposes internal error: {line.strip()}")

이 테스트가 확인하는 내용은 두 가지입니다.

  1. 타이밍 어택 방어: Admin API 키 비교에 == 대신 hmac.compare_digest를 써야 합니다. ==는 문자열을 앞에서부터 비교하면서 틀린 시점에 즉시 반환하기 때문에, 응답 시간 차이로 키를 한 글자씩 추측할 수 있습니다. 누군가 실수로 ==로 바꾸면 이 테스트가 잡습니다.

  2. 내부 에러 노출 방지: str(e)를 사용자 향 에러 메시지에 넣으면 DB 비밀번호, 내부 경로 같은 정보가 노출될 수 있습니다. 에러 응답에는 "관리자에게 문의하세요" 같은 일반적인 메시지만 나가야 합니다.

python
def test_query_error_no_internal_details(self, client):
    pipeline.run.side_effect = RuntimeError("SECRET_DB_PASSWORD leaked")
    resp = client.post("/query", json={"question": "매출 보여줘"})
 
    assert data["success"] is False
    assert "SECRET_DB_PASSWORD" not in data.get("error", "")
    assert "관리자에게 문의" in data.get("error", "")

실제로 에러 메시지에 DB 커넥션 문자열이 노출된 적이 있었습니다. 이 테스트를 추가한 뒤로는 str(e)가 사용자 향 응답에 들어가는 코드가 PR에 올라오면 CI에서 바로 잡힙니다.

테넌트 격리 테스트 — 전 레이어에 걸쳐#

멀티테넌트 시스템에서 가장 위험한 건 테넌트 간 데이터 유출입니다. ConversationStore, FeedbackStore, API 엔드포인트 전부에서 격리를 검증합니다.

python
def test_get_with_wrong_tenant(self):
    store = ConversationStore(db_url=None)
    store.add_message("c1", "t1", "user", "hi")
    assert store.get("c1", tenant_id="t2") is None  # 다른 테넌트 접근 불가
 
def test_delete_with_wrong_tenant(self):
    store.add_message("c1", "t1", "user", "hi")
    assert store.delete("c1", tenant_id="t2") is False  # 삭제도 불가
    assert store.get("c1", tenant_id="t1") is not None   # 원본 유지
 
def test_list_only_own_tenant(self, client, conv_store):
    conv_store.add_message("conv-t1", "tenant1", "user", "질문")
    conv_store.add_message("conv-d1", "default", "user", "질문")
    resp = client.get("/conversations?tenant_id=default")
    ids = [c["conversation_id"] for c in resp.json()["conversations"]]
    assert "conv-d1" in ids
    assert "conv-t1" not in ids  # 다른 테넌트 대화 안 보임

dev 모드(API Key 없음)에서는 tenant_id=None으로 필터링 없이 조회 가능합니다. 개발 편의성과 프로덕션 보안을 모두 만족하는 설계입니다.

잘한 것#

가장 효과적이었던 선택은 질의 처리 단계를 분리한 것입니다. Intent, Table, SQL, Validation Agent의 입출력을 따로 확인할 수 있어, 잘못된 결과가 나왔을 때 어느 단계부터 다시 검증해야 하는지 좁힐 수 있었습니다. 315개 테스트에도 개별 Agent의 분기뿐 아니라 전체 파이프라인, SQL 보안, 테넌트 간 데이터 접근을 함께 포함했습니다.

LLM의 출력과 애플리케이션이 보장해야 할 값도 구분했습니다. 질문의 의도를 분류하고 후보 SQL을 만드는 일은 Agent가 담당하지만, 테넌트와 권한, 실행 가능한 SQL의 종류, 코드성 값은 애플리케이션에서 확인합니다. 요청마다 사용한 프롬프트와 모델, 토큰, 실행 시간, 실패 지점을 남겨 결과가 달라졌을 때 원인을 추적할 수 있게 했습니다.

특히 생성된 SQL을 실제 DB에서 실행하기 때문에 READ-ONLY 제약, 다중 쿼리 차단, 내부 오류 정보 비노출, 테넌트 격리는 모델의 응답 품질과 별개로 테스트에서 계속 확인하도록 했습니다.

다시 한다면#

구현과 도메인 데이터 수집을 병렬로 진행할 것입니다. 이번에는 코어 기능을 먼저 만든 뒤 용어 사전과 Sample Query를 채웠는데, 이 데이터가 준비되지 않으면 파이프라인이 동작해도 결과를 제대로 평가할 수 없었습니다. 테이블·컬럼 설명과 평가용 질문-정답 SQL을 초기 개발 일정에 함께 포함하는 편이 맞았습니다.

프롬프트 버전 관리도 더 일찍 시작할 것입니다. 실행 결과만 저장하면 어떤 프롬프트 변경이 결과에 영향을 줬는지 확인하기 어려웠습니다. 한국어 도메인 용어는 dense 검색만으로 찾지 못하는 경우가 있어, Hybrid Search도 초기 검색 구조에 포함하는 편이 좋았습니다.

정리#

처음에는 자연어를 SQL로 바꾸는 모델의 성능이 가장 큰 문제라고 생각했습니다. 실제 레거시 데이터베이스에 연결하고 나니 더 많은 시간을 쓴 곳은 메타데이터, 테넌트 격리, SQL 검증, 실행 추적과 테스트였습니다.

이 작업을 통해 LLM 기반 기능을 운영 환경에 연결할 때는 모델이 만드는 결과뿐 아니라, 그 결과가 어떤 데이터와 권한 안에서 실행되는지를 함께 설계해야 한다는 점을 배웠습니다. 다음에 비슷한 기능을 만든다면 더 긴 프롬프트보다 도메인 데이터와 검증 가능한 실행 흐름부터 준비할 것입니다.

Connected Notes