티스토리 뷰

스프링 로고

 

개발 환경

 

Spring Boot 3.4.5
Spring Boot JPA 3.4.5
Hibernate 6.6.13
PostgreSQL 15
Java 17
IntelliJ
MacOS

 

 

 

 

 

처음 국비교육 들었을 때도 Mybatis를 이용했고, 와 정말 어렵다고 생각했는데 생각보다 별로 아니였었던..

 

박명수 머쓱
[그림1] 박명수 머쓱

 

인프런 강의도 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] 이광수

라고 만 생각하지말고 왜 페이징 데이터를 내려주는데 전체 개수가 왜 필요한거지?

항상 페이징 데이터 호출할 때마다 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부터 시작됩니다.

 

유재석 놀래는 표정
[그림3] 유재석 놀래는 표정

 

이렇게 그냥 사용할까? 고민이 되는...

처음에 생각했을 때는 프로젝트 개발할 때 그냥 저렇게 사용해서 페이징이 필요한 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>

이수근 아예 모름
[그림4] 이수근 아예 모름

 

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 때 했던 총 데이터 쿼리문을 따로 안줘도 총 데이터 쿼리가 나간 것을 확인 할 수 있습니다!!

 

[그림5] Page는 전체 데이터 수 쿼리가 호출된다.

이 쿼리 아래는 N+1 쿼리가 나가서 여러개 쿼리 호출된건 안비밀..

N+1는 차후에 천천히 다뤄보도록 하겠습니다. ㅠㅠ

피오 가마 이써
[그림6] 피오 가마 이써

 

 

자 이제 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 호출한 수 + 1 호출
[그림7] Slice 호출한 수 + 1 호출

자 이제 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> 객체는 알아두면 정말 편할 것 같습니다.

 

퇴근
[그림8] 퇴근

 

자 모두 퇴근하세요~

감사합니다.

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/12   »
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
글 보관함