Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

쇼핑몰 고객 상담 챗봇 개발기 (1) — 규칙 기반 대화 엔진과 CI 구축 본문

외주 개발일지

쇼핑몰 고객 상담 챗봇 개발기 (1) — 규칙 기반 대화 엔진과 CI 구축

HM소프트 2026. 7. 4. 13:41
반응형

쇼핑몰 고객 상담 챗봇을 LLM 없이 규칙 기반으로 만든 기록. 버튼 우선·자유 입력 보조의 두 갈래 대화 엔진과 키워드·FAQ 양방향 매칭, 환불 정책을 SQL 조건으로 표현하기, sql.js의 동시성 트레이드오프, npm ci와의 CI 싸움까지 담았다.

왜 LLM 없이 시작했나

쇼핑몰에 들어오는 고객 문의는 패턴이 지독하게 뻔하다. "주문한 거 언제 와요", "환불 어떻게 해요", "사이즈 바꿀 수 있나요". 상담 인력이 같은 답을 하루에 수십 번씩 복사·붙여넣기 하는 걸 보면, 이건 사람이 아니라 코드가 할 일이라는 생각이 든다. 그래서 한 쇼핑몰의 고객 상담용 챗봇을 만들기로 했다.

처음부터 외부 LLM API를 붙일 수도 있었다. 하지만 일부러 그 길을 택하지 않았다. 이유는 세 가지였다. 첫째, 쇼핑몰 상담의 핵심은 "창의적인 대화"가 아니라 정확한 주문·배송 정보 조회다. "내 주문 어디쯤 왔어요"라는 질문에 필요한 건 그럴듯한 문장이 아니라 DB에 박힌 송장번호다. 둘째, LLM은 비용과 지연이 따라붙고, 무엇보다 모르는 걸 그럴듯하게 지어내는(hallucination) 위험이 있다. 배송일을 환각으로 답하는 상담봇은 없느니만 못하다. 셋째, 규칙 기반으로 먼저 골격을 세워두면, 나중에 LLM을 얹더라도 "어떤 의도일 때 무슨 데이터를 줘야 하는가"라는 매핑이 이미 검증돼 있게 된다.

그래서 1편의 목표는 명확했다. LLM 없이도 실무 상담의 80%를 처리하는 규칙 기반 대화 엔진을 만들고, 그 위에 React 프론트엔드와 GitHub Actions CI를 붙이는 것. 이 글에서는 그 과정을 — 특히 의외로 가장 애를 먹인 CI 구축의 시행착오까지 — 순서대로 기록한다.

시스템 한눈에 보기 — 3계층 구조

먼저 전체 그림부터 짚자. 프레임워크 위에 또 프레임워크를 얹는 무거운 구성 대신, React 프론트엔드 + Express 백엔드 + 파일 기반 SQLite라는 단순한 3계층으로 잡았다. 백엔드 한 대가 API도 처리하고, 빌드된 프론트엔드 정적 파일도 같이 서빙한다. 작은 서비스에서 배포 단위를 하나로 줄이는 건 운영 부담을 크게 덜어준다.

여기서 한 가지 특이한 선택이 있다. DB로 일반적인 네이티브 SQLite 드라이버가 아니라 sql.js 를 썼다는 점이다. sql.js는 SQLite를 WebAssembly로 컴파일한 라이브러리로, DB 전체를 메모리에서 다루고 파일로 직렬화한다. 네이티브 바이너리 컴파일(node-gyp)이 필요 없어서 어떤 OS·CI 환경에서도 npm install 한 방에 돌아간다. 데모와 프로토타입 단계에서 "설치가 안 돼서 막히는" 일을 원천 차단하려는 의도였다. 대신 트레이드오프가 있는데, 이건 뒤에서 다시 다룬다.

데이터 모델 — 상담봇이 답할 수 있는 범위를 정한다

규칙 기반 봇의 능력치는 결국 DB 스키마가 결정한다. 봇이 답할 수 있는 질문은 테이블에 있는 정보를 넘지 못한다. 그래서 스키마 설계가 곧 "이 봇은 무엇을 아는가"를 정의하는 작업이었다. 다섯 개의 테이블로 정리했다.

// backend/database.js — 핵심 테이블 (발췌)
CREATE TABLE orders (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  order_id TEXT UNIQUE NOT NULL,
  customer_id TEXT NOT NULL,
  product_id TEXT NOT NULL,
  total_price INTEGER,
  status TEXT DEFAULT 'pending',
  shipping_status TEXT DEFAULT 'preparing',
  shipping_company TEXT,
  tracking_number TEXT,
  estimated_delivery TEXT,
  FOREIGN KEY (customer_id) REFERENCES customers(customer_id),
  FOREIGN KEY (product_id) REFERENCES products(product_id)
);

CREATE TABLE faq (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  category TEXT NOT NULL,
  question TEXT NOT NULL,
  answer TEXT NOT NULL,
  keywords TEXT
);

설계에서 의식적으로 신경 쓴 두 가지가 있다.

첫째, orders 테이블에 status(주문 상태)와 shipping_status(배송 상태)를 분리했다. 처음엔 상태 하나로 합칠까 했지만, 둘은 생애주기가 다르다. 주문은 pending → confirmed → completed → refund_requested → refunded로 흐르고, 배송은 preparing → shipped → in_transit → out_for_delivery → delivered로 흐른다. 환불을 요청한(refund_requested) 주문도 배송은 이미 delivered 상태일 수 있다. 이 둘을 한 컬럼에 욱여넣었다면 "배송은 끝났지만 환불 진행 중"이라는 흔한 상태를 표현할 수 없었을 것이다.

둘째, faq 테이블에 keywords 컬럼을 따로 뒀다. 질문(question)과 답변(answer) 외에 콤마로 구분된 검색용 키워드를 별도로 저장한다. 이게 뒤에서 다룰 자유 입력 FAQ 매칭의 연료가 된다. 질문 문장 자체로 매칭하면 표현이 조금만 달라도 빗나가지만, "환불,반품,돈,취소,신청" 같은 키워드 묶음을 미리 박아두면 "돈 돌려주세요"도 환불 FAQ에 걸린다.

대화 엔진 — 두 갈래 경로

대화 엔진의 핵심 설계는 입력을 두 갈래로 나눈 것이다. 사용자가 버튼을 누르면 의도가 명확하므로 action 값으로 정확한 핸들러를 호출하고, 자유롭게 텍스트를 입력하면 키워드와 FAQ로 의도를 추론한다. 같은 /api/chat 엔드포인트가 둘 다 받되, action 필드 유무로 분기한다.

이 "버튼 우선, 자유 입력 보조" 구조가 규칙 기반 봇의 정확도를 끌어올린 핵심이다. 챗봇 UX의 함정은 입력창만 덜렁 주고 "뭐든 물어보세요"라고 하는 것이다. 사용자는 무얼 물어야 할지 모르고, 봇은 자유 입력을 잘 못 알아듣는다. 그래서 매 응답마다 다음에 누를 수 있는 버튼을 함께 내려준다. 사용자를 정해진 의도의 길로 부드럽게 유도하면, 자유 입력에 의존할 일 자체가 줄어든다.

전체 메시지 처리 흐름을 시퀀스로 그리면 이렇다.

액션 핸들러 — 데이터가 응답의 모양을 결정한다

버튼 클릭(action)은 handleAction()이 받아 의도별 전용 함수로 보낸다. 여기서 중요한 설계 원칙은 응답이 정적인 문구가 아니라 실시간 DB 상태에서 만들어진다는 점이다. 같은 "배송 조회" 버튼이라도 고객마다, 시점마다 완전히 다른 답이 나온다.

작은 디테일이지만 신경 쓴 부분이 두 군데 있다. 하나는 shipping_status▓░░░░ 같은 텍스트 진행 막대로 시각화한 것. 채팅 말풍선 안에서 별도 UI 컴포넌트 없이 배송 단계를 직관적으로 보여줄 수 있어, 프론트엔드를 단순하게 유지하면서도 정보 전달력을 높였다. 다른 하나는 빈 결과의 폴백이다. 배송 중인 상품이 없으면 "없습니다"로 끝내지 않고, 최근 배송 완료된 상품을 대신 보여준다. "조회 결과 없음"이라는 막다른 길은 챗봇에서 가장 나쁜 경험이라, 항상 다음 행동을 제시하도록 했다.

환불·교환 핸들러는 한 발 더 나아가 자격 조건을 SQL로 필터링한다. 예컨대 환불 가능한 주문은 "배송 완료됐고(shipping_status = 'delivered'), 아직 환불·교환 신청 전(status NOT IN (...))인 것"만 골라 보여준다.

// 환불 신청 가능한 주문만 추려서 노출 — 정책을 SQL로 표현
WHERE o.customer_id = ?
  AND o.shipping_status = 'delivered'
  AND o.status NOT IN ('refund_requested', 'refunded')

이렇게 하면 고객이 애초에 환불 불가능한 주문을 고를 수가 없다. "이미 환불 신청된 건입니다" 같은 사후 거절 메시지를 띄울 일이 사라진다. 비즈니스 규칙(환불 정책)을 코드 분기가 아니라 쿼리 조건으로 표현한 것 — 이게 규칙 기반 봇에서 정책을 다루는 가장 깔끔한 방법이라고 생각한다.

자유 입력 처리 — 키워드 먼저, FAQ는 폴백

action 없이 사용자가 직접 글을 쓰면 generateSmartResponse()가 의도를 추론한다. 여기서 핵심은 우선순위를 가진 다단계 폴백이다. 강한 신호(특정 키워드 조합)를 먼저 확인하고, 거기서 못 잡으면 점점 느슨한 매칭(FAQ)으로, 끝내 못 잡으면 메인 메뉴로 되돌린다.

const lowerMsg = message.toLowerCase();

// 1) 강한 신호: 키워드 조합으로 의도 직격
if (lowerMsg.includes('배송') && (lowerMsg.includes('조회') ||
    lowerMsg.includes('어디') || lowerMsg.includes('언제'))) {
  return getShippingResponse(customerId);
}
if (lowerMsg.includes('환불') || lowerMsg.includes('반품')) {
  return getRefundResponse(customerId);
}
// ... 주문/교환/상담원 분기 ...

// 2) 약한 신호: FAQ 키워드 매칭
const faqMatch = searchFaq(message);
if (faqMatch) return { message: `💡 **${faqMatch.question}**\n\n${faqMatch.answer}`, buttons: [...] };

// 3) 폴백: 메인 메뉴로 회귀
return { message: `${customer.name}님, 문의 내용이 접수되었습니다...`, buttons: getMainMenuButtons() };

"배송 + (조회/어디/언제)"처럼 단어 하나가 아니라 조합을 본다는 점이 의외로 중요했다. "배송비 얼마예요"와 "배송 어디까지 왔어요"는 둘 다 '배송'을 포함하지만 의도가 전혀 다르다. 전자는 FAQ로, 후자는 실시간 조회로 가야 한다. 단순 키워드 포함만 보면 둘을 구분 못 한다. 그래서 동작을 트리거하는 의도(실시간 조회)에는 보조 키워드 조합을 요구하고, 나머지는 FAQ 매칭으로 흘려보냈다.

FAQ 매칭 함수 searchFaq()는 이 봇에서 LLM에 가장 가까운 부분이다. 입력 문장을 단어로 쪼개고, 각 FAQ의 키워드 묶음과 양방향 부분 문자열 매칭으로 점수를 매겨 최고점을 고른다.

k.includes(word) || word.includes(k)라는 양방향 조건이 핵심이다. 한쪽 방향만 보면 "환불해주세요"의 '환불해주세요'가 키워드 '환불'을 못 잡거나(완전 일치만 볼 때), 반대로 너무 짧은 단어가 아무 키워드에나 걸린다. 양방향으로 두면 "환불"⊂"환불해주세요"와 "환불"⊃"환" 양쪽을 모두 커버해 한국어 조사 변형에 꽤 강해진다. 물론 이건 정교한 형태소 분석이 아니라 휴리스틱이라 한계가 분명하다 — 이 한계와 개선 방향은 다음 편의 주제다.

솔직한 약점 하나 — XSS 노출 지점

개발기는 잘된 것만 적으면 거짓말이 된다. 프론트엔드에서 봇 메시지를 렌더링하는 formatMessage()잠재적 XSS 취약점이 있다. 마크다운 굵게(**텍스트**)를 HTML로 바꾸려고 dangerouslySetInnerHTML을 쓴 부분이다.

// frontend/src/components/ChatBot.js — 위험 신호가 이름에 그대로 박혀 있다
const formatMessage = (text) => text.split('\n').map((line, i) => {
  line = line.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>'); // 굵게 변환
  return <span key={i} dangerouslySetInnerHTML={{ __html: line + '<br/>' }} />;
});

지금은 표시되는 텍스트가 전부 서버가 만든 정형 메시지라 당장 터지진 않는다. 하지만 자유 입력 문의를 상담원 화면에 이 함수로 그대로 렌더링하는 순간, 고객이 입력한 <script>가 실행될 수 있다. 사용자 입력이 출력 경로에 닿는다면 dangerouslySetInnerHTML은 거의 항상 잘못된 선택이다. 메모해 두고, 마크다운 렌더링은 화이트리스트 기반 파서(또는 React 엘리먼트로 직접 조립)로 교체하는 걸 다음 편 과제로 남겼다. 프로토타입에서 의도적으로 진 기술 부채는, 적어도 어디에 졌는지 기록은 해둬야 한다.

CI 구축 — npm ci와의 싸움

기능이 어느 정도 돌고 나서 GitHub Actions로 CI를 붙였다. 처음엔 echo Hello, world!만 찍는 기본 워크플로(blank.yml)로 감을 잡고, 곧 Node 18.x / 20.x 매트릭스로 백엔드·프론트엔드를 설치하고 프론트를 빌드해 아티팩트로 올리는 진짜 파이프라인(ci.yml)으로 확장했다. 두 버전에서 동시에 돌려, 특정 Node 버전에서만 깨지는 문제를 미리 잡으려는 의도였다.

여기까지는 순조로웠는데, 진짜 문제는 npm ci였다. 처음 워크플로는 재현 가능한 빌드를 위해 정석대로 npm ci를 썼다. 그런데 CI가 계속 빨간불이었다.

원인은 package-lock.jsonpackage.json의 불일치였다. npm ci는 둘이 정확히 맞물려야만 동작한다 — 이게 npm ci의 존재 이유이자, 동시에 이번엔 발목을 잡은 지점이었다. 로컬에서 의존성을 손본 이력(특히 react-scripts가 끌고 오는 typescript 버전)이 lock 파일에 깔끔히 반영되지 않아, CI 환경에서 검증에 실패했다.

수습 과정은 커밋 로그에 흔적이 그대로 남아 있다.

Fix: Regenerate package-lock.json for CI compatibilityFix: Regenerate all package-lock.json filesFix: Use npm install instead of npm ci. 세 번의 커밋이 그대로 이 싸움의 일지다.

원칙적으로는 npm ci가 맞다. 재현 가능한 빌드를 보장하고, lock 파일에 없는 의존성이 몰래 끼어드는 걸 막아준다. 하지만 lock 파일 관리가 아직 안정화되지 않은 초기 프로토타입에서는, CI가 의존성 정합성 문제로 매번 깨지는 비용이 그 이점보다 컸다. npm install로 바꾸자 CI는 곧바로 초록불이 됐다.

도구의 원칙보다 프로젝트의 단계에 맞는 선택이 있다는 걸 커밋 세 개를 태우며 배웠다. 다만 이건 영구적 결정이 아니라 의도적 유예다. lock 파일이 안정화되고 의존성이 고정되면 npm ci로 되돌리는 게 맞다. 한 가지 덧붙이면, cache: 'npm'cache-dependency-path로 두 디렉터리의 lock 파일을 캐시 키에 묶어둔 설정은 그대로 살려뒀다 — 설치 방식이 바뀌어도 캐시는 빌드 시간을 꾸준히 줄여준다.

sql.js의 트레이드오프 — 동시성이라는 외상값

앞서 미뤄둔 sql.js의 대가를 짚고 마무리하자. sql.js는 DB 전체를 메모리에 올려두고, 변경이 생길 때마다 db.export()로 직렬화해 파일에 통째로 덮어쓴다.

설치가 쉽고 OS·CI를 안 가린다는 장점은 분명하다. 하지만 쓰기마다 DB 파일 전체를 다시 쓴다는 건, 데이터가 커지거나 동시 쓰기가 몰리면 곧장 병목이자 데이터 경합 위험이 된다는 뜻이다. 단일 프로세스·소규모 데모에는 완벽하지만, 실제 트래픽이 붙는 운영에는 부적합하다. 이건 버그가 아니라 선택한 도구의 성격이고, 프로토타입 단계에서 "설치 편의"를 "동시성·성능"보다 우선한 의도적 결정이었다. 다음 단계에서 트래픽을 받게 되면 네이티브 SQLite나 별도 DB 서버로 옮기는 게 자연스러운 진화 경로다.

정리하며

  • 쇼핑몰 상담의 핵심은 창의적 대화가 아니라 정확한 데이터 조회다. 그래서 LLM 대신 규칙 기반으로 골격을 먼저 세웠다. 모르면 지어내지 않고 상담원으로 폴백.
  • 대화 엔진은 버튼(action) 우선, 자유 입력 보조의 두 갈래로 나눴다. 매 응답에 다음 버튼을 함께 내려 사용자를 정해진 의도로 유도하면, 자유 입력 의존도 자체가 줄어든다.
  • 규칙 기반 봇의 능력치는 스키마가 결정한다. status/shipping_status를 분리하고, 환불 가능 조건을 SQL 필터로 표현해 "고를 수 없으면 거절할 일도 없다"를 구현했다.
  • 자유 입력은 키워드 조합 → FAQ 양방향 부분 매칭 → 메인 메뉴의 다단계 폴백으로 처리. 단어가 아니라 조합을 봐야 "배송비"와 "배송 어디"를 가른다.
  • 프론트엔드의 dangerouslySetInnerHTML의도적으로 진 기술 부채다. 사용자 입력이 닿는 순간 XSS가 되므로 다음 편에서 안전한 렌더러로 교체한다.
  • npm ci는 lock 파일이 안정된 뒤의 이야기. 초기에는 npm install로 CI를 살리는 게 실용적이었다 — 도구의 원칙보다 프로젝트의 단계. 안정화되면 되돌린다.
  • sql.js는 설치 편의를 동시성·성능과 맞바꾼 선택. 데모엔 완벽하지만 운영엔 네이티브 DB로의 이주가 예정된 외상값이다.

다음 편에서는 FAQ 매칭을 형태소·동의어 기반으로 끌어올리는 작업, dangerouslySetInnerHTML 제거, 그리고 상담원 연결 시뮬레이션을 실제 인입 큐로 바꾸는 구조를 다룰 예정이다.

반응형