UX를 고려한 페이지네이션 설계 및 구현
UX 관점에서 본 좋은 페이지네이션 설계
웹사이트에서 페이지네이션(pagination)은 데이터를 여러 페이지로 나누어 사용자에게 제공하는 중요한 UI 요소입니다. 특히 많은 데이터를 효율적으로 탐색할 수 있도록 돕는 UX 설계가 필요합니다. 좋은 페이지네이션은 다음과 같은 특징을 가집니다.
1.1 현재 위치를 명확하게 보여주기
사용자는 현재 페이지가 몇 번째인지 한눈에 파악할 수 있어야 합니다.
현재 페이지 강조: 현재 페이지 버튼을 시각적으로 구분(예: 색상 변경)
페이지 그룹 표시: 현재 페이지가 속한 블록을 보여주어 사용자가 전체적인 맥락을 이해하도록 함
1.2 빠른 이동 경로 제공
사용자가 특정 페이지로 빠르게 이동할 수 있어야 합니다.
처음, 마지막 페이지 이동 버튼
일정 블록 단위(예: 5~6개)로 점프할 수 있는 기능
너무 많은 페이지가 한꺼번에 보이지 않도록, 적절한 개수만 표시
1.3 직관적인 인터랙션 요소 제공
비활성화된 버튼(예: 첫 페이지에서 '이전' 버튼)은 클릭할 수 없도록 설정
마우스를 올렸을 때 피드백(hover 효과) 제공
1.4 반응형 디자인 고려
모바일에서도 보기 편하도록 크기와 간격 조절
결과물
어떻게 구현했는지 설명하기에 앞서 결과물 먼저 보여드리겠습니다.

커스텀 페이지네이션 훅
위의 UX 원칙을 바탕으로 재사용 가능한 커스텀 훅을 만들었습니다.
1 usePagination
의 역할
usePagination
의 역할이 훅은 다음과 같은 기능을 담당합니다.
현재 페이지와 최대/최소 페이지 관리
한 번에 보여줄 페이지 개수(블록 단위) 관리
이전/다음 블록으로 이동 기능 제공
특정 페이지로 이동 기능 제공
2 usePagination
코드 및 상세 설명
usePagination
코드 및 상세 설명타입 및 반환값 정의
type Page = number | 'nextBlock' | 'prevBlock';
Page
타입은number
또는'nextBlock' | 'prevBlock'
중 하나숫자는 실제 페이지 번호를 나타냄
'nextBlock'
과'prevBlock'
은 이전/다음 페이지 블록을 의미
PaginationHookReturn
인터페이스 정의
PaginationHookReturn
인터페이스 정의export interface PaginationHookReturn {
currentPage: number;
visiblePages: (number | 'nextBlock' | 'prevBlock')[];
minPage: number;
maxPage: number;
handleClickPage: (page: number) => void;
handleClickPrevPage: () => void;
handleClickNextPage: () => void;
handleClickPrevPages: () => void;
handleClickNextPages: () => void;
setMaxPage: (newMaxPage: number) => void;
setMinPage: (newMinPage: number) => void;
}
currentPage
: 현재 선택된 페이지 번호visiblePages
: 현재 화면에 보이는 페이지 목록 (예:[1, 2, 3, 'nextBlock', 10]
)maxPage
: 최소 페이지 번호handleClickPage(page)
: 특정 페이지를 클릭했을 때 실행handleClickPrevPage()
: 이전 페이지로 이동handleClickNextPage()
: 다음 페이지로 이동handleClickPrevPages()
: 이전 블록으로 이동handleClickNextPages()
: 다음 블록으로 이동setMaxPage(newMaxPage)
:maxPage
를 업데이트setMinPage(newMinPage)
:minPage
를 업데이트하는
훅의 기본 구조 및 블록 크기 설정
export const usePagination = (
initialMinPage: number,
initialMaxPage: number,
blockSize: number = 7,
): PaginationHookReturn => {
const END_BLOCK_SIZE = blockSize - 1;
const MIDDLE_BLOCK_SIZE = blockSize - 4;
initialMinPage
와initialMaxPage
를 받아 페이지 범위를 설정blockSize
는 한 번에 보이는 페이지 개수(기본값 7)로, 이전/다음페이지 UI를 제외한 블록 개수를 의미END_BLOCK_SIZE = blockSize - 1
: 양 끝 페이지에 위치할 때 가용 페이지 개수MIDDLE_BLOCK_SIZE = blockSize - 4
: 중간 블록에서의 가용 페이지 개수
상태 선언
const [maxPage, setMaxPage] = useState(initialMaxPage);
const [minPage, setMinPage] = useState(initialMinPage);
const [currentPage, setCurrentPage] = useState(minPage);
const [visiblePages, setVisiblePages] = useState<Page[]>([]);
maxPage
: 최대 페이지minPage
: 최소 페이지currentPage
: 현재 페이지 (기본적으로 minPage에서 시작)visiblePages
: 현재 보이는 페이지 목록 (useEffect
에서 업데이트됨)
페이지 목록 업데이트 로직
useEffect(() => {
if (currentPage < minPage || currentPage > maxPage) return;
setVisiblePages(getBlockPages());
}, [currentPage, maxPage]);
currentPage
,maxPage
가 변경될 때 실행됨현재 페이지가 최소/최대 페이지 범위를 벗어나면 아무 작업도 하지 않음
getBlockPages()
를 호출하여visiblePages
를 업데이트함
페이지 블록을 계산하는 함수들
const getBlockPages = () => {
if (maxPage <= blockSize) return getAllPages();
if (currentPage <= minPage + MIDDLE_BLOCK_SIZE) return getFirstBlock();
else if (currentPage >= maxPage - MIDDLE_BLOCK_SIZE) return getLastBlock();
else return getMiddleBlock();
};
전체 페이지 개수가
blockSize
이하이면getAllPages()
를 호출현재 페이지가
minPage
근처면getFirstBlock()
호출현재 페이지가
maxPage
근처면getLastBlock()
호출그 외의 경우는
getMiddleBlock()
호출
getAllPages()
: 모든 페이지 반환
getAllPages()
: 모든 페이지 반환 const getAllPages = (): Page[] => {
return Array.from({ length: maxPage - minPage + 1 }, (_, i) => minPage + i);
};
[1, 2, 3, 4, 5, 6]
같은 전체 페이지 목록을 반환
getFirstBlock()
: 처음 페이지 블록 생성
getFirstBlock()
: 처음 페이지 블록 생성 const getFirstBlock = (): Page[] => {
const pages = Array.from({ length: Math.min(maxPage, END_BLOCK_SIZE - 1) }, (_, i) => minPage + i);
if (maxPage - minPage > END_BLOCK_SIZE) return [...pages, 'nextBlock', maxPage];
return pages;
};
[1, 2, 3, 4, 5, ..., 51]
형태로 반환
getLastBlock()
: 마지막 페이지 블록 생성
getLastBlock()
: 마지막 페이지 블록 생성 const getLastBlock = (): Page[] => {
const pages = Array.from({ length: END_BLOCK_SIZE - 1 }, (_, i) => maxPage - (END_BLOCK_SIZE - 2) + i);
if (maxPage - minPage > END_BLOCK_SIZE) return [minPage, 'prevBlock', ...pages];
return pages;
};
[1, ..., 47, 48, 49, 50, 51]
형태로 반환
getMiddleBlock()
: 중간 페이지 블록 생성
getMiddleBlock()
: 중간 페이지 블록 생성 const getMiddleBlock = (): Page[] => {
const start = Math.max(minPage, currentPage - Math.floor(MIDDLE_BLOCK_SIZE / 2));
const end = Math.min(maxPage, currentPage + Math.floor(MIDDLE_BLOCK_SIZE / 2));
const pages = Array.from({ length: end - start + 1 }, (_, i) => start + i);
return [minPage, 'prevBlock', ...pages, 'nextBlock', maxPage];
};ㅇ
[1, ..., 9, 10, 11, ..., 51]
형태로 반환
이벤트 핸들러 함수들
const handleClickPage = (page: number) => setCurrentPage(page);
const handleClickPrevPage = () => setCurrentPage((prev) => Math.max(minPage, prev - 1));
const handleClickNextPage = () => setCurrentPage((prev) => Math.min(maxPage, prev + 1));
const handleClickPrevPages = () => {
if (currentPage > maxPage - END_BLOCK_SIZE + 1) setCurrentPage(Math.max(minPage, maxPage - END_BLOCK_SIZE));
else setCurrentPage((prev) => Math.max(minPage, prev - MIDDLE_BLOCK_SIZE));
};
const handleClickNextPages = () => {
if (currentPage < minPage + END_BLOCK_SIZE - 1) setCurrentPage(Math.min(maxPage, minPage + END_BLOCK_SIZE));
else setCurrentPage((prev) => Math.min(maxPage, prev + MIDDLE_BLOCK_SIZE));
};
특정 페이지 선택, 이전/다음 페이지, 이전/다음 블록 이동 기능을 담당
페이지네이션 컴포넌트
페이지네이션 버튼
interface PaginationButtonProps extends PropsWithChildren {
isActive?: boolean; // 현재 선택된 버튼인지 여부
isDisAbled?: boolean; // 버튼이 비활성화(클릭 불가능) 상태인지 여부
handleClick?: () => void; // 버튼 클릭 이벤트 핸들러
}
function PaginationButton({ isActive = false, isDisAbled = false, children, handleClick }: PaginationButtonProps) {
return (
<li>
<button
disabled={isDisAbled} // 버튼 비활성화 여부
onClick={handleClick} // 클릭 이벤트 핸들러
className={cn(
{
[styles.paginationButtonActive]: isActive && !isDisAbled,
[styles.paginationButtonInactive]: !isActive && !isDisAbled,
[styles.paginationButtonDisabled]: isDisAbled,
},
styles.paginationButton,
)}
>
{children}
</button>
</li>
);
}
isDisAbled
이true
면 버튼을 클릭할 수 없도록disabled
속성을 추가className
을 동적으로 설정 (cn
함수 활용)isActive
가true
이면 현재 선택된 버튼임을 나타내는 스타일 적용isDisAbled
가true
이면 버튼을 비활성화 해야 하므로 비활성화 버튼의 스타일 적용
페이지네이션 UI
interface PaginationProps {
nextBlockChar?: string; // 다음 블록 표시 문자 (기본값: '...')
prevBlockChar?: string; // 이전 블록 표시 문자 (기본값: '...')
nextPageChar?: string; // 다음 페이지 버튼 문자 (기본값: '>')
prevPageChar?: string; // 이전 페이지 버튼 문자 (기본값: '<')
paginationTools: PaginationHookReturn; // usePagination 훅에서 반환된 툴
}
paginationTools
를usePagination
훅에서 받아와 페이지네이션 상태를 제어
function Pagination({
nextBlockChar = '...',
prevBlockChar = '...',
nextPageChar = '>',
prevPageChar = '<',
paginationTools,
}: PaginationProps) {
기본값을 설정하여 유연한 커스터마이징이 가능하도록 구현
const {
visiblePages, // 현재 표시해야 하는 페이지 목록
currentPage, // 현재 페이지
minPage, // 최소 페이지
maxPage, // 최대 페이지
handleClickPrevPage, // 이전 페이지 이동
handleClickPrevPages, // 이전 블록 이동
handleClickNextPage, // 다음 페이지 이동
handleClickNextPages, // 다음 블록 이동
handleClickPage, // 특정 페이지 클릭
} = paginationTools;
paginationTools
에서 페이지 이동과 관련된 함수 및 상태를 구조 분해 할당하여 사용
이전 페이지 버튼
<PaginationButton key="prevPage" isDisAbled={currentPage === minPage} handleClick={handleClickPrevPage}>
{prevPageChar}
</PaginationButton>
현재 페이지가
minPage
이면 비활성화<
아이콘을 클릭하면handleClickPrevPage()
실행
페이지 목록 버튼
{visiblePages.map((page) => {
if (page === 'prevBlock')
return (
<PaginationButton key="prevBlock" handleClick={handleClickPrevPages}>
{prevBlockChar}
</PaginationButton>
);
이전 블록 이동 버튼 (
...
)을 나타냄"prevBlock"
값이 있으면handleClickPrevPages()
실행
else if (page === 'nextBlock') {
return (
<PaginationButton key="nextBlock" handleClick={handleClickNextPages}>
{nextBlockChar}
</PaginationButton>
);
}
다음 블록 이동 버튼 (
...
)을 나타냄"nextBlock"
값이 있으면handleClickNextPages()
실행
else
return (
<PaginationButton isActive={page === currentPage} handleClick={() => handleClickPage(page)} key={page}>
{page}
</PaginationButton>
);
})}
각 페이지 버튼 (숫자 버튼)을 나타냄
현재 페이지(
currentPage
)와 동일하면 활성화(isActive=true
)클릭 시 특정 페이지로 이동하는
handleClickPage(page)
호출
다음 페이지 버튼
<PaginationButton key="nextPage" isDisAbled={currentPage === maxPage} handleClick={handleClickNextPage}>
{nextPageChar}
</PaginationButton>
현재 페이지가
maxPage
이면 비활성화>
아이콘을 클릭하면handleClickNextPage()
실행
느낀점
이번 페이지네이션 구현을 통해 UX 관점에서의 중요성을 다시 한번 깨닫게 되었습니다. 단순히 기능적인 요소가 아니라, 사용자 경험을 극대화할 수 있는 설계를 고민하는 과정이 매우 흥미로웠습니다. 앞으로도 더욱 직관적이고 효율적인 UI를 고민하며 개선해 나가고 싶습니다.
Last updated