사이트에 서비스 페이지가 50개를 넘어가면서 문제가 생겼다. 방문자가 원하는 걸 못 찾는다. 사이트맵 페이지를 만들어놨지만 누가 그걸 보겠나. 검색 기능도 있지만 “사주 보고 싶은데” 같은 자연어는 처리하지 못한다. 결국 이탈률만 높아진다.
챗봇을 달면 해결될 것 같았다. 근데 외부 서비스를 쓰자니 월 2~3만원은 기본이고, ChatGPT API를 바로 붙이면 트래픽 늘 때 비용 폭탄을 맞는다. 그래서 직접 만들기로 했다. 비용 0원으로 시작해서 단계적으로 AI를 붙이는 전략이다.
3단계 아키텍처 — 왜 이렇게 설계했나
처음부터 AI를 쓰면 안 되는 이유가 있다. 첫째, 비용. 둘째, 속도. 셋째, 정확도. “타로 보고 싶어요” 같은 명확한 요청에 GPT를 호출할 이유가 없다. URL 하나 던져주면 끝인데 3초씩 기다리게 할 필요가 있을까?
그래서 3단계로 설계했다:
| 단계 | 처리 방식 | 비용 | 응답 속도 |
|---|---|---|---|
| 1단계 | 규칙 기반 키워드 매칭 (JS) | 0원 | 즉시 (~0.1초) |
| 2단계 | WordPress DB 검색 (PHP) | 0원 | 빠름 (~0.3초) |
| 3단계 | Ollama/Claude AI (PHP) | 0원 또는 유료 | 느림 (3~15초) |
사용자 메시지가 들어오면 먼저 JS에서 키워드를 매칭한다. “운세”라는 단어가 들어있으면 운세 서비스 목록을 바로 보여준다. 매칭 실패하면 서버로 보내서 WordPress DB 검색을 한다. 검색에서도 결과가 없으면 그때서야 AI를 호출한다.
이 구조의 핵심은 대부분의 질문이 1단계에서 끝난다는 것이다. 실제 로그를 보면 70% 이상이 규칙 기반으로 처리된다. AI 호출 없이.
mu-plugin으로 만드는 이유
일반 플러그인 대신 mu-plugin(Must-Use Plugin)을 사용했다. 경로는 wp-content/mu-plugins/chatbot.php. 이렇게 하면 관리자 화면에서 실수로 비활성화하는 일이 없고, 테마 교체에도 영향을 안 받는다. 파일 하나에 PHP + CSS + JS를 다 넣어서 의존성도 없다.
<?php
/**
* Plugin Name: Chatbot - 고객 지원 챗봇
* Description: 규칙 기반 FAQ 챗봇 위젯 (비용 0원, 키워드 매칭 방식)
* Version: 1.0.0
* Author: nalkkul
*/
if (!defined('ABSPATH')) exit;
knowledgeBase — 규칙 기반 엔진의 심장
규칙 기반 엔진은 JS 객체 하나로 돌아간다. knowledgeBase라는 객체에 카테고리별로 키워드와 응답을 정의해 놓으면 된다. 구조는 이렇다:
var knowledgeBase = {
fortune: {
keywords: ["운세", "타로", "사주", "궁합", "점", "운명"],
response: "운세 서비스를 이용해보세요! 🔮\n\n🃏 타로 카드 리딩 → /tarot/\n🏛️ 사주팔자 풀이 → /saju/\n💕 궁합 보기 → /gunghap/\n💑 이름 궁합 → /name-match/\n\n모두 무료입니다!",
links: [
{text: "🃏 타로 보러가기", url: "/tarot/"},
{text: "🏛️ 사주 보러가기", url: "/saju/"},
{text: "💕 궁합 보러가기", url: "/gunghap/"}
]
},
calculator: {
keywords: ["계산기", "계산", "연봉", "대출", "퇴직금", "bmi", "학점", "글자수", "단위", "환율", "연차", "칼로리"],
response: "다양한 계산기를 이용하세요! 🧮\n\n💰 연봉 실수령액 → /salary-calculator/\n🏦 대출 이자 → /loan-calculator/\n...",
links: [
{text: "💰 연봉 계산기", url: "/salary-calculator/"},
{text: "🏦 대출 계산기", url: "/loan-calculator/"}
]
},
// ... 16개 카테고리
};
각 카테고리에는 세 가지 속성이 있다:
- keywords — 이 카테고리를 트리거하는 단어 배열. “운세”든 “사주”든 하나만 걸리면 매칭된다.
- response — 보여줄 텍스트. URL을 포함하면 자동으로 클릭 가능한 링크로 변환된다.
- links / buttons — 응답 아래에 표시할 바로가기 링크 또는 추가 질문 버튼.
현재 정의된 16개 카테고리를 정리하면:
| 카테고리 | 키워드 예시 | 서비스 수 |
|---|---|---|
| services (전체) | 서비스, 뭐 있, 할 수 있 | 전체 안내 |
| fortune (운세) | 운세, 타로, 사주, 궁합 | 4개 |
| quiz (심리테스트) | 심리, 테스트, mbti, 성격 | 5개 |
| game (게임) | 게임, 월드컵, 끝말, 야구 | 7개 |
| calculator (계산기) | 계산기, 연봉, 대출, bmi | 8개 |
| info (정보조회) | 미세먼지, 약국, 증시, 주식 | 11개 |
| education (학습) | 맞춤법, 상식, 퀴즈, 사자성어 | 5개 |
| utility (유틸리티) | qr, 비밀번호, 색상, 닉네임 | 7개 |
| community (커뮤니티) | 방명록, 투표, 의견 | 2개 |
| auth (회원) | 회원가입, 로그인, 비밀번호 찾기 | 4개 |
| blog (블로그) | 블로그 보기, 포스트 목록 | 블로그 링크 |
| greeting (인사) | 안녕, 하이, hello, 반가 | 환영 메시지 |
| thanks (감사) | 감사, 고마, 땡큐 | 응답 |
| contact (문의) | 문의, 연락, 이메일, 상담 | 문의 폼 표시 |
| about (소개) | 사이트, 소개, 누가 만들 | 사이트 정보 |
| help (도움) | 도움, help, 모르겠, 사용법 | 사용 가이드 |
findResponse() — 매칭 로직의 핵심
사용자 입력이 들어오면 findResponse() 함수가 knowledgeBase를 순회하면서 키워드를 찾는다. 코드를 보자:
function findResponse(userMessage) {
var msg = userMessage.toLowerCase().trim();
// 검색/추천 의도가 있으면 AI로 바로 보냄 (규칙 기반 스킵)
var searchIntents = ["검색", "찾아", "추천", "관련 글", "관련된", "최근 글",
"최신 글", "올라온 글", "업데이트", "뭐 있어", "볼만한", "읽을만한"];
for (var si = 0; si < searchIntents.length; si++) {
if (msg.indexOf(searchIntents[si]) !== -1 && AI_ENGINE) {
return null; // AI로 전달
}
}
for (var key in knowledgeBase) {
if (!knowledgeBase.hasOwnProperty(key)) continue;
var data = knowledgeBase[key];
for (var i = 0; i < data.keywords.length; i++) {
if (msg.indexOf(data.keywords[i].toLowerCase()) !== -1) {
return data;
}
}
}
// No match → return null to trigger AI
if (AI_ENGINE) return null;
// No AI engine → static fallback
return {
response: "죄송합니다, 정확히 이해하지 못했어요. 😅\n\n다음 중 선택해보세요:",
buttons: [
{text: "🔮 운세", action: "운세"},
{text: "🧮 계산기", action: "계산기"},
{text: "🎮 게임", action: "게임"},
{text: "📊 정보조회", action: "정보조회"},
{text: "📧 문의하기", action: "문의"}
]
};
}
여기서 중요한 포인트가 두 가지 있다.
첫째, 검색 의도 우선 처리. "여행 관련 글 추천해줘" 같은 메시지는 키워드 매칭으로 처리하면 안 된다. "추천"이라는 단어가 검색 의도를 가지고 있으니까 서버로 보내서 DB 검색을 해야 한다. 그래서 searchIntents 배열에 검색/추천 관련 단어를 모아두고, 이 단어가 감지되면 규칙 기반을 건너뛰고 바로 null을 리턴한다.
둘째, AI 엔진 유무에 따른 폴백. AI 엔진이 설정되어 있으면(AI_ENGINE이 truthy) 매칭 실패 시 null을 리턴해서 AI를 호출한다. AI 엔진이 없으면 버튼들을 표시해서 사용자를 안내한다. 비용 0원 모드에서도 챗봇이 멈추지 않고 동작하는 이유가 이거다.
handleUserInput() — 1단계와 3단계의 분기점
사용자가 메시지를 보내면 handleUserInput()이 호출된다. 여기서 규칙 기반과 AI의 분기가 일어난다:
function handleUserInput(text) {
if (!text || !text.trim()) return;
text = text.trim();
// Show user message
addMessage(text, 'user');
// Try rule-based first
var result = findResponse(text);
if (result !== null) {
// Rule-based match found — log it
var logFd = new FormData();
logFd.append('action', 'nalkkul_chatbot_log');
logFd.append('nonce', NONCE);
logFd.append('question', text);
logFd.append('answer', result.response.substring(0, 200));
fetch(AJAX_URL, {method: 'POST', body: logFd});
var typing = showTyping();
setTimeout(function() {
removeTyping();
addMessage(result.response, 'bot', {
buttons: result.buttons,
links: result.links,
showContactForm: result.showContactForm
});
}, 400 + Math.random() * 400);
} else {
// No match → call AI
callAI(text);
}
}
규칙 기반으로 매칭되면 서버에 로그만 남기고 즉시 응답한다. setTimeout으로 400~800ms 랜덤 딜레이를 주는 건 진짜 생각하고 있는 것처럼 보이게 하는 트릭이다. 즉시 응답하면 오히려 기계적으로 느껴진다.
매칭 실패하면 callAI(text)로 서버에 AJAX 요청을 보낸다. 이건 다음 파트에서 자세히 다룬다.
규칙 기반의 한계 — 솔직한 이야기
몇 달 운영해보니 한계가 명확하다:
- 자연어 이해 불가. "오늘 뭐 재미있는 거 없나" 같은 질문은 어디에도 매칭되지 않는다. 키워드가 하나도 안 걸린다.
- 수동 업데이트. 새 서비스 페이지를 추가하면 knowledgeBase도 수동으로 수정해야 한다. 까먹으면 챗봇이 안내 못 한다.
- 동의어 처리 불가. "돈 계산" → 연봉 계산기? 대출 계산기? "뚱뚱한지 궁금" → BMI 계산기? 이런 건 절대 못 잡는다.
- 맥락 무시. 대화의 흐름을 이해하지 못한다. "그거 말고 다른 거" 같은 후속 질문은 처리 불가.
하지만 장점도 확실하다. 비용 0원, 응답 속도 0.1초 미만, 서버 부하 없음, 100% 정확한 응답 (매칭되면). 대부분의 사이트에서 FAQ 수준의 챗봇은 이것만으로 충분하다. "사주 보고 싶어요" → 사주 페이지 링크. 이게 전부인 사이트가 많다.
다음 단계
규칙 기반 엔진은 뼈대다. 다음 글에서는 이 엔진 위에 올라가는 플로팅 UI와 대화 인터페이스를 구현한다. 채팅 창 디자인, 타이핑 인디케이터, 다크모드, 모바일 전체화면 대응까지. 코드를 파일 하나에 전부 넣는 방법도 다룬다.
이 시리즈의 다른 글
- (1) 설계와 규칙 기반 엔진 — 현재 글
- (2) 플로팅 UI와 대화 인터페이스
- (3) RAG 검색 연동으로 블로그 글 찾기
- (4) Ollama 로컬 AI 연동
- (5) 대화 로그, 문의 접수, 운영 팁