https://newsapi.org/ 에서 제공하는 API를 사용하여 카테고리별로 최신 뉴스 목록을 보여주는 뉴스 뷰어 프로젝트를 만들어보겠습니다. 두 가지 방법으로 카테고리를 관리하여
웹 애플리케이션을 만들다 보면 처리할 때 시간이 걸리는 작업이 있습니다. 예를 들어 웹 애플리케이션에서 서버 쪽 데이터가 필요할 때는 Ajax 기법을 사용하여 서버의 API를 호출함으로써 데이터를 수신하는데 이렇게 서버의 API를 사용해야 할 때는 시간이 걸리기 때문에 이 과정에서 해당 작업을 비동기적으로 처리하게 됩니다.
이렇게 서버 API를 호출할 때 외에도 작업을 비동기적으로 처리할 때가 있는데, 바로 setTimeout 함수를 사용하여 특정 작업을 예약할 때입니다. 예를 들어 다음 코드는 3초 후 printMe 함수를 호출합니다.
function printMe() {
console.log('Hello world');
}
setTimeout(printMe, 3000);
console.log('대기중..');
setTimeout이 사용되는 시점에서 코드가 3초 동안 멈추는 것이 아니라, 일단 코드가 위부터 아래까지 다 호출되고 3초 뒤에 우리가 지정해 준 printMe 가 호출됩니다.
자바스크립트에서 비동기작업을 할 때 가장 흔히 사용하는 방법은 콜백 함수를 사용하는 것입니다. 위 코드에서 printMe 함수 자체를 setTimeout 함수의 인자로 전달해 주었는데, 이런 함수를 콜백 함수라고 부릅니다.
파라미터 값이 주어지면 1초 뒤에 10을 더해서 반환하는 함수가 있다고 가정해 봅니다. 그리고 해당 함수가 처리된 직후 어떠한 작업을 하고 싶다면 다음과 같이 콜백 함수를 활용해서 작업합니다.
function increase(number, callback) {
setTimeout(() => {
const result = number + 10;
if (callback) {
callback(result);
}
}, 1000);
}
increase(0, (result) => {
console.log(result);
});
... 해야 함 ...
axios는 현재 가장 많이 사용되고 있는 자바스크립트 HTTP 클라이언트입니다. 이 라이브러리의 특징은 HTTP 요청을 Promise 기반으로 처리한다는 점입니다.
$ yarn create react-app news-viewer
$ cd news-viewer
$ yarn add axios
import React, { useState } from 'react';
import axios from '../node_modules/axios/index';
const App = () => {
const [data, setData] = useState(null);
const onClick = () => {
axios
.get('https://jsonplaceholder.typicode.com/todos/1')
.then((response) => {
setData(response.data);
});
};
return (
<div>
<div>
<button onClick={onClick}>불러오기</button>
</div>
{data && (
<textarea
rows={7}
value={JSON.stringify(data, null, 2)}
readOnly={true}
/>
)}
</div>
);
};
export default App;
위 코드는 불러오기 버튼을 누르면 JSONPlaceholder('https://jsonplaceholder.typicode.com/) 에서 제공하는 가짜 API를 호출하고 이에 대한 응답을 컴포넌트 상태에 넣어서 보여 주는 예제입니다.
onClick 함수에서는 axios.get 함수를 사용했습니다. 이 함수는 파라미터로 전달된 주소에 GET 요청을 해줍니다. 그리고 이에 대한 결과는 .then을 통해 비동기적으로 확인할 수 있습니다.
위 코드에 async를 적용할 때는 async () ⇒ {} 와 같은 형식으로 적용합니다.
const onClick = async () => {
try {
const response = await axios.get(
'https://jsonplaceholder.typicode.com/todos/1',
);
setData(response.data);
} catch (e) {
console.log(e);
}
};
API 키는 https://newsapi.org/register 에 가입하면 발급받을 수 있습니다.
발급 받은 API 키는 추후 API를 요청할 때 API 주소의 쿼리 파라미터로 넣어서 사용하면 됩니다.
https://newsapi.org/s/south-korea-news-api 에 들어가면 한국 뉴스를 가져오는 API 에 대한 설명서가 있습니다.
사용할 API 주소는 두 가지 형태입니다.
전체 뉴스 불러오기
https://newsapi.org/v2/top-headlines?country=kr&apiKey=22ff47f773bd4db9b21428aa8f6185e1
특정 카테고리 뉴스 불러오기
import React, { useState } from 'react';
import axios from '../node_modules/axios/index';
const App = () => {
const [data, setData] = useState(null);
const onClick = async () => {
try {
const response = await axios.get(
'https://newsapi.org/v2/top-headlines?country=kr&apiKey=22ff47f773bd4db9b21428aa8f6185e1',
);
setData(response.data);
} catch (e) {
console.log(e);
}
};
return (
<div>
<div>
<button onClick={onClick}>불러오기</button>
</div>
{data && (
<textarea
rows={7}
value={JSON.stringify(data, null, 2)}
readOnly={true}
/>
)}
</div>
);
};
export default App;
styled-component를 사용하여 뉴스 정보를 보여 줄 컴포넌트를 만들어 보겠습니다.
$ yarn add styled-components
{
"source": {
"id": null,
"name": "Hani.co.kr"
},
"author": null,
"title": "[사설] '온라인 여론조작'에 경종 울린 김경수 유죄 확정 판결 - 한겨레",
"description": "이른바 ‘드루킹 댓글 조작 사건’과 관련해 김경수 경남지사가 21일 대법원으로부터 징역 2년의 확정판결을 받았다. ‘드루킹’ ...",
"url": "https://www.hani.co.kr/arti/opinion/editorial/1004544.html",
"urlToImage": "https://flexible.img.hani.co.kr/flexible/normal/970/625/imgdb/original/2021/0721/20210721503677.jpg",
"publishedAt": "2021-07-21T09:51:59Z",
"content": ".\r\n[] \r\n :2021-07-21 18:51 :2021-07-21 18:54"
}
}
위 코드는 각 뉴스 데이터가 지니고 있는 정보로 이루어진 JSON 객체입니다. 그 중에서 다음 필드를 리액트 컴포넌트에 나타내겠습니다.
NewsItem 컴포넌트는 article이라는 객체를 props로 통째로 받아 와서 사용합니다.
import React from 'react';
import styled from 'styled-components';
const NewsItemBlock = styled.div`
display: flex;
.thumbnail {
margin-right: 1rem;
img {
display: block;
width: 160px;
height: 100px;
object-fit: cover;
}
}
.contents {
h2 {
margin: 0;
a {
color: black;
}
}
p {
margin: 0;
line-height: 1.5;
margin-top: 0.5rem;
white-space: normal;
}
}
& + & {
margin-top: 3rem;
}
`;
const NewsItem = ({ article }) => {
const { title, description, url, urlToImage } = article;
return (
<NewsItemBlock>
{urlToImage && (
<div className="thumbnail">
<a href={url} target="_blank" rel="noopener noreferrer">
<img src={urlToImage} alt="thumbnail" />
</a>
</div>
)}
<div className="contents">
<h2>
<a href={url} target="_blank" rel="noopener noreferrer">
{title}
</a>
</h2>
<p>{description}</p>
</div>
</NewsItemBlock>
);
};
export default NewsItem;
import React from 'react';
import NewsItem from './NewsItem';
import styled from 'styled-components';
const NewsListBlock = styled.div`
box-sizing: border-box;
padding-bottom: 3rem;
width: 768px;
margin: 0 auto;
margin-top: 2rem;
@media screen and (max-width: 768px) {
width: 100%;
padding-left: 1rem;
padding-right: 1rem;
}
`;
const sampleArticle = {
title: '제목',
description: '내용',
url: 'https://google.com',
urlToImage: 'https://via.placeholder.com/160',
};
const NewsList = () => {
return (
<NewsListBlock>
<NewsItem article={sampleArticle} />
<NewsItem article={sampleArticle} />
<NewsItem article={sampleArticle} />
<NewsItem article={sampleArticle} />
<NewsItem article={sampleArticle} />
<NewsItem article={sampleArticle} />
</NewsListBlock>
);
};
export default NewsList;
이제 NewsList 컴포넌트에서 API를 호출해 보겠습니다. useEffect를 사용하여 컴포넌트가 처음 렌더링되는 시점에 API를 요청하면 됩니다.
주의할 점은 useEffect에 등록하는 함수에 async를 붙이면 안 됩니다. useEffect에서 반환해야 하는 값은 뒷정리 함수이기 때문입니다. 따라서 useEffect 내부에서 async/await를 사용하고 싶다면, 함수 내부에 async 키워드가 붙은 또 다른 함수를 만들어서 사용해 주어야 합니다.
추가로 loading이라는 상태도 관리하여 API 요청이 대기 중일 때는 loading 값이 true가 되고, 요청이 끝나면 loading 값이 false가 되도록 합니다.
import React, { useState, useEffect } from 'react';
import NewsItem from './NewsItem';
import styled from 'styled-components';
import axios from '../../node_modules/axios/index';
const NewsListBlock = styled.div`
box-sizing: border-box;
padding-bottom: 3rem;
width: 768px;
margin: 0 auto;
margin-top: 2rem;
@media screen and (max-width: 768px) {
width: 100%;
padding-left: 1rem;
padding-right: 1rem;
}
`;
const sampleArticle = {
title: '제목',
description: '내용',
url: 'https://google.com',
urlToImage: 'https://via.placeholder.com/160',
};
const NewsList = () => {
const [articles, setArticles] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
// async를 사용하는 함수 따로 선언
const fetchData = async () => {
setLoading(true);
try {
const response = await axios.get(
'https://newsapi.org/v2/top-headlines?country=kr&apiKey=22ff47f773bd4db9b21428aa8f6185e1',
);
setArticles(response.data.articles);
} catch (e) {
console.log(e);
}
setLoading(false);
};
fetchData();
}, []);
// 대기 중일 때
if (loading) {
return <NewsListBlock> 대기중...</NewsListBlock>;
}
// 아직 articles 값이 설정되지 않았을 때
if (!articles) {
return null;
}
// articles 값이 유효할 때
return (
<NewsListBlock>
{articles.map((article) => (
<NewsItem key={setArticles.url} article={article} />
))}
</NewsListBlock>
);
};
export default NewsList;
데이터를 불러와서 뉴스 데이터 배열을 map 함수를 사용하여 컴포넌트 배열로 변환하기 전에 꼭 !articles를 조회하여 해당 값이 현재 null이 아닌지 검사해야 합니다. 이 작업을 하지 않으면, 아직 데이터가 없을 때 null에는 map 함수가 없기 때문에 렌더링 과정에서 오류가 발생합니다. 그래서 애플리케이션이 제대로 나타나지 않고 흰 페이지만 보이게 됩니다.
이번에는 뉴스의 카테고리 선택 기능을 구현해 보겠습니다. 뉴스 카테고리는 총 여섯 개입니다.
import React from 'react';
import styled from 'styled-components';
const categories = [
{
name: 'all', // 실제 카테고리
text: '전체보기', // 렌더링할 때 사용할 한글 카테고리
},
{
name: 'business',
text: '비즈니스',
},
{
name: 'entertainment',
text: '엔터테인먼트',
},
{
name: 'health',
text: '건강',
},
{
name: 'science',
text: '과학',
},
{
name: 'sports',
text: '스포츠',
},
{
name: 'technology',
text: '기술',
},
];
const CategoriesBlock = styled.div`
display: flex;
padding: 1rem;
width: 768px;
margin: 0 auto;
@media screen and (max-width: 768px) {
width: 100%;
overflow-x: auto;
}
`;
const Category = styled.div`
font-size: 1.125rem;
cursor: pointer;
white-space: pre;
text-decoration: none;
color: inherit;
padding-bottom: 0.25rem;
&:hover {
color: #495057;
}
& + & {
margin-left: 1rem;
}
`;
const Categories = () => {
return (
<CategoriesBlock>
{categories.map((c) => (
<Category key={c.name}>{c.text}</Category>
))}
</CategoriesBlock>
);
};
export default Categories;
위 코드에서는 categories라는 배열 안에 name과 text 값이 들어가 있는 객체들을 넣어 주어서 한글로 된 카테고리와 실제 카테고리 값을 연결시켜 주었습니다.
이제 App 에서 category 상태를 useState로 관리하겠습니다. 추가로 category 값을 업데이트하는 onSelect라는 함수도 만들어 주겠습니다. category와 onSelect 함수를 Categories 컴포넌트에게 props로 전달해 주세고, category 값을 NewsList 컴포넌트에게 전달해 줍니다.
import React, { useState, useCallback } from 'react';
import Categories from './components/Categories';
import NewsList from './components/NewsList';
const App = () => {
const [category, setCategory] = useState('all');
const onSelect = useCallback((category) => setCategory(category), []);
return (
<>
<Categories category={category} onSelect={onSelect} />
<NewsList category={category} />
</>
);
};
export default App;
다음으로 Categories에서는 props로 전달받은 onSelect를 각 Category 컴포넌트의 onClick으로 설정 해주고, 현재 선택된 카테고리 값에 따라 다른 스타일을 적용시킵니다.
import React from 'react';
import styled, { css } from 'styled-components';
const categories = [
...
];
const CategoriesBlock = styled.div`
...
`;
const Category = styled.div`
...
${(props) =>
props.active &&
css`
font-weight: 600;
border-bottom: 2px solid #22b8cf;
color: #22b8cf;
&:hover {
color: #3bc9db;
}
`}
& + & {
margin-left: 1rem;
}
`;
const Categories = ({ onSelect, category }) => {
return (
<CategoriesBlock>
{categories.map((c) => (
<Category
key={c.name}
active={category === c.name}
onClick={() => onSelect(c.name)}
>
{c.text}
</Category>
))}
</CategoriesBlock>
);
};
export default Categories;
NewsList 컴포넌트에서 현재 props로 받아 온 category에 따라 카테고리를 지정하여 API를 요청하도록 구현합니다.
import React, { useState, useEffect } from 'react';
import NewsItem from './NewsItem';
import styled from 'styled-components';
import axios from 'axios';
const NewsListBlock = styled.div`
box-sizing: border-box;
padding-bottom: 3rem;
width: 768px;
margin: 0 auto;
margin-top: 2rem;
@media screen and (max-width: 768px) {
width: 100%;
padding-left: 1rem;
padding-right: 1rem;
}
`;
const NewsList = ({ category }) => {
const [articles, setArticles] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
// async를 사용하는 함수 따로 선언
const fetchData = async () => {
setLoading(true);
try {
// 카테고리 적용
const query = category === 'all' ? '' : `&category=${category}`;
const response = await axios.get(
`https://newsapi.org/v2/top-headlines?country=kr${query}&apiKey=22ff47f773bd4db9b21428aa8f6185e1`,
);
setArticles(response.data.articles);
} catch (e) {
console.log(e);
}
setLoading(false);
};
fetchData();
}, [category]); // category 값이 바뀔 때마다 뉴스를 불러와야 하기 때문에 useEffect의 의존 배열에 category 넣어 줌s
// 대기 중일 때
if (loading) {
return <NewsListBlock> 대기중...</NewsListBlock>;
}
// 아직 articles 값이 설정되지 않았을 때
if (!articles) {
return null;
}
// articles 값이 유효할 때
return (
<NewsListBlock>
{articles.map((article) => (
<NewsItem key={setArticles.url} article={article} />
))}
</NewsListBlock>
);
};
export default NewsList;
현재 category 값이 무엇인지에 따라 요청할 주소가 동적으로 바뀌고 있습니다. category 값이 all 이라면 query 값을 공백으로 설정하고, all이 아니라면 "&category=카테고리" 형태의 문자열을 만들도록 합니다. 그리고 이 query를 요청할 때 주소에 포함시켜 주었습니다.
추가로 category 값이 바뀔 때마다 뉴스를 새로 불러와야 하기 때문에 useEffect의 의존 배열(두 번째 파라미터로 설정하는 배열)에 category를 넣어 주어야 합니다.
뉴스 뷰어 프로젝트에 리액트 라우터를 적용해 보겠습니다. 기존에는 카테고리 값은 useState로 관리했는데, 이번에는 이 값을 리액트 라우터의 URL 파라미터를 사용하여 관리해 보겠습니다.
$ yarn add react-router-dom
현재 프로젝트에 리액트 라우터를 설치한 후 index.js 에서 리액트 라우터를 적용합니다.
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root'),
);
이번 프로젝트에서 리액트 라우터를 적용할 때 만들어야 할 페이지는 단 하나입니다. src 디렉터리에 pages라는 디렉터리를 생성하고, 그 안에 NewsPage.js 파일을 만들어서 달음과 같이 작성합니다.
import React from 'react';
import Categories from '../components/Categories';
import NewsList from '../components/NewsList';
const NewsPage = ({ match }) => {
// 카테고리가 선택되지 않았으면 기본값 all 로 사용
const category = match.params.category || 'all';
return (
<>
<Categories />
<NewsList category={category} />
</>
);
};
export default NewsPage;
현재 선택된 category 값을 URL 파라미터를 통해 사용할 것이므로 Categories 컴포넌트에서 현재 선택된 카테고리 값을 알려 줄 필요도 없고, onSelect 함수를 따로 전달해 줄 필요도 없습니다.
그 다음 App의 기존 내용을 모두 주석처리 해주고 Route를 정의해 줍니다.
import React from 'react';
import {Route} from 'react-router-dom';
import NewsPage from './page/NewsPage';
const App = () => {
// 1) useState로 카테고리를 관리하는 방법
// const [category, setCategory] = useState('all');
// const onSelect = useCallback((category) => setCategory(category), []);
// return (
// <>
// <Categories category={category} onSelect={onSelect} />
// <NewsList category={category} />
// </>
// );
// 2) URL 파라미터를 통해 category 값을 관리하는 방법
return <Route path="/:category?" component={NewsPage} />
};
export default App;
위 코드에서 사용된 path에 /:category?와 같은 형태로 맨 뒤에 물음표 문자가 들어가 있는데요. 이는 category 값이 선택적이라는 의미입니다. category URL 파라미터가 없다면 전체 카테고리를 선택한 것으로 간주합니다.
이제 Categories에서 기존의 onSelect 함수를 호출하여 카테고리를 선택하고, 선택된 카테고리에 다른 스타일을 주는 기능을 NavLink로 대체해 보겠습니다.
import React from 'react';
import { NavLink } from 'react-router-dom';
import styled, { css } from 'styled-components';
...
const Category = styled(NavLink)`
font-size: 1.125rem;
cursor: pointer;
white-space: pre;
text-decoration: none;
color: inherit;
padding-bottom: 0.25rem;
&:hover {
color: #495057;
}
// 1) useState로 카테고리를 관리하는 방법
/* ${(props) =>
props.active &&
css`
font-weight: 600;
border-bottom: 2px solid #22b8cf;
color: #22b8cf;
&:hover {
color: #3bc9db;
}
`} */
// 2) URL 파라미터를 통해 category 값을 관리하는 방법
&.active {
font-weight: 600;
border-bottom: 2px solid #22b8cf;
color: #22b8cf;
&:hover {
color: #3bc9db;
}
}
& + & {
margin-left: 1rem;
}
`;
const Categories = () => {
return (
<CategoriesBlock>
{categories.map((c) => (
// 1) useState로 카테고리를 관리하는 방법
// <Category
// key={c.name}
// active={category === c.name}
// onClick={() => onSelect(c.name)}
// >
// 2) URL 파라미터를 통해 category 값을 관리하는 방법
<Category
key={c.name}
activeClassName="active"
exact={c.name === 'all'}
to={c.name === 'all' ? '/' : `${c.name}`}
>
{c.text}
</Category>
))}
</CategoriesBlock>
);
};
export default Categories;
NavLink로 만들어진 Category 컴포넌트에 to 값은 "/카테고리이름"으로 설정해 주었습니다. 전체보기의 경우는 예외적으로 "/all" 대신 "/"로 설정했습니다.
to 값이 "/"를 가리키고 있을 대는 exact 값을 true로 해 주어야 합니다. 이 값을 설정하지 않으면, 다른 카테고리가 선택되었을 때도 전체보기 링크에 active 스타일일 적용되는 오류가 발생합니다.
이번에는 컴포넌트에서 API 호출처럼 Promise를 사용해야 하는 경우 더욱 간결하게 코드를 작성할 수 있도록 해 주는 커스텀 Hook을 만들어서 프로젝트에 적용해보겠습니다.
src 디렉터리에 lib 디렉터리를 만들고, 그 안에 usePromise.js를 생성합니다.
import { useState, useEffect } from 'react';
export default function usePromise(promiseCreator, deps) {
// 로딩중 / 완료 / 실패에 대한 상태 관리
const [loading, setLoading] = useState(false);
const [resolved, setResolved] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
const process = async () => {
setLoading(true);
try {
const resolved = await promiseCreator();
setResolved(resolved);
} catch (e) {
setError(e);
}
setLoading(false);
};
process();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return [loading, resolved, error];
}
프로젝트의 다양한 곳에서 사용될 수 있는 유틸 함수들은 보통 이렇게 src > lib 안에 작성합니다.
usePromise Hook은 Promise의 대기 중, 완료 결과, 실패 결과에 대한 상태를 관리하며, usePromise의 의존 배열 deps를 파라미터로 받아 옵니다.
deps 배열은 usePromise 내부에서 사용한 useEffext의 의존 배열로 설정되는데, 이 배열을 설정하는 부분에서 ESLint 경고가 나타납니다. 이 경고를 무시하려면 ESLint 규칙을 비활성화시키는 주석을 입력해야 합니다.
import React from 'react';
import NewsItem from './NewsItem';
import styled from 'styled-components';
import axios from 'axios';
import usePromise from '../lib/usePromise';
const NewsListBlock = styled.div`
box-sizing: border-box;
padding-bottom: 3rem;
width: 768px;
margin: 0 auto;
margin-top: 2rem;
@media screen and (max-width: 768px) {
width: 100%;
padding-left: 1rem;
padding-right: 1rem;
}
`;
const NewsList = ({ category }) => {
const [loading, response, error] = usePromise(() => {
// 카테고리 적용
const query = category === 'all' ? '' : `&category=${category}`;
return axios.get(
`https://newsapi.org/v2/top-headlines?country=kr${query}&apiKey=22ff47f773bd4db9b21428aa8f6185e1`,
);
}, [category]); // category 값이 바뀔 때마다 뉴스를 불러와야 하기 때문에 useEffect의 의존 배열에 category 넣어 줌s
// 대기 중일 때
if (loading) {
return <NewsListBlock> 대기중...</NewsListBlock>;
}
// 아직 response 값이 설정되지 않았을 때
if (!response) {
return null;
}
// 에러가 발생했을 때
if (error) {
return <NewsListBlock>에러 발생!</NewsListBlock>;
}
// articles 값이 유효할 때
const { articles } = response.data;
return (
<NewsListBlock>
{articles.map((article) => (
<NewsItem key={articles.url} article={article} />
))}
</NewsListBlock>
);
};
export default NewsList;
usePromise를 사용하면 NewsList에서 대기 중 상태 관리와 useEffext 설정을 직접 하지 않아도 되므로 코드가 훨씬 간결해집니다. 상황에 따라 적절히 사용하면 좋은 코드를 만들어 갈 수 있습니다.
리액트 컴포넌트에서 API를 연동하여 개발 할 때 유의 사항은 useEffect에 등록하는 함수는 async로 작성하면 안된다는 점입니다. 그 대신 함수 내부에 async 함수를 ㄸ다로 만들어 주어야 합니다.
- 해당 코드는 GitHub 에서 볼 수 있습니다.
- 본 글은 [리액트를 다루는 기술]을 참고하여 작성되었음을 밝힙니다.