본문 바로가기

Front/React

[React] 외부 API를 연동하여 뉴스 뷰어 만들기

1. 비동기 작업의 이해

  • 동시에 여러 가지 요청을 처리할 수 있고, 기다리는 과정에서 다른 함수도 호출할 수 있다.
  • 서버API를 호출할 때 외에도 작업을 비동기적으로 처리할 때가 있는데, 바로 setTimeout함수를 사용하여 특정 작업을 예약할 때 이다.

 

콜백함수

  • 자바스크립트에서 비동기 작업을 할 때 가장 흔히 사용하는 방법
  • 콜백 안에 콜백을 넣어 구현할 수 있는데, 너무 여러 번 중첩 되면 '콜백 지옥' 이라 한다.

Promise

  • 콜백 지옥 같은 코드가 형성되지 않게 하는 방안으로 ES6에 도입된 기능
  • 여러 작업을 연달아 처리한다고 해서 여러 번 감싸는 것이 아니라, then을 사용하여 그다음 작업을 설정하기 때문에 콜백 지옥이 형성되지 않는다.

 

async/await

  • Promise를 더욱 쉽게 사용할 수 있도록 해주는 ES2017(ES8) 문법
  • 함수 앞 부분에 async 키워드를 추가하고, 해당 함수 내부에서 Promise 앞부분에 await 키워드를 사용해야 한다.

 

2. axios로 API호출해서 데이터 받아오기

[.prettierrc]

  • Prittier로 코드 스타일을 자동으로 정리하고 싶다면, 프로젝트 최상위 디렉터리에 파일 생성 후 코드 입력
{
    "singleQuote": true,
    "semi": true,
    "useTabs": false,
    "tabWidth": 2,
    "trailingComma": "all",
    "printWidth": 80
}

 

[jsconfig.json]

  • VS Code에서 파일 자동 불러오기 기능을 잘 활용하고 싶다면 최상위 디렉터리에 파일 생성 후 코드 입력
{
    "compilerOptions": {
        "target": "es6"
    }
}

 

[App.js]

import React, { useState } from "react";
import axios from 'axios';

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
  • onClick함수에서 axios.get함수 사용
    • 파라미터로 전달된 주소에 GET요청
  • 이에 대한 결과는 .then을 통해 비동기적으로 확인
  • 위 코드에 async를 적용하면??

 

[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
  • 화살표 함수에 qsync/await를 적용할 때는 async () => {} 와 같은 형식으로 적용한다.

 

[결과]

 

 

3. newsapi API 키 발급받기

  • newsapi.org/register 에 가입한 뒤 key를 받는다.
  • 발급 받은 API key는 추후 API를 요청할 때 API주소의 파라미터로 넣어 사용하면 된다.
  • newsapi.org/s/south-korea-news-api 링크에 들어가면 한국 뉴스를 가져오는 API에 대한 설명서가 있다.
    • 1. 전체 뉴스 불러오기
    • 2. 특정 카테고리 뉴스 불러오기

 

 

4. 뉴스 뷰어 UI 만들기

[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.5 rem;
            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.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.js]

import React from "react";
import NewsList from './components/NewsList'

const App = () => {
  return <NewsList />
};

export default App

 

[결과]

 

5. 데이터 연동하기

  • 컴포넌트가 화면에 보이는 기점에 API를 요청해 볼 것이다.
  • useEffect를 사용하여 컴포넌트가 처음 렌더링되는 시점에 API를 요청
    • useEffect에 등록하는 함수에 async를 붙이면 안된다.
    • useEffect에서 반환해야 하는 값을 뒷정리 함수이기 때문
    • useEffect내부에서 async/await를 사용하고싶다면, 함수 내부에 async키워드가 붙은 또다른 함수를 만들어서 사용해야함
  • loading이라는 상태도 관리하여 API요청이 대기중인지 판별할 것이다.

[NewsList.js]

import React, { useEffect, useState } 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(() => {
        // async를 사용하는 함수 따로 선언
        const fetchData = async () => {
            setLoading(true);
            try {
                const response = await axios.get(
                    'https://newsapi.org/v2/top-headlines?country=kr&apiKey=fc444b3a393c49eb99efd6f63d669444',
                );
                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함수를 사용하기 전에 !articles를 조회하여 해당 값이 null이 아닌지 검사해야한다.
    • 이 작업을 하지 않으면, 데이터가 없을 때 null에는 map함수가 없기 때문에 렌더링 과정에서 오류가 발생

 

[결과]

 

 

 

6. 카테고리 기능 구현

  • components디렉터리에 Categories.js 컴포넌트 파일 생성

[Categories.js]

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-w: 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;
    }

    ${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;

 

[App.js]

import React, {useState, useCallback} from "react";
import NewsList from './components/NewsList'
import Categories from './components/Categories'

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

 

[NewsList.js]

import React, { useEffect, useState } 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 = ({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=fc444b3a393c49eb99efd6f63d669444`,
                );
                setArticles(response.data.articles);
            } catch(e) {
                console.log(e)
            }
            setLoading(false);
        };
        fetchData();
    }, [category]);

    // 대기 중일 때
    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;
  • category 값이 all이라면 query값을 공백으로 설정하고, all이 아니라면 "&category=카테고리" 형태의 문자열을 만들도록 했다.
  • query를 요청할 때 주소에 포함시켜 줬다.
  • category값이 바뀔 때마다 뉴스를 새로 불러와야 하기 때문에 useEffect의 의존 배열(두번째 파라미터로 설정하는 배열)에 category를 넣어줘야 한다.
  • 만약 클래스형 컴포넌트로 만들게 된다면 componentDidMount와 componentDidUpdate에서 요청을 시작하도록 설정해줘야한다.
    • 함수형 컴포넌트라면 이렇게 useEffect 한 번으로 컴포넌트가 맨 처음 렌더링 될 때
    • 그리고 category값이 바뀔 때 요청하도록 설정해 줄 수 있다.

 

 

[결과]

 

 

7. 리액트 라우터 적용하기

  • 기존에 카테고리 값을 useState로 관리했지만, 이번에 라우터의 URL 파라미터를 사용하여 관리해 보겠다.
  • 리액트 라우터 설치 ( $ yarn add react-router-dom)
  • index.js에서 리액트 라우터를 적용

[index.js]

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')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

 

  • src디렉터리에 pages라는 디렉터리를 생성하고, NewsPage.js 파일을 생성

[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.js]

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
  • path에 /:category?와 같은 형태로 맨 뒤에 물음표 문자가 들어가 있는데, 이는 category값이 선택적이라는 의미
  • category URL 파라미터가 없다면 전체 카테고리를 선택하나 것으로 간주

 

 

이제 Categories에서 기존의 onSelect 함수를 호출하여 카테고리를 선택하고, 선택된 카테고리에 다른 스타일을 주는 기능을 NavLink로 대체해 보겠다.

 

[Categories.js]

import React from 'react';
import styled from 'styled-components';
import {NavLink} from 'react-router-dom';

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-w: auto;
    }
`;

const Category = styled.div(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 = ({ onSelect, category }) => {
    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;
  • to값이 '/' 를 가리키고 있을 때는 exact값을 true로 해 주어야 합니다.
    • 이 값을 설정하지 않으면, 다른 카테고리가 선택되었을 때도 전체보기 링크에 active 스타일이 적용되는 오류가 발생한다.

 

[결과]

 

8. usePromise 커스텀 Hook 만들기

  • 컴포넌트에서 API 호출처럼 Promise를 사용해야 하는 경우 더욱 간결하게 코드를 작성할 수 있도록 해주는 커스텀 Hook을 만들어 적용해보자
  • 프로젝트의 다양한 곳에서 사용될 수 있는 유틸 함수들은 보통 src 디렉터리에 lib 디렉터리를 만든 후 그 안에 작성한다.

[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/exhautive-deps
    }, deps);

    return [loading, resolved, error]
}
  • 방금 만든 usePromise Hook은 Promise의 대기중, 완료 결과, 실패 결과에 대한 상태를 관리하며, usePromise의 의존배열 deps를 파라미터로 받아온다.
  • 파라미터로 받아 온 deps배열은 usePromise내부에서 사용한 useEffect의 의존 배열로 설정되는데 이 배열을 설정하는 부분에서 ESLint경고가 나타나게 된다.
  • 이 경고를 무시하려면 특정 줄에서만 ESLint 규칙을 무시하도록 주석을 작성해 주어야 한다.
  • 코드를 저장한 뒤 NewsList 컴포넌트에서 usePromise를 사용해보세요

 

[components/NewsList.js]

import React from 'react';
import styled from 'styled-components';
import NewsItem from './NewsItem';
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=fc444b3a393c49eb99efd6f63d669444`,
        );
    }, [category]);

    // 대기 중일 때
    if (loading) {
        return <NewsListBlock>대기 중...</NewsListBlock>
    }

    // 아직 .response 값이 설정되지 않았을 때
    if(!response) {
        // alert('에러발생')
        return null;
    }

    // 에러가 발생했을 때
    if (error) {
        return <NewsList>에러 발생!</NewsList>
    }

    // response 값이 유효할 때
    const {articles} = response.data;
    return (       
        <NewsListBlock>
            {articles.map(article => (
                <NewsItem key={article.url} article={article} />
            ))}
        </NewsListBlock>
    );
};

export default NewsList;

 

  • usePromise를 사용하면 NewsList에 대기 중 상태 관리와 useEffect 설정을 직접 하지 않아도 되므로 코드가 훨씬 간결해진다.
  • 요청 상태를 관리할 때 무조건 커스텀 Hook을 만들어 사용해야 하는 것은 아니지만, 상황에 따라 적절히 사용하면 좋은 코드를 만들어 갈 수 있다.

 

[결과]

 

 

 

 

'Front > React' 카테고리의 다른 글

[React] 리덕스 라이브러리  (0) 2021.01.14
[React] Context API  (0) 2021.01.14
[React] 외부 API를 연동하여 뉴스 뷰어 만들기  (0) 2021.01.12
[React] 리액트 라우터 부가 기능  (0) 2021.01.09
[React] 서브 라우트  (0) 2021.01.09
[React] URL 파라미터와 쿼리  (0) 2021.01.09