✍️ 개발 기록

[🌸다시, 봄] cursor 기반 pagination으로 무한스크롤 구현하기 🛠

ming412 2023. 9. 21. 17:40

클라이언트가 목록 조회를 요청하면, 서버는 데이터를 가져와야 하는데 이때 모든 데이터를 한번에 가져올 수는 없다.

따라서 서버 입장에서 1. `특정한 정렬 기준`에 따라 2. `지정된 개수`의 데이터를 전달하는 방법이 필요하다.

이를 페이지네이션(Pagination)이라고 표현하는데, 페이지네이션은 아래 두 가지 방식으로 구현할 수 있다.

 

Offset-based Pagination vs Cursor-based Pagination

오프셋 기반 페이지네이션

- `offset`(시작 위치)과 `limit`(가져올 개수)을 사용하여 "페이지" 단위로 데이터를 가져온다.

select *
from post
order by id desc # id 기준 내림차순 -> 최신 게시글부터
limit 5 # 가져올 개수
offset (0 * 5) # 건너뛸 개수 = (몇 번째 페이지인지 * 가져올 개수)

- 장점

    - 직관적이며 구현이 간단하다.

- 단점

    - 데이터 중복 문제

        - A 사용자가 1페이지의 게시글을 요청하면, [10,9,8,7,6]번 게시글을 내려준다. (최근 게시글부터)

        - A 사용자가 보고 있는 동안 B 사용자가 3개의 게시글을 추가한다. (총 게시글은 13개)

        - 이후 A 사용자가 2페이지의 게시글을 요청하면, [13,12,11,10,9] 다음 페이지인 [8,7,6,5,4]번 게시글을 내려준다.

        - 이렇게 되면 id가 8,7,6인 게시글을 또 다시 가져오게 된다. -> 데이터 중복 발생

    - 성능 저하 문제

        - `offset` 값이 클 때, 앞에 있는 모든 데이터를 읽어야 하기 때문에 성능 저하가 발생한다.

        - 만약 `offset`이 1억이라면 앞에 1억 개의 데이터를 읽고, 그 다음 `limit`개의 데이터를 읽어서 응답한다.

 

 

커서 기반 페이지네이션

- 클라이언트에게 응답해준 마지막 데이터의 식별자가 cursor가 된다.

- 해당 cursor를 기준으로 다음 n개의 데이터를 응답한다.

# 첫 페이지 진입시 발생 쿼리
select *
from post 
order by id desc
limit 10;

# 이후 페이지 요청시 발생 쿼리
select *
from post
where id < 100 # 예를 들어 cursor값이 100인 경우
limit 10;

- 장점

    - offset-based pagination의 단점인 데이터 중복 문제와 성능 저하 문제를 모두 해결할 수 있다.

    - 무한스크롤 방식을 구현하기에 적합하다.

- 단점

    - 상대적으로 구현이 복잡하다.

 

나는 무한스크롤을 구현하기 위해 커서 기반 페이지네이션 방식을 선택했다.

 

QueryDSL

Cursor-based Pagination을 구현하기 위해 QueryDSL을 이용할 수 있다.

 

QueryDSL이 뭔데? 🤔

QueryDSL은 Java를 사용하여 SQL 쿼리를 작성하고 실행할 수 있는 라이브러리이다.

 

장점

- 컴파일 시점에서 쿼리 오류를 발견할 수 있기 때문에 안정성을 향상시킨다.

- Java 빌더 형태로 쿼리를 작성할 수 있으므로 가독성이 뛰어난다.

- 동적 쿼리를 작성할 수 있다.

 

단점

- QueryDSL에 대한 추가적인 학습이 필요하다.

- QueryDSL 라이브러리에 대한 종속성이 추가된다.

 

QueryDSL 문법

FROM, WHERE, ORDERBY, LIMIT, OFFSET

queryFactory.select(user.name, user.email) # 사용자의 이름과 이메일 조회
.from(user) # 사용자 테이블에서
.where(user.age.gt(18)) # 나이가 18세 이상인 사용자 검색
.orderBy(user.name.asc()) # 사용자를 이름 오름차순으로 정렬
.limit(5).offset(10) # 10번째부터 5개의 결과를 가져옴
.fetch();

WHERE 절에서는 아래와 같은 문법을 사용할 수 있다.

eq: 두 값이 같은지 확인 (ex. user.age.eq(25) - 나이가 25인 사용자 조회)
ne: 두 값이 다른지 확인 (ex. user.name.ne("admin") - 이름이 "admin"이 아닌 사용자 조회)
gt: 큰 지 확인 (ex. user.salary.gt(50000) - 급여가 50000보다 큰 사용자 조회)
lt: 작은지 확인 (ex. user.age.lt(30) - 나이가 30보다 작은 사용자 조회)
between: 주어진 범위 내에 있는지 확인 (ex. user.salary.between(30000, 60000) - 급여가 30000에서 60000 사이인 사용자 조회)
in: 주어진 목록 중 하나와 일치하는지 확인 (ex. user.department.in("IT", "Engineering") - 부서가 "IT" 또는 "Engineering"인 사용자 조회)

 

동적 쿼리 생성

queryFactory.select(user.name, user.email)
.from(user)
.where(condition(18)) # 동적 쿼리 적용 가능 # 예시는 하드 코딩
.fetch();

//동적 쿼리를 위한 BooleanExpression
private BooleanExpression condition(Long age) {
    return return (age == null) ? null : user.age.gt(age);
}

QueryDSL의 WHERE 절에서 null은 무시되기 때문에, `condition()`의 파라미터 값이 null이 아닐때만 해당 `age` 이상의 사용자를 검색한다.

 

Cursor-based Pagination 구현하기

CustomDiaryRepositoryImpl

WHERE 절을 자세히 보면 된다.

(`lt()`는 QueryDSL에서 제공해주는 메서드로, 바로 위 내용을 참고하자.)

@Repository
@RequiredArgsConstructor
public class CustomDiaryRepositoryImpl implements CustomDiaryRepository {

	private final JPAQueryFactory queryFactory;

	// 일기 조회
	@Override
	public Slice<DiaryBriefResponse> getDiaryBriefScroll(Long cursorId, ReadCondition condition, Pageable pageable) {
		List<Diary> diaryList = queryFactory.selectFrom(diary)
			.where(eqCursorId(cursorId))
			.limit(SliceUtil.getLimit(pageable)) // limit 보다 데이터를 1개 더 들고와서, 해당 데이터가 있다면 hasNext 변수에 true 를 넣어 알림
			.orderBy(SliceUtil.getOrderSpecifier(pageable)) // 최신순 정렬
			.fetch();

		return SliceUtil.toSlice(pageable, diaryList, DiaryBriefResponse::from);
	}

	//동적 쿼리를 위한 BooleanExpression
	private BooleanExpression eqCursorId(Long cursorId) {
		return (cursorId == null) ? null : diary.id.lt(cursorId); // lt: 작다
	}
}

 

SliceUtil

페이징 정보로부터 limit 값을 가져온다거나 정렬 조건을 생성하는 등의 로직은 유틸리티 클래스로 분리했다.

public class SliceUtil {
	private static final String ORDER_BY_UPDATED_DATE = "updatedDate";

	/**
	 * 페이징 정보로부터 limit 값 가져오기
	 */
	public static int getLimit(Pageable pageable) {
		return pageable.getPageSize() + 1;
	}

	/**
	 * 페이징 정보로부터 정렬 조건 생성
	 */
	public static OrderSpecifier<?> getOrderSpecifier(Pageable pageable) {
		return pageable.getSort().stream()
			.map(order -> {
				// 서비스에서 넣어준 DESC or ASC 를 가져오기
				Order direction = order.getDirection().isAscending() ? Order.ASC : Order.DESC;
				// 서비스에서 넣어준 정렬 조건을 스위치 케이스 문을 활용하여 세팅
				switch (order.getProperty()) {
					case ORDER_BY_UPDATED_DATE:
						return new OrderSpecifier<>(direction, diary.updatedDate);
					default:
						return new OrderSpecifier<>(direction, diary.createdDate);
				}
			})
			.findFirst()
			.orElseGet(() -> new OrderSpecifier<>(Order.DESC, diary.createdDate));
	}

	/**
	 * 리스트를 Slice로 변환
	 */
	public static <T, R> SliceImpl<R> toSlice(Pageable pageable, List<T> lst, Function<T, R> mapper) {
		boolean hasNext = lst.size() > pageable.getPageSize();
		if (hasNext) {
			lst.remove(pageable.getPageSize());
		}
		return new SliceImpl<>(
			lst.stream().map(mapper).collect(Collectors.toList()),
			pageable,
			hasNext
		);
	}
}

 

+) Slice vs Page

Slice와 Page는 데이터 집합을 처리하기 위한 인터페이스이다. 하지만 동작 방식에서 차이가 있다.


Slice

Slice는 Streamable를 상속받는 인터페이스로, 아래와 같이 여러 메서드를 가지고 있다. 

 

Page

Page는 Slice를 상속한다. 따라서 Slice가 가진 모든 메서드를 사용할 수 있다.

Page가 추가적으로 구현하고 있는 메서드 두 가지만 살펴보자.

 

무엇을 사용할까?

Slice는 전체 페이지 개수나 데이터 개수를 제공하지 않고, 이전/다음 Slice가 존재하는지만 확인할 수 있다.

따라서 무한 스크롤 등을 구현하는 경우 유용하다.

Page에 비해 쿼리가 하나 덜 날아가므로 데이터의 양이 많을수록 Slice를 사용하는 것이 성능상 유리하다.

 

Page는 전체 데이터의 개수를 조회하는 쿼리를 한번 더 실행한다.

따라서 전체 페이지 개수나 데이터 개수가 필요한 경우 유용하다.

 

참고 자료

QueryDSL을 이용해 cursor-based-pagination 구현하기 + condition을 이용한 filtering

[JPA] Slice & Page

Cursor absed Pagination(커서 기반 페이지네이션)이란? - Querydsl로 무한스크롤 구현하기

커서 기반 페이지네이션 (Cursor-based Pagination) 구현하기

[SpringBoot] Spring Data JPA에서 Page와 Slice