[유데미x스나이퍼팩토리] 10주 완성 프로젝트 캠프 - Movie App을 Redux를 사용하여 데이터 흐름 관리해보기

강경서·2023년 7월 4일
0
post-thumbnail

🎬 Movie App을 Redux를 사용하여 데이터 흐름 관리해보기

Movie App을 Redux를 사용하여 데이터 흐름 관리해보기


Movies

  • src/redux/movieSlice.js
const { createSlice } = require("@reduxjs/toolkit");

const initialState = {
  movies: [],
  isLoading: true,
};

const movieSlice = createSlice({
  name: "Movie",
  initialState,
  reducers: {
    updateMovieStore(state, action) {
      return { ...state, ...action.payload };
    },
    resetMovieStore(state, action) {
      return initialState;
    },
  },
});

export const { updateMovieStore, resetMovieStore } = movieSlice.actions;
export default movieSlice.reducer;

Movies의 데이터를 관리해줄 reducer입니다. 간단히 데이터를 상태에 저장하는 action을 가지고 있습니다. 로딩상태도 관리하기 위해 초기값에 로딩값도 추가하였습니다.


  • src/pages/home.js
import React, { useEffect, useState } from "react";
import Header from "components/Header";
import Movie from "components/Movie";
import Styles from "styles/Home.module.css";
import Loader from "components/Loader";
import Footer from "components/Footer";
import useFetchMovies from "libs/useFetchMovies";
import usePagination from "libs/usePagination";
import { useDispatch, useSelector } from "react-redux";
import { updateMovieStore } from "redux/movieSlice";

const Home = () => {
  const dispatch = useDispatch();
  const [pageCount, setPageCount] = useState(10);
  const [inputValue, setInputValue] = useState(10);
  const onSubmit = (event) => {
    event.preventDefault();
    setPageCount(inputValue);
  };

  const { movies, error } = useFetchMovies(
    "https://yts.mx/api/v2/list_movies.json?minimum_rating=8&limit=50&sort_by=like_count"
  );

  const { movies: homeMovies, isLoading: homeIsLoading } = useSelector(
    (state) => state.MovieStore
  );

  const { page, nextPage, prevPage, movePage, maxPage, sliceData } =
    usePagination(homeMovies, pageCount);

  useEffect(() => {
    if (!movies) return;
    dispatch(
      updateMovieStore({ movies: movies.data.movies, isLoading: false })
    );
  }, [movies, dispatch]);

  useEffect(() => {
    if (error) console.log(error);
  }, [error]);

  return (
    <>
      <Header />
      <div className={Styles.home}>
        {!homeIsLoading && sliceData ? (
          <div className={Styles.hone}>
            <form onSubmit={onSubmit} className={Styles.pageForm}>
              <label>Movies per page</label>
              <input
                type="number"
                value={inputValue}
                onChange={(e) => setInputValue(e.target.value)}
              />
            </form>
            <div className={Styles.movieContainer}>
              {sliceData.map((movie) => (
                <Movie
                  key={movie.id}
                  id={movie.id}
                  image={movie.medium_cover_image}
                  title={movie.title}
                  rating={movie.rating}
                  runtime={movie.runtime}
                  year={movie.year}
                />
              ))}
            </div>
            <div className={Styles.pageList}>
              <button onClick={prevPage}>◀︎</button>
              <ul>
                {Array(maxPage)
                  .fill({})
                  .map((_, index) => (
                    <li
                      key={index}
                      onClick={() => movePage(index + 1)}
                      style={{ fontWeight: page === +(index + 1) && "bold" }}
                    >
                      {index + 1}
                    </li>
                  ))}
              </ul>
              <button onClick={nextPage}>▶︎</button>
            </div>
          </div>
        ) : (
          <Loader />
        )}
      </div>
      <Footer />
    </>
  );
};

export default Home;

Home에서 커스텀 훅을 사용하여 데이터를 가져오기 때문에 해당 컴포넌트에서 dispatch를 통해 상태를 저장하고 useSlector를 이용해 실제로 Redux를 통해 관리되는 상태를 사용해 컴포넌트를 출력하였습니다. 덕분에 Home 이외의 컴포넌트에서 해당 영화데이터를 사용한다면 별도의 fetch 작업없이 사용이 가능해졌습니다.


  • src/redux/searchSlice.js
const { createSlice } = require("@reduxjs/toolkit");

const initialState = { movies: [], isLoading: true };

const searchSlice = createSlice({
  name: "Search",
  initialState,
  reducers: {
    updateSearchStore(state, action) {
      return { ...state, ...action.payload };
    },
    resetSearchStore(state, action) {
      return initialState;
    },
  },
});

export const { updateSearchStore, resetSearchStore } = searchSlice.actions;
export default searchSlice.reducer;

searchSlice 또한 위의 movieSlice와 큰 차이는 없습니다.


  • src/components/Header.js
import useSearchMovies from "libs/useSearchMovies";
import React, { useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { updateSearchStore } from "redux/searchSlice";
import Styles from "styles/Header.module.css";

const Header = ({ search = false }) => {
  const navigate = useNavigate();
  const location = useLocation();
  const dispatch = useDispatch();
  const queryParams = new URLSearchParams(location.search);
  const query = queryParams.get("keyword");
  const [keyword, setKeyword] = useState("");

  const onChange = (event) => {
    const {
      target: { value },
    } = event;
    setKeyword(value);
  };
  const onSubmit = (event) => {
    event.preventDefault();
    navigate(`/search?keyword=${keyword}`);
    setKeyword("");
  };
  const { movies, error } = useSearchMovies(query);

  useEffect(() => {
    dispatch(updateSearchStore({ isLoading: true }));
  }, [query]);

  useEffect(() => {
    if (!movies) return;
    dispatch(
      updateSearchStore({ movies: movies.data.movies, isLoading: false })
    );
  }, [movies]);

  useEffect(() => {
    if (error) console.log(error);
  }, [error]);

  return (
    <header className={Styles.header}>
      <div className={Styles.logoContainer}>
        <h1 className={Styles.logo}>Movie App</h1>
        <nav className={Styles.navigation}>
          <ul>
            <li style={{ opacity: location.pathname === "/" && "1" }}>
              <Link to={"/"}>Home</Link>
            </li>
            <li style={{ opacity: location.pathname === "/search" && "1" }}>
              <Link to={"/search"}>Search</Link>
            </li>
            <li style={{ opacity: location.pathname === "/stream" && "1" }}>
              <Link to={"/stream"}>Stream</Link>
            </li>
          </ul>
        </nav>
      </div>
      {search && (
        <form onSubmit={onSubmit} className={Styles.searchForm}>
          <input
            type="text"
            value={keyword}
            onChange={onChange}
            placeholder="Search..."
          />
        </form>
      )}
    </header>
  );
};

export default Header;
  • src/pages/Search.js
import Header from "components/Header";
import Loader from "components/Loader";
import Movie from "components/Movie";
import Styles from "styles/Home.module.css";
import React from "react";
import Footer from "components/Footer";
import { useSelector } from "react-redux";

const Search = () => {
  const { movies, isLoading } = useSelector((state) => state.SearchStore);
  return (
    <>
      <Header search={true} />
      {isLoading ? (
        <Loader text="Search Movie" />
      ) : !movies ? (
        <Loader text="Dose not Exist" />
      ) : (
        <div className={Styles.search}>
          <div className={Styles.movieContainer}>
            {movies.map((movie) => (
              <Movie
                key={movie.id}
                id={movie.id}
                image={movie.medium_cover_image}
                title={movie.title}
                rating={movie.rating}
                runtime={movie.runtime}
                year={movie.year}
              />
            ))}
          </div>
        </div>
      )}
      <Footer />
    </>
  );
};

export default Search;

Redux 사용전에는 Search를 통한 영화 데이터를 fetch하기 위해 필요한 query값을 가진 컴포넌트와 데이터를 출력해야하는 컴포넌트가 달라 따로 커스텀 훅에 함수를 추가하여 해당 함수를 prop으로 전달하는 번거러운 과정이 있었지만, Redux 이후에는 Header에서 query값을 이용해 데이터를 가져와 데이터를 store에 저장하고 Search 컴포넌트에서 store의 데이터를 바로 사용이 가능해졌습니다.


Detail

  • src/redux/detailSlice.js
const { createSlice } = require("@reduxjs/toolkit");

const initialState = { movie: {}, relatedMovies: [], isLoading: true };

const detailSlice = createSlice({
  name: "Detail",
  initialState,
  reducers: {
    updateDetailStore(state, action) {
      return { ...action.payload };
    },
    resetDetailStore(state, action) {
      return initialState;
    },
  },
});

export const { updateDetailStore, resetDetailStore } = detailSlice.actions;
export default detailSlice.reducer;

detailSlice는 초기값에 디테일 데이터 외에도 관련 영화 데이터도 한번에 담아 관리하였습니다.


  • src/pages/Detail.js
import React, { useEffect } from "react";
import Loader from "components/Loader";
import Movie from "components/Movie";
import Styles from "styles/Detail.module.css";
import { Link, useNavigate, useParams } from "react-router-dom";
import Actor from "components/Actor";
import { useDispatch, useSelector } from "react-redux";
import useFetchMovies from "libs/useFetchMovies";
import { updateDetailStore } from "redux/detailSlice";

const Detail = () => {
  const dispatch = useDispatch();
  const navigate = useNavigate();
  const { id } = useParams();

  const { movies, error } = useFetchMovies(
    `https://yts.mx/api/v2/movie_details.json?movie_id=${id}&with_cast=true`
  );

  const { movies: relatedMovies, error: relatedError } = useFetchMovies(
    `https://yts.mx/api/v2/movie_suggestions.json?movie_id=${id}`
  );

  const DetailStore = useSelector((state) => state.DetailStore);

  useEffect(() => {
    if (movies && relatedMovies) {
      dispatch(
        updateDetailStore({
          movie: movies.data.movie,
          relatedMovies: relatedMovies.data.movies,
          isLoading: false,
        })
      );
    }
  }, [movies, relatedMovies, dispatch]);

  useEffect(() => {
    if (error) {
      console.log(error);
    }
    if (relatedError) {
      console.log(relatedError);
    }
  }, [error, relatedError]);

  return (
    <>
      {DetailStore.isLoading ? (
        <Loader />
      ) : (
        <div className={Styles.detail}>
          <div
            style={{
              backgroundImage: `linear-gradient(rgba(0,0,0,0.7), rgba(0,0,0,0.7)), url(${DetailStore.movie.background_image})`,
            }}
            className={Styles.detailBackground}
          >
            <div className={Styles.detailHeader}>
              <button onClick={() => navigate(-1)}></button>
              <button onClick={() => navigate("/")}>𝖷</button>
            </div>
            <div className={Styles.detailContainer}>
              <img
                src={DetailStore.movie.large_cover_image}
                className={Styles.detailProfileImg}
                alt="poster"
              />
              <div className={Styles.detailProfile}>
                <div className={Styles.detailTitle}>
                  <div className={Styles.detailYear}>
                    {DetailStore.movie.year}
                  </div>
                  <h1>{DetailStore.movie.title}</h1>
                </div>
                <div className={Styles.detailRating}>
                  {DetailStore.movie.rating} / 10
                </div>
                <ul className={Styles.detailGenre}>
                  {DetailStore.movie.genres.map((genre) => (
                    <li key={genre}>{genre}</li>
                  ))}
                </ul>
                <div className={Styles.detailRuntime}>
                  {DetailStore.movie.runtime !== 0
                    ? `Runtime : ${DetailStore.movie.runtime}m`
                    : null}
                </div>
                <div className={Styles.detailDescription}>
                  {DetailStore.movie.description_intro.length > 500
                    ? DetailStore.movie.description_intro.substring(0, 500) +
                      "..."
                    : DetailStore.movie.description_intro}
                </div>
                {DetailStore.movie.cast ? (
                  <>
                    <div className={Styles.detailActorTitle}>Actor</div>
                    <div className={Styles.detailActorList}>
                      {DetailStore.movie.cast.map((cast) => (
                        <Actor
                          key={cast.imdb_code}
                          img={cast.url_small_image}
                          name={cast.name}
                          character={cast.character_name}
                        />
                      ))}
                    </div>
                  </>
                ) : null}
              </div>
              <div>
                <div className={Styles.related}>
                  <div className={Styles.relatedTitle}>Related Movie</div>
                  <ul className={Styles.relatedList}>
                    {DetailStore.relatedMovies.map((movie) => (
                      <Movie
                        key={movie.id}
                        id={movie.id}
                        image={movie.medium_cover_image}
                        title={movie.title}
                        rating={movie.rating}
                        runtime={movie.runtime}
                        year={movie.year}
                        type="small"
                      />
                    ))}
                  </ul>
                </div>

                <div className={Styles.detailLink}>
                  <div className={Styles.detailLinkTitle}>Link</div>
                  <div className={Styles.detailLinkList}>
                    <Link
                      to={`https://www.youtube.com/embed/${DetailStore.movie.yt_trailer_code}?rel=0&wmode=transparent&border=0&autoplay=1&iv_load_policy=3`}
                    >
                      YOUTUBE LINK
                    </Link>
                    <Link to={DetailStore.movie.url}>MOVIE LINK</Link>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      )}
    </>
  );
};

export default Detail;

각기 다른 API를 통해 다른 데이터를 불러왔지만 Redux에 상태를 저장할때는 하나의 store에 담아 관리하여 더욱 데이터 사용에 편리했습니다.


🖥 Stream

다른 이용자와 영상을 보면서 채팅이 가능한 Stream 페이지를 간단히 만들어보았습니다.
Redux를 이용하여 chat 데이터를 관리하였습니다. 다만 아직 socket.io의 room을 사용하지 않아 간단하게 관리가 가능하지만 만약 room이 생겨 각기 다른 chat 데이터를 관리해야한다면 어떻게 구조를 만들어야 하는지 생각하는 시간을 가질 수 있었습니다. 그래도 Redux덕분에 누군가 중간에 Stream에 들어와도 이전 chat을 확인이 가능하게 되었습니다.


Stream

Github 링크

  • src/pages/Stream.js
import Header from "components/Header";
import React, { useState } from "react";
import Footer from "components/Footer";
import Styles from "styles/Stream.module.css";
import StreamBlock from "components/StreamBlock";

const Stream = () => {
  const [streams, setStreams] = useState([]);
  const [addBoxHidden, setAddBoxHidden] = useState(true);
  const [title, setTitle] = useState("");
  const [content, setContent] = useState("");
  const [url, setUrl] = useState("");
  const onSubmit = (event) => {
    event.preventDefault();
    setStreams((pre) => [...pre, { title, content, url }]);
    setAddBoxHidden(true);
  };
  return (
    <>
      <Header />
      <div className={Styles.stream}>
        <div className={Styles.streamContainer}>
          {streams.map((stream, index) => (
            <StreamBlock
              key={index}
              title={stream.title}
              content={stream.content}
              id={index}
              url={stream.url}
            />
          ))}
        </div>
      </div>
      <div
        className={Styles.streamAdd}
        style={{ display: addBoxHidden ? "none" : "flex" }}
      >
        <div className={Styles.streamAddBox}>
          <div className={Styles.streamAddTitle}>New Stream</div>
          <form className={Styles.streamAddForm} onSubmit={onSubmit}>
            <input
              type="string"
              placeholder="Title"
              value={title}
              onChange={(e) => setTitle(e.target.value)}
            />
            <input
              type="string"
              placeholder="Content"
              value={content}
              onChange={(e) => setContent(e.target.value)}
            />
            <input
              type="string"
              placeholder="URL"
              value={url}
              onChange={(e) => setUrl(e.target.value)}
            />
            <input
              type="submit"
              value={"Create"}
              className={Styles.streamAddSubmit}
            />
            <input
              type="button"
              value={"Cancle"}
              className={Styles.streamAddCancel}
              onClick={() => setAddBoxHidden(true)}
            />
          </form>
        </div>
      </div>
      <div
        className={Styles.streamAddBtn}
        onClick={() => setAddBoxHidden(false)}
      >
        +
      </div>
      <Footer />
    </>
  );
};

export default Stream;

Stream 페이지에서 Stream을 바로 생성할 수 있습니다.


Stream Block

Github 링크

  • src/components/StreamBlock.js
import React from "react";
import { Link } from "react-router-dom";
import Styles from "styles/StreamBlock.module.css";

const StreamBlock = ({ title, content, id, url }) => {
  return (
    <Link to={`/stream/${id}`} state={{ url }}>
      <div className={Styles.StreamBlock}>
        <div className={Styles.StreamBlockTitle}>{title}</div>
        <div>🎬 {content}</div>
      </div>
    </Link>
  );
};

export default StreamBlock;

Link를 통해 prop으로 받아온 id를 param으로 사용하여 각기 다른 Stream이 재생되는 페이지로 이동이 가능하며 Link의 state 속성을 사용하여 url을 따로 담아서 보냈습니다.


Stream Box

Github 링크

  • src/pages/StreamBox.js
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useLocation } from "react-router-dom";
import { addChatStore } from "redux/chatSlice";
import { io } from "socket.io-client";
import Styles from "styles/StreamBox.module.css";

const socket = io("http://localhost:3001");

const StreamBox = () => {
  const dispatch = useDispatch();
  const location = useLocation();
  const url = location.state.url;
  const query = new URL(url).searchParams.get("v");
  const [messages, setMessages] = useState([]);
  const [username, setUsername] = useState("");
  const [inputValue, setInputValue] = useState("");

  useEffect(() => {}, []);

  const onSubmit = (event) => {
    event.preventDefault();
    if (inputValue.trim() !== "") {
      const currentTime = new Date().toLocaleDateString();
      const id = Date.now();
      socket.emit("message", {
        id,
        username,
        content: inputValue,
        time: currentTime,
      });
      dispatch(
        addChatStore({ id, username, content: inputValue, time: currentTime })
      );
      setInputValue("");
    }
  };

  const chats = useSelector((state) => state.ChatStore);

  const handleMessage = (message) => {
    setMessages((pre) => [...pre, message]);
  };

  useEffect(() => {
    socket.on("message", handleMessage);
    return () => {
      socket.off("message", handleMessage);
    };
  }, []);
  return (
    <>
      <div className={Styles.streamBox}>
        <div className={Styles.streamContainer}>
          <iframe
            src={`https://www.youtube.com/embed/${query}`}
            title="YouTube video player"
            frameBorder="0"
            allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
            allowFullScreen
          ></iframe>
          <div className={Styles.streamChat}>
            <div className={Styles.streamChatTitle}>실시간 채팅</div>
            <ul className={Styles.streamChatContainer}>
              {chats.map((message) => (
                <li key={message.id}>
                  {message.username} : {message.content} - {message.time}
                </li>
              ))}
            </ul>
            <form className={Styles.streamChatForm} onSubmit={onSubmit}>
              <input
                type="text"
                placeholder="사용자 이름"
                value={username}
                onChange={(e) => setUsername(e.target.value)}
              />
              <input
                type="text"
                placeholder="메세지"
                value={inputValue}
                onChange={(e) => setInputValue(e.target.value)}
              />
              <input
                type="submit"
                value={"전송"}
                className={Styles.streamBoxChatSubmit}
              />
            </form>
          </div>
        </div>
      </div>
    </>
  );
};

export default StreamBox;

Stream이 재생되는 페이지에는 영상을 재생하는 공간과 채팅을 할 수 있는 공간이 같이 있습니다. Link의 state를 통해 받아온 url을 useLocation을 이용하여 받아와 query값만 추출하여 iframe의 src에 넣어 영상을 재생합니다. 채팅은 socket.io를 통해 만들었으면 채팅 데이터는 Redux를 통해 관리합니다. 덕분에 중간에 Stream에 들어와도 이전 채팅 데이터를 확인이 가능하게 되었습니다.


결과


📝 후기

직접 만든 무비 앱에 Redux를 시용해보면서 Redux를 익혀보았습니다. 영화 데이터뿐만 아니라 채팅 데이터도 Redux를 통해 관리하면서 전역적인 상태 관리의 이점에 대해 알 수 있었습니다.



본 후기는 유데미-스나이퍼팩토리 10주 완성 프로젝트캠프 학습 일지 후기로 작성 되었습니다.

#프로젝트캠프 #프로젝트캠프후기 #유데미 #스나이퍼팩토리 #웅진씽크빅 #인사이드아웃 #IT개발캠프 #개발자부트캠프 #리액트 #react #부트캠프 #리액트캠프

profile
기록하고 배우고 시도하고

0개의 댓글