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

김지원·2020년 11월 26일
1

React

목록 보기
21/31

비동기 작업의 이해

  • 서버의 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

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

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로 API 호출해서 데이터 받아 오기

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 키 발급받기

newsapi에서 제공하는 제공하는 API를 사용하여 최신 뉴스를 불러온 후 보여줄 것 입니다.
API는 https://newsapi.org/register에 가입하면 발급받을 수 있습니다.

// 9c872d4a6bdf4481a5e270d7b95e753e

발급받은 API 키는 추후 API를 요청할 때 API 주소의 쿼리 파라미터로 넣어서 사용하면 됩니다.

NewsItem 만들기

  1. NewsItem 컴포넌트 코드를 작성

각 뉴스 데이터에는

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 객체가 있습니다.

그 중에서

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

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 함수가 없기 때문에 렌더링 과정에서 오류가 발생합니다.

실행 결과 화면

카테고리 기능 구현하기

카테고리 선택 UI 만들기

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

실행 화면

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

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

NewsPage 생성

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

카테고리를 클릭할 때 페이지 주소가 바뀌고 이에 따라 뉴스 목록을 보여줍니다.

usePromise 커스텀 Hook 만들기

컴포넌트에서 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>
  );
};

0개의 댓글

관련 채용 정보