JPA를 이용하여 cursor 기반 페이징 구현

2019. 12. 29. 17:50· Spring
목차
  1. Offset Based Pagination
  2. Cursor Based Pagination
  3. ID가 순차적이지 않으면 어떻게 될까?

언젠가부터 무한 스크롤을 이용한 페이징 방식이 우리들에 스며들기 시작했다. 이는 과거의 1, 2, 3... 의 페이지를 클릭하여 다음 콘텐츠를 보는 것이 아니라, 페이스북처럼 마지막 콘텐츠를 보게 되면 다음 페이지가 로딩되어 보이는 것을 의미한다. 그러나 SNS에서는 일반적으로 사용하는 Offset 기반의 페이징을 사용하게 되면 문제가 생길 수 있다.

 

Offset Based Pagination

SELECT * FROM BOARDS ORDER BY ID DESC LIMIT 0, 10;
SELECT * FROM BOARDS ORDER BY ID DESC LIMIT 10, 10;
SELECT * FROM BOARDS ORDER BY ID DESC LIMIT 20, 10;

위의 쿼리를 해석해보면 최신 게시글의 0~10, 10~20, 20~30을 조회한다. 그러나 SNS처럼 실시간으로 많은 글이 올라오는 환경에서는 중복해서 컨텐츠가 노출될 수 있다. 이유는 다음과 같다.

 

첫번째 쿼리가 실행된 후 그 사이에 콘텐츠 하나가 추가가 되었다면, 두 번째 쿼리를 조회할 때 중복될 수가 있다. 즉 첫 번째 쿼리의 마지막 요소와 두 번째 쿼리의 첫 번째 요소는 동일한 결과가 나타나게 된다. 계속해서 이러한 문제가 생긴다면 사용자에게 부정적인 인식을 심어주게 될 것이다. 이러한 이슈를 Cursor 기반의 페이징으로 풀어나갈 수 있다.

 

Cursor Based Pagination

Cursor 기반의 페이징은 간단하게 말하자면 리스트를 조회할 때 내가 읽은 마지막 요소를 알려줌으로써 그 뒤의 값을 조회하는 것을 의미한다.

SELECT * FROM BOARDS ORDER BY ID DESC LIMIT 10;

SELECT * FROM BOARDS 
WHERE 
	ID < {이전에 조회한 마지막 id} 
ORDER BY 
	ID DESC LIMIT 10;

위의 쿼리를 해석해보면 최신 10개의 게시글을 조회하고, 이후에는 이전에 조회한 마지막 ID보다 작은 10개를 가져오게 된다.

 

ID가 1 ~ 30인 30개의 게시글이 있다고 생각해보자.

최초에는 ID가 30 ~ 21 인 게시글이 조회된다. 두번째 조회 시에 where절에 21을 넣게 되어 20~11이 조회된다. 세 번째도 비슷한 원리로 11을 넣게 되면 10~1이 조회된다.

 

원리는 이해했으니 Java코드로 풀어보자.

@Entity
public class Board {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    private String title;

    private String contents;

    private LocalDateTime createAt;
	//...
}

id, 제목, 내용, 생성일이 있는 간략한 게시글이다. 여기서 키포인트는 ID는 순차적이어야 한다는 것이다. 그래야 간편하게 Cursor 구현이 가능하다.

 

@RestController
@RequestMapping("/boards")
public class BoardController {

    private static final int DEFAULT_SIZE = 10;

    private final BoardService boardService;

    public BoardController(BoardService boardService) {
        this.boardService = boardService;
    }

    @GetMapping
    public CursorResult<Board> getBoards(Long cursorId, Integer size) {
        if (size == null) size = DEFAULT_SIZE;
        return this.boardService.get(cursorId, PageRequest.of(0, size));
    }

}
public class CursorResult<T> {
    private List<T> values;
    private Boolean hasNext;

    public CursorResult(List<T> values, Boolean hasNext) {
        this.values = values;
        this.hasNext = hasNext;
    }
	//...
}

Controller 또한 간단하다. 파라미터로 cursorId와 size를 받게된다. 여기서 주목해야 될 점은 PageRequest.of()의 첫 번째 파라미터는 무조건 0으로, 즉 최초의 페이지로 처리를 해야 한다. 

 

Client 개발자와 소통할 때 다음 조회할 리스트가 존재하는지 내려주면 좀 더 효율적이다. 왜냐하면 불필요하게 한번 더 리스트를 조회할 필요가 없기 때문이다.

그래서 CursorResult에서는 게시글 정보와 다음 리스트가 존재하는지의 여부를 알려주는 hasNext를 내려준다.

 

@Service
public class BoardService {

    private final BoardRepository boardRepository;

    public BoardService(BoardRepository boardRepository) {
        this.boardRepository = boardRepository;
    }

    CursorResult<Board> get(Long cursorId, Pageable page) {
        final List<Board> boards = getBoards(cursorId, page);
        final Long lastIdOfList = boards.isEmpty() ?
                null : boards.get(boards.size() - 1).getId();

        return new CursorResult<>(boards, hasNext(lastIdOfList));
    }

    private List<Board> getBoards(Long id, Pageable page) {
        return id == null ?
                this.boardRepository.findAllByOrderByIdDesc(page) :
                this.boardRepository.findByIdLessThanOrderByIdDesc(id, page);
    }

    private Boolean hasNext(Long id) {
        if (id == null) return false;
        return this.boardRepository.existsByIdLessThan(id);
    }
}

 

1. 최초로 조회한 경우(cursorId가 null인 경우)

findAllByOrderByIdDesc()를 통하여 size만큼의 최신 게시글을 조회한다.

2. 최초가 아닌 경우 (유효한 cursorId가 들어오는 경우)

findByIdLessThanOrderByIdDesc()를 통하여 커서보다 낮은 게시글을 조회한다.

3. 다음에 조회될 게시글이 있는지 여부

existsByIdLessThan()를 통하여 판단한다.

 

혹시 이해가 잘안된다면 Test Code를 작성해 놓았으니 실행하여 확인해보면 좀 더 이해하기 편할 듯하다.

 

ID가 순차적이지 않으면 어떻게 될까?

어떠한 이유로 ID가 순차적이지 않고 UUID와 같이 비순차적이면 위의 방법은 크게 의미가 없어진다. 

그래서 생성일을 기준으로 가져오는 방식을 생각해볼 수 있지만, 동일한 생성일을 가질 경우 데이터가 누락될 가능성이 있다. 그래서 ID값을 활용하여 이를 예방시켜보자.

SELECT * FROM BOARD 
ORDER BY 
	CREATE_AT DESC, ID DESC LIMIT 10;

SELECT * FROM BOARD 
WHERE 
	(CREATE_AT = {CREATE_AT} && ID < {ID}) OR 
	(CREATE_AT < {CREATE_AT}) 
ORDER BY 
	CREATE_AT DESC, ID DESC LIMIT 10;

위의 쿼리를 사용하면 ID가 순차적이지 않은 경우에도 Cursor Based Pagination을 사용할 수 있다. 

 
저작자표시 비영리 변경금지 (새창열림)

'Spring' 카테고리의 다른 글

10분만에 구현하는 CircuitBreaker  (0) 2023.01.29
WebFlux에서 micrometer로 모니터링 데이터 수집하기  (0) 2023.01.11
Spring WebFlux는 어떻게 적은 리소스로 많은 트래픽을 감당할까?  (43) 2019.06.22
Spring Cache 구현 원리  (1) 2019.03.24
Dubbo Spring Boot  (0) 2019.03.17
  1. Offset Based Pagination
  2. Cursor Based Pagination
  3. ID가 순차적이지 않으면 어떻게 될까?
'Spring' 카테고리의 다른 글
  • 10분만에 구현하는 CircuitBreaker
  • WebFlux에서 micrometer로 모니터링 데이터 수집하기
  • Spring WebFlux는 어떻게 적은 리소스로 많은 트래픽을 감당할까?
  • Spring Cache 구현 원리
AlwaysPr
AlwaysPr
민수's 기술 블로그AlwaysPr 님의 블로그입니다.
AlwaysPr
민수's 기술 블로그
AlwaysPr
전체
오늘
어제
  • All (38)
    • Programming (8)
    • Java (10)
    • Spring (13)
    • JavaScript (1)
    • Book (1)
    • Seminar (1)
    • Diary (4)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • FFM
  • 가상스레드
  • Webflux
  • DefaultBatchConfiguration
  • 개발자
  • 레코드매칭
  • structured-concurrency
  • r2dbc
  • lombok.config
  • stringtemplates
  • @Cachable
  • record pattern
  • StepBuilder
  • 자바21
  • 고졸개발자
  • @EnableBatchProcessing
  • scopedValue
  • eventdriven
  • 스프링배치5
  • Java21
  • Reactor
  • Spring
  • java17
  • aop
  • nonblocking
  • 웹플럭스
  • JobBuilder
  • Spring batch5
  • loom project
  • virtual thread

최근 댓글

최근 글

hELLO · Designed By 정상우.v4.2.0
AlwaysPr
JPA를 이용하여 cursor 기반 페이징 구현
상단으로

티스토리툴바

단축키

내 블로그

내 블로그 - 관리자 홈 전환
Q
Q
새 글 쓰기
W
W

블로그 게시글

글 수정 (권한 있는 경우)
E
E
댓글 영역으로 이동
C
C

모든 영역

이 페이지의 URL 복사
S
S
맨 위로 이동
T
T
티스토리 홈 이동
H
H
단축키 안내
Shift + /
⇧ + /

* 단축키는 한글/영문 대소문자로 이용 가능하며, 티스토리 기본 도메인에서만 동작합니다.