[ReactJS] 2. 기능 연습 & 3. 앱 만들기
이 글은 NomadCoders의 <ReactJS로 영화 웹 서비스 만들기> 강의 내용을 정리한 글입니다.
넘나 좋은 강의 (게다가 무료!) 🙇🏻♀️ 감사합니다 🙇🏻♀️
개인 스터디 글로, 맞지 않는 내용이나 더 나은 방법을 공유해 주신다면 복받으실 거예요 👼🙌
1) 시작하기
2) 기능 연습 & 3) 앱 만들기 ☀︎
4) styled-components
5) 𝒘𝒊𝒕𝒉 타입스크립트
2. 연습하기
2-1. To-do App
지금까지 배운 걸 활용해 간단한 투두리스트를 만들어 봅니다!
우선 투두를 입력할 인풋이 있어야겠죠!
/* App.js */
function App() {
return (
<div>
<input type="text" placeholder="Write your to do..." />
</div>
);
}
export default App;
이 인풋의 value는 state로 관리하려고 합니다. useState를 쓰윽 써줄 차례네요.
const [todo, setTodo] = useState('');
그리고 인풋에 변화가 일어나면(onChange) 인풋의 value를 받아와 state로 설정합니다.
const onChange = e => setToDo(e.target.value);
/* App.js */
import { useState } from 'react';
function App() {
const [toDo, setToDo] = useState('');
const onChange = (e) => setTodo(e.target.value);
return (
<div>
<input
type="text"
placeholder="Write your to do..."
value={toDo}
onChange={onChange}
/>
</div>
);
}
export default App;
이제 이 input을 form안에 넣고 button도 추가해 줍니다.
그리고 버튼 클릭 시 입력된 값이 찍히도록 합니다.
버튼의 기본 타입은 submit이기 때문에, form 내의 버튼 클릭 시 submit 이벤트가 발생합니다.
그러니 form에 onSubmit 이벤트 핸들러를 걸어주면 됩니다.
/* App.js */
import { useState } from 'react';
function App() {
const [toDo, setToDo] = useState('');
const onChange = (e) => setToDo(e.target.value);
const onSubmit = (e) => {
e.preventDefault();
console.log(toDo);
};
return (
<div>
<h1>My To Dos</h1>
<form onSubmit={onSubmit}>
<input
type="text"
placeholder="Write your to do..."
value={toDo}
onChange={onChange}
/>
<button>Add To Do</button>
</form>
</div>
);
}
export default App;
이제 입력된 값을 toDos라는 곳에 차곡차곡 쌓아보죠!
toDos도 state로 만들어야겠죠. 초기값은 빈 배열로 해줍시다.
const [toDos, setTodos] = useState([]);
이때 배열인 state를 변경하려면 어떻게 해야 할까요? push() 같은 메서드를 써야할까요?
놉, 우리는 state를 직접 건드릴 수 없습니다.
그래서 우린 setTodos()를 쓸 건데, 이때 새롭게 변경된 배열을 넘겨주면 됩니다.
빈 배열인 상태에서 ‘아침 먹기’란 할 일을 추가했다고 해봅시다.
그럼 [’아침 먹기’]를 넘겨주면 됩니다.
여기에 ‘점심 먹기’를 추가하면 [’아침 먹기’, ‘점심 먹기’] 를 넘기고,
‘저녁 먹기’까지 추가하면 [’아침 먹기’, ‘점심 먹기’, ‘저녁 먹기’] 를 넘겨주면 됩니다.
여기서 써먹을 수 있는 게 바로 spread 문법입니다.
이미 존재하는 배열을 일부 가지고 새로운 배열을 생성할 수 있습니다.
const list = ['딸기', '포도'];
const newList = ['바나나', ...list]; //['바나나', '딸기', '포도']
투두리스트의 경우 기존의 할 일 배열에 하나씩 추가하는 형태이고,
가장 마지막에 추가한 일이 배열의 첫 번째로 보여지길 원하니 아래와 같이 써먹을 수 있겠네요.
const oldToDo = ['3번째 일', '2번째 일', '1번째 일'];
const newToDo = '새로운 일';
const myTodo = [newToDo, ...oldToDo]; //['새로운 일', '3번째 일', '2번째 일', '1번째 일']
항시 최신의 값을 가져오려면 함수를 쓰는 게 안전하다고 했으니 함수 형태로 써봅시다!
setToDos((toDos) => [toDo, ...toDos]);
함수에 들어가는 첫 번째 인자 = 현재 state
이 함수가 리턴한 값 = 새로운 state
클릭 시 setTodo('')로 인풋이 자동으로 비워지도록 하고, 빈 값일 땐 submit되지 않도록 기능도 추가해 줍시다.
/* App.js */
import { useState } from 'react';
function App() {
const [toDo, setToDo] = useState('');
const [toDos, setToDos] = useState([]);
const onChange = (e) => setToDo(e.target.value);
const onSubmit = (e) => {
e.preventDefault();
if (toDo === '') {
return;
}
setToDos((toDos) => [toDo, ...toDos]);
setToDo('');
};
console.log(toDos);
return (
<div>
<h1>My To Dos ({toDos.length})</h1>
<form onSubmit={onSubmit}>
<input
type="text"
placeholder="Write your to do..."
value={toDo}
onChange={onChange}
/>
<button>Add To Do</button>
</form>
</div>
);
}
export default App;
이제 쌓여있는 toDos를 화면에 뿌려줄 차례입니다.
이럴 때는 map() 메서드를 쓰면 아주 편해요! 이 함수는 주어진 요소 각각에 함수를 실행하고 그 결과들을 모아 새로운 배열로 “반환”합니다.
JSX 내에 { } 로 map 함수를 써봅시다!
<ul>{toDos.map((todo) => ( <li>{todo}</li>))}</ul>
이렇게 해두면 일단 화면에 잘 나오지만 콘솔이 뭐라고 하는데,
모든 리스트의 자식 요소는 반드시 ‘고유한 키’를 가져야 한다고 합니다.
다행히 map 함수의 두 번째 매개변수는 처리할 현재 요소의 인덱스이기 때문에 이 값을 key로 넣어줄 수 있습니다.
<ul>{toDos.map((todo, idx) => ( <li key={idx}>{todo}</li>))}</ul>
2-2. Coin Tracker
로드 시 1번만 데이터를 불러올 것이므로 아래와 같이 fetch API 코드를 작성합니다.
useEffect(() => {
fetch('<https://api.coinpaprika.com/v1/tickers>')
.then((response) => response.json())
.then((json) => {
setCoins(json);
setLoading(false);
});
}, []);
그리고 이런저런 기능을 추가해 봅니다 ;)
import { useState, useEffect } from 'react';
import styles from './App.module.css';
function App() {
const topCoins = ['BTC', 'ETH', 'BNB', 'KLAY'];
const [loading, setLoading] = useState(true);
const [coins, setCoins] = useState([]);
const [currentUnit, setCurrentUnit] = useState(topCoins[0]);
const [value, setValue] = useState('');
const onSelect = (e) => {
setCurrentUnit(e.target.value);
setValue('');
};
const onChange = (e) => {
if (!e.target.value.replace(/^[1-9]\d*(\d+)?$/i, '')) {
setValue(e.target.value);
}
};
const hideDecimal = (number, length) => {
return Math.floor(number * 10 ** length) / 10 ** length;
};
useEffect(() => {
fetch('https://api.coinpaprika.com/v1/tickers')
.then((response) => response.json())
.then((json) => {
setCoins(json);
setLoading(false);
});
}, []);
return (
<div>
<h1>My Coins! ({coins.length})</h1>
{loading ? (
<strong>Loading...</strong>
) : (
<div>
<div className={styles.currentPrice}>
<h2>Current Price</h2>
<p></p>
{topCoins.map((elem, idx) => (
<p key={idx}>
<strong>1 {elem} =</strong>
<span>
{hideDecimal(
coins.filter((coin) => coin.symbol === elem)[0].quotes.USD
.price,
3
)}
</span>
<i>USD</i>
</p>
))}
</div>
<div className="convertPrice">
<h3>Convert Price</h3>
<select onChange={onSelect}>
{topCoins.map((elem, idx) => (
<option key={idx} defaultValue={idx === 0}>
{elem}
</option>
))}
</select>
<div>
<input
type="text"
value={value}
placeholder="Enter here..."
onChange={onChange}
></input>
<span className="unit">USD</span>
</div>
<div>
<input
type="text"
disabled
value={
value
? hideDecimal(
value /
coins.filter((coin) => coin.symbol === currentUnit)[0]
.quotes.USD.price,
6
)
: ''
}
onChange={onChange}
></input>
<span className="unit">{currentUnit}</span>
</div>
<button onClick={() => setValue('')}>Clear</button>
</div>
</div>
)}
</div>
);
}
export default App;
3. 무비 앱 만들기
3-1. 데이터 받아오기
일단 API를 통해 우리가 필요한 데이터를 받아와 보죠!
요청을 하고 fetch()
⇒ 그게 끝나면 then()
⇒ 그 결과를 JSON 형태로 만들고 response.json()
⇒ 그게 끝나면 then()
⇒ 그 결과를 콘솔에 찍도록 합니다 console.log()
fetch()
.then((response) => response.json)
.then((json) => console.log(json))
하지만 이렇게 then.then... 으로 이어가는 대신 async-await를 사용하는 게 훨씬 편합니다.
함수 앞에 async
를 붙이고 기다리는 대상에 await
를 붙이면 됩니다 :D
const getMovies = async() => {
const response = await fetch();
const json = await response.json();
console.log(json);
}
// 더 짧게 한다면
const getMovies = async () => {
const json = await(await fetch()).json();
console.log(json);
}
참고
이렇게 받아온 정보는 movies라는 상태로 관리하려고 합니다.
const [movies, setMovies] = useState([]);
아, 그리고 API를 불러오는 중이라면 화면을 가릴 수 있도록 loading이란 상태도 만들어 두죠!
const [loading, setLoading] = useState(true);
이제 getMovies 함수 내에서 불러온 데이터를 setMovies 로 넣어주고 그 후 loading 상태를 false로 바꿉니다.
getMovies는 로드 후 딱 한 번만 실행되도록 useEffect 설정도 해줘야겠죠 ;)
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=9&sort_by=year`
)
).json();
setMovies(json.data.movies);
setLoading(false);
};
useEffect(() => {
getMovies();
}, []);
JSX 부분을 작성할 차례입니다.
받아온 movies는 map 함수를 통해 뿌려줍니다. 이때, movies.genres는 배열 형태이기 때문에 map 내에 map 함수를 사용합니다.
map 함수를 쓸 때는 key 값을 빼먹지 않도록 주의!
return (
<div>
{loading ? (
<h1>Loading...</h1>
) : (
<div>
{movies.map((movie) => (
<div key={movie.id}>
<h2><a href="">{movie.title}</a></h2>
<img src={movie.cover} alt={movie.title} />
<p>{movie.summary}</p>
<ul>
{movie.genres.map((g) => (
<li key={g}>{g}</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
);
짠! 데이터를 잘 받아왔네요!
/* App.js */
import { useState } from 'react';
import { useEffect } from 'react/cjs/react.development';
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=9&sort_by=year`
)
).json();
setMovies(json.data.movies);
setLoading(false);
};
useEffect(() => {
getMovies();
}, []);
console.log(movies);
return (
<div>
{loading ? (
<h1>Loading...</h1>
) : (
<div>
{movies.map((movie) => (
<div key={movie.id}>
<h2>{movie.title}</h2>
<img src={movie.medium_cover_image} alt={movie.title} />
<p>{movie.summary}</p>
<ul>
{movie.genres.map((g) => (
<li key={g}>{g}</li>
))}
</ul>
</div>
))}
</div>
)}
</div>
);
}
export default App;
3-2. 컴포넌트 만들기
저 movies.map() 부분을 아예 컴포넌트화 시켜서 뚝 떼다쓸 수 있도록 바꿔 봅시다.
components/ 폴더 내에 Movie.js를 만들고 내용을 분리해 줍니다.
/* components/Movie.js */
function Movie() {
return (
<div key={movie.id}>
<h2>{movie.title}</h2>
<img src={movie.medium_cover_image} alt={movie.title} />
<p>{movie.summary}</p>
<ul>
{movie.genres.map((g) => (
<li key={g}>{g}</li>
))}
</ul>
</div>
);
}
export default Movie;
여기서 key 값은 필요 없고, 부모 컴포넌트에서 props로 내려주면 되는 부분이므로 삭제합니다.
그 외에 movie.title, movie.summary 등도 props로 받아올 것이므로 매개변수 자리에 구조분해 할당하여 자리를 만들어 주자고요!
완성도를 높이기 위해 propTypes도 지정해주면 더욱 좋고요.
추가로 화면에 보여질 글자 수를 제한하고 싶다면 이런 식으로 설정하면 됩니다.
<p>{summary.length > 235 ? ${summary.slice(0, 235)}... : summary}</p>
/* components/Movie.js */
import PropTypes from 'prop-types';
function Movie({ title, cover, summary, genres }) {
return (
<div>
<div>
<h2>{title}</h2>
<img src={cover} alt={title} />
<p>{summary.length > 235 ? `${summary.slice(0, 235)}...` : summary}</p>
<ul>
{genres.map((g) => (
<li key={g}>{g}</li>
))}
</ul>
</div>
</div>
);
}
Movie.propTypes = {
cover: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
summary: PropTypes.string.isRequired,
genres: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default Movie;
그럼 이제 부모 컴포넌트인 App으로 올라가 props를 하나하나 내려줍니다.
/* App.js */
// ... 상략 ...
return (
<div>
{loading ? (
<h1>Loading...</h1>
) : (
<div>
{movies.map((movie) => (
<Movie
key={movie.id}
title={movie.title}
cover={movie.medium_cover_image}
summary={movie.summary}
genres={movie.genres}
/>
))}
</div>
)}
</div>
);
export default App;
3-3. 라우팅
지금은 App.js에서 화면을 다 그리고 있는데 구조를 바꿔보죠!
우선 App.js의 내용은 복사해서 Home.js로 변경합니다. App.js는 다른 역할을 할 거에요.
우리가 만들 구조는 대충 이렇게 됩니다.
- App.js : 여기서 경로를 안내해요.
/
→ Home 화면,/movies
→ Detail 화면으로 가라고 알려주는 역할! - routes/ : 이 폴더 내에 Home.js, Detail.js 등 각 라우트 파일이 들어갑니다.
- components/ : 실제 화면을 렌더링하는 컴포넌트들이에요. Home과 Detail 등이 불러와 사용하게 됩니다.
폴더 정리가 끝났으면 우릴 도와줄 라우트 라이브러리를 아묻따 설치!
$ npm i react-router-dom
우선 라우터 객체들을 쏙쏙 꺼내와 줍니다.
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
(※ 여기선 v6 문법을 기준으로 합니다)
사용하는 단계는 아래와 같아요!
👉 Router ⇒ Routes ⇒ Route (path, element)
그러니 이렇게 써주면 됩니다.
App.js는 들어온 path에 따라 ‘저기로 가세요’라고 안내해주는 역할을 합니다.
/* App.js */
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './routes/Home';
import Detail from './routes/Detail';
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/movie" element={<Detail />} />
<Route path="/hello" element={<p>Hello ;)</p>} />
</Routes>
</Router>
);
}
export default App;
여기서 체크할 점. Movie 컴포넌트 내에는 하이퍼링크가 하나 있었죠.
<a href="/movie">{title}</a>
이걸 누르면 이동은 되지만 그때마다 리로드 됩니다.
하지만 이때! 라이브러리가 제공하는 Link 컴포넌트를 쓰면 어떨까요!
import { Link } from 'react-router-dom';
<Link to="/movie">{title}</Link>
그럼 이제 영화를 클릭할 때마다 /movie 로 슈슈슉 이동합니다.
/hello 로 접근하면 Hello 컴포넌트의 내용이 잘 나오고요 ;)
3-4. 상세정보 불러오기
지금은 클릭해도 Detail 이라는 글자밖에 나오지 않습니다.
이제 클릭한 영화의 id값을 알아내서 그에 맞게 API 요청을 해 정보를 뿌려주는 작업을 해봅시다.
리액트 라우터는 다이나믹 로ㄷ... 다이나믹 URL을 지원합니다.
그래서 클릭한 영화의 id값을 URL로 넘겨줄 수 있습니다.
사용자를 Detail로 넘기기 전, id 값도 함께 보내줍니다. 이렇게 점 두개(콜론) 찍어주면 돼요!
<Route path="/movie/:id" element={<Detail />} />
/* App.js */
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './routes/Home';
import Detail from './routes/Detail';
function App() {
return (
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/movie/:id" element={<Detail />} />
<Route path="/hello" element={<p>Hello ;)</p>} />
</Routes>
</Router>
);
}
export default App;
하지만 지금 id라는 props를 넘겨주고 있지 않기 때문에, Home 컴포넌트(부모)에서 Movie 컴포넌트(자식)으로 id란 props를 넘겨줍니다.
/* Home.js */
//...
return (
<div>
{loading ? (
<h1>Loading...</h1>
) : (
<div>
{movies.map((movie) => (
<Movie
id={movie.id}
key={movie.id}
title={movie.title}
cover={movie.medium_cover_image}
summary={movie.summary}
genres={movie.genres}
/>
))}
</div>
)}
</div>
);
//...
그리고 Movie 컴포넌트에서 id를 받아오고, Link 컴포넌트의 경로도 변경해 줍니다.
/* Movie.js */
import PropTypes from 'prop-types';
import { Link } from 'react-router-dom';
function Movie({ id, title, cover, summary, genres }) {
return (
<div>
<div>
<h2>
<Link to={`/movie/${id}`}>{title}</Link>
</h2>
<img src={cover} alt={title} />
<p>{summary}</p>
<ul>
{genres.map((g) => (
<li key={g}>{g}</li>
))}
</ul>
</div>
</div>
);
}
Movie.propTypes = {
id: PropTypes.number.isRequired,
cover: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
summary: PropTypes.string.isRequired,
genres: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default Movie;
짜라잔- 해당 movie.id가 URL 파라미터에 잘 들어가 있네요!
이제 이걸 이용해 해당 id의 영화 정보를 받아와 뿌려주면 되겠습니다. Detail.js를 수정할 차례입니다.
API 요청을 1번만 하기 위해 useEffect를 씁니다.
/* Detail.js */
import { useEffect } from 'react';
function Detail() {
const getMovie = () => {};
useEffect(() => {
getMovie();
}, []);
return <h1>Detail</h1>;
}
export default Detail;
getMovie의 함수 내용은 Home.js에서 썼던 것과 유사합니다.
차이가 있다면 id 값을 담아 요청해야한다는 건데, 이건 useParams()로 받아옵니다.
/* Detail.js */
import { useParams } from 'react-router-dom';
function Detail() {
console.log(useParams());
return <h1>Detail</h1>;
}
export default Detail;
id 값이 넘어온 게 보입니다.
그럼 만약 <Route path="/movie/:id/:nana" element={<Detail />} />
처럼 쓴다면 nana 라는 값도 받아볼 수 있겠네요!
이제 객체 조지기를 통해 { id } 로 꺼내와주고 이 값을 fetch 할 때 ${id}로 넣어 요청합니다.
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;
3-5. 배포하기
이렇게 만든 프로젝트를 github pages에 업로드해볼 차례입니다!
npm을 통해 gh-pages 란 패키지를 설치합니다.
$npm i gh-pages
pacakge.json을 보면 이미 지정된 명령문이 있는 걸 볼 수 있습니다.
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
우리는 npm run build를 치기만 하면 CRA가 알아서 파일들을 최적화해 줍니다.
이제 이걸 github에 올릴 수 있도록 설정해 봅니다.
먼저 package.json 내에 homepage 내용을 추가합니다.
{
"homepage" : "https://{GitHub ID}.github.io/{Repository name}"
}
$ git remote -v
를 치면 저장소 이름을 알 수 있어요.
저는 https://nana-like.github.io/react-movie 가 되겠네요!
다음으로 deploy라는 script 를 추가할 차례입니다.
이 명령어를 통해 gh-pages를 실행시키고 빌드된 파일들을 가져가도록 명령 합니다.
"deploy": "gh-pages -d build"
gh-pages 야 / 이 디렉토리 안에 있는 거 가져가 / build라는 이름의
음... 생각해보니 배포를 하려면 빌드된 파일이 먼저 존재해야 할 텐데요.
그럼 deploy 명령문을 실행시키면 먼저 npm run build가 돌아가도록 하면 편하겠네요.
스크립트 앞에 pre가 붙으면 해당 스크립트가 먼저 실행되기 때문에, npm run build를 predeploy란 이름으로 만들어주면 되겠죠!
{
"script": {
"predeploy": "npm run build",
"deploy": "gh-pages -d build"
}
}
그리고 npm run deploy를 치면... predeploy가 먼저 실행되고 ‘Published’란 내용이 뜹니다.
잠시 기다렸다가 homepage에 적어준 주소로 이동하면...
잘 안 나옵니다....엙?
우리는 CRA 작업할 때 루트(/ ) 기준으로 했지만, github pages는 저장소 이름(react-movie)을 갖고 있기 때문이죠.
실제로 이 상태에서 localhost:3000으로 들어가면 자동으로 /react-movie 가 URL에 붙습니다.
따라서 라우터의 basename을 슥 바꿔주는 작업이 필요합니다.
<Router basename={process.env.PUBLIC_URL}>
/* App.js */
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import Home from './routes/Home';
import Detail from './routes/Detail';
function App() {
return (
<Router basename={process.env.PUBLIC_URL}>
<Routes>
<Route path={`/`} element={<Home />} />
<Route path="/movie/:id" element={<Detail />} />
<Route path="/hello" element={<p>Hello ;)</p>} />
</Routes>
</Router>
);
}
export default App;
이제 다시 npm run deploy를 해주면...
참고
추후 뚝딱뚝딱은 미래의 나에게...
중간에 잠깐 텀을 두고 다시 공부하려고 들어갔는데, 내가 왜 이렇게 작성했는지 하나도 기억이 안 나서 놀랐다ㅋㅋㅋㅋㅋ
엙 내가 코멘트를 남겼다고요...? 이것이 나의 커밋...? 그땐 어이가 없었는데 지금보니 당연한 거였다!
그야 처음 보는 건데 한번에 뚝딱 이해가 가고 뚝딱 외워질 리가...
그래서 맘 편히 먹고 공부하기로 허허허 그래도 덕분에 나한테 맞는 학습 스타일을 알 수 있었다.
1) 난 그냥 여러 번 봐야 이해되는 스타일! 처음에 2배속으로 쫙 돌리거나 스크립트 먼저 읽어서 복습 회차 늘리는 것도 좋더라
2) 난 내 언어로 바꿔야 정리되는 스타일! (아아 이것이 찐 문과) 특히 복습할 때 인강은 여러 번 가볍게 보기가 힘든데, 노트라도 남기면 마따마따 하면서 볼 수 있는 게 장점
후후후...