개발 일지 (Pagenation 도입하기)
Page 기능 도입의 이유
우리 서비스에서는, 관광지 목록을 조회하는 기능이 있다.
만약에 관광지가 총 6만개라고 쳤을 때, 모든 관광지를 다 조회한다고 가정해보자.
Page가 없다면 이 6만개가 다 response로 내려온다.
근데 사용자 입장에서 이 6만개가 바로 필요할까?
어차피 6만개를 전부다 보지 않을 텐데, 사용자가 볼 수 있는 일부만 보여주면 되지 않을까?
이러한 고민에서 나온 것이 바로 Page 처리이다.
Page를 도입하면 달라지는 구조
Page를 도입하면, 프론트에서 요청하는 구조가 달라지게 된다.
GET /api/attractions?keyword=전주&page=0&size=20
이러한 형태로, 프론트에서는 가져올 페이지의 번호, 그리고 가져올 개수를 추가로 요청하는 것이다.
이렇게 되면, "0번 페이지에서 데이터를 20개 가져와줘" 라는 뜻이 된다.
Pageable의 등장
프론트에서 요청을 받고 나면, 요구사항에 맞게 Page를 내놓아야한다.
그래서 우리는 Pageable 객체를 만들어서, 레포지토리에 조회할 때, 추가 파라미터로 넣는다.
Pageable은 Page 단위로 DB에서 가져오기 위해서, 필요한 page 관련 요구사항 객체와 같다.
페이지는 0번, size는 20개라는 요청 자체는 Pageable 객체를 만들 때 필요한 정보인 것이고,
이렇게 만들어진 Pageable 객체를 이용해서 최종적으로 repository에서 가져온다.
코드 뜯어보기
private Pageable createPageable(AttractionSearchRequest request) {
int page = request.getPage() == null || request.getPage() < 0
? DEFAULT_PAGE
: request.getPage();
int size = request.getSize() == null
? DEFAULT_SIZE
: Math.min(Math.max(request.getSize(), 1), MAX_SIZE);
return PageRequest.of(
page,
size,
Sort.by(Sort.Direction.DESC, "apiModifiedTime")
);
}
위의 코드를 보면, AttractionSearchRequest 요청의 page 정보와 size 정보를 이용해서 PageRequest.of라는 메서드로 Pabeable 객체를 만든다.
Page<Attraction> attractions = attractionRepository.findAll(
AttractionSpecification.search(condition),
pageable
);
위에서 만든 Pageable 객체를, 레포지토리 조회에서 pageable 파라미터로 넣어서 조회를 한다.
주의사항
public interface AttractionRepository extends JpaRepository<Attraction, Long>, JpaSpecificationExecutor<Attraction>
이때 주의사항은, Pageable 객체를 이용해서 레포지토리에서 조회해서 Page 데이터를 가져오기 위해서는, JpaSpecificationExecutor라는 인터페이스를 추가로 상속을 받아야 한다.
Pageable을 이용해서 Page<Attraction>을 받았다.
Page<Attraction>은 어떻게 생겼을까?
Page<Attraction> {
content = [
Attraction(id=1, name="전주한옥마을", address="전북 전주시 ..."),
Attraction(id=2, name="경기전", address="전북 전주시 ..."),
Attraction(id=3, name="전동성당", address="전북 전주시 ...")
],
pageable = {
pageNumber = 0,
pageSize = 20,
offset = 0
},
totalElements = 53,
totalPages = 3,
first = true,
last = false,
empty = false
}
Page<Attraction>의 의미는, Attraction 정보 + Page 정보인 것이다.
content에는, Attraction들이 List 형태로 나열되어있고,
pageable에는, 요청할 때 포함했던, 요청 페이지와 가져올 데이터의 개수가 포함되어있다.
그리고 중요한 점은, 전체 총 데이터의 개수, 전체 페이지의 개수, 이번 페이지가 처음인지, 마지막인지, 비었는지 알 수 있다.
그렇다면, 프론트 입장에서, 내가 조회하고 싶은 페이지가 몇번째에 있는지 어떻게 알 수 있을까?
프론트에서 요청할 때 주는 정보는 "원하는 페이지, 원하는 데이터 사이즈"이다.
그러면, 이 요청이 들어올 때, 백엔드에서는, 전체 데이터 개수 / 원하는 데이터 사이즈를 계산을 해서, 페이지로 나눈 다음에, 프론트가 원하는 페이지를 준다.
즉, 프론트에서 원하는 데이터 사이즈가 20이라고 가정하면,
0 ~ 20 : 0페이지
20 ~ 40 : 1페이지
40 ~ 60 : 2페이지
이런식으로 페이지가 자동으로 나뉘게 되고, 여기서 구분된 페이지 번호대로 프론트의 요청을 받아들이는 것이다.
일반 게시판 사이트를 보면, 게시글 목록에서 여러 페이지로 나뉘어져있는 것을 볼 수 있다.
만약에 프론트와 백엔드가 서로 회의를 통해서 "우리 한 페이지에 10개만 보여주자"고 이야기가 나왔다면, 전체 데이터 / 10을 해서, 페이지의 개수를 나눈 다음에, 그 페이지만큼 UI로 나타내면 되는 것이다.
사용자가 5번 페이지를 누르면, 프론트에서는 백으로 "5번 페이지, 사이즈는 10"을 날리면 된다.
Page<Attraction>으로 받은 객체는,
public class AttractionPageResponse {
private List<AttractionListResponse> content;
private int page;
private int size;
private long totalElements;
private int totalPages;
private boolean first;
private boolean last;
private boolean empty;
public static AttractionPageResponse from(Page<AttractionListResponse> page) {
return AttractionPageResponse.builder()
.content(page.getContent())
.page(page.getNumber())
.size(page.getSize())
.totalElements(page.getTotalElements())
.totalPages(page.getTotalPages())
.first(page.isFirst())
.last(page.isLast())
.empty(page.isEmpty())
.build();
}
}
public class AttractionListResponse {
private Long attractionId;
private String contentId;
private String name;
private String address;
private String thumbnailImageUrl;
private String contentTypeId;
private BigDecimal latitude;
private BigDecimal longitude;
private List<AttractionAccessibilityResponse> accessibility;
public static AttractionListResponse from(
Attraction attraction,
List<AttractionAccessibilityResponse> accessibility
) {
return AttractionListResponse.builder()
.attractionId(attraction.getId())
.contentId(attraction.getContentId())
.name(attraction.getName())
.address(attraction.getAddress())
.thumbnailImageUrl(attraction.getThumbnailImageUrl())
.contentTypeId(attraction.getContentTypeId())
.latitude(attraction.getLatitude())
.longitude(attraction.getLongitude())
.accessibility(accessibility)
.build();
}
public static AttractionListResponse from(Attraction attraction) {
return from(attraction, List.of());
}
}
이렇게 우리만의 Response로 변환하여, 최종적으로 controller에게 넘겨준다.
Page<Attraction>은, 레포지토리에서 조회한 데이터 그자체이고, AttractionPageResponse는, Page<Attraction>에서 최종 응답에 필요한 필드만 추출한 것이다.
Pageable을 만들 때 주의할 점
앞에서 보면 알 수 있겠지만, 방어를 하지 않으면 프론트에서 보낸 요청을 곧이곧대로 레포지토리에 던져버릴 수 있다.
예를 들면, 총 데이터의 개수가 200개인데, "2번페이지 사이즈 300개 줘." 라고 부탁하면 어떻게 될까?
이렇게 되면 그냥 빈페이지가 나온다.
백엔드에서는 이런 프론트 요청에 대해서 어느정도 방어를 할 필요가 있다.
DEFAULT_SIZE와 MAX_SIZE를 두어서, 최대로 요청할 수 있는 사이즈를 정해두는 것이다. 그렇지 않으면, 프론트에서 잘못 요청을 보내서, 수천개씩의 데이터가 한번에 조회되는 불상사가 생길 수 있기 때문에 주의해야한다.
마무리하며
결국 Page는 단순히 데이터를 나눠서 조회하기 위한 타입이 아니라, 목록 API를 안정적으로 설계하기 위한 도구라고 볼 수 있다. 현재 페이지의 데이터뿐만 아니라 전체 데이터 개수, 전체 페이지 수, 첫 페이지와 마지막 페이지 여부까지 함께 제공해주기 때문에 프론트엔드에서도 페이지 UI를 구성하기 쉬워진다.
이번 관광지 목록 API에서는 Pageable로 조회 범위와 정렬 기준을 정하고, Repository에서 Page<Attraction>을 반환받은 뒤, Page.map()을 통해 Page<AttractionListResponse>로 변환했다. 덕분에 엔티티를 직접 노출하지 않으면서도 페이징 메타 정보는 그대로 유지할 수 있었다.
페이징 처리는 처음에는 단순히 page, size만 넘기면 되는 기능처럼 보이지만, 실제로는 size 제한, 정렬 기준, DTO 변환, 빈 페이지 처리, 1:N 연관관계 조회 방식까지 함께 고려해야 한다. 이번 구현을 통해 목록 API에서 페이징은 단순한 조회 옵션이 아니라, 성능과 응답 구조를 함께 설계하는 과정이라는 점을 배울 수 있었다.
'개인 프로젝트 > modu' 카테고리의 다른 글
| [daon] 12일차 개발 회고록 (0) | 2026.07.05 |
|---|---|
| [daon] 9일차 개발 회고록 (0) | 2026.06.14 |
| [daon] 8일차 개발 회고록 (0) | 2026.06.10 |
| [daon] 7일차 개발 회고록 (0) | 2026.06.07 |
| [daon] 6일차 개발 회고록 (0) | 2026.05.29 |