본문 바로가기
Frontend/React

[React] 뉴스뷰어 프로젝트(비동기작업 기초)

by 디스코비스킷 2023. 12. 28.
반응형

비동기작업의 이해

서버쪽 데이터가 필요할때 Ajax기법을 사용하여 서버의 API를 호출함으로써 데이터를 수신한다. 이과정에서 해당 작업을 비동기적으로 처리하게된다. 만약 작업을 동기적으로 처리한다면 요청이 끝날때까지 기다리는 동안 중지상태가 되기때문에 다른작업을 할 수 없다. *비동기적으로 처리한다면 웹 애플리케이션이 멈추지 않기때문에 동시에 여러가지 요청을 처리할 수 있고, 기다리는 과정에서 다른 함수도 호출 할 수 있다. *

서버 API 호출 외에도 작업을 비동기적으로 할때가 있는데, setTimeout함수를 사용하여 특정작업을 예약할 때이다.

function printMe() {
  console.log("HELLO WORLD"
}
setTimeout(printMe, 3000);

setTimeout이 사용되는 시점에서 코드가 3초동안 멈추는것이 아니라, 일단 코드가 위부터 아래까지 다 호출되고 3초뒤에 지정해둔 printMe가 호출된다. 비동기작업을 할때 가장 흔히 사용하는 방법은 콜백함수를 사용하는 것이다. printMe가 3초뒤에 호출되도록 함수자체를 첫번째 인자로 전달하였다. 이것을 콜백함수라 한다.

콜백함수

Promise

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

yarn create react-app react-news-app

yarn start가 안되고
'react-scripts'은(는) 내부 또는 외부 명령, 실행할 수 있는 프로그램, 또는 배치 파일이 아닙니다.
라는 메시지가 자꾸 떠서
npm i react-scripts했더니 잘 된다...

newsapi API 키 발급

https://newsapi.org
들아가서 Get API Key 하면 회원등록후 키를 발급해준다.

import {useState} from 'react'
import axios from 'axios'

function App() {
  const [data, setData] = useState(null);
  const onClick = async () => {
    try {
      const apiKey = "f9c9929c82944e4da5e778ecdd416126";
      const url = `https://newsapi.org/v2/top-headlines?country=kr&apiKey=${apiKey}`;
      const response = await axios.get(url);
      setData(response.data);
    } catch (error) {
      console.log(error)
    }
  }

  return (
    <div className="App">
      <button onClick={onClick}>불러오기</button>
      {data && <div>{JSON.stringify(data, null, 2)}</div>}

    </div>
  );
}

export default App;

테스트해보니 잘 나옴

뉴스뷰어 UI만들기

$ yarn add styled-components

yarn으로 설치하니까 왤케... 체감상 npm 패키지설치 보다 느린지..
One or more node_modules have been detected and will be removed. This operation may take some time.
이런 에러가 나타나서 멈추는게 큰것같다. 그냥 yarn cra보다 vite프로젝트가 편하다.
eslint나 다른 필수패키지가 자동설치돼서 코드작성도 편하고 여러모로.. 이젠 vite로 그냥 써야겠다!

vite 프로젝트로 변경하고 다음 패키지를 설치했다.
npm i react-router-dom react-redux @reduxjs/toolkit toastify uuid styled-components

NewsItem 만들기

components/NewsItem/index.jsx

import React from 'react'
import { NewsItemBlock } from './NewsItem.styles'
import { Link } from 'react-router-dom'

const NewsItem = ({ article }) => {
  const { title, description, url, urlToImage } = article;

  return (
    <NewsItemBlock>
      {urlToImage && (
         <div className='thumbnail'>
          <Link to={url} target='_blank'>
            <img src={urlToImage} alt={`${title} 썸네일`} />
          </Link>
         </div>
      )}
      <div className="contents">
        <h2>
          <Link to={url} target='_blank' rel="noopener noreferrer">{title}</Link>
        </h2>
        <p>{description}</p>
      </div>
    </NewsItemBlock>
  )
}

export default NewsItem

components/NewsItem/NewsItem.styles.js

import styled from "styled-components";
import colors from "../../styles/colors";

export const NewsItemBlock = styled.div`
  display: flex;
  .thumbnail {
    margin-right: 1rem;
    flex-shrink: 0;
    img {
      display: block;
      width: 160px;
      height: 100px;
      object-fit: cover;
    }
  }
  .contents {
    h2 {
      margin: 0;
      a {
        color: black;
        word-break: break-all;
      }
    }
    p {
      margin: 0;
      line-height: 1.5;
      margin-top: 0.5rem;
      white-space: normal;
    }
  }
  & + & {
    margin-top: 3rem;
  }
`;

뉴스리스트에서 article데이터를 받아와서 구조분해하여 각 속성에 데이터를 할당하고
뉴스항목 아이템에 데이터를 뿌린다.

NewsList 만들기

components/NewsList/index.jsx

import React, {useEffect, useState} from 'react'
import NewsItem from '../NewsItem';
import { NewsListBlock } from './NewsList.styles';
import axios from 'axios'


const NewsList = ({ category }) => {
  const [articles, setArticles] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    const fetchData = async() => {
      setLoading(true);
      try {
        const apiKey = "f9c9929c82944e4da5e778ecdd416126";
        const query = category === 'all' ? '' : `&category=${category}`;
        const url = `https://newsapi.org/v2/top-headlines?country=kr${query}&apiKey=${apiKey}`;
        const response = await axios.get(url);
        setArticles(response.data.articles);
      } catch(e) {
        console.log(e)
      }
      setLoading(false);
    }
    fetchData();   
  }, [category]);

  if(loading) {
    // return <CardSkeleton />; // 추후 스켈레톤만들기
    return <NewsListBlock>loading... </NewsListBlock>;
  }

  if(!articles) {
    return null;
  }

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

export default NewsList

Components/NewsList/NewsList.styles.js

import styled from "styled-components";
import colors from "../../styles/colors";

export 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;
  }
`

카테고리 기능 구현하기

components/Categories/index.js

import React from 'react'

const categories = [
    {
        name: 'all',
        text: '전체보기'
    },
    {
        name: 'business',
        text: '비즈니스'
    },
    {
        name: 'entertainment',
        text: '엔터테인먼트'
    },
    {
        name: 'health',
        text: '건강'
    },
    {
        name: 'science',
        text: '과학'
    },
    {
        name: 'sports',
        text: '스포츠'
    },
    {
        name: 'technology',
        text: '기술'
    }
]

export default categories;

components/NavBar/index.jsx

import React from 'react'
import { CategoriesBlock, Category } from './NavBar.styles'
import categories from '../Categories'

const NavBar = ({ category, onSelect }) => {
  return (
    <CategoriesBlock>
      {categories.map((cate) => (
        <Category 
          key={cate.name}
          activeClassName="active"
          exact={cate.name === 'all'}
          to={cate.name === 'all' ? '/' : `/${cate.name}`}
        >
          {cate.text}
        </Category>
      ))}
    </CategoriesBlock>
  );
}

export default NavBar

components/NavBar/NavBar.styles.js

import styled from "styled-components";
import colors from "../../styles/colors";
import { NavLink  } from "react-router-dom";

export const CategoriesBlock = styled.div`
    display: flex;
    padding: 1rem;
    width: 758px;
    margin: 0 auto;
    @media screen and (max-width: 768px) {
        width: 100px;
        overflow-x: auto;
    }
`

export const Category = styled(NavLink)`
  font-size: 1.125rem;
  cursor: pointer;
  white-space: pre;
  text-decoration: none;
  color: inherit;
  padding-bottom: 0.25rem;

  &:hover {
    color: ${colors.alto[500]};
  }

  &.active {
    font-weight: 600;
    border-bottom: 2px solid #22b8cf;
    color: #22b8cf;
    &:hover {
      color: #3bc9db;
    }
  }
  & + & {
    margin-left: 1rem;
  }
`;

components/Layout/index.jsx

import React, { useCallback, useState } from 'react'
import NavBar from "../NavBar"
import { Outlet } from "react-router-dom";

const Layout = () => {
  return (
    <>
      <NavBar />
      <Outlet />
    </>
  );
}

export default Layout

리액트 라우터 적용

App.jsx

import { BrowserRouter, Route, Routes } from "react-router-dom";
import MainPage from "./pages/MainPage";
import DetailPage from "./pages/DetailPage";
import Layout from "./components/Layout";

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<MainPage />} />
          <Route path="/:category?" element={<MainPage />} />
          <Route path="/article/:id" element={<DetailPage />} />
          {/* <Route path="login" element={<LoginPage />} /> */}
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

export default App;

뉴스 뷰페이지도 만들걸 감안해서 Detail페이지도 만들었다.
로그인기능도 생각했는데 추후 해봐야겠다.

components/NavBar/index.jsx

const NavBar = ({ category, onSelect }) => {
  return (
    <CategoriesBlock>
      {categories.map((cate) => (
        <Category 
          key={cate.name}
          className={({ isActive }) => isActive ? "active" : ""}
          exact={cate.name === 'all'}
          to={cate.name === 'all' ? '/' : `/${cate.name}`}
        >
          {cate.text}
        </Category>
      ))}
    </CategoriesBlock>
  );
}

기존에는 useState를 사용하여 category기능(NavBar)을 구현했었는데
라우터를 사용하여 NavLink의 active속성으로 좀더 편리하게 구현했다.

to값이 /를 가리키고 있을때는 exact 값을 true로 해주어야한다. 이 값을 설정하지 않으면, 다른 카테고리가 선택되었을때도 전체보기 링크에 active 스타일이 적용되는 오류가 발생한다.


NavLink에 속성을 activeClassName이라고 쓰니 경고문구가 떠서 알아보니
activeClassName이란 속성이 사라졌고 아래와 같이 작성해야한다고 한다.
리액트책을 산지 2년이 돼서 그런지.. 업데이트된 부분이 상당히 많아서 주의가 필요하다.
className={({ isActive }) => isActive ? "active" : ""}

usePromise 커스텀 Hook만들기

utils/usePromise.js

import { useState, useEffect } from 'react';

const usePromise = (promiseCreator, deps) => {
    // 대기중, 완료, 실패에 대한 상태관리
    const [loading, setLoading] = useState(false);
    const [response, setResponse] = useState(null);
    const [error, setError] = useState(null);

    useEffect(() => {
      const process = async () => {
        setLoading(true);
        try {
            const response = await promiseCreator();
            setResponse(response);
        } catch (e) {
            setError(e)
        }
        setLoading(false);
      }
      process();
      // eslint-disable-next-line react-hooks/exhaustive-deps
    }, deps)

    return [loading, response, error];
}

export default usePromise;

usePromise는 대기중, 완료결과, 실패 결과에 대한 상태를 관리하며,
의존배열 deps를 파라미터로 받아온다. 받아온 deps배열은 usePromise 내부에서 사용한 useEffect의 의존배열로 설정. 이부분에서 ESLint 경고가 나타남..
이 경고를 무시하려면 특정줄에서만 ESLint 규칙을 무시하도록 설정해야함.
// eslint-disable-next-line react-hooks/exhaustive-deps라고 빠른수정을 통해 클릭하여 주석을 넣을수있다.

MainPage에서 NewsList에 category를 useParams를 통해 내려주었다.
pages/MainPage/index.jsx

import React from 'react'
import NewsList from '../../components/NewsList/NewsList'
import { useParams } from 'react-router-dom';

const MainPage = () => {
  const params = useParams();

  return (
    <NewsList category={params.category}/>
  )
}

export default MainPage

compoenents/NewsList/index.jsx

const NewsList = ({ category }) => {
  const handleFetch = () => {
    const apiKey = "f9c9929c82944e4da5e778ecdd416126";
    const query = category === 'all' ? '' : `&category=${category}`;
    const url = `https://newsapi.org/v2/top-headlines?country=us${query}&apiKey=${apiKey}`;
    console.log(url)
    return axios.get(url);
  }
  const [loading, response, error] = usePromise(handleFetch, [category]);

  if(loading) {
    // return <CardSkeleton />; // 추후 스켈레톤만들기
    return <NewsListBlock>loading... </NewsListBlock>;
  }

  console.log("response", response)


  if (!response) {
    return null;
  }

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

  const { articles } = response.data;

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

그리고 뉴스리스트에서 usePromise를 사용해 handleFetch함수와 useEffect의 디펜던시로 사용할 category를 배열안에 넣어 넘겨주었다. 그러면 [loading, response, error]를 리턴한다. 그걸 받아서 response가 null이 아닐때 articles에 response.data를 할당해준다.
setArticles를 하니 무한루프가 돌아서 안된다. (useEffect안에서 해야함)

정리

리액트 컴포넌트에서 *API를 연동하여 개발할때 절대 잊지 말아야할 유의사항은, useEffect에 등록하는 함수는 async로 작성하면 안된다는 점이다. 그 대신 함수 내부에 async 함수를 따로 만들어주어야한다. *
지금은 usePromise라는 커스텀훅을 만들어 사용함으로써 코드가 간결해지기는했지만, 나중에 사용해야할 API의 종류가 많아지면 요청을 위한 상태관리를 하는것이 번거로워질 수 있다. *리덕스와 리덕스 미들웨어를 사용하면 요청에 대한 상태를 쉽게 관리할 수 있다. *

반응형

최근댓글

최근글

© Copyright 2023 jngmnj