외부 API 연동하여 뉴스 뷰어 만들기

나혜수·2023년 3월 22일
0

리액트

목록 보기
15/23

지금까지 배운 내용을 활용하여 뉴스 뷰어 프로젝트를 진행할 것이다. https://newsapi.org/에서 제공하는 API를 사용해 데이터를 받아오고 styled-components 를 활용해 스타일링 할 것이다.

axios로 API 호출

axios는 브라우저, Node.js를 위한 Promise 기반의 HTTP 비동기 통신 라이브러리이다.
* 바닐라 자바스크립트에서는 fetch api를 사용

$ yarn create react-app news-viewer
$ cd news-viewer
$ yarn add axios

🏷️App.js
axios.get 함수는 파라미터로 전달된 주소에 GET 요청을 해준다.

  • response
  • response.data
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 value={JSON.stringify(data,null,2)} readOnly={true}/>}
    </div>
  )
}

export default App;

⇩ async/await 사용 코드

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.message)
    }
  }  

  return (
    <div>
      <div>
        <button onClick={onClick}>불러오기</button>
      </div>
      {data && <textarea value={JSON.stringify(data,null,2)} readOnly={true}/>}
    </div>
  )
}

export default App;


newapi API 키 발급받기

이번 프로젝트에서는 newsapi에서 제공하는 API를 사용해 최신 뉴스를 불러와 보여줄 것이다.
이를 위해 사전에 newsapi에서 API 키를 발급받아야 한다. 키는 https://newsapi.org/register에 가입하면 발급받을 수 있다.

API 키 : 95dd24f8f4844af6b5ead4862ca93a8d

우리가 사용할 API에 대해 알아보자. https://newsapi.org/s/south-korea-news-api 들어가면 한국 뉴스를 가져오는 API에 대한 설명이 있다.

🏷️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://newsapi.org/v2/top-headlines?country=kr&apiKey=95dd24f8f4844af6b5ead4862ca93a8d')
      setData(response.data.articles)
    } catch (e){
      console.log(e.message)
    }
  }  

  return (
    <div>
      <div>
        <button onClick={onClick}>불러오기</button>
      </div>
      {data && <textarea value={JSON.stringify(data,null,2)} readOnly={true}/>}
    </div>
  )
}

export default App;


뉴스 뷰어 UI 만들기

styled-components를 사용해 뉴스 정보를 보여줄 컴포넌트를 만들어보자.

yarn add styled-components
  • NewsItem : 각 뉴스 정보를 보여주는 컴포넌트
  • NewsList : api를 요청하고 뉴스 데이터가 들은 배열을 컴포넌트 배열로 변환하여 렌더링 해주는 컴포넌트

🏷️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 

🏷️components/NewsList.js
API 요청 전 더미데이터를 NewsItem의 props로 전달한다.

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 dummyData = {
    title: "제목",
    description: "내용",
    url: "https://www.google.com/",
    urlToImage: "https://via.placeholder.com/160"
}

const NewsList = () => {
    return(
        <NewsListBlock>
            <NewsItem article={dummyData} />
            <NewsItem article={dummyData} />
            <NewsItem article={dummyData} />
            <NewsItem article={dummyData} />
            <NewsItem article={dummyData} />
        </NewsListBlock>
    )
}

export default NewsList

🏷️App.js

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

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

export default App;


API 요청

NewsList 컴포넌트에서 API를 호출해 보자. useEffect를 사용해 컴포넌트가 처음 렌더링되는 시점에 API를 요청하면 된다.

useEffect에 등록하는 함수에는 async를 붙이면 안된다. 이펙트 함수에서 반환하는 함수 (뒷정리 함수)는 이펙트 함수가 호출되기 전과 컴포넌트가 언마운트될 때 한 번씩 호출된다. 이펙트 함수에서는 뒷정리 함수를 리턴해야 하는데, async 함수를 useEffect에 그대로 전달하면 구조상 Promise를 반환할 수 밖에 없다. 이로 인해 렌더링 성능에도 영향을 미칠 수 있고, 경우에 따라서는 다른 컴포넌트를 렌더링할 시 아예 오류를 출력하기도 한다.

↓ useEffect 내부에서 async/await를 사용하고 싶다면 함수 내부에 async 키워드가 붙은 또 다른 함수를 만들어서 사용해야 한다.

🏷️components/NewsList.js
API 요청이 대기 중인지 판별하기 위해 loading 상태를 관리한다, 요청이 대기 중일 때는 true, 요청이 끝나면 false가 되어야 한다. 불러온 articles 배열을 map 함수를 사용해 컴포넌트 배열로 전환한다. 이때 !articles를 조회하여 해당 값이 현재 null인지 아닌지 확인해야 한다. 데이터가 없다면 렌더링 과정에서 오류가 나기 때문이다.

import React, { useEffect, useState } 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=95dd24f8f4844af6b5ead4862ca93a8d")
                setArticles(response.data.articles)
            } catch(e){
                alert(e.message)
            }
            setLoading(false)
        }
        fetchData()
    }, [])

    if(loading){
        return <div>로딩 중...</div>
    }

    if(!articles){
        return null
    }

    return(
        <NewsListBlock>
            {articles.map(article => (
                <NewsItem article={article} key={article.url}/>
            ))}
        </NewsListBlock>
    )
}

export default NewsList

* 불러온 데이터의 urlToImage, description 값이 null이라 title만 보인다.


카테고리 기능 구현하기

뉴스 카테고리는 총 6개이며 영어로 되어 있다.

  • business, entertainment, health, science, sports, technology

화면에 카테고리를 보여줄 때는 한글로 보여주고, 클릭하면 영어로 된 카테고리 값을 사용하도록 구현해 보자.

🏷️App.js
App에서 category 상태를 관리한다. 또한 category 값을 업데이트 하는 onSelect 함수도 만든다. category, onSelect 함수를 Categories 컴포넌트에 props로 전달한다. category 값을 NewsList 컴포넌트에도 전달한다.

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

const App = () => {
  const [category, setCategory] = useState('all')

  const onSelect = (category) => {
    setCategory(category)
  }

  return(
    <>
      <Categories category={category} onSelect={onSelect}/>
      <NewsList category={category}/>
    </>
  )
}

export default App;

🏷️components/Categories.js

onClick={() => onSelect(c.name)

특정 카테고리 선택 시 onSelect 함수가 호출되어 App에서 category 상태가 바뀌게 되고,

active={category === c.name}
${props =>
        props.active && css`
        font-weight: 600;
        border-bottom: 2px solid #22b8cf;
        color: #22b8cf;
        &:hover {
            color: #3bc9db
        }
    `}

활성화 된 카테고리의 css가 바뀌게 된다.

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;
    `
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 = ({category, onSelect}) => {
    return(
        <CategoriesBlock>
            {categories.map(c =>(
                <Category 
                key={c.name} 
                active={category === c.name}
                onClick={() => onSelect(c.name)} >
                    {c.text}
                </Category>
            ))}
        </CategoriesBlock>
    )
} 

export default Categories

🏷️components/NewsList.js
props로 받아온 category에 따라 카테고리를 지정하여 API를 요청하게 한다.

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

    if(loading){
        return <div>로딩 중...</div>
    }

    if(!articles){
        return null
    }

    return(
        <NewsListBlock>
            {articles.map(article => (
                <NewsItem article={article} key={article.url}/>
            ))}
        </NewsListBlock>
    )
}

export default NewsList


리액트 라우터 적용하기

위의 코드에서는 category 값을 useState로 관리했다. 이번에는 이를 리액트 라우터의 URL 파라미터를 사용해 관리할 것이다. 우선 현재 프로젝트에 react-router-dom을 설치하고 index.js에 리액트 라우터를 적용한다.

$ yarn add react-router-dom

🏷️index.js

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {BrowserRouter} from 'react-router-dom'

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
);

// 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();

🏷️App.js

import React from 'react';
import { Route, Routes } from 'react-router-dom';
import NewsPage from './pages/NewsPage';

const App = () => {
  return (
    <Routes>
      <Route path='/' element={<NewsPage />} />
      <Route path='/:category' element={<NewsPage />} />
    </Routes>
  )
}

export default App;

🏷️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 = () => {
    const params = useParams() 
    // 카테고리 선택되지 않으면 기본값 all 사용
    const category = params.category || 'all' 

    return (
        <>
            <Categories/>
            <NewsList category={category}/>
        </>
    )
}

export default NewsPage

🏷️components/Categories.js

import React from "react";
import { NavLink } from "react-router-dom";
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(NavLink)`
  font-size: 1.125rem;
  cursor: pointer;
  white-space: pre;
  text-decoration: none;
  color: inherit;
  padding-bottom: 0.25rem;

  &:hover {
    color: #495057;
  }
  & + & {
    margin-left: 1rem;
  }

  // 카테고리가 active일 경우 
  &.active {
    font-weight: 600;
    border-bottom: 2px solid #22b8cf;
    color: #22b8cf;
    &:hover {
      color: #3bc9db;
    }
  }
`

const Categories = () => {
    return (
        <CategoriesBlock>
          {/* map 메서드로 각 카테고리들(NavLink)을 생성 */}
          {categories.map((c) => (
            <Category key={c.name} 
            className={({isActive})=>(isActive ? 'active' : undefined)}
            // NavLink의 주소 
            // 'all'이면 기본 페이지로 그 외의 카테고리면 '/카테고리이름'
            to={c.name ==='all' ? '/' : `/${c.name}`}>
              {c.text}
            </Category>
          ))}
        </CategoriesBlock>
      );
}

export default Categories

🏷️components/NewsList.js

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

    if(loading){
        return <div>로딩 중...</div>
    }

    if(!articles){
        return null
    }

    return(
        <NewsListBlock>
            {articles.map(article => (
                <NewsItem article={article} key={article.url}/>
            ))}
        </NewsListBlock>
    )
}

export default NewsList

🏷️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 

profile
오늘도 신나개 🐶

0개의 댓글