배운 개념을 이론으로 정리하면 쉽고 간단하다. 하지만 쉬운 건 금방 잊혀지기 마련. 의미 있는 걸 해야 의욕이 생기니까 목적을 구체화하며 큰 틀을 짰다.
우선 전체 코드를 공유하고, 개념별로 코드를 설명해가며 정리해 볼 생각이다.
// 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를 만드는 것이다.
일반적인 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.css
와 sass
css.module.css
sass
지난 프로젝트 때 왓챠피디아를 주로 레퍼런스로 삼았다. 무수한 div도 압도적이었는데 정체불명의 글자들이 나열된 클래스 이름들도 신기했다. 지금은 작은 추측을 할 수 있게 되었다. 아마 CSS에 해시값이 포함된 게 아닐까?
리액트는 기존 DOM을 복사한 가상DOM을 기존 것과 비교하고, 변화가 있는 부분만 자동으로 재렌더링 한다. 바닐라 자바스크립트에서 하듯 직접 DOM을 조작하는 일을 최대한 지양하라고 권고하는데, 그럼 어떤 식으로 화면을 변경할까?
원래 클래스형 컴포넌트에서만 사용 가능했던 터라 함수형 컴포넌트에서 사용하려면 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에서는 실행만 해줬다. 마운트(첫 랜더링) 되었을 때 보여주면 되니까 의존성 배열을 비워두었다.
그러고나니 이런 생각이 드는 거다.
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에 담아두기로 했다.
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
등으로 불러올 수도, 그럴 필요도 없다. 다만 몇 가지 주의사항이 있다.
첫번째는 폴더구조를 보면 알 수 있다.
.env
는 디렉터리 루트에 넣어야 한다. 다른 폴더의 내부에 넣어두면 인식을 못한다.
반드시 .env
확장자로 파일 생성
파일 내부에 REACT_APP_
을 접두어로 쓰기
ex. REACT_APP_API_KEY = ~~~
process.env.REACT_APP_설정변수
를 적용 후
서버 재시작
의외로(?) 5번이 중요하다. 방법은 정말 간단한데 서버를 아예 껐다가 켜질 않아서 왜 안 되는지 조금 삽질했다.
state에서 잠시 했던 useEffect 이야기를 이어가보자. 이건 Life Cycle 개념을 먼저 살펴보는 게 좋다. 컴포넌트를 인간의 생애주기에 빗대었다. 생성(첫 랜더링, mount)되고 변화를 겪다가(update) 사라진다(unmount).
각 타이밍에 훅을 걸어(hook === 갈고리) 마운트 되었을 때, 업데이트 되었을 때, 사라졌을 때 특정 동작을 수행하게 만들 수 있다. useEffect는 함수이고 두 개의 매개변수를 갖는다.
useEffect(()=>{}, [])
첫번째는 콜백함수, 두번째는 의존성 배열이다. 의존성 배열을 어떻게 작성하느냐에 따라 어느 시점에 동작하는지가 달라진다.
언마운트 시 동작할 함수는 의존성 배열이 빈 배열일 때, return 이후의 콜백함수에 작성한다.
useEffect(() => {
// 마운트 되었을 때 동작
return () => {
// 언마운트 되었을 때 동작
};
}, []);
이제 마지막, 좋아요 기능이다. 구현에 앞서 하트 아이콘부터 가져오자.
웬만하면 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={변수명}만 잘 지켜주자.
이제 아이콘을 써먹어보자.
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>
✅ 익명함수 내부에 함수명만 간단히 넘기는 상황이라면 전자가 편해 보인다.
자연스럽게 포함할 수 없는 개념은 이번에 사용하지 못했다.
더 알아볼 개념/기능도 있고.
내일 또 새로운 훅을 배운다는데 어떤 개념일지 기대된다!