본문 바로가기

react

[리액트를 다루는 기술] 14장 외부 API를 연동하여 뉴스 뷰어 만들기

14.1 비동기 작업의 이해 

 

- 서버의 API를 사용해야 할 때는 네트워크 송수신 과정에서 시간이 걸리기 때문에 작업이 즉시 처리되는 것이 아니라, 응답을 받을 때까지 기다렸다가 전달받은 응답 데이터를 처리. 이 과정에서 해당 작업을 비동기적으로 처리 

 

 

14.1.1 콜백 함수 

 

ex) 파라미터 값이 주어지면 1초 뒤에 10을 더해서 반환하는 함수 

function increase(number, callback) {
	setTimeout(() => {
    	const result = number + 10;
      if(callback) {
      	callback(result);
      }
    },1000)
}

increase(0, result => {
	console.log(result);
});

 

- 1초에 걸쳐서 10,20,30,40과 같은 형태로 여러 번 순차적으로 처리하고 싶다면 콜백함수를 중첩해서 구현할 수 있음.

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('작업 완료');
            });
        });
    });
});

 

- 여러번 중첩되니까 코드의 가독성이 나빠짐 -> '콜백 지옥'

 

 

14.1.2 Promise

 

- 콜백 지옥 같은 코드가 형성되지 않게 하는 방안으로 ES6에 도입된 기능

- 여러 작업을 연달아 처리한다고 해서 함수를 여러 번 감싸는 것이 아니라, .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);
    })
    .catch(e => {
        //도중에 에러가 발생한다면 .catch를 통해 알 수 있음
        console.log(e);
    });

 

 

 

14.1.3 async / await

 

- Promise를 더욱 쉽게 사용할 수 있도록 해주는 ES2017(ES8) 문법

- 함수 앞부분에 async 키워드를 추가하고, 해당 함수 내부에서 Promise의 앞부분에 await 키워드를사용 

 

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 runTascks() {
    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);
    }
}

 

 

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

 

- axios는 자바스크립트 HTTP 클라이언트 

- HTTP 요청을 Promise 기반으로 처리 

 

App.js

- 불러오기 버튼을 누르면  'Jsonplaceholder.typicode.com/'에서 제공하는 가짜 API를 호출하고 이에 대한 응답을 컴포넌트 상태에 넣어서 보여줌

- onClick 함수에서는 axios.get 함수를 사용, 파라미터로 전달된 주소에 GET 요청을 해줌 

- 결과는 .then을 통해 비동기적으로 확인할 수 있음 

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

 

 

 

- async 적용 

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

 

 

14.3 newsapi API 키 발급받기 

 

- newsapi에서 제공하는 API를 사용하여 최신 뉴스를 불러온 후 보여줌

 

https://newsapi.org/s/south-korea-news-api

 

South Korea News API - Live top headlines from South Korea

Get live top and breaking news headlines from South Korea with our JSON API. Live example This example demonstrates the HTTP request to make, and the JSON response you will receive, when you use the News API to get live headlines from South Korea. Top head

newsapi.org

- api 호출

 

 

14.4 뉴스 뷰어 UI 만들기 

 

14.4.1 NewsItem 만들기 

 

- 각 뉴스 정보를 보여주는 컴포넌트 

- NewsItem 컴포넌트는 aritcle이라는 객체를 props로 통째로 받아 와서 사용 

  • title : 제목
  • description : 내용
  • url : 링크 
  • urlToImage : 뉴스 이미지 

 

components/NewsItem.js

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="content">
                <h2>
                    <a href={url} target="_blank" rel="noopener noreferrer">
                        {title}
                    </a>
                </h2>
                <p>{description}</p>
            </div>
        </NewsItemBlock>
    );
};

export default NewsItem;

 

 

14.4.2 NewsList 만들기 

 

- API를 요청하고 뉴스 데이터가 들어 있는 배열을 컴포넌트 배열로 변환하여 렌더링해주는 컴포넌트 

- sampleArticle이라는 객체에 미리 예시 데이터를 넣은 후 각 컴포넌트에 전달하여 가짜 내용이 보이게 함

 

components/NewsList.js

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}/>
            <NewsItem article={sampleArticle}/>
        </NewsListBlock>
    );
};

export default NewsList;

 

 

 

 

14.5 데이터 연동하기 

 

- 컴포넌트가 화면에 보이는 시점에 API를 요청 

- useEffect를 사용하여 컴포넌트가 처음 렌더링되는 시점에 API를 요청

 

- useEffect에 등록하는 함수에 async를 붙이면 안됨 

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

 

- loading이라는 상태를 관리하여 API 요청이 대기 중인지 판별

 -> 요청이 대기중일 때는 loading 값이 true가 되고 ,요청이 끝나면 loading 값이 false가 되어야 함 

 

components/NewsList.js

 

- 뉴스 데이터 배열을 map 함수를 사용하여 컴포넌트 배열로 변환

- map 함수를 사용하기 전에 !articles를 조회하여 해당 값이 현재 null이 아닌지 검사 해야함

- 위 작업을 하지 않으면 아직 데이터가 없을 때 null에는 map 함수가 없기 때문에 렌더링 과정에서 오류 발생

import { 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(() => {
        //async를 사용하는 함수 따로 선언
        const fetchData = async () => {
            setLoading(true);
            try {
                const response = await axios.get (
                    'https://newsapi.org/v2/top-headlines?country=kr&apiKey=6c232a3858524e94a97156e2afeed3f5',
                );
                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;

 

 

 

14.6 카테고리 기능 구현하기 

 

14.6.1 카테고리 선택 UI 만들기 

 

Categories.js

- categories라는 배열 안에 name과 text 값이 들어가 있는 객체들을 넣어 주어서 한글로 된 카테고리와 실제 카테고리 값을 연결시켜 줌

- name은 실제 카테고리 값을 가리키고, text 값은 렌더링할 때 사용할 한글 카테고리를 가리킴

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

 

- App에서 category 상태를 useState로 관리 

- category 값을 업데이트하는 onSelect 함수 작성

- category와 onSelect 함수를 Categories 컴포넌트에게 props로 전달

- category 값을 NewsList 컴포넌트에게도 전달 

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

 

 

Categories.js

 

- props로 전달받은 onSelect를 각 Category 컴포넌트의 onClick으로 설정해 주고, 현재 선택된 카테고리 값에 따라 다른 스타일을 적용 

import React from 'react';
import styled, { css } from 'styled-components';


...

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;

 

 

14.6.2 API를 호출할 때 카테고리 지정하기 

 

- NewsList 컴포넌트에서 현재 props로 받아 온 category에 따라 카테고리를 지정하여 API를 요청

 

components/NewsList.js

 

- 현재 category 값이 무엇인지에 따라 요청할 주소가 동적으로 바뀌고 있음

- category값이 all이라면 query 값을 공백으로 설정하고, all이 아니라면 "&category=카테고리" 형태의 문자열을 생성

- 이 query를 요청할 때 주소에 포함

- category 값이 바뀔 때마다 뉴스를 새로 불러와야 하기 때문에 useEffect의 의존 배열에 category를 넣어줌

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

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

  // 아직 articles 값이 유효할 때 
  if (!articles) {
    return null;
  }


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

export default NewsList;

14.7 리액트 라우터 적용하기 

 

- 카테고리 값을 리액트 라우터의 URL 파라미터를 사용하여 관리 

 

14.7.1 NewsPage 생성 

 

pages/NewsPage.js

 

- 현재 선택된 category 값을 URL 파라미터를 통해 사용할 것이므로 Categoreis 컴포넌트에서 현재 선택된 카테고리 값을 알려 줄 필요도 없고, onSelect 함수를 따로 전달해 줄 필요도 없음 

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;

 

 

App.js

 - 경로에 category URL 파라미터가 없어도 NewsPage 컴포넌트를 보여줘야 하고, category가 있어도 NewsPage를 보여줘야 함

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;

 

 

14.7.2 Categoreis에서 NavLink 사용하기 

 

- div, a, button, input처럼 일반 HTML 요소가 아닌 특정 컴포넌트에 styled-components를 사용할 떄는 styled(컴포넌트 이름)' '과 같은 형식을 사용 

 

components/Categories.js

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

...

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;
  }
`;
const Categories = () => {
  return (
    <CategoriesBlock>
      {categories.map((c) => (
        <Category
          key={c.name}
          className={({ isActive }) => (isActive ? 'active' : undefined)}
          to={c.name === 'all' ? '/' : `/${c.name}`}
        >
          {c.text}
        </Category>
      ))}
    </CategoriesBlock>
  );
};

export default Categories;

 

 

 

 

 

14.8 usePromise 커스텀 Hook 만들기 

 

lib/usePromise.js

- 프로젝트의 다양한 곳에서 사용될 수 있는 유틸 함수들은 src 디렉토리에 lib 디렉토리를 만든 후 그 안에 작성

- usePromise 훅은 Promise의 대기 중, 완료 결과, 실패 결과에 대한 상태를 관리

  - usePromise의 의존 배열 deps를 파라미터로 받아옴 

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

  return [loading, resolved, error];
}

 

- 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=6c232a3858524e94a97156e2afeed3f5`,
    );
  }, [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>
  );
};

export default NewsList;