개주 훈련일지/🏋️ 전집중 호흡 훈련

AI 추천 서비스 준비: 챗봇 UI(위젯) 만들기

lshfood2 2026. 2. 13. 16:29

[ 챗봇 UI(위젯) 만들기 ]

프로젝트에 AI 기능을 도입하기로 했다.
목표는 단순히 질문에 답하는 챗봇이 아니라,

사용자가 대화로 남기는 취향과 니즈를 파악해서

추천 애니를 제시하는 서비스다.

 

이번에 내 프론트라서 AI 모델이나 추천 로직보다

먼저 사용자가 대화를 시작하고,

계속 이어갈 수 있는 UI를 설계해야 했다.


그래서 어떤 페이지에 있든 항상

접근 가능한 우하단 챗봇 아이콘을 만들고,

클릭하면 카톡 미니창처럼

작은 채팅 패널이 열리는 형태로 구현했다.

 

이번 포스팅은 백엔드 연동은 제외하고,

UI(위젯) 구현까지를 정리한다.

 

완성한 위젯 미리보기

왼쪽 : 홈페이지 우측 하단에 위치한 위젯 / 오른쪽 : 위젯 실행 시 열리는 채팅 패널


먼저 정해야 하는 UX

AI 챗봇 UI는 한 번에 전체 화면으로

띄우면 진입 장벽이 생긴다.

 

반대로 너무 작으면 대화가 불편하다.

그래서 아래 방식으로 정리했다.


1) 항상 보이는 작은 진입점(FAB 버튼)

  • 우하단에 떠 있는 아이콘
  • 어떤 페이지에서도 동일하게 노출

2)필요할 때만 열리는 채팅 패널

  • 평소에는 숨김
  • 버튼 클릭 시 부드럽게 등장
  • 닫기 버튼/ESC/바깥 클릭으로 닫힘

즉, 항상 떠 있는 버튼과

열렸을 때만 보이는 패널을 분리하고,

열림/닫힘 상태를 토글로 제어하는 구조가 핵심이다.


폴더 구조 설정하기

위젯이 모든 페이지에서

동일하게 보여야 하기 때문에,

페이지별로 붙이는 방식은 피해야 한다.


그래서 공통 레이아웃(header)에

한 번만 include하도록 구성했다.

  • /WEB-INF/common/chatai.jsp
    위젯의 HTML(아이콘 버튼 + 채팅 패널 마크업)
  • /css/chatai.css
    위치/크기/색상/레이아웃 등 스타일 전용
  • /js/chatai.js
    열기/닫기 토글, 접근성 처리, 바깥 클릭 닫기 등 동작 전용
  • /img/chatai_icon.jpg
    우하단 아이콘 이미지

이렇게 분리해두면

나중에 AI 기능이 바뀌어도

UI 레이어는 그대로 유지할 수 있다.


적용 순서

  1. chatai.jsp(마크업) 만들기
  2. chatai.css(위치/크기/톤) 만들기
  3. chatai.js(열기/닫기 토글) 만들기
  4. header.jsp에 한 번만 include + 리소스 로드

1. 공통 레이아웃에서 한 번만 로드하기

목표는 전 페이지 공통 노출이므로,

header.jsp에 아래 로드 코드를 넣는다.

<%-- 1) 위젯 전용 CSS: 위젯만의 스타일이므로 별도 파일로 분리 --%>
<link rel='stylesheet' href='${ctx}/css/chatai.css' />

<%-- 2) 위젯 전용 JS: 토글/닫기 등 동작 담당. defer로 DOM 로드 후 실행 --%>
<script src='${ctx}/js/chatai.js' defer></script>

<%-- 3) 위젯 JSP: 버튼 + 패널 마크업이 들어있다(한 번만 include) --%>
<jsp:include page='/WEB-INF/common/chatai.jsp' />

여기서 중요한 건 중복 로드 방지다.
각 페이지에서 따로 로드하기 시작하면

같은 이벤트가 여러 번 붙어서

이상한 동작이 나오기 쉽다.


2. chatai.jsp (버튼 + 패널은 같은 파일 안에 둔다)

위젯은 화면에 두 요소가 존재하지만,

둘은 항상 함께 움직이므로

하나의 JSP로 관리하는 게 편하다.

<div
  class='chatai'
  id='chatai'
  data-endpoint='${ctx}/api/chat'
  aria-live='polite'
>
  <%-- =========================================================
       [A] FAB 버튼 (우하단 떠있는 아이콘)
       - 항상 보이므로 fixed 위치는 CSS가 담당한다
       - 클릭하면 JS가 루트(.chatai)에 is-open 클래스를 붙여 패널을 연다
       - 텍스트가 없는 버튼이므로 aria-label로 의미를 부여한다
     ========================================================= --%>
  <button type='button' class='chatai-fab' id='chataiFab' aria-label='AI 채팅 열기'>
    <%-- 이미지 아이콘: CSS에서 원형 크롭(cover) 처리 --%>
    <img src='${ctx}/img/chatai_icon.jpg' alt='AI' class='chatai-fab-img' />
  </button>

  <%-- =========================================================
       [B] 채팅 패널 (기본 숨김 → 열림 상태일 때만 표시)
       - CSS: 기본 opacity 0 + pointer-events none
       - JS: 열릴 때 is-open 클래스 추가 + aria-hidden false
     ========================================================= --%>
  <section class='chatai-panel' id='chataiPanel' aria-hidden='true'>

    <%-- 상단 헤더: 제목 + 닫기 버튼 --%>
    <header class='chatai-panel-header'>
      <div class='chatai-title'>AI 챗봇</div>

      <%-- 닫기 버튼: JS가 클릭 이벤트로 close() 실행 --%>
      <button type='button' class='chatai-close' id='chataiClose' aria-label='닫기'>×</button>
    </header>

    <%-- 메시지 리스트 영역: 스크롤되는 공간 --%>
    <div class='chatai-messages' id='chataiMessages'>
      <%-- 초기 안내 메시지(더미). 실제로는 JS가 append해서 쌓는다 --%>
      <div class='chatai-msg chatai-msg-bot'>
        <div class='chatai-bubble'>안녕하세요. 무엇을 도와드릴까요?</div>
      </div>
    </div>

    <%-- 입력 영역: form으로 감싸면 Enter 전송 처리가 쉬워진다 --%>
    <form class='chatai-inputbar' id='chataiForm'>
      <input
        type='text'
        class='chatai-input'
        id='chataiInput'
        placeholder='메시지를 입력하세요'
        autocomplete='off'
      />
      <button type='submit' class='chatai-send'>전송</button>
    </form>
  </section>
</div>

이 구조로 만든 이유는 간단하다.

  • '열기 버튼'과 '패널'이 항상 한 세트로 움직임
  • 전 페이지 공통 include로 한 번에 붙임
  • JS는 id만 잡아서 토글하면 끝

3. chatai.css (위젯 위치/크기/톤을 변수로 통제)

이 위젯은 계속 미세 조정하게 된다.
그래서 숫자를 여기저기 흩뿌리는 대신,

변수로 모아서 '조절 지점'을 고정했다.

 

디자인 톤은 기존 홈페이지에 맞춰

네이비 컬러를 베이스로 작업했다.

 

채팅 배경은 이용하던 웹 화면과 분리시키기 위해
네이비와 대비되는 회색 배경으로 설정하였다.

.chatai{
  /* =========================================================
     (1) 가장 자주 만지는 값들은 변수로 모아둔다
     - 아이콘 크기: 72px (너가 선택한 최종 사이즈)
     - 아이콘 위치: right/bottom 값이 커질수록 화면 안쪽으로 들어온다
     ========================================================= */
  --chatai-fab-size: 72px;
  --chatai-fab-right: 34px;
  --chatai-fab-bottom: 44px;

  /* 패널 크기: 카톡 미니창보다 살짝 크게 */
  --chatai-panel-w: 400px;
  --chatai-panel-h: 600px;

  /* 패널은 FAB 위로 뜨게 계산 */
  --chatai-panel-bottom: calc(var(--chatai-fab-bottom) + var(--chatai-fab-size) + 16px);

  /* 패널 톤(네이비) */
  --chatai-bg: rgba(7,7,32,0.92);
  --chatai-border: rgba(255,255,255,0.14);
  --chatai-radius: 18px;

  /* 레이어 */
  --chatai-z: 9000;
}

/* 위젯 루트는 fixed로 독립 */
.chatai{
  position: fixed;
  right: 0;
  bottom: 0;
  z-index: var(--chatai-z);
  pointer-events: none; /* 루트는 클릭 막고, 실제 버튼/패널만 클릭 허용 */
}

/* FAB: 우하단 동그란 아이콘 */
.chatai .chatai-fab{
  position: fixed;
  right: var(--chatai-fab-right);
  bottom: var(--chatai-fab-bottom);
  width: var(--chatai-fab-size);
  height: var(--chatai-fab-size);
  border-radius: 50%;
  overflow: hidden;

  background: transparent;
  border: 1px solid rgba(255,255,255,0.22);
  box-shadow: 0 14px 30px rgba(0,0,0,0.35);

  cursor: pointer;
  pointer-events: auto;
}

/* 이미지 원형 크롭 핵심 */
.chatai .chatai-fab-img{
  width: 100%;
  height: 100%;
  display: block;
  border-radius: 50%;
  object-fit: cover;
  object-position: center;
}

/* 패널: 기본 숨김 */
.chatai .chatai-panel{
  position: fixed;
  right: 24px;
  bottom: var(--chatai-panel-bottom);

  width: var(--chatai-panel-w);
  height: var(--chatai-panel-h);
  border-radius: var(--chatai-radius);

  background: var(--chatai-bg);
  border: 1px solid var(--chatai-border);
  overflow: hidden;

  display: flex;
  flex-direction: column;

  /* 닫힘 상태(숨김) */
  opacity: 0;
  transform: translateY(10px) scale(0.98);
  pointer-events: none;
  visibility: hidden;
  transition: opacity .2s ease, transform .2s ease, visibility .2s ease;
}

/* 열림 상태: 루트에 is-open이 붙으면 패널이 보인다 */
.chatai.is-open .chatai-panel{
  opacity: 1;
  transform: translateY(0) scale(1);
  pointer-events: auto;
  visibility: visible;
}

/* 메시지 영역: 배경은 더 회색, 말풍선 대비를 위해 밝게 유지 */
.chatai .chatai-messages{
  flex: 1;
  padding: 14px;
  overflow: auto;
  background: #e9edf3; /* 여기 숫자만 바꾸면 톤 변경 가능 */
}

/* 말풍선 공통: 네이비 */
.chatai .chatai-bubble{
  max-width: 78%;
  padding: 10px 12px;
  border-radius: 14px;
  font-size: 13px;
  line-height: 1.5;

  border: 1px solid rgba(255,255,255,0.14);
  background: rgba(11,12,42,0.92);
  color: rgba(255,255,255,0.92);
}

/* AI(왼쪽) 말풍선: 사용자보다 살짝 밝게(너가 최종 조정한 포인트) */
.chatai .chatai-msg-bot .chatai-bubble{
  background: rgba(22,24,58,0.86);
}

4) chatai.js (열기/닫기 상태를 토글로 관리)

여기서 목표는 복잡한 상태 관리가 아니라,

UI 토글을 안정적으로 만드는 것이다.


그래서 root에 is-open 클래스를

붙였다 떼는 방식으로 통일한다.

document.addEventListener('DOMContentLoaded', () => {
  // =========================================================
  // [1] DOM 캐싱: id는 마크업과 JS의 계약이다(이름 바뀌면 동작이 깨진다)
  // =========================================================
  const root = document.getElementById('chatai');
  const fab = document.getElementById('chataiFab');
  const panel = document.getElementById('chataiPanel');
  const closeBtn = document.getElementById('chataiClose');

  // =========================================================
  // [2] open/close 함수: 열림 상태를 한 곳에서만 바꾼다
  // - CSS는 .is-open 유무로 보이기/숨기기를 처리한다
  // - aria-hidden까지 같이 바꿔 접근성도 맞춘다
  // =========================================================
  const open = () => {
    root.classList.add('is-open');
    panel.setAttribute('aria-hidden', 'false');
  };

  const close = () => {
    root.classList.remove('is-open');
    panel.setAttribute('aria-hidden', 'true');
  };

  const toggle = () => {
    if (root.classList.contains('is-open')) close();
    else open();
  };

  // =========================================================
  // [3] 이벤트 바인딩
  // - FAB 클릭: 열기/닫기 토글
  // - X 버튼: 닫기
  // =========================================================
  fab.addEventListener('click', toggle);
  closeBtn.addEventListener('click', close);

  // =========================================================
  // [4] UX 보완(선택)
  // - ESC로 닫기: 작은 창 UX에서 거의 필수 느낌
  // - 바깥 클릭 닫기: 패널 외부 클릭 시 닫혀서 사용감이 좋아진다
  // =========================================================
  document.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') close();
  });

  document.addEventListener('click', (e) => {
    if (!root.classList.contains('is-open')) return;
    if (panel.contains(e.target) || fab.contains(e.target)) return;
    close();
  });
});

튜닝 포인트 모음

아래 항목들은 위젯 특성상 작업하면서

자주 바뀌는 값들이라 한 곳에서 빠르게

조정할 수 있도록 모아둔 튜닝 포인트다.

  • 아이콘 크기
    --chatai-fab-size
  • 아이콘 위치(안쪽으로 당기기)
    --chatai-fab-right / --chatai-fab-bottom
    값이 커질수록 구석에서 멀어진다
  • 패널 크기
    --chatai-panel-w / --chatai-panel-h
  • 배경 회색 농도
    .chatai-messages background
  • AI 말풍선 밝기
    rgba(22,24,58,0.86)에서 0.80~0.94 미세 조절

[ 마무리 ]

이번 작업은 AI 추천 기능 자체를 붙이기 전에,

사용자가 언제든 대화를 시작할 수 있는

‘진입 UI’를 먼저 안정적으로 만드는 게 목표였다.


우하단 FAB 버튼과 채팅 패널을 분리하고,

공통 레이아웃에 한 번만 include 하는 구조로

잡아두니 페이지가 늘어나도 위젯은 그대로 유지된다.

 

또한 크기, 위치, 톤을 CSS 변수로 통제했기 때문에

디자인 튜닝이 필요해도 수정 지점이 명확하다.


이제 UI 골격은 완성됐으니,

다음 단계에서는 실제로

서버 endpoint와 연동해 메시지를 전송/수신하고,

응답 생성 중 로딩 말풍선, 에러 안내 같은

상태 처리까지 붙여서 실제 서비스처럼

동작하는 챗봇으로 확장할 예정이다.