11. AI 챗봇 연동하기
요약
Gemini CLI로 구현하기 — AI 챗봇 컴포넌트
npx gemini-
1src/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 /> |
정보
챗봇 백엔드 서버는 별도 프로젝트로 Python + FastAPI + HuggingFace로 구축하여 Render에 배포한 상태여야 한다.
2. 1단계 — import + 상태 변수
src/components/ 폴더 안에 Chatbot.jsx 파일을 새로 만들고 아래 코드를 입력한다. 이 파일 하나에 플로팅 버튼(화면 우하단에 떠 있는 동그란 버튼)과 채팅창이 모두 들어 있다.
1import { useState, useRef, useEffect } from "react";2import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";3import { faComment, faXmark } from "@fortawesome/free-solid-svg-icons";4import { Button } from "./UI.jsx";5
6const BACKEND = "https://cbot-rfcl.onrender.com/chat";7
8export default function Chatbot() {9 const [open, setOpen] = useState(false);10 const [messages, setMessages] = useState([11 { role: "bot", text: "안녕하세요! 영화에 대해 무엇이든 물어보세요" },12 ]);13 const [input, setInput] = useState("");14 const [loading, setLoading] = useState(false);15 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는 렌더링과 무관하게 값을 유지하거나, DOM 요소를 직접 참조할 때 사용하는 훅이다.
1const bottomRef = useRef(null); // 참조 생성2// ...3<div ref={bottomRef} /> // DOM 요소에 연결4// ...5bottomRef.current // 연결된 실제 DOM 요소useState와 달리 값이 바뀌어도 리렌더링이 발생하지 않는다.
3. 2단계 — 자동 스크롤
같은 Chatbot.jsx 파일에서, const bottomRef = useRef(null) 줄 바로 아래에 이어서 작성한다. 채팅 앱을 쓸 때 새 메시지가 오면 자동으로 스크롤이 내려가는 것을 본 적이 있을 것이다. 그 기능을 만드는 코드이다.
1 useEffect(() => {2 if (open) bottomRef.current?.scrollIntoView({ behavior: "smooth" });3 }, [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
- ② handleKeyDown
1 async function sendMessage() {2 const text = input.trim();3 if (!text || loading) return;4
5 setMessages((prev) => [...prev, { role: "user", text }]);6 setInput("");7 setLoading(true);8
9 try {10 const res = await fetch(BACKEND, {11 method: "POST",12 headers: { "Content-Type": "application/json" },13 body: JSON.stringify({ text }),14 });15 const data = await res.json();16 setMessages((prev) => [...prev, { role: "bot", text: data.reply }]);17 } catch {18 setMessages((prev) => [19 ...prev,20 { role: "bot", text: "서버 연결에 실패했습니다. 다시 시도해주세요." },21 ]);22 } finally {23 setLoading(false);24 }25 }| 줄 | 설명 |
|---|---|
| 3 | `!text |
| 5 | 스프레드 연산자(...prev)로 기존 메시지에 사용자 메시지를 추가한다. |
| 9-16 | fetch(패치)는 브라우저 내장 함수로 HTTP 요청을 보낸다. method: "POST"로 서버에 데이터를 전송한다. res.json()은 서버 응답(텍스트)을 JavaScript 객체로 변환하는 메서드이다. 변환된 객체에서 data.reply로 봇 답변을 꺼내 메시지 목록에 추가한다. |
| 17-21 | catch — 네트워크 에러 등 실패 시 에러 메시지를 봇 응답으로 표시한다. 앱이 멈추지 않는다. |
| 22-24 | finally(파이널리) — 성공이든 실패든 반드시 실행된다. 로딩 상태를 끄는 데 사용한다. |
1 function handleKeyDown(e) {2 if (e.key === "Enter" && !e.shiftKey) {3 e.preventDefault();4 sendMessage();5 }6 }| 줄 | 설명 |
|---|---|
| 2 | ===(엄격한 동등 연산자)는 값과 타입이 모두 같을 때만 true를 반환한다. e.key === "Enter"는 눌린 키가 정확히 문자열 "Enter"인지 확인한다. Shift+Enter는 줄바꿈으로 허용하기 위해 !e.shiftKey로 Shift 키가 눌리지 않았는지도 함께 확인한다. |
| 3 | e.preventDefault()(프리벤트디폴트) — Enter 키의 기본 동작(폼 제출 등)을 막는다. |
1try {2 // 성공할 수도 있는 코드3} catch {4 // 실패했을 때 실행되는 코드5} finally {6 // 성공이든 실패든 반드시 실행되는 코드7}| 블록 | 실행 조건 | 용도 |
|---|---|---|
try | 항상 실행 시도 | API 호출, 데이터 처리 |
catch | try에서 에러 발생 시 | 에러 메시지 표시, 대체 동작 |
finally | 항상 (성공/실패 무관) | 로딩 상태 초기화, 정리 작업 |
이전 교안(5편)에서는 try/catch만 사용했다. finally를 추가하면 setLoading(false)를 try와 catch 양쪽에 중복 작성할 필요가 없어진다.
5. 4단계 — JSX 렌더링
같은 Chatbot.jsx 파일에서, handleKeyDown 함수 바로 아래에 return문을 작성한다. 코드가 길기 때문에 세 부분으로 나누어 설명한다. 탭을 눌러 각 부분을 확인하면서 순서대로 이어 붙인다.
- ① 플로팅 버튼
- ② 채팅창 헤더 + 메시지 목록
- ③ 입력창
1 return (2 <>3 <Button4 variant="primary"5 onClick={() => setOpen((v) => !v)}6 className="fixed bottom-8 right-8 w-14 h-14 rounded-full text-2xl shadow-lg z-50 flex items-center justify-center"7 title="AI 챗봇"8 >9 {open ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faComment} />}10 </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 아이콘, 닫힘 시 말풍선 아이콘이다. |
1 {open && (2 <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">3 <div className="py-3 px-4 bg-yellow-500 rounded-t-xl font-bold text-sm text-gray-700">Goflix AI 챗봇</div>4
5 <div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2 min-h-50 max-h-85">6 {messages.map((m, i) => (7 <div8 key={i}9 className={`py-2 px-3 max-w-4/5 text-sm leading-snug whitespace-pre-wrap break-words text-gray-70010 ${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"}`}11 >12 {m.text}13 </div>14 ))}15 {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>}16 <div ref={bottomRef} />17 </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가 이 위치로 스크롤한다. |
1 <div className="flex border-t border-[#333] p-2 gap-2">2 <input3 value={input}4 onChange={(e) => setInput(e.target.value)}5 onKeyDown={handleKeyDown}6 placeholder="메시지 입력..."7 disabled={loading}8 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"9 />10 <Button11 variant="primary"12 onClick={sendMessage}13 disabled={loading || !input.trim()}14 className="rounded-lg py-2 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed"15 >16 전송17 </Button>18 </div>19 </div>20 )}21 </>22 );23}| 줄 | 설명 |
|---|---|
| 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 영역에 한 줄을 추가한다.
1import Chatbot from "./components/Chatbot.jsx";요약
Chatbot은 export default로 내보냈으므로 중괄호 \{ \} 없이 가져온다. 다른 컴포넌트(Header, Footer 등)는 export function(named export)이라서 중괄호가 필요하다.
6.2. return에 Chatbot 추가
<Footer /> 아래에 한 줄을 추가한다.
1 return (2 <>3 <Header />4 <Outlet context={ctx} />5 <Footer />6 <Chatbot />7 </>8 );| 줄 | 설명 |
|---|---|
| 6 | <Chatbot /> — 플로팅 버튼과 채팅창이 모두 이 컴포넌트 안에 있으므로, 한 줄만 추가하면 된다. |
7. 동작 확인
| 확인 항목 | 기대 결과 |
|---|---|
| 화면 우하단 | 노란색 둥근 버튼에 말풍선 아이콘 표시 |
| 버튼 클릭 | 버튼 위에 채팅창 팝업. 아이콘이 X로 전환 |
| 채팅창 열림 | ”안녕하세요! 영화에 대해 무엇이든 물어보세요” 메시지 표시 |
| 메시지 전송 | 오른쪽 노란 말풍선 → ”…” 로딩 → 왼쪽 흰 말풍선 |
| 서버 에러 시 | ”서버 연결에 실패했습니다” 에러 메시지 표시 |
| 메시지 추가 | 자동으로 맨 아래로 스크롤 |
| X 버튼 클릭 | 채팅창 닫힘 |
정보
무료 호스팅(Render)은 첫 응답이 30초~1분 걸릴 수 있다. 서버가 슬립 상태에서 깨어나는 시간이다.
8. 모노레포 구조와 GitHub 올리기
8.1. 모노레포란?
이 프로젝트는 하나의 GitHub 저장소 안에 프론트엔드(frontend/)와 백엔드(backend/)가 함께 들어 있다. 이런 구조를 모노레포(Monorepo, 모노리포)라고 한다. 책 한 권에 소설과 삽화가 함께 들어 있는 것처럼, 하나의 저장소에서 두 프로젝트를 관리한다.
1movie-2026/2├── frontend/ ← React 프론트엔드3│ ├── src/4│ ├── public/5│ ├── vite.config.js6│ ├── package.json7│ └── .env ← TMDB API 키 (GitHub에 올리면 안 됨)8└── backend/ ← Python 챗봇 서버9 ├── main.py10 ├── requirements.txt11 └── .env ← HuggingFace API 키 (GitHub에 올리면 안 됨)8.2. .gitignore 설정
프로젝트 최상위 폴더(movie-2026/)에 .gitignore 파일을 만들고 아래 내용을 입력한다. 비밀 정보와 불필요한 파일이 GitHub에 올라가지 않도록 막는 역할이다.
frontend/.envfrontend/node_modules/frontend/dist/backend/.envbackend/.venv/backend/__pycache__/| 줄 | 설명 |
|---|---|
| 1 | 프론트엔드의 TMDB API 키 파일을 제외한다. |
| 2 | 설치된 패키지 폴더를 제외한다. 용량이 매우 크고, npm install로 다시 설치할 수 있다. |
| 3 | 빌드 결과물을 제외한다. Render가 배포 시 자동으로 빌드한다. |
| 4-6 | 백엔드의 비밀 키, 가상환경, 캐시를 제외한다. |
8.3. GitHub에 코드 올리기
- https://github.com 에서 **New repository(뉴 리포지토리)**를 클릭하여 새 저장소를 만든다.
- 프로젝트 최상위 폴더(movie-2026/)에서 터미널을 열고, 아래 명령어를 한 줄씩 실행한다.
1git init2git add .3git commit -m "first commit"4git remote add origin https://github.com/내아이디/movie-2026.git5git 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형태의 주소가 생성된다. 이 주소를 메모해 둔다.
주의
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번 줄을 수정한다.
1const BACKEND = "https://goflex-backend-xxxx.onrender.com/chat";xxxx 부분을 9.1에서 생성된 실제 주소로 바꾼다. 수정 후 GitHub에 다시 push 한다.
1git add .2git commit -m "update backend url"3git push9.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를 볼 수 있다.
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. 자주 발생하는 에러
.env 파일의 API 키를 확인한다. 로컬 개발 시에는 Ctrl+C 후 npm run dev로 재시작해야 반영된다. Render 배포 시에는 Environment 탭에서 VITE_TMDB_API_KEY를 정확히 입력했는지 확인한다.
Render가 Python 프로젝트를 Django로 인식하여 gunicorn을 실행하려 할 때 발생한다. Settings → Start Command를 uvicorn main:app --host 0.0.0.0 --port 10000으로 직접 입력하고 저장한 뒤 Manual Deploy를 클릭한다.
모든 영화에 예고편이 있는 것은 아니다. trailer가 undefined이면 “예고편 보기” 버튼 자체가 표시되지 않는다. 인기 영화 대부분은 예고편이 있으므로 상위 랭킹 영화로 테스트한다.
frontend/public/_redirects 파일이 없거나 내용이 잘못된 경우이다. 9.4 항목의 SPA 리다이렉트 설정을 확인한다.
11. 전체 코드
- Chatbot.jsx
- App.jsx (챗봇 추가)
1import { useState, useRef, useEffect } from "react";2import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";3import { faComment, faXmark } from "@fortawesome/free-solid-svg-icons";4import { Button } from "./UI.jsx";5
6const BACKEND = "https://cbot-rfcl.onrender.com/chat";7
8export default function Chatbot() {9 const [open, setOpen] = useState(false);10 const [messages, setMessages] = useState([11 { role: "bot", text: "안녕하세요! 영화에 대해 무엇이든 물어보세요" },12 ]);13 const [input, setInput] = useState("");14 const [loading, setLoading] = useState(false);15 const bottomRef = useRef(null);16
17 useEffect(() => {18 if (open) bottomRef.current?.scrollIntoView({ behavior: "smooth" });19 }, [messages, open]);20
21 async function sendMessage() {22 const text = input.trim();23 if (!text || loading) return;24
25 setMessages((prev) => [...prev, { role: "user", text }]);26 setInput("");27 setLoading(true);28
29 try {30 const res = await fetch(BACKEND, {31 method: "POST",32 headers: { "Content-Type": "application/json" },33 body: JSON.stringify({ text }),34 });35 const data = await res.json();36 setMessages((prev) => [...prev, { role: "bot", text: data.reply }]);37 } catch {38 setMessages((prev) => [39 ...prev,40 { role: "bot", text: "서버 연결에 실패했습니다. 다시 시도해주세요." },41 ]);42 } finally {43 setLoading(false);44 }45 }46
47 function handleKeyDown(e) {48 if (e.key === "Enter" && !e.shiftKey) {49 e.preventDefault();50 sendMessage();51 }52 }53
54 return (55 <>56 <Button57 variant="primary"58 onClick={() => setOpen((v) => !v)}59 className="fixed bottom-8 right-8 w-14 h-14 rounded-full text-2xl shadow-lg z-50 flex items-center justify-center"60 title="AI 챗봇"61 >62 {open ? <FontAwesomeIcon icon={faXmark} /> : <FontAwesomeIcon icon={faComment} />}63 </Button>64
65 {open && (66 <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">67 <div className="py-3 px-4 bg-yellow-500 rounded-t-xl font-bold text-sm text-gray-700">Goflix AI 챗봇</div>68
69 <div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2 min-h-50 max-h-85">70 {messages.map((m, i) => (71 <div72 key={i}73 className={`py-2 px-3 max-w-4/5 text-sm leading-snug whitespace-pre-wrap break-words text-gray-70074 ${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"}`}75 >76 {m.text}77 </div>78 ))}79 {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>}80 <div ref={bottomRef} />81 </div>82
83 <div className="flex border-t border-[#333] p-2 gap-2">84 <input85 value={input}86 onChange={(e) => setInput(e.target.value)}87 onKeyDown={handleKeyDown}88 placeholder="메시지 입력..."89 disabled={loading}90 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"91 />92 <Button93 variant="primary"94 onClick={sendMessage}95 disabled={loading || !input.trim()}96 className="rounded-lg py-2 px-3 text-sm disabled:opacity-50 disabled:cursor-not-allowed"97 >98 전송99 </Button>100 </div>101 </div>102 )}103 </>104 );105}1import { useState, useEffect } from "react";2import { Outlet } from "react-router";3import { Header } from "./components/Header.jsx";4import { Footer } from "./components/Footer.jsx";5import api from "./api/axios";6import Chatbot from "./components/Chatbot.jsx";7
8export default function App() {9 const [now, setNow] = useState(null);10 const [popular, setPopular] = useState(null);11 const [topRated, setTopRated] = useState(null);12
13 async function loadMovie() {14 const [res1, res2, res3] = await Promise.all([15 api.get("movie/now_playing"),16 api.get("movie/popular"),17 api.get("movie/top_rated"),18 ]);19 setNow(res1.data.results.filter((m) => m.poster_path));20 setPopular(res2.data.results.filter((m) => m.poster_path));21 setTopRated(res3.data.results.filter((m) => m.poster_path));22 }23
24 useEffect(() => {25 loadMovie();26 }, []);27
28 const loading = now === null || popular === null || topRated === null;29 const ctx = {30 now: now || [],31 popular: popular || [],32 topRated: topRated || [],33 loading,34 };35
36 return (37 <>38 <Header />39 <Outlet context={ctx} />40 <Footer />41 <Chatbot />42 </>43 );44}