Spring

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

AlwaysPr 2019. 12. 29. 17:50

언젠가부터 무한 스크롤을 이용한 페이징 방식이 우리들에 스며들기 시작했다. 이는 과거의 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을 사용할 수 있다.