서버의 API를 사용해야 할 때는 네트워크 송수신 과정에서 시간이 걸리기 때문에 작업이 즉시 처리되는 것이 아니라, 응답을 받을 때까지 기다렸다가 전달받은 응답 데이터를 처리합니다.
해당 작업을 비동기적으로 처리하게 됩니다.
이렇게 서버 API를 호출할 때 외에도 작업을 비동기적으로 처리할 때가 있는데,
바로 setTimeout 함수를 사용하여 특정 작업을 예약할 때입니다.
function printMe() {
console.log("hello world");
}
setTimeout(printMe, 3000);
console.log('대기 중');
실행 결과
>대기 중
>hello world
setTimeout이 사용되는 시점에서 코드가 3초 동안 멈추는 것이 아니라, 일단 코드가 위부터 아래까지 다 호출되고 3초 뒤에 우리가 지정해 준 printMe가 호출 됩니다.
파라미터 값이 주어지면 1초 뒤에 10을 더해서 반환하는 함수가 있을 때 해당 함수가 처리된 직후 어떠한 작업을 하고 싶다면 다음과 같이 콜백 함수를 활용해서 작업합니다.
function increase(number, callback){
setTimeout(()=>{
const result = number + 10;
if(callback){
callback(result);
}
},1000)
}
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('작업 완료');
});
});
});
});
실행 결과
>작업시작
>10
>20
>30
>40
>작업 완료
Promise
는 콜백 지옥 같은 코드가 형성되지 않게 하는 방안으로 ES6에 도입된 기능입니다.
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);
});
실행 결과
여러 작업을 연달아 처리한다고 해서 함수를 여러 번 감싸는 것이 아니라 .then을 사용하여 그다음 작업을 설정하기 때문에 콜백 지옥이 형성되지 않습니다.
async/await는 Promise 를 더욱 쉽게 사용할 수 있도록 해주는 ES2017(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 increase(0);
console.log(result);
result = await increase(result);
console.log(result);
result = await increase(result);
console.log(result);
result = await increase(result);
console.log(result);
result = await increase(result);
console.log(result);
result = await increase(result);
console.log(result);
} catch(e) {
console.log(e);
}
}
runTasks();
axios는 현재 가장 많이 사용되고 있는 자바스크립트 HTTP 클라이언트입니다.
이 라이브러리의 특징은 HTTP 요청을 Promise 기반으로 처리한다는 점입니다.
설치
$ npm install axios
prettier로 코드 스타일 자동으로 정리하고 싶다면, 프로젝트의 최상위 디렉터리에 .prettierrc 파일 생성하여 설정을 입력하세요.
{
"singleQuote": true,
"semi": true,
"useTabs": false,
"tabWidth": 2,
"trailingComma": "all",
"printWidth": 80
}
아래 코드는 불러오기 버튼을 누르면 JSONPlceholder에서 제공하는 가짜 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://jsonplaceholder.typicode.com/todos/1',
);
setData(response.data);
} catch (e) {
console.log(e);
}
};
return (
<div>
<div>
<button onClick={onClick}>불러오기</button>
{data && (
<textarea
rows={7}
value={JSON.stringify(data, null, 2)}
readOnly={true}
/>
)}
</div>
</div>
);
};
export default App;
newsapi에서 제공하는 제공하는 API를 사용하여 최신 뉴스를 불러온 후 보여줄 것 입니다.
API는 https://newsapi.org/register에 가입하면 발급받을 수 있습니다.
// 9c872d4a6bdf4481a5e270d7b95e753e
발급받은 API 키는 추후 API를 요청할 때 API 주소의 쿼리 파라미터로 넣어서 사용하면 됩니다.
각 뉴스 데이터에는
articles": [
{
"source": {
"id": null,
"name": "Khan.co.kr"
},
"author": null,
"title": "7년 만에 전국서 평검사 회의…집단행동 검찰 전체로 확산 - 경향신문",
"description": "윤석열 검찰총장에 대한 징계 청구·직무집행 정지에 반발하는 검사들의 집단행동이 26일 검찰 조직 전체로...",
"url": "http://news.khan.co.kr/kh_news/khan_art_view.html?art_id=202011262119005",
"urlToImage": "http://img.khan.co.kr/news/2020/11/26/2020112701003160400267641.jpg",
"publishedAt": "2020-11-26T12:19:00Z",
"content": "· 26 . cut@kyunghyang.com"
}
정보로 이루어진 JSON 객체가 있습니다.
그 중에서
NewsItem 컴포넌트는 article이라는 객체를 props로 통째로 받아와서 사용합니다.
components/NewsItems.js 코드
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>
);
};
NewsList 컴포넌트를 일단 가짜 article을 넣어줘서 만듭니다.
const NewsList = () => {
const sampleArticle = {
title: '제목',
description: '내용',
url: 'https://google.com',
urlToImage: 'https://via.placeholder.com/160',
};
return (
<NewsListBlock>
<NewsItem article={sampleArticle} />
<NewsItem article={sampleArticle} />
<NewsItem article={sampleArticle} />
<NewsItem article={sampleArticle} />
<NewsItem article={sampleArticle} />
<NewsItem article={sampleArticle} />
</NewsListBlock>
);
};
NewsList 컴포넌트에 API를 호출해 보겠습니다.
컴포넌트가 화면에 보이는 시점에 API를 요청해 보겠습니다.
이때 useEffect를 사용하여 컴포넌트가 처음 렌더링되는 시점에 API를 요청하면 됩니다.
여기서 주의할 점)
useEffect
에 등록하는 함수에 async를 붙이면 안됩니다.
useEffect에서 반환해야 하는 값은 뒷정리 함수이기 때문입니다.
따라서 useEffect 내부에서 async/await를 사용하고 싶다면, 함수 내부에 async 키워드가 붙은 또 다른 함수를 만들어서 사용해 주어야 합니다.
추가로 loading
이라는 상태도 관리하여 API 요청
이 대기 중인지 판별할 것입니다.
요청이 대기중
일 때는 loading 값이 true가 되고, 요청이 끝나면
loading 값이 false가 되어야 합니다.
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=9c872d4a6bdf4481a5e270d7b95e753e',
);
setArticles(response.data.articles);
} catch (e) {
console.log(e);
}
setLoading(false);
};
fetchData();
}, []);
const sampleArticle = {
title: '제목',
description: '내용',
url: 'https://google.com',
urlToImage: 'https://via.placeholder.com/160',
};
//대기 중 일때
if (loading) {
return <NewsListBlock>대기 중...</NewsListBlock>;
}
//아직 articles 값이 설정되지 않았을 때
if (!articles) {
return null;
}
//articles 값이 유효할 때
return (
<NewsListBlock>
{articles.map((article) => (
<NewsItem key={article.url} article={article} />
))}
</NewsListBlock>
);
};
주의해야 할 점)
뉴스 데이터 배열을map
함수를 사용하여 컴포넌트 배열로 변환할 때
map 함수를 사용하기 전에 꼭!articles
를 조회하여 해당 값이 현재 null이 아닌지 검사해야 합니다.
아직 데이터가 없을 때 null에는 map 함수가 없기 때문에 렌더링 과정에서 오류가 발생합니다.
실행 결과 화면
Categories 컴포넌트에 categories라는 배열을 만들어 줍니다.
배열 안에 name과 text 값이 들어가 있는 객체들을 넣어 주어서 한글로 된 카테고리와 실제 카테고리 값을 연결시켜 주었습니다.
name은 실제 카테고리 값을 가리키고, text 값은 렌더링할 때 사용할 한글 카테고리를 가리킵니다.
const categories = [
{
name: 'all',
text: '전체보기',
},
{
name: 'business',
text: '비즈니스',
},
{
name: 'entertainment',
text: '엔터테인먼트',
},
{
name: 'health',
text: '건강',
},
{
name: 'science',
text: '과학',
},
{
name: 'sports',
text: '스포츠',
},
{
name: 'technology',
text: '기술',
},
];
const Categories = () => {
return (
<CategoriesBlock>
{categories.map((c) => (
<Category key={c.name}>{c.text}</Category>
))}
</CategoriesBlock>
);
};
App에서 category 상태를 useState로 관리해주었습니다.
const App = () => {
const [category, setCategory] = useState('all');
const onSelect = useCallback((category) => setCategory(category), []);
return (
<>
<Categories category={category} onSelect={onSelect} />
<NewsList category={category} />
</>
);
};
props로 전달받은 category, onSelect로 현재 선택된 카테고리 값에 따라 스타일을 지정해 주었습니다.
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>
);
};
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;
}
`;
실행 화면
props로 받아 온 category에 따라 카테고리를 지정하여 API를 요청하도록 구현하였습니다.
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=9c872d4a6bdf4481a5e270d7b95e753e`,
);
setArticles(response.data.articles);
} catch (e) {
console.log(e);
}
setLoading(false);
};
fetchData();
}, [category]);
현재 category 값이 무엇인지에 따라 요청할 주소가 동적으로 바뀌고 있습니다.
category 값이 all이라면 query 값을 공백으로 설정하고, all이 아니라면 `"&category=카테고리" 형태의 문자열을 만들도록 했습니다. 그리고 이 query를 요청할 때 주소에 포함시켜 주었습니다.
category 값이 바뀔 때마다 뉴스를 새로 불러와야 하기 때문에 useEffect의 의존 배열에 category를 넣어 주어야 합니다.
위에서는 카테고리 값을 useState로 관리했는데, 이제는 이 값을 리액트 라우터의 URL 파라미터를 사용하여 관리해보겠습니다.
설치
$ npm install react-router-dom
index에서 리액트 라우터 적용
import {BrowserRouter} from "react-router-dom";
ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root'),
);
pages라는 디렉터리 안에 파일을 만들어줍니다.
const NewsPage = ({ match }) => {
// 카테고리가 선택되지 않았으면 기본값 all로 사용
const category = match.params.category || 'all';
return (
<div>
<Categories />
<NewsList category={category} />
</div>
);
};
export default NewsPage;
비즈니스 선택했을 때 match 값
현재 선택된 category 값을 URL 파라미터를 통해 사용할 것이므로 Categories 컴포넌트에서 현재 선택된 카테고리 값을 알려 줄 필요도 없고, onSelect 함수를 따로 전달해 줄 필요도 없습니다.
App.js 코드에 Route 정의해줍니다.
const App = () => {
return <Route path="/:category?" component={NewsPage} />;
};
기존의 onSelect 함수를 호출하여 카테고리를 선택하고, 선택된 카테고리에 다른 스타일을 주는 기능을 NavLink로 대체해 보겠습니다.
const Categories = () => {
return (
<CategoriesBlock>
{categories.map((c) => (
<Category
key={c.name}
exact={c.name === 'all'}
to={c.name === 'all' ? '/' : `/${c.name}`}
>
{c.text}
</Category>
))}
</CategoriesBlock>
);
};
...
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;
}
`;
카테고리를 클릭할 때 페이지 주소가 바뀌고 이에 따라 뉴스 목록을 보여줍니다.
컴포넌트에서 API 호출처럼 Promsie를 사용해야 하는 경우 더욱 간결하게 코드를 작성할 수 있도록 해주는 커스텀 Hook을 만들어보겠습니다.
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();
}, deps);
return [loading, resolved, error];
usePromise를 사용하면 NewList에서 대기 중 상태 관리와 useEffect 설정을 직접 하지 않아도되므로 코드가 훨씬 간결해집니다.
NewsList.js
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=9c872d4a6bdf4481a5e270d7b95e753e`,
);
}, [category]);
//대기 중 일때
if (loading) {
return <NewsListBlock>대기 중...</NewsListBlock>;
}
//아직 response 값이 설정되지 않았을 때
if (!response) {
return null;
}
//에러가 발생했을 때
if (error) {
return <NewsListBlock>에러 발생!</NewsListBlock>;
}
//response 값이 유효할 때
const { articles } = response.data;
return (
<NewsListBlock>
{articles.map((article) => (
<NewsItem key={article.url} article={article} />
))}
</NewsListBlock>
);
};