전체 아키텍처 한 장 — FastAPI·RAG·멀티 LLM을 어떻게 엮었나
한 장으로 보면 이렇게 생겼습니다. 신입·경력직이 특정 B2B 도메인을 빠르게 익히도록 AI 고객과 사업 미팅을 롤플레이하는 학습 챗봇을 직접 만들면서, 저는 이 시스템을 네 개의 레이어로 나눠 엮었습니다. 화면을 그리는 프론트엔드, 요청을 받아 기능별로 분기하는 FastAPI 백엔드, 도메인 지식을 검색해 근거를 대주는 RAG 계층, 그리고 실제 대화를 생성하는 멀티 LLM 백엔드. 이 글은 그 전체 그림을 먼저 펼쳐 놓고, 각 레이어가 무엇을 맡는지, 그리고 왜 굳이 이렇게 잘라 놓았는지를 순서대로 풀어 갑니다.
[시리즈] S2S·LLM 음성 에이전트 엔지니어링 63편 · 현재 10-2편
← 이전: 10-1 왜 롤플레이 챗봇을 직접 만들었나 / 다음 → 10-3 RAG 파이프라인과 로컬 임베딩
- 9-7 온프레미스 상담어시스트 — GPU 제약과 모델 선택
- 10-1 왜 롤플레이 챗봇을 직접 만들었나
- 10-2 전체 아키텍처 한 장 (현재 글)
- 10-3 RAG 파이프라인과 로컬 임베딩
- 10-4 멀티 LLM 백엔드 스위칭
전체 목차 펼치기 (63편)
Part 1 — 음성 AI가 느린 이유: 레이턴시 진단 (4편)
- 1-1 AI 보이스봇이 답답한 이유 — Cascaded 레이턴시
- 1-2 1초의 벽 — 지연이 UX를 결정하는 이유
- 1-3 지연 줄이기 — 스트리밍·청크로 TTFT 단축
- 1-4 RAG 병목과 오디오 버퍼링 제어
Part 2 — 텍스트를 넘어: End-to-End S2S의 원리 (4편)
- 2-1 End-to-End S2S 작동 원리
- 2-2 OpenAI Realtime API vs 오픈소스 S2S
- 2-3 음성 토큰(Audio Token)이란
- 2-4 감정·톤 조절이 CS를 바꾸는 이유
Part 3 — 실시간 대화와 에이전트화: 인터럽트·MCP·도구 (5편)
- 3-1 실시간 인터럽트(끼어들기) 감지
- 3-2 실시간 음성에 MCP가 필요한 이유
- 3-3 스트리밍 중 MCP 초고속 데이터 룩업
- 3-4 통화 중 외부 API 도구 호출
- 3-5 실시간 세션 관리 — 통화 맥락·고객 데이터
Part 4 — 인프라·세션·보안: 프로덕션 백엔드 (5편)
- 4-1 동적 컨텍스트 윈도우 관리
- 4-2 WebRTC·WebSocket 초저지연 음성
- 4-3 비동기 이벤트 루프와 동시 콜 처리
- 4-4 트래픽 스파이크를 견디는 확장성 설계
- 4-5 실시간 음성 보안 — 암호화·PII 마스킹
Part 5 — 운영·품질·평가: 에이전틱 워크플로우와 신뢰성 (7편)
- 5-1 에이전틱 워크플로우 — 응대를 넘어 완결로
- 5-2 상용화의 한계와 트러블슈팅
- 5-3 보이스 에이전트 평가(Eval) 설계
- 5-4 관측성(Observability) — 어디서 끊기나
- 5-5 가드레일과 환각 제어
- 5-6 상담사 전환(Handoff) 설계
- 5-7 프롬프트 A/B 테스트와 튜닝
Part 6 — 음성 경험 고도화·실전 통합: 한국어·UX·전화망·엣지 (5편)
- 6-1 한국어 음성 AI의 난관 — 존댓말·코드스위칭
- 6-2 턴테이킹과 시맨틱 VAD
- 6-3 보이스 페르소나 디자인
- 6-4 SIP·PSTN 전화망 연동
- 6-5 온디바이스·엣지 S2S와 프라이버시
Part 7 — S2S 도입 전략·구매 의사결정 (4편)
Part 8 — LLM 콜봇 PoC 현장 (6편)
- 8-1 LLM 콜봇 PoC — 검증의 갭
- 8-2 STT 정확도 93% 목표가 부족한 이유
- 8-3 금융 콜봇에서 소수점이 중요한 이유
- 8-4 TTFT 1.5초, 콜봇 스트리밍 파이프라인 설계
- 8-5 콜봇 SLM: 30B vs 70B 트레이드오프
- 8-6 PoC와 본사업 사이의 성능 괴리
Part 9 — 상담어시스트: 상담사 화면을 바꾸는 실시간 AI (7편)
- 9-1 상담어시스트란 무엇인가 — 실시간 STT가 상담사 화면을 바꾸는 구조
- 9-2 상담어시스트의 뼈대 — STT·인텐트·KMS 파이프라인
- 9-3 추천의 근거 — KMS·RAG 지식 그라운딩
- 9-4 통화 중 놓친 고지를 잡는 실시간 필수 안내 체크
- 9-5 AI가 상담 품질을 자동 채점하는 법 — AutoQA
- 9-6 신입 상담사에게 어시스트가 더 필요한 이유
- 9-7 온프레미스 상담어시스트 — GPU 제약과 모델 선택
Part 10 — 직접 만든 롤플레이 챗봇: RAG 에이전트 구축기 (8편)
- 10-1 왜 롤플레이 챗봇을 직접 만들었나
- 10-2 전체 아키텍처 한 장 (현재 글)
- 10-3 RAG 파이프라인과 로컬 임베딩
- 10-4 멀티 LLM 백엔드 스위칭
- 10-5 AI 고객 페르소나 프롬프트 엔지니어링
- 10-6 퀴즈 풀 사전생성
- 10-7 대화가 끝나면 채점한다
- 10-8 혼자 쓰는 챗봇의 현실
Part 11 — KMS 실무 — 지식관리시스템의 설계와 운영 현실 (8편)
전체 그림 — 요청 하나가 어디를 지나는가
말로 설명하기 전에 구조를 먼저 그려 보겠습니다. 사용자의 브라우저에서 시작된 요청 하나가 어느 경로를 지나 응답으로 돌아오는지를, 실제로 제가 코드를 배치한 대로 옮기면 다음과 같습니다.
[ Frontend ] Vanilla HTML + Tailwind + JS (SPA)
| fetch(JSON)
v
[ FastAPI (main.py) ] 라우팅 · 인증(bcrypt)
|
+--> roleplay : AI 고객 롤플레이 턴 생성
+--> quiz : 도메인 퀴즈 출제
+--> feedback : 종료 후 채점 · 코칭
+--> rag : 도메인 지식 검색(근거 제공)
|
v
[ Multi-LLM 백엔드 ] runtime_config로 매 호출 교체
Claude CLI(subprocess) / Gemini API / 로컬 Ollama
─ 별도 오프라인 경로 ─
[ ingest ] md·txt → 청킹 → 로컬 임베딩 → Chroma
(Vector Store는 미리 채워 두고, 런타임엔 검색만)
그림 1. 사용자 요청이 서버 안에서 RAG 검색과 LLM 라우팅 두 경로로 나뉘어 처리된 뒤 응답으로 합쳐진다.
핵심은 두 가지입니다. 첫째, 런타임 경로는 프론트 → FastAPI → (기능 분기) → LLM으로 흐르고, RAG는 그 안에서 필요할 때만 벡터 스토어를 조회해 근거를 얹습니다. 둘째, 인제스트(문서를 벡터로 만드는 과정)는 런타임과 분리된 오프라인 경로입니다. 사용자가 채팅을 칠 때마다 문서를 임베딩하지 않습니다. 지식은 미리 벡터 스토어에 넣어 두고, 실시간에는 검색만 합니다. 이 분리가 왜 중요한지는 뒤에서 다시 이야기하겠습니다.
Frontend — 프레임워크 없이, 그냥 Vanilla로
프론트엔드는 Vanilla HTML에 Tailwind CSS를 얹고 순수 JavaScript로 동작하는 SPA(단일 페이지 애플리케이션)입니다. React도, Vue도 쓰지 않았습니다. 처음엔 저도 습관적으로 "프론트니까 프레임워크를 깔아야지"라고 생각했지만, 화면을 종이에 그려 보니 실제로 필요한 건 로그인, 시나리오 선택, 대화창, 힌트 큐카드, 퀴즈 화면, 종료 후 피드백 리포트 정도였습니다.
이 정도 규모에서 프레임워크를 얹으면 얻는 것보다 짊어지는 것이 많았습니다. 빌드 도구, 번들러, 의존성 트리, 버전 업그레이드… 혼자 만들고 혼자 유지보수하는 도구에 그 무게를 지고 싶지 않았습니다. 그래서 화면 전환은 JS로 DOM을 갈아 끼우고, 서버와는 fetch로 JSON을 주고받는 얇은 구조로 갔습니다. Tailwind는 CSS를 클래스로만 처리해 별도 스타일시트를 관리할 필요를 없애 줬고, 덕분에 프론트는 정적 파일 몇 개로 끝났습니다. "가볍게"가 이 레이어의 유일한 설계 목표였습니다.
Backend — FastAPI, 기능별로 모듈을 쪼갰다
백엔드는 FastAPI입니다. Python으로 uvicorn 위에서 돌고, 라우팅의 중심은 main.py가 잡습니다. 여기서 인증(bcrypt로 비밀번호 해시)을 처리하고, 들어온 요청을 기능별 모듈로 분기합니다.
제가 의식적으로 지킨 원칙은 기능 하나 = 모듈 하나였습니다. 롤플레이 턴 생성, 퀴즈 출제, 피드백·채점, 코칭, RAG 검색을 각각 별도 파일로 떼어 놓았습니다. 처음 프로토타입은 한 파일에 다 몰아넣고 시작했는데, 힌트 생성 로직을 손대다 채점 로직을 실수로 건드리는 일이 생겼습니다. 기능이 늘어날수록 한 파일이 지뢰밭이 되더군요. 그래서 롤플레이는 롤플레이대로, 채점은 채점대로 파일을 갈랐습니다. 프롬프트도 prompts.py 하나로 모아 공통 베이스와 모델별 오버라이드를 분리했습니다. 이렇게 나눠 두니 "이번엔 퀴즈만 손본다"가 가능해졌고, 각 기능을 독립적으로 테스트할 수 있게 됐습니다. 실제로 pytest 스위트도 시나리오·채점·코칭·페르소나처럼 기능 단위로 나뉘어 있습니다.
RAG / Vector — 지식은 미리 벡터로 만들어 둔다
이 챗봇의 차별점은 "범용 챗봇에 물어보는 것과 차원이 다른 정확도"를 도메인 지식에서 내는 것입니다. 그 정확도를 담당하는 것이 RAG 계층입니다. 구조는 LangChain으로 문서를 로드하고, 청크로 자른 뒤, 로컬 임베딩으로 벡터화해서 Chroma라는 로컬 벡터 데이터베이스에 저장하는 흐름입니다. 실시간에는 사용자의 질문과 가까운 청크 몇 개를 검색해 LLM에 근거로 함께 넘깁니다.
앞의 다이어그램에서 강조했듯, 이 인제스트 과정은 런타임과 완전히 분리된 오프라인 배치입니다. 문서를 넣거나 갱신할 때 한 번 돌려서 벡터 스토어를 채워 두고, 채팅 요청이 올 때는 검색만 합니다. 임베딩을 굳이 외부 API가 아니라 로컬 CPU에서 돌린 이유, 청크 크기를 어떻게 잡았는지, 검색 개수를 몇 개로 뒀는지 같은 구체적인 선택은 이 글의 범위를 넘어섭니다. 그 트레이드오프는 다음 편 10-3 RAG 파이프라인과 로컬 임베딩에서 하나씩 뜯어 보겠습니다. 여기서는 "지식을 미리 벡터로 만들어 두고 런타임엔 검색만 한다"는 위치만 기억하면 충분합니다.
LLM — 하나의 모델에 묶이지 않기 위해
실제 대화를 만들어 내는 것은 LLM 레이어입니다. 저는 여기를 멀티 백엔드로 설계했습니다. 클라우드 API 한 곳, 구독형 CLI 한 곳, 그리고 로컬 모델 한 곳을 모두 붙여 두고, 어떤 백엔드로 이 호출을 처리할지를 런타임에 결정합니다. 별도의 설정값을 매 호출마다 읽어서 provider와 model을 교체하는 방식이라, 코드를 다시 배포하지 않고도 모델을 바꿀 수 있습니다.
왜 하나로 정하지 않고 이렇게 번거롭게 만들었을까요. 로컬 모델은 비용이 0이고 데이터가 밖으로 나가지 않지만 무겁고, 클라우드는 빠르고 품질이 좋지만 비용과 데이터 정책을 따져야 하고, 구독형 CLI는 또 다른 트레이드오프가 있습니다. 상황에 따라 최적의 선택이 달라지는데, 그 선택을 아키텍처에 못 박아 두고 싶지 않았습니다. 백엔드마다 최적 프롬프트가 다르다는 것도 실제로 겪었고, 그래서 프롬프트 관리 계층에 모델별 오버라이드까지 넣어 뒀습니다. 이 멀티 백엔드를 붙이는 과정에서 로컬 모델이 동시 호출로 뻗어 버리는 사고도 있었는데, 그 트러블슈팅 서사는 10-4 멀티 LLM 백엔드 스위칭에서 따로 다루겠습니다.
왜 이렇게 레이어를 나눴나 — 교체 가능성과 의존성 최소화
지금까지 프론트·백엔드·RAG·LLM을 각각 떼어서 설명했는데, 사실 이 글에서 가장 하고 싶은 이야기는 "왜 굳이 이렇게 잘랐나"입니다. 결론부터 말하면 교체 가능성과 의존성 최소화 두 단어로 요약됩니다.
레이어를 나눈 첫째 이유는, 각 조각을 다른 조각을 건드리지 않고 갈아 끼우고 싶었기 때문입니다. LLM 백엔드를 로컬에서 클라우드로 바꿔도 프론트와 백엔드 라우팅은 그대로여야 합니다. 임베딩 모델을 교체해도 RAG를 쓰는 쪽 코드는 몰라도 돼야 합니다. 각 레이어가 옆 레이어의 내부 구현이 아니라 "무엇을 주고받는가"라는 계약에만 의존하게 만들면, 한 곳을 바꿔도 파장이 그 레이어 안에 갇힙니다.
둘째 이유는 의존성을 최소로 유지하기 위해서입니다. 프론트에 프레임워크를 안 쓴 것, RAG 인제스트를 런타임에서 떼어 낸 것, LLM 선택을 코드가 아니라 설정값으로 뺀 것 모두 같은 방향입니다. 각 레이어가 짊어지는 짐을 줄이면, 혼자 만들고 혼자 고치는 도구가 감당 가능한 크기로 유지됩니다. 아래 표는 네 레이어의 역할과 핵심 선택을 한눈에 정리한 것입니다.
| 레이어 | 기술 | 맡는 일 | 핵심 선택 |
|---|---|---|---|
| Frontend | Vanilla HTML + Tailwind + JS (SPA) | 화면·대화창·힌트·리포트 | 프레임워크 없이 가볍게 |
| Backend | FastAPI (Python, uvicorn) | 라우팅·인증·기능 분기 | 기능별 모듈 분리 |
| RAG / Vector | LangChain + Chroma + 로컬 임베딩 | 도메인 지식 검색·근거 제공 | 인제스트는 오프라인 배치 |
| LLM | 멀티 백엔드(클라우드·CLI·로컬) | 대화·힌트·채점 생성 | 런타임 설정으로 교체 |
정리하면, 이 아키텍처는 화려한 설계가 아니라 "한 곳을 바꿔도 다른 곳이 안 무너지게" 하는 방어적인 분리에 가깝습니다. 혼자 오래 유지보수해야 하는 도구일수록 이 분리가 결국 나 자신을 지켜 준다는 게, 만들면서 얻은 감각입니다. 이 글은 2026년 7월 시점에 개인적으로 구축한 학습용 도구의 설계 관점만 일반화해 정리한 것으로, 회사·팀·고객사·인물을 특정할 수 있는 정보는 담지 않았습니다.
자주 묻는 질문(FAQ)
Q1. 왜 프론트에 프레임워크를 안 썼나요?
필요한 화면이 로그인·대화창·힌트·퀴즈·리포트 정도로 한정적이었기 때문입니다. 이 규모에서 React·Vue 같은 프레임워크를 얹으면 빌드 도구, 번들러, 의존성 관리라는 짐이 기능보다 무거워집니다. 혼자 만들고 유지보수하는 도구에서는 그 무게가 부담이라, DOM을 직접 갈아 끼우고 서버와는 fetch로 JSON만 주고받는 Vanilla SPA로 갔습니다. Tailwind로 스타일까지 클래스로 처리하니 정적 파일 몇 개로 프론트가 끝났고, 결과적으로 "가볍게"라는 목표에 가장 잘 맞았습니다.
Q2. RAG와 LLM을 왜 굳이 분리했나요?
둘의 역할과 교체 주기가 다르기 때문입니다. RAG는 "무엇을 근거로 답할까"를 담당하고, LLM은 "그 근거로 어떻게 말할까"를 담당합니다. 지식(벡터 스토어)은 문서가 갱신될 때 오프라인으로 다시 채우면 되고, 대화 품질·비용을 위한 모델 교체는 런타임에 설정값만 바꾸면 됩니다. 이 둘을 한 덩어리로 묶으면 임베딩을 바꿀 때 대화 로직이 흔들리고, 모델을 바꿀 때 검색 계층까지 건드려야 합니다. 분리해 두면 각각을 독립적으로 갈아 끼울 수 있어, 교체 가능성과 의존성 최소화라는 이 아키텍처의 원칙과도 맞아떨어집니다.
댓글
댓글 쓰기