🐨CoalaCoding
DocsExamplesTry itBoardB반
🐨CoalaCoding

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

문서

  • JavaScript
  • Web Publishing
  • React
  • Python

커뮤니티

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

기타

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

11. 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를 가리키는 참조이다. 새 메시지가 추가되면 이 위치로 자동 스크롤한다.

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 키를 누르면 전송하는 함수이다.

  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(파이널리) — 성공이든 실패든 반드시 실행된다. 로딩 상태를 끄는 데 사용한다.

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 아이콘, 닫힘 시 말풍선 아이콘이다.

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를 볼 수 있다.

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. 자주 발생하는 에러


11. 전체 코드

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

목차

  • 구문