블로그워드프레스에 AI 고객지원 챗봇 만들기 (2) — 플로팅 UI와 대화 인터페이스

워드프레스에 AI 고객지원 챗봇 만들기 (2) — 플로팅 UI와 대화 인터페이스

챗봇의 두뇌가 준비됐으니 이제 몸을 만들 차례다. 아무리 응답이 좋아도 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="닫기">&times;</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나 다른 스크립트에 의존하지 않으니 순서가 중요하지는 않지만, 혹시 모를 충돌을 피하기 위해서다.


이 시리즈의 다른 글

이 글이 도움이 되셨다면 공유해주세요!

댓글 남기기

무엇이든 물어보세요! 💬