지금까지는 회원 인증 시스템과 글쓰기 기능의 구현을 완료했습니다. 이번에는 등록한 포스트를 조회할 수 있는 기능을 구현하겠습니다. 포스트를 읽는 것에는 두 가지가 있는데 첫 번째는 포스트 하나를 읽는 포스트 읽기 기능이고, 두 번째는 여러 포스트를 조회하는 포스트 목록 기능입니다.
PostView UI
src/components/post/PostView.js
import React from 'react'; import styled from 'styled-components'; import palette from '../../lib/styles/palette'; import Responsive from '../common/Responsive'; const PostViewBlock = styled(Responsive)` margin-top: 4rem; `; const PostHead = styled.div` border-bottom: 1px solid ${palette.gray[2]}; padding-bottom: 3rem; margin-bottom: 3rem; h1 { font-size: 3rem; line-height: 1.5; margin: 0; } `; const SubInfo = styled.div` margin-top: 1rem; color: ${palette.gray[6]}; /* span 사이에 가운뎃점 문자 보여 주기 */ span + span:before { color: ${palette.gray[5]}; padding-left: 0.25rem; padding-right: 0.25rem; content: '\\B7'; } `; const Tags = styled.div` margin-top: 0.5rem; .tag { display: inline-block; color: ${palette.cyan[7]}; text-decoration: none; margin-right: 0.5rem; &:hover { color: ${palette.cyan[6]}; } } `; const PostContent = styled.div` font-size: 1.3125rem; color: ${palette.gray[8]}; `; const PostView = () => { return ( <PostViewBlock> <PostHead> <h1>제목</h1> <SubInfo> <span>tester</span> <span>{new Date().toLocaleDateString()}</span> </SubInfo> <Tags> <div className="tag">#태그1</div> <div className="tag">#태그2</div> <div className="tag">#태그3</div> </Tags> </PostHead> <PostContent dangerouslySetInnerHTML={{ __html: `<p>HTML <b>내용</b>입니다.</p>` }} /> </PostViewBlock> ); }; export default PostView;
src/pages/postPage.js
import React from 'react'; import HeaderContainer from '../containers/common/HeaderContainer'; import PostView from '../components/post/PostView'; const PostPage = () => { return ( <> <HeaderContainer /> <PostView /> </> ); }; export default PostPage;

코드를 보면 PostContent에 dangerouslySetInnerHTML이라는 값을 설정해 주었습니다. 리액트에서는 <div>{html}</div>와 같이 HTML을 그대로 렌더링하는 형태로 JSX를 작성하면 HTML태그가 적용되지 않고 일반 텍스트 형태로 나타나 버립니다. 따라서 HTML을 적용하고 싶다면 dangerouslySetInnerHTML이라는 props를 설정해 주어야 합니다.
API연동하기
src/lib/api/posts.js
import client from './client'; export const writePost = ({ title, body, tags }) => client.post('/api/posts', { title, body, tags }); export const readPost = (id) => client.get(`/api/posts/${id}`);
src/modules/post.js
import { createAction, handleActions } from 'redux-actions'; import createRequestSaga, { createRequestActionTypes, } from '../lib/api/createRequestSaga'; import * as postAPI from '../lib/api/posts'; import { takeLatest } from 'redux-saga/effects'; const [READ_POST, READ_POST_SUCCESS, READ_POST_FAILURE] = createRequestActionTypes('post/READ_POST'); // 포스트 페이지에서 벗어날 때 데이터 비우기 const UNLOAD_POST = 'post/UNLOAD_POST'; export const readPost = createAction(READ_POST, (id) => id); export const unloadPost = createAction(UNLOAD_POST); const readPostSaga = createRequestSaga(READ_POST, postAPI.readPost); export function* postSaga() { yield takeLatest(READ_POST, readPostSaga); } const initialState = { post: null, error: null, }; const post = handleActions( { [READ_POST_SUCCESS]: (state, { payload: post }) => ({ ...state, post, }), [READ_POST_FAILURE]: (state, { payload: error }) => ({ ...state, error, }), [UNLOAD_POST]: () => initialState, }, initialState, ); export default post;
src/modules/index.js
import { combineReducers } from 'redux'; import { all } from 'redux-saga/effects'; import auth, { authSaga } from './auth'; import loading from './loading'; import user, { userSaga } from './user'; import write, { writeSaga } from './write'; import post, { postSaga } from './post'; const rootReducer = combineReducers({ auth, loading, user, write, post, }); export function* rootSaga() { yield all([authSaga(), userSaga(), writeSaga(), postSaga()]); } export default rootReducer;
src/containers/post/PostViewContainer.js
/* eslint-disable no-unused-vars */ import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { readPost, unloadPost } from '../../modules/post'; import PostView from '../../components/post/PostView'; import { useParams } from 'react-router-dom'; const PostViewContainer = () => { // 처음 마운트 될 때 포스트 읽기 API요청 const { postId } = useParams(); const dispatch = useDispatch(); const { post, error, loading } = useSelector(({ post, loading }) => ({ post: post.post, error: post.error, loading: loading['post/READ_POST'], })); useEffect(() => { dispatch(readPost(postId)); // 언마운트될 떄 리덕스에서 포스트 데이터 없애기 return () => { dispatch(unloadPost()); }; }, [dispatch, postId]); return <PostView post={post} loading={loading} error={error} />; }; export default PostViewContainer;
src/pages/PostPage.js
import React from 'react'; import HeaderContainer from '../containers/common/HeaderContainer'; import PostViewContainer from '../containers/post/PostViewContainer'; const PostPage = () => { return ( <> <HeaderContainer /> <PostViewContainer /> </> ); }; export default PostPage;
src/components/post/PostView.js
import React from 'react'; import styled from 'styled-components'; import palette from '../../lib/styles/palette'; import Responsive from '../common/Responsive'; const PostViewBlock = styled(Responsive)` margin-top: 4rem; `; const PostHead = styled.div` border-bottom: 1px solid ${palette.gray[2]}; padding-bottom: 3rem; margin-bottom: 3rem; h1 { font-size: 3rem; line-height: 1.5; margin: 0; } `; const SubInfo = styled.div` margin-top: 1rem; color: ${palette.gray[6]}; /* span 사이에 가운뎃점 문자 보여 주기 */ span + span:before { color: ${palette.gray[5]}; padding-left: 0.25rem; padding-right: 0.25rem; content: '\\B7'; } `; const Tags = styled.div` margin-top: 0.5rem; .tag { display: inline-block; color: ${palette.cyan[7]}; text-decoration: none; margin-right: 0.5rem; &:hover { color: ${palette.cyan[6]}; } } `; const PostContent = styled.div` font-size: 1.3125rem; color: ${palette.gray[8]}; `; const PostView = ({ post, loading, error }) => { // 에러 발생 시 if (error) { if (error.response && error.response.status === 409) { return <PostViewBlock>존재하지 않는 포스트입니다.</PostViewBlock>; } return <PostViewBlock>오류 발생!</PostViewBlock>; } // 로딩 중이거나 아직 포스트 데이터가 없을 때 if (loading || !post) { return null; } const { title, body, user, publishedDate, tags } = post; return ( <PostViewBlock> <PostHead> <h1>{title}</h1> <SubInfo> <span>{user.username}</span> <span>{new Date(publishedDate).toLocaleDateString()}</span> </SubInfo> <Tags> {tags.map((tag) => ( <div className="tag" key={tag}> #{tag} </div> ))} </Tags> </PostHead> <PostContent dangerouslySetInnerHTML={{ __html: body }} /> </PostViewBlock> ); }; export default PostView;
이제 새 post를 작성하고 결과를 확인해 보겠습니다.


PostList UI준비하기
PostList라는 컴포넌트를 만듭니다. 이 컴포넌트에서는 포스트들을 배열로 받아서 렌더링해 줍니다. 사용자가 로그인 중이라면 페이지 상단 우측에 새 글 작성하기 버튼을 보여줍니다.
src/components/post/PostView.js
import React from 'react'; import styled from 'styled-components'; import Responsive from '../common/Responsive'; import Button from '../common/Button'; import palette from '../../lib/styles/palette'; import SubInfo from '../common/SubInfo'; import Tags from '../common/Tags'; const PostListBlock = styled(Responsive)` margin-top: 3rem; `; const WritePostButtonWrapper = styled.div` display: flex; justify-content: flex-end; margin-bottom: 3rem; `; const PostItemBlock = styled.div` padding-top: 3rem; padding-bottom: 3rem; /* 맨 위 포스트는 padding-top없음 */ &:first-child { padding-top: 0; } & + & { border-top: 1px solid ${palette.gray[2]}; } h2 { font-size: 2rem; margin-bottom: 0; margin-top: 0; &:hover { color: ${palette.gray[6]}; } } p { margin-top: 2rem; } `; const PostItem = () => { return ( <PostItemBlock> <h2>제목</h2> <SubInfo username="username" publishedDate={new Date()} /> <Tags tags={['태그1', '태그2', '태그3']} /> <p>포스트 내용의 일부분...</p> </PostItemBlock> ); }; const PostList = () => { return ( <PostListBlock> <WritePostButtonWrapper> <Button cyan to="/write"> 새 글 작성하기 </Button> </WritePostButtonWrapper> <div> <PostItem /> <PostItem /> <PostItem /> </div> </PostListBlock> ); }; export default PostList;
src/components/common/SubInfo.js
import React from 'react'; import styled, { css } from 'styled-components'; import { Link } from 'react-router-dom'; import palette from '../../lib/styles/palette'; const SubInfoBlock = styled.div` ${(props) => props.hasMarginTop && css` margin-top: 1rem; `} color: ${palette.gray[6]}; /* span 사이에 가운뎃점 문자 보여주기 */ span + span:before { color: ${palette.gray[4]}; padding-left: 0.25rem; padding-right: 0.25rem; content: '\\B7'; } `; const SubInfo = ({ username, publishedDate, hasMarginTop }) => { return ( <SubInfoBlock hasMarginTop={hasMarginTop}> <span> <b> <Link to={`/@${username}`}>{username}</Link> </b> </span> <span>{new Date(publishedDate).toLocaleDateString()}</span> </SubInfoBlock> ); }; export default SubInfo;
src/components/common/Tags.js
import React from 'react'; import styled from 'styled-components'; import palette from '../../lib/styles/palette'; import { Link } from 'react-router-dom'; const TagsBlock = styled.div` margin-top: 0.5rem; .tag { display: inline-block; color: ${palette.cyan[7]}; text-decoration: none; margin-right: 0.5rem; &:hover { color: ${palette.cyan[6]}; } } `; const Tags = ({ tags }) => { return ( <TagsBlock> {tags.map((tag) => ( <Link className="tag" to={`/?tag=${tag}`} key={tag}> #{tag} </Link> ))} </TagsBlock> ); }; export default Tags;
이 PostView에서 사용하는 컴포넌트가 겹치므로 이들을 따로 빼네서 Tags로 SubInfo로 빼내서 재사용하였습니다.

또한 PostView도 재사용 하도록 하겠습니다.
src/components/post/PostView.js
import React from 'react'; import styled from 'styled-components'; import palette from '../../lib/styles/palette'; import Responsive from '../common/Responsive'; import SubInfo from '../common/SubInfo'; import Tags from '../common/Tags'; const PostViewBlock = styled(Responsive)` margin-top: 4rem; `; const PostHead = styled.div` border-bottom: 1px solid ${palette.gray[2]}; padding-bottom: 3rem; margin-bottom: 3rem; h1 { font-size: 3rem; line-height: 1.5; margin: 0; } `; const PostContent = styled.div` font-size: 1.3125rem; color: ${palette.gray[8]}; `; const PostView = ({ post, loading, error }) => { // 에러 발생 시 if (error) { if (error.response && error.response.status === 409) { return <PostViewBlock>존재하지 않는 포스트입니다.</PostViewBlock>; } return <PostViewBlock>오류 발생!</PostViewBlock>; } // 로딩 중이거나 아직 포스트 데이터가 없을 때 if (loading || !post) { return null; } const { title, body, user, publishedDate, tags } = post; return ( <PostViewBlock> <PostHead> <h1>{title}</h1> <SubInfo username={user.username} publishedDate={publishedDate} hasMarginTop /> <Tags tags={tags} /> </PostHead> <PostContent dangerouslySetInnerHTML={{ __html: body }} /> </PostViewBlock> ); }; export default PostView;
이렇게 여러 곳에서 재사용할 수 있는 컴포넌트는 이렇게 따로 분리하여 사용하면, 코드의 양도 줄일 수 있을 뿐만 아니라 유지 보수성도 높일 수 있어서 좋습니다.
포스트 목록 조회 API 연동하기
postList컴포넌트에서 실제 데이터를 보여 줄 수 있도록 APi를 연동해야 합니다. 우리가 사용할 list API는 username, page, tag값을 쿼리 값으로 넣어서 사용합니다.
import client from './client'; import qs from 'qs'; export const writePost = ({ title, body, tags }) => client.post('/api/posts', { title, body, tags }); export const readPost = (id) => client.get(`/api/posts/${id}`); export const listPosts = ({ page, username, tags }) => { const queryString = qs.stringify({ page, username, tags, }); return client.get(`/api/posts?${queryString}`); };
Koa Server
src/api/posts/post.ctrl.js - write
/** * 포스트 목록 조회 * GET /api/posts?username=&tag=&page= */ export const list = async (ctx) => { const removeHtmlAndShorten = (body) => { const filtered = sanitizeHtml(body, sanitizeOption); return filtered.length < 200 ? filtered : `${filtered.slice(0, 200)}...`; }; const page = parseInt(ctx.query.page || '1', 10); if (page < 1) { ctx.status = 400; return; } const { tag, username } = ctx.query; // tag, username값이 유효하면 객체 안에 넣고, 그렇지 않으면 넣지 않음 const query = { ...(username ? { 'user.username': username } : {}), ...(tag ? { tags: tag } : {}), }; console.log('query >> ', query); try { const posts = await Post.find(query) .sort({ _id: -1 }) .limit(10) .skip((page - 1) * 10) .lean() .exec(); console.log('posts >> ', posts); const postCount = await Post.countDocuments(query).exec(); ctx.set('Last-page', Math.ceil(postCount / 10)); ctx.body = posts.map((post) => ({ ...post, body: removeHtmlAndShorten(post.body), })); } catch (e) { ctx.throw(500, e); } };
API의 형식은 그냥 query로 tag와 username을 넣으면 이에 해당하는 post를 찾아주는 API입니다.
이제 API를 처리해 줄 리덕스를 만들겠습니다.
src/modules/posts.js
import { createAction, handleActions } from 'redux-actions'; import createRequestSaga, { createRequestActionTypes, } from '../lib/api/createRequestSaga'; import * as postAPI from '../lib/api/posts'; import { takeLatest } from 'redux-saga/effects'; const [LIST_POSTS, LIST_POSTS_SUCCESS, LIST_POSTS_FAILURE] = createRequestActionTypes('posts/LIST_POSTS'); export const listPosts = createAction( LIST_POSTS, ({ tag, username, page }) => ({ tag, username, page, }), ); const listPostsSaga = createRequestSaga(LIST_POSTS, postAPI.listPosts); export function* postsSaga() { yield takeLatest(LIST_POSTS, listPostsSaga); } const initialState = { posts: null, error: null, }; const posts = handleActions( { [LIST_POSTS_SUCCESS]: (state, { payload: posts }) => ({ ...state, posts, }), [LIST_POSTS_FAILURE]: (state, { payload: error }) => ({ ...state, error, }), }, initialState, ); export default posts;
src/modules/index.js
import { combineReducers } from 'redux'; import { all } from 'redux-saga/effects'; import auth, { authSaga } from './auth'; import loading from './loading'; import user, { userSaga } from './user'; import write, { writeSaga } from './write'; import post, { postSaga } from './post'; import posts, { postsSaga } from './posts'; const rootReducer = combineReducers({ auth, loading, user, write, post, posts, }); export function* rootSaga() { yield all([authSaga(), userSaga(), writeSaga(), postSaga(), postsSaga()]); } export default rootReducer;
src/containers/posts/PostListContainer.js
import React, { useEffect } from 'react'; import qs from 'qs'; import { useDispatch, useSelector } from 'react-redux'; import PostList from '../../components/posts/PostList'; import { listPosts } from '../../modules/posts'; import { useParams, useLocation } from 'react-router-dom'; const PostListContainer = () => { const dispatch = useDispatch(); const location = useLocation(); const { username } = useParams(); const { posts, error, loading, user } = useSelector( ({ posts, loading, user }) => ({ posts: posts.posts, error: posts.error, loading: loading['posts/LIST_POSTS'], user: user.user, }), ); useEffect(() => { const { tag, page } = qs.parse(location.search, { ignoreQueryPrefix: true, }); dispatch(listPosts({ tag, username, page })); }, [dispatch, location.search, username]); return ( <PostList loading={loading} error={error} posts={posts} showWriteButton={user} /> ); }; export default PostListContainer;
postList컴포넌트를 사용할 떄 showWriteButton props를 현재 로그인 중엔 사용자의 정보를 지니고 있는 user객체로 설정해 주었습니다. 이렇게 하면 user객체가 유효할 때, 즉 사용자가 로그인 중일때만 포스트를 작성하는 버튼을 나타나게 할 수 있습니다.
또한 parameter로 전달된 username을 받아오고 쿼리스트링으로 전달된 tag와 page를 받아옵니다. 그리고 이 정보를 바탕으로 listPosts 액션 함수를 dispatch해 주어서 정보를 받아옵니다.
src/components/posts/PostList.js
import React from 'react'; import styled from 'styled-components'; import Responsive from '../common/Responsive'; import Button from '../common/Button'; import palette from '../../lib/styles/palette'; import SubInfo from '../common/SubInfo'; import Tags from '../common/Tags'; import { Link } from 'react-router-dom'; const PostListBlock = styled(Responsive)` margin-top: 3rem; `; const WritePostButtonWrapper = styled.div` display: flex; justify-content: flex-end; margin-bottom: 3rem; `; const PostItemBlock = styled.div` padding-top: 3rem; padding-bottom: 3rem; /* 맨 위 포스트는 padding-top없음 */ &:first-child { padding-top: 0; } & + & { border-top: 1px solid ${palette.gray[2]}; } h2 { font-size: 2rem; margin-bottom: 0; margin-top: 0; &:hover { color: ${palette.gray[6]}; } } p { margin-top: 2rem; } a { text-decoration: none; color: black; } `; const PostItem = ({ post }) => { const { publishedDate, user, tags, title, body, _id } = post; return ( <PostItemBlock> <h2> <Link to={`/@${user.username}/${_id}`}>{title}</Link> </h2> <SubInfo username={user.username} publishedDate={new Date(publishedDate)} /> <Tags tags={tags} /> <p>{body}</p> </PostItemBlock> ); }; const PostList = ({ posts, loading, error, showWriteButton }) => { // 에러 발생 시 if (error) { return <PostListBlock>에러가 발생하였습니다.</PostListBlock>; } return ( <PostListBlock> <WritePostButtonWrapper> {showWriteButton && ( <Button cyan to="/write"> 새 글 작성하기 </Button> )} </WritePostButtonWrapper> {/* 로딩 중이 아니고, 포스트 배열이 존재할 때만 보여 줌 */} <div> {!loading && posts && posts.map((post) => <PostItem post={post} key={post._id} />)} </div> </PostListBlock> ); }; export default PostList;
또한 해당 제목을 클릭하게 되면 해당 포스트를 보여줄 수 있는 PostView 컴포넌트로 연결되게 Link를 사용하였습니다. 그리고 PostItem에서는 전달된 posts들의 정보를 바탕으로 화면에 적절히 렌더링 시켜주는 작업을 진행하였습니다. 또한 error객체가 유효하면 오류가 있다고 알려주고 showWriteButton에 user객체를 넣어주었는데 이가 유효하면 새 글 작성하기 버튼을 보여주게끔 처리해 주었습니다.
HTML 필터링하기
sanitize-html을 활용하여 HTML을 필터링 해 보겠습니다. 단순히 HTML을 제거하는 기능뿐만 아니라 특정 HTML만을 허용하는 기능도 있기 때문에 글쓰기 API에서 사용하면 손쉽게 악성 스크립트 삽입을 막을 수 있습니다.
백엔드 서버 Koa서버를 수정합니다.
src/api/posts/posts.ctrl.js - list & update & write
import Post from '../../models/post'; import mongoose from 'mongoose'; import Joi from 'joi'; import sanitizeHtml from 'sanitize-html'; const { ObjectId } = mongoose.Types; const sanitizeOption = { allowTags: [ 'h1', 'h2', 'b', 'i', 'u', 's', 'p', 'ul', 'ol', 'li', 'blockquote', 'a', 'img', ], allowedAttributes: { a: ['href', 'name', 'target'], img: ['src'], li: ['class'], }, allowedSchemes: ['data', 'http'], }; export const getPostById = async (ctx, next) => { const { id } = ctx.params; if (!ObjectId.isValid(id)) { ctx.status = 400; return; } try { const post = await Post.findById(id); // 포스트가 존재하지 않을 때 if (!post) { ctx.status = 404; return; } ctx.state.post = post; } catch (e) { ctx.throw(500, e); } return next(); }; export const checkOwnPost = (ctx, next) => { const { user, post } = ctx.state; if (post.user._id.toString() !== user._id) { ctx.status = 403; return; } return next(); }; /** * 포스트 작성 * POST /api/posts * { title, body } */ export const write = async (ctx) => { // REST API의 Request Body는 ctx.request.body에서 조회할 수 있습니다. const schema = Joi.object().keys({ // 객체가 다음 필드를 가지고 있음을 검증 title: Joi.string().required(), body: Joi.string().required(), tags: Joi.array().items(Joi.string()).required(), }); const result = schema.validate(ctx.request.body); if (result.error) { ctx.status = 400; ctx.body = result.error; return; } const { title, body, tags } = ctx.request.body; const post = new Post({ title, body: sanitizeHtml(body, sanitizeHtml), tags, user: ctx.state.user, }); try { await post.save(); ctx.body = post; } catch (e) { ctx.throw(500, e); } }; /** * 포스트 목록 조회 * GET /api/posts?username=&tag=&page= */ export const list = async (ctx) => { const removeHtmlAndShorten = (body) => { const filtered = sanitizeHtml(body, sanitizeOption); return filtered.length < 200 ? filtered : `${filtered.slice(0, 200)}...`; }; const page = parseInt(ctx.query.page || '1', 10); if (page < 1) { ctx.status = 400; return; } const { tag, username } = ctx.query; // tag, username값이 유효하면 객체 안에 넣고, 그렇지 않으면 넣지 않음 const query = { ...(username ? { 'user.username': username } : {}), ...(tag ? { tags: tag } : {}), }; console.log('query >> ', query); try { const posts = await Post.find(query) .sort({ _id: -1 }) .limit(10) .skip((page - 1) * 10) .lean() .exec(); console.log('posts >> ', posts); const postCount = await Post.countDocuments(query).exec(); ctx.set('Last-page', Math.ceil(postCount / 10)); ctx.body = posts.map((post) => ({ ...post, body: removeHtmlAndShorten(post.body), })); } catch (e) { ctx.throw(500, e); } }; /** * 특정 포스트 조회 * GET /api/posts/:id */ export const read = async (ctx) => { ctx.body = ctx.state.post; }; /** * 특정 포스트 제거 * DELETE /api/posts/:id */ export const remove = async (ctx) => { const { id } = ctx.params; console.log(id); try { await Post.findByIdAndRemove(id).exec(); ctx.status = 204; } catch (e) { ctx.throw(500, e); } }; /** * 포스트 수정(특정 필드 변경) * PATCH /api/posts/:id * { title, body } */ export const update = async (ctx) => { // PATCH 메서드는 주어진 필드만 교체합니다. const { id } = ctx.params; // write에서 사용한 schema와 비슷한데, required()가 없습니다. const schema = Joi.object().keys({ title: Joi.string(), body: Joi.string(), tags: Joi.array().items(Joi.string()), }); const result = schema.validate(ctx.request.body); if (result.error) { ctx.status = 400; ctx.body = result.error; return; } const nextData = { ...ctx.request.body }; if (nextData.body) { nextData.body = sanitizeHtml(nextData.body, sanitizeOption); } try { const post = await Post.findByIdAndUpdate(id, nextData, { new: true, }).exec(); if (!post) { ctx.status = 404; return; } ctx.body = post; } catch (e) { ctx.throw(500, e); } };
sanitizeOption을 작성해서 write함, list함수, update함수를 수정해 주었습니다.
이를 기반으로 postman에서 로그인하고 write API를 호출해서 악성 스크립트를 body에 적었을 때 잘 걸러지는지 확인해 보겠습니다.


이처럼 body에 <script>태그가 없어진 것을 보아 정상 작동하는 것을 확인할 수 있습니다.
페이지네이션 구현하기
list API를 만들 때 마지막 페이지 번호를 HTTP헤더를 통해 클라이언트에 전달하도록 설정했습니다. 그러나 요청을 관리하는 사가를 쉽게 만들기 위해 작성한 createRequestSaga에서는 SUCCESS액션을 발생시킬 때 payload에 response.data값만 넣어주기 때문에 현재 구조로는 헤더를 확인할 수 없습니다.
src/lib/api/createRequestSaga.js
import { call, put } from 'redux-saga/effects'; import { startLoading, finishLoading } from '../../modules/loading'; export const createRequestActionTypes = (type) => { const SUCCESS = `${type}_SUCCESS`; const FAILURE = `${type}_FAILURE`; return [type, SUCCESS, FAILURE]; }; export default function createRequestSaga(type, request) { const SUCCESS = `${type}_SUCCESS`; const FAILURE = `${type}_FAILURE`; return function* ({ payload }) { yield put(startLoading(type)); try { const response = yield call(request, payload); yield put({ type: SUCCESS, payload: response.data, meta: response, }); } catch (e) { yield put({ type: FAILURE, payload: e, error: true, }); } yield put(finishLoading(type)); }; }
이렇게 액션 값으로 meta값을 response로 넣어주게 되면 나중에 HTTP헤더 및 상태 코드를 쉽게 조회할 수 있습니다.
src/modules/posts.js
import { call, put } from 'redux-saga/effects'; import { startLoading, finishLoading } from '../../modules/loading'; export const createRequestActionTypes = (type) => { const SUCCESS = `${type}_SUCCESS`; const FAILURE = `${type}_FAILURE`; return [type, SUCCESS, FAILURE]; }; export default function createRequestSaga(type, request) { const SUCCESS = `${type}_SUCCESS`; const FAILURE = `${type}_FAILURE`; return function* ({ payload }) { yield put(startLoading(type)); try { const response = yield call(request, payload); yield put({ type: SUCCESS, payload: response.data, meta: response, }); } catch (e) { yield put({ type: FAILURE, payload: e, error: true, }); } yield put(finishLoading(type)); }; }
이렇게 하여 스토어 안에 마지막 페이지 번호를 lastPage라는 값으로 담아 둘 수 있게 되었습니다. 다음으로는 페이지네이션을 위한 Pagination.js와 PaginationContainer.js를 만들어보도록 하겠습니다.
src/components/posts/Pagination.js
import React from 'react'; import styled from 'styled-components'; import qs from 'qs'; import Button from '../common/Button'; const PaginationBlock = styled.div` width: 320px; margin: 0 auto; display: flex; justify-content: space-between; margin-bottom: 3rem; `; const PageNumber = styled.div``; const buildLink = ({ username, tag, page }) => { const query = qs.stringify({ tag, page }); return username ? `/@${username}?${query}` : `/?${query}`; }; const Pagination = ({ page, lastPage, username, tag }) => { return ( <PaginationBlock> <Button disabled={page === 1} to={ page === 1 ? undefined : buildLink({ username, tag, page: page - 1 }) } > 이전 </Button> <PageNumber>{page}</PageNumber> <Button disabled={page === lastPage} to={ page === lastPage ? undefined : buildLink({ username, tag, page: page + 1 }) } > 다음 </Button> </PaginationBlock> ); }; export default Pagination;
src/components/common/Button.js
import React from 'react'; import styled, { css } from 'styled-components'; import palette from '../../lib/styles/palette'; import { Link } from 'react-router-dom'; const buttonStyle = css` border: none; border-radius: 4px; font-size: 1rem; font-weight: bold; padding: 0.25rem 1rem; color: white; outline: none; cursor: pointer; background: ${palette.gray[8]}; &:hover { background: ${palette.gray[6]}; } ${(props) => props.cyan && css` background: ${palette.cyan[5]}; &:hover { background: ${palette.cyan[4]}; } `} ${(props) => props.fullWidth && css` padding-top: 0.75rem; padding-bottom: 0.75rem; width: 100%; font-size: 1.125rem; `} &:disabled { background: ${palette.gray[3]}; color: ${palette.gray[5]}; cursor: not-allowed; } `; const StyledButton = styled.button` ${buttonStyle} `; const StyledLink = styled(Link)` ${buttonStyle} `; const Button = (props) => { return props.to ? ( <StyledLink {...props} cyan={props.cyan ? 1 : 0} /> ) : ( <StyledButton {...props} /> ); }; export default Button;
비활성화 스타일은 :disable CSS셀렉터를 사용하여 적용할 수 있습니다.
src/containers/posts/PaginationContainer.js
import React from 'react'; import Pagination from '../../components/posts/Pagination'; import { useSelector } from 'react-redux'; import { useParams, useLocation } from 'react-router-dom'; import qs from 'qs'; const PaginationContainer = () => { const { username } = useParams(); const location = useLocation(); const { lastPage, posts, loading } = useSelector(({ posts, loading }) => ({ lastPage: posts.lastPage, posts: posts.posts, loading: loading['posts/LIST_POSTS'], })); if (!posts || loading) return null; // page가 만약에 안들어 온다면 어떻게하지 => defualt 1 // username, tag는 없어도 되는 선택적인 값인데 page는 필수이기 때문 const { tag, page = 1 } = qs.parse(location.search, { ignoreQueryPrefix: true, }); return ( <Pagination page={parseInt(page, 10)} lastPage={lastPage} tag={tag} username={username} /> ); }; export default PaginationContainer;
src/pages/PostListPage.js
import React from 'react'; import PostListContainer from '../containers/posts/PostListContainer'; import HeaderContainer from '../containers/common/HeaderContainer'; import PaginationContainer from '../containers/posts/PaginationContainer'; const PostListPage = () => { return ( <div> <HeaderContainer /> <PostListContainer /> <PaginationContainer /> </div> ); }; export default PostListPage;


첫 번쨰 페이지일 때는 이전 버튼이 비활성화되고, 마지막 페이지일 때는 다음 버튼이 비활성화됩니다. 계정명이나 태그를 클릭해서 확인해도 페이지네이션 기능이 정확하게 작동하는 것을 확인할 수 있습니다.
'React > ReactJs' 카테고리의 다른 글
React - FrontEnd Project - 6 (0) | 2022.03.22 |
---|---|
React - FrontEnd Project - 4 (0) | 2022.03.20 |
React - React Router v6 (0) | 2022.03.18 |
React - FrontEnd Project - 3 (0) | 2022.03.17 |
React - FrontEnd project - 2 (0) | 2022.03.15 |