🐨CoalaCoding
DocsExamplesTry itBoardB반B반
🐨CoalaCoding

개발자를 위한 한국어 웹 기술 문서

문서

  • JavaScript
  • Web Publishing
  • React
  • Python

커뮤니티

  • 게시판
  • 예제 모음
  • Try it 에디터

기타

  • GitHub
  • 관리자
© 2026 CoalaCoding. All rights reserved.
  • GOFLIX 프로젝트 소개와 개발환경 설정
  • React 진입점과 라우팅 설정
  • Axios로 TMDB API 연결하기
  • 공통 UI 컴포넌트 만들기 (UI.jsx)
  • App.jsx — 레이아웃 구성과 데이터 가져오기
  • Header와 Footer 만들기
  • Home.jsx — 메인 페이지 완성하기
  • Section과 Card — 영화 카드 목록 만들기
  • MovieDetail — 영화 상세 페이지 만들기
  • Category, ErrorPage 완성하기
  • AI 챗봇 연동하기
  • Swiper_캐러셀_적용과_프로젝트_마무리
  • GOFLEX Gemini CLI 바이브코딩 프롬프트 템플릿
  • 무비앱-완료본
  1. 홈
  2. 문서
  3. React
  4. 프로젝트: 영화앱
  5. AI 챗봇 연동하기

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);
줄설명
1useRef(유즈레프) — DOM 요소를 직접 참조하는 훅이다. 여기서는 메시지 목록의 맨 아래를 가리키는 데 사용한다.
3faComment(말풍선), faXmark(X) 아이콘이다.
5챗봇 백엔드 서버 주소이다. 본인의 Render 배포 주소로 변경한다.
7export default — 이 컴포넌트는 파일의 대표 내보내기이다. App.jsx에서 import Chatbot으로 가져온다.
8open — 채팅창의 열림/닫힘 상태이다.
9-11messages — 대화 목록이다. 초기값에 봇의 인사 메시지를 넣어두면 채팅창을 열었을 때 바로 안내 메시지가 보인다.
14bottomRef — 메시지 목록의 맨 아래 빈 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]);
줄설명
2scrollIntoView(스크롤인투뷰) — 해당 DOM 요소가 화면에 보이도록 자동 스크롤한다. \{ behavior: "smooth" \}는 부드럽게 스크롤하라는 옵션이다.
2bottomRef.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-16fetch(패치)는 브라우저 내장 함수로 HTTP 요청을 보낸다. method: "POST"로 서버에 데이터를 전송한다. res.json()은 서버 응답(텍스트)을 JavaScript 객체로 변환하는 메서드이다. 변환된 객체에서 data.reply로 봇 답변을 꺼내 메시지 목록에 추가한다.
17-21catch — 네트워크 에러 등 실패 시 에러 메시지를 봇 응답으로 표시한다. 앱이 멈추지 않는다.
22-24finally(파이널리) — 성공이든 실패든 반드시 실행된다. 로딩 상태를 끄는 데 사용한다.

② handleKeyDown

  function handleKeyDown(e) {
    if (e.key === "Enter" && !e.shiftKey) {
      e.preventDefault();
      sendMessage();
    }
  }
줄설명
2===(엄격한 동등 연산자)는 값과 타입이 모두 같을 때만 true를 반환한다. e.key === "Enter"는 눌린 키가 정확히 문자열 "Enter"인지 확인한다. Shift+Enter는 줄바꿈으로 허용하기 위해 !e.shiftKey로 Shift 키가 눌리지 않았는지도 함께 확인한다.
3e.preventDefault()(프리벤트디폴트) — Enter 키의 기본 동작(폼 제출 등)을 막는다.
try / catch / finally 패턴
try {
  // 성공할 수도 있는 코드
} catch {
  // 실패했을 때 실행되는 코드
} finally {
  // 성공이든 실패든 반드시 실행되는 코드
}
블록실행 조건용도
try항상 실행 시도API 호출, 데이터 처리
catchtry에서 에러 발생 시에러 메시지 표시, 대체 동작
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>
줄설명
14편에서 만든 Button 컴포넌트이다. variant="primary"로 노란색 스타일을 적용한다.
3setOpen((v) => !v) — 함수형 업데이트이다. 이전 값 v를 받아서 반전시킨다. setOpen(!open)과 같은 결과이지만 더 안전하다.
4fixed bottom-8 right-8 — 화면 우하단에 고정 배치한다. rounded-full — 완전한 원형이다. shadow-lg — 큰 그림자 효과이다.
5title 속성 — 버튼에 마우스를 올리면 "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>
줄설명
2fixed bottom-24 right-8 — 플로팅 버튼 바로 위에 채팅창을 배치한다. w-85는 너비 약 340px이다. max-h-1/2는 화면 높이의 절반까지만 차지한다.
2bg-gray-950 — Tailwind의 가장 어두운 회색이다. rounded-xl — 모서리를 둥글게 12px 처리한다.
5overflow-y-auto — 내용이 넘치면 세로 스크롤이 생긴다. min-h-50은 최소 높이, max-h-85는 최대 높이이다.
9whitespace-pre-wrap — 줄바꿈(\n)을 유지하면서 긴 텍스트는 자동 줄바꿈한다. break-words — 긴 단어가 넘치면 강제로 줄바꿈한다.
10self-end(사용자 메시지, 오른쪽) / self-start(봇 메시지, 왼쪽)로 정렬한다. 각각 다른 방향의 모서리를 둥글게 처리하여 말풍선 느낌을 만든다.
16ref=\{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>
      )}
    </>
  );
}
줄설명
1border-[#333] — Tailwind의 임의값(Arbitrary Value) 문법이다. 미리 정의되지 않은 색상을 대괄호로 직접 지정한다.
7disabled=\{loading\} — 로딩 중이면 입력창을 비활성화한다.
8outline-none — 포커스 시 기본 파란색 외곽선을 제거한다. placeholder:text-zinc-300 — 플레이스홀더 텍스트 색상을 지정하는 Tailwind 수식어이다.
114편에서 만든 Button 컴포넌트를 사용한다. variant="primary"로 노란색 스타일을 적용한다.
13disabled=\{loading &#124;&#124; !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에 코드 올리기

  1. https://github.com 에서 **New repository(뉴 리포지토리)**를 클릭하여 새 저장소를 만든다.
  2. 프로젝트 최상위 폴더(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"이라는 메시지와 함께 기록을 남긴다.
4GitHub 원격 저장소와 연결한다. 내아이디를 본인의 GitHub 아이디로 바꾼다.
5코드를 GitHub에 올린다.

9. Render에 배포하기

Render(렌더)는 GitHub 저장소를 연결하면 코드를 자동으로 배포해 주는 무료 호스팅 서비스이다. 모노레포에서는 같은 저장소를 두 번 연결하되, 각각 다른 Root Directory(루트 디렉토리, 시작 폴더)를 지정한다.

9.1. 백엔드 배포 (Python 서버)

  1. https://render.com 에 접속하여 GitHub 계정으로 로그인한다.
  2. New → Web Service를 클릭한다.
  3. GitHub 저장소(movie-2026)를 연결하고 아래와 같이 설정한다.
항목입력값
Namegoflex-backend (원하는 이름)
Root Directorybackend
RuntimePython 3
Build Commandpip install -r requirements.txt
Start Commanduvicorn main:app --host 0.0.0.0 --port 10000
  1. Environment(환경변수) 탭에서 아래 값을 추가한다.
KeyValue
HF_TOKENHuggingFace에서 발급받은 API 키
  1. Create Web Service를 클릭하면 배포가 시작된다.
  2. 배포 완료 후 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)

  1. Render에서 New → Static Site(스태틱 사이트)를 클릭한다.
  2. 같은 GitHub 저장소(movie-2026)를 다시 연결하고 아래와 같이 설정한다.
항목입력값
Namegoflex-frontend (원하는 이름)
Root Directoryfrontend
Build Commandnpm install && npm run build
Publish Directorydist
  1. Environment(환경변수) 탭에서 아래 값을 추가한다.
KeyValue
VITE_TMDB_API_KEYTMDB에서 발급받은 API 키
  1. Create Static Site를 클릭한다.
  2. 배포 완료 후 생성된 주소(예: https://goflex-frontend-xxxx.onrender.com)로 접속하면 완성된 GOFLEX를 볼 수 있다.
모노레포에서 Root Directory가 핵심인 이유

Render는 기본적으로 저장소의 최상위 폴더를 빌드한다. 모노레포에서는 frontend/와 backend/가 분리되어 있으므로, Root Directory에 어떤 폴더를 지정하느냐에 따라 전혀 다른 프로젝트가 배포된다.

서비스Root Directory빌드 대상
백엔드 (Web Service)backendPython 서버
프론트엔드 (Static Site)frontendReact 앱

같은 저장소, 같은 커밋이지만 시작 폴더만 다르게 설정하면 각각 올바른 프로젝트가 배포된다.

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 />
    </>
  );
}

목차

  • 구문