본문 바로가기

JPA

[Spring Data JPA] WEB 확장 기능, 페이지 1처리 코드 구현

Web 확장 - 도메인 클래스 컨버터

HTTP 파라미터로 넘어온 엔티티의 아이디로 엔티티 객체를 찾아서 바인딩

 

**도메인 클래스 컨버터 사용 전** 

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/members/{id}")
    public String findMember(@PathVariable Long id) {
        Member member = memberRepository.findById(id).get();
        return member.getUsername();
    }

    @PostConstruct
    public void init() {
        memberRepository.save(new Member("userA"));
    }
}

 

**도메인 클래스 컨버터 사용 후**

@RestController
@RequiredArgsConstructor
public class MemberController {

    private final MemberRepository memberRepository;

    @GetMapping("/members/{id}")
    public String findMember(@PathVariable("id") Member member) {
        return member.getUsername();
    }

    @PostConstruct
    public void init() {
        memberRepository.save(new Member("userA"));
    }
}
  • HTTP 요청은 회원 `id` 를 받지만 도메인 클래스 컨버터가 중간에 동작해서 회원 엔티티 객체를 반환
  • 도메인 클래스 컨버터도 리파지토리를 사용해서 엔티티를 찾음

주의!: 도메인 클래스 컨버터로 엔티티를 파라미터로 받으면, 이 엔티티는 단순 조회용으로만 사용해야 한다.

(트랜잭션이 없는 범위에서 엔티티를 조회했으므로, 엔티티를 변경해도 DB에 반영되지 않는다.)

그리고 PK를 외부에 공개하는 경우는 많지 않고, 쿼리도 단순하게 돌아가는 경우도 많지 않으므로 간단한 경우에만 쓸 수 있다.


Web 확장 - 페이징과 정렬

스프링 데이터가 제공하는 페이징과 정렬 기능을 스프링 MVC에서 편리하게 사용할 수 있다.

 

**페이징과 정렬 예제**

@GetMapping("/members")
 public Page<Member> list(Pageable pageable) {
     Page<Member> page = memberRepository.findAll(pageable);
     return page;
 }
 
 @PostConstruct
    public void init() {
        for (int i = 0; i < 100; i++) {
            memberRepository.save(new Member("user" + i, i));
        }
    }
  • 파라미터로 `Pageable` 을 받을 수 있다.
  • `Pageable` 은 인터페이스, 실제는 `org.springframework.data.domain.PageRequest` 객체 생성
  • 다음 프로젝트를 할 때 DTO에 현재 page 필드를 추가해서 API를 만들어보자.

**1. 요청 파라미터**

예) /members?page=0&size=3&sort=id,desc&sort=username,desc
  • page: 현재 페이지, **0부터 시작한다.**
  • size: 한 페이지에 노출할 데이터 건수
  • sort: 정렬 조건을 정의한다. (ASC | DESC), 정렬 방향을 변경하고 싶으면 `sort` 파라 미터 추가 (asc 생략 가능)

**2. 기본값**

// 글로벌 설정: 스프링 부트
spring.data.web.pageable.default-page-size=20 /# 기본 페이지 사이즈/
spring.data.web.pageable.max-page-size=2000 /# 최대 페이지 사이즈/

**3. 개별 설정**

@GetMapping("/members")
    public Page<Member> list(@PageableDefault(size = 5, sort = "username") Pageable pageable) {
        Page<Member> page = memberRepository.findAll(pageable);
        return page;
    }

@RequestMapping(value = "/members_page", method = RequestMethod.GET)
public String list(@PageableDefault(size = 12, sort = "username",
direction = Sort.Direction.DESC) Pageable pageable) {
    ... 
    }
  • `@PageableDefault` 어노테이션을 사용

**4. 접두사**

public String list(
     @Qualifier("member") Pageable memberPageable,
     @Qualifier("order") Pageable orderPageable, ...
  • 페이징 정보가 둘 이상이면 접두사로 구분
  • `@Qualifier` 에 접두사명 추가 "{접두사명}_xxx"
  • 예제: /members?member_page=0&order_page=1

**5. Page 내용을 DTO로 변환하기**

  • 엔티티를 API로 노출하면 다양한 문제가 발생한다. 그래서 엔티티를 꼭 DTO로 변환해서 반환해야 한다.
  • Page는 `map()` 을 지원해서 내부 데이터를 다른 것으로 변경할 수 있다.

Member DTO

@Data
 public class MemberDto {
     private Long id;
     private String username;
     
     public MemberDto(Member m) {
         this.id = m.getId();
         this.username = m.getUsername();
    } 
}
@GetMapping("/members2")
public Page<MemberDto> listDto(@PageableDefault(size = 5, sort = "username") Pageable pageable) {
    Page<Member> page = memberRepository.findAll(pageable);
    Page<MemberDto> memberDtoPage = 
        page.map(member -> new MemberDto(member.getId(), member.getUsername());
    return memberDtoPage;
}

Page.map() 코드 최적화

 @GetMapping("/members")
 public Page<MemberDto> list(Pageable pageable) {
     return memberRepository.findAll(pageable).map(MemberDto::new);
 }

 

**6. Page1부터 시작하기**

스프링 데이터는 Page0부터 시작한다. 만약 1부터 시작하려면?

 

1. Pageable, Page를 파리미터와 응답 값으로 사용하지 않고, 직접 클래스를 만들어서 처리한다.

  • 그리고 직접 PageRequest(Pageable 구현체)를 생성해서 리포지토리에 넘긴다.
  • 물론 응답값도 Page 대신에 직접 만들어서 제공해야 한다.

2. spring.data.web.pageable.one-indexed-parameters 를 `true 로 설정한다.

  • 그런데 이 방법은 web에서 `page` 파라미터를 `-1` 처리 할 뿐이다.
  • 따라서 응답값인 `Page` 에 모두 0 페이지 인덱스를 사용하는 한계가 있다.

CustomPageable, Page 구현

1. 커스텀 Pageable 클래스 구현

import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;

public class CustomPageRequest implements Pageable {

    private final int page; // 1부터 시작하는 페이지 번호
    private final int size;
    private final Sort sort;

    public CustomPageRequest(int page, int size, Sort sort) {
        this.page = Math.max(1, page); // 1 미만의 값은 1로 처리
        this.size = size;
        this.sort = sort;
    }

    @Override
    public int getPageNumber() {
        return page - 1; // 스프링 데이터 JPA는 0부터 시작하므로 -1 처리
    }

    @Override
    public int getPageSize() {
        return size;
    }

    @Override
    public long getOffset() {
        return (long) (page - 1) * size;
    }

    @Override
    public Sort getSort() {
        return sort;
    }

    @Override
    public Pageable next() {
        return new CustomPageRequest(page + 1, size, sort);
    }

    @Override
    public Pageable previousOrFirst() {
        return hasPrevious() ? new CustomPageRequest(page - 1, size, sort) : first();
    }

    @Override
    public Pageable first() {
        return new CustomPageRequest(1, size, sort);
    }

    @Override
    public boolean hasPrevious() {
        return page > 1;
    }
}
  • JPA가 페이지 번호를 0부터 시작하기 때문에, 1부터 시작하는 사용자 입력값을 0 기반으로 변환.

2. Page 응답을 1 기반으로 맞추기

import org.springframework.data.domain.Page;

import java.util.List;

public class CustomPage<T> {
    private final List<T> content;
    private final int pageNumber; // 1부터 시작
    private final int pageSize;
    private final long totalElements;
    private final int totalPages;

    public CustomPage(Page<T> page) {
        this.content = page.getContent();
        this.pageNumber = page.getNumber() + 1; // 0 기반을 1 기반으로 변환
        this.pageSize = page.getSize();
        this.totalElements = page.getTotalElements();
        this.totalPages = page.getTotalPages();
    }

    public List<T> getContent() {
        return content;
    }

    public int getPageNumber() {
        return pageNumber;
    }

    public int getPageSize() {
        return pageSize;
    }

    public long getTotalElements() {
        return totalElements;
    }

    public int getTotalPages() {
        return totalPages;
    }
}
  • JPA가 반환한 0 기반 페이지 번호를, 사용자에게 보여줄 때 1부터 시작하도록 다시 변환.

3. 서비스와 컨트롤러에서 적용

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;

@Service
public class MemberService {
    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    public CustomPage<MemberDto> getMembers(int page, int size) {
        CustomPageRequest pageRequest = new CustomPageRequest(page, size, Sort.by("name").ascending());
        Page<Member> membersPage = memberRepository.findAll(pageRequest);
        return new CustomPage<>(membersPage.map(MemberDto::fromEntity));
    }
}
  • 응답 시, JPA가 반환한 0 기반 페이지 번호를 다시 1 기반으로 변환한다.
  • 이것은 사용자가 기대하는 페이지 번호(1부터 시작)를 제공하기 위함이다.

4. 컨트롤러 계층: 사용자로부터 1 기반 페이지 번호를 입력받아 서비스로 전달한다.

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class MemberController {
    private final MemberService memberService;

    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }

    @GetMapping("/members")
    public CustomPage<MemberDto> getMembers(
            @RequestParam(defaultValue = "1") int page, // 기본 1페이지
            @RequestParam(defaultValue = "10") int size) {
        return memberService.getMembers(page, size);
    }
}
  • 예제 컨트롤러다.