우선 외부 API를 호출하는 법을 알기 전에 동기 비동기에 대한 이해를 해보자.
만약 작업들을 동기적으로 처리한다면, 한 작업이 끝날 때까지 기다렸다가 다른 작업을 시작한다. 하지만 비동기 방식은 동시에 여러 가지 요청을 처리할 수도 있고, 기다리는 과정에서 다른 함수도 호출할 수 있다.
서버 API를 호출할 때 외에도 setTimeout
함수를 사용하여 특정 작업을 예약할 때에도 비동기적 방식을 사용한다.
function printMe() {
console.log('Hello World!');
}
setTimeout(printMe, 3000);
console.log('대기 중...');
실행 결과
대기 중... Hello World!
setTimeout
이 사용될 때 코드가 3초 동안 멈추는 것이 아니라, 일단 코드가 위부터 아래까지 다 호출되고(console.log
까지 동시에) 3초 뒤에 실행하라고 한 printMe
가 호출되었다.
이런 비동기 처리 방식을 콜백 함수라고 한다. 3초 뒤에 호출되도록 printMe
함수 자체를 setTimeout
함수의 인자로 전달해 주는 것!
function increase(number, callback) {
setTimeout(() => {
const result = number + 10;
if (callback) {
callback(result);
}
}, 1000)
}
increase(0, result => {
console.log(result);
});
위 코드에서는 1초 뒤에 10을 더해서 반환하는 함수를 정의하고 콜백함수로 값을 넘겨주고 있다. 하지만 1초 간격으로 여러 번 호출해서 콜백을 중첩한다면?
console.log('작업 시작');
increase(0, result => {
console.log(result);
increase(result, result => {
console.log(result);
increase(result, result => {
console.log(result);
increase(result, result => {
console.log(result);
console.log('작업 완료');
});
});
});
});
...이런 걸 콜백 지옥이라고 부른다.
콜백 지옥 같은 코드가 형성되지 않게 하는 방안으로 ES6에 도입된 것이 바로 Promise
다. 여러 작업을 연달아 사용할 때 .then
을 사용하기 때문에 콜백 지옥이 형성되지 않는다.
function increase(number) {
const promise = new Promise((resolve, reject) => {
// resolve는 성공, reject는 실패
setTimeout(() => {
const result = number + 10;
if (result > 50) {
// 50보다 높으면 에러 발생시키기
const e = new Error('NumberTooBig');
return reject(e);
}
resolve(result); // number 값에 +10 후 성공 처리
}, 1000);
});
return promise;
}
increase(0)
.then(number => {
// Promise에서 resolve된 값은 .then을 통해 받아 올 수 있음
console.log(number);
return increase(number); // Promise를 리턴하면
})
.then(number => {
// 또 .then으로 처리 가능
console.log(number);
return increase(number);
})
.then(number => {
console.log(number);
return increase(number);
})
.then(number => {
console.log(number);
return increase(number);
})
.then(number => {
console.log(number);
return increase(number);
})
.catch(e => {
// 도중에 에러가 발생한다면 .catch를 통해 알 수 있음
console.log(e);
});
async/await
는 Promise
를 더욱 쉽게 사용할 수 있도록 해 주는 ES8 문법이다. 이 문법을 사용하려면 함수의 앞부분에 async
키워드를 추가하고, 해당 함수 내부에서 Promise
의 앞부분에 await
키워드를 사용하면 된다. 이렇게 하면 Promise
가 끝날 때까지 기다리고, 결과 값을 특정 변수에 담을 수 있다.
function increase(number) {
const promise = new Promise((resolve, reject) => {
// resolve는 성공, reject는 실패
setTimeout(() => {
const result = number + 10;
if (result > 50) { // 50보다 높으면 에러 발생시키기
const e = new Error(‘NumberTooBig‘);
return reject(e);
}
resolve(result); // number 값에 +10 후 성공 처리
}, 1000)
});
return promise;
}
async function runTasks() {
try { // try/catch 구문을 사용하여 에러를 처리합니다.
let result = await increment(0);
console.log(result);
result = await increment(result);
console.log(result);
result = await increment(result);
console.log(result);
result = await increment(result);
console.log(result);
result = await increment(result);
console.log(result);
result = await increment(result);
console.log(result);
} catch (e) {
console.log(e);
}
}
axios는 현재 가장 많이 사용되고 있는 자바스크립트 HTTP 클라이언트이다. axios의 특징은 요청을 Promise 기반으로 처리한다는 점이다. npm install axios
명령어로 설치할 수 있다.
App.js
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://jsonplaceholder.typicode.com/todos/1',
);
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;
위 코드는 불러오기 버튼을 누르면 JSONPlaceholder에서 제공하는 가짜 API를 호출하고 이에 대한 응답을 state에 넣어 보여주는 예제이다.
newsapi에서 제공하는 API를 사용하기 위해 https://newsapi.org/register 에 가입해 API 키를 발급받아야 한다.
발급을 받았다면 링크로 들어가면 실습에 사용할 한국 뉴스를 가져오는 API에 대한 설명서를 확인해 보자. 헤드라인을 가져오는 API 주소에 대한 예시를 보면 https://newsapi.org/v2/top-headlines?country=kr&apiKey=4427a267c77a42c6b59358fa99ac7dc0
처럼 사용하면 된다고 설명되어 있다. axios의 get 요청을 이 주소로 수정해서 보내면 다음과 같이 뉴스 헤드라인이 잘 받아와지는 것을 확인할 수 있다.
이제 위 API를 사용해서 뉴스 뷰어를 만들기 위해 필요한 component들을 만들어 보자. src/components/NewsItem.js
, src/components/NewsList.js
파일을 만들고, npm install styled-components
로 라이브러리를 설치해 준다.
위에서 response
를 찍어 봤을 때 나오는 articles
배열을 살펴보고 우리가 필요한 정보들만 골라 보자.
title
: 제목description
: 내용url
: 링크urlToImage
: 뉴스 이미지NewsItem
컴포넌트는 article
이라는 객체를 props
로 통째로 받아 와서 사용한다. NewsItem
컴포넌트를 다음과 같이 작성해 보자.
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;
아직 API로 데이터를 불러오지 않고 있으니 sampleArticle
이라는 객체에 미리 예시 데이터를 넣은 후 각 컴포넌트에 전달하여 가짜 내용이 보이게 해 보자.
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} />
</NewsListBlock>
);
};
export default NewsList;
그리고 App
컴포넌트에서 NewsList
만 보이도록 렌더링하면 아래와 같은 화면이 뜰 것이다.
이제 만들은 컴포넌트들에서 API를 호출해 보자. useEffect
를 사용하여 컴포넌트가 처음 렌더링되는 시점에 API를 요청하면 되는데, 여기서 주의할 점은 useEffect
내부에서 async/await
를 사용하고 싶다면, 함수 내부에 async
키워드가 붙은 또 다른 함수를 만들어서 사용해 주어야 한다.
추가로 loading
이라는 상태도 관리하여 API 요청이 대기 중일 때를 표시해준다. 요청이 대기 중일 때는 loading
값이 true
가 되고, 요청이 끝나면 loading
값이 false
가 되어야 한다.
import React, { useState, useEffect} from 'react';
import styled from 'styled-components';
import NewsItem from './NewsItem';
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 = () => {
const [articles, setArticles] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchData = async () => {
setLoading(true);
try {
const response = await axios.get(
'https://newsapi.org/v2/top-headlines?country=kr&apiKey=0a8c4202385d4ec1bb93b7e277b3c51f'
);
setArticles(response.data.articles);
} catch (e) {
console.log(e);
}
setLoading(false);
};
fetchData();
}, []);
if (loading) {
return <NewsListBlock>대기 중...</NewsListBlock>;
}
if (!articles){
return null;
}
return (
<NewsListBlock>
{articles.map(article => (
<NewsItem key={article.url} article={article} />
))}
</NewsListBlock>
);
};
export default NewsList;
• business(비즈니스)
• entertainment(연예)
• health(건강)
• science(과학)
• sports(스포츠)
• technology(기술)
뉴스를 위 여섯 개 카테고리로 분류하고, 선택하는 것에 따라 다르게 렌더링하는 기능을 구현해 보자.
카테고리를 선택할 수 있는 UI component를 만들어야 한다. components/Categories.js
컴포넌트 파일을 만들고 아래와 같이 코드를 작성해 주자. 그리고 App.js
에서도 <NewsList />
위에 <Categories />
으로 불러와 준다.
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;
아래처럼 카테고리 바가 잘 나타나는 걸 확인할 수 있다.
이제 카테고리 선택에 따라 상태를 관리하는 부분을 코딩해 보자. App.js
에서 category
상태를 useState
로 관리하고, category
값을 업데이트하는 onSelect
함수를 만들어 주고 컴포넌트들에게 필요한 상태들을 전달한다.
import React, { useState, 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;
다음으로 Categories
컴포넌트에서는 props
로 전달받은 onSelect
를 각 Category
컴포넌트의 onClick
으로 설정해 주고, 현재 선택된 카테고리 값에 따라 다른 스타일을 적용시켜 보자.
import React from 'react';
import styled, { css } from 'styled-components';
...
const Category = styled.div`
...
&: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;
선택하는대로 스타일이 달라지는 걸 확인할 수 있다.
이번에는 catogory
에 따라 NewsList
컴포넌트에서 다르게 API를 요청하도록 구현해 보자. category
값이 달라짐에 따라 useEffect
를 다시 실행해 뉴스를 다시 불러와야 하기 때문에 의존 배열에 category
를 넣어 준다. useEffect
안에서 category
값이 all
이라면 query
값을 공백으로 설정하고, all
이 아니라면 "&category=카테고리"
형태의 문자열을 만들도록 한다. 그리고 이 query
를 주소에 포함시켜 주면 된다.
...
const NewsList = ({ category }) => {
const [articles, setArticles] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
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=0a8c4202385d4ec1bb93b7e277b3c51f`
);
setArticles(response.data.articles);
} catch (e) {
console.log(e);
}
setLoading(false);
};
fetchData();
}, [category]);
...
여기까지 코딩을 마쳤다면 브라우저를 열어서 다른 카테고리를 선택해 보자. 카테고리에 따른 뉴스가 잘 나타나는 걸 확인할 수 있다.
카테고리를 useState로 관리하는 대신 라우터를 적용해보자. npm install react-router-dom
으로 설치를 해 주고, index.js에서 App을 감싸 적용한다.
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter } from 'react-router-dom';
ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root')
);
reportWebVitals();
url 따라 페이지가 바뀌지는 않을 거니까... 일단 src/pages/NewsPage.js
를 만들어 다음과 같이 작성한다.
import React from 'react';
import Categories from '../components/Categories';
import NewsList from '../components/NewsList';
const NewsPage = ({ match }) => {
const category = match.params.category || 'all';
return (
<>
<Categories />
<NewsList category={category} />
</>
);
};
export default NewsPage;
현재 선택된 category 값을 url 파라미터를 통해 사용할 것이므로 App애서 Route를 정의해 준다.
path="/:category?"에서 물음표는 category 값이 optional 하다는 의미이다.
import React from 'react';
import { Route } from 'react-router-dom';
import NewsPage from './pages/NewsPage';
const App = () => {
return (
<Route path="/:category?" component={NewsPage} />
);
};
export default App;
import React from 'react';
import styled from 'styled-components';
import { NavLink } from 'react-router-dom';
...
const Category = styled(NavLink)`
...
&: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"
exact={c.name === 'all'}
to={c.name === 'all' ? '/' : `${c.name}`}
>
{c.text}
</Category>
))}
</CategoriesBlock>
);
};
export default Categories;