대용량시스템에 대한 이해(6) - 페이지네이션

남순식·2023년 12월 11일
0

대용량 시스템

목록 보기
7/7

페이지네이션

스마트폰 시대 -> 화면 작아짐 -> 데이터 사이즈는 커지는 중

  • 수 백, 수 천, 수 만개가 넘는 게시물들을 하나의 화면에 노출할 수 없다
    => 많은 양의 데이터를 어떻게 노출 시킬 것 인가

  • 페이지네이션

    • 하나의 페이지에서 데이터를 받는다.
    • 1번 페이지, 2번 페이지 ... n번 페이지
    • offset 기반
  • 스크롤

    • 인스타그램, 페이스북 타임라인과 같은 무한 스크롤 방식
    • 커서 기반

01. offset 기반 페이징

  • 마지막 페이지를 구현하기 위해서 total_pages, total_elements가 필요
  • 서버는 total_pages, total_elements를 응답으로 반환
  • 다음페이지 확인을 위해 현재 요청했던 이전 offset에서 숫자 1을 올려 요청을 보낸다.
    • 이때 불필요한 데이터에 액세스하는 과정이 생긴다.
#sql
SELECT *
FROM post
ORDER BY ____ 
WHERE memberId = :memberId
LIMIT __ # size
OFFSET __; # page
  • memberId가 일치하는 post를 offset, limit을 이용해 접근하는 쿼리

service:

public Page<Post> getPosts(Long memberId, Pageable pageable) {
	return postRepository.findAllByMemberId(memberId, pageable);
}

repository:

public Page<Post> findAllByMemberId(Long memberId, Pageable pageable) {
	MapSqlParameterSource params = new MapSqlParameterSource()
		.addValue("memberId", memberId)
		.addValue("size", pageable.getPageSize())
		.addValue("offset", pageable.getOffset());

	String sql = String.format("""
		SELECT *
		FROM %s
		WHERE memberId = :memberId
		ORDER BY %s
		LIMIT :size
		OFFSET :offset
		""", TABLE, PageHelper.orderBy(pageable.getSort()));

	List<Post> posts = namedParameterJdbcTemplate.query(sql, params, ROW_MAPPER);

	return new PageImpl<>(posts, pageable, getCount(memberId));
}

PageHelper.class

public class PageHelper {

	public static String orderBy(Sort sort) {
		if (sort.isEmpty()) {
			return "id DESC";
		}

		List<Sort.Order> orders = sort.toList();

		List<String> orderBys = orders.stream()
			.map(order -> order.getProperty() + " " + order.getDirection())
			.toList();

		return String.join(", ", orderBys);
	}

}
  • Sort
    • PageHelper class는 Sort를 받아 orderBy로 사용할 수 있도록 해주는 클래스
    • isEmpty()를 지원하기 때문에 sort.isEmpty()면 defualt id desc
    • 컨트롤러에서 Pageable을 받아서 쿼리로 사용할 수 있다

[정리]

  • offset, limit을 이용한 page query
  • PageImpl = Page객체를 만들 수 있다.
  • offset = page, limit = size로 사용된다
  • Pageable을 이용하면 get_offset, get_page_size를 사용할 수 있다.
  • Page를 만들려면 객체의 총 개수가 필요하다.

[문제]

  • 데이터가 많아지면 총 개수를 알아내는것에 대한 부담이 생긴다.
  • 4번 offset을 보기 위해 0~3 offset을 확인해야 한다. (size = 10 이라면 사용하지 않는 30개 데이터를 확인 해야한다)

02. 커서 기반 페이징

post-thumbnail
  • 클라이언트가 key를 이용해 요청한다.
  • 서버는 key값 이후의 데이터 중 limit 만큼 데이터를 응답하고 마지막 데이터 키값을 알려준다.
  • 이후 부터 key를 클라이언트가 가지고 있다가 커서를 내리면서 요청한다.
    -> nextKey로 바꾸어 새로 들어온 요청에 대한 응답을 내려준다. (반복)
  • key를 기준으로 데이터 탐색범위를 최소화
#sql
SELECT *
FROM post
WHERE member_id = :member_id and id < :id # 조건
ORDER BY id desc
LIMIT __; # size
  • memberId로 post를 조회하는데 key < id (key이후의 데이터)를 내림차순 해서 limit만큼

PageCursor.record

public record PageCursor<T>(
	CursorRequest nextCursorRequest,
	List<T> body
) {
// 제너릭하게 이용
}

CursorRequest.record

public record CursorRequest(Long key, int size) {
	public static final Long NONE_KEY = -1L;

	public boolean hasKey() {
		return key != null;
	}
	public CursorRequest next(Long key) {
		return new CursorRequest(key, size);
	}
}
// request 객체
  • key는 중복을 허용하지 않는다. = 유니크해야 한다.
    • 현재는 pk(id)를 사용할 예정
    • 중복이 발생하면 데이터를 읽다가 중간에 끊기는 이슈가 생길 수 있음
  • 최초 요청일 때는 key가 null일 수 있음
  • 마지막 데이터인지는 알아야 더 이상 요청을 하지 않음

repository:

public List<Post> findAllByMemberIdAndOrderByIdDesc(Long memberId, int size) {
	String sql = String.format("""
		SELECT *
		FROM %s
		WHERE memberId = :memberId
		ORDER BY id desc
		LIMIT :size
		""", TABLE);

	MapSqlParameterSource params = new MapSqlParameterSource()
		.addValue("memberId", memberId)
		.addValue("size", size);

	return namedParameterJdbcTemplate.query(sql, params, ROW_MAPPER);
}

public List<Post> findAllLessThanIdAndByMemberIdAndOrderByIdDesc(Long id, Long memberId, int size) {
	String sql = String.format("""
		SELECT *
		FROM %s
		WHERE memberId = :memberId and id < :id
		ORDER BY id desc
		LIMIT :size
		""", TABLE);

	MapSqlParameterSource params = new MapSqlParameterSource()
		.addValue("memberId", memberId)
		.addValue("id", id)
		.addValue("size", size);

	return namedParameterJdbcTemplate.query(sql, params, ROW_MAPPER);
}
  • id = null 일 때는 size만큼 내림차순해서 보여준다.
  • id가 있을 때는 id 이후 값들을 내림차순해서 보여준다.

controller:

@GetMapping("/members/{memberId}/by-cursor")
public PageCursor<Post> getPosts(
	@PathVariable Long memberId,
	CursorRequest cursorRequest) {
	return postReadService.getPosts(memberId, cursorRequest);
}

service:

public PageCursor<Post> getPosts(Long memberId, CursorRequest cursorRequest) {
	List<Post> posts = findAllBy(memberId, cursorRequest);
	Long nextKey = posts.stream()
		.mapToLong(Post::getId)
		.min().orElse(CursorRequest.NONE_KEY);

	return new PageCursor<>(cursorRequest.next(nextKey), posts);
}

private List<Post> findAllBy(Long memberId, CursorRequest cursorRequest) {
	if (cursorRequest.hasKey()) {
		return postRepository.findAllLessThanIdAndByMemberIdAndOrderByIdDesc(
			cursorRequest.key(),
			memberId,
			cursorRequest.size());
	}

		return postRepository.findAllByMemberIdAndOrderByIdDesc(
		memberId,
		cursorRequest.size());
}

[정리]

  • key 이전 데이터들을 알 필요가 없다.
  • key == null 일 때는 size만큼 데이터를 응답하고, 이후부터는 nextKey를 이용해 반복적으로 요청한다

03. 차이점

  • offset
    • 페이지 ui를 제공할 수 있다.
    • 마지막 페이지를 위해 총 데이터 count를 알아야한다.
    • offset 이전의 데이터를 모두 확인해야 한다.
    • 중복 데이터가 발생할 수 있다.
      (1page를 조회중에 최신 post가 발생하면 2page로 넘어갔을 때 1page에서 본 data가 보일 수도 있다.)
  • cursor
    • page ui를 제공할 수 없다.
    • key값 이후의 데이터를 내려주는 일이라서 이전 데이터를 확인할 필요가 없어진다.
    • 중복데이터를 볼 일이 없다.
    • 마지막 페이지를 알 필요는 없지만 마지막 데이터는 알아야 한다.

인사이트

커버링 인덱스

  • 데이터 조회
    • 먼저 인덱스 테이블을 조회한다.
    • 테이블에 접근한다.

[index table]

ageid
191
273
302
405
474

[user table]

idagenameemailpassword
119ssss@ss1234
230aaaa@aa1234
327bbbb@bb1234
447cccc@cc1234
540dddd@dd1234
  • 테이블에 접근하지 않고 인덱스만을 이용해서 데이터 응답을 내려줄 수 있는지?
    => 커버링 인덱스
# 1
select age
from user
where age > 30;

# 2
select age, id
from user
where age > 30;
  • 1, 2두 쿼리 모두 테이블을 직접 조회하지 않아도 데이터 응답 가능
    => 커버링 인덱스처럼 활용함
    • 나이는 이미 인덱스 테이블이기 때문에 나이를 가져오는 것은 데이터 블록(테이블)을 거치지 않아도 된다.
    • id컬럼이 쿼리에 추가 됐다. 그러나 mysql에서는 pk가 클러스터인덱스 이기 때문에 모든 인덱스들은 id값을 가지고 있다. 따라서 id를 조회하고 위한 테이블 접근이 필요없다.
  • 따라서 mysql은 id가 클러스터인덱스이기 때문에 커버링 인덱스에 매우 유리하다

[커버링 인덱스]

  • 테이블에 접근하지 않고 인덱스로만 데이터 응답을 내려줄 수 있는 경우라면 인덱스로만 커버하겠다. = 커버링 인덱스
  • 테이블에 접근하지 않기 때문에 테이블에 직접 접근하는 쿼리문에 비해 훨씬 빠르다

커버링 인덱스를 이용해 페이지네이션을 최적화하기 (how?)

  • ex) 나이가 30 이하인 회원의 이름을 2개만 조회

    SELECT name
    FROM user
    WHERE age < 30
    LIMIT 2;
    • 나이가 30 이하인 회원이 1000000명 있다면?
    • 이름은 커버링하지 못함
    • limit 2를 사용하면
      나이가 30이하인 두개의 데이터를 찾음 ->
      1000000개의 데이터 모두 액세스 됨 ->
      1000000개의 disk random i/o ->
      성능 저하
  • 해결 방법

    with 커버링 as (
    SELECT id
    FROM user
    WHERE age < 30
    LIMIT 2
    ); # age와 id 모두 인덱스로 커버 가능 -> 원본데이터를 참조할 일 없음
    
    SELECT 이름
    FROM user INNER JOIN 커버링 on user.id = 커버링.id; # -> 위 쿼리로 받아온   id
    • 불필요한 랜덤 액세스가 필요없음
profile
응집력있는 시간을 보내기 위한 블로그

0개의 댓글