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

Lynn·2021년 10월 9일
0

React

목록 보기
17/17
post-thumbnail

우선 외부 API를 호출하는 법을 알기 전에 동기 비동기에 대한 이해를 해보자.

만약 작업들을 동기적으로 처리한다면, 한 작업이 끝날 때까지 기다렸다가 다른 작업을 시작한다. 하지만 비동기 방식은 동시에 여러 가지 요청을 처리할 수도 있고, 기다리는 과정에서 다른 함수도 호출할 수 있다.

콜백 함수


서버 API를 호출할 때 외에도 setTimeout 함수를 사용하여 특정 작업을 예약할 때에도 비동기적 방식을 사용한다.

function printMe() {
	console.log('Hello World!');
}
setTimeout(printMe, 3000);
console.log('대기 중...'); 

실행 결과

대기 중...
Hello World!

setTimeout이 사용될 때 코드가 3초 동안 멈추는 것이 아니라, 일단 코드가 위부터 아래까지 다 호출되고(console.log까지 동시에) 3초 뒤에 실행하라고 한 printMe가 호출되었다.

이런 비동기 처리 방식을 콜백 함수라고 한다. 3초 뒤에 호출되도록 printMe 함수 자체를 setTimeout 함수의 인자로 전달해 주는 것!

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


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

위 코드에서는 1초 뒤에 10을 더해서 반환하는 함수를 정의하고 콜백함수로 값을 넘겨주고 있다. 하지만 1초 간격으로 여러 번 호출해서 콜백을 중첩한다면?

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

...이런 걸 콜백 지옥이라고 부른다.

Promise


콜백 지옥 같은 코드가 형성되지 않게 하는 방안으로 ES6에 도입된 것이 바로 Promise다. 여러 작업을 연달아 사용할 때 .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);
})
.then(number => {
  console.log(number);
  return increase(number);
})
.catch(e => {
  // 도중에 에러가 발생한다면 .catch를 통해 알 수 있음
  console.log(e);
});

async/await


async/awaitPromise를 더욱 쉽게 사용할 수 있도록 해 주는 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 increment(0);
    console.log(result);
    result = await increment(result);
    console.log(result);
    result = await increment(result);
    console.log(result);
    result = await increment(result);
    console.log(result);
    result = await increment(result);
    console.log(result);
    result = await increment(result);
    console.log(result);
  } catch (e) {
    console.log(e);
  }
}

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

axios는 현재 가장 많이 사용되고 있는 자바스크립트 HTTP 클라이언트이다. axios의 특징은 요청을 Promise 기반으로 처리한다는 점이다. npm install axios 명령어로 설치할 수 있다.

  • 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;

위 코드는 불러오기 버튼을 누르면 JSONPlaceholder에서 제공하는 가짜 API를 호출하고 이에 대한 응답을 state에 넣어 보여주는 예제이다.

newsapi에서 제공하는 API를 사용하기 위해 https://newsapi.org/register 에 가입해 API 키를 발급받아야 한다.
발급을 받았다면 링크로 들어가면 실습에 사용할 한국 뉴스를 가져오는 API에 대한 설명서를 확인해 보자. 헤드라인을 가져오는 API 주소에 대한 예시를 보면 https://newsapi.org/v2/top-headlines?country=kr&apiKey=4427a267c77a42c6b59358fa99ac7dc0 처럼 사용하면 된다고 설명되어 있다. axios의 get 요청을 이 주소로 수정해서 보내면 다음과 같이 뉴스 헤드라인이 잘 받아와지는 것을 확인할 수 있다.

뉴스 뷰어 UI 만들기


이제 위 API를 사용해서 뉴스 뷰어를 만들기 위해 필요한 component들을 만들어 보자. src/components/NewsItem.js, src/components/NewsList.js 파일을 만들고, npm install styled-components로 라이브러리를 설치해 준다.

위에서 response를 찍어 봤을 때 나오는 articles 배열을 살펴보고 우리가 필요한 정보들만 골라 보자.

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

NewsItem 컴포넌트는 article이라는 객체를 props로 통째로 받아 와서 사용한다. NewsItem 컴포넌트를 다음과 같이 작성해 보자.

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;

아직 API로 데이터를 불러오지 않고 있으니 sampleArticle이라는 객체에 미리 예시 데이터를 넣은 후 각 컴포넌트에 전달하여 가짜 내용이 보이게 해 보자.

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} />
        </NewsListBlock>
    );
};

export default NewsList;

그리고 App 컴포넌트에서 NewsList만 보이도록 렌더링하면 아래와 같은 화면이 뜰 것이다.

데이터 연동하기


이제 만들은 컴포넌트들에서 API를 호출해 보자. useEffect를 사용하여 컴포넌트가 처음 렌더링되는 시점에 API를 요청하면 되는데, 여기서 주의할 점은 useEffect 내부에서 async/await를 사용하고 싶다면, 함수 내부에 async 키워드가 붙은 또 다른 함수를 만들어서 사용해 주어야 한다.

추가로 loading이라는 상태도 관리하여 API 요청이 대기 중일 때를 표시해준다. 요청이 대기 중일 때는 loading 값이 true가 되고, 요청이 끝나면 loading 값이 false가 되어야 한다.

import React, { 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(() => {
        const fetchData = async () => {
            setLoading(true);
            try {
                const response = await axios.get(
                    'https://newsapi.org/v2/top-headlines?country=kr&apiKey=0a8c4202385d4ec1bb93b7e277b3c51f'
                );
                setArticles(response.data.articles);
            } catch (e) {
                console.log(e);
            }
            setLoading(false);
        };
        fetchData();
    }, []);

    if (loading) {
        return <NewsListBlock>대기 중...</NewsListBlock>;
    }
    if (!articles){
        return null;
    }

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

export default NewsList;

카테고리 기능 구현하기

• business(비즈니스)
• entertainment(연예)
• health(건강)
• science(과학)
• sports(스포츠)
• technology(기술)

뉴스를 위 여섯 개 카테고리로 분류하고, 선택하는 것에 따라 다르게 렌더링하는 기능을 구현해 보자.

카테고리를 선택할 수 있는 UI component를 만들어야 한다. components/Categories.js 컴포넌트 파일을 만들고 아래와 같이 코드를 작성해 주자. 그리고 App.js에서도 <NewsList /> 위에 <Categories />으로 불러와 준다.

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에서 category 상태를 useState로 관리하고, category 값을 업데이트하는 onSelect 함수를 만들어 주고 컴포넌트들에게 필요한 상태들을 전달한다.

import React, { useState, useCallback } from 'react';
...

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 컴포넌트에서는 props로 전달받은 onSelect를 각 Category 컴포넌트의 onClick으로 설정해 주고, 현재 선택된 카테고리 값에 따라 다른 스타일을 적용시켜 보자.

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

...

const Category = styled.div`
    ...

    &: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;

선택하는대로 스타일이 달라지는 걸 확인할 수 있다.

이번에는 catogory에 따라 NewsList 컴포넌트에서 다르게 API를 요청하도록 구현해 보자. category 값이 달라짐에 따라 useEffect를 다시 실행해 뉴스를 다시 불러와야 하기 때문에 의존 배열에 category를 넣어 준다. useEffect 안에서 category 값이 all이라면 query 값을 공백으로 설정하고, all이 아니라면 "&category=카테고리" 형태의 문자열을 만들도록 한다. 그리고 이 query를 주소에 포함시켜 주면 된다.

...

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

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

...

여기까지 코딩을 마쳤다면 브라우저를 열어서 다른 카테고리를 선택해 보자. 카테고리에 따른 뉴스가 잘 나타나는 걸 확인할 수 있다.

리액트 라우터 적용하기

카테고리를 useState로 관리하는 대신 라우터를 적용해보자. npm install react-router-dom으로 설치를 해 주고, index.js에서 App을 감싸 적용한다.

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

reportWebVitals();

url 따라 페이지가 바뀌지는 않을 거니까... 일단 src/pages/NewsPage.js를 만들어 다음과 같이 작성한다.

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

const NewsPage = ({ match }) => {
    const category = match.params.category || 'all';
    return (
        <>
            <Categories />
            <NewsList category={category} />
        </>
    );
};

export default NewsPage;

현재 선택된 category 값을 url 파라미터를 통해 사용할 것이므로 App애서 Route를 정의해 준다.
path="/:category?"에서 물음표는 category 값이 optional 하다는 의미이다.

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;
import React from 'react';
import styled from 'styled-components';
import { NavLink } from 'react-router-dom';

...
const Category = styled(NavLink)`
    ...

    &: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}
                    activeClassName="active"
                    exact={c.name === 'all'}
                    to={c.name === 'all' ? '/' : `${c.name}`}
                >
                    {c.text}
                </Category>
            ))}
        </CategoriesBlock>
    );
};

export default Categories;
profile
wanderlust

0개의 댓글