웹이든 앱이든 사용하다 보면 다양한 페이지를 필수적으로 보게 된다.
특정 페이지를 선택해서 보기도 하고, 모든 페이지들이 끊임없이 보여지기도 한다.
특정 페이지를 선택해서 보는 것이 일반적인 방식인데 이를 pagination(페이지네이션) 방식이라고 하며,
모든 페이지들이 끊임없이 보여지는 방식을 Infinite scroll(무한스크롤) 방식이라고 한다.
사실 유저 입장에서만 볼 땐 특정 페이지를 선택을 하든 인스타처럼 무한 스크롤 방식으로 모든 페이지들을 보여주든 다 같은거 아닌가란 생각을 하며 살아왔다.
역시나 내 자신은 무지했고ㅎㅎ 이번 수업을 통해 차이점을 명확히 알게 되었다!
pagination이란 페이지 번호를 클릭해서 이동하는 방식의 페이지 처리 방법이다.
출처: 육육걸즈 (https://66girls.co.kr/product/list.html?cate_no=70)
막연히 쇼핑몰이 가장 좋은 예시인 것 같아 육육걸즈 쇼핑몰 속 페이지네이션을 하나의 예시로 들고 왔다.
Infinite Scroll이란 페이지를 아래로 스크롤 하다가 종단점에 도달하면 새로운 데이터가 계속 추가되는 방식의 페이지 처리 방법이다.
가장 좋은 예시로 페이스북, 인스타와 같이 피드가 끊임없이 나오게 하는 페이지 처리 방식을 생각할 수 있다.
1~10페이지의 게시물을 보여주기 위해 span 태그로 10개를 만들어주었다.
이러한 방식은 페이지수에 따라 span 태그를 일일히 입력해줘야하므로 비효율적이다.
'map'을 활용하여 비효율성을 없앨 수 있을 것이다!
import { useQuery, gql } from "@apollo/client";
import styled from "@emotion/styled";
import { MouseEvent } from "react";
import {
IQuery,
IQueryFetchBoardsArgs,
} from "../../src/commons/types/generated/types";
const FETCH_BOARDS = gql`
query fetchBoards($page: Int) {
fetchBoards(page: $page) {
_id
writer
title
contents
}
}
`;
const Row = styled.div`
display: flex;
flex-direction: row;
`;
const Column = styled.div`
width: 25%;
`;
export default function StaticRoutedPage() {
const { data, refetch } = useQuery<
Pick<IQuery, "fetchBoards">,
IQueryFetchBoardsArgs
>(FETCH_BOARDS);
const onClickPage = (event: MouseEvent<HTMLSpanElement>) => {
if (!(event.target instanceof HTMLSpanElement)) return;
refetch({ page: Number(event.target.id) });
};
return (
<>
{data?.fetchBoards?.map((el) => (
<Row key={el._id}>
<Column>{el.writer}</Column>
<Column>{el.title}</Column>
</Row>
))}
<span id="1" onClick={onClickPage}> 1 </span>
<span id="2" onClick={onClickPage}> 2 </span>
<span id="3" onClick={onClickPage}> 3 </span>
<span id="4" onClick={onClickPage}> 4 </span>
<span id="5" onClick={onClickPage}> 5 </span>
<span id="6" onClick={onClickPage}> 6 </span>
<span id="7" onClick={onClickPage}> 7 </span>
<span id="8" onClick={onClickPage}> 8 </span>
<span id="9" onClick={onClickPage}> 9 </span>
<span id="10" onClick={onClickPage}> 10 </span>
</>
);
}
map을 활용하여 return 부분의 코드 길이를 확 줄일 수 있다.
그러나 지금과 같은 방법도 10페이지 이후의 페이지들은 볼 수 없다는 단점이 있다.
import { useQuery, gql } from "@apollo/client";
import styled from "@emotion/styled";
import { MouseEvent } from "react";
import {
IQuery,
IQueryFetchBoardsArgs,
} from "../../src/commons/types/generated/types";
const FETCH_BOARDS = gql`
query fetchBoards($page: Int) {
fetchBoards(page: $page) {
_id
writer
title
contents
}
}
`;
const Row = styled.div`
display: flex;
flex-direction: row;
`;
const Column = styled.div`
width: 25%;
`;
export default function StaticRoutedPage() {
const { data, refetch } = useQuery<
Pick<IQuery, "fetchBoards">,
IQueryFetchBoardsArgs
>(FETCH_BOARDS);
const onClickPage = (event: MouseEvent<HTMLSpanElement>) => {
if (!(event.target instanceof HTMLSpanElement)) return;
refetch({ page: Number(event.target.id) });
};
return (
<>
{data?.fetchBoards?.map((el) => (
<Row key={el._id}>
<Column>{el.writer}</Column>
<Column>{el.title}</Column>
</Row>
))}
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((el) => (
<span key={el} id={String(el)} onClick={onClickPage}> {el} </span>
))}
</>
);
}
startPage를 useState를 활용하여 초기값을 1로 설정하였으며,
페이지를 누르면 해당 페이지로 이동되도록 설정하였고, 이전 페이지와 다음 페이지 버튼을 누르면 10 이전 이후의 페이지로 이동 및 refetch되도록 설정하였다.
10페이지 이후 페이지도 구현되도록 하기 위해 map 안에 index+startPage를 넣어주었다.
이전 페이지를 계속 누를 경우 0 또는 음수의 페이지가 나오며, 다음 페이지를 계속 누를 경우 게시물이 없는 페이지도 계속 나온다. 0 또는 음수의 페이지, 게시물이 없는 페이지가 나오지 않고 1페이지 또는 마지막 페이지인 경우 이전 다음 페이지 버튼이 비활성화되도록 설정이 필요하다.
import { useQuery, gql } from "@apollo/client";
import styled from "@emotion/styled";
import { MouseEvent, useState } from "react";
import {
IQuery,
IQueryFetchBoardsArgs,
} from "../../src/commons/types/generated/types";
const FETCH_BOARDS = gql`
query fetchBoards($page: Int) {
fetchBoards(page: $page) {
_id
writer
title
contents
}
}
`;
const Row = styled.div`
display: flex;
flex-direction: row;
`;
const Column = styled.div`
width: 25%;
`;
export default function StaticRoutedPage() {
const [startPage, setStartPage] = useState(1);
const { data, refetch } = useQuery<
Pick<IQuery, "fetchBoards">,
IQueryFetchBoardsArgs
>(FETCH_BOARDS);
const onClickPage = (event: MouseEvent<HTMLSpanElement>) => {
if (!(event.target instanceof HTMLSpanElement)) return;
refetch({ page: Number(event.target.id) });
};
const onClickPrevPage = () => {
setStartPage((prev) => prev - 10);
refetch({ page: startPage - 10 });
};
const onCLickNextPage = () => {
setStartPage((prev) => prev + 10);
refetch({ page: startPage + 10 });
};
return (
<>
{data?.fetchBoards?.map((el) => (
<Row key={el._id}>
<Column>{el.writer}</Column>
<Column>{el.title}</Column>
</Row>
))}
<span onClick={onClickPrevPage}>이전페이지</span>
{new Array(10).fill(1).map((_, index) => (
<span
key={index + startPage}
id={String(index + startPage)}
onClick={onClickPage}
>
{index + startPage}
</span>
))}
<span onClick={onCLickNextPage}>다음페이지</span>
</>
);
}
드디어 마지막 버전!!
게시물 갯수를 세주는 fetchBoardsCount API를 호출하여 마지막 페이지(lastPage)를 정의해줬다.
이전 페이지 버튼은 startPage가 1일 때 onClick이 return 되도록 설정하였으며, 다음 페이지 버튼 역시 lastPage가 startPage+10보다 크거나 같을 때만 활성화되도록 설정하였다.
페이지수는 1 이전 페이지, 마지막 페이지 이후 페이지가 나오지 않도록 "index+startPage<=lastPage" 조건을 설정해줬다.
import { useQuery, gql } from "@apollo/client";
import styled from "@emotion/styled";
import { MouseEvent, useState } from "react";
import {
IQuery,
IQueryFetchBoardsArgs,
IQueryFetchBoardsCountArgs,
} from "../../src/commons/types/generated/types";
const FETCH_BOARDS = gql`
query fetchBoards($page: Int) {
fetchBoards(page: $page) {
_id
writer
title
contents
}
}
`;
const FETCH_BOARDS_COUNT = gql`
query fetchBoardsCount {
fetchBoardsCount
}
`;
const Row = styled.div`
display: flex;
flex-direction: row;
`;
const Column = styled.div`
width: 25%;
`;
export default function StaticRoutedPage() {
const [startPage, setStartPage] = useState(1);
const { data, refetch } = useQuery<
Pick<IQuery, "fetchBoards">,
IQueryFetchBoardsArgs
>(FETCH_BOARDS);
const { data: dataBoardCount } = useQuery<
Pick<IQuery, "fetchBoardsCount">,
IQueryFetchBoardsCountArgs
>(FETCH_BOARDS_COUNT);
const lastPage = dataBoardCount
? Math.ceil(dataBoardCount?.fetchBoardsCount / 10)
: 1;
const onClickPage = (event: MouseEvent<HTMLSpanElement>) => {
if (!(event.target instanceof HTMLSpanElement)) return;
refetch({ page: Number(event.target.id) });
};
const onClickPrevPage = () => {
if (startPage === 1) return;
setStartPage((prev) => prev - 10);
refetch({ page: startPage - 10 });
};
const onCLickNextPage = () => {
if (startPage + 10 <= lastPage) {
setStartPage((prev) => prev + 10);
refetch({ page: startPage + 10 });
}
};
return (
<>
{data?.fetchBoards?.map((el) => (
<Row key={el._id}>
<Column>{el.writer}</Column>
<Column>{el.title}</Column>
</Row>
))}
<span onClick={onClickPrevPage}>이전페이지</span>
{new Array(10).fill(1).map(
(_, index) =>
index + startPage <= lastPage && (
<span
key={index + startPage}
id={String(index + startPage)}
onClick={onClickPage}
>
{index + startPage}
</span>
)
)}
<span onClick={onCLickNextPage}>다음페이지</span>
</>
);
}
pagination 부분을 컴포넌트로 분리하여 활용할 경우 다른 페이지에서 pagination이 또 필요할 때 해당 컴포넌트를 가져와서 활용할 수 있다.
이전 글에서 살펴본 것처럼 자식 컴포넌트 -> 자식 컴포넌트는 props를 전달해줄 수 없으므로 데이터를 불러오는 컴포넌트에서 refetch, lastPage와 같은 데이터를 pagination 컴포넌트로 전달해줄 수 없다.
refetch, lastPage는 pagination 컴포넌트에서 필요한 데이터이므로 API 데이터들과 lastPage 정의 함수는 부모 컴포넌트에서 선언해주고 있다.
import { useQuery, gql } from "@apollo/client";
import {
IQuery,
IQueryFetchBoardsArgs,
IQueryFetchBoardsCountArgs,
} from "../../src/commons/types/generated/types";
import Board from "../../src/components/units/14-board-pagination/Board";
import Pagination from "../../src/components/units/14-board-pagination/Pagination";
const FETCH_BOARDS = gql`
query fetchBoards($page: Int) {
fetchBoards(page: $page) {
_id
writer
title
contents
}
}
`;
const FETCH_BOARDS_COUNT = gql`
query fetchBoardsCount {
fetchBoardsCount
}
`;
export default function StaticRoutedPage() {
const { data, refetch } = useQuery<
Pick<IQuery, "fetchBoards">,
IQueryFetchBoardsArgs
>(FETCH_BOARDS);
const { data: dataBoardCount } = useQuery<
Pick<IQuery, "fetchBoardsCount">,
IQueryFetchBoardsCountArgs
>(FETCH_BOARDS_COUNT);
const lastPage = dataBoardCount
? Math.ceil(dataBoardCount?.fetchBoardsCount / 10)
: 1;
return (
<>
<Board data={data}></Board>
<Pagination refetch={refetch} lastPage={lastPage}></Pagination>
</>
);
}
해당 페이지와 관련된 게시물 목록을 그려주는 컴포넌트이다. props 타입은 임시로 any로 지정하였으며, props를 통해 index.tsx에서 data를 가져오고 있다.
import styled from "@emotion/styled";
const Row = styled.div`
display: flex;
flex-direction: row;
`;
const Column = styled.div`
width: 25%;
`;
export default function Board(props: any) {
return (
<div>
{props.data?.fetchBoards?.map((el) => (
<Row key={el._id}>
<Column>{el.writer}</Column>
<Column>{el.title}</Column>
</Row>
))}
</div>
);
}
pagination을 그려주는 컴포넌트이다. props 타입 역시 임시로 any로 지정하였으며 props를 통해 refetch와 lastPage를 가져오고 있다.
import { MouseEvent, useState } from "react";
export default function Pagination(props: any) {
const [startPage, setStartPage] = useState(1);
const onClickPage = (event: MouseEvent<HTMLSpanElement>) => {
if (!(event.target instanceof HTMLSpanElement)) return;
props.refetch({ page: Number(event.target.id) });
};
const onClickPrevPage = () => {
if (startPage === 1) return;
setStartPage((prev) => prev - 10);
props.refetch({ page: startPage - 10 });
};
const onCLickNextPage = () => {
if (startPage + 10 <= props.lastPage) {
setStartPage((prev) => prev + 10);
props.refetch({ page: startPage + 10 });
}
};
return (
<div>
<span onClick={onClickPrevPage}>이전페이지</span>
{new Array(10).fill(1).map(
(_, index) =>
index + startPage <= props.lastPage && (
<span
key={index + startPage}
id={String(index + startPage)}
onClick={onClickPage}
>
{" "}
{index + startPage}{" "}
</span>
)
)}
<span onClick={onCLickNextPage}>다음페이지</span>
</div>
);
}
무한 스크롤 방식을 활용하기 위해 'react infinite scroller'를 활용하였다.
yarn과 typescripts를 사용하고 있기 때문에
yarn add react-infinite-scroll-component
yarn add -D @types/react-infinite-scroller
를 통해 설치해주었다.
API는 해당 데이터들을 객체 형태로 보내준다. 객체는 String, Number, Boolean타입과 다르게 복사가 되지 않는데 해당 문제는 얕은 복사, 깊은 복사로 해결 가능하다. 얕은 복사, 깊은 복사 개념은 다음 블로깅을 통해 자세히 살펴보도록 하며, 객체 타입으로 받아진 API 데이터들을 얕은 복사하기 위해 스프레드 연산자를 사용한 것을 볼 수 있다.
import { useQuery, gql } from "@apollo/client";
import styled from "@emotion/styled";
import {
IQuery,
IQueryFetchBoardsArgs,
} from "../../src/commons/types/generated/types";
import InfiniteScroll from "react-infinite-scroller";
const FETCH_BOARDS = gql`
query fetchBoards($page: Int) {
fetchBoards(page: $page) {
_id
writer
title
contents
}
}
`;
const Row = styled.div`
display: flex;
flex-direction: row;
`;
const Column = styled.div`
width: 25%;
`;
export default function StaticRoutedPage() {
const { data, fetchMore } = useQuery<
Pick<IQuery, "fetchBoards">,
IQueryFetchBoardsArgs
>(FETCH_BOARDS);
const onFetchMore = () => {
if (!data) return;
fetchMore({
variables: { page: Math.ceil(data?.fetchBoards.length / 10) + 1 },
updateQuery: (prev, { fetchMoreResult }) => {
if (!fetchMoreResult.fetchBoards)
return { fetchBoards: [...prev.fetchBoards] };
return {
fetchBoards: [...prev.fetchBoards, ...fetchMoreResult.fetchBoards],
};
},
});
};
return (
<InfiniteScroll pageStart={0} loadMore={onFetchMore} hasMore={true}>
{data?.fetchBoards.map((el) => (
<Row key={el._id}>
<Column>{el.writer}</Column>
<Column>{el.title}</Column>
</Row>
)) || <div></div>}
</InfiniteScroll>
);
}