[ReactJS로 영화 웹 서비스 만들기] Chapter07. PRACTICE MOVIE APP

IRISH·2024년 4월 11일

ReactJS-Movie-Web-Service

목록 보기
6/23
post-thumbnail

7.0 To Do List part One

  • 학습 일자 : 240401

내용

  • form은 submit 이벤트를 갖고 있음
    • event.preventDefault(); 를 활용하여 새로 고침 방지
  • toDo, toDos나 직접적으로 수정 불가능
    • 함수를 가져와서 수정하게 만들어야함(setToDo, setToDos)

코드

⇒ App.js

import { useState, useEffect } from "react";

function App() {
  // toDo, toDos나 직접적으로 수정 불가능 >>> 함수를 가져와서 수정하게 만들어야함(setToDo, setToDos)
  const [toDo, setToDo] = useState("");
  const [toDos, setToDos] = useState([]);

  const onChange = (event) => setToDo(event.target.value);
  const onSubmit = (event) => {
    event.preventDefault(); // 새로 고침 방지
    if (toDo === "") {
      return;
    }
    setToDos((currentArray) => [toDo, ...currentArray]);
    setToDo("");
  };
  console.log(toDos);

  return (
    <div>
      <h1>My To Dos ({toDos.length})</h1>
      {/* form은 submit 이벤트를 갖고 있음 */}
      <form onSubmit={onSubmit}>
        <input
          onChange={onChange}
          value={toDo}
          type="text"
          placeholder="Write your to do..."
        />
        <button>Add To Do</button>
      </form>
    </div>
  );
}

export default App;
  • form은 submit 이벤트를 갖고 있음
    • event.preventDefault(); 를 활용하여 새로 고침 방지
  • toDo, toDos나 직접적으로 수정 불가능
    • 함수를 가져와서 수정하게 만들어야함(setToDo, setToDos)
  • setToDos(currentArray => [toDo, ...currentArray]);
    • ...을 써서 currentArray 배열에 toDo를 추가 시켜줌
    • 어플리케이션이 시작될 때는 비어있는 배열을 가짐
    • 첫 번째 to-do를 입력할 때 비어있는 currentArray 받아옴
      • 이건 새로운 toDos가 input을 통해 작성한 toDo와 아무것도 들어있지 않은 빈 배열의 element가 더해지게 된다는 것
    • 첫 번째 toDo 가 Hello라면 엔터를 눌러 실행됨
      • 그리고 byebye라고 적으면
      • currentArray에는 Hello 이미 있고 toDo는 byebye가 되는 것
    • 그리고 currentArray는 Hello와 byebye를 가지고 있는 배열이 됨

결과

7.1 To Do List part Two

  • 학습 일자 : 240401

내용

  • toDo 목록을 차례대로 화면에 보여주기
    • map((item, index) => {item}) 활용

코드

import { useState, useEffect } from "react";

function App() {
  // toDo, toDos나 직접적으로 수정 불가능 >>> 함수를 가져와서 수정하게 만들어야함(setToDo, setToDos)
  const [toDo, setToDo] = useState("");
  const [toDos, setToDos] = useState([]);

  const onChange = (event) => setToDo(event.target.value);
  const onSubmit = (event) => {
    event.preventDefault(); // 새로 고침 방지
    if (toDo === "") {
      return;
    }
    setToDos((currentArray) => [toDo, ...currentArray]);
    setToDo("");
  };
  console.log(toDos);

  return (
    <div>
      <h1>My To Dos ({toDos.length})</h1>
      {/* form은 submit 이벤트를 갖고 있음 */}
      <form onSubmit={onSubmit}>
        <input
          onChange={onChange}
          value={toDo}
          type="text"
          placeholder="Write your to do..."
        />
        <button>Add To Do</button>
      </form>
      <hr />
      <ul>
        {/* map((item, index) => {item}) >>> argument = 값 // index  = 숫자 */}
        {toDos.map((item, index) => (
          <li key={index}>{item}</li>
        ))}
      </ul>
    </div>
  );
}

export default App;
  • map() 함수
    • 배열을 가지고 있을 때 각각의 element들을 바꿀 수 있게 해줌map() 은 ()에 함수를 넣을 수 있는데 배열의 모든 item에 대해 실행됨
      • 즉 배열에 6개의 item이 있다면 6번 함수가 실행됨
      • 그리고 그 함수로부터 내가 return한 값은 새로운 배열에 들어가게 함
      • [‘a’, ‘b’, ‘c’, ‘d’, ‘e’, ‘f’].map(() => “:)”)
        • [‘:)’, ‘:)’, ‘:)’, ‘:)’, ‘:)’ ‘:)’] 인 새 배열을 만들어줌
        • 다만 기존의 배열에 접근할 수 없게됨
    • map은 함수의 첫 번째 argument로 현재의 item을 가지고 올 수 있음
      • map(item) -> item이나 원하는 어떤 변수명을 넣으면 item자체를 리턴하는 것도 가능
      • map((item) => item.toUpperCase(); 로 하면 item이 대문자로 바뀐 새로운 배열은 만들어줌
  • 리액트는 기본적으로 list에 있는 모든 item을 인식하기 때문에 key를 넣어 고유하게 만들어줘야함
  • map의 첫 번째 argument는 값이고 두번째는 index 즉 숫자를 의미함
    • 그래서 {toDos.map((item, index) => {item})} 만들어줌
    • 즉, {{item},{item},{item}...} 배열을 만들어 각자 고유의 key를 가지게 함

결과

7.2 Coin Tracker

  • 학습 일자 : 240401

내용

  • 코인 API 활용하기

코드

⇒ App.js

import { useEffect, useState } from "react";

function App() {
  const [loading, setLoading] = useState(true);
  const [coins, setCoins] = useState([]);
  useEffect(() => {
    fetch("https://api.coinpaprika.com/v1/tickers")
      .then((response) => response.json())
      .then((json) => {
        setCoins(json);
        setLoading(false);
      });
  }, []);

  return (
    <div>
      <h1>The Coins! {loading ? "" : `(${coins.length})`}</h1>
      {loading ? (
        <strong>Loading...</strong>
      ) : (
        <select>
          {coins.map((coin) => (
            <option>
              {coin.name} ({coin.symbol}: ${coin.quotes.USD.price})
            </option>
          ))}
        </select>
      )}
    </div>
  );
}

export default App;
  • 코인 API에는 이미 key가 있으므로 안 가져와도 됨
  • coin이라는 변수는 coins 배열 안에 있는 각각의 coin을 의미함
  • 처음에는 기본값으로 비어있는 배열을 넘겨주기 때문에 coin이 처음엔 0개
  • 기본값을 적어도 빈값으로 정해주지 않으면 에러가 남

결과

7.3 Movie App part One

  • 학습 일자 : 240402

내용

  • Movie API 활용하기
  • then 대신 async과 await 활용하기

코드

⇒ App.js

import { useEffect, useState } from "react";

function App() {
  const [loading, setLoading] = useState(true);
  const [movies, setMovies] = useState([]);
  const getMovies = async () => {
    const json = await (
      await fetch(
        `https://yts.mx/api/v2/list_movies.json?minimum_rating=8.8&sort_by=year`
      )
    ).json();
    setMovies(json.data.movies);
    setLoading(false);
  };
  useEffect(() => {
    getMovies();
  }, []);
  return (
    <div>
      {loading ? (
        <h1>Loading...</h1>
      ) : (
        <div>
          {movies.map((movie) => (
            <div key={movie.id}>
              <img src={movie.medium_cover_image} />
              <h2>{movie.title}</h2>
              <p>{movie.summary}</p>
              <ul>
                {movie.genres.map((g) => (
                  <li key={g}>{g}</li>
                ))}
              </ul>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}
export default App;
  • fetch, json을 진행 후 로딩을 끝냈기 때문에 반드시 setLoading(false)를 해줘야함
  • then대신에 async-await를 보편적으로 사용함
    • await을 감싸는 await을 만들 수 있음
    • movies.map((movie
      • map의 argument는 x, m, g 등등 마음대로 해도됨
      • 여기선 movie라고 정함
  • div key={movie.id} h2{movie.title}/h2
    • 이 컴포넌트들은 movie 배열에 있는 각 movie에서 변형되어 나온 것
  • key={g}
    • 따로 정해진 key가 없기 때문에 g를 가져와 key로 써줌
    • 단, g가 고유한 값일 경우에만 가능

결과

  • movie API를 활용하여 깔끔하게 화면을 구성한 것 같다.

7.4 Movie App part Two

  • 학습 일자 : 240402

내용

  • react-router-dom 설치
    • npm i react-router-dom@5.3.0
      • 강의와 동일한 버전을 하기 위해 5.3.0 으로 설치
  • home 라우트(페이지)는 모든 영화를 보여주고 Movie 라우트는 영화 하나만 보여줌
    • 이렇게 라우트 별로 생각해야함
  • home 라우트는 기본적으로 App 컴포넌트 전체를 가지고 있게 만듦

코드

⇒ src/App.js

function App() {
  return null;
}

export default App;
  • 이전 강의까지에서 했었던 App에 있는 것을 모두 Home라우트로 옮겼으니 App.js는 라우터를 렌더한다.

⇒ src/routes.Home.js

import { useEffect, useState } from "react";
import Movie from "../components/Movie";

function Home() {
  const [loading, setLoading] = useState(true);
  const [movies, setMovies] = useState([]);
  const getMovies = async () => {
    const json = await (
      await fetch(
        `https://yts.mx/api/v2/list_movies.json?minimum_rating=8.8&sort_by=year`
      )
    ).json();
    setMovies(json.data.movies);
    setLoading(false);
  };
  useEffect(() => {
    getMovies();
  }, []);
  return (
    <div>
      {loading ? (
        <h1>Loading...</h1>
      ) : (
        <div>
          {movies.map((movie) => (
            <Movie
              key={movie.id}
              coverImg={movie.medium_cover_image}
              title={movie.title}
              summary={movie.summary}
              genres={movie.genres}
            />
          ))}
        </div>
      )}
    </div>
  );
}
export default Home;
  • home 라우트(페이지)는 모든 영화를 보여줌
  • home 라우트는 기본적으로 App 컴포넌트 전체를 가지고 있게 만듦
  • coverImg={movie.medium_cover_image}
    • 자바스크립트에서는 medium_cover_image가 아닌mediumCoverImage로 쓰지만 내가 만든 컴포넌트라 아무렇게 써도 됨.
    • 그러나 movie.medium_cover_image 에서는 API에서 가져오므로 API 정보와 똑같이 써야함
  • 이미지 element들을 alt속성을 가짐 -> alt={title}

⇒ src/routes.Detail.js

function Detail() {
  return <h1>Detail</h1>;
}
export default Detail;

⇒ src/components/Movie.js

import PropTypes from "prop-types";

function Movie({ coverImg, title, summary, genres }) {
  return (
    <div>
      <img src={coverImg} alt={title} />
      <h2>{title}</h2>
      <p>{summary}</p>
      <ul>
        {genres.map((g) => (
          <li key={g}>{g}</li>
        ))}
      </ul>
    </div>
  );
}

Movie.propTypes = {
  coverImg: PropTypes.string.isRequired,
  title: PropTypes.string.isRequired,
  summary: PropTypes.string.isRequired,
  genres: PropTypes.arrayOf(PropTypes.string).isRequired,
};

export default Movie;
  • Movie 라우트는 영화 하나만 보여줌
  • Movie 컴포넌트는 medium_cover_image, title, summary, genres
    • 이 props를 모두 부모 컴포넌트로부터 받아옴

7.5 React Router

  • 학습 일자 : 240402

내용

코드

⇒ src/App.js

import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import Detail from "./routes/Detail";
import Home from "./routes/Home";
function App() {
  return (
    <Router>
      <Switch>
        <Route path="/hello">
          <h1>Hello</h1>
        </Route>
        <Route path="/movie">
          <Detail />
        </Route>
        <Route path="/">
          <Home />
        </Route>
      </Switch>
    </Router>
  );
}

export default App;
  • 웹 url에서 http://localhost:3000 뒤에 "/hello", "/movie", "/" 를 붙이는 것에 따른 결과가 다르게 나타남
    • "/hello" 의 경우
      • 화면에 Hello 출력
    • "/movie" 의 경우
      • src/routes/Detail.js 의 내용이 나옴

        // src/routes/Detail.js
        function Detail() {
          return <h1>Detail</h1>;
        }
        export default Detail;
        
    • "/" 으로 오면 다시 영화 목록들 출연

⇒ src/components/Movie.js

import PropTypes from "prop-types";
import { Link } from "react-router-dom";

function Movie({ coverImg, title, summary, genres }) {
  return (
    <div>
      <img src={coverImg} alt={title} />
      <h2>
        <Link to="/movie">{title}</Link>
      </h2>
      <p>{summary}</p>
      <ul>
        {genres.map((g) => (
          <li key={g}>{g}</li>
        ))}
      </ul>
    </div>
  );
}

Movie.propTypes = {
  coverImg: PropTypes.string.isRequired,
  title: PropTypes.string.isRequired,
  summary: PropTypes.string.isRequired,
  genres: PropTypes.arrayOf(PropTypes.string).isRequired,
};

export default Movie;
  • Link
    • 이거를 사용하지 않으면 라우팅을 할 때마다 페이지가 새로고침 됨
    • 이 문제를 방지하기 위해 Link 를 react-router-dom을 통해 import 함

결과

http://localhost:3000/

http://localhost:3000/movie 을 치거나 각 영화의 제목을 클릭한 경우

http://localhost:3000/movie 을 치거나 각 영화의 제목을 클릭한 경우

http://localhost:3000/hello

7.6 Parameters

  • 학습 일자 : 240403

내용

코드

⇒ src/App.js

import { BrowserRouter as Router, Switch, Route } from "react-router-dom";
import Detail from "./routes/Detail";
import Home from "./routes/Home";
function App() {
  return (
    <Router>
      <Switch>
        <Route path="/abot-us">
          <h1>Hello</h1>
        </Route>
        <Route path="/movie/:id">
          <Detail />
        </Route>
        <Route path="/">
          <Home />
        </Route>
      </Switch>
    </Router>
  );
}

export default App;
  • - id 앞에 : 을 붙여줘야 한다. - 안붙여주면 그냥 텍스트이다. - 붙인 경우 → 영화 제목했을 때 각 영화의 id 값이 Detail로 넘어간다.

⇒ src/components/Movie.js

import PropTypes from "prop-types";
import { Link } from "react-router-dom";

function Movie({ id, coverImg, title, summary, genres }) {
  return (
    <div>
      <img src={coverImg} alt={title} />
      <h2>
        <Link to={`/movie/${id}`}>{title}</Link>
      </h2>
      <p>{summary}</p>
      <ul>
        {genres.map((g) => (
          <li key={g}>{g}</li>
        ))}
      </ul>
    </div>
  );
}

Movie.propTypes = {
  id: PropTypes.number.isRequired,
  coverImg: PropTypes.string.isRequired,
  title: PropTypes.string.isRequired,
  summary: PropTypes.string.isRequired,
  genres: PropTypes.arrayOf(PropTypes.string).isRequired,
};

export default Movie;
  • 파라미터에 id 추가
  • {title} - user가 화면에서 제목을 클릭했을 때 url을 “http://localhost:3000/movie/60810” 와 같이 볼 수 있게 한다.
  • Movie.propTypes
    • id: PropTypes.number.isRequired 을 추가한다.

⇒ src/routes/Detail.js

import { useEffect } from "react";
import { useParams } from "react-router-dom";
function Detail() {
  const { id } = useParams();
  const getMovie = async () => {
    const json = await (
      await fetch(`https://yts.mx/api/v2/movie_details.json?movie_id=${id}`)
    ).json();
    console.log(json);
  };
  useEffect(() => {
    getMovie();
  }, []);
  return <h1>Detail</h1>;
}
export default Detail;
  • 영화의 정보를 async / await 을 통해 json화 한다.

⇒ src/routes/Home.js

import { useEffect, useState } from "react";
import Movie from "../components/Movie";

function Home() {
  const [loading, setLoading] = useState(true);
  const [movies, setMovies] = useState([]);
  const getMovies = async () => {
    const json = await (
      await fetch(
        `https://yts.mx/api/v2/list_movies.json?minimum_rating=8.8&sort_by=year`
      )
    ).json();
    setMovies(json.data.movies);
    setLoading(false);
  };
  useEffect(() => {
    getMovies();
  }, []);
  return (
    <div>
      {loading ? (
        <h1>Loading...</h1>
      ) : (
        <div>
          {movies.map((movie) => (
            <Movie
              key={movie.id}
              id={movie.id}
              coverImg={movie.medium_cover_image}
              title={movie.title}
              summary={movie.summary}
              genres={movie.genres}
            />
          ))}
        </div>
      )}
    </div>
  );
}
export default Home;
  • id={movie.id} 를 셋팅해준다.

코드 챌린지

  • Home에서 해줬던 loading을 Detail에 해주기
  • movie가 State에 없음. 현재 API에서 json을 받아와서 아무것도 안 하고 있는 상태.
    • 힌트: json을 state에 넣어보기

⇒ src/components/MovieInfo.js

  • Home에서 특정 영화 제목 클릭 시 “http://localhost:3000/movie/58831” 와 같은 형식에서 특정 영화의 상세 내용을 보여줄 수 있는 사용자 정의함수 코드를 짜줌
function MovieInfo({ description, downCount, genre, coverImg, title }) {
  return (
    <div>
      <img src={coverImg} alt={title} />
      <h2>{title}</h2>
      <ul>
        {genre.map((g) => (
          <li key={g}>{g}</li>
        ))}
      </ul>
      <p>DownLoaded Count: {downCount}</p>
      <p>{description}</p>
    </div>
  );
}

export default MovieInfo;

⇒ src/routes/Detail.js

import { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import MovieInfo from "../components/MovieInfo";

function Detail() {
  const [loading, setLoading] = useState(true);
  const [movie, setMovie] = useState([]);
  const { id } = useParams();
  console.log(id);
  const getMovie = async () => {
    const json = await (
      await fetch(`https://yts.mx/api/v2/movie_details.json?movie_id=${id}`)
    ).json();
    setMovie(json.data.movie);
    setLoading(false);
  };
  useEffect(() => {
    getMovie();
  }, []);
  return (
    <div>
      {loading ? (
        <h1>Loading...</h1>
      ) : (
        <MovieInfo
          description={movie.description_full}
          downCount={movie.download_count}
          genre={movie.genres}
          coverImg={movie.medium_cover_image}
          title={movie.title_long}
        />
      )}
    </div>
  );
}

export default Detail;
  • src/components/MovieInfo.js 에서 짠 컴포넌트를 import 하여 사용자에게 HTML화 하여 보여주기

7.9 Parameters

  • 학습 일자 : 240404

내용

  • useCallback
  • CSS

코드

⇒ src/routes/Detail.js

import { useState, useEffect, useCallback } from "react";
import { useParams } from "react-router-dom";
import MovieInfo from "../components/MovieInfo";

function Detail() {
  const [loading, setLoading] = useState(true);
  const [movie, setMovie] = useState([]);
  const { id } = useParams();

  const getMovie = useCallback(async () => {
    // Use useCallback to memoize getMovie
    const json = await (
      await fetch(`https://yts.mx/api/v2/movie_details.json?movie_id=${id}`)
    ).json();
    setMovie(json.data.movie);
    setLoading(false);
  }, [id]); // Depend on id, although it's stable for the component lifecycle

  useEffect(() => {
    getMovie();
  }, [getMovie]); // Include getMovie in the dependency array

  return (
    <div>
      {loading ? (
        <h1>Loading...</h1>
      ) : (
        <MovieInfo
          description={movie.description_full}
          downCount={movie.download_count}
          genre={movie.genres}
          coverImg={movie.medium_cover_image}
          title={movie.title_long}
        />
      )}
    </div>
  );
}

export default Detail;
  • useCallback

⇒ src/style.css

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  padding: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
    Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
  background-color: #eff3f7;
  height: 100%;
}

⇒ src/index.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import "./styles.css";

ReactDOM.render(<App />, document.getElementById("root"));

⇒ src/components/Movie.module.css

.movie {
  background-color: white;
  margin-bottom: 70px;
  font-weight: 300;
  padding: 20px;
  border-radius: 5px;
  color: #adaeb9;
  display: grid;
  grid-template-columns: minmax(150px, 1fr) 2fr;
  grid-gap: 20px;
  text-decoration: none;
  color: inherit;
  box-shadow: 0 13px 27px -5px rgba(50, 50, 93, 0.25),
    0 8px 16px -8px rgba(0, 0, 0, 0.3), 0 -6px 16px -6px rgba(0, 0, 0, 0.025);
}

.movie__img {
  position: relative;
  top: -50px;
  max-width: 150px;
  width: 100%;
  margin-right: 30px;
  box-shadow: 0 30px 60px -12px rgba(50, 50, 93, 0.25),
    0 18px 36px -18px rgba(0, 0, 0, 0.3), 0 -12px 36px -8px rgba(0, 0, 0, 0.025);
}

.movie__title,
.movie__year {
  margin: 0;
  font-weight: 300;
  text-decoration: none;
}

.movie__title a {
  margin-bottom: 5px;
  font-size: 24px;
  color: #2c2c2c;
  text-decoration: none;
}

.movie__genres {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-wrap: wrap;
  margin: 5px 0px;
}

.movie__genres li,
.movie__year {
  margin-right: 10px;
  font-size: 14px;
}

⇒ src/components/Movie.js

import PropTypes from "prop-types";
import { Link } from "react-router-dom";
import styles from "./Movie.module.css";

function Movie({ id, coverImg, title, year, summary, genres }) {
  return (
    <div className={styles.movie}>
      <img src={coverImg} alt={title} className={styles.movie__img} />
      <div>
        <h2 className={styles.movie__title}>
          <Link to={`/movie/${id}`}>{title}</Link>
        </h2>
        <h3 className={styles.movie__year}>{year}</h3>
        <p>{summary.length > 235 ? `${summary.slice(0, 235)}...` : summary}</p>
        <ul className={styles.movie__genres}>
          {genres.map((g) => (
            <li key={g}>{g}</li>
          ))}
        </ul>
      </div>
    </div>
  );
}

Movie.propTypes = {
  id: PropTypes.number.isRequired,
  coverImg: PropTypes.string.isRequired,
  title: PropTypes.string.isRequired,
  summary: PropTypes.string.isRequired,
  genres: PropTypes.arrayOf(PropTypes.string).isRequired,
};

export default Movie;
  • Movie.module.css 적용
  • slice 메서드

⇒ src/routes/Home.module.css

.container {
  height: 100%;
  display: flex;
  justify-content: center;
}

.loader {
  width: 100%;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  font-weight: 300;
}

.movies {
  display: grid;
  grid-template-columns: repeat(2, minmax(400px, 1fr));
  grid-gap: 100px;
  padding: 50px;
  width: 80%;
  padding-top: 70px;
}

@media screen and (max-width: 1090px) {
  .movies {
    grid-template-columns: 1fr;
    width: 100%;
  }
}
  • 반응형
    • .movies
    • @media screen and (max-width: 1090px)

⇒ src/routes/Home.js

import { useEffect, useState } from "react";
import Movie from "../components/Movie";
import styles from "./Home.module.css";

function Home() {
  const [loading, setLoading] = useState(true);
  const [movies, setMovies] = useState([]);
  const getMovies = async () => {
    const json = await (
      await fetch(
        `https://yts.mx/api/v2/list_movies.json?minimum_rating=8.8&sort_by=year`
      )
    ).json();
    setMovies(json.data.movies);
    setLoading(false);
  };
  useEffect(() => {
    getMovies();
  }, []);
  return (
    <div className={styles.container}>
      {loading ? (
        <div className={styles.loader}>
          <span>Loading...</span>
        </div>
      ) : (
        <div className={styles.movies}>
          {movies.map((movie) => (
            <Movie
              key={movie.id}
              id={movie.id}
              year={movie.year}
              coverImg={movie.medium_cover_image}
              title={movie.title}
              summary={movie.summary}
              genres={movie.genres}
            />
          ))}
        </div>
      )}
    </div>
  );
}
export default Home;

결과

⇒ 메인 홈

⇒ 특정 영화 선택

profile
#Software Engineer #IRISH

0개의 댓글