UI/UX 고민을 많이 할 수 밖에 없는 프론트엔드 개발자는 즐겨찾는 웹사이트에서 많은 영감과 공부할 거리를 찾을 수 있습니다. 제가 매일 몇 번씩 찾는 Daum 뉴스는 기술적으로 화려한 건 많이 없지만 컴포넌트를 하나 하나 보다보면 개발자가 했던 고민의 흔적을 느껴볼 수 있습니다.

이를테면 다음 이미지와 같이, Daum 뉴스 기사 페이지에서 오른쪽 pane에 노출되고 있는 컴포넌트가 두 개가 있습니다. 먼저 가장 중요한 광고가 있고, 그 다음은 ‘많이 본 뉴스’ 컴포넌트가 노출되고 있습니다.

Daum 뉴스의 기사 페이지

‘많이 본 뉴스’가 이 페이지에서 얼마나 중요한 컴포넌트인 지는 페이지에서 조금만 스크롤 해보면 더욱 명확해집니다. 유저가 기사를 조금씩 읽어내려가면서 일정 위치가 되면 ‘많이 본 뉴스’ 컴포넌트는 오른쪽 pane에 아예 고정되어 버립니다. 그리고 유저가 기사를 거의 다 읽어서 또 다른 중요 컴포넌트인 ‘댓글’ 목록이 나타나기 시작하면 ‘많이 본 뉴스’ 컴포넌트의 위치 고정이 해제됩니다.

‘많이 본 뉴스’ 컴포넌트의 위치가 고정된 모습

‘많이 본 뉴스’ 컴포넌트는 이 페이지에서 유저가 뉴스를 읽는다는 가장 큰 목적을 해치지 않으면서, 많지 않은 공간에 유저에게 주요 분야의 다양한 기사 목록과 전체 기사 목록을 보여주고 있습니다. 이런 의미에서 ‘많이 본 뉴스’ 컴포넌트의 목적은 유저에게 다른 흥미로운 기사를 계속 읽게끔 유도하고 사이트 내 체류시간을 늘리는 것입니다.

이런 목적을 달성하기 위해서 개발자는 아마 다음과 같은 순서로 컴포넌트를 구현했을 것입니다.

  1. 분야별 많이 본 뉴스 데이터를 서버에서 불러온다.
  2. 사용자가 분야를 선택할 수 있게 한다.
  3. 유저가 선택한 분야의 뉴스를 렌더한다.
  4. 분야별 뉴스를 모두 한 페이지에 모두 담을 수 없으므로 페이지네이션을 구현한다.
  5. 전체보기 메뉴를 노출해서 더 많은 뉴스를 볼 수 있게끔 유도한다.

자, 이제 우리도 Daum 뉴스 담당 프론트엔드 개발자가 되어 볼 수 있는 시간입니다. 우리가 연습해 볼 수 있는 좋은 주제는 바로 1번부터 4번까지입니다. 조금 더 상세하게, 우리가 구현해 볼 기능을 다음과 같이 요약해봅시다.

  • 리액트를 이용해서 뉴스 데이터를 불러오고,
  • 유저가 뉴스 메뉴를 hover하면 해당 분야의 뉴스를 불러와서 렌더하고,
  • 분야별 뉴스에서 페이지네이션을 이용해 더 많은 뉴스를 렌더한다.

단순한 개념이지만 많은 서비스에서 비슷한 방식의 컴포넌트들이 많이 사용되고 있고, 그와 동시에 사용자 경험에 지대한 영향을 끼칠 수 있는 중요 기능이기 때문에 꼭 연습해보는 게 좋을 것 같습니다.

완성된 프로젝트

완성된 ‘많이 본 뉴스’ 컴포넌트

프로젝트 시작

$ create-react-app react-popular-articles
$ cd react-popular-articles

필요한 모듈 설치

Service Worker와 같이 필요하지 않은 모든 내용을 삭제해준 후, 필요한 모듈을 설치해보도록 하겠습니다. 우리가 만들 컴포넌트는 데이터를 가져와야 하므로 Axios를 사용해야겠습니다. 또한 뉴스 기사로 보여줄 fake 데이터 API가 필요한데, 이런 목적에 적합한 KoreanJSON을 사용하겠습니다.

먼저 Axios부터 설치해봅시다.

$ yarn add axios

스타일은 간편하게 Bootstrap과 FontAwesome을 이용해보도록 하겠습니다. 다음과 같이 index.html의 head에 추가하겠습니다.

<head>

  <link
    rel="stylesheet"
    href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css"
    integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS"
    crossorigin="anonymous"
  />
  <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.2/css/all.css" integrity="sha384-fnmOCqbTlWIlj8LyTjo7mOUStjsKC4pOpQbqyi7RrhN7udi9RwhKkMHpvLbHG9Sr" crossorigin="anonymous">

</head>

기본적인 세팅은 끝났습니다. 다시 한번 우리가 구현할 내용을 살펴봅시다.

컴포넌트 기능 정의

  1. 리액트를 이용해서 뉴스 데이터를 불러오고,
  2. 뉴스 메뉴를 hover하면 해당 분야의 뉴스를 불러와서 렌더하고,
  3. 분야별 뉴스에서 페이지네이션을 이용해 더 많은 뉴스를 렌더한다.

차례대로 진행해볼까요? 3개의 주요 분야(정치, 경제, 사회)가 있다고 가정하고 먼저 뉴스를 불러와야 합니다.

App.js파일은 라이프사이클과 상태 관리가 모두 필요하므로 stateful 컴포넌트로 시작해봅시다.

App.js

import React, { Component } from 'react';
import './App.css';

class App extends Component {
  render() {
    return <div className="App">Hello World</div>;
  }
}

export default App;

최소한의 스타일은 필요하므로 다음과 같이 작성해줍니다.

App.css

.App {
  width: 500px;
  margin: 5px auto 5px;
}

KoreanJSON을 이용해서 fake data를 불러오자

KoreanJSON에 접속해서 뉴스로 쓸 만한 데이터를 가져와야 합니다. /posts 리소스가 좋아보입니다. 총 200개의 오브젝트를 제공하고 있고 UserId당 20개의 오브젝트가 있습니다. 아래와 같이 id, title, content, UserId를 기본적으로 제공하기 때문에 바로 사용해도 무방하지만 UserId를 뉴스 분야 id라고 가정하고 진행하겠습니다.

https://koreanjson.com/posts 응답 예시

[
  {
  id: 1,
  title: "대한민국은 국제평화의 유지에 노력하고 침략적 전쟁을 부인한다.",
  content: "주거에 대한 압수나 수색을 할 때에는 검사의 신청에 의하여 법관이 발부한 영장을 제시하여야 한다. 대한민국은 국제평화의 유지에 노력하고 침략적 전쟁을 부인한다. 국가유공자·상이군경 및 전몰군경의 유가족은 법률이 정하는 바에 의하여 우선적으로 근로의 기회를 부여받는다. 여자의 근로는 특별한 보호를 받으며, 고용·임금 및 근로조건에 있어서 부당한 차별을 받지 아니한다. 모든 국민은 주거의 자유를 침해받지 아니한다.",
  createdAt: "2019-02-24T16:17:47.000Z",
  updatedAt: "2019-02-24T16:17:47.000Z",
  UserId: 1
  },
  {
    ...
  },
  ...
]

컴포넌트 구조

우리가 구현할 기능을 정의했으니 기능을 수행할 각 컴포넌트의 구조를 처음부터 제대로 설계하는 것이 중요합니다. 그래야 나중에 새로운 기능을 추가할 때, 기존 기능을 수정할 때, 그리고 퍼포먼스 모든 측면에서 유리합니다. 다음과 같이 App 컴포넌트 하위에는 Tabs, Articles, Button 총 3개의 컴포넌트가 있습니다.

‘많이 본 뉴스’ 컴포넌트의 내부 컴포넌트

  • Tabs 컴포넌트: 버튼 3개로 구성되어 있고 각 버튼을 호버할 때마다 해당 기사 목록의 1페이지가 뜬다.
  • Articles 컴포넌트: 유저가 현재 보고 있는 기사 목록
  • Button 컴포넌트: 이전/다음 페이지 버튼
render() {
  return (
    <div className="App">
      <Tabs />
      <Articles />
      <Button />
    </div>
  )
}

상태 정의와 initial data fetching

App.js 컴포넌트에서는 어떤 상태가 필요한지 생각해봅시다. 먼저 전체 뉴스를 담을 배열 allArticles가 필요하겠네요. componentDidMount 함수에서 데이터를 불러오면 해당 내용을 allArticles에 넣겠습니다. 그리고 유저가 선택한 뉴스 카테고리를 담을 currentCategory와 유저가 현재 보고있는 currentArticles, 그리고 유저가 현재 보고있는 페이지 currentPage 상태가 각기 필요합니다. 이와 더불어 한 페이지당 몇 개의 기사를 보여줄 지 정해야 합니다. 이번 연습에서는 페이지당 5개씩 보여준다고 하고, this.articlesPerPage = 5;를 App.js의 constructor 함수에 추가해줍시다.

articlesPerPage는 상태로써 관리해야 하는 것 아닌가라고 생각하실 수도 있지만, 다른 데이터나 유저의 interaction에 영향받을 일이 없는 요소는 상수와 같이 쓰이는 게 맞습니다. 따라서 App 컴포넌트 인스턴스의 프로퍼티로 추가해주는 것입니다. 이제 App.js의 constructor 함수는 아래와 같이 생겼습니다.

App.js

constructor(props) {
  super(props);
  this.state = {
    allArticles: [],
    currentCategory: '정치',
    currentArticles: [],
    currentPage: 1,
  };
  this.articlesPerPage = 5;
}

componentDidMount 함수에서는 KoreanJSON API를 이용해 우리에게 필요한 데이터를 불러와야 합니다. 그 이후 불러온 모든 기사 데이터와 유저에게 기본적으로 보여줘야 할 ‘정치’ 카테고리의 기사를 setState 함수를 이용해 각 상태에 추가해줍니다.

componentDidMount = async () => {
  const articlesData = await axios.get('https://koreanjson.com/posts');
  const allArticles = articlesData.data;

  this.setState({ 
    allArticles,
    currentArticles: allArticles
      .filter(article => article.UserId === 1) // UserId 1이 정치 카테고리라고 가정합니다
      .slice(0, this.articlesPerPage)
  })
};

그럼 이제 Tabs 컴포넌트를 만들어봅시다. 다양한 방법이 있을 것 같은데, 일단 constructor 함수에 categories라는 배열을 추가해줍시다.

constructor(props) {
  super(props);
  this.state = {
    allArticles: [],
    currentCategory: '정치',
    currentArticles: [],
    currentPage: 1,
  };
  this.articlesPerPage = 5;
  this.categories = [
    {
      id: 1,
      name: '정치'
    },
    {
      id: 2,
      name: '경제'
    },
    {
      id: 3,
      name: '사회'
    }
  ];
}

굳이 id 프로퍼티가 필요한 이유는 배열을 순회하고 각 요소를 렌더하기 위해서는 .map() 함수를 써야하고, 각 요소마다 unique identifier로써 key 값에 필요하기 때문입니다. (Eslint는 순회되고 있는 요소의 인덱스값을 쓰지 말도록 권장하고 있습니다. 참조 링크) 또한 유저가 호버한 탭을 구분하기 위한 unique identifier로도 쓰일 수 있습니다.

그럼 이제 Tabs 컴포넌트를 만들어봅시다.

Tabs 컴포넌트

const Tabs = props => {
  const { categories, currentCategory } = props;

  return (
    <>
      <ul className="nav nav-pills nav-justified">
        {categories.map(category => {
          let btnClassName;

          category.name === currentCategory
            ? (btnClassName = 'btn-primary')
            : (btnClassName = 'btn-outline-primary');

          return (
            <li className="nav-item active p-1" key={category.id}>
              <button
                className={`btn ${btnClassName} btn-block`}
              >
                {category.name}
              </button>
            </li>
          );
        })}
      </ul>
    </>
  );
};

class App extends Components {
  // ...
  render() {
    const { currentPosts } = this.state;

    return (
      <div className="App">
        <Tabs
          categories={this.categories}
          currentCategory={currentCategory}
        />
      </div>
    );
  }
}

좋습니다. 하지만 아직 한가지 문제가 남았습니다. 각 카테고리 버튼을 호버할 때마다 기사 목록을 바꿔주고 싶습니다. App.js에서 changeCurrentCategory 함수를 작성해봅시다. 이 함수를 실행하면 this.categories 배열에서 유저가 선택한 카테고리를 찾아서 currentCategorycurrentArticles 상태를 변경합니다. currentPage 상태를 1로 바꿔주는 이유는 카테고리를 선택할 때마다 1페이지의 기사를 보여주고 싶기 때문입니다.

changeCurrentCategory = userSelectedCategory => {
  const { allArticles } = this.state;
  const category = this.categories.find(category => category.name === userSelectedCategory);

  this.setState({
    currentCategory: category.name,
    currentArticles: allArticles
      .filter(post => post.UserId === category.id)
      .slice(0, this.articlesPerPage),
    currentPage: 1
  });
};

이제 render 함수의 Tabs 컴포넌트에 changeCurrentCategory 함수를 props로 전달합니다.

render() {
  const { currentArticles, currentCategory } = this.state;

  return (
    <div className="App">
      <Tabs
        categories={this.categories}
        currentCategory={currentCategory}
        changeCurrentCategory={this.changeCurrentCategory}
      />
    </div>
  );
}

이전에 작성한 Tabs 함수에서는 이제 props로 전달받은 changeCurrentCategory를 사용해서 카테고리를 변경해야 합니다. 유저가 버튼을 hover 할 때 changeCurrentCategory 함수를 실행해야 하므로 다음과 같이 버튼에 onMouseEnter 이벤트 핸들러를 작성합시다.

 <button
    className={`btn ${btnClassName} btn-block`}
    onMouseEnter={() => changeCurrentCategory(category.name)}
  >
  {category.name}
</button>

Articles 컴포넌트

Articles 컴포넌트는 현재 유저가 보고 있는 기사 목록을 담은 currentArticles 상태 배열을 렌더하는 컴포넌트입니다. 특별할 게 없으므로 아래와 같이 작성해서 Tabs 컴포넌트 아래에 넣어봅시다.

const Articles = props => {
  const { articles } = props;

  return (
    <div className="list-group">
      {articles.map(article => (
        <div
          className="list-group-item list-group-item-action"
          key={article.id}
        >
          <small>article #{article.id}</small>
          <div className="mb-1">{article.title}</div>
        </div>
      ))}
    </div>
  );
};

App.js의 렌더 함수에 Articles 컴포넌트를 추가하는 것도 잊지 마세요.

render() {
  const { currentArticles, currentCategory } = this.state;

  return (
    <div className="App">
      <Tabs
        categories={this.categories}
        currentCategory={currentCategory}
        changeCurrentCategory={this.changeCurrentCategory}
      />
      <Articles articles={currentArticles} />
    </div>
  );
}

이제 유저가 마우스 커서를 탭 아이콘에 호버할 때마다 해당 카테고리에 속하는 기사만 필터되어 뜰 것입니다. 잘 안 된다면 깃헙 리포의 코드를 참조해보세요.

페이지네이션, 어떻게 구현할까

이제 우리는 페이지네이션을 구현하는 방법을 고민해봐야합니다. allArticlescurrentArticles에는 각각 모든 기사와 현재 유저가 보고 있는 5개의 기사 데이터가 담겨있습니다. 이를 이용해서 유저가 ‘다음’ 버튼을 누르면 다음 페이지의 5개 기사가 렌더되어야 하고, ‘이전’ 버튼을 누르면 이전 페이지의 5개 기사가 렌더되어야 합니다.

또한 컴포넌트를 만들기에 앞서 사용자 경험에 대해서도 생각해 볼 필요가 있습니다. ‘다음’ 버튼을 계속 눌러서 마지막 페이지를 넘어서면 currentArticles는 빈 배열이 됩니다. 그럼 Articles 컴포넌트에 아무 것도 뜨지 않을텐데, 괜찮은 걸까요? 사용자에게 빈 데이터를 보여줄 순 없기 때문에 다시 첫번째 페이지의 기사를 렌더해보는 건 어떨까요. 돌려도 돌려도 끝나지 않는 회전목마 같이 말이죠.

마찬가지로 방법으로, ‘이전’ 버튼을 계속 누르고 제일 첫 페이지를 넘어서면 (0페이지), 제일 마지막 페이지의 기사를 렌더해야 합니다.

좋습니다. 그럼 먼저 할 일은 Articles 컴포넌트 아래에 Button 컴포넌트를 만들어서 렌더하는 일입니다. 아래와 같이 버튼을 만들어보세요. 유저가 ‘이전’ 버튼을 누르면 loadMoreArticles 함수에 인자로 ‘prev’를 넣어서 실행시키고, ‘다음’ 버튼을 누르면 인자에 ‘next’를 넣어서 실행시켜봅시다.

const Button = ({ loadMoreArticles }) => (
  <div className="mt-3">
    <button
      className="btn btn-outline-dark btn-sm mr-2"
      onClick={() => loadMoreArticles('prev')}
    >
      <i className="fas fa-angle-left" />
    </button>
    <button
      className="btn btn-outline-dark btn-sm"
      onClick={() => loadMoreArticles('next')}
    >
      <i className="fas fa-angle-right" />
    </button>
  </div>
);
render() {
  const { currentArticles, currentCategory } = this.state;

  return (
    <div className="App">
      <Tabs
        categories={this.categories}
        currentCategory={currentCategory}
        changeCurrentCategory={this.changeCurrentCategory}
      />
      <Articles articles={currentArticles} />
      <Button loadMoreArticles={this.loadMoreArticles} />
    </div>
  );
}

loadMoreArticles 함수는 어떤 용도일까요? 유저가 누른 버튼의 속성(prev 또는 next)을 인자로 받아서 ‘prev’인 경우 이전 페이지의 기사 목록을 렌더하고, ‘next’인 경우 다음 페이지의 기사 목록을 렌더하면 됩니다.

본격적으로 loadMoreArticles 함수를 작성해봅시다.

loadMoreArticles = direction => {
  // 상태에 저장되어 있는 allArticles와 currentCategory를 이용해서
  // 유저에게 보여 줄 currentCategoryArticles를 가져온다.

  const { allArticles, currentCategory } = this.state;
  const currentCategoryId = this.categories.find(
    category => category.name === currentCategory
  ).id;
  const currentCategoryArticles = allArticles.filter(
    article => article.UserId === currentCategoryId
  );
}

페이지네이션 구현 원리를 살펴봅시다. 다음과 같이 9개의 요소를 가진 배열이 있다고 가정합시다.

const allArticles = ['a', 'b', 'd', 'e', 'f', 'g', 'h'];

한 페이지에 5개의 요소를 보여주려면 자바스크립트 배열의 내장 메서드 중 하나인 slice를 사용하면 간편할 것 같습니다. slice 메서드는 원본 배열을 수정하지 않고 shallow copy를 리턴합니다. 또한 두번째 인자인 종료 인덱스에 위치한 요소는 반환되지 않음을 참고하세요.

slice 메서드로 첫번째 페이지를 표현하려면

allArticles.slice(0, 5);
// ['a', 'b', 'c', 'd', 'e']

위와 같이 쓸 수 있겠네요. 그럼 두번째 페이지는 어떻게 할까요? 시작 인덱스와 종료 인덱스를 그만큼 이동시켜주면 됩니다.

allArticles.slice(5, 10);
// ['g', 'h']

위의 예시에서 한가지 중요한 점은 한 페이지에 몇 개의 요소를 보여줄지 미리 정해져 있어야 한다는 것입니다. 우린 이미 App.jsconstructor 함수에 this.articlesPerPage = 5;를 추가했기 때문에 이 프로퍼티를 바로 사용하면 되겠습니다.

이런 원리로 page 인자를 받으면 그 페이지의 articles만 보여주는 pagedArticles 함수가 필요합니다. 다음과 같이 만들 수 있습니다.

const pagedArticles = page => {
  const lastIndex = page * this.articlesPerPage;
  const firstIndex = lastIndex - this.articlesPerPage;
  return currentCategoryArticles.slice(firstIndex, lastIndex);
};

추가적으로 페이지네이션을 구현하기 위해선 전체 배열에서 마지막 페이지의 값이 무엇일지 알아야 합니다. 그래야 마지막 페이지를 넘어설때 1페이지를 다시 렌더하는 등, 적절히 처리할 수 있습니다. 예를 들어 위의 allArticles 배열을 기준으로, ‘a’부터 ‘h’까지 모두 7개의 요소가 있습니다. 앞서 articlesPerPage를 5로 설정하였으므로 마지막 페이지는 2페이지가 됩니다. 이를 getLastPage 함수로 만들면 다음과 같이 할 수 있겠네요.

const getLastPage = () =>
  Math.floor(currentCategoryArticles.length / this.articlesPerPage);

이제 거의 다 되었습니다. 유저가 ‘이전’과 ‘다음’ 버튼을 클릭하면 실행될 loadMoreArticles 함수를 작성해봅시다. 위에서 작성한 두개의 함수가 페이지네이션 구현에 필수적으로 필요하므로, 간편하게 클로저로 구현해보겠습니다.

loadMoreArticles = direction => {
  const { allArticles, currentCategory } = this.state;
  const currentCategoryId = this.categories.find(
    category => category.name === currentCategory
  ).id;
  const currentCategoryArticles = allArticles.filter(
    article => article.UserId === currentCategoryId
  );

  const pagedArticles = page => {
    const lastIndex = page * this.articlesPerPage;
    const firstIndex = lastIndex - this.articlesPerPage;
    return currentCategoryArticles.slice(firstIndex, lastIndex);
  };

  const getLastPage = () =>
    Math.floor(currentCategoryArticles.length / this.articlesPerPage);

  // eslint-disable-next-line default-case
  switch (direction) {
    case 'prev':
      this.setState(prevState => {
        if (prevState.currentPage === 1) {
          return {
            currentPage: getLastPage(),
            currentArticles: pagedArticles(getLastPage())
          };
        } else {
          return {
            currentPage: prevState.currentPage - 1,
            currentArticles: pagedArticles(prevState.currentPage - 1)
          };
        }
      });
      break;

    case 'next':
      this.setState(prevState => {
        if (prevState.currentPage === getLastPage()) {
          return {
            currentPage: 1,
            currentArticles: pagedArticles(1)
          };
        } else {
          return {
            currentPage: prevState.currentPage + 1,
            currentArticles: pagedArticles(prevState.currentPage + 1)
          };
        }
      });
      break;
  }
};

유저로부터 받은 direction에 따라 바로 setState 함수를 실행시키는 게 아니라 prevState 오브젝트의 state를 매번 확인합니다. 현재 페이지가 1페이지인데 유저가 ‘이전’ 버튼을 클릭하면 getLastPage 함수를 실행해서 currentPage값을 가장 마지막 페이지로 지정합니다. 1페이지가 아닌 경우는 단순히 prevstate의 currentPage에서 1씩 빼면 되겠네요.

유저가 ‘다음’ 버튼을 클릭했고 prevState.currentPage가 아직 마지막 페이지가 아니라면 현재 페이지에서 1씩 더하면 됩니다. 다만 마지막 페이지에서 ‘다음’ 버튼을 클릭하는 경우라면 currentPage를 1페이지로 할당하면 되겠습니다.

마치며

리액트를 이용하면 굳이 라이브러리를 사용하지 않고도 나름 간단하게 페이지네이션을 구현할 수 있습니다. 이번 예제 프로젝트와 같이 유저가 특정 카테고리를 선택하고 거기다가 각 카테고리별로 여러 페이지로 나뉘어져 있는 상황에서도 페이지네이션의 기준점만 잘 생각하면 어렵지 않게 구현할 수 있었습니다. 물론 리팩토링할 만한 여지는 있어보이는 예제 코드이지만 리액트로 페이지네이션을 구현하고자 하신다면 참고하시고 연습해보셔도 좋겠습니다.