2-3. 포스트 목록 보여주기
이제 포스트 목록을 보여줄 차례입니다. 포스트 목록을 보여주는 것에 있어서는, 여러가지 변수가 존재합니다. 첫째로, 홈 페이지에서는 최근 작성된 포스트 10개를 보여줍니다. 그리고, 하단의 페이지네이션 부분에서 다음버튼을 누르면 다음 10개를 보여줍니다.
추가적으로, 유저가 태그를 클릭하면 특정 태그를 가진 포스트들만 분류해서 보여줄 수 있습니다.
포스트 리스트 API 함수 만들기
우리는 getPostList 라는 함수를 작성 할 건데요, 이 함수에서는 두가지 옵션을 설정 할 수 있습니다. 함수의 파라미터로는 tag 값과 page 값이 있는 객체를 전달받게 되며, 객체로 전달된 값을 URL 쿼리로 변환하여 API 주소 뒷부분에 붙여줍니다. (현재 구현된 API 에선 tag 옵션이 설정 되어있지 않습니다. 이 부분은 추후 구현하게됩니다.)
이 작업을 하기 위해선, query-string 라이브러리를 사용해야합니다. 이 라이브러리를 이전에 리액트 라우터를 배울 때 사용해봤었지요? 이 라이브러리로 문자열 형태의 URL 쿼리를 객체 형태로 변환 할 수 있고, 반대로 객체형태를 문자열 형태로 변환 할 수도 있답니다.
$ yarn add query-string@5
자, 이제 lib/api.js 파일에 새 함수를 작성하세요.
src/lib/api.js
import axios from 'axios';
import queryString from 'query-string';
(...)
export const getPostList = ({ tag, page }) => axios.get(`/api/posts/?${queryString.stringify({ tag, page })}`);
객체를 URL 쿼리 문자열로 변환 할 때에는, 위와 같이 queryString.stringify 함수를 사용합니다.
list 모듈 작성하기
getPostList API 를 호출 할 때 필요한 액션과, 상태 관리 로직들을 list.js 모듈에 작성하겠습니다. 이 모듈의 상태에는, 포스트 목록 데이터가 들어있는 posts 값과, 마지막 페이지를 알려주는 lastPage 값이 들어있습니다.
우리가 이전에 이 API 를 만들 때, Last-Page 라는 커스텀 HTTP 헤더를 넣어서 응답을 하도록 코드를 작성했는데요, axios 에서 헤더를 읽어올 때는, 소문자로 읽어오게 되니, action.payload.headers[‘last-page’] 값을 읽어오면 되겠습니다. 추가적으로 해당 값은 문자열 형태로 들어오니 이 값을 parseInt 를 통하여 숫자로 변환하세요.
src/store/modules/list.js
import { createAction, handleActions } from 'redux-actions';
import { Map, List, fromJS } from 'immutable';
import { pender } from 'redux-pender';
import * as api from 'lib/api';
// action types
const GET_POST_LIST = 'list/GET_POST_LIST';
// action creators
export const getPostList = createAction(GET_POST_LIST, api.getPostList, meta => meta);
// initial state
const initialState = Map({
posts: List(),
lastPage: null
});
// reducer
export default handleActions({
...pender({
type: GET_POST_LIST,
onSuccess: (state, action) => {
const { data: posts } = action.payload;
const lastPage = action.payload.headers['last-page'];
return state.set('posts', fromJS(posts))
.set('lastPage', lastPage);
}
})
}, initialState)
ListContainer 컴포넌트 만들기
포스트 리스트 관련 리덕스 상태와 액션들이 연동된 컨테이너 컴포넌트인 ListContainer 를 만들어봅시다. 이 컴포넌트 내부에는, PostList 와 Pagination 컴포넌트가 내장되어있습니다.
이 컴포넌트는 나중에 ListPage 에서로부터 tag 값과 page 값을 전달받게 됩니다. 이에 따라 포스트 리스트를 불러오는 API 를 호출하고, 데이터를 PostList 와 Pagination 에 넣어주고 page 값이 변하면 리스트를 새로 불러오도록 코드를 작성해보겠습니다.
src/containers/list/ListContainer.js
import React, { Component } from 'react';
import PostList from 'components/list/PostList';
import Pagination from 'components/list/Pagination';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux'
import * as listActions from 'store/modules/list';
class ListContainer extends Component {
getPostList = () => {
// 페이지와 태그 값을 부모로서부터 받아옵니다.
const { tag, page, ListActions } = this.props;
ListActions.getPostList({
page,
tag
});
}
componentDidMount() {
this.getPostList();
}
componentDidUpdate(prevProps, prevState) {
// 페이지/태그가 바뀔 때 리스트를 다시 불러옵니다.
if(prevProps.page !== this.props.page || prevProps.tag !== this.props.tag) {
this.getPostList();
// 스크롤을 맨 위로 올립니다.
document.documentElement.scrollTop = 0;
}
}
render() {
const { loading, posts, page, lastPage, tag } = this.props;
if(loading) return null; // 로딩중엔 아무것도 보여주지 않습니다.
return (
<div>
<PostList posts={posts}/>
<Pagination page={page} lastPage={lastPage} tag={tag}/>
</div>
);
}
}
export default connect(
(state) => ({
lastPage: state.list.get('lastPage'),
posts: state.list.get('posts'),
loading: state.pender.pending['list/GET_POST_LIST']
}),
(dispatch) => ({
ListActions: bindActionCreators(listActions, dispatch)
})
)(ListContainer);
이 컴포넌트를 ListPage 에서 불러와서, PostList 와 Pagination 컴포넌트를 대체시키세요. 그리고, page 와 tag 값을 params 에서 읽어와서 컨테이너 컴포넌트로 전달하세요.
page 가 존재하지 않을때는 기본값을 1로 설정하겠습니다.
src/pages/ListPage.js
import React from 'react';
import PageTemplate from 'components/common/PageTemplate';
import ListWrapper from 'components/list/ListWrapper';
import ListContainer from 'containers/list/ListContainer';
const ListPage = ({match}) => {
// page 의 기본값을 1로 설정합니다.
const { page = 1, tag } = match.params;
return (
<PageTemplate>
<ListWrapper>
<ListContainer
page={parseInt(page, 10)}
tag={tag}
/>
</ListWrapper>
</PageTemplate>
);
};
export default ListPage;
PostList 컴포넌트 데이터 렌더링
기존에 임시로 텍스트를 직접 넣어줘서 보여지던 부분을, props 로 받아온 데이터로 채워주겠습니다. PostList 내부에 있는 PostItem 컴포넌트에선 포스트의 내용 일부를 보여주게 되는데요, 이 부분에서는 마크다운 html 변환이 이뤄지기 않기 때문에 마크다운에서 사용되는 #, **, ```, > 등의 특수문자가 고스란히 보여지는 문제점이 있습니다. 이를 숨겨주기 위하여, 우리는 remove-markdown 이라는 라이브러리를 사용하겠습니다. 이 라이브러리는 마크다운에서 사용된 특수문자를 제거시켜주는데요, 지금과 같은 상황에 매우 유용한 라이브러리 입니다.
$ yarn add remove-markdown
자, 이제 PostList 컴포넌트를 다음과 같이 작성하세요.
src/components/list/PostList/PostList.js
import React from 'react';
import styles from './PostList.scss';
import classNames from 'classnames/bind';
import { Link } from 'react-router-dom';
import moment from 'moment';
import removeMd from 'remove-markdown';
const cx = classNames.bind(styles);
const PostItem = ({ title, body, publishedDate, tags, id}) => {
const tagList = tags.map(
tag => <Link key={tag} to={`/tag/${tag}`}>#{tag}</Link>
);
return (
<div className={cx('post-item')}>
<h2><Link to={`/post/${id}`}>{title}</Link></h2>
<div className={cx('date')}>{moment(publishedDate).format('ll')}</div>
<p>{removeMd(body)}</p>
<div className={cx('tags')}>
{tagList}
</div>
</div>
)
}
const PostList = ({posts}) => {
const postList = posts.map(
(post) => {
const { _id, title, body, publishedDate, tags } = post.toJS();
return (
<PostItem
title={title}
body={body}
publishedDate={publishedDate}
tags={tags}
key={_id}
id={_id}
/>
)
}
);
return (
<div className={cx('post-list')}>
{postList}
</div>
);
};
export default PostList;
코드를 다 작성하고, 홈 페이지를 확인해보세요. 지금까지 작성된 포스트들이 나타났나요?

Pagination 기능 구현하기
Pagination 이 실제로 작동하도록 기능을 구현해줍시다. 이 부분은, 딱히 함수를 실행하거나 그러진 않고, 전달받은 page, lastPage, tag 값을 사용해서 이전 / 다음 페이지의 링크로 이동시켜주겠습니다. 우리가 만들어둔 Button 컴포넌트에 Link 의 기능도 있기 때문에, to 값을 설정해주시면 됩니다.
그리고, 첫번째 페이지에선 이전 버튼을 비활성화 시키고, 마지막 페이지에선 다음 버튼을 비활성화 시키세요.
태그가 선택된 경우엔 /tag 라우트를 사용하고, 태그가 선택되지 않은 경우엔 /page 라우트가 사용됩니다. 주소생성 작업을 용이하게 진행하기 위해서 함수를 따로 작성해주겠습니다.
src/components/list/Pagination/Pagination.js
import React from 'react';
import styles from './Pagination.scss';
import classNames from 'classnames/bind';
import Button from 'components/common/Button';
const cx = classNames.bind(styles);
const Pagination = ({page, lastPage, tag}) => {
const createPagePath = (page) => {
return tag ? `/tag/${tag}/${page}` : `/page/${page}`;
}
return (
<div className={cx('pagination')}>
<Button disabled={page === 1} to={createPagePath(page -1)}>
이전 페이지
</Button>
<div className={cx('number')}>
페이지 {page}
</div>
<Button disabled={page===lastPage} to={createPagePath(page+1)}>
다음 페이지
</Button>
</div>
);
};
export default Pagination;
Pagination 컴포넌트의 기능이 완성되었습니다. 홈 페이지를 열어서 마지막 페이지까지 가보세요. 버튼이 비활성화 되나요?

API 에서 tag 분류하기
우리가 이전에 만들어놨던 백엔드 API 를 조금 수정하여 tag 값도 분류 할 수 있도록 설정하겠습니다.
백엔드 프로젝트의 posts.ctrl.js 파일을 다음과 같이 수정해보세요.
src/api/posts/posts.ctrl.js - list
exports.list = async (ctx) => {
// page 가 주어지지 않았다면 1로 간주
// query 는 문자열 형태로 받아오므로 숫자로 변환
const page = parseInt(ctx.query.page || 1, 10);
const { tag } = ctx.query;
const query = tag ? {
tags: tag // tags 배열에 tag 를 가진 포스트 찾기
} : {};
// 잘못된 페이지가 주어졌다면 에러
if (page < 1) {
ctx.status = 400;
return;
}
try {
const posts = await Post.find(query)
.sort({ _id: -1 })
.limit(10)
.skip((page - 1) * 10)
.lean()
.exec();
const postCount = await Post.count(query).exec();
const limitBodyLength = post => ({
...post,
body: post.body.length < 350 ? post.body : `${post.body.slice(0, 350)}...`
});
ctx.body = posts.map(limitBodyLength);
// 마지막 페이지 알려주기
// ctx.set 은 response header 를 설정해줍니다.
ctx.set('Last-Page', Math.ceil(postCount / 10));
} catch (e) {
ctx.throw(500, e);
}
};
URL 쿼리 중 tag 의 존재 유무에 따라 find 함수에 넣을 파라미터를 다르게 설정하였습니다. 여기서, 무조건 { tags: tag } 객체를 넣은것이 아니라, tag 가 비어있을 때 빈 객체 { } 를 전달 한 이유는, 만약에 tag 가 없다면 find 함수에 { tags: undefined } 가 전달되면서, 아무 데이터도 나타나지 않는 이슈가 발생하기 때문입니다.
이제 태그 분류도 완성 되었습니다. 태그가 있는 포스트에서 태그 링크를 눌러보세요.

잘 분류가 되었나요?