챗봇의 두뇌가 준비됐으니 이제 몸을 만들 차례다. 아무리 응답이 좋아도 UI가 구리면 아무도 안 쓴다. 실제로 처음에 못생긴 프로토타입을 올렸다가 클릭률이 2% 미만이었다. 디자인을 갈아엎고 나서야 10%를 넘겼다.
목표는 간단했다. 카카오톡이나 네이버 톡톡 수준의 채팅 UI를 단일 PHP 파일 안에 구현하는 것. 외부 라이브러리 없이, CSS와 바닐라 JS만으로.
플로팅 채팅 버튼 — 첫인상이 전부
화면 오른쪽 하단에 둥근 버튼 하나가 떠 있다. 이 버튼이 챗봇의 입구다. 단순해 보이지만 신경 쓸 게 꽤 많았다.
#tg-chatbot-toggle {
position: fixed;
bottom: 80px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #3498db, #2980b9);
border: none;
cursor: pointer;
z-index: 9998;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
box-shadow: 0 4px 15px rgba(52, 152, 219, 0.4);
transition: transform 0.3s ease, box-shadow 0.3s ease;
line-height: 1;
}
#tg-chatbot-toggle:hover {
transform: scale(1.1);
box-shadow: 0 6px 20px rgba(52, 152, 219, 0.6);
}
bottom: 80px으로 설정한 이유가 있다. 이 사이트에는 다크모드 토글 버튼이 bottom: 20px에 있다. 처음에 챗봇 버튼도 bottom: 20px으로 했다가 두 버튼이 겹쳤다. z-index 싸움을 하는 대신 그냥 위치를 올렸다. 단순한 해결책이 가장 좋다.
펄스 애니메이션 — “나 여기 있어”
첫 방문자는 채팅 버튼이 있는지 모른다. 페이지 콘텐츠에 집중하고 있으니까. 그래서 펄스 애니메이션을 넣었다. 파란 빛이 버튼 주변에서 퍼져나가는 효과.
@keyframes tg-pulse {
0% { box-shadow: 0 0 0 0 rgba(52, 152, 219, 0.5); }
70% { box-shadow: 0 0 0 15px rgba(52, 152, 219, 0); }
100% { box-shadow: 0 0 0 0 rgba(52, 152, 219, 0); }
}
#tg-chatbot-toggle.tg-pulse {
animation: tg-pulse 2s infinite;
}
채팅 창을 한번 열면 tg-pulse 클래스를 제거해서 애니메이션을 끈다. 이미 존재를 알았으니 더 이상 시선을 끌 필요가 없다.
알림 배지 — 첫 방문자 유도
채팅 버튼에 빨간 배지를 달았다. 숫자 “1”이 표시된다. 마치 읽지 않은 메시지가 있는 것처럼. 사용자가 한번 방문하면 sessionStorage에 플래그를 저장하고 다음부터는 숨긴다.
<button id="tg-chatbot-toggle" class="tg-pulse" aria-label="챗봇 열기">
<span style="pointer-events:none;">💬</span>
<span class="tg-chat-badge" id="tg-chat-badge">1</span>
</button>
#tg-chatbot-toggle .tg-chat-badge {
position: absolute;
top: -2px;
right: -2px;
width: 20px;
height: 20px;
background: #e74c3c;
color: #fff;
font-size: 12px;
font-weight: 700;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
// 배지 자동 숨김
try {
if (sessionStorage.getItem('tg_chatbot_visited')) {
if (badge) badge.style.display = 'none';
} else {
sessionStorage.setItem('tg_chatbot_visited', '1');
}
} catch(e) {}
채팅 창 UI — 슬라이드업 애니메이션
버튼을 클릭하면 채팅 창이 아래에서 위로 슬라이드업 하면서 나타난다. 처음에는 display: none에서 display: flex로 바꾸기만 했는데, 그러면 애니메이션이 안 된다. CSS transition은 display 속성 변경에 반응하지 않으니까.
해결 방법: display를 먼저 바꾸고, 강제 리플로우를 트리거한 다음, 클래스를 추가한다.
#tg-chatbot-window {
position: fixed;
bottom: 150px;
right: 20px;
width: 370px;
height: 520px;
border-radius: 16px;
background: #f5f5f5;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
display: none;
flex-direction: column;
z-index: 9998;
overflow: hidden;
opacity: 0;
transform: translateY(20px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
#tg-chatbot-window.tg-open {
display: flex;
opacity: 1;
transform: translateY(0);
}
function openChat() {
chatOpen = true;
chatWindow.style.display = 'flex';
// 강제 리플로우 트리거 — 이 한 줄이 핵심
chatWindow.offsetHeight;
chatWindow.classList.add('tg-open');
toggleBtn.classList.remove('tg-pulse');
if (badge) badge.style.display = 'none';
if (!hasOpened) {
hasOpened = true;
var restored = restoreHistory();
if (!restored) {
showGreeting();
}
}
setTimeout(function() { inputEl.focus(); }, 300);
}
chatWindow.offsetHeight — 이 한 줄이 없으면 애니메이션이 안 된다. 브라우저에게 “지금 레이아웃을 계산해라”고 강제하는 것이다. 그래야 display 변경과 opacity/transform 변경이 별도 프레임에서 처리된다.
메시지 버블 — 봇 vs 사용자
카카오톡처럼 봇 메시지는 왼쪽, 사용자 메시지는 오른쪽에 표시된다. flexbox의 align-self로 구현한다.
.tg-chat-msg {
max-width: 85%;
padding: 10px 14px;
font-size: 13.5px;
line-height: 1.5;
word-break: break-word;
white-space: pre-line;
}
.tg-chat-msg.tg-bot {
background: #fff;
color: #333;
border-radius: 0 12px 12px 12px;
align-self: flex-start;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
.tg-chat-msg.tg-user {
background: #3498db;
color: #fff;
border-radius: 12px 0 12px 12px;
align-self: flex-end;
}
white-space: pre-line이 중요하다. knowledgeBase의 응답에 \n을 넣어두면 실제 줄바꿈으로 표시된다. word-break: break-word는 URL 같은 긴 문자열이 버블 밖으로 튀어나가는 걸 방지한다.
봇 메시지의 border-radius: 0 12px 12px 12px는 왼쪽 위 모서리만 각지게 만든다. 말풍선의 꼬리처럼 보이는 효과. 사용자 메시지는 반대로 오른쪽 위가 각지다.
타이핑 인디케이터 — 봇이 생각 중
봇이 응답을 준비하는 동안 점 세 개가 통통 튀는 애니메이션을 보여준다. 단순해 보이지만 사용자 경험에 큰 차이를 만든다. 아무 표시 없이 2초 기다리는 것과 “…” 애니메이션을 보면서 2초 기다리는 건 체감이 완전히 다르다.
.tg-typing {
display: flex;
gap: 4px;
padding: 10px 14px;
align-self: flex-start;
}
.tg-typing span {
width: 8px;
height: 8px;
background: #bbb;
border-radius: 50%;
animation: tg-bounce 1.4s infinite ease-in-out both;
}
.tg-typing span:nth-child(1) { animation-delay: -0.32s; }
.tg-typing span:nth-child(2) { animation-delay: -0.16s; }
@keyframes tg-bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
function showTyping() {
var el = document.createElement('div');
el.className = 'tg-typing';
el.id = 'tg-typing-indicator';
el.innerHTML = '<span></span><span></span><span></span>';
messagesEl.appendChild(el);
scrollToBottom();
return el;
}
function removeTyping() {
var t = document.getElementById('tg-typing-indicator');
if (t) t.remove();
}
세 개의 점에 animation-delay를 다르게 줘서 순차적으로 커졌다 작아지는 효과를 만든다. tg-bounce 키프레임에서 scale(0)과 scale(1)을 오가면서 통통 튀는 느낌을 준다.
빠른 액션 버튼과 링크
봇 응답 아래에 칩 스타일의 버튼을 배치한다. 사용자가 키보드 입력 없이 터치 한번으로 다음 질문을 할 수 있다. 모바일에서 특히 중요한 기능이다.
.tg-chat-buttons button {
background: #e8f4fd;
color: #2980b9;
border: 1px solid #bee0f5;
border-radius: 20px;
padding: 6px 14px;
font-size: 12.5px;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
white-space: nowrap;
}
.tg-chat-buttons button:hover {
background: #d0ebfa;
transform: translateY(-1px);
}
처음 채팅을 열면 인사 메시지와 함께 네 개의 빠른 액션 버튼이 나온다:
var GREETING = "안녕하세요! 무엇이든알아보자 AI 도우미입니다. 😊\n무엇을 도와드릴까요?";
var GREETING_BUTTONS = [
{text: "🔮 운세 보기", action: "운세"},
{text: "🧮 계산기", action: "계산기"},
{text: "🎮 게임", action: "게임"},
{text: "❓ 자주 묻는 질문", action: "도움"}
];
버튼을 클릭하면 handleUserInput(b.action)이 호출된다. “운세” 버튼을 누르면 사용자가 직접 “운세”라고 타이핑한 것과 똑같이 처리된다.
대화 히스토리 유지 — sessionStorage
사이트 내에서 페이지를 이동하면 챗봇이 리셋되는 문제가 있었다. 사주 페이지 링크를 안내받고 클릭해서 이동하면 대화 기록이 사라진다. 짜증나는 경험이다.
sessionStorage로 해결했다. 메시지가 추가될 때마다 전체 대화를 저장하고, 채팅 창을 열 때 복원한다.
var STORAGE_KEY = 'tg_chatbot_history';
function saveHistory() {
try {
var msgs = [];
var children = messagesEl.children;
for (var i = 0; i < children.length; i++) {
var el = children[i];
var bubble = el.querySelector('.tg-chat-msg');
if (!bubble) continue;
var type = bubble.classList.contains('tg-user') ? 'user' : 'bot';
var text = bubble.textContent;
msgs.push({type: type, text: text});
}
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(msgs));
} catch(e) {}
}
function restoreHistory() {
try {
var stored = sessionStorage.getItem(STORAGE_KEY);
if (!stored) return false;
var msgs = JSON.parse(stored);
if (!msgs || !msgs.length) return false;
msgs.forEach(function(m) {
var bubble = document.createElement('div');
bubble.className = 'tg-chat-msg ' + (m.type === 'user' ? 'tg-user' : 'tg-bot');
bubble.innerHTML = escapeHtml(m.text).replace(/\n/g, '<br>');
var wrapper = document.createElement('div');
wrapper.appendChild(bubble);
messagesEl.appendChild(wrapper);
});
scrollToBottom();
return true;
} catch(e) {
return false;
}
}
sessionStorage를 선택한 이유: localStorage와 달리 브라우저 탭을 닫으면 자동으로 삭제된다. 오래된 대화 기록이 남아서 혼란을 주는 일이 없다. 개인정보 측면에서도 낫다.
한 가지 트레이드오프가 있다. 복원된 메시지에는 버튼과 링크가 없다. 텍스트만 저장하기 때문. 버튼/링크까지 직렬화하면 코드가 복잡해지고, 어차피 사용자가 새로 질문하면 다시 나온다. 완벽보다 실용을 택했다.
모바일 전체화면 대응
모바일에서 370x520px 채팅 창은 너무 작다. 480px 미만 화면에서는 전체화면으로 전환한다.
@media (max-width: 480px) {
#tg-chatbot-window {
bottom: 0 !important;
right: 0 !important;
left: 0 !important;
width: 100% !important;
height: 100% !important;
border-radius: 0 !important;
}
}
!important를 쓴 이유: 인라인 스타일이나 다른 미디어 쿼리보다 확실하게 우선하기 위해서다. 모바일 전체화면에서 border-radius가 남아있으면 꼴사나우니까 0으로 밀어버린다.
다크모드 지원
사이트에 다크모드가 있으면 챗봇도 따라가야 한다. body.dark-mode 클래스를 기준으로 색상을 오버라이드한다.
body.dark-mode #tg-chatbot-window {
background: #1a1a2e;
}
body.dark-mode .tg-chat-msg.tg-bot {
background: #2d2d40;
color: #e0e0e0;
}
body.dark-mode #tg-chatbot-window .tg-chat-input-area {
background: #16213e;
border-top-color: #2d2d40;
}
body.dark-mode #tg-chatbot-window .tg-chat-input {
background: #2d2d40;
border-color: #3a3a50;
color: #e0e0e0;
}
body.dark-mode .tg-chat-buttons button {
background: #2d2d40;
color: #5dade2;
border-color: #3a3a50;
}
다크모드에서 주의할 점: 사용자 메시지 버블(파란색)은 라이트/다크 모드에서 모두 잘 보이므로 건드리지 않았다. 봇 메시지와 입력 영역만 어둡게 바꿔준다.
URL 자동 링크 변환
응답 텍스트에 /tarot/ 같은 경로가 포함되면 자동으로 클릭 가능한 링크로 변환한다. XSS를 방지하면서 링크만 살려야 해서 약간 까다로운 부분이다.
function addMessage(text, type, extras) {
// ...
var bubble = document.createElement('div');
bubble.className = 'tg-chat-msg ' + (type === 'bot' ? 'tg-bot' : 'tg-user');
var safeText = escapeHtml(text).replace(/\n/g, '<br>');
// URL 자동 링크 변환: https:// 형태
safeText = safeText.replace(/(?:https?:\/\/[^\s<]+)/g, function(url) {
return '<a href="' + url + '" style="color:#3498db;">' + url + '</a>';
});
// /path/ 형태
safeText = safeText.replace(/(?:^|[\s→])(\/[a-zA-Z0-9_-]+\/)/g, function(match, path) {
return match.replace(path, '<a href="' + path + '" style="color:#3498db;">' + path + '</a>');
});
bubble.innerHTML = safeText;
// ...
}
먼저 escapeHtml()로 모든 HTML 태그를 이스케이프한다. 그 다음에 URL 패턴만 찾아서 링크로 변환한다. 순서가 중요하다. 이스케이프 먼저, 링크 변환 나중에. 반대로 하면 XSS 취약점이 생긴다.
전체 HTML 구조
채팅 창의 전체 HTML은 이게 전부다. 군더더기 없다:
<button id="tg-chatbot-toggle" class="tg-pulse" aria-label="챗봇 열기">
<span style="pointer-events:none;">💬</span>
<span class="tg-chat-badge" id="tg-chat-badge">1</span>
</button>
<div id="tg-chatbot-window">
<div class="tg-chat-header">
<span class="tg-chat-header-title">무엇이든 물어보세요! 💬</span>
<button class="tg-chat-close" aria-label="닫기">×</button>
</div>
<div class="tg-chat-messages" id="tg-chat-messages"></div>
<div class="tg-chat-input-area">
<input type="text" class="tg-chat-input" id="tg-chat-input"
placeholder="메시지를 입력하세요..." autocomplete="off" />
<button class="tg-chat-send" id="tg-chat-send" aria-label="전송">
<svg viewBox="0 0 24 24"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/></svg>
</button>
</div>
</div>
세 영역으로 구성된다: 헤더(타이틀 + 닫기 버튼), 메시지 영역(스크롤 가능), 입력 영역(인풋 + 전송 버튼). 이 구조를 wp_footer 액션에 PHP로 직접 출력한다.
add_action('wp_footer', 'nalkkul_chatbot_render', 99);
function nalkkul_chatbot_render() {
if (is_admin()) return;
$nonce = wp_create_nonce('nalkkul_chatbot_nonce');
$ajax_url = admin_url('admin-ajax.php');
$ai_engine = defined('NALKKUL_CHATBOT_AI') ? NALKKUL_CHATBOT_AI : '';
// ... HTML/CSS/JS 출력
}
priority 99를 준 이유: 다른 플러그인의 footer 출력보다 뒤에 나오도록. jQuery나 다른 스크립트에 의존하지 않으니 순서가 중요하지는 않지만, 혹시 모를 충돌을 피하기 위해서다.
이 시리즈의 다른 글
- (1) 설계와 규칙 기반 엔진
- (2) 플로팅 UI와 대화 인터페이스 — 현재 글
- (3) RAG 검색 연동으로 블로그 글 찾기
- (4) Ollama 로컬 AI 연동
- (5) 대화 로그, 문의 접수, 운영 팁