챗봇을 만드는 건 쉬웠다. 엔지니어링은 쉽지 않았다.
요약
금융권용 프로덕션 AI 챗봇 구축 과정에서 겪는 실질적인 엔지니어링 도전 과제를 다룹니다. 단순한 메시지 전달을 넘어 의도 탐지(Intent Detection)의 필요성과 시스템 최적화 문제를 다루는 시리즈의 첫 번째 파트입니다.
핵심 포인트
- 단순한 메시지 스트리밍 이상의 복잡한 아키텍처 설계 필요
- 지식 베이스 범위를 벗어난 질문을 차단하기 위한 의도 탐지(Intent Detection)의 중요성
- LLM 호출 비용과 지연 시간을 줄이기 위한 캐싱 전략의 필요성
금융권용 프로덕션 AI 챗봇 구축에 관한 시리즈의 파트 1.
우리 팀이 뱅킹 챗봇 프로젝트를 시작했을 때, 나는 내 업무가 이미 해결된 것이나 다름없다고 진심으로 생각했다.
나는 Node.js 개발자였다. 내 역할은 부끄러울 정도로 단순하게 들렸다:
- 사용자가 연결될 때 WebSocket을 연다
- 사용자의 메시지를 AI 서비스로 보낸다
- 응답을 다시 스트리밍(Stream)한다
그게 전부였다. 그것이 업무의 전부였다.
WebSocket은 나에게 제2의 천성과도 같다. 나는 반쯤 잠든 상태에서도 구축할 수 있을 정도로 충분히 많은 실시간 시스템을 만들어 보았다. 그래서 나는 _'이건 정말 쉽겠는데'_라고 생각하며 킥오프 미팅을 마쳤다.
소프트웨어 엔지니어링에서 가장 큰 거짓말이다.
User
│
▼
...
그것이 아키텍처(Architecture) v1이었다. 네 개의 박스. 나는 그것이 자랑스러웠다. 하지만 그것은 일주일도 채 가지 못했다.
제1장 — IPL 질문
프로젝트 시작 며칠 후, AI 팀과의 싱크(Sync) 미팅 중에 나는 통찰력이라기보다는 본능에 가까운 질문을 하나 던졌다.
나: "만약 누군가 봇에게 어제 IPL 경기가 누가 이겼는지 물어보면 어떻게 되나요?"
아무도 즉시 대답하지 않았다. 누군가는 내가 농담을 하는 줄 알고 웃었다. 나는 농담을 한 게 아니었다.
AI 리드: "...잠깐만요. 대체 어떻게 되는데요?"
우리는 앱을 열고 바로 그 통화 중에 테스트를 해보았다. 봇은 질문을 받았고, 정기예금(FD) 금리와 대출 조건으로 가득 찬 지식 베이스(Knowledge base)를 검색했다. 근접한 내용을 전혀 찾지 못했음에도 불구하고, 봇은 어쨌든 자신 있게 답변했다. 벡터 검색(Vector search)에서 가장 가까웠던 파편들을 짜깁기하여 "경기 관련 문의는 지점을 통해 문의해 주세요"와 같은 모호한 답변을 내놓았다.
그 상황은 약 10초 동안은 웃겼다. 그다음부터는 웃기지 않았다.
우리 챗봇에는 _"이것은 내가 답변할 영역이 아니다"_라는 개념이 없었다. 그리고 실제 사용자들이 사용하는 제품을 수년간 출시해 온 경험을 통해, 나는 기회만 생기면 누군가는 반드시 이상한 질문을 던질 것이라는 사실을 이미 알고 있었다. 악의가 있어서가 아니다. 그저 채팅창이 그런 질문을 유도하기 때문이다.
그래서 우리에게는 게이트키퍼(Gatekeeper)가 필요했다. 다른 모든 것의 앞단에 위치하여, _'이것이 정말 우리의 문제인가?'_를 결정할 수 있는 무언가가 말이다.
그것이 바로 의도 탐지 (Intent Detection)입니다. 슬라이드에서 멋있어 보였기 때문이 아닙니다. 통화 중 터져 나온 웃음이, 우리가 실제로 테스트를 진행하는 순간 진짜 격차(Gap)로 변했기 때문입니다.
User
│
▼
...
AI 팀은 저에게 엔드포인트(Endpoint) 하나를 주었습니다. 쿼리(Query)를 보내면 의도(Intent)와 몇 가지 메타데이터(Metadata)를 돌려받는 방식이었죠. 제 쪽에서 작성할 코드는 아마 열 줄 정도였습니다. 저는 만족했습니다.
그때는 몰랐지만, 저는 훨씬 더 긴 이야기 속으로 발을 들여놓은 상태였습니다.
제2장 — "이걸 캐싱(Cache)할 수 있을까요?"
의도 계층(Intent layer)은 잘 작동했습니다. 데모도 성공적이었고, 고객도 만족했습니다. 모두가 웃었습니다.
그러다 누군가 실제로 이를 측정하기 시작했습니다.
"오늘 FD 금리가 얼마인가요?"와 같이 지루한 질문조차도, 이제는 질문에 답할 LLM(Large Language Model)에 도달하기도 전에, 질문이 어디로 가야 하는지를 파악하기 위해 전체 LLM 호출을 거쳐야만 했습니다. 이 우회로 때문에 매번 200~300ms의 비용이 발생했습니다. 매번 말이죠.
우리는 다음 리뷰 회의에서 이 문제를 제기했습니다.
AI 리드: "Suraj, 이걸 캐싱(Cache)할 수 있을까요?"
나: "네, 물론이죠. Redis를 쓰면 됩니다."
자신만만했습니다. 즉각적인 답변이었죠. 커리어 내내 "Redis"라는 말을 너무 많이 해서, 질문에 대해 실제로 생각하기도 전에 입 밖으로 튀어나오는 그런 종류의 답변 말입니다.
5초 후, 현실이 저를 따라잡았습니다.
나: "...사실 — 정확히 무엇을 캐싱하라는 말씀이시죠?"
회의실은 웃음바다가 되었습니다. 저도 웃었지만, 진심이었습니다. 저는 정말로 아직 답을 가지고 있지 않았습니다.
제3장 — 다시 화이트보드로. 두 번이나.
저의 첫 번째 본능은 모든 백엔드 개발자가 가진 본능이었습니다. 쿼리를 저장하고, 응답을 저장하고, 키-값(Key-value) 방식으로 처리하면 끝이라고 생각했죠.
"오늘 FD 금리가 얼마인가요?" → 캐시 (Cache) → HIT ✅
기분은 좋았습니다. 바로 다음 테스트를 하기 전까지는 말이죠.
"오늘 정기 예금(Fixed deposit) 이자율을 알려줄 수 있나요?" → 캐시 (Cache) → MISS ❌
같은 질문입니다. 같은 의도(Intent)입니다. 하지만 문자열(String)이 다릅니다. 사용자들은 인생에서 똑같은 문장을 두 번 입력하지 않으며, 완전 일치(Exact-match) 캐싱은 의미가 아닌 문자만을 인식합니다. 다시 화이트보드로 돌아갔습니다.
두 번째 시도는 더 똑똑해 보였습니다. 원문 텍스트가 아닌 의도(Intent)로 캐싱하는 것. 분류기(Classifier)가 이미 이것이 FD_RATE 질문이라고 알려주었다면, 왜 캐시 키를 그에 맞춰 설정하지 않을까요?
Intent: FD_RATE
│
▼
...
하루 정도는 아주 훌륭해 보였습니다. 그러다 누군가 연속으로 두 개의 질문을 던졌습니다.
"What is FD rate?" → FD_RATE
"What are FD benefits?" → FD_RATE
동일한 인텐트 (Intent) 버킷입니다. 완전히 다른 질문들입니다. 완전히 다른 답변들이 필요합니다. 만약 FD 혜택 (benefits)에 대해 묻는 사람에게 캐시된 FD 이율 (rate) 정보를 제공했다면, 저는 아주 확신에 찬 태도로 틀린 답을 전달한 셈이 됩니다. 이는 차라리 캐시를 아예 사용하지 않는 것보다 더 나쁜 상황일 수 있습니다.
다시 화이트보드 앞으로 돌아갔습니다.
그 주 내내 저는 막혀 있었고, "AI 레이어 (AI layer)를 캐싱한다"는 것이 제가 이미 알고 있는 도구들로 해결 가능한 문제인지 진심으로 의심하기 시작했습니다.
제4장 — 얇은 종이 한 장 (The Tissue Paper)
커피 휴식 시간 중이었습니다. 우리 아키텍트 (Architect)가 테이블 위의 냅킨을 집어 들었습니다. 처음에는 아무 말도 하지 않았습니다. 그저 두 단어를 적어 제 쪽으로 밀어 놓았습니다.
시맨틱 캐시 (Semantic Cache)
"오늘 밤에 이거에 대해 읽어봐"라고 그는 말하고는 다시 커피를 마시러 갔습니다.
저는 이해한 것처럼 고개를 끄덕였습니다. 하지만 이해하지 못했습니다.
그날 밤 저는 Redis 문서를 열었습니다. 벡터 검색 (vector search) 페이지를 한 번 읽고 탭을 닫았습니다.
다시 열었습니다.
Redis가... 벡터 검색을 지원한다고?
언제부터?
저는 제가 무언가 잘못 읽었을지도 모른다는 확신을 가지고 변경 로그 (changelog)를 확인했습니다. 단순히 지난주에 추가된 것이 아니었습니다. 이미 한동안 그곳에 자리 잡고 있었습니다. 단지 이전에는 필요가 없었기에 찾아본 적이 없었을 뿐입니다.
Redis가 하룻밤 사이에 변한 것이 아니었습니다. 제 이해도가 변한 것이었습니다.
자정쯤 되었을 때 드디어 깨달음이 왔습니다. 쿼리 (query)의 _정확한 텍스트 (exact text)_를 매칭하는 대신, 그것을 임베딩 (embedding) — 즉, 그 _의미 (meaning)_를 포착하는 벡터 (vector) — 로 변환한 뒤, 이전에 캐싱된 임베딩들과 비교하는 것입니다. 충분히 유사하다면, 검색 (retrieval)과 생성 (generation)에 비용을 다시 지불하는 대신 캐시된 답변을 제공하면 됩니다.
User Query
│
▼
...
이제 _"what's today's FD rate"_와 _"can you tell me today's fixed deposit interest rate"_는 벡터 공간 (vector space) 상에서 같은 항목에 도달할 만큼 충분히 가깝게 위치하는 반면, _"FD benefits"_는 그 항목을 놓칠 만큼 충분히 멀리 떨어져 있게 됩니다. 즉, 글자(characters)가 아니라 의미를 따지는 것입니다.
우리는 그 주에 그것을 구축했습니다. 금요일에 집으로 돌아가며 저는 이 프로젝트를 시작한 이후 처음으로, _'나 정말 괜찮은 백엔드 엔지니어(backend engineer)가 되어가고 있구나'_라고 생각했습니다.
Chapter 5 — 월요일 아침
월요일 아침. 한 손에는 커피, 다른 한 손에는 노트북을 들고 있었습니다. 저는 승전보를 기대하며 Grafana를 열었습니다.
Cache Hit Ratio
8%
저는 꼬박 10초 동안 그것을 뚫어지게 쳐다보았습니다. 새로고침을 했습니다. 숫자는 그대로였습니다.
이게 어떻게 가능한 일이지?
알고 보니, 어떤 Medium 기사에서 0.90을 사용했다는 이유로 유사도 임계값(similarity threshold)을 0.90으로 설정하는 것은, 자신의 트래픽을 대상으로 테스트하여 0.90을 선택하는 것과는 차원이 다른 문제였습니다. 실제로 유사한 질문 중 일부가 임계값 바로 아래에 걸려 누락되고 있었습니다. 반대로 관련 없는 질문 중 몇몇은 임계값을 살짝 넘겨 들어오고 있었습니다. 우리는 이틀 중 대부분의 시간을 실제 쿼리 로그(query logs)를 추출하고, 임계값이 실제로 어디에 위치해야 하는지 수동으로 눈으로 확인하며 보냈습니다.
임계값이 제대로 설정되자, 비율이 실제로 상승했습니다. 비용이 절감되었습니다. 잠시나마 자신감이 회복되었습니다.
그다음 우리는 엔드 투 엔드 지연 시간(end-to-end latency)을 측정했습니다.
거의 변하지 않았습니다.
그다음 우리는 스트리밍(streaming)을 시도했습니다. 전체 응답을 기다리는 대신 토큰(tokens)이 생성되는 대로 즉시 전송하는 방식입니다.
LLM
│
▼
...
사용자들은 더 빠르다고 느꼈습니다. 데모에서 클라이언트는 매우 좋아했습니다. 하지만 엔드 투 엔드로 측정된 실제 완료 시간(time-to-completion)은 단 1밀리초(millisecond)도 줄어들지 않았습니다. 스트리밍은 대기 시간을 _느끼는 방식_을 해결했을 뿐입니다. 작업 자체에는 아무런 영향을 주지 못했습니다.
Chapter 6 — 칼로 채소 썰기
다음 아키텍처 리뷰(architecture review)에 시니어 엔지니어 한 명이 참석했습니다. 설계의 타당성을 검토(sanity-check)하기 위해 팀 외부에서 불러온 분이었습니다. 그는 화면에 있는 다이어그램을 한참 동안 바라보았습니다. 그러더니 미소를 지었습니다.
"채소를 칼로 썰고 계시네요."
사람들은 왜 그런 말을 했는지 아직 정확히 알지 못한 채 웃음을 터뜨렸습니다.
그는 설명하지 않았습니다.
Chapter 7 — 칼
그는 잠시 침묵을 지키더니, 다이어그램에서 _의도 탐지(Intent Detection)_라고 표시된 박스를 가리켰습니다. 모든 요청마다 여전히 200~300ms의 비용을 발생시키고 있는 바로 그 LLM 호출이었습니다.
"검은 인상적이죠,"라고 그는 말했습니다. "하지만 칼이 그 작업에는 더 나은 도구입니다. 그것은 전체 모델(full model)이 할 일이 아닙니다. 분류기 (classifier)가 할 일이죠."
작은 분류기 (classifier)는 LLM이 아닙니다. 그것은 아무것도 생성하지 않습니다. 분류기의 유일한 역할은 쿼리 (query)를 읽고 그것이 어느 버킷 (bucket)에 속하는지 알려주는 것입니다. 접수원과 같다고 생각하면 됩니다. 문제를 해결하는 것이 아니라, 단지 당신을 올바른 방으로 안내할 뿐입니다.
사용자 질문: "오늘의 FD 금리는 얼마인가요?"
분류기 (Classifier) 반환:
...
우리는 LLM 기반의 의도 (intent) 단계를 우리가 직접 정의한 카테고리 (categories)로 학습된 작은 트랜스포머 (transformer) 모델로 교체했습니다. 우리가 실제로 필요로 했던 기능 측면에서 정확도는 동일했습니다. 지연 시간 (latency)은 ~300ms에서 10–15ms로 급감했습니다. Node.js 측면에서는 거의 바뀐 것이 없었습니다. 제 레이어 (layer)는 이제 단지 다른, 훨씬 더 작은 서비스를 호출할 뿐입니다:
const intent = await intentService.predict(query);
if (intent === "FD_RATE") {
...
나중에 우리는 더 크고 성숙한 시스템들이 이를 훨씬 더 계층적으로 구성한다는 것을 알게 되었습니다. 저렴한 규칙 (rules)을 먼저 적용하고, 두 번째로 작은 분류기 (classifier)를 사용하며, 앞선 두 단계를 모두 통과한 진정으로 모호한 케이스에 대해서만 전체 LLM을 사용하는 방식입니다:
사용자 쿼리 (User Query)
│
▼
...
명확한 95%의 요청에 대해서는 빠르고 저렴하게 처리합니다. 비싼 모델은 진정으로 모델이 필요한 나머지 5%를 위해 남겨둡니다.
8장 — 아무도 다이어그램에 넣지 않는 것들
위의 과정 중 어느 것도 독립적으로 일어나지 않았습니다. 우리가 의도 (intent)를 수정하고 캐싱 (caching)을 처리하는 동안, 배경에서는 더 조용한 문제들이 쌓여가고 있었습니다. 아키텍처 (architecture) 슬라이드에는 절대 등장하지 않지만, 시스템이 운영 환경 (production)에서 살아남을 수 있을지를 결정짓는 아주 지루한 종류의 문제들이었습니다.
- WebSocket 재연결 (reconnects). 모바일 네트워크는 끊깁니다. 사용자는 대화 도중에 Wi-Fi에서 데이터로 전환합니다. 모든 재연결은 사용자가 눈치채거나 말을 반복하지 않고도 동일한 세션을 재개할 수 있어야 했습니다.
- Redis 다운. 한 번, 스테이징 (staging) 환경에서 잠시 발생했습니다. 이는 폴백 (fallback)이 없는 시맨틱 캐시 (semantic cache)가 더 이상 성능 계층이 아니라, 단일 장애점 (single point of failure)이라는 사실을 깨닫게 해주기에 충분한 시간이었습니다.
- 중복 메시지. 불안정한 연결과 클라이언트의 재시도 (retry)가 결합되어, 동일한 쿼리가 때때로 수 밀리초 간격으로 백엔드에 두 번 도달했고, 아주 잠깐 동안 우리 봇이 자신의 질문에 두 번째로 답변하는 일이 발생했습니다.
- 계획에 없던 큐 (queue). 트래픽이 항상 완벽하게 매끄럽지는 않았기에, 요청을 누락시키지 않고 급증하는 트래픽 (bursts)을 흡수할 수 있는 방법은 더 이상 선택 사항이 아니었습니다.
- 스트리밍 버그 (Streaming bugs). 부하가 걸릴 때 토큰이 순서에 어긋나게 도착하거나, 응답 도중 스트림이 조용히 끊겨버려 UI에는 커서만 영원히 깜빡이는 현상이 발생했습니다.
- 경쟁 상태 (Race conditions). 두 사용자가 거의 동시에 동일한 키에 충돌할 정도로 유사한 질문을 했을 때, 캐시 쓰기 (cache write)와 캐시 읽기 (cache read) 사이에서 발생했습니다.
- 로그 및 모니터링 (Logs and monitoring). 무엇이 문제인지 알려주기 훨씬 전부터, 무언가가 느리다는 사실만을 알려주었습니다. Grafana는 대시보드라기보다는 그 주에 우리가 저지른 모든 실수에 대한 일기장에 가까워졌습니다.
이 중 그 어떤 것도 피치 덱 (pitch deck)에는 포함되지 않았습니다. 하지만 그 모든 것들은 프로덕션 (production) 환경에 포함되었습니다.
아무도 계획하지 않았던 것들
프로젝트가 훨씬 진행된 어느 저녁, 나는 아키텍처 다이어그램을 바라보며 실제로 박스(구성 요소)의 개수를 세어보았습니다.
WebSocket. 인증 (Authentication). 소형 분류기 (Small Classifier). 시맨틱 캐시 (Semantic Cache). 리트리버 (Retriever). RAG. LLM. 스트리밍 (Streaming). 큐 (Queue). 모니터링 (Monitoring). 분석 (Analytics). 속도 제한기 (Rate Limiter).
사용자 (User)
│
▼
...
그리고 이것은 이제 다이어그램의 전체 모습조차 아닙니다. 그저 슬라이드 한 장에 들어갈 수 있는 부분일 뿐입니다.
그 첫 번째 킥오프 미팅(kickoff meeting)에서 그 누구도 이것을 계획하지 않았습니다. 첫날에 앉아서 12개의 서비스를 설계한 사람은 아무도 없었습니다. 모든 상자(box) 하나하나가 존재하는 이유는, 어느 날 무언가가 고장 났거나, 누군가 형편없는 질문을 던져 비웃음을 샀거나, 월요일 아침 대시보드(dashboard)가 우리를 겸허하게 만들었거나, 혹은 시니어 엔지니어(senior engineer)가 우리의 설계를 오이를 향해 칼을 휘두르는 사람에 비유했기 때문입니다.
이것이 제가 이 일을 시작했을 때 이해하지 못했던 부분입니다. 저는 제가 챗봇(chatbot)을 만들 것이라고 생각했습니다. 하지만 실제로 제가 한 일은, 하나의 잘못된 가정과 새벽 2시의 Redis 문서 탐독(Redis-docs binge)을 거듭하며, 시스템이 제 눈앞에서 스스로 설계되는 과정을 지켜보는 것이었습니다. 실제 시스템이 항상 그러하듯, 그것은 첫날에 그리는 깔끔한 다이어그램(diagram)과는 전혀 달랐습니다.
이것은 결코 진정한 AI 이야기가 아니었습니다. AI는 그저 구실이었을 뿐입니다. 이것은 운 좋게 그 한가운데에 LLM(Large Language Model)이 서 있게 된 엔지니어링(engineering) 이야기입니다.
다음 내용: RAG를 위해 문서를 파싱(parsing)하는 것이 RAG 자체보다 더 어려운 문제라는 것을 깨닫게 된 부분.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기