본문 바로가기
개발/Web and Frontend

LLM 기반 추천 시스템 프론트엔드

by 아르카눔 2025. 4. 25.

Home.tsx Home 화면

// pages/Home.tsx  
import React, { useState, useEffect } from "react";
import SearchBar from "../components/SearchBar";
import MovieCard from "../components/MovieCard";
import UserSearchBar from "../components/UserSearchBar";
import { useNavigate } from 'react-router-dom';
import styles from './Home.module.css'

const API_URL = import.meta.env.VITE_API_URL;


interface Movie {
    movieId: number;
    title: string;
    rating: number;
    genre: string;
}

// 인기 영화 목록 (고정)
const popularMovies: Movie[] = [
    {movieId: 1, title: "The Matrix", rating: 4.8, genre: "Action"},
    {movieId: 2, title: "Inception", rating: 4.7, genre: "Sci-Fi"},
    {movieId: 3, title: "Godfather", rating: 4.6, genre: "Crime"},
];

// 추천 영화 목록 (고정)
const recommendedMovies: Movie[] = [
    {movieId: 1, title: "The Matrix", rating: 4.8, genre: "Action"},
    {movieId: 2, title: "Inception", rating: 4.7, genre: "Sci-Fi"},
    {movieId: 3, title: "Godfather", rating: 4.6, genre: "Crime"},
];



const Home: React.FC = () => {
    const [search, setSearch] = useState<string>("");
    const [userId, setUserId] = useState<number | null>(null);
    const [createUserId, setCreateUserId] = useState<number | null>(null);
    const [getUserId, setGetUserId] = useState<number | null>(null);
    const [userRecommendedMovies, setUserRecommendedMovies] = useState<Movie[]>([]);
    const navigate = useNavigate();

    useEffect(() => {
        const fetchRecommendedMovies = async () => {
            const userId = 1;
            try {
                const response = await fetch(`${API_URL}/api/recommended?userId=${encodeURIComponent(userId)}`);
                if (!response.ok) {
                    throw new Error(`추천 영화 불러오기 실패: ${response.status}`);
                }
                const recommendations = await response.json();
                setUserRecommendedMovies(recommendations);
            } catch (error) {
                console.error('Error:', error);
                alert('추천 영화 불러오기 중 오류가 발생했습니다: ' + error);
            }
        };

        fetchRecommendedMovies();
    }, []); // Empty dependency array means this effect runs once when the component mounts


    const handleSearch = async () => {
        if (!search.trim()) {
            alert('검색어를 입력해주세요');
            return;
        }
        console.log(API_URL);

        try {
            const response = await fetch(`${API_URL}/api/search?query=${encodeURIComponent(search)}`);
            if (!response.ok) {
                throw new Error(`검색 실패: ${response.status}`);
            }
            const searchResults = await response.json();
            
            navigate('/search-results', {
                state: {
                    results: searchResults,
                    query: search
                }
            });
        } catch (error) {
            console.error('Error:', error);
            alert('검색 중 오류가 발생했습니다: ' + error);
        }
    };

    const handleUserCreateRecommendSearch = async () => {
        if (!createUserId) {
            alert('유저 ID를 입력해주세요');
            return;
        }

        try {
            {/* 추천 영화 등록, POST part*/}
            const json_body = {userId: createUserId};
            console.log(json_body);
            const response = await fetch(`${API_URL}/api/recommend`, {
                method: "POST",
                headers: {
                  "Content-Type": "application/json",
                },
                body: JSON.stringify(json_body)
              });
            
            if (!response.ok) {
                throw new Error(`추천 영화 등록 실패: ${response.status}`);
            };
        }
        catch (error) {
            console.error('Error:', error);
            alert('추천 영화를 등록하는 중 오류가 발생했습니다: ' + error);
        }
    };

    const handleUserGetRecommendSearch = async () => {
        if (!getUserId) {
            alert('유저 ID를 입력해주세요');
            return;
        }

        try {
            {/* 추천 영화 불러오기, GET part*/}
            const json_body = {userId: getUserId};
            console.log(json_body);
            const response = await fetch(`${API_URL}/api/recommended?userId=${encodeURIComponent(getUserId)}`);
            if (!response.ok) {
                throw new Error(`추천 영화 불러오기 실패: ${response.status}`);
            }
            const Recommendations = await response.json();
            
            navigate(`/user/${getUserId}`, {
                state: {
                    results: Recommendations,
                    query: getUserId
                }
            });
        } catch (error) {
            console.error('Error:', error);
            alert('추천 영화 불러오기 중 오류가 발생했습니다: ' + error);
        }
    };

    return (
        <div className="container mx-auto px-4 py-8">
            {/* 검색 섹션 */}
            <div className="mb-12">
                <h1 className="text-3xl font-bold text-center mb-8">영화 검색</h1>
                <div className={styles.searchContainer}>
                    <div>
                    <SearchBar 
                        search={search} 
                        setSearch={setSearch} 
                        onSearch={handleSearch}
                    />
                    </div>
                    <div>
                    <button 
                        onClick={handleSearch}
                        className={styles.searchButton}
                    >
                        검색
                    </button>
                    </div>
                </div>
            </div>

            {/* 인기 영화 섹션 */}
            <div className="mt-8">
                {/* text-2xl이란 1.5rem 24px */}
                <h2 className="text-3xl font-bold mb-6">인기 영화</h2>
                {/* 그리드 방식 <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> */}
                {/*recommendedMovies.map((movie) => (
                        <MovieCard key={movie.movieId} movie={movie} />
                    ))*/}
                <div className={styles.popularContainer}>
                    {popularMovies.map((movie) => (
                        <div key={movie.movieId} className="flex-shrink-0">
                            <MovieCard movie={movie} />
                        </div>
                    ))} 
                </div>
            </div>

            {/* 추천 영화 섹션 */}
            <div className="mt-8">
                {/* text-2xl이란 1.5rem 24px */}
                <h2 className="text-3xl font-bold mb-6">추천 영화</h2>
                {/* 그리드 방식 <div className="grid grid-cols-1 md:grid-cols-3 gap-6"> */}
                {/*recommendedMovies.map((movie) => (
                        <MovieCard key={movie.movieId} movie={movie} />
                    ))*/}
                <div className={styles.recommendContainer}>
                    {/* 5개만 보여주는 경우 */}
                    {userRecommendedMovies.slice(0, 5).map((movie) => (
                        <div key={movie.movieId} className="flex-shrink-0">
                            <MovieCard movie={movie} />
                        </div>
                    ))} 
                </div>
            </div>

             {/* 추천 영화 생성 확인 섹션 */}
             <div className="mt-8">
                <h2 className="text-3xl font-bold mb-6">유저 기반 추천 영화 등록</h2>
                <div className={styles.searchContainer}>
                    <div>
                    <UserSearchBar 
                        search={createUserId} 
                        setSearch={setCreateUserId} 
                        onSearch={handleUserCreateRecommendSearch}
                    />
                    </div>
                    <div>
                    <button 
                        onClick={handleUserCreateRecommendSearch}
                        className={styles.searchButton}
                    >
                        생성
                    </button>
                    </div>
                </div>
            </div>

            {/* 추천 영화 불러오기 확인 섹션 */}
            <div className="mt-8">
                <h2 className="text-3xl font-bold mb-6">유저 기반 추천 영화 불러오기</h2>
                <div className={styles.searchContainer}>
                    <div>
                    <UserSearchBar 
                        search={getUserId} 
                        setSearch={setGetUserId} 
                        onSearch={handleUserGetRecommendSearch}
                    />
                    </div>
                    <div>
                    <button 
                        onClick={handleUserGetRecommendSearch}
                        className={styles.searchButton}
                    >
                        불러오기
                    </button>
                    </div>
                </div>
            </div>

        </div>
    );
};

export default Home;

 

인기 영화 목록은 고정하고,

 

유저별 추천 영화는 fetchRecommendedMovies로 가져와서 표시한다.

 

 

const [userId, setUserId] = useState<number | null>(null);

 

userId는 number 이거나 null인데 null로 초기화 한다. 

 

 

search는 데이터 타입이 string이며 setSearch로 값을 변경한다.

 

fetch(`${API_URL}/api/search?query=${encodeURIComponent(search)}`)

 

위 코드를 보면 알겠지만 RESTFul API에 따른 쿼리 형태를 Typescript에서 표현하는 방식임을 알 수 있다. 

 

search의 결과는 router를 이용해서 SearchResults.tsx 페이지로 이동한다.

 

이때 useNavigate를 사용한다. 검색 결과를 받아온 다음에 navigate('/search-results') 코드로 연결된 페이지로 이동한다.

 

router와 관련된 사항은 아래의 App.tsx에서 정의한다.

 

 

 

App.tsx

 

import React from 'react';
import { BrowserRouter as Router, Route, Routes } from 'react-router-dom';
import SearchBar from './components/SearchBar';
import MovieList from './components/MovieList';
import SearchResults from './pages/SearchResults';
import UserRecommendations from './pages/UserRecommendations';
import Home from './pages/Home';
import './App.css'

/* 
React Router를 사용
<div> 아래, <Routes> 위에 있던 
<SearchBar /> 를 삭제해본다  
*/
const App: React.FC = () => {
  return (
    <Router>
      <div className="container">
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/search-results" element={<SearchResults />} />
          <Route path="/user/:userId" element={<UserRecommendations />} /> {/* 새로운 라우트 추가 */}
        </Routes>
      </div>
    </Router>
  );
};

export default App;

 

 

 

SearchResults.tsx

 

import React from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
// 같은 폴더에 있는 경우 ./tsxfile.tsx로 import 가능
import MovieCard from '../components/MovieCard';

interface Movie {
    id: number;
    title: string;
    rating: number;
    genre: string;
}

const SearchResults: React.FC = () => {
    const location = useLocation();
    const navigate = useNavigate();
    const searchResults = location.state?.results as Movie[];
    const searchQuery = location.state?.query as string;

    const handleBackToHome = () => {
        navigate('/');
    };

    return (
        <div className="container mx-auto px-4 py-8">
            <div className="flex items-center justify-between mb-8">
                <h1 className="text-2xl font-bold">"{searchQuery}" 검색 결과</h1>
                <button 
                    onClick={handleBackToHome}
                    className="px-4 py-2 bg-gray-500 text-white rounded-lg hover:bg-gray-600 transition-colors"
                >
                    홈으로 돌아가기
                </button>
            </div>

            {searchResults?.length === 0 ? (
                <div className="text-center py-12">
                    <p className="text-gray-500 text-lg">검색 결과가 없습니다.</p>
                </div>
            ) : (
                <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
                    {searchResults?.map((movie) => (
                        <MovieCard key={movie.id} movie={movie} />
                    ))}
                </div>
            )}
        </div>
    );
};

export default SearchResults;

 

SearchResults라는 페이지인데 MovieCard를 불러와서 보여준다.

 

MovieCard.tsx는 다음과 같다.

 

MovieCard.tsx

 

// MovieCards  
import React, { useState } from 'react';
import styles from './MovieCard.module.css'

const API_URL = import.meta.env.VITE_API_URL;

interface Movie {
    movieId: number;
    title: string;
    rating: number;
    genre: string;
}


interface MovieCardProps {
    movie: Movie;
    userId: number;
}


const MovieCard: React.FC<MovieCardProps> = ({ movie, userId = 230213 }) => {
    const [showRating, setShowRating] = useState(false);
    const [userRating, setUserRating] = useState<number | null>(null);

    /* Mouse and corresponding rating card Control */
    const handleMouseEnter = () => {
        setShowRating(true);
    };

    const handleMouseLeave = () => {
        setShowRating(false);
    };

    const handleRatingChange = (rating: number) => {
        setUserRating(rating);
      };

    const handleRatingSubmit = async () => {
        if (userRating !== null) {
            try {

              const timestamp = new Date();
              const isoTimestampString = timestamp.toISOString();
              const unixTimeInSeconds = Math.floor(timestamp.getTime() / 1000);

              const userFloatRating = userRating + 0.0; // Convert to float
              
              console.log(timestamp);
              console.log(isoTimestampString);
              console.log(unixTimeInSeconds);
              console.log(userId);
              console.log(movie.movieId);
              console.log(userRating);
              console.log(userFloatRating);

              const jsonBody = {
                userId: userId,
                movieId: movie.movieId,
                rating: userFloatRating,
                timestamp: unixTimeInSeconds,
              };

              const response = await fetch(`${API_URL}/api/ratings`, {
                method: "POST",
                headers: {
                  "Content-Type": "application/json",
                },
                body: JSON.stringify(jsonBody)
                /*body: JSON.stringify({ userId: userId, 
                                        movieId: movie.movieId, 
                                        rating: userRating }),*/
              });
      
              if (response.ok) {
                alert('Rating added successfully');
                setShowRating(false); // 제출 후 별점 창 숨김
              } else {
                alert('Failed to add rating');
              }
            } catch (error) {
              console.error('Error submitting rating:', error);
              alert('An error occurred while submitting rating');
            }
          }
      };


    return (
        <div
          className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow relative"
          onMouseEnter={handleMouseEnter}
          onMouseLeave={handleMouseLeave}
         >
            <div className="p-6">
                <h3 className="text-xl font-semibold mb-2">{movie.title}</h3>
                <div className="flex flex-col">
                    <span>평점: {movie.rating}</span>
                    <span>장르: {movie.genre}</span>
                </div>
            </div>
            {showRating && (
                <div className="absolute top-0 left-0 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
                    <div className="bg-white p-6 rounded-log">
                        <h4 className="text-lg font-semibold mb-4">별점 매기기</h4>
                        <div className="flex items-center space-x-2">
                        {[1, 2, 3, 4, 5].map((rating) => (
                            <button
                                key={rating}
                                className={`text-2xl ${userRating && userRating >= rating ? 'text-yellow-500' : 'text-gray-300'}`}
                                onClick={() => handleRatingChange(rating)}
                            >
                            ★
                            </button>
                        ))}
                    </div>
                    {userRating && <p className="mt-4">선택한 별점: {userRating}</p>}
                    <button className="mt-4 bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" onClick={handleRatingSubmit}>
                    Submit Rating
                    </button>
                </div>
            </div>
            )}
        </div>
    );
};

export default MovieCard;

 

 

아래의 코드 부분에서 영화의 제목, 평점, 장르를 표기한다.

 

 

<div className="p-6">
    <h3 className="text-xl font-semibold mb-2">{movie.title}</h3>
    <div className="flex flex-col">
        <span>평점: {movie.rating}</span>
        <span>장르: {movie.genre}</span>
    </div>
</div>

 

 

위 Movie Card를 리스트의 형태로 보여주는 코드는 아래 MovieList.tsx를 사용한다.

 

 

MovieList.tsx

import React from 'react';
import styles from './MovieItem.module.css';

interface MovieListProps{
    movies: {
        movieId: number;
        title: string;
        genre: string;
        poster: string;
     }[];
}

/*
이미지 포함 버젼
<div className={styles.movieGrid}>
          {movies.map(movie => (
            <div key={movie.movieId} className={styles.movieCard}>
              <img src={movie.poster} alt={movie.title} />
              <h3>{movie.title}</h3>
            </div>
          ))}
        </div>
*/

const MovieList: React.FC<MovieListProps> = ({ movies }) => {
  
    return (
      <div className={styles.movieListContainer}>
        <h2 className={styles.title}>추천 영화</h2>
        <div className={styles.movieGrid}>
          {movies.map(movie => (
            <div key={movie.movieId} className={styles.movieCard}>
              <h3>{movie.title}</h3>
            </div>
          ))}
        </div>
      </div>
    );
  };

export default MovieList;

 

MovieList의 경우 그리드나 카드의 크기, 타이틀의 사이즈 등을 아래에 있는 별도의 css 파일로 컨트롤한다.

 

 

MovieList.module.css

.movieListContainer {
    width: 100%;
    max-width: 1200px;
    margin: 0 auto;
    padding: 20px;
}

.title {
    text-align: center;
    margin-bottom: 30px;
}

.movieGrid {
    display: flex;
    justify-content: center;
    gap: 20px;
    flex-wrap: wrap;
}

.movieCard {
    width: 200px;
    background-color: #1a1a1a;
    border-radius: 8px;
    overflow: hidden;
    transition: transform 0.2s;
}

.movieCard:hover {
    transform: translateY{-5px};
} 

.movieCard:img{
    width: 100%;
    height: 300px;
    object-fit: cover;
}

.movieCard h3 {
    padding: 10px;
    margin: 0;
    text-align: center;
}

 

 

 

여담

 

개인적으로는 프론트엔드와 Typescript 자체를 처음 다루어봐서 어려웠다.

 

ChatGPT, Gemini를 기반으로 코드를 만들어 달라고 하고 하나하나 console.log로 찍어보거나 고치면서 익숙해져 나갔다.

 

특히 프론트엔드에서 백엔드로 보낼 때 데이터 타입을 매칭시키는게 어려웠다.

 

rating을 DB에서 float로 설정해서 userFloatRating을 만들기전까지 왜 안되지 하면서 시간을 많이 썼다.

 

다음부터는 이를 신경 써서 구현해야겠다. 

 

 

'개발 > Web and Frontend' 카테고리의 다른 글

Typescript Function  (0) 2025.03.31
Typescript Interface  (0) 2025.03.31
Typescript Data Types  (0) 2025.03.31
Typescript 기초  (0) 2025.03.31