1. 오프셋 vs 커서
1-1. 성능 비교와 벤치마크
두 방식의 성능 차이는 데이터 규모와 페이지 깊이에 따라 극명하게 나타난다. 실제 환경에서의 성능 특성을 이해하는 것이 중요하다.
오프셋 기반 성능 특성
- 첫 페이지 (OFFSET 0): 매우 빠름
- 중간 페이지 (OFFSET 10000): 점진적 성능 저하
- 깊은 페이지 (OFFSET 100000): 심각한 성능 저하
커서 기반 성능 특성
- 첫 페이지: 매우 빠름
- 중간 페이지: 빠름 (일정)
- 깊은 페이지: 빠름 (일정)
실무 벤치마크 예시 (100만 건 데이터 기준)
오프셋 기반:
- OFFSET 0 LIMIT 10: 5ms
- OFFSET 10000 LIMIT 10: 50ms
- OFFSET 100000 LIMIT 10: 500ms
- OFFSET 500000 LIMIT 10: 2000ms
커서 기반:
- WHERE id > 0 LIMIT 10: 5ms
- WHERE id > 10000 LIMIT 10: 5ms
- WHERE id > 100000 LIMIT 10: 5ms
- WHERE id > 500000 LIMIT 10: 5ms
커서 기반은 페이지 깊이와 무관하게 일정한 성능을 보이지만, 오프셋 기반은 깊어질수록 선형적으로 느려진다.
1-2. UI/UX 요구사항에 따른 선택
페이지네이션 방식은 기술적 성능뿐만 아니라 사용자 경험과도 밀접하게 연관되어 있다.
오프셋 기반이 적합한 경우
- 페이지 번호가 명시적으로 표시되어야 하는 경우
- 사용자가 특정 페이지로 직접 이동해야 하는 경우
- 전체 페이지 수를 알아야 하는 경우
- 검색 결과 페이지 (Google, 네이버 검색)
- 관리자 페이지의 데이터 테이블
- 전자상거래 상품 목록 (페이지 번호 클릭)
커서 기반이 적합한 경우
- 무한 스크롤 UI
- 모바일 앱의 피드
- 실시간 업데이트가 빈번한 데이터
- SNS 타임라인 (Facebook, Twitter, Instagram)
- 채팅 메시지 목록
- 뉴스 피드
실무 의사결정 프로세스
- UI 디자인 확인: 페이지 번호가 있는가? 무한 스크롤인가?
- 데이터 규모 예측: 몇십만 건 이상인가?
- 업데이트 빈도: 실시간 업데이트가 많은가?
- 사용자 행동 패턴: 깊은 페이지까지 탐색하는가?
1-3. 하이브리드 접근법
실무에서는 두 방식을 혼합하여 사용하는 경우도 많다. 각각의 장점을 살리고 단점을 보완하는 전략이다.
전략 1: 얕은 페이지는 오프셋, 깊은 페이지는 커서
public Object getProducts(Integer pageNumber, Long cursor, int size) {
// 처음 몇 페이지는 오프셋 방식 (페이지 번호 제공)
if (cursor == null && pageNumber != null && pageNumber < 10) {
Pageable pageable = PageRequest.of(pageNumber, size);
return productRepository.findAll(pageable);
}
// 깊은 페이지는 커서 방식
return productRepository
.findTop20ByProductIdGreaterThanOrderByProductIdAsc(cursor);
}
전략 2: 검색 결과는 오프셋, 피드는 커서
@RestController
public class ProductController {
// 검색 결과: 오프셋 기반
@GetMapping("/api/products/search")
public Page<Product> search(
@RequestParam String keyword,
@RequestParam int page) {
return productService.search(keyword, page);
}
// 추천 피드: 커서 기반
@GetMapping("/api/products/feed")
public CursorPage<Product> getFeed(@RequestParam Long cursor) {
return productService.getFeed(cursor);
}
}
전략 3: 첫 페이지는 캐싱, 나머지는 커서
@Service
public class ProductService {
@Cacheable(value = "firstPage", key = "#category")
public List<Product> getFirstPage(String category) {
// 첫 페이지는 캐싱하여 빠르게 제공
return productRepository.findTop20ByCategoryOrderByProductIdDesc(category);
}
public List<Product> getNextPages(String category, Long cursor) {
// 나머지는 커서 기반
return productRepository
.findTop20ByCategoryAndProductIdLessThanOrderByProductIdDesc(
category, cursor);
}
}
1-4. 데이터 정합성 요구사항에 따른 선택
데이터 정합성이 얼마나 중요한지에 따라서도 선택이 달라진다.
정합성이 중요하지 않은 경우 (오프셋 가능)
- 히스토리성 데이터 (로그, 통계)
- 변경이 거의 없는 마스터 데이터
- 정확성보다 편의성이 중요한 관리 화면
- 일일 배치로 업데이트되는 데이터
정합성이 중요한 경우 (커서 권장)
- 실시간 거래 데이터
- 금융 거래 내역
- 주문 목록
- 실시간 채팅 메시지
- SNS 피드
실무 예시: 전자상거래 주문 목록
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public CursorPage<Order> getMyOrders(Long userId, Long cursor, int size) {
// 주문 목록은 중복/누락이 있으면 안 되므로 커서 기반 사용
List<Order> orders;
if (cursor == null) {
orders = orderRepository
.findTop20ByUserIdOrderByOrderIdDesc(userId);
} else {
orders = orderRepository
.findTop20ByUserIdAndOrderIdLessThanOrderByOrderIdDesc(
userId, cursor);
}
Long nextCursor = orders.isEmpty() ? null :
orders.get(orders.size() - 1).getOrderId();
return new CursorPage<>(orders, nextCursor, orders.size() == size);
}
}
2. 실무 구현 패턴과 베스트 프랙티스
2-1. DTO 계층 설계
페이지네이션 응답은 명확한 DTO 계층으로 설계해야 한다. 엔티티를 직접 노출하지 않고, API 스펙에 맞는 응답 구조를 만들어야 한다.
오프셋 기반 응답 DTO
@Getter
@AllArgsConstructor
public class PageResponse<T> {
private List<T> content;
private int pageNumber;
private int pageSize;
private long totalElements;
private int totalPages;
private boolean first;
private boolean last;
public static <T> PageResponse<T> from(Page<T> page) {
return new PageResponse<>(
page.getContent(),
page.getNumber(),
page.getSize(),
page.getTotalElements(),
page.getTotalPages(),
page.isFirst(),
page.isLast()
);
}
}
커서 기반 응답 DTO
@Getter
@AllArgsConstructor
public class CursorResponse<T> {
private List<T> content;
private String nextCursor; // Base64 인코딩된 커서
private boolean hasNext;
public static <T> CursorResponse<T> of(
List<T> content,
String nextCursor,
boolean hasNext) {
return new CursorResponse<>(content, nextCursor, hasNext);
}
}
커서 인코딩 유틸리티
@Component
public class CursorEncoder {
public String encode(Long id) {
if (id == null) return null;
return Base64.getEncoder()
.encodeToString(id.toString().getBytes(StandardCharsets.UTF_8));
}
public Long decode(String cursor) {
if (cursor == null) return null;
byte[] decoded = Base64.getDecoder().decode(cursor);
return Long.parseLong(new String(decoded, StandardCharsets.UTF_8));
}
}
클라이언트에게 커서를 불투명한(opaque) 문자열로 제공하면, 내부 구현을 숨길 수 있고 나중에 커서 구조를 변경하기도 쉽다.
2-2. 예외 처리와 검증
페이지네이션 파라미터에 대한 철저한 검증과 예외 처리가 필요하다.
Request DTO with Validation
@Getter
@Setter
public class PageRequest {
@Min(0)
@Max(100)
private int page = 0;
@Min(1)
@Max(100)
private int size = 20;
@Pattern(regexp = "^(productId|createdAt|price)$")
private String sortBy = "productId";
@Pattern(regexp = "^(ASC|DESC)$")
private String direction = "DESC";
}
커서 검증
@Service
@RequiredArgsConstructor
public class ProductService {
private final CursorEncoder cursorEncoder;
private final ProductRepository productRepository;
public CursorResponse<ProductDto> getProducts(String cursorString, int size) {
// 사이즈 검증
if (size < 1 || size > 100) {
throw new IllegalArgumentException("Size must be between 1 and 100");
}
// 커서 디코딩 및 검증
Long cursor = null;
if (cursorString != null) {
try {
cursor = cursorEncoder.decode(cursorString);
if (cursor < 0) {
throw new IllegalArgumentException("Invalid cursor");
}
} catch (IllegalArgumentException e) {
throw new InvalidCursorException("Malformed cursor", e);
}
}
// 조회 로직
List<Product> products = cursor == null
? productRepository.findTop20ByOrderByProductIdDesc()
: productRepository.findTop20ByProductIdLessThanOrderByProductIdDesc(cursor);
// 응답 생성
String nextCursor = products.isEmpty() ? null :
cursorEncoder.encode(products.get(products.size() - 1).getProductId());
List<ProductDto> dtos = products.stream()
.map(ProductDto::from)
.toList();
return CursorResponse.of(dtos, nextCursor, products.size() == size);
}
}
글로벌 예외 핸들러
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InvalidCursorException.class)
public ResponseEntity<ErrorResponse> handleInvalidCursor(
InvalidCursorException e) {
return ResponseEntity
.badRequest()
.body(new ErrorResponse("INVALID_CURSOR", e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(
MethodArgumentNotValidException e) {
String message = e.getBindingResult()
.getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ResponseEntity
.badRequest()
.body(new ErrorResponse("VALIDATION_ERROR", message));
}
}
2-3. N+1 문제 해결
페이지네이션과 함께 연관 엔티티를 조회할 때 N+1 문제가 발생하기 쉽다.
문제 상황
// Product 엔티티
@Entity
public class Product {
@Id
private Long productId;
@ManyToOne(fetch = FetchType.LAZY)
private Category category;
@OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
private List<ProductImage> images;
}
// 문제가 되는 코드
Page<Product> products = productRepository.findAll(pageable);
products.forEach(p -> {
System.out.println(p.getCategory().getName()); // N번 쿼리 발생!
System.out.println(p.getImages().size()); // N번 쿼리 발생!
});
해결 방법 1: Fetch Join 사용
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("SELECT DISTINCT p FROM Product p " +
"LEFT JOIN FETCH p.category " +
"LEFT JOIN FETCH p.images")
Page<Product> findAllWithDetails(Pageable pageable);
}
하지만 Fetch Join과 Pageable을 함께 사용하면 Hibernate가 경고를 발생시킨다.
컬렉션 Fetch Join은 메모리에서 페이징을 수행하기 때문이다.
해결 방법 2: @EntityGraph 사용
public interface ProductRepository extends JpaRepository<Product, Long> {
@EntityGraph(attributePaths = {"category", "images"})
Page<Product> findAll(Pageable pageable);
}
해결 방법 3: @BatchSize 사용
@Entity
public class Product {
@Id
private Long productId;
@ManyToOne(fetch = FetchType.LAZY)
private Category category;
@BatchSize(size = 100)
@OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
private List<ProductImage> images;
}
@BatchSize를 사용하면 N+1 쿼리 대신 IN 절을 사용한 일괄 조회가 이루어진다.
해결 방법 4: DTO 직접 조회 (가장 권장)
@Query("SELECT new com.example.dto.ProductWithCategoryDto(" +
"p.productId, p.name, p.price, c.name) " +
"FROM Product p " +
"JOIN p.category c")
Page<ProductWithCategoryDto> findAllAsDto(Pageable pageable);
필요한 컬럼만 선택하여 DTO로 직접 조회하는 것이 가장 효율적이다.
2-4. 캐싱 전략
페이지네이션 결과를 캐싱하여 성능을 크게 향상시킬 수 있다.
첫 페이지 캐싱
@Service
@RequiredArgsConstructor
public class ProductService {
@Cacheable(
value = "products:firstPage",
key = "#category + ':' + #size",
unless = "#result.isEmpty()"
)
public List<ProductDto> getFirstPage(String category, int size) {
return productRepository
.findTop20ByCategoryOrderByProductIdDesc(category)
.stream()
.map(ProductDto::from)
.toList();
}
}
Redis 설정
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.build();
}
}
주의사항
- 실시간성이 중요한 데이터는 캐시 TTL을 짧게 설정한다
- 데이터 변경 시 캐시를 무효화(invalidate)하는 로직이 필요하다
- 캐시 키 설계를 신중하게 해야 한다 (카테고리, 정렬 옵션 등 모두 포함)
2-5. 로깅과 모니터링
페이지네이션 성능 문제를 조기에 발견하기 위한 로깅과 모니터링이 중요하다.
쿼리 성능 로깅
# application.yml
spring:
jpa:
properties:
hibernate:
show_sql: true
format_sql: true
use_sql_comments: true
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
커스텀 AOP 로깅
@Aspect
@Component
@Slf4j
public class PaginationLoggingAspect {
@Around("@annotation(com.example.annotation.LogPagination)")
public Object logPagination(ProceedingJoinPoint joinPoint) throws Throwable {
long start = System.currentTimeMillis();
Object[] args = joinPoint.getArgs();
log.info("Pagination started: method={}, args={}",
joinPoint.getSignature().getName(), args);
Object result = joinPoint.proceed();
long duration = System.currentTimeMillis() - start;
if (result instanceof Page) {
Page<?> page = (Page<?>) result;
log.info("Pagination completed: duration={}ms, totalElements={}, pageNumber={}",
duration, page.getTotalElements(), page.getNumber());
} else if (result instanceof CursorResponse) {
CursorResponse<?> cursor = (CursorResponse<?>) result;
log.info("Cursor pagination completed: duration={}ms, resultSize={}, hasNext={}",
duration, cursor.getContent().size(), cursor.isHasNext());
}
if (duration > 1000) {
log.warn("Slow pagination detected: duration={}ms", duration);
}
return result;
}
}
메트릭 수집
@Service
@RequiredArgsConstructor
public class ProductService {
private final MeterRegistry meterRegistry;
private final ProductRepository productRepository;
public Page<Product> getProducts(int page, int size) {
Timer.Sample sample = Timer.start(meterRegistry);
try {
Page<Product> result = productRepository.findAll(
PageRequest.of(page, size));
sample.stop(Timer.builder("pagination.query.time")
.tag("type", "offset")
.tag("page", String.valueOf(page))
.register(meterRegistry));
return result;
} catch (Exception e) {
meterRegistry.counter("pagination.errors",
"type", "offset").increment();
throw e;
}
}
}
Prometheus + Grafana를 연동하면 페이지네이션 쿼리의 응답 시간, 에러율, 페이지별 성능 등을 시각화하여 모니터링할 수 있다!
끗 !
'공부일기.. > DataBase' 카테고리의 다른 글
| [DB] 페이지네이션 종류 (커서 , 오프셋) (0) | 2025.11.30 |
|---|---|
| [mySql 인덱스 설계] 기본원칙, 설계방법 , 주의사항!!! (3) | 2025.07.28 |
| [인덱스 용어정리] 클러스터 인덱스 ,보조인덱스, 단일 인덱스,복합 인덱스, 커버링 인덱스 (1) | 2025.07.27 |
| [DB 인덱스 개념 정리] B+Tree, 클러스터드 인덱스, 카디널리티, 커버링 인덱스, 디스크와 메모리 (2) | 2025.07.27 |
| [ERD] 이커머스 ERD 설계 과정: 정규화, 관계 설계, 제약 조건 (1) | 2025.07.18 |