AI 챗봇 연동하기
플로팅 버튼과 채팅 팝업을 하나의 컴포넌트로 만들고, 백엔드 서버와 메시지를 주고받는다
코드 블록의 Try it Yourself 버튼으로 직접 실행할 수 있다.
구문
Tip: Gemini CLI로 구현하기 — AI 챗봇 컴포넌트
- 프롬프트:
gemini "src/components/Chatbot.jsx를 작성해줘. 화면 우하단에 고정된 플로팅 버튼을 클릭하면 채팅창이 열리는 컴포넌트야. useState로 열림/닫힘, 메시지 목록, 입력값, 로딩 상태를 관리하고, useRef로 새 메시지가 오면 자동 스크롤해줘. 메시지 전송 시 [백엔드 URL]/chat 엔드포인트에 fetch로 POST 요청을 보내줘. Tailwind CSS를 사용해줘."
- 사용 가이드:
[백엔드 URL]을 배포된 서버 주소로 바꾼다(예:https://my-api.onrender.com). 로컬 테스트 시에는http://localhost:8000으로 바꾼다.
1. 챗봇 연동 개요
이번 편에서는 GOFLEX 프로젝트에 AI 챗봇을 연동한다. 플로팅 버튼과 채팅창이 하나의 Chatbot.jsx 컴포넌트에 들어 있고, App.jsx에서 바로 사용한다.
| 순서 | 만들 기능 | 핵심 개념 |
|---|---|---|
| 1단계 | Chatbot.jsx — import + 상태 | useState, useRef |
| 2단계 | 자동 스크롤 | useRef, scrollIntoView |
| 3단계 | 메시지 전송 함수 | fetch, try/catch/finally |
| 4단계 | JSX — 플로팅 버튼 + 채팅창 | Tailwind 말풍선, 조건부 렌더링 |
| 5단계 | App.jsx에 연결 | import, <Chatbot /> |
Info: 챗봇 백엔드 서버는 별도 프로젝트로 Python + FastAPI + HuggingFace로 구축하여 Render에 배포한 상태여야 한다.
2. 1단계 — import + 상태 변수
src/components/ 폴더 안에 Chatbot.jsx 파일을 새로 만들고 아래 코드를 입력한다. 이 파일 하나에 플로팅 버튼(화면 우하단에 떠 있는 동그란 버튼)과 채팅창이 모두 들어 있다.
import { useState, useRef, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faComment, faXmark } from "@fortawesome/free-solid-svg-icons";
import { Button } from "./UI.jsx";
const BACKEND = "https://cbot-rfcl.onrender.com/chat";
export default function Chatbot() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState([
{ role: "bot", text: "안녕하세요! 영화에 대해 무엇이든 물어보세요" },
]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const bottomRef = useRef(null);
| 줄 | 설명 |
|---|---|
| 1 | useRef(유즈레프) — DOM 요소를 직접 참조하는 훅이다. 여기서는 메시지 목록의 맨 아래를 가리키는 데 사용한다. |
| 3 | faComment(말풍선), faXmark(X) 아이콘이다. |
| 5 | 챗봇 백엔드 서버 주소이다. 본인의 Render 배포 주소로 변경한다. |
| 7 | export default — 이 컴포넌트는 파일의 대표 내보내기이다. App.jsx에서 import Chatbot으로 가져온다. |
| 8 | open — 채팅창의 열림/닫힘 상태이다. |
| 9-11 | messages — 대화 목록이다. 초기값에 봇의 인사 메시지를 넣어두면 채팅창을 열었을 때 바로 안내 메시지가 보인다. |
| 14 | bottomRef — 메시지 목록의 맨 아래 빈 div를 가리키는 참조이다. 새 메시지가 추가되면 이 위치로 자동 스크롤한다. |
useRef란?
useRef는 렌더링과 무관하게 값을 유지하거나, DOM 요소를 직접 참조할 때 사용하는 훅이다.
const bottomRef = useRef(null); // 참조 생성
// ...
<div ref={bottomRef} /> // DOM 요소에 연결
// ...
bottomRef.current // 연결된 실제 DOM 요소
useState와 달리 값이 바뀌어도 리렌더링이 발생하지 않는다.
3. 2단계 — 자동 스크롤
같은 Chatbot.jsx 파일에서, const bottomRef = useRef(null) 줄 바로 아래에 이어서 작성한다. 채팅 앱을 쓸 때 새 메시지가 오면 자동으로 스크롤이 내려가는 것을 본 적이 있을 것이다. 그 기능을 만드는 코드이다.
useEffect(() => {
if (open) bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, open]);
| 줄 | 설명 |
|---|---|
| 2 | scrollIntoView(스크롤인투뷰) — 해당 DOM 요소가 화면에 보이도록 자동 스크롤한다. \{ behavior: "smooth" \}는 부드럽게 스크롤하라는 옵션이다. |
| 2 | bottomRef.current?. — 옵셔널 체이닝이다. bottomRef.current가 아직 null이면 에러 없이 넘어간다. |
| 3 | [messages, open] — 메시지가 추가되거나 채팅창이 열릴 때마다 실행된다. |
새 메시지가 도착하면 채팅창이 자동으로 맨 아래로 스크롤되므로, 사용자가 수동으로 내릴 필요가 없다.
4. 3단계 — 메시지 전송 함수
같은 Chatbot.jsx 파일에서, useEffect 블록 바로 아래에 이어서 두 개의 함수를 작성한다. sendMessage는 메시지를 서버에 보내고 응답을 받는 함수이고, handleKeyDown은 Enter 키를 누르면 전송하는 함수이다.
① sendMessage
async function sendMessage() {
const text = input.trim();
if (!text || loading) return;
setMessages((prev) => [...prev, { role: "user", text }]);
setInput("");
setLoading(true);
try {
const res = await fetch(BACKEND, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
const data = await res.json();
setMessages((prev) => [...prev, { role: "bot", text: data.reply }]);
} catch {
setMessages((prev) => [
...prev,
{ role: "bot", text: "서버 연결에 실패했습니다. 다시 시도해주세요." },
]);
} finally {
setLoading(false);
}
}
| 줄 | 설명 |
|---|---|
| 3 | `!text |
| 5 | 스프레드 연산자(...prev)로 기존 메시지에 사용자 메시지를 추가한다. |
| 9-16 | fetch(패치)는 브라우저 내장 함수로 HTTP 요청을 보낸다. method: "POST"로 서버에 데이터를 전송한다. res.json()은 서버 응답(텍스트)을 JavaScript 객체로 변환하는 메서드이다. 변환된 객체에서 data.reply로 봇 답변을 꺼내 메시지 목록에 추가한다. |
| 17-21 | catch — 네트워크 에러 등 실패 시 에러 메시지를 봇 응답으로 표시한다. 앱이 멈추지 않는다. |
| 22-24 | finally(파이널리) — 성공이든 실패든 반드시 실행된다. 로딩 상태를 끄는 데 사용한다. |
② handleKeyDown
function handleKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
| 줄 | 설명 |
|---|---|
| 2 | ===(엄격한 동등 연산자)는 값과 타입이 모두 같을 때만 true를 반환한다. e.key === "Enter"는 눌린 키가 정확히 문자열 "Enter"인지 확인한다. Shift+Enter는 줄바꿈으로 허용하기 위해 !e.shiftKey로 Shift 키가 눌리지 않았는지도 함께 확인한다. |
| 3 | e.preventDefault()(프리벤트디폴트) — Enter 키의 기본 동작(폼 제출 등)을 막는다. |
try / catch / finally 패턴
try {
// 성공할 수도 있는 코드
} catch {
// 실패했을 때 실행되는 코드
} finally {
// 성공이든 실패든 반드시 실행되는 코드
}
| 블록 | 실행 조건 | 용도 |
|---|---|---|
try | 항상 실행 시도 | API 호출, 데이터 처리 |
catch | try에서 에러 발생 시 | 에러 메시지 표시, 대체 동작 |
finally | 항상 (성공/실패 무관) | 로딩 상태 초기화, 정리 작업 |
이전 교안(5편)에서는 try/catch만 사용했다. finally를 추가하면 setLoading(false)를 try와 catch 양쪽에 중복 작성할 필요가 없어진다.
5. 4단계 — JSX 렌더링
같은 Chatbot.jsx 파일에서, handleKeyDown 함수 바로 아래에 return문을 작성한다. 코드가 길기 때문에 세 부분으로 나누어 설명한다. 탭을 눌러 각 부분을 확인하면서 순서대로 이어 붙인다.
① 플로팅 버튼
return (
<>
<Button
variant="primary"
onClick={() => setOpen((v) => !v)}
className="fixed bottom-8 right-8 w-14 h-14 rounded-full text-2xl shadow-lg z-50 flex items-center justify-center"
title="AI 챗봇"
>
{open ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faComment} />}
</Button>
| 줄 | 설명 |
|---|---|
| 1 | 4편에서 만든 Button 컴포넌트이다. variant="primary"로 노란색 스타일을 적용한다. |
| 3 | setOpen((v) => !v) — 함수형 업데이트이다. 이전 값 v를 받아서 반전시킨다. setOpen(!open)과 같은 결과이지만 더 안전하다. |
| 4 | fixed bottom-8 right-8 — 화면 우하단에 고정 배치한다. rounded-full — 완전한 원형이다. shadow-lg — 큰 그림자 효과이다. |
| 5 | title 속성 — 버튼에 마우스를 올리면 "AI 챗봇" 툴팁이 표시된다. |
| 7 | 삼항 연산자로 열림 시 X 아이콘, 닫힘 시 말풍선 아이콘이다. |
② 채팅창 헤더 + 메시지 목록
{open && (
<div className="fixed bottom-24 right-8 w-85 max-h-1/2 bg-gray-950 border border-gray-600 rounded-xl flex flex-col shadow-2xl z-40">
<div className="py-3 px-4 bg-yellow-500 rounded-t-xl font-bold text-sm text-gray-700">Goflix AI 챗봇</div>
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2 min-h-50 max-h-85">
{messages.map((m, i) => (
<div
key={i}
className={`py-2 px-3 max-w-4/5 text-sm leading-snug whitespace-pre-wrap break-words text-gray-700
${m.role === "user" ? "self-end bg-yellow-500 rounded-tl-xl rounded-tr-xl rounded-bl-xl" : "self-start bg-gray-100 rounded-tl-xl rounded-tr-xl rounded-br-xl"}`}
>
{m.text}
</div>
))}
{loading && <div className="self-start bg-gray-100 text-gray-400 rounded-tl-xl rounded-tr-xl rounded-br-xl py-2 px-3 text-sm">...</div>}
<div ref={bottomRef} />
</div>
| 줄 | 설명 |
|---|---|
| 2 | fixed bottom-24 right-8 — 플로팅 버튼 바로 위에 채팅창을 배치한다. w-85는 너비 약 340px이다. max-h-1/2는 화면 높이의 절반까지만 차지한다. |
| 2 | bg-gray-950 — Tailwind의 가장 어두운 회색이다. rounded-xl — 모서리를 둥글게 12px 처리한다. |
| 5 | overflow-y-auto — 내용이 넘치면 세로 스크롤이 생긴다. min-h-50은 최소 높이, max-h-85는 최대 높이이다. |
| 9 | whitespace-pre-wrap — 줄바꿈(\n)을 유지하면서 긴 텍스트는 자동 줄바꿈한다. break-words — 긴 단어가 넘치면 강제로 줄바꿈한다. |
| 10 | self-end(사용자 메시지, 오른쪽) / self-start(봇 메시지, 왼쪽)로 정렬한다. 각각 다른 방향의 모서리를 둥글게 처리하여 말풍선 느낌을 만든다. |
| 16 | ref=\{bottomRef\} — 이 빈 div가 메시지 목록의 맨 아래이다. 2단계의 scrollIntoView가 이 위치로 스크롤한다. |
③ 입력창
<div className="flex border-t border-[#333] p-2 gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="메시지 입력..."
disabled={loading}
className="flex-1 bg-[#1e1e1e] border border-[#444] rounded-lg py-2 px-3 text-white caret-white text-sm outline-none placeholder:text-zinc-300"
/>
<Button
variant="primary"
onClick={sendMessage}
disabled={loading || !input.trim()}
className="rounded-lg py-2 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
전송
</Button>
</div>
</div>
)}
</>
);
}
| 줄 | 설명 |
|---|---|
| 1 | border-[#333] — Tailwind의 임의값(Arbitrary Value) 문법이다. 미리 정의되지 않은 색상을 대괄호로 직접 지정한다. |
| 7 | disabled=\{loading\} — 로딩 중이면 입력창을 비활성화한다. |
| 8 | outline-none — 포커스 시 기본 파란색 외곽선을 제거한다. placeholder:text-zinc-300 — 플레이스홀더 텍스트 색상을 지정하는 Tailwind 수식어이다. |
| 11 | 4편에서 만든 Button 컴포넌트를 사용한다. variant="primary"로 노란색 스타일을 적용한다. |
| 13 | disabled=\{loading || !input.trim()\} — 로딩 중이거나 빈 입력이면 전송 버튼을 비활성화한다. disabled:opacity-50 disabled:cursor-not-allowed — 비활성화 시 투명도와 커서를 변경한다. |
6. 5단계 — App.jsx에 연결
src/App.jsx를 수정한다. 두 곳을 변경한다.
6.1. import 추가
기존 import 영역에 한 줄을 추가한다.
import Chatbot from "./components/Chatbot.jsx";
Tip:
Chatbot은export default로 내보냈으므로 중괄호\{ \}없이 가져온다. 다른 컴포넌트(Header,Footer등)는export function(named export)이라서 중괄호가 필요하다.
6.2. return에 Chatbot 추가
<Footer /> 아래에 한 줄을 추가한다.
return (
<>
<Header />
<Outlet context={ctx} />
<Footer />
<Chatbot />
</>
);
| 줄 | 설명 |
|---|---|
| 6 | <Chatbot /> — 플로팅 버튼과 채팅창이 모두 이 컴포넌트 안에 있으므로, 한 줄만 추가하면 된다. |
7. 동작 확인
| 확인 항목 | 기대 결과 |
|---|---|
| 화면 우하단 | 노란색 둥근 버튼에 말풍선 아이콘 표시 |
| 버튼 클릭 | 버튼 위에 채팅창 팝업. 아이콘이 X로 전환 |
| 채팅창 열림 | "안녕하세요! 영화에 대해 무엇이든 물어보세요" 메시지 표시 |
| 메시지 전송 | 오른쪽 노란 말풍선 → "..." 로딩 → 왼쪽 흰 말풍선 |
| 서버 에러 시 | "서버 연결에 실패했습니다" 에러 메시지 표시 |
| 메시지 추가 | 자동으로 맨 아래로 스크롤 |
| X 버튼 클릭 | 채팅창 닫힘 |
Info: 무료 호스팅(Render)은 첫 응답이 30초~1분 걸릴 수 있다. 서버가 슬립 상태에서 깨어나는 시간이다.
8. 모노레포 구조와 GitHub 올리기
8.1. 모노레포란?
이 프로젝트는 하나의 GitHub 저장소 안에 프론트엔드(frontend/)와 백엔드(backend/)가 함께 들어 있다. 이런 구조를 모노레포(Monorepo, 모노리포)라고 한다. 책 한 권에 소설과 삽화가 함께 들어 있는 것처럼, 하나의 저장소에서 두 프로젝트를 관리한다.
movie-2026/
├── frontend/ ← React 프론트엔드
│ ├── src/
│ ├── public/
│ ├── vite.config.js
│ ├── package.json
│ └── .env ← TMDB API 키 (GitHub에 올리면 안 됨)
└── backend/ ← Python 챗봇 서버
├── main.py
├── requirements.txt
└── .env ← HuggingFace API 키 (GitHub에 올리면 안 됨)
8.2. .gitignore 설정
프로젝트 최상위 폴더(movie-2026/)에 .gitignore 파일을 만들고 아래 내용을 입력한다. 비밀 정보와 불필요한 파일이 GitHub에 올라가지 않도록 막는 역할이다.
frontend/.env
frontend/node_modules/
frontend/dist/
backend/.env
backend/.venv/
backend/__pycache__/
| 줄 | 설명 |
|---|---|
| 1 | 프론트엔드의 TMDB API 키 파일을 제외한다. |
| 2 | 설치된 패키지 폴더를 제외한다. 용량이 매우 크고, npm install로 다시 설치할 수 있다. |
| 3 | 빌드 결과물을 제외한다. Render가 배포 시 자동으로 빌드한다. |
| 4-6 | 백엔드의 비밀 키, 가상환경, 캐시를 제외한다. |
8.3. GitHub에 코드 올리기
- https://github.com 에서 **New repository(뉴 리포지토리)**를 클릭하여 새 저장소를 만든다.
- 프로젝트 최상위 폴더(movie-2026/)에서 터미널을 열고, 아래 명령어를 한 줄씩 실행한다.
git init
git add .
git commit -m "first commit"
git remote add origin https://github.com/내아이디/movie-2026.git
git push -u origin main
| 줄 | 설명 |
|---|---|
| 1 | 이 폴더를 Git 저장소로 초기화한다. |
| 2 | 모든 파일을 스테이지(stage, 올릴 준비)에 올린다. |
| 3 | "first commit"이라는 메시지와 함께 기록을 남긴다. |
| 4 | GitHub 원격 저장소와 연결한다. 내아이디를 본인의 GitHub 아이디로 바꾼다. |
| 5 | 코드를 GitHub에 올린다. |
9. Render에 배포하기
Render(렌더)는 GitHub 저장소를 연결하면 코드를 자동으로 배포해 주는 무료 호스팅 서비스이다. 모노레포에서는 같은 저장소를 두 번 연결하되, 각각 다른 Root Directory(루트 디렉토리, 시작 폴더)를 지정한다.
9.1. 백엔드 배포 (Python 서버)
- https://render.com 에 접속하여 GitHub 계정으로 로그인한다.
- New → Web Service를 클릭한다.
- GitHub 저장소(
movie-2026)를 연결하고 아래와 같이 설정한다.
| 항목 | 입력값 |
|---|---|
| Name | goflex-backend (원하는 이름) |
| Root Directory | backend |
| Runtime | Python 3 |
| Build Command | pip install -r requirements.txt |
| Start Command | uvicorn main:app --host 0.0.0.0 --port 10000 |
- Environment(환경변수) 탭에서 아래 값을 추가한다.
| Key | Value |
|---|---|
HF_TOKEN | HuggingFace에서 발급받은 API 키 |
- Create Web Service를 클릭하면 배포가 시작된다.
- 배포 완료 후
https://goflex-backend-xxxx.onrender.com형태의 주소가 생성된다. 이 주소를 메모해 둔다.
Warning: Start Command를 반드시
uvicorn main:app --host 0.0.0.0 --port 10000으로 직접 입력해야 한다. Render는 Python 프로젝트를 감지하면 자동으로gunicorn을 사용하려고 하는데, FastAPI는uvicorn을 사용하므로 에러가 발생한다.
9.2. Chatbot.jsx의 BACKEND 주소 변경
배포된 백엔드 주소를 Chatbot.jsx에 반영한다. src/components/Chatbot.jsx 파일을 열고 5번 줄을 수정한다.
const BACKEND = "https://goflex-backend-xxxx.onrender.com/chat";
xxxx 부분을 9.1에서 생성된 실제 주소로 바꾼다. 수정 후 GitHub에 다시 push 한다.
git add .
git commit -m "update backend url"
git push
9.3. 프론트엔드 배포 (React)
- Render에서 New → Static Site(스태틱 사이트)를 클릭한다.
- 같은 GitHub 저장소(
movie-2026)를 다시 연결하고 아래와 같이 설정한다.
| 항목 | 입력값 |
|---|---|
| Name | goflex-frontend (원하는 이름) |
| Root Directory | frontend |
| Build Command | npm install && npm run build |
| Publish Directory | dist |
- Environment(환경변수) 탭에서 아래 값을 추가한다.
| Key | Value |
|---|---|
VITE_TMDB_API_KEY | TMDB에서 발급받은 API 키 |
- Create Static Site를 클릭한다.
- 배포 완료 후 생성된 주소(예:
https://goflex-frontend-xxxx.onrender.com)로 접속하면 완성된 GOFLEX를 볼 수 있다.
모노레포에서 Root Directory가 핵심인 이유
Render는 기본적으로 저장소의 최상위 폴더를 빌드한다. 모노레포에서는 frontend/와 backend/가 분리되어 있으므로, Root Directory에 어떤 폴더를 지정하느냐에 따라 전혀 다른 프로젝트가 배포된다.
| 서비스 | Root Directory | 빌드 대상 |
|---|---|---|
| 백엔드 (Web Service) | backend | Python 서버 |
| 프론트엔드 (Static Site) | frontend | React 앱 |
같은 저장소, 같은 커밋이지만 시작 폴더만 다르게 설정하면 각각 올바른 프로젝트가 배포된다.
9.4. SPA 리다이렉트 설정
React Router를 사용하는 SPA(싱글 페이지 애플리케이션)는 한 가지 추가 설정이 필요하다. frontend/public/ 폴더에 _redirects 파일을 만들고 아래 한 줄을 입력한다.
/* /index.html 200
| 설명 |
|---|
모든 URL 요청(/*)을 index.html로 보내고 200 상태코드를 반환한다. 이 설정이 없으면 /movie/550 같은 URL에 직접 접속하거나 새로고침할 때 404 에러가 발생한다. React Router가 URL을 처리하려면 항상 index.html이 먼저 로드되어야 하기 때문이다. |
10. 자주 발생하는 에러
에러: TMDB API에서 401 에러
.env 파일의 API 키를 확인한다. 로컬 개발 시에는 Ctrl+C 후 npm run dev로 재시작해야 반영된다. Render 배포 시에는 Environment 탭에서 VITE_TMDB_API_KEY를 정확히 입력했는지 확인한다.
에러: gunicorn command not found
Render가 Python 프로젝트를 Django로 인식하여 gunicorn을 실행하려 할 때 발생한다. Settings → Start Command를 uvicorn main:app --host 0.0.0.0 --port 10000으로 직접 입력하고 저장한 뒤 Manual Deploy를 클릭한다.
에러: 예고편이 표시되지 않는다
모든 영화에 예고편이 있는 것은 아니다. trailer가 undefined이면 "예고편 보기" 버튼 자체가 표시되지 않는다. 인기 영화 대부분은 예고편이 있으므로 상위 랭킹 영화로 테스트한다.
에러: 새로고침하면 404 페이지가 나온다
frontend/public/_redirects 파일이 없거나 내용이 잘못된 경우이다. 9.4 항목의 SPA 리다이렉트 설정을 확인한다.
11. 전체 코드
Chatbot.jsx
import { useState, useRef, useEffect } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faComment, faXmark } from "@fortawesome/free-solid-svg-icons";
import { Button } from "./UI.jsx";
const BACKEND = "https://cbot-rfcl.onrender.com/chat";
export default function Chatbot() {
const [open, setOpen] = useState(false);
const [messages, setMessages] = useState([
{ role: "bot", text: "안녕하세요! 영화에 대해 무엇이든 물어보세요" },
]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const bottomRef = useRef(null);
useEffect(() => {
if (open) bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, open]);
async function sendMessage() {
const text = input.trim();
if (!text || loading) return;
setMessages((prev) => [...prev, { role: "user", text }]);
setInput("");
setLoading(true);
try {
const res = await fetch(BACKEND, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text }),
});
const data = await res.json();
setMessages((prev) => [...prev, { role: "bot", text: data.reply }]);
} catch {
setMessages((prev) => [
...prev,
{ role: "bot", text: "서버 연결에 실패했습니다. 다시 시도해주세요." },
]);
} finally {
setLoading(false);
}
}
function handleKeyDown(e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
return (
<>
<Button
variant="primary"
onClick={() => setOpen((v) => !v)}
className="fixed bottom-8 right-8 w-14 h-14 rounded-full text-2xl shadow-lg z-50 flex items-center justify-center"
title="AI 챗봇"
>
{open ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faComment} />}
</Button>
{open && (
<div className="fixed bottom-24 right-8 w-85 max-h-1/2 bg-gray-950 border border-gray-600 rounded-xl flex flex-col shadow-2xl z-40">
<div className="py-3 px-4 bg-yellow-500 rounded-t-xl font-bold text-sm text-gray-700">Goflix AI 챗봇</div>
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2 min-h-50 max-h-85">
{messages.map((m, i) => (
<div
key={i}
className={`py-2 px-3 max-w-4/5 text-sm leading-snug whitespace-pre-wrap break-words text-gray-700
${m.role === "user" ? "self-end bg-yellow-500 rounded-tl-xl rounded-tr-xl rounded-bl-xl" : "self-start bg-gray-100 rounded-tl-xl rounded-tr-xl rounded-br-xl"}`}
>
{m.text}
</div>
))}
{loading && <div className="self-start bg-gray-100 text-gray-400 rounded-tl-xl rounded-tr-xl rounded-br-xl py-2 px-3 text-sm">...</div>}
<div ref={bottomRef} />
</div>
<div className="flex border-t border-[#333] p-2 gap-2">
<input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="메시지 입력..."
disabled={loading}
className="flex-1 bg-[#1e1e1e] border border-[#444] rounded-lg py-2 px-3 text-white caret-white text-sm outline-none placeholder:text-zinc-300"
/>
<Button
variant="primary"
onClick={sendMessage}
disabled={loading || !input.trim()}
className="rounded-lg py-2 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
전송
</Button>
</div>
</div>
)}
</>
);
}
App.jsx (챗봇 추가)
import { useState, useEffect } from "react";
import { Outlet } from "react-router";
import { Header } from "./components/Header.jsx";
import { Footer } from "./components/Footer.jsx";
import api from "./api/axios";
import Chatbot from "./components/Chatbot.jsx";
export default function App() {
const [now, setNow] = useState(null);
const [popular, setPopular] = useState(null);
const [topRated, setTopRated] = useState(null);
async function loadMovie() {
const [res1, res2, res3] = await Promise.all([
api.get("movie/now_playing"),
api.get("movie/popular"),
api.get("movie/top_rated"),
]);
setNow(res1.data.results.filter((m) => m.poster_path));
setPopular(res2.data.results.filter((m) => m.poster_path));
setTopRated(res3.data.results.filter((m) => m.poster_path));
}
useEffect(() => {
loadMovie();
}, []);
const loading = now === null || popular === null || topRated === null;
const ctx = {
now: now || [],
popular: popular || [],
topRated: topRated || [],
loading,
};
return (
<>
<Header />
<Outlet context={ctx} />
<Footer />
<Chatbot />
</>
);
}