[React] 배운 내용 토대로 좋아요 기능 구현하기

Yunhye Park·2023년 12월 6일
0
post-custom-banner

배운 개념을 이론으로 정리하면 쉽고 간단하다. 하지만 쉬운 건 금방 잊혀지기 마련. 의미 있는 걸 해야 의욕이 생기니까 목적을 구체화하며 큰 틀을 짰다.

Want To Do

  1. 리액트 특성 상 데이터가 자주 바뀌고, 레이아웃 구성이 동일한 페이지를 구현해 보는 게 좋겠다.
    • 카드 컴포넌트를 만들자!
  2. 수업 실습에서 API 받아오는 게 흥미로웠다.
    • 프로젝트에서 사용한 API 복습 겸 다시 가져오되
    • axios는 많이 써봤으니까 fetch로
    • API KEY는 환경변수로 보안 설정
  3. css 중복 방지를 위해 module.css로 작성
  4. click event로 좋아요 토글 기능

완성

복습/공부한 개념

  • state, useEffect, env 설정, module.css, map, click event, fetch, 오픈 API 가져오기, fontawesome 아이콘 적용

우선 전체 코드를 공유하고, 개념별로 코드를 설명해가며 정리해 볼 생각이다.

// App.js
import Card from './components/Card';

function App() {
  return (
    <div>
      <Card />
    </div>
  );
}

export default App;
// Card.js
import { useState, useEffect } from 'react';
import styles from '../styles/card.module.css';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHeart as heartIcon } from '@fortawesome/free-solid-svg-icons';

export default function Card() {
  const apiKey = process.env.REACT_APP_API_KEY;
  const [movie, setMovie] = useState([]);
  const [isLike, setIsLike] = useState([]);

  // 영화 API 가져오기
  const getMovies = async () => {
    const response =
      await fetch(`https://api.themoviedb.org/3/movie/now_playing?api_key=${apiKey}&language=ko-KR
    `);
    const movielist = await response.json();
    setMovie(movielist.results);
  };

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

  // 좋아요 개별 동작
  const handleHeart = (index) => {
    setIsLike(() => {
      const newLike = [...isLike];
      newLike[index] = !newLike[index];
      return newLike;
    });
  };

  return (
    <>
      <h1 className={styles.listTitle}>Now Playing</h1>
      <div className={styles.cardList}>
        {movie.map((value, i) => (
          <div key={value.id} className={styles.cardBox}>
            <div className={styles.cardPoster}>
              <img
                src={`https://image.tmdb.org/t/p/w200${value.poster_path}`}
                alt=""
              />
              <div className={styles.heartBox}>
                <FontAwesomeIcon
                  icon={heartIcon}
                  onClick={() => {
                    handleHeart(i);
                  }}
                  className={`${styles.heart} ${isLike[i] && styles.full}`}
                />
              </div>
            </div>
            <div className={styles.cardInfo}>
              <h3 className={styles.cardTitle}>{value.title}</h3>
            </div>
          </div>
        ))}
      </div>
    </>
  );
}

화면을 만들고자 할 때 가장 먼저 할 일은 컴포넌트의 html과 css를 만드는 것이다.

module.css

일반적인 css는 전역에서 사용하기에 다른 컴포넌트에서 같은 이름을 쓸 때 충돌이 발생할 수 있다. BEM 규칙 등으로 네이밍하는 방법도 있지만, 아예 컴포넌트별로 파일을 만들어주는 것도 방법이다. (module.css)

모듈(파일)마다 다른 스코프를 가지기 때문에 동일한 클래스명으로 작성해도 해시값이 달라 충돌이 일어나지 않는다.

이번 컴포넌트는 card.module.css로 만들었다. 이걸 import한 컴포넌트 파일에서 하나의 변수로 담아와서 각 클래스명을 객체로 접근한다.

예시를 들자면,

import styles from '../styles/card.module.css';

<h3 className={styles.cardTitle}>{value.title}</h3>
.cardTitle {
  font-size: 19px;
  font-weight: 600;
  padding: 20px;
  height: 10vh;
  overflow: hidden;
  text-overflow: ellipsis;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

이걸 .cardTitle_hash값으로 인지한다. 그래서

.card를 만들었는데, 개발자도구로 보면 .card_zyjeIElYZ이다. 그래서 네이밍을 똑같이 해도 동일한 클래스로 간주되지 않는다.

많은 css 중에서 굳이 저걸 가져온 이유가 있다. 바로 텍스트 말줄임을 설정한 css이기도 하다.

텍스트 말줄임

간단하게 만들 거라면 사실 컴포넌트 내부에서도 할 수 있다. 문자열도 length 속성과 slice 메서드 사용 가능하니까, 아래처럼 삼항연산자로 처리하면 된다.

{value.title.length < 12
  ? value.title
: value.title.slice(0, 12) + '...'}

하지만 다양한 단어가 들어오는 케이스에는 적절치 못하다. 영화 제목엔 :, -, 영문 등 다양한 글자가 들어올 수 있어서 CSS를 손보는 게 더 나았다.

-webkit-line-clamp을 2로 설정해 2줄까지 표시하고, 그외의 텍스트를 overflow: hidden으로 숨겼다. 그리고 말줄임을 뜻하는 ellipsis로 해당 범위를 넘어서는 텍스트는 ...로 처리했다.

module.csssass

  • 중복 방지 : css.module.css
  • css를 편리하게 작성 : sass

💡 덧붙이는 생각

지난 프로젝트 때 왓챠피디아를 주로 레퍼런스로 삼았다. 무수한 div도 압도적이었는데 정체불명의 글자들이 나열된 클래스 이름들도 신기했다. 지금은 작은 추측을 할 수 있게 되었다. 아마 CSS에 해시값이 포함된 게 아닐까?

state

리액트는 기존 DOM을 복사한 가상DOM을 기존 것과 비교하고, 변화가 있는 부분만 자동으로 재렌더링 한다. 바닐라 자바스크립트에서 하듯 직접 DOM을 조작하는 일을 최대한 지양하라고 권고하는데, 그럼 어떤 식으로 화면을 변경할까?

  • variable이 아닌 state(상태)임에 주목하자. 상태는 정적이지 않다. 어떤 상황이냐에 따라 그 모습을 달리한다.
  • 고로 변화가 자주 일어날 것 같은 값, 자동으로 재렌더링하길 바라는 값을 state에 담는다.

원래 클래스형 컴포넌트에서만 사용 가능했던 터라 함수형 컴포넌트에서 사용하려면 useState라는 훅을 불러와야 한다. 또, state는 동기 처리가 디폴트라서 automatic batching으로 일괄적으로 리렌더링 처리가 되므로 (24/04/05 수정) 한번에 연달아 2번을 바꾸고 싶다면 콜백함수로 넘겨준다.

<button
onClick={() => {
  setNumber((prevNumber) => prevNumber + 1);
  setNumber((prevNumber) => prevNumber + 1);
}}
  >+2
</button>

이번 코드에서는 두 개의 state를 만들어줬다.

import {useState} from 'react';

export default function Card() {
  const [movie, setMovie] = useState([]);
  const [isLike, setIsLike] = useState([]);
  
    // 영화 API 가져오기
  const getMovies = async () => {
    const response =
      await fetch(`https://api.themoviedb.org/3/movie/now_playing?
				  api_key=${apiKey}&language=ko-KR`);
    const movielist = await response.json();
    setMovie(movielist.results);
  };

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

하나는 API get요청으로 영화 API를 불러와서 담는 movie, 다른 하나는 각 영화마다 좋아요 클릭 유무를 담을 isLike이다.

fetch는 json으로 변환하는 과정이 필수다. 그런데 useEffect에는 async/await을 사용할 수 없어서 외부 함수(getMovie)에 담고 useEffect에서는 실행만 해줬다. 마운트(첫 랜더링) 되었을 때 보여주면 되니까 의존성 배열을 비워두었다.

그러고나니 이런 생각이 드는 거다.

🤔 API를 담는 용도면 일반 변수여도 되지 않나?

fetch로 받아온 결과는 json 객체에 담긴다.

  return (
    <>
      <h1 className={styles.listTitle}>Now Playing</h1>
      <div className={styles.cardList}>
        {movie.map((value, i) => (
          <div key={value.id} className={styles.cardBox}>
            <div className={styles.cardPoster}>
              <img
                src={`https://image.tmdb.org/t/p/w200
					${value.poster_path}`}
                alt=""
              />
              <div className={styles.heartBox}>
                <FontAwesomeIcon
                  icon={heartIcon}
                  onClick={() => handleHeart(i)}
                  className={`${styles.heart} 
							  ${isLike[i] && styles.full}`}
                />
              </div>
            </div>
            <div className={styles.cardInfo}>
              <h3 className={styles.cardTitle}>{value.title}</h3>
            </div>
          </div>
        ))}
      </div>
    </>
  );

useState에서 초기값을 배열로 설정하면 json객체도 배열로 저장된다. 고로 배열 메서드인 map을 사용할 수 있다.

일반 변수로 받으면 객체를 배열로 바꿔주거나 객체 순회하는 다른 방법을 사용해야 한다.

대표적으로 forEach가 있는데, 이 방법으로는 매개변수로 index를 넘겨줄 수 없다. 로직을 바꿀 만큼 유의미한 변화가 아니라는 생각이 들어 그냥 state에 담아두기로 했다.

map에서 자주 만나는 경고/에러

Warning: Each child in a list should have a unique "key" prop

반복문을 돌릴 땐 각 문을 구별하기 쉽게 고유한 key를 설정해주는 게 좋다. Warning이라서 작성하지 않아도 동작은 하지만, 혹시 모를 충돌 방지를 위해 적어주는 게 좋다. 반드시 숫자일 필요 없이 고유값이 될 수 있는 거라면 OK.
ex. DB 테이블의 PK

못지 않게 자주 만나는 에러. 이번엔 경고가 아니라서 동작하질 않는다.

.map is not a function

map은 배열에 담긴 요소 각각을 나열한다.

const [list, setList] = useState(['a', 'b', 'c']);

return <>
  {list} // abc
  </>

그래서 객체를 비롯한 그외 타입은 순회할 수가 없다.

다시 코드로 돌아와서, API를 받아오려면 늘 key를 발급 받아 주소에 적용해줘야 한다. 개인정보는 공유하지 않는 게 좋으니까 환경변수로 설정해뒀다.

  const apiKey = process.env.REACT_APP_API_KEY;

환경변수(env) 적용

리액트에서는 환경변수를 어떻게 쓸까? 우선 필요한 모듈을 설치한다.

npm i dotenv

그런데 node.js 환경이 아니라서 require 등으로 불러올 수도, 그럴 필요도 없다. 다만 몇 가지 주의사항이 있다.

첫번째는 폴더구조를 보면 알 수 있다.

폴더구조

  1. .env디렉터리 루트에 넣어야 한다. 다른 폴더의 내부에 넣어두면 인식을 못한다.

  2. 반드시 .env 확장자로 파일 생성

  3. 파일 내부에 REACT_APP_을 접두어로 쓰기
    ex. REACT_APP_API_KEY = ~~~

  4. process.env.REACT_APP_설정변수를 적용 후

  5. 서버 재시작

의외로(?) 5번이 중요하다. 방법은 정말 간단한데 서버를 아예 껐다가 켜질 않아서 왜 안 되는지 조금 삽질했다.

useEffect

state에서 잠시 했던 useEffect 이야기를 이어가보자. 이건 Life Cycle 개념을 먼저 살펴보는 게 좋다. 컴포넌트를 인간의 생애주기에 빗대었다. 생성(첫 랜더링, mount)되고 변화를 겪다가(update) 사라진다(unmount).

각 타이밍에 훅을 걸어(hook === 갈고리) 마운트 되었을 때, 업데이트 되었을 때, 사라졌을 때 특정 동작을 수행하게 만들 수 있다. useEffect는 함수이고 두 개의 매개변수를 갖는다.

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

첫번째는 콜백함수, 두번째는 의존성 배열이다. 의존성 배열을 어떻게 작성하느냐에 따라 어느 시점에 동작하는지가 달라진다.

  • 의존성 배열 X : 마운트, 업데이트 시 동작
  • 의존성 배열이 빈 배열 상태 : 마운트 시 동작
  • 의존성 배열에 값을 담아두면 : 마운트, 값이 업데이트할 때 동작

언마운트 시 동작할 함수는 의존성 배열이 빈 배열일 때, return 이후의 콜백함수에 작성한다.

  useEffect(() => {
	// 마운트 되었을 때 동작
    return () => {
    // 언마운트 되었을 때 동작
    };
  }, []);

이제 마지막, 좋아요 기능이다. 구현에 앞서 하트 아이콘부터 가져오자.

fontawesome

웬만하면 cdn으로 해결하는데 react에서는 별 수 없이 설치를 해야 한다. 공식 문서

설치

npm i --save @fortawesome/fontawesome-svg-core
npm install --save @fortawesome/free-solid-svg-icons
npm install --save @fortawesome/react-fontawesome

특정 아이콘만 사용할 경우 camelCase로 아이콘명을 작성해주면 된다. 이때 fontAwesome의 네이밍 규칙을 알아두면 편리하다.

예를 들어 -solid는 색상이 filled인 것, -regular는 비어있는 걸 뜻한다.

사용

import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faHeart as heartIcon } from '@fortawesome/free-solid-svg-icons';

속이 꽉찬 하트 아이콘을 가져오고 싶어서 faHeart를 선택했는데 이 변수명이 마음에 들지 않아서 as로 이름을 바꾸어줬다.

<FontAwesomeIcon icon={heartIcon} />

fontawesome에서 가져온 아이콘도<i> 태그와 차이가 없어서 클래스명이든 이벤트든 원하는 대로 걸어줄 수 있다. 다만 icon={변수명}만 잘 지켜주자.

이제 아이콘을 써먹어보자.

click Event

isLike의 초기값을 true/false처럼 하나의 값으로 설정하면 당연히 모든 하트 아이콘이 똑같이 동작한다. 좋아요 상태를 저장할 값이 딱 하나밖에 없으니까.

고로 초기값을 배열로 설정해야 한다.

  // 좋아요 개별 동작
  const handleHeart = (index) => {
    setIsLike(() => {
      const newLike = [...isLike];
      newLike[index] = !newLike[index];
      return newLike;
    });
  };

//...
  {movie.map((value, i) => (
// ...
    <FontAwesomeIcon
    icon={heartIcon}
    onClick={() => {
      handleHeart(i);
    }}
    className={`${styles.heart} ${isLike[i] && styles.full}`}
    />

handleHeart의 index는 map의 두번째 매개변수인 i가 담겼다. 그래서 각각의 좋아요를 순회한다.

20개의 하트 아이콘 중 하나 이상을 이미 클릭했다면 그 상태(해당 아이콘의 isLike === true)를 유지한 채 새로 추가해야 한다. 원본 배열을 훼손하지 않고자 스프레스 연산자로 좋아요 상태를 그대로 가져와 배열에 담았다.

그리고 현재 상태와 정반대인 값으로 업데이트 하고, 그 결과를 return했다.

isLike아이콘 색깔에도 관여한다. isLike가 true일 때만 색 변화가 필요하므로 논리곱으로 처리했다.

이벤트 핸들링에서 함수의 매개변수를 받고 싶을 때

✅ 방법 1. onClick에 익명함수를 선언하고 내부에서 함수 실행(매개변수 전달)

<button onClick={()=>{handleOnClickTest('안녕?')}}>Test</button>

익명함수 안에 넣지 않으면?

Too many re-renders.
React limits the number of renders to prevent an infinite loop.

랜더링하자마자 실행이 되니까 너무 많은 리랜더링이 발생한다며 에러가 뜬다.

✅ 방법 2. bind 이용
bind는 함수 메서드이다. 첫번째 인자는 함수의 this 설정하고, 두번째 인자부터 순차적으로 매개변수로 전달된다.

지금은 매개변수 넘기는 게 목적이니까 첫 인자로 null을 넘겼다.

<button onClick={handleOnClickTest.bind(null, '안녕?')}>테스트</button>

✅ 익명함수 내부에 함수명만 간단히 넘기는 상황이라면 전자가 편해 보인다.


자연스럽게 포함할 수 없는 개념은 이번에 사용하지 못했다.

  • useRef
  • scss의 @extend와 @mixin

더 알아볼 개념/기능도 있고.

  • 클라이언트 사이드 랜더링 개념 익히기
  • 새로 고침 시 좋아요 유지

내일 또 새로운 훅을 배운다는데 어떤 개념일지 기대된다!

profile
일단 해보는 편
post-custom-banner

0개의 댓글