logo

DowanKim

34. 우리 어플에는 무한스크롤이 맞을까 버튼이 맞을까

2025년 11월 3일

이게머니

초기에는 문제 수가 적어 전체를 한 번에 받아 표시해도 문제없었습니다. 하지만 서비스를 지속 운영하며 문제 수가 늘어날 것을 고려해, 초기에 페이지네이션을 도입했습니다.

1. 페이지네이션 방식 선택: 무한 스크롤 vs 페이지 버튼

1.1 사용자 경험 관점에서의 고민

무한 스크롤과 페이지 버튼 중 선택을 위해 사용자 시나리오를 분석했습니다.

  • 1.사용자는 목록을 스캔하며 관심 있는 문제를 선택합니다.
  • 2.특정 문제의 위치를 기억해 다시 찾아가야 합니다.

무한 스크롤은 위치 기억이 어렵고, 페이지 버튼은 위치를 명확히 기억할 수 있습니다.

1.2 최종 결정: 페이지 버튼 방식

결론적으로 페이지 버튼 방식을 선택했습니다. 이유는 다음과 같습니다.

  • 1.위치 기억 용이: 페이지 번호로 위치를 기억하고 바로 이동 가능
  • 2.탐색 편의성: 원하는 페이지로 직접 이동 가능
  • 3.진행 상황 파악: 현재 위치와 전체 범위를 한눈에 확인 가능

2. 오프셋 vs 커서 기반 페이지네이션

2.1 데이터 특성 분석

퀴즈 데이터는 정적에 가깝습니다:

  • 1.문제 추가/수정 빈도가 낮음
  • 2.순서 변경이 거의 없음
  • 3.캐싱과 사전 로딩에 유리

2.2 오프셋 방식 선택

오프셋 방식을 선택한 이유:

  • 1.구현 단순: page, size 파라미터로 구현
  • 2.캐싱 효율: 페이지별 캐싱이 용이
  • 3.사용자 경험: 페이지 번호와 직관적으로 매핑

3. 백엔드 API 설계

백엔드 API를 페이지네이션을 지원하도록 변경했습니다.

// API 엔드포인트 GET /topics/{topicId}?page={page}&size={size} // 응답 예시 { "quizzes": [...], // 기타 메타데이터 }
  • page: 0부터 시작하는 페이지 번호
  • size: 페이지당 항목 수 (기본값: 10)

4. 프론트엔드 구현

4.1 상태 관리 구조

const [currentPage, setCurrentPage] = useState(0); const totalQuizCount = location.state?.totalQuizCount || 0; const PAGE_SIZE = 10; const totalPages = Math.ceil(totalQuizCount / PAGE_SIZE);
  • currentPage: 현재 페이지 (0부터 시작)
  • totalQuizCount: 토픽 선택 페이지에서 전달받은 전체 문제 수
  • totalPages: 전체 페이지 수 계산

4.2 API 호출과 React Query 연동

const { data: quizListData, error, isLoading, } = useQueryApi<QuizListResponse>( ['topics', topicId || '', currentPage], `/topics/${topicId || ''}?page=${currentPage}&size=${PAGE_SIZE}`, );
  • 쿼리 키에 currentPage를 포함해 페이지별 캐싱
  • 페이지 변경 시 자동으로 새 데이터 요청

4.3 블록 페이지네이션 알고리즘

모바일에서 5개씩 표시하는 블록 페이지네이션을 구현했습니다.

const getPageNumbers = () => { const pages: number[] = []; const maxVisiblePages = 5; const blockStart = Math.floor(currentPage / maxVisiblePages) * maxVisiblePages; let startPage = blockStart; let endPage = Math.min(totalPages - 1, startPage + maxVisiblePages - 1); for (let i = startPage; i <= endPage; i++) { pages.push(i); } return pages; };

동작 예시:

  • 현재 페이지가 7이면: Math.floor(7 / 5) * 5 = 5 → 5, 6, 7, 8, 9 표시
  • 현재 페이지가 12이면: Math.floor(12 / 5) * 5 = 10 → 10, 11, 12, 13, 14 표시

4.4 페이지 전환 핸들러

const handlePrevPage = () => { if (currentPage > 0) { setCurrentPage(currentPage - 1); window.scrollTo({ top: 0, behavior: 'smooth' }); } }; const handleNextPage = () => { if (currentPage < totalPages - 1) { setCurrentPage(currentPage + 1); window.scrollTo({ top: 0, behavior: 'smooth' }); } }; const handlePageClick = (page: number) => { setCurrentPage(page); window.scrollTo({ top: 0, behavior: 'smooth' }); };
  • 1.페이지 변경 시 상단으로 스크롤
  • 2.경계 조건 체크로 버튼 비활성화

5. 상태 유지: 페이지 복귀 시 상태 보존

5.1 문제 상황

퀴즈 상세 → 뒤로가기 시 목록 페이지의 상태가 사라지는 문제가 있었습니다. api구조 상 navigate시 state로 전달해주든 정보들에 대해, 뒤로가기에는 이를 가져오지 못하기 때문이었습니다.

5.2 해결 방법: React Router state 활용

모든 네비게이션에서 상태를 전달하도록 수정했습니다.

5.2.1 퀴즈 목록 → 퀴즈 상세

const handleQuizClick = (quizId: number) => { navigate(`/topics/${topicId}/quizzes/${quizId}`, { state: { currentPage, topicName, totalQuizCount, }, }); };

5.2.2 퀴즈 상세 → 퀴즈 결과

navigate(resultPath, { state: { selectedAnswer, isCorrect, quizData, currentPage: location.state?.currentPage, topicName: location.state?.topicName, totalQuizCount: location.state?.totalQuizCount, }, });

5.2.3 퀴즈 결과 → 다음 문제 (페이지 경계 처리)

const handleNextQuestion = () => { if (nextQuiz) { const currentPageIndex = currentPage ?? 0; const isNextQuizInNextPage = nextPageQuizListData?.quizzes?.some((q) => q.quizId === nextQuiz.quizId) ?? false; const nextPageForState = isNextQuizInNextPage ? currentPageIndex + 1 : currentPageIndex; navigate(nextPath, { state: { currentPage: nextPageForState, topicName: location.state?.topicName, totalQuizCount: totalQuizCount, }, }); } };
  • 현재 페이지의 마지막 문제에서 다음 문제로 이동 시 다음 페이지 인덱스로 업데이트
  • 뒤로가기 시 올바른 페이지로 복귀

5.2.4 퀴즈 목록 페이지에서 상태 복구

useEffect(() => { if (location.state?.currentPage !== undefined) { setCurrentPage(location.state.currentPage); } }, [location.state]);
  • location.state에서 currentPage를 복구해 사용자가 보던 페이지로 복귀

6. 다음 페이지 프리페치로 UX 개선

퀴즈 결과 페이지에서 다음 페이지 데이터를 미리 로드합니다.

const nextPageIndex = currentPageForQuery + 1; const hasNextPage = nextPageIndex < totalPages; const { data: nextPageQuizListData } = useQueryApi<QuizListResponse>( ['topics', topicId || '', nextPageIndex], `/topics/${topicId || ''}?page=${nextPageIndex}&size=10`, { enabled: !isRecordPage && !isReviewMode && hasNextPage }, );
  • 1.현재 페이지의 마지막 문제에서 다음 문제로 이동할 때 즉시 표시
  • 2.React Query 캐싱으로 중복 요청 방지

7. UI/UX 개선 사항

7.1 버튼 상태 관리

const hasPrevPage = currentPage > 0; const hasNextPage = currentPage < totalPages - 1;
  • 경계에서 버튼 비활성화로 명확한 피드백 제공

7.2 스타일링

const PageNumberButton = styled.button<{ $isActive: boolean }>` min-width: 36px; height: 36px; border: 1px solid ${(props) => (props.$isActive ? theme.colors.primary : '#e0e0e0')}; background: ${(props) => (props.$isActive ? theme.colors.primary : '#ffffff')}; color: ${(props) => (props.$isActive ? '#ffffff' : '#666666')}; // ... 기타 스타일 `;
  • 1.현재 페이지 강조로 위치 인지 용이

8. 성능 최적화

1. React Query 캐싱: 페이지별 캐싱으로 재방문 시 즉시 표시
2. 조건부 데이터 로딩: 필요한 경우에만 다음 페이지 프리페치
3. 스크롤 최적화: 페이지 전환 시 상단으로 이동해 렌더링 부담 감소

9. 향후 개선 방향

1. 첫/마지막 페이지 버튼: 페이지가 많아지면 « 처음, 마지막 » 버튼 추가
2. URL 쿼리 동기화: currentPage를 URL 쿼리에 반영해 공유/새로고침 대응
3. 가상화: 문제 수가 매우 많아지면 가상 스크롤 도입 검토

결론

사용자 경험을 우선해 페이지 버튼 방식의 오프셋 페이지네이션을 도입했습니다. 블록 페이지네이션과 상태 유지로 모바일에서도 사용하기 쉽고, 위치를 기억해 다시 찾아가기 편한 구조를 만들었습니다. 앞으로도 사용자 중심의 개선을 지속하겠습니다.