Querydsl + No-Offset 페이지네이션 구현

2024. 7. 9. 11:21

변화에 유연하게 !

 

기존에는 검색 결과 리스트 반환은 Offset-Paging 방식으로 구현했었습니다.

 

하지만 한 주간 회의 진행 후 , 찾기는 무한 스크롤로 변경하기로 결정이 났습니다.

그 이유는 검색 결과 리스트가 화면 구조상 최대 5개 정도 밖에 나오지 않는다. => 페이지를 넘길 때 무한스크롤 방식이 편리 (기존 페이징이라면 사용자가 페이지 버튼을 일일히 눌러야 하니 사용자 경험 하락 예상) 때문이였습니다.

 

이렇듯 기획과 요구사항은 시시각각 달라지고, 그것을 해결하기 위해 개발자가 필요하다고 생각합니다. 코드를 계속 리팩토링 하고 구조를 개선하고 이러한 과정들은 다 이런 변화에 비교적 쉽게 대처하기 위해 진행하는 것 같습니다. 

 

우선 커서 기반 페이지네이션을 적용하는 데 있어서 그리 쉽지만은 않았습니다.

많이 알려진 방법은 cursorId를 만들고 매개변수로 id를 넘겨줌으로써 해당 id를 통해 바로 다음 페이지를 매우 빠른 속도로 조회할 수 있다는 것입니다. 이는 기존 offset 방식이 offset만큼 페이지를 읽고 결과를 반환하는 것에 비해 상당히 빠를 수 밖에 없습니다. (하지만 만약 offset이 800000이라면..?!)

 그러나 사실 일반적으로 800000번째 페이지까지 잘 이동하지는 않습니다. 보통 앞에 20페이지 내에서 대부분의 작업이 이루어지죠.

사실 이렇다고 가정한다면 추후 언급할 복합커서를 이용한 커서 기반 페이지네이션보다 Offset 페이지네이션이 성능이 좋습니다. (아래 이미지) 대부분 200만개 이하에서 데이터 탐색이 이루어질 것이기 때문이죠.

그래서 offset 방식의 페이징을 사용하고 대용량 데이터를 보유하고 있는 대표적인 기업인 Google은 어떻게 되어있나 확인해보았습니다.

최대 28페이지 까지로 제한을 둔 것을 확인할 수 있습니다. (매우 흔한 Hello로 검색했는데 말이죠. 어제는 19 페이지까지였던 것 같은데..)

그리고 이건 제 생각입니다만 대량의 페이징 이동을 막기 위해 '이전' 이나 '다음' 버튼을 누를 시에 보통 10페이지 단위로 이동할 수 도 있을 텐데 구글은 1페이지씩만 이동합니다. 또한 url 쿼리 파라미터에 page를 숨겨놓음으로써 임의 이동도 불가능하게 막아놓은 것 같습니다. 

 

물론 이 방법은 현실적으로 매우 좋다고 판단됩니다. 

1. 대부분의 사용자가 앞 페이지에서 활동을 한다는 점

2. 구글이 보유한 기술력을 바탕으로 크롤링, 알고리즘 등으로 최고, 최적의 검색 결과를 1페이지부터 반환해준다는 점

3. 페이지간 이동도 자유롭다는 점 (커서는 페이지 간 이동이 불가능합니다. 1 -> 5페이지 x)

4. 정렬 자유로움 (No-offset 적용 시 복합정렬커서는 좀 까다롭습니다. 또한 위 사진처럼 다중 복합 커서 사용 시 성능 저하 문제 발생)

 

 하지만 제가 진행중인 프로젝트는 검색 결과가 나오는 화면을 모달로 진행하기 때문에, 무한스크롤 방식이 더 사용자 편의성에 좋다고 회의 결과가 정해졌습니다.

 

 

No-Offset + 커버링 인덱스

 

먼저 커버링 인덱스란, 조회할 모든 SQL에 속한 컬럼들이 인덱스를 가지고 있어서, 인덱스를 통해 실제 DB에서 데이터를 찾는 과정이 사라지고, 인덱스 자체에서 모든 데이터를 빠르게 조회할 수 있는 방법입니다. 

즉, SELECT, WHERE, ORDER BY 등 쿼리에 사용되는 모든 컬럼이 인덱스를 보유하고 있어야 한다는 것입니다.

보통 SubQuery를 통해 구현이 가능합니다. 하지만 저는 Querydsl을 사용하여 진행하고 있고, JPQL에서는 서브 쿼리가 지원이 안되기 때문에 복합 인덱스를 이용해 커버링 인덱스 방식으로 원하는 데이터들을 빠르게 조회하고, 이후 두 번째 쿼리에서 해당하는 데이터들의 값을 조회하였습니다.

List<Long> memberIds = queryFactory
        .select(member.id)
        .from(member)
        .where(
                addressEq(condition.getAddr()),
                nameEq(condition.getName()),
                genderEq(condition.getGender()),
                ageLoe(condition.getAgeLoe()),
                member.isProfilePublic,
                cursorViewCountsAndPatientId(lastViewCounts, lastIndex)
        )
        .orderBy(member.viewCounts.desc(), member.id.asc())
        .limit(pageable.getPageSize() + 1)
        .fetch();

List<PatientProfileResponse> content = queryFactory
        .select(new QPatientProfileResponse(
                patient.id,
                patient.name,
                patient.age,
                patient.gender,
                patient.address,
                patient.profileImageUrl,
                patient.viewCounts))
        .from(patient)
        .where(patient.id.in(memberIds))
        .orderBy(patient.viewCounts.desc(), patient.id.asc())
        .fetch();

boolean hasNext = content.size() > pageable.getPageSize();
if (hasNext) {
    content.remove(pageable.getPageSize());
}

return new SliceImpl<>(content, pageable, hasNext);

 

 

 No-Offset 방식의 페이지네이션을 진행하면서 좀 까다로웠던 점은 정렬입니다. 현재 저는 Querydsl을 이용하여 동적 검색, 정렬을 진행하고 있습니다.사실 정렬이 고유값이라면 그 값으로 cursor를 설정하여 진행한다면 매우 간단합니다.  cursor_id를 매개변수에 넘겨줌으로써 그 바로 다음 id들을 조회하면 끝이죠.
 하지만 정렬 cursor가 고유값이 아닌 중복값이라면 약간 문제가 생깁니다. 예를 들어 정렬 기준이 추천수라고 해보죠.추천수가 5, 5, 5, 4, 4, 3 이렇게 있는 rows들이 있다고 가정해봅시다. 그리고 limit을 4로 잡아보죠. 그러면 첫 요청에서 추천수 <100 (임의값) 요청이 들어가고 결과는 5, 5, 5, 4가 나올겁니다. 여기까지는 매우 바람직합니다. 하지만 두 번 째 요청에는 첫 번째 응답 값의 마지막 cursor_id, 즉 4를 매개변수로 넘깁니다. 그러면 추천수 < 4라는 쿼리가 나가겠죠. 하지만 이는 3만 반환이 됩니다. 우리는 4, 3을 보고 싶은데 말이죠..!이를 해결하기 위해서는 복합커서를 만들어야 합니다. 고유키 + 중복키 = 고유키라는 성질을 이용한 것이죠. 즉, 고유키인 member_id와 추천수(vote_count)를 같이 매개변수에 보내는 것입니다. 그리고 or 문법을 이용한다면 데이터 사라짐 문제를 막을 수 있습니다. 이제 첫 요청시에 5, 5, 5, 4가 나오고 두번째 요청에는 (id<4 and vote_count = 4) or vote_count < 4 이렇게 쿼리가 발생한다면 정상적으로 4, 3을 반환받을 수 있습니다. 물론 order by 에도 vote_count와 id를 desc로 모두 정렬해주어야하죠.

 

 사실 여기까지가 끝이라면 어려워 할 이유가 없겠죠. 가장 큰 문제는 동적 검색(카디널리티가 낮은) + 복합 커서 정렬 이였습니다. 테스트 데이터 500만 건으로 테스트 해보았을 시에, 정렬을 하지 않았을 때, 성별, 성별 + 경력 검색은 큰 문제가 되지 않았습니다. 왜냐하면 테이블에서 member_id 순서대로 검색을 해가며 해당 조건에 맞는 user를 limit만큼만 찾아서 응답하면 됐으니까 말이죠. 그래서 PK로 인덱스가 탔고 매우 빠르게 결과를 받을 수 있었습니다. 하지만 문제는 정렬을 조회순으로 진행하였을 때, viewCounts 값은 정렬된 상태가 아닙니다. 즉, 조회수가 높은 5개(limit)을 찾기 위해서는 모든 테이블을 탐색한 뒤, 정렬을 해서 5개를 응답해야 한다는 것이죠.(FTS, using temporary, using filesort) 
하지만 이 또한 검색 조건에 지역이 포함된다면 미리 생성한 인덱스(지역)를 잘 타면서 빠르게 응답이 나왔습니다.

문제는 지역이 제외된, 성별(혹은 경력) + 조회순 검색이었습니다. 

현재 실행 쿼리는 이렇습니다.(성별 + 최신순)

select c1_0.member_id
from members c1_0
where c1_0.gender = 'FEMALE'
  and c1_0.isProfilePublic
order by c1_0.viewCounts desc, c1_0.member_id desc
limit 6;

커버링 인덱스가 적용이 됩니다. 하지만 수행 시간이 8초가 넘어갑니다. 원인은 당연하겠지만 너무 많은 rows가 존재한다는 것입니다.

 

MySQL 의 쿼리 실행 순서는

1. from,join 2. where 3. group by 4. having 5. select 6. order by 7. limit 입니다.

이 쿼리의 순서를 살펴보면,  gender = FEMALE , isProfilePublic = true 인 조건에 해당하는 모든 row를 조회합니다. 그 뒤에 order by 구문을 해석하죠. 즉, 커버링 인덱스를 통해 데이터를 빠르게 가져온다 하더라도, 대량의 데이터가 존재한다면 실행 속도는 오래 걸릴 수 밖에 없습니다. 즉, 인덱스 gender, isProfilePublic으로 데이터를 찾는다고 해도 해당하는 데이터가 너무 많다는 것이 문제이죠. 이는 인덱스 만으로 해결이 될 문제가 아니라고 생각했습니다.

하지만 여기서 지역을 필수 입력값으로 받는다면,

평균 쿼리 속도는 매우 빨라집니다. (평균 500ms 이하)

대부분의 이용자가 지역 기반 검색을 할 것이라 예상하기 때문에 큰 문제가 되지 않을 것이라 판단하였습니다.

 

BELATED ARTICLES

more