본문으로 건너뛰기

카탈로그 검색

관리자 목록에는 페이지 번호가 필요하고, 모바일 앱에는 무한 스크롤이 필요하고, 배치 작업에는 전체 데이터를 순회해야 합니다. 세 가지 페이지네이션 중 어떤 것을 선택해야 할까요?

이 장에서는 카탈로그(Catalog) 검색 도메인을 통해 Offset, Cursor, Stream 세 가지 페이지네이션 방식을 비교합니다. 동일한 Specification으로 세 가지 조회를 수행하며, 각 방식의 특성과 적합한 사용 시나리오를 학습합니다.


이 장을 완료하면 다음을 할 수 있습니다:

  1. Offset, Cursor, Stream 세 가지 페이지네이션의 차이를 설명할 수 있습니다
  2. Specification 조합으로 다양한 필터 조건을 구현할 수 있습니다
  3. 동일한 Specification이 모든 Query 메서드에서 동작하는 것을 확인할 수 있습니다
  4. 각 방식의 성능 특성에 따라 적합한 시나리오를 선택할 수 있습니다

어떤 방식을 선택할지 결정할 때 아래 표를 참고하세요.

특성Search (Offset)SearchByCursor (Keyset)Stream
Total CountOXX
임의 페이지 접근OXX
Deep Page 성능O(N)O(1)N/A
메모리 사용페이지 단위페이지 단위항목 단위
적합한 시나리오UI 목록무한 스크롤배치 처리

세 가지 조회 방식 모두 동일한 Specification을 사용합니다. 필터링(“무엇을 조회할 것인가”)과 페이지네이션(“어떻게 결과를 나눌 것인가”)이 분리되어 있기 때문입니다.

// 재고 있음 AND 가격 30,000~100,000
var spec = new InStockSpec() & new PriceRangeSpec(30_000m, 100_000m);
// 동일한 Specification으로 3가지 조회 수행
await query.Search(spec, page, sort); // Offset
await query.SearchByCursor(spec, cursor, sort); // Cursor
query.Stream(spec, sort); // Stream

각 방식이 반환하는 결과 타입과 다음 페이지 처리 방식이 다릅니다. 용도에 맞는 방식을 선택하세요.

// 1. Offset: TotalCount 제공, 페이지 번호로 접근
var paged = await query.Search(spec, new PageRequest(1, 20), sort);
// paged.TotalCount, paged.TotalPages, paged.HasNextPage
// 2. Cursor: HasMore + NextCursor로 다음 페이지
var cursor = await query.SearchByCursor(spec, new CursorPageRequest(pageSize: 20), sort);
// cursor.HasMore, cursor.NextCursor → 다음 요청의 after로 전달
// 3. Stream: await foreach로 하나씩 소비
await foreach (var item in query.Stream(spec, sort, ct))
{
Process(item); // 메모리 부담 없이 대량 처리
}

각 파일이 페이지네이션 비교에서 어떤 역할을 하는지 확인하세요.

파일역할
ProductId.csUlid 기반 상품 식별자
Product.cs카탈로그 상품 Aggregate
ProductDto.csQuery 측 DTO
InStockSpec.cs재고 > 0 Specification
PriceRangeSpec.cs가격 범위 Specification
InMemoryCatalogQuery.cs3가지 Query 메서드 구현

이 예제에서 사용된 페이지네이션 패턴을 정리하면 다음과 같습니다.

개념구현
Offset 페이지네이션Search(spec, PageRequest, SortExpression)PagedResult<T>
Cursor 페이지네이션SearchByCursor(spec, CursorPageRequest, SortExpression)CursorPagedResult<T>
비동기 스트림Stream(spec, SortExpression)IAsyncEnumerable<T>
Specification 조합new InStockSpec() & new PriceRangeSpec(min, max)
통합 Query AdapterInMemoryCatalogQuery : InMemoryQueryBase<Product, ProductDto>

Q1: Offset과 Cursor를 동시에 제공하는 이유는?

섹션 제목: “Q1: Offset과 Cursor를 동시에 제공하는 이유는?”

A: UI 요구사항에 따라 다릅니다. 관리자 목록(페이지 번호 필요)에는 Offset, 모바일 무한 스크롤에는 Cursor가 적합합니다. InMemoryQueryBase가 두 방식을 모두 구현하므로 UseCase에서 선택하면 됩니다.

A: CSV 내보내기, 데이터 마이그레이션, 통계 집계 등 전체 데이터를 순회해야 하는 배치 작업에 적합합니다. IAsyncEnumerable<T>로 한 건씩 yield하므로 메모리에 전체를 올리지 않습니다.

Q3: 같은 Specification이 세 방식 모두에서 동작하는 이유는?

섹션 제목: “Q3: 같은 Specification이 세 방식 모두에서 동작하는 이유는?”

A: Specification은 “무엇을 필터링할 것인가”의 관심사이고, 페이지네이션은 “어떻게 결과를 나눌 것인가”의 관심사입니다. 두 관심사를 분리했기 때문에 동일한 조건을 다른 방식으로 자유롭게 조합할 수 있습니다.


네 가지 도메인 예제를 통해 CQRS 패턴의 실전 적용을 완성했습니다. 부록에서는 CQRS vs CRUD 비교, Repository vs Query 어댑터 선택 가이드, 안티패턴 등 추가 참고 자료를 제공합니다.

부록 A: CQRS vs 전통적 CRUD