1. UI 및 페이지네이션 전략

※ 해당 UI에서 필요한 페이지 이동 동작은 다음과 같습니다.
- 맨 끝 페이지 이동(앞/뒤)
- 10 페이지 이동(앞/뒤)
- 1~4 페이지 이동(앞/뒤)
※ 운영하는 서비스는 페이지 당 기본 size는 15이기에 10 페이지 이동을 위한 9 페이지 크기의 offset은 성능에 문제가 되지 않을 것이라 판단하였기에 다음과 같은 전략을 세웠습니다.
- 맨 끝 페이지 이동은 offset 페이지네이션
- 1 페이지 이동은 cursor 페이지네이션
- 2~4, 10 페이지 이동은 cursor + offset 페이지네이션
2. API 쿼리 파라미터 설계
※ 프런트엔드와 백엔드 간의 명확한 통신을 위해 다음과 같이 파라미터를 정의했습니다.
| 파라미터 | 데이터타입 | 형식 | 설명 | 예시 |
| order | 문자열 | "mainSortField,sortDriection" | 메인 정렬 필드와 방향 | "receivedAt,Desc" |
| page | 문자열 | "pageJump,pageDirection" | 이동할 페이지 수와 방향 | "10,Next" |
| size | 숫자 | 15 | 한 페이지 당 노출 데이터 수 | 15 |
| firstCursor | 문자열 | "mainSortFieldValue,subSortFieldValue" | 앞쪽 페이지 이동에 사용하는 cursor | "31,20251229" |
| lastCursor | 문자열 | "mainSortFieldValue,subSortFieldValue" | 뒤쪽 페이지 이동에 사용하는 cursor | "45,20251210" |
- cursor의 경우 mainSortField가 고유한 값들을 가지지 않는 경우가 있기에, 고유한 값을 갖는 subSortFieldValue와 같이 사용하였습니다.
3. 코드 구현
- document 예시 코드
public record MyDocument(
String id,
Long mainSortField,
String subSortField,
String groupName
// 나머지 field
) {
public String getSearchId() {
return this.id;
}
public String getIndexName() {
return DOCUMENT_INDEX;
}
}
- 응답 DTO 예시 코드
public record MyReponse(
String id,
Long mainSortField,
String subSortField,
String groupName
// 나머지 field
) {
// opensearch에서 projection할 field 리스트
public static List<String> getFieldList() {
return List.of(
"id", "receivedAt", "messageType"
);
}
// opensearch에서 sort할 FieldType
public static FieldType getFieldType(String field) {
if (Objects.equals("receivedAt", field)) {
return FieldType.valueOf("Date");
} else {
return FieldType.valueOf("Keyword");
}
}
}
- common repository 예시 코드
@RequiredArgsConstructor
public abstract class CursorPaginationOpenSearchRepository {
protected final OpenSearchClient client;
/**
* search hit page
*
* @param indexName: 검색할 opensearch index
* @param query: 검색어 대한 정의된 Query
* @param cursor: custom cursor string => "mainValue,subValue"
* @param size: 현재 검색할 page당 크기
* @param sort: custom sort string => "mainSortField,mainSortOrder"
* - subSortField는 "_id" 고정
* - subSortOrder는 페이지 이동 방향에 따라 결정
* @param page: custom page string => "pageJump,pageDirection"
* - "pageJump만큼 pageDirection 방향으로 이동
* - "0,Next"는 첫 페이지 이동
* - "-1,Prev"는 마지막 페이지 이동
* @param projectionFieldList: source filter할 리스트
* @param getFieldType: main SortOptions의 unmappedType을 얻기 위한 함수
* @param documentClass: projection Entry class
*
* @return result: 검색된 opensearch의 Hit List
*/
protected <T> List<Hit<T>> searchHitsPage(
String indexName,
Query query,
String cursor,
int size,
String sort,
String page,
List<String> projectionFieldList,
Function<String, FieldType> getFieldType,
Class<T> documentClass
) throws IOException {
SearchRequest.Builder requestBuilder = new SearchRequest.Builder();
// index select
requestBuilder.index(indexName);
// 검색어 query
requestBuilder.query(query);
// cursor
if (!Objects.equals(cursor, DEFAULT_PAGINATION_CURSOR)) {
requestBuilder.searchAfter(withSearchAfter(sort, cursor));
}
// size
int fetchSize = withSize(page, size);
requestBuilder.size(fetchSize);
// sort
requestBuilder.sort(withSort(sort, page, getFieldType));
// source filter
requestBuilder.source(s -> s.filter(f -> f
.includes(projectionFieldList)
));
// search
SearchResponse<T> response = client.search(requestBuilder.build(), documentClass);
List<Hit<T>> result = new ArrayList<>(
response.hits().hits().stream()
.filter(Objects::nonNull)
.skip(fetchSize - size)
.limit(size)
.toList()
);
String pageDirection = page.split(",")[1];
if (Objects.equals(pageDirection, "Prev")) {
Collections.reverse(result);
}
return result;
}
/**
* search total elements
*
* @param indexName: 검색할 opensearch index
* @param query: 검색어 대한 정의된 Query
*
* @return totalElements: 검색된 문서 총 개수
*/
protected Long searchTotalElements(
String indexName,
Query query
) throws IOException {
SearchRequest.Builder requestBuilder = new SearchRequest.Builder();
// index select
requestBuilder.index(indexName);
// 검색어 query
requestBuilder.query(query);
// 정확한 전체 개수를 세기 위한 설정
requestBuilder
.size(0)
.source(s -> s.fetch(false))
.trackTotalHits(t -> t.enabled(true));
SearchResponse<Object> response = client.search(requestBuilder.build(), Object.class);
return Optional.ofNullable(response.hits().total())
.map(TotalHits::value)
.orElse(null);
}
// Internal Method =================================================================================================
// sort, cursor에 대한 searchAfter에 사용할 FieldValue List 정의
private List<FieldValue> withSearchAfter(String sort, String cursor) {
String[] sortParts = sort.split(",");
String[] cursorParts = cursor.split(",");
if (sortParts.length != 2 || cursorParts.length != 2) {
throw new IllegalArgumentException("Invalid sort or cursor format: (sort) %s (cursor) %s".formatted(sort, cursor));
}
String mainValue = cursorParts[0];
FieldValue mainFieldValue = Objects.equals(mainValue, "null") ? FieldValue.NULL : FieldValue.of(mainValue);
String subValue = cursorParts[1];
FieldValue subFieldValue = FieldValue.of(subValue);
return List.of(mainFieldValue, subFieldValue);
}
// page와 size 대한 opensearch fetch size 정의
private int withSize(String page, int size) {
int pageJump = Integer.parseInt(page.split(",")[0]);
return pageJump > 1 ? pageJump * size : size;
}
// sort, page, cursor에 대한 Sort 정의
private List<SortOptions> withSort(
String sort,
String page,
Function<String, FieldType> getFieldType
) {
// 메인 정렬 기준 get
String[] parts = sort.split(",");
String mainSortField = parts[0];
String sortDirection = parts[1].substring(0, 1).toUpperCase() + parts[1].substring(1).toLowerCase();
SortOrder order = SortOrder.valueOf(sortDirection);
String missingOrder = Objects.equals(sortDirection, "Asc") ? "_first" : "_last";
FieldType unmappedType = getFieldType.apply(mainSortField);
// 메인 정렬 기준 적용
SortOptions mainSort = SortOptions.of(s -> s
.field(f -> f
.field(mainSortField)
.order(order)
.missing(FieldValue.of(missingOrder))
.unmappedType(unmappedType)
)
);
// 서브 정렬 기준 적용
String pageDirection = page.split(",")[1];
SortOrder subSortOrder = Objects.equals(pageDirection, "Next") ? SortOrder.Desc : SortOrder.Asc;
SortOptions subSort = SortOptions.of(s -> s
.field(f -> f
.field("_id")
.order(subSortOrder)
)
);
return List.of(mainSort, subSort);
}
}
- domain repository 예시 코드
@Repository
public class DomainSearchRepository extends CursorPaginationOpenSearchRepository {
public DomainSearchRepository(OpenSearchClient client) {
super(client);
}
// Pagination으로 search
public OpenSearchPageEntry<DomainBoardEntry> searchByPagination(
String groupName,
String cursor,
int size,
String sort,
String page
) throws IOException {
// 그룹 이름에 대한 Query 정의
Query query = withKeywordQuery(groupName);
List<Hit<PlatformWithinPeriodBoardEntry>> searchHits =
searchHitsByPagination(query, cursor, size, sort, page);
Long totalElements = searchTotalElementsByPagination(query);
return OpenSearchCursorPageEntry.of(searchHits, totalElements);
}
// Pagination으로 hits search
public List<Hit<PlatformWithinPeriodBoardEntry>> searchHitsByPagination(
Query query,
String cursor,
int size,
String sort,
String page
) throws IOException {
// search hits page
return super.searchHitsPage(
INDEX_PATTERN,
query,
cursor,
size,
sort,
page,
DomainBoardEntry.getFieldList(),
DomainBoardEntry::getFieldType,
DomainBoardEntry.class
);
}
// Pagination으로 전체 개수 search
public Long searchTotalElementsByPagination(Query query) throws IOException {
// search total elements
return super.searchTotalElements(INDEX_PATTERN, query);
}
// Internal Method =================================================================================================
// 검색어에 대한 Query 정의
private Query withKeywordQuery(String groupName) {
List<Query> mustQueries = new ArrayList<>();
// 그룹 이름 must Query
if (Objects.nonNull(groupName)) {
mustQueries.add(withGroupNameQuery(groupName));
}
// 검색어 입력이 없는 경우
if (mustQueries.isEmpty()) {
mustQueries.add(Query.of(q -> q.matchAll(m -> m)));
}
// filter Query가 필요한 경우....
// List<Query> filterQueries = new ArrayList<>();
// fiter Query
// Query 정의
return Query.of(q -> q
.bool(b -> b
.filter(filterQueries)
.must(mustQueries)
)
);
}
// 그룹 이름 검색어에 대한 Query 정의
private Query withGroupNameQuery(String groupName) {
return Query.of(q -> q.term(t -> t
.field("groupName")
.value(FieldValue.of(groupName))
));
}
}
4. 마무리
서버사이드 페이지네이션에 사용되는 개념인 cursor와 offset을 opensearch에 적용하여 구현한 초기 코드입니다.
초기 코드인 만큼 미흡한 부분이 존재하니 여러분 프로젝트에 맞게 개선하여 적용하면 좋을 것 같습니다.
감사합니다.