
💬 왜 페이지네이션이 필요했나?
이전 게시글에서 견적서 전체 조회 API을 JMeter로 성능 테스트를 진행했습니다. PostDM 프로젝트에서 견적서 전체 조회 API는 수천~수만 개의 데이터를 반환할 수 있습니다. 초기에는 단순히 전체 데이터를 리스트로 한 번에 내려주는 방식이었고, 그로 인해 다음과 같은 문제가 발생했습니다.
- 응답 시간 급증: 견적서 데이터가 10,000건인 경우 API 응답 시간이 최대 3~4초까지 증가
- 메모리 사용량 급증: 전체 조회 응답 크기 약 1.5MB로 한 번에 모든 데이터를 메모리에 로드하면서 서버 부하 증가
이러한 문제를 해결하기 위해 페이지네이션(Pagination) 도입을 결정했습니다.
페이지네이션이란?
페이지네이션은 대량의 데이터를 일정한 크기의 청크(chunk)로 나누어 조회하는 기법입니다. 데이터베이스 레벨에서부터 클라이언트까지 전체 데이터 흐름을 최적화하는 패턴입니다. 프론트엔드는 몇 번째 페이지를 원하는지, 백엔드는 해당 범위의 데이터만 쿼리해 반환합니다.
주요 용어
- Page: 현재 조회하고 있는 페이지 번호 (0부터 시작)
- Size: 한 페이지에 포함될 데이터 개수
- Total Elements: 전체 데이터 개수
- Total Pages: 전체 페이지 수
- Offset: 건너뛸 데이터 개수 (Page × Size)
Total Pages = ⌈Total Elements ÷ Size⌉
Offset = Page × Size
페이지네이션 구현 방식 (Offset vs Cursor)
페이지네션을 구현하는 방식은 주로 Offset 방식과 Cursor 방식을 사용합니다. 먼저 두 방식의 특징과 장단점을 정리하고, 현재 프로젝트에서는 어떤 방식이 적합한지 결정하고자 합니다.
Offset 기반 페이지네이션
Offest 기반 페이지네이션은 사용자가 요청한 페이지 번호와 페이지당 데이터 개수로 OFFSET과 LIMIT을 계산해 데이터를 조회합니다.
장점
- 구현이 간단하고 직관적
- 임의의 페이지로 점프 가능
- Spring Data JPA에서 기본 지원
단점
- 대용량 데이터에서 OFFSET이 클수록 성능 저하
- 데이터 변경 시 중복/누락 가능성
SELECT * FROM estimates
ORDER BY created_at DESC
LIMIT 10 OFFSET 20;
Cursor 기반 페이지네이션
Cursor 기반 페이지네이션은 이전 요청에서 받은 마지막 데이터의 고유 식별자(커서)를 기준으로 그 이후(또는 이전)의 데이터를 조회합니다.
장점
- 데이터 양과 무관하게 일정한 성능
- 데이터 변동에도 중복/누락 없음
- 실시간 데이터 변경에 안정적
- 무한 스크롤에 적합
단점
- 구현 복잡도 증가
- 임의의 페이지로 점프 불가능
- 정렬 기준이 고유해야 함
SELECT * FROM estimates
WHERE created_at < '2025-01-01 10:30:00'
ORDER BY created_at DESC
LIMIT 10;
프로젝트에서 사용할 방식은?
PostDM 프로젝트에서는 다음 이유로 Offset 기반 페이지네이션을 선택했습니다.
- 페이지 네비게이션 방식의 디자인: 페이지 번호를 통한 직접 접근 필요
- 데이터 규모: 현재 데이터량(약 10,000건)에서는 성능상 문제없음
- 개발 생산성: Spring Data JPA의 Pageable 인터페이스 활용 가능
프로젝트에서 페이지네이션 구현
현재 프로젝트에서는 Spring Data JPA를 사용하여 이를 활용해 다음과 같이 페이지네이션을 구현했습니다.
[Feature] 견적서 조회 페이지네이션 적용 by rimeir · Pull Request #69 · fullstack-dev-hub/postdm
📌 개요 견적서 조회 API(GET /api/v1/estimates)에 페이지네이션 기능을 적용했습니다. 관리자와 사용자 모두 페이지 기반으로 견적서가 조회되며, 기존 전체 리스트 반환 방식은 폐기합니다. 🚀 관
github.com
Controller 계층
- @PageableDefault: 기본 페이지, 크기, 정렬 기준을 명시
- Pageable: Spring에서 자동 주입되는 페이징 요청 정보
- 응답은 Page<T> → PageResponse<T>로 감싸서 프론트에서 파싱하기 쉽게 반환
@Operation(summary = "견적서 조회", description = "관리자는 모든 견적서를, 사용자는 본인의 견적서만 조회합니다.")
@GetMapping
public ResponseEntity<ResponseTemplate<PageResponse<EstimateListResponseDto>>> getEstimates(
@AuthenticationPrincipal MemberPrincipalDto currentUser,
@PageableDefault(page = 0, size = 6, sort = {"id"}, direction = Sort.Direction.DESC) Pageable pageable) {
Page<EstimateListResponseDto> page = estimateService.getEstimates(currentUser, pageable);
PageResponse<EstimateListResponseDto> response = PageResponse.from(page);
return ResponseEntity
.status(HttpStatus.OK)
.body(new ResponseTemplate<>(HttpStatus.OK, "견적서 조회 성공", response));
}
Service 계층
- 역할(ADMIN vs USER)에 따라 리포지토리 조회 방식 분기
- Page<Estimate> → Page<EstimateListResponseDto>로 DTO 변환
public Page<EstimateListResponseDto> getEstimates(MemberPrincipalDto principal, Pageable pageable) {
Member member = findMemberOrThrow(principal.getId());
Page<Estimate> page = (member.getRole() == MemberRole.ADMIN)
? adminPolicy.getEstimates(member, estimateRepository, pageable)
: userPolicy.getEstimates(member, estimateRepository, pageable);
return page.map(EstimateListResponseDto::from);
}
Repository 계층
- Spring Data JPA에서 제공하는 Pageable 기반 메서드 사용
- 내부적으로 LIMIT, OFFSET, ORDER BY 쿼리 자동 생성
Page<Estimate> findAll(Pageable pageable); // 관리자용
Page<Estimate> findByMember(Member member, Pageable pageable); // 사용자용
커스텀 PageResponse DTO
- 기본 Page<T>는 프론트에 너무 많은 정보(중첩된 구조 포함)를 제공
- 따라서 필요한 정보만 커스터마이징하여 PageResponse DTO로 제공
- SortInfo는 정렬된 필드를 명시적으로 전달 (ex: id, DESC)
@Getter
@Builder
@AllArgsConstructor
public class PageResponse<T> {
private List<T> content; // 실제 데이터
private int page; // 현재 페이지 (0-based)
private int size; // 페이지 크기
private long totalElements; // 전체 요소 수
private int totalPages; // 전체 페이지 수
private boolean first; // 첫 페이지 여부
private boolean last; // 마지막 페이지 여부
private List<SortInfo> sort; // 정렬 정보
// Spring Data의 Page 객체를 PageResponse로 변환
public static <T> PageResponse<T> from(Page<T> page) {
List<SortInfo> sortInfo = page.getSort().stream()
.map(SortInfo::new)
.collect(Collectors.toList());
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast(),
sortInfo
);
}
@Getter
@AllArgsConstructor
public static class SortInfo {
private String property;
private String direction;
public SortInfo(Sort.Order order) {
this.property = order.getProperty();
this.direction = order.getDirection().name();
}
}
}
성능 테스트 및 결과
[Docs] 견적서 조회 API 페이지네이션 적용 성능 테스트 리포트 · Issue #70 · fullstack-dev-hub/postdm
📌 개요 /api/v1/estimates API에 페이지네이션을 적용하여 전체 조회 방식의 성능 문제를 해결하고, 사용자와 관리자 모두 효율적으로 데이터를 조회할 수 있도록 개선했습니다. ✅ 변경 전 문제점
github.com
| 구분 | 적용 전 | 적용 후 | 개선율 |
| 평균 응답시간 | 1,196ms | 14ms | 98.8% 개선 |
| 최소 응답시간 | 92ms | 7ms | 92.4% 개선 |
| 최대 응답시간 | 3,401ms | 39ms | 98.9% 개선 |
| 표준편차 | 602.33 | 4.60 | 99.2% 개선 |
| 처리량(req/s) | 56.26 | 99.70 | 77.2% 향상 |
| 평균 응답 크기 | 1,483KB | 1.7KB | 99.9% 감소 |
- 견적서 리스트 API에 대해 페이지네이션을 적용하여 10,000건 전체 조회 시 평균 1,196ms 걸리던 응답 속도를 14ms로 줄였습니다.
- 표준편차를 602.3에서 4.60으로 줄어들어 응답 시간을 안정적으로 최적화했습니다.
- 평균 응답 크기를 1.5MB에서1.7KB로 줄여 네트워크 대역폭 절약했습니다.
- 최대 응답 시간을 3.4초에서 0.039초로 줄여 사용자 경험을 향상시켰습니다.
💬 마무리하며
페이지네이션을 프로젝트에 적용해 보고 테스트를 진행해 보면서,데이터베이스 설계부터 사용자 인터페이스까지 전체 시스템에 영향을 미치는 중요한 기술이라는 것을 실감했습니다. 이번 경험을 통해 성능 테스트를 해야 하는 이유와 최적화의 중요성을 다시 한번 깨달을 수 있었습니다.
페이지네이션을 적용하고 나서 아래와 같은 고려해야 할 트러블슈팅을 찾았습니다. 이런 문제들도 함께 고려하여 최적화하는 것을 목표로 하고 있습니다.
- N+1 문제 해결
- 모바일 사용자 경험을 위한 무한 스크롤 도입 고려
- 웹 버전: 페이지 네비게이션 (정확한 위치 파악 필요)
- 모바일 버전: 무한 스크롤 (사용자 경험 우선)
'프로젝트' 카테고리의 다른 글
| Redis란 무엇인가?: Spring Boot에 캐시로 적용해보기 (1) | 2025.07.10 |
|---|---|
| JMeter를 활용한 성능 테스트 (1) | 2025.06.18 |
| Access Token 재발급: Refresh Token의 역할 (0) | 2025.05.13 |
| JWT 기반 인증: 로그아웃은 어떻게 구현할까? (0) | 2025.04.17 |
| 팀 디스코드 봇 만들기(feat. Docker 활용) (1) | 2025.01.15 |