Audit Event에서 AI 챗봇까지
엔진은 있지만 제품은 없었다
이전 글에서 Text-to-SQL과 RAG 엔진을 만들었다. LLM이 SQL을 생성하고, 문서를 검색해서 답변을 만들 수 있었다. 하지만 이 상태로는 API 호출을 직접 해야만 결과를 볼 수 있었다.
실제 사용자가 이 시스템을 활용하려면:
- 대화형 인터페이스가 필요했다
- 답변이 생성되는 동안 기다리는 게 아니라 실시간으로 스트리밍되어야 했다
- 데이터 쿼리 결과는 숫자 나열이 아니라 차트로 시각화되어야 했다
- 사내 플랫폼에 안정적으로 배포되어야 했다
전체 아키텍처
| 레이어 | 기술 스택 |
|---|---|
| Frontend | React, Vite, react-markdown, 차트 라이브러리 |
| Backend | FastAPI, Uvicorn, Pydantic |
| AI/ML | Databricks Vector Search, LLM Serving Endpoints |
| 데이터 | Databricks SQL Warehouse, Delta Tables |
| 배포 | Docker (multi-stage), NPE (사내 managed EKS) |
SSE 스트리밍: 한 글자씩 흘러가는 답변
LLM 응답은 생성에 수 초가 걸린다. 사용자에게 빈 화면을 보여주고 기다리게 하는 것은 UX적으로 치명적이다. **Server-Sent Events(SSE)**를 사용해 토큰이 생성되는 즉시 프론트엔드로 스트리밍했다.
이벤트 프로토콜
백엔드에서 프론트엔드로 전달되는 SSE 이벤트 타입을 설계했다:
| 이벤트 | 페이로드 | 용도 |
|---|---|---|
intent | {intent: "sql"} 또는 {intent: "wiki"} | 라우팅 결과 알림 |
sources | [{url, title}] | RAG 소스 문서 (wiki) |
sql_query | {query: "SELECT ..."} | 생성된 SQL (sql) |
token | {content: "답변"} | 스트리밍 텍스트 청크 |
chart_loading | {} | 차트 생성 시작 |
data_table | {data, chart_config} | 테이블 + 차트 데이터 |
done | {model, tokens_used} | 완료 |
error | {message} | 에러 |
백엔드 구현
# SSE 스트리밍 개념 예시
def sse_format(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
async def chat_stream(request):
def generate():
intent = router.route(request.question)
yield sse_format("intent", {"intent": intent})
if intent == "sql":
for evt in sql_engine.query_stream(request.question):
yield sse_format(evt["event"], evt["data"])
else:
for evt in rag_engine.chat_stream(request.question):
yield sse_format(evt["event"], evt["data"])
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}
)프론트엔드 수신
// SSE 수신 개념 예시
const response = await fetch('/chat/stream', { method: 'POST', body: ... });
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
if (line.startsWith('event: ')) currentEvent = line.slice(7);
if (line.startsWith('data: ')) handleEvent(currentEvent, JSON.parse(line.slice(6)));
}
}Auto Charting: LLM이 차트까지 결정한다
Text-to-SQL의 결과는 보통 테이블 형태다. 하지만 “월별 로그인 추이”를 묻는 사용자에게 숫자 테이블만 보여주는 건 불친절하다. LLM이 데이터를 해석할 때, 적절한 차트 타입과 축 매핑까지 함께 결정하도록 했다.
작동 방식
LLM의 해석 프롬프트에 차트 설정을 함께 출력하도록 지시한다. 텍스트 답변 뒤에 ---CHART_CONFIG--- 마커를 넣고, 그 뒤에 JSON 형태의 차트 설정을 출력한다.
{
"chart_type": "stacked_bar",
"x_column": "month",
"y_columns": ["LOGIN", "REGISTRATION", "PASSWORD_RESET"],
"title": "Monthly Events by Type"
}지원 차트 타입
14가지 이상의 차트 타입을 지원하도록 구현했다:
| 카테고리 | 차트 타입 |
|---|---|
| 추이 분석 | line, area, composed |
| 비교 분석 | bar, stacked_bar, horizontal_bar |
| 비율 분석 | pie, donut, treemap |
| 분포 분석 | scatter, radar |
| 특수 | radial_bar, funnel, none |
같은 데이터에 대해 bar ↔ stacked_bar ↔ line ↔ area 간 전환도 가능하도록 구현했다.
스트리밍 중 차트 감지
SSE 스트리밍 중에 CHART_CONFIG 마커를 감지하는 것은 까다로운 문제였다. 토큰이 한 글자씩 들어오기 때문에 마커가 여러 토큰에 걸쳐 나뉠 수 있다.
30자 look-ahead 버퍼를 두어, 마커가 완전히 들어올 때까지 텍스트 출력을 지연시키는 방식으로 해결했다. 마커가 감지되면 chart_loading 이벤트를 먼저 보내고, JSON 파싱이 완료되면 data_table 이벤트로 차트 데이터를 전달한다.
배포: NPE로 올리기
챗봇은 사내 managed EKS 플랫폼인 NPE에 배포했다. Docker multi-stage 빌드로 프론트엔드와 백엔드를 하나의 이미지에 담았다.
Docker 빌드 전략
# Stage 1: Frontend 빌드
FROM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci --production=false
COPY frontend/ ./
RUN npm run build
# Stage 2: Backend + Frontend 정적 파일
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py backend/ ./
COPY --from=frontend-builder /app/frontend/dist ./frontend/dist
EXPOSE 8000
CMD ["python", "app.py"]FastAPI가 프론트엔드의 정적 파일도 함께 서빙하는 SPA(Single Page Application) 구조를 사용해서, Nginx 같은 별도 웹 서버 없이 하나의 프로세스로 동작한다.
완성된 이미지는 사내 managed EKS 플랫폼(NPE)에 컨테이너로 배포했다. 비용 효율을 위해 ARM64 아키텍처로 빌드했고, Databricks 자격 증명은 사내 시크릿 관리 시스템에서 주입받도록 구성했다.
운영에서 배운 것
프롬프트 버전 관리
LLM 프롬프트는 사실상 코드다. SQL 생성 프롬프트, 해석 프롬프트, 라우팅 프롬프트 모두 버전 관리가 필요했다. 프롬프트를 바꾸면 결과가 바뀌기 때문에, 변경 전후를 비교할 수 있는 테스트 세트를 만들어 두는 것이 중요했다.
예시 품질이 정확도를 결정한다
Text-to-SQL의 정확도를 가장 크게 올린 것은 모델을 바꾸는 게 아니라 예시 쿼리의 품질을 높이는 것이었다. 실제 사용자가 자주 묻는 질문과 그에 대한 정확한 SQL을 예시 인덱스에 추가할수록 정확도가 올라갔다.
안전 장치
- 생성된 SQL은
SELECT만 허용, DDL/DML은 전부 차단 - 기본
LIMIT 1000, 반환은 최대 200행 - 쿼리 실행 타임아웃 설정
- 에러 시 사용자에게 기술 세부 사항이 아닌 안내 메시지 표시
감사 이벤트에서 시작한 여정
이 챗봇의 출발점은 이 시리즈의 첫 글에서 다룬 감사 이벤트 데이터 설계였다. ActionType, TargetType, ActorType을 신중하게 정의한 것이 Gold 테이블의 컬럼이 되었고, 그 컬럼이 Vector Search 인덱스에 임베딩되어 자연어 질문의 답이 되었다.
“KPI를 뽑을 수 없었던 관리자 플랫폼”에서 시작한 문제가, 데이터 설계 → 파이프라인 → 메달리온 → AI 챗봇이라는 흐름을 거쳐, 관리자가 자연어로 KPI를 물어볼 수 있는 시스템으로 도달했다. 기술의 진화보다 더 중요했던 건 “어떤 질문에 답할 수 있어야 하는가”를 처음부터 명확히 한 것이었다.
다음 시리즈에서는 시야를 바꿔, 운영 중인 시스템의 성능 병목을 어떻게 찾고 개선했는지로 초점을 옮긴다.