Text-to-SQL 엔진 개발 회고 [5]
개발 타임라인#
처음에는 여기까지 커질 줄 몰랐다. 리서치 좀 해보고, PoC 하나 붙여보고, 되면 키워보자는 정도였는데 막상 들어가보니 멀티테넌트, 보안, 피드백 루프, 평가, 스트리밍이 전부 따라붙었다.
1편의 리서치부터 현재 v0.4.0까지를 돌아보면, 크게 4단계로 나뉩니다.
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 태깅
리서치를 먼저 해둔 덕분에 코어 구현 단계에서 방향을 놓고 오래 헤매지는 않았습니다.
숫자로 보는 결과#
| 항목 | 수치 |
|---|---|
| 총 코드량 | 약 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/ | 12 | 3,917 | Multi-Agent 파이프라인 |
| interface/ | 11 | 2,350 | API, SSE, Slack Bot |
| onboarding/ | 7 | 2,053 | 테넌트 온보딩 자동화 |
| learning/ | 7 | 1,026 | 피드백 학습, Active Learning |
| tenant/ | 7 | 926 | 멀티테넌트 관리 |
| store/ | 3 | 832 | 대화 저장소, 캐시, SQL 실행기 |
| metadata/ | 2 | 764 | Workspace, Sample Query |
| observability/ | 5 | 705 | Langfuse, 비용 추적 |
| dictionary/ | 5 | 701 | 비즈니스 용어 사전 |
| guardrail/ | 4 | 673 | 보안 (SQL Filter, PII, Audit) |
| rag/ | 3 | 583 | 임베딩, 인덱싱, 검색 |
| evaluation/ | 2 | 447 | 평가 프레임워크 |
| schema/ | 4 | 376 | DB 스키마 추출, Dialect |
agents/가 가장 크고, 그 다음이 interface/와 onboarding/입니다. 에이전트 로직과 API/온보딩이 시스템의 핵심이라는 걸 코드량이 보여줍니다.
리서치가 구현 속도를 높였습니다#
돌이켜보면 이 프로젝트에서 제일 잘한 선택 중 하나가, 일단 리서치부터 길게 해본 것이었습니다.
리서치 없이 바로 구현에 들어갔다면:
- Multi-Agent vs 단일 LLM 논쟁에 며칠을 써야 했을 겁니다
- RAG를 쓸지 Fine-tuning을 쓸지 고민했을 겁니다
- Self-Correction이 필요한지 몰랐을 겁니다
- 멀티테넌트를 나중에 붙이려다 갈아엎었을 겁니다
14개 기업이 이미 거친 시행착오를 조사해뒀으니, 구현 단계에서는 "뭘 만들지"가 아니라 "어떻게 만들지"에만 집중할 수 있었습니다.
멀티테넌트를 Day 1부터 설계한 이유#
Uber, LinkedIn은 내부 도구라서 멀티테넌트가 필요 없습니다. 하지만 B2B SaaS로 제공해야 하는 상황에서는 처음부터 테넌트 격리를 설계해야 합니다.
나중에 멀티테넌트를 붙이면 거의 갈아엎어야 합니다. DB 커넥션, 벡터 DB 컬렉션, Redis 키스페이스, 용어 사전, Rate Limiting, 비용 추적 — 이 모든 게 테넌트별로 격리되어야 하니까요.
TenantResourceRegistry 패턴을 썼습니다. 공유하는 것(LLM 에이전트, API 서버)과 테넌트별로 격리하는 것(DB 커넥션, Qdrant 컬렉션, Redis 네임스페이스, 용어 사전, Rate Limit, 비용 추적)을 명확히 분리합니다.
공유 리소스:
- LLM 에이전트 (로직은 동일)
- API 서버
- LangGraph 그래프 구조
테넌트별 격리:
- DB Engine (커넥션 풀)
- Qdrant 컬렉션 (tables_{id}, columns_{id}, sample_queries_{id})
- Redis 네임스페이스
- Dictionary (비즈니스 용어)
- Rate Limit (할당량)
- Cost Tracker (비용 귀속)
MVP 블로커 — 코드가 아니라 도메인 지식#
코드가 완성되어도 진짜 MVP로 사용자에게 가치를 전달하려면 채워야 할 것들이 있습니다:
-
도메인 용어 사전 (30~50개 용어): "활성 사용자"가 뭔지, "매출"이 어떤 테이블의 어떤 컬럼인지. 이건 도메인 전문가가 정의해야 합니다.
-
Sample Query 수집 (20~30개 패턴): 자주 묻는 질문과 그에 대한 정답 SQL. 기존 쿼리 로그에서 마이닝하거나, 분석가가 직접 작성해야 합니다.
-
Workspace 설정: 수백 개 테이블을 도메인별로 클러스터링하는 작업. 자동 클러스터링 도구를 만들어놨지만, 결과를 검수하고 조정하는 건 사람 몫입니다.
-
테이블/컬럼 설명 검수: Auto Descriptor가 LLM으로 자동 생성해주지만, 도메인 맥락이 빠지거나 부정확한 경우가 있습니다. 검수가 필요합니다.
-
평가 테스트셋: 시스템이 잘 동작하는지 확인하려면 "질문 -> 정답 SQL" 쌍이 필요합니다. 이것도 도메인 전문가가 만들어야 합니다.
5가지 다 엔지니어링이 아니라 도메인 전문가의 작업입니다. 코드는 완성인데 데이터가 없으면 시스템이 제대로 동작하지 않습니다.
이게 Text-to-SQL 시스템의 본질적인 어려움입니다. 1편에서 우아한형제들이 "성능의 핵심은 어떤 문서를 수집하느냐"라고 한 것과 같은 맥락입니다. 메타데이터 품질이 모델 품질보다 중요합니다.
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 경로 분기, 피드백 통계 계산 등.
소스코드를 테스트로 검증하는 패턴#
가장 독특한 테스트 패턴은, 소스 파일을 텍스트로 읽어서 보안 패턴을 검사하는 것입니다.
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()}")왜 이런 테스트를 만들었나:
-
타이밍 어택 방어: Admin API 키 비교에
==대신hmac.compare_digest를 써야 합니다.==는 문자열을 앞에서부터 비교하면서 틀린 시점에 즉시 반환하기 때문에, 응답 시간 차이로 키를 한 글자씩 추측할 수 있습니다. 누군가 실수로==로 바꾸면 이 테스트가 잡습니다. -
내부 에러 노출 방지:
str(e)를 사용자 향 에러 메시지에 넣으면 DB 비밀번호, 내부 경로 같은 정보가 노출될 수 있습니다. 에러 응답에는 "관리자에게 문의하세요" 같은 일반적인 메시지만 나가야 합니다.
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 엔드포인트 전부에서 격리를 검증합니다.
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으로 필터링 없이 조회 가능합니다. 개발 편의성과 프로덕션 보안을 모두 만족하는 설계입니다.
잘한 것#
1. 리서치 기반 설계
감이 아니라 근거로 결정했습니다. "왜 Multi-Agent?"라는 질문에 "Uber, LinkedIn, 당근페이가 다 이렇게 하고 있으니까"라고 답할 수 있습니다.
2. 에이전트 분리
각 에이전트가 독립적으로 테스트 가능합니다. Intent Agent가 잘못 분류하는지, Table Agent가 엉뚱한 테이블을 고르는지 각각 확인할 수 있습니다. 315개 테스트 케이스 중 상당수가 개별 에이전트 단위 테스트입니다.
3. 보안을 코드와 테스트 양쪽에서 강제
SQL Filter, PII Masker, 테넌트 격리 같은 보안 기능을 코드로 구현하고, 테스트로 그 보안이 깨지지 않음을 강제합니다. 소스코드 정적 분석 테스트까지 넣어서, 누군가 실수로 str(e)를 에러 응답에 넣으면 CI에서 바로 실패합니다.
4. 문서화
아키텍처, 검색 파이프라인, 변경 체크리스트, 기획 대응 분석 등 12개 문서를 작성했습니다. 이 문서들이 이 블로그 시리즈의 소재가 되기도 했습니다.
다르게 할 것#
1. 도메인 세팅을 병렬로
코드 구현과 도메인 데이터 수집을 순차적으로 했는데, 병렬로 진행했어야 합니다. 구현하는 동안 도메인 전문가가 용어 사전과 Sample Query를 준비했으면 MVP까지 더 빨랐을 겁니다.
2. 프롬프트 버저닝을 더 일찍
Langfuse의 프롬프트 관리 기능을 나중에 연동했는데, 처음부터 썼으면 프롬프트 변경 이력을 더 체계적으로 관리할 수 있었습니다. 프롬프트가 자주 바뀌는 LLM 앱에서 버전 관리는 필수입니다.
3. Hybrid Search를 초기에
Dense 검색만으로 시작했다가 키워드 매칭 문제를 뒤늦게 발견하고 Hybrid Search를 추가했습니다. 3편에서 다뤘던 것처럼, 한국어 도메인에서는 키워드 매칭이 중요하다는 걸 미리 알았으면 처음부터 Hybrid로 갔을 겁니다.
기업 사례 대비 비교#
리서치한 14개 기업 대비 이 시스템의 위치를 정리하면:
| 패턴 | 이 시스템 | Uber | 당근페이 | |
|---|---|---|---|---|
| Multi-Agent | O | O | O | O |
| RAG + Reranking | O | O | O | O |
| Self-Correction | O (3회) | - | O | O |
| Multi-Tenant | O | - | - | - |
| Workspace | O | O | O | - |
| Business Dictionary | O | - | - | O |
| Conversation Memory | O | - | - | - |
| Cost Tracking | O | - | - | - |
| PII Masking | O | - | - | - |
| Streaming SSE | O | - | - | - |
| Question Decomposition | O | - | - | - |
| Sample Query Few-shot | O | O | - | O |
| Feedback Learning | O | - | - | O |
| Active Learning | O | - | - | - |
Uber와 LinkedIn은 내부 도구라서 멀티테넌트, 비용 추적, PII 같은 건 불필요했습니다. 이 시스템은 B2B SaaS를 고려했기 때문에 이런 부분이 추가됐습니다.
시리즈를 마치며#
5편에 걸쳐 Text-to-SQL 시스템의 리서치부터 구현, 검색, 검증, 회고까지 정리했는데, 막상 끝까지 오고 나니 남는 건 생각보다 단순했습니다.
핵심 교훈 3가지를 꼽으면 이렇습니다:
- 리서치가 속도를 만듭니다. 14개 기업의 시행착오를 먼저 봐둔 덕분에, 최소한 어디서 삽질할지는 미리 피할 수 있었습니다.
- 메타데이터가 모델보다 중요합니다. 모델을 바꾸는 것보다 용어 사전과 샘플 쿼리 품질을 올리는 쪽이 훨씬 직접적으로 체감됐습니다.
- 테스트가 시스템을 지킵니다. LLM 앱은 겉으로는 멀쩡해 보여도 쉽게 흔들립니다. 결국 마지막에 믿을 건 테스트였습니다.
개인적으로는 이 프로젝트를 하면서 "LLM을 잘 붙이는 법"보다 "LLM이 틀려도 서비스가 버티게 만드는 법"을 더 많이 배운 느낌입니다. 아마 다음에 비슷한 걸 다시 만든다고 해도, 출발점은 더 좋은 프롬프트가 아니라 메타데이터, 가드레일, 테스트 쪽일 겁니다.