📗목차
이번 프로젝트에서는 newsapi에서 제공하는 API를 사용하여 최신 뉴스를 불러온 후 보여 줄 것이다. 이를 수행하기 앞서 newsapi에서 API 키를 발급받아야 한다.
API 키는 https://newsapi.org 에 가입하면 발급받을 수 있다.
발급받은 API 키는 추후 API를 요청할 때 API 주소의 쿼리 파라미터로 넣어서 사용하면 된다.
이제 우리가 사용할 API에 대해 알아보자.
https://newsapi.org/s/south-korea-news-api 이 링크에 들어가면 한국 뉴스를 가져오는 API에 대한 설명서가 있다.
사용할 API 주소는 두 가지 형태이다.
전체 뉴스 불러오기
https://newsapi.org/v2/top-headlines?country=kr&apiKey=(본인 API 키)
특정 카테고리 뉴스 불러오기
https://newsapi.org/v2/top-headlines?country=kr&category=business&apiKey=(본인 API 키)
카테고리는 다음과 같이 있고,
이제 전체 뉴스를 불러오도록 API를 프로젝트에 적용해보겠다.
이전 비동기 작업의 이해에서 작성했던 코드에 적용할 것이다.
import React, { useState } from 'react';
import axios from 'axios';
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=40155d54fd46473aa68310e4bd657c97',
);
setData(response.data);
} catch(e) {
console.log(e);
};
};
return (
<div>
<div>
<button onClick={onClick}>불러오기</button>
</div>
{data && <textarea rows={7} cols={30} value={JSON.stringify(data, null, 2)} readOnly={true} />}
</div>
);
};
export default App;
가짜 API를 전체 뉴스를 불러오는 API만 대체하였다.
출력 결과는 다음과 같이 나온다.
불러오기를 누르면 다음과 같이 데이터가 잘 나타나는 것을 볼 수 있다.
이제 이 데이터를 화면에 예브게 보여 주도록 하자.
styled-components를 사용하여 뉴스 정보를 보여 줄 컴포넌트를 만들어 보겠다.
npm i styled-components
를 입력하여 설치해보자.
그리고 src 디렉터리 안에 components 디렉터리를 생성한 뒤, 그 안에 NewsItem.js와 NewsList.js 파일을 생성한다.
먼저 NewsItem 컴포넌트 코드를 작성해보자. 그 전에 각 뉴스 데이터에는 어떤 필드가 있는지 확인해보자.
"source": {
"id": null,
"name": "Kbs.co.kr"
},
"author": "김기범",
"title": "이강인 전성시대…아시안게임 금메달에 이어 A매치 데뷔골까지 - KBS뉴스",
"description": "[앵커]<br /><br /> 어제(13일) 튀니지와의 평가전에서 대승을 거둔 한국 축구 대표팀, 특히 이강인 선수의 활약이 빛났죠.<br /><br />아시안게임 금메달에 이어 대표팀 A매치에서 한꺼번에 두 개의 데뷔골을 터트렸는데요.<br /><br />환호성으로 가득했던 어젯밤의 열기, 김기범 기자가 보도합니다.<br /><br /> [리포트]<br /><br /> 튀니지와 평가전에서 답답한 흐름을 바꾼 주역은 이강인이었습니다.<br /><br /> 번…",
"url": "https://news.kbs.co.kr/news/view.do?ncd=7793138",
"urlToImage": "http://news.kbs.co.kr/data/news/title_image/newsmp4/news9/2023/10/14/90_7793138.jpg",
"publishedAt": "2023-10-14T12:21:00Z",
"content": "[](13) , .\r\nA .\r\n, .\r\n[]\r\n.\r\n, .\r\n2 .\r\nA 15 1,2 .\r\n, .\r\n4 0 A 2 .\r\n[/ : \" . .\"]\r\n.\r\n.\r\n.\r\n.\r\n[/ : \" . . .\"]\r\n17 10 , .\r\nKBS . \r\n:/:\r\n: 'KBS' , : 02-781-1234, 4444: kbs1234@kbs.co.kr, , KBS !"
}
위 코드는 각 뉴스 데이터가 지니고 있는 정보로 이루어진 JSON 객체이다. 그 중에서 다음 필드를 리액트 컴포넌트에 나타내보겠다.
NewsItem 컴포넌트는 article이라는 객체를 props로 통째로 받아 와서 사용한다. NewsItem 컴포넌트를 다음과 같이 작성해보자.
components/NewsItem.js
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;
이번에는 NewsList 컴포넌트를 만들어 보자. 나중에 이 컴포넌트에서 API를 요청하게 될 것이다. 지금은 아직 데이터를 불러오고 있지 않으니 sampleArticle이라는 객체에 미리 예시 데이터를 넣은 후 각 컴포넌트에 전달하여 가짜 내용이 보이게 하자.
components/NewsList.js
import React from 'react'; import styled from 'styled-components'; import NewsItem from './NewsItem'; 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;
이 컴포넌트를 App 컴포넌트에서 보이도록 하겠다. App 컴포넌트에 기존에 작성했던 코드는 모두 지우고, NewsList만 렌더링해보자.
import React from 'react';
import NewsList from './components/NewsList';
const App = () => {
return <NewsList />
};
export default App;
이렇게 컴포넌트들이 나타날 것이다.
이제 NewsList 컴포넌트에서 연습 삼아 사용했던 API를 호출해보겠다. 컴포넌트가 화면에 보이는 시점에 API를 요청해볼 것인데, 이 때 useEffect를 사용하여 컴포넌트가 처음 렌더링되는 시점에 API를 요청하면 된다.
여기서 주의할 점은 useEffect에 등록하는 함수에 async를 붙이면 안 되는 것이다. useEffect에서 반환해야 하는 값은 뒷정리 함수이기 때문이다. (useEffect 뒷정리 참고)
따라서 useEffect 내부에서 async/await를 사용하고 싶다면, 함수 내부에 async 키워드가 붙은 또 다른 함수를 만들어서 사용해줘야 한다.
추가로 loading이라는 상태도 관리하여 API 요청이 대기 중인지 판별할 것이다.
요청이 대기 중일 때는 loading 값이 true가 되고, 요청이 끝나면 loading 값이 false가 되어야 한다.
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import styled from 'styled-components';
import NewsItem from './NewsItem';
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 = () => {
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=40155d54fd46473aa68310e4bd657c97'
);
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={article.url} article={article} />
))}
</NewsListBlock>
);
};
export default NewsList;
데이터를 불러와서 뉴스 데이터 배열을 map 함수를 사용하여 컴포넌트 배열로 변환할 때 신경 써야할 부분이 있다.
map 함수를 사용하기 전에 꼭 !articles를 조회하여 해당 값이 현재 null이 아닌지 검사해야 한다.
이 작업을 하지 않으면, 아직 데이터가 없을 때 null에는 map 함수가 없기 때문에 렌더링 과정에서 오류가 발생한다. 그래서 애플리케이션이 제대로 나타나지 않고 흰 페이지만 보이게 된다.
이제 뉴스 정보가 잘 보이는지 확인해보자.
뉴스는 위의 카테고리 종류들을 가지고 있다. 이 카테고리들로 뉴스의 카테고리 선택 기능을 구현하려 한다.
화면에 카테고리를 보여 줄 땐 영어로 된 값을 그대로 보여 주지 않고, 한글로 보여 준 뒤 클릭했을 때 영어로 된 카테고리 값을 사용하도록 구현하겠다.
components/Categories.js
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 값이 들어가 있는 객체들을 넣어줘서 한글로된 카테고리와 실제 카테고리 값을 연결시켜 주었다.
여기서 name은 실제 카테고리 값을 가리키고, text 값은 렌더링할 때 사용할 한글 카테고리를 가리킨다.
다 만든 컴포넌트는 App에서 NewList 컴포넌트 상단에 렌더링하자.
import React from 'react';
import NewsList from './components/NewsList';
import Categories from './components/Categories';
const App = () => {
return (
<>
<Categories />
<NewsList />
</>
)
};
export default App;
코드를 저장하고 나면 아래와 같이 카테고리가 보일 것이다.
이제 App에서 category 상태를 useState로 관리하겠다. 추가로 category 값을 업데이트하는 onSelect라는 함수도 만들어 주겠다.
그러고 나서 category와 onSelect 함수를 Categories 컴포넌트에게 props로 전달해보자.
App.js
import React, { useState } from 'react'; import NewsList from './components/NewsList'; import Categories from './components/Categories'; import { useCallback } from 'react'; 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;
App이 Categories 컴포넌트에 전달한 props를 활용해보자.
components/Categories.js
import React from 'react'; import styled, { css } from 'styled-components'; const categories = [ (...) // 생략 ]; const CategoriesBlock = styled.div` (...) // 생략 `; 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; } ${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;
이제 선택된 카테고리가 청록색으로 보일 것이다.
현재 props로 받아온 category에 따라 카테고리를 지정하여 API를 요청하도록 NewsList 컴포넌트를 구현해보자.
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import styled from 'styled-components';
import NewsItem from './NewsItem';
const NewsListBlock = styled.div`
(...) // 생략
`;
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=40155d54fd46473aa68310e4bd657c97`
);
setArticles(response.data.articles);
} catch(e) {
console.log(e);
}
setLoading(false);
};
fetchData();
}, [category]);
(...) // 생략
};
export default NewsList;
category 값이 all이라면 query 값을 공백으로 설정하고, all이 아니라면 "&categoey=카테고리" 형태의 문자열을 만들도록 했다. 그리고 이 query를 요청할 때 주소에 포함시켜 줬다.
추가로 category 값이 바뀔 때마다 뉴스를 새로 불러와야 하기 때문에 useEffect의 의존 배열(두 번재 파라미터로 설정하는 배열)에 category를 넣어줘야 한다.
만약 이 컴포넌트를 클래스형 컴포넌트로 만들게 된다면 componentDidMount와 componentDidUpdate에서 요청을 시작하도록 설정해줘야 한다.
함수형 컴포넌트라면 이렇게 useEffect 한 번으로 컴포넌트가 맨 처음 렌더링 될 때, 그리고 category 값이 바뀔 때 요청하도록 설정해 줄 수 있다.
이제 다른 카테고리를 누르면 해당 기사가 나타난다.
현재 프로젝트에 리액트 라우터를 설치하자.
npm i react-router-dom
그런 다음 index.js에서 리액트 라우터를 적용하자.
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<BrowserRouter>
<App />
</BrowserRouter>
);
src 디렉터리에 pages라는 디렉터리를 생성하고, 그 안에 NewsPage.js 파일을 만들어서 다음과 같이 작성해보자.
pages/NewsPage.js
import React from 'react'; import { useParams } from 'react-router-dom'; import Categories from '../components/Categories'; import NewsList from '../components/NewsList'; const NewsPage = () => { // 카테고리가 선택되지 않았으면 기본값 all 사용 const params = useParams(); const category = params.category || 'all'; return ( <> <Categories /> <NewsList category={category} /> </> ); }; export default NewsPage;
현재 선택된 category 값을 URL 파라미터를 통해 사용할 것이므로 Categories 컴포넌트에서 현재 선택된 카테고리 값을 알려 줄 필요도 없고, onSelect 함수를 따로 전달해 줄 필요도 없다.
다 만들었으면 App의 기존 내용을 모두 지우고 Route를 정의해보자.
import React from 'react';
import { Routes, Route } from 'react-router-dom';
import NewsPage from './pages/NewsPage';
const App = () => {
return (
<Routes>
<Route path="/:category?" element={<NewsPage />} />
</Routes>
);
};
export default App;
위 코드에서 사용된 path에 /:category?와 같은 형태로 맨 뒤에 물음표 문자가 들어가 있다.
이는 category 값이 선택적이라는 의미이다. 즉, 있을 수도 있고 없을 수도 있다는 뜻이다.
category URL 파라미터가 없다면 전체 카테고리를 선택한 것으로 간주한다.
Categories에서 기존의 onSelect 함수를 호출하여 카테고리를 선택하고, 선택된 카테고리에 다른 스타일을 주는 기능을 NavLink로 대체해보겠다.
div, a, button, input처럼 일반 HTML 요소가 아닌 특정 컴포넌트에 styled-components를 사용할 때는 styled(컴포넌트이름)``과 같은 형식으로 사용한다.
import React from 'react';
import styled, { css } 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(NavLink)`
font-size: 1.125rem;
cursor: pointer;
white-space: pre;
text-decoration: none;
color: inherit;
padding-bottom: 0.25rem;
&:hover {
color: #495057;
}
$.active {
font-weight: 600;
border-bottom: 2px solid #22b8cf;
color: #22b8cf;
&:hover {
color: #3bc9db;
}
}
& + & {
margin-left: 1rem;
}
`;
const Categories = () => {
return (
<CategoriesBlock>
{categories.map(c => (
<Category
key={c.name}
activeClassName="active"
to={c.name === 'all' ? '/' : `/${c.name}`}
>
{c.text}
</Category>
))}
</CategoriesBlock>
)
}
export default Categories;
NavLink로 만들어진 Category 컴포넌트에 to 값은 "/카테고리이름"으로 설정해줬다.
전체보기의 경우는 예외적으로 "/all" 대신에 "/"로 설정했다.
코드를 저장하고 나면 카테고리를 클릭할 때 페이지 주소가 바뀌는 것을 확인할 수 있다.
라우터 적용
![]()
![]()
참고문헌
김민준,「리액트를 다루는 기술 :실무에서 알아야 할 기술은 따로 있다!」, 길벗, 개정판[실은 2판] 2019 (개정판)