티스토리 뷰
개발냥이/타입스크립트(Typescript)
[Next.js + typescript] 페이지네이션(Pagination) 구현해보기
데브캣_DevCat 2023. 7. 10. 08:57개요
- 페이지네이션의 구현을 도와주는 좋은 라이브러리도 많지만
- next.js와 친숙해질겸 페이지네이션의 동작 방식도 익힐겸 간단하게 next.js 환경에서 구현해보았습니다.
요구 사항
- 모든 데이터를 한 번에 가져오는 것이 아니라 매 페이지를 누를 때마다 해당 페이지에 해당하는 API 요청만 합니다.
- 페이지의 총 개수는 전체 데이터 개수에 따릅니다. 예를 들어 한 페이지에 10개의 데이터를 표시하고 총 데이터의 개수가 38개라면 페이지의 개수는 4개가 되어야 합니다.
- < or > 기호를 누르면 페이지를 한 칸씩 이동합니다.
주요 로직
- 우선 화면에 보여줄 데이터를 불러와보겠습니다.
pagination/page.tsx
export interface ItemsProps {
Id: number;
Name: string;
Grade: string;
Icon: string;
BundleCount: number;
TradeRemainCount: number;
YDayAvgPrice: number;
RecentPrice: number;
CurrentMinPrice: number;
}
export interface FetchData {
PageNo: number;
PageSize: number;
TotalCount: number;
Items: ItemsProps[];
}
const [fetchData, setFetchData] = useState<ItemsProps[]>([]);
const [page, setPage] = useState(1);
const total = 50;
const limit = 10;
useEffect(() => {
fetch(`https://developer-lostark.game.onstove.com/markets/items`, {
method: 'POST',
headers: {
Authorization: `Bearer ${APIkey}`,
'content-type': 'application/json;charset=UTF-8',
},
body: JSON.stringify({
Sort: 'RECENT_PRICE',
CategoryCode: 40000,
CharacterClass: '',
ItemTier: null,
ItemGrade: '',
ItemName: '',
PageNo: page,
SortCondition: 'ASC',
}),
})
.then((res) => res.json())
.then((data) => {
setFetchData(data.Items);
});
}, [page]);
- 가져올 데이터는 로스트아크의 거래소에 올라와있는 각인서 리스트입니다.
- 로스트아크 API key를 발급받은 후 API docs를 보고 body에 들어갈 데이터와 타입을 세팅해주었습니다.
- 데이터는 위와 같이 응답이 오는데, PageNo은 1부터 10의 Size를 가지고 Items 배열 안에 리스트가 담겨서 옵니다.
- 그러면 한 페이지에 10개씩 보여주고(limit) 사용자가 페이지네이션의 숫자를 클릭한 경우 body의 pageNo에 해당 페이지네이션 숫자를 넣어서 요청을 보내면 될 것 같네요!
- 그리고 저는 데이터를 어떻게 나누어서 표시할 것인지 고민해보았습니다.
- 만약 47개의 데이터가 있고 한 페이지에 10개의 데이터를 표시한다면 47 / 10 = 4.7 여기서 올림하면 결국 5개의 페이지가 필요하게 됩니다. 1~4 페이지는 10개의 데이터가 표시되고 5 페이지는 7개만 표시가 되겠죠!
- 위 계산을 하려면 우선 총 데이터의 개수(total) 와 한 페이지에 표시할 데이터의 개수(limit) 를 알아야 하겠네요!
- total의 경우 응답 데이터의 TotalCount를 이용해도 되지만 저는 임의로 50으로 정하고 진행해보았습니다.
component/DataList.tsx
import { ItemsProps } from '../pagination/page';
import Image from 'next/image';
interface Props {
data: ItemsProps[];
}
const DataList = ({ data }: Props) => {
return (
<>
{data.map((e) => (
<>
<div key={e.Id} className="flex flex-col justify-center items-center">
<Image src={e.Icon} width={48} height={48} alt="item image"></Image>
<div className="text-l text-center ">{e.Name}</div>
</div>
</>
))}
</>
);
};
export default DataList;
- 이 컴포넌트는 페이지에 데이터를 뿌려주는 역할을 합니다.
- 데이터가 표시되는 곳 아래에 페이지네이션을 위치시키면 적당할 것 같습니다.
pagination/page.tsx
return (
<section>
<h2 className="text-3xl text-center text-orange-700 my-8">
This is pagination page
</h2>
<div className="text-xl w-screen text-center text-blue-600">
LostArk Market!
</div>
<DataList data={fetchData} />
<PaginationComponent
total={total}
page={page}
limit={limit}
setPage={setPage}
/>
</section>
);
};
- DataList에는 props로 데이터를 넘겨줍니다.
- 저의 경우는 데이터가 이미 10개씩 잘려서 오기 때문에 그대로 넘겨주면 되지만
- 100개의 데이터를 미리 가져온 후 10개씩 잘라야 하는 경우에는 slice를 이용해 데이터를 잘라주어야 합니다.
- 또한 데이터를 잘라서 보낸다면, 어디서부터 어디까지를 잘라야 하는지를 알아야 합니다. 예를 들어서 1~100까지 10등분을 하는데 사용자가 3번 페이지네이션을 눌렀다면 30~39번에 해당하는 데이터를 잘라서 보내주어야 하는 것이죠!
- 이 때는
const offset = (page - 1) * limit;
의 수식을 이용해서 보낼 수 있습니다.- 1번 페이지의 시작 인덱스 : (1-1) * 10 = 0
- 1번 페이지의 끝 인덱스 : 시작 인덱스 + limit-1
- 1번 페이지의 범위 = 0 ~ 9
- 2번 페이지의 시작 인덱스 : (2-1) * 10 = 10
- 2번 페이지의 끝 인덱스 : 시작 인덱스 + limit-1
- 2번 페이지의 범위 = 10 ~ 19
- 위와 같이 우리가 이미 알고 있는 정보들을 가지고 페이지의 범위를 알아낼 수 있습니다.
- props로 데이터를 넘겨준다면
data={fetchData.slice(offset, offset + limit)}
로 넘겨줄 수 있겠습니다. - 그리고 페이지네이션 컴포넌트에는 총 페이지 개수(total), 한 페이지에 나타낼 데이터 개수(limit), 그리고 현재 페이지를 알기 위한 현재 페이지(page) 와 페이지를 변화시킬 setPage 함수 를 prop로 넘기게 됩니다.
paginationComponent.tsx
import { Dispatch } from 'react';
interface PaginationProps {
total: number;
page: number;
limit: number;
setPage: Dispatch<React.SetStateAction<number>>;
}
const PaginationComponent = ({
total,
page,
limit,
setPage,
}: PaginationProps) => {
const pageNum = Math.ceil(total / limit);
return (
<section className="mt-8">
<div className="flex justify-center gap-x-4">
<button
onClick={() => {
setPage(page - 1);
}}
disabled={page === 1}
>
<
</button>
{Array(pageNum)
.fill(0)
.map((_, i) => (
<button
key={i + 1}
onClick={() => setPage(i + 1)}
aria-current={page === i + 1 && 'page'}
>
{i + 1}
</button>
))}
<button
onClick={() => {
setPage(page + 1);
}}
disabled={page === pageNum}
>
>
</button>
</div>
</section>
);
};
export default PaginationComponent;
- 앞서 이야기했듯이 총 페이지의 개수(pageNum)는 총 데이터의 개수(total) / 한 페이지에 표시할 데이터 개수(limit) 을 올림한 값이 됩니다.
- 그리고 총 페이지의 개수만큼 루프를 돌며 페이지 넘버버튼을 생성하게 됩니다.
{Array(pageNum)
.fill(0)
.map((_, i) => (
<button
key={i + 1}
onClick={() => setPage(i + 1)}
aria-current={page === i + 1 && 'page'}
>
{i + 1}
</button>
))}
- 각 버튼은 눌렀을 때 자신의 번호를 setPage하게 되고 page의 변화를 감지하면 새로운 page를 요청 body의 pageNo에 실어서 POST 요청을 보낼 것 같네요!
- 그리고 aria-current 는 컴포넌트 내에서 현재 항목을 나타내는 키워드입니다. page, step, location, date, time, true, false 의 속성값을 가질 수 있는데 aria-current = "page" 라고 한다면 컴포넌트 내에서 현재 page라는 것을 명시해주는 것이죠!
- 그래서 onClick 되었을 때 aria-current = page 를 붙여주고 해당 키워드가 붙었을 경우를 조건으로 CSS를 입혀준다면 사용자 입장에서는 어떤 페이지에 머무르고 있는지를 확인할 수 있으며 스크린 리더를 사용하는 유저에게도 도움이 될 수 있습니다.
global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
button[aria-current='page'] {
@apply bg-yellow-300 font-bold rounded-3xl;
}
}
- 또한 이전 페이지 버튼('<') 다음 페이지 버튼('>')은 각각 가장 처음 페이지와 마지막 페이지에서는 활성화가 되지 않도록 disabled 키워드를 조건으로 걸어주면 좀 더 사용자 UX에 좋은 페이지네이션이 될 것 같습니다!
전체 코드
pagination/page.tsx
'use client';
import { useEffect, useState } from 'react';
import DataList from '../components/DataList';
import PaginationComponent from '../components/PaginationComponent';
export interface ItemsProps {
Id: number;
Name: string;
Grade: string;
Icon: string;
BundleCount: number;
TradeRemainCount: number;
YDayAvgPrice: number;
RecentPrice: number;
CurrentMinPrice: number;
}
export interface FetchData {
PageNo: number;
PageSize: number;
TotalCount: number;
Items: ItemsProps[];
}
const Pagination = () => {
/**
* state: pokemon data list
* page: current page
* limit: The number of data to be displayed on one page
* offset: Size between start and end points => fetchData.slice(offset, offset + limit)
*/
const [fetchData, setFetchData] = useState<ItemsProps[]>([]);
const [page, setPage] = useState(1);
const total = 50;
const limit = 10;
const offset = (page - 1) * limit;
const APIkey = process.env.NEXT_PUBLIC_LOSTARK_API_KEY;
useEffect(() => {
fetch(`https://developer-lostark.game.onstove.com/markets/items`, {
method: 'POST',
headers: {
Authorization: `Bearer ${APIkey}`,
'content-type': 'application/json;charset=UTF-8',
},
body: JSON.stringify({
Sort: 'RECENT_PRICE',
CategoryCode: 40000,
CharacterClass: '',
ItemTier: null,
ItemGrade: '',
ItemName: '',
PageNo: page,
SortCondition: 'ASC',
}),
})
.then((res) => res.json())
.then((data) => {
setFetchData(data.Items);
});
}, [page]);
return (
<section>
<h2 className="text-3xl text-center text-orange-700 my-8">
This is pagination page
</h2>
<div className="text-xl w-screen text-center text-blue-600">
LostArk Market!
</div>
<DataList data={fetchData} />
<PaginationComponent
total={total}
page={page}
limit={limit}
setPage={setPage}
/>
</section>
);
};
export default Pagination;
DataList.tsx
import { ItemsProps } from '../pagination/page';
import Image from 'next/image';
interface Props {
data: ItemsProps[];
}
const DataList = ({ data }: Props) => {
return (
<>
{data.map((e) => (
<>
<div key={e.Id} className="flex flex-col justify-center items-center">
<Image src={e.Icon} width={48} height={48} alt="item image"></Image>
<div className="text-l text-center ">{e.Name}</div>
</div>
</>
))}
</>
);
};
export default DataList;
PaginationComponent
import { Dispatch } from 'react';
interface PaginationProps {
total: number;
page: number;
limit: number;
setPage: Dispatch<React.SetStateAction<number>>;
}
const PaginationComponent = ({
total,
page,
limit,
setPage,
}: PaginationProps) => {
const pageNum = Math.ceil(total / limit);
return (
<section className="mt-8">
<div className="flex justify-center gap-x-4">
<button
onClick={() => {
setPage(page - 1);
}}
disabled={page === 1}
>
<
</button>
{Array(pageNum)
.fill(0)
.map((_, i) => (
<button
key={i + 1}
onClick={() => setPage(i + 1)}
aria-current={page === i + 1 && 'page'}
>
{i + 1}
</button>
))}
<button
onClick={() => {
setPage(page + 1);
}}
disabled={page === pageNum}
>
>
</button>
</div>
</section>
);
};
export default PaginationComponent;
global.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
button[aria-current='page'] {
@apply bg-yellow-300 font-bold rounded-3xl;
}
}
참고 자료
반응형
'개발냥이 > 타입스크립트(Typescript)' 카테고리의 다른 글
[Next.js + 타입스크립트] 무한 스크롤(Infinite scroll) 구현해보기(feat. Intersection observer) (0) | 2023.07.15 |
---|---|
[리액트+타입스크립트] 이미지 업로드 구현 & 이미지와 콘텐츠 하나의 객체로 관리하기 (0) | 2023.07.12 |
[리액트+타입스크립트] 경과 시간(날짜) 표시하기 & Intl API 알아보기 (0) | 2023.07.07 |
[리액트+타입스크립트] 커스텀 셀렉트 박스(custom select box) 만들어보기)_리팩토링 추가(2023-07-29) (0) | 2023.07.06 |
[리액트+타입스크립트] 커스텀 훅(Custom hook) 만들어보기(useInput) (0) | 2023.06.22 |
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
- Total
- Today
- Yesterday
링크
TAG
- Nest
- DP
- 자바
- 해시맵
- 형변환
- 이분탐색
- 스프링부트
- dfs
- Spring
- SQLD
- java
- Comparator
- SQL
- Queue
- 자바스크립트
- 자바트리
- 정렬
- 스프링
- 백준
- JPA
- 자바bfs
- CS
- 리액트
- JavaScript
- BFS
- Algorithm
- 타입스크립트
- 알고리즘
- 프로그래머스
- 자바dp
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
글 보관함