티스토리 뷰

개발 환경
Spring Boot 3.4.5
Spring Boot JPA 3.4.5
Hibernate 6.6.13
PostgreSQL 15
Java 17
IntelliJ
MacOS
처음 국비교육 들었을 때도 Mybatis를 이용했고, 와 정말 어렵다고 생각했는데 생각보다 별로 아니였었던..

인프런 강의도 JPA 관련해서 듣고, 개인적인 프로젝트도 JPA를 공부하는 와중에
어떻게 하면 프론트엔드 개발자 분이나 내가 편하게 사용할 수 있을까? 라는 생각을 하게 되었습니다..
사용하는 것이 점점 줄어드는 MyBatis의 페이징
물론 레거시한 것은 맞지만 아직 현.역 이라고 부르고 싶습니다.
과, 부장님들은 JPA를 새로 배우는 것보단 그냥 익숙하고 빨리빨리 쳐낼 수 있는 것으로 그냥 사용하기 때문이라고 생각이 들기 때문입니다.
그래서 간단하게 사용법을 보여주면 아 그땐 그랬지 or 오호 얘는 이렇게 쓰네? 처럼 보시면 될 것 같습니다.
저는 요청Dto, 응답Dto를 나누지 않고 DTO를 1개로 사용하는 방법으로 작성하겠습니다.
Dto, Controller, XML(쿼리) 만 간단하게 봅시다!
페이징 DTO
@Getter
@Setter
@EqualsAndHashCode(callSuper=false)
@NoArgsConstructor
@AllArgsConstructor
public class PageDto {
private int pageSize; // 한 페이지에 보여줄 게시글 수
private int pageNo; // 페이지 번호
private String searchField; // 검색 조건
private String searchValue; // 검색어
}
BoardSearchDto
특이점은 PageDto를 상속 받았습니다.
public class BoardSearchDto extends PageDto {
private String title;
private String content;
}
Controller
특이점은 예외 처리는 제외하며 정말 데이터 그 자체로 응답하기 위해 간단하게 작성하였습니다.
또한, 눈에 보이는건 totalCount와 list가 보이는데요!
데이터의 총 개수와 내가 조회한 데이터들을 응답하는 것을 볼 수 있습니다.
@GetMapping("/list")
public ResponseEntity<Map<String, Object>> boardList(@ModelAttribute BoardSearchDto boardSearchDto) {
int totalCount = boardService.getBoardTotalCount(boardSearchDto);
List<BoardDto> list = boardService.getBoardList(boardSearchDto);
Map<String, Object> response = new HashMap<>();
response.put("totalCount", totalCount);
response.put("list", list);
return ResponseEntity.ok(response);
}
아~ 페이징을 하면 총 개수와 데이터를 응답해야 하는구나~

라고 만 생각하지말고 왜 페이징 데이터를 내려주는데 전체 개수가 왜 필요한거지?
항상 페이징 데이터 호출할 때마다 2번 DB 커넥션을 맺고 끊네? 를 궁금해하고 알아 차리시면 좋겠습니다!
여러가지 이유는 있을 수 있겠지만 전체 개수가 필요한 이유는
- UI (클라이언트) 단에서 page와 pageSize를 알고 있기 때문에 전체 페이지 수 계산이 가능해 집니다.
- 보통 화면 하단에 페이지 네비게이션을 구현할 수 있게 됩니다.
- 사용자가 현재 보고 있는 데이터의 규모를 알 수 있기 때문에 UX가 개선됩니다.
쿼리 XML
조회할 때 각 필드를 작성해야 하지만 우선 예시를 들어주기 위해 *로 나타내었습니다.
또한, 페이징 데이터를 나타내기 위해 OFFSET, LIMIT을 항상 두어야 했습니다.
지금은 PorstgreSQL이라 괜찮은데 Oracle이면 끔찍..
<select id="boardListSelect" resultType="패키지.board.BoardDto">
SELECT *
FROM tbl_board A
OFFSET #{pageSize} * (#{pageNo} - 1)
LIMIT #{pageSize};
</select>
그럼 JPA를 사용할 때 어떻게 요청하는가? 와 어떻게 응답하는가? 를 보도록 하겠습니다.
JPA로 페이징 요청을 받을 때 어떻게 받을 것인가?
Spring MVC에는 페이징 객체를 받을 수 있습니다!
package org.springframework.data.domain;
public interface Pageable {
.. 내부 생략
}
즉, 아래와 같이 Controller를 구현해두고
@GetMapping("/api/product")
public Slice<ProductListRes> getProducts(Pageable pageable) {
return productService.getProductList(pageable);
}
아래와 같이 호출하면 스프링에서 해당 파라미터를 받을 수 있습니다!!
GET {{domain}}/api/product?page=1&size=10
Tip) Page는 항상 0부터 시작됩니다.

이렇게 그냥 사용할까? 고민이 되는...
처음에 생각했을 때는 프로젝트 개발할 때 그냥 저렇게 사용해서 페이징이 필요한 API는 편하게 개발하면 되는 것 아닌가?
근데 실무에서는 "중요한 것이 생각보다 필터링 해야할 것이 많다." 와 "API 문서화가 정말정말 중요하다." 입니다.
pageable로 있으면 Swagger는 어찌어찌 할 수 있겠지만 필터링하기 위해 추가적으로 받아야하는 데이터가 생기므로
컨트롤러의 시그니처(?)가 길어질 수 있겠구나라고 생각하였습니다.
예시로 보자면 아래와 같습니다.
@GetMapping("/boards")
public Page<Board> getBoards(BoardSearchDto dto, Pageable pageable) {
return boardService.search(dto, pageable);
}
여러분도 항상 dto와 pageable을 항상 받고 Service로 넘긴다?
뭐 뭐든 정답은 없겠지만 굳이 2개로 나눌 필요가 있는가? 그리고 그냥 짜친다 라는 생각이 듭니다.
나 같으면 공통으로 사용할 수 있게 만들어 버려~
페이징 추상 클래스
@Getter
@Setter
public abstract class BasePageRequest {
private int page = 0;
private int size = 10;
private String sortBy = "id";
private String direction = "DESC";
public Pageable toPageable() {
Sort sort = Sort.by(Sort.Direction.fromString(direction), sortBy);
return PageRequest.of(page, size, sort);
}
}
요청 DTO
@Getter
@Setter
public class BoardSearchDto extends BasePageRequest {
private String title;
private String category;
}
개선된 Controller
@GetMapping("/boards")
public Page<BoardDto> getBoards(BoardSearchDto dto) {
return boardService.search(dto);
}
Service
@Service
@RequiredArgsConstructor
public class BoardService {
private final BoardRepository boardRepository;
public Page<BoardDto> search(BoardSearchDto dto) {
Pageable pageable = dto.toPageable();
return boardRepository.search(dto, pageable)
.map(BoardDto::fromEntity);
}
}
자 이제 아래와 같이 호출하면 필터링 + 정렬까지 할 수 있습니다 ㅎㅎㅎㅎ
검색 조회하는 동적 쿼리는 QueryDsl을 이용해서 해야하는데 여기서는 다루지 않을 겁니다.
GET /boards?page=0&size=10&title=Spring&sortBy=createdDate&direction=ASC
요청을 받은 뒤 JPA로 페이징 데이터 조회할 여러가지 타입들
여러분들은 응답 데이터로 무.조.건 아래의 타입들을 보았을 것 입니다.
- Page<T>
- Slice<T>
- List<T>

Page<T>
@Transactional(readOnly = true)
public Page<ProductListRes> getProductList(ProductSearchDto dto) {
Pageable pageable = dto.toPageable();
Page<Product> products = productRepository.findAll(pageable);
return products.map(product -> ProductListRes.from(product, 0));
}
findAll() 메서드에 Pageable 객체를 넣어서 호출했고 그것에 받는 타입을 Page<T>로 잡았습니다.!!
정말 특이한 점은 MyBatis 때 했던 총 데이터 쿼리문을 따로 안줘도 총 데이터 쿼리가 나간 것을 확인 할 수 있습니다!!

이 쿼리 아래는 N+1 쿼리가 나가서 여러개 쿼리 호출된건 안비밀..
N+1는 차후에 천천히 다뤄보도록 하겠습니다. ㅠㅠ

자 이제 Page 객체를 응답하여 포스트맨에서 확인한 결과 아래와 같습니다.
따로 정리하는 것보다 주석으로 설명을 추가하여 즉시 확인 할 수 있게 하였습니다.
{
"content": [
{
"id": 1,
"productName": "[1+1/인사이드아웃2] 닥터지 레드 블레미쉬 클리어 수딩 토너 300ml 1+1 기획",
"productCode": "P00000001",
"totalStock": 0,
"brandName": "닥터지",
"categoryName": "스킨/토너",
"productStatus": "PREORDER"
},
... 생략
],
"pageable": {
"pageNumber": 0, // 페이지 번호
"pageSize": 10, // 페이지 크기
"sort": { // 현재 정렬 정보 (필요없음)
"empty": true,
"sorted": false,
"unsorted": true
},
"offset": 0,
"paged": true,
"unpaged": false
},
"last": false, // 마지막 페이지 여부
"totalPages": 205, // 총 페이지 수
"totalElements": 2046, // 전체 데이터 개수
"first": true, // 첫번째 페이지 여부
"size": 10, // 페이지 당 데이터 수
"number": 0, // 현재 페이지 번호
"sort": { // 현재 정렬 정보 (필요없음)
"empty": true,
"sorted": false,
"unsorted": true
},
"numberOfElements": 10, // 현재 페이지에 담긴 데이터 수
"empty": false // 현재 페이지가 비어 있는지 여부
}
이걸 보면 굳이 이렇게 응답할 필요가 있나 싶습니다.
되게 불필요한 데이터를 응답해주는 것인데 정말 필요한 것은
"데이터", "페이지 번호", "페이지 크기", "총 페이지 수", "총 데이터 수" 만 있어도 충분하다고 생각합니다.
Slice<T>
@Transactional(readOnly = true)
public Slice<ProductListRes> getProductList(ProductSearchDto dto) {
Pageable pageable = dto.toPageable();
Slice<Product> products = productRepository.findAllBy(pageable);
return products.map(product -> ProductListRes.from(product, 0));
}
이렇게 사용하면 JPA 메서드인 findAll에서 Page<T>로 반환해주기 때문에 JPA메서드에 새롭게 만들어줘야합니다.
왜냐하면... 내가 Slice로 받고 싶어도 JPA에선 Page로 주고 있었기 때문입니다.
public interface Page<T> extends Slice<T> {
int getTotalPages(); //전체 페이지 수
long getTotalElements(); //전체 데이터 수
<U> Page<U> map(Function<? super T, ? extends U> converter); //변환기
}
// JPA 메서드인 이걸 타고 있다.
Page<T> findAll(Pageable pageable);
그래서 아래와 같이 메서드를 새롭게 정의해주었습니다.
public interface ProductRepository extends JpaRepository<Product, Long> {
Slice<Product> findAllBy(Pageable pageable);
}
이것도 정말 특이하게 나는 페이지 개수 10개를 호출했는데 10 + 1 이 호출 되었습니다.!!!!
왜냐하면 Slice는 더보기 형식 느낌이 더 강하며 다음 데이터가 있는지 체크 및 마지막 페이지인지 여부를 찾기 위함이 더 강합니다.

자 이제 Slice 객체를 응답하여 포스트맨에서 확인한 결과 아래와 같습니다.
따로 정리하는 것보다 주석으로 설명을 추가하여 즉시 확인 할 수 있게 하였습니다.
{
"content": [
{
"id": 16,
"productName": "[1+1/인사이드아웃2] 닥터지 레드 블레미쉬 클리어 수딩 토너 300ml 1+1 기획",
"productCode": "P00000016",
"totalStock": 0,
"brandName": "닥터지",
"categoryName": "스킨/토너",
"productStatus": "PREORDER"
},
... 생략
],
"pageable": {
"pageNumber": 0, // 페이지 번호
"pageSize": 10, // 페이지 크기
"sort": { // 현재 정렬 정보 (필요없음)
"empty": true,
"unsorted": true,
"sorted": false
},
"offset": 0,
"unpaged": false,
"paged": true
},
"first": true, // 첫번째 페이지 여부
"last": false, // 마지막 페이지 여부
"size": 10, // 페이지 당 데이터 수
"number": 0, // 현재 페이지 번호
"sort": { // 현재 정렬 정보 (필요없음)
"empty": true,
"unsorted": true,
"sorted": false
},
"numberOfElements": 10, // 현재 페이지에 담긴 데이터 수
"empty": false // 현재 페이지가 비어 있는지 여부
}
이것 또한 보면 굳이 이렇게 응답할 필요가 있나 싶습니다.
되게 불필요한 데이터를 응답해주는 것인데 정말 필요한 것은
"데이터", "페이지 번호", "페이지 크기", "다음 페이지 존재 여부", "이전 페이지 존재 여부" 만 있어도 충분하다고 생각합니다.
List<T>
@Transactional(readOnly = true)
public List<ProductListRes> getProductList(Pageable pageable) {
List<Product> products = productRepository.findAllBy(pageable);
return products.stream()
.map(product -> ProductListRes.from(product, 0))
.collect(Collectors.toList());
}
거의 대부분 이렇게 사용하고 계실 겁니다.
그냥 데이터만 조회한 것입니다. 이렇게 사용하면 MyBatis처럼 Count 쿼리도 추가적으로 필요합니다.
마무리
여러분들은 페이징 전략과 데이터를 어떻게 사용하고 계신가요?
저는 Page<T>를 사용하여 넘겨주면 될 것이라고 생각했지만, 정말 불필요한 데이터까지 넘어가기 때문에
응답 데이터에 Page 관련 필드를 넣고 그걸 변환시켜서 응답해주면 될 것 같습니다.
MyBatis에서 사용한 (데이터 + 카운트 쿼리) 전략처럼 정말 조인이 많이 필요한 데이터를 페이징 할 때
QueryDsl로 데이터를 조회하고 카운트 쿼리도 추가적으로 설계하면 될 것같습니다.
레거시한 전략이 아니였고, 선배님들이 최적화하여 사용하는 전략이였습니다. ㅎㅎㅎ
Page<T> 객체와 Slice<T> 객체는 알아두면 정말 편할 것 같습니다.

자 모두 퇴근하세요~

감사합니다.
'백엔드 > 🌸Spring' 카테고리의 다른 글
| [Spring] schema.sql, data.sql이 적용 안될 때와 그에 맞는 JPA ddl-auto 설정 (0) | 2025.12.29 |
|---|---|
| [Spring] 여러 개 기동하는 멀티 모듈 기초 세팅 (1) | 2025.09.04 |
| [Spring] API 서버에서 카카오 로그인 구현 (2/2) : 토큰 받고 사용자 정보 조회 (1) | 2025.06.15 |
| [Spring] API 서버에서 카카오 로그인 구현 (1/2) : 인가 코드 받기 (1) | 2025.05.19 |
| [Spring] 멀티 모듈 설계 : 도메인, API, 테스트 환경 세팅 전략까지 (1) | 2025.05.13 |
- Total
- Today
- Yesterday
- DBeaver
- 프로세스
- 카카오 로그인
- 데이터 베이스
- Fetch
- 알고리즘
- Flutter
- DART
- 디자인패턴
- 개발블로그
- aws
- 개발환경
- 개발자
- Front
- Cors
- BFS
- JPA 페이징
- 시간 객체
- java
- 계단 오르기
- 실시간 채팅
- JavaScript
- 멀티모듈
- 그리디
- 소셜로그인
- spring
- 깃허브 액션
- 네트워크
- 코딩테스트
- 트랜잭션
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 | 31 |
