1. 페이지네이션의 기본 개념과 필요성
1-1. 페이지네이션이란 무엇인가
페이지네이션(Pagination)은 대용량 데이터를 효율적으로 조회하고 사용자에게 제공하기 위해 데이터를 일정 단위로 나누어 처리하는 기법이다. 실무에서 테이블에 수십만, 수백만 건의 데이터가 존재할 때 이를 한 번에 모두 조회하면 메모리 부족, 네트워크 대역폭 낭비, 사용자 경험 저하 등의 문제가 발생한다.
예를 들어 쇼핑몰의 상품 테이블에 100만 건의 상품이 있다고 가정해보자. 사용자가 상품 목록 페이지에 접속할 때마다 100만 건을 모두 조회하여 클라이언트로 전송한다면 서버 메모리는 물론이고 네트워크 비용도 엄청나게 증가한다. 또한 사용자는 실제로 처음 몇십 개의 상품만 확인하는 경우가 대부분인데, 불필요한 데이터까지 모두 로딩하느라 응답 시간이 길어지게 된다.
페이지네이션은 이러한 문제를 해결하기 위해 전체 데이터를 10개, 20개 등의 작은 단위로 나누어 필요한 부분만 조회한다. 이를 통해 서버 리소스를 절약하고 응답 속도를 개선하며 사용자 경험을 향상시킬 수 있다.
1-2. 페이지네이션의 두 가지 핵심 방식
데이터베이스에서 페이지네이션을 구현하는 방식은 크게 두 가지로 나뉜다. 오프셋 기반(Offset-based) 페이지네이션과 커서 기반(Cursor-based) 페이지네이션이다. 이 두 방식은 각각 다른 원리로 작동하며, 사용 시나리오와 성능 특성이 완전히 다르다.
오프셋 기반은 전체 데이터셋에서 "몇 번째부터 몇 개"를 가져오는 방식이다. 예를 들어 "처음 20개를 건너뛰고 그 다음 10개를 가져와"와 같은 방식으로 동작한다. 이는 우리가 일반적으로 웹에서 보는 페이지 번호 방식(1, 2, 3...)에 해당한다.
커서 기반은 이전에 조회한 마지막 레코드의 특정 값을 기준으로 "그 다음부터" 데이터를 가져오는 방식이다. 예를 들어 "ID가 100인 레코드 이후부터 10개를 가져와"와 같은 방식이다. 이는 소셜 미디어의 무한 스크롤에서 주로 사용된다.
두 방식은 각각의 장단점이 명확하며, 어떤 방식을 선택할지는 서비스의 요구사항, 데이터의 특성, UI/UX 패턴에 따라 결정된다. 두 방식의 차이를 정확히 이해하고, 상황에 맞는 방식을 선택할 수 있어야 한다.
2. 오프셋 기반 페이지네이션의 이해
2-1. 오프셋 기반 페이지네이션의 작동 원리
오프셋 기반 페이지네이션은 SQL의 OFFSET과 LIMIT 구문을 사용하여 구현된다. 기본 개념은 매우 단순하다.
전체 데이터에서 앞의 N개를 건너뛰고(OFFSET), 그 다음 M개를 가져온다(LIMIT).
예를 들어 상품 테이블에서 3페이지(페이지당 10개)를 조회한다고 가정해보자. 3페이지는 21번째부터 30번째 레코드를 의미하므로, 앞의 20개를 건너뛰고 그 다음 10개를 가져와야 한다.
Oracle 12c 이상 표준 문법
SELECT *
FROM products
ORDER BY product_id
OFFSET 20 ROWS
FETCH NEXT 10 ROWS ONLY;
Oracle 11g 이하 ROWNUM 방식
SELECT *
FROM (
SELECT a.*, ROWNUM rnum
FROM (
SELECT *
FROM products
ORDER BY product_id
) a
WHERE ROWNUM <= 30
)
WHERE rnum > 20;
Oracle 12c부터는 표준 SQL 문법인 OFFSET ... ROWS FETCH NEXT ... ROWS ONLY 구문을 지원하므로 가독성과 유지보수성이 크게 개선되었다.
11g 이하에서는 서브쿼리와 ROWNUM을 중첩하여 사용해야 하므로 쿼리가 복잡해진다.
2-2. JPA에서의 오프셋 기반 페이지네이션 구현
Spring Data JPA는 오프셋 기반 페이지네이션을 매우 쉽게 구현할 수 있도록 Pageable 인터페이스와 Page 타입을 제공한다.
개발자는 복잡한 SQL을 직접 작성할 필요 없이, Pageable 객체를 메서드 파라미터로 전달하기만 하면 된다.
Repository 인터페이스
public interface ProductRepository extends JpaRepository<Product, Long> {
Page<Product> findAll(Pageable pageable);
Page<Product> findByCategory(String category, Pageable pageable);
}
Service 계층 사용 예시
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
public Page<Product> getProducts(int pageNumber, int pageSize) {
// pageNumber는 0부터 시작 (0 = 첫 페이지)
Pageable pageable = PageRequest.of(pageNumber, pageSize,
Sort.by("productId").descending());
return productRepository.findAll(pageable);
}
}
Controller 계층
@RestController
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping("/api/products")
public ResponseEntity<Page<Product>> getProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Page<Product> products = productService.getProducts(page, size);
return ResponseEntity.ok(products);
}
}
Page 객체는 단순히 데이터 목록만 제공하는 것이 아니라, 전체 페이지 수(getTotalPages()), 전체 데이터 개수(getTotalElements()), 현재 페이지 번호(getNumber()), 다음 페이지 존재 여부(hasNext()) 등 페이지네이션에 필요한 모든 메타데이터를 함께 제공한다.
2-3. 실무에서의 오프셋 페이지네이션 최적화
오프셋 기반 페이지네이션을 실무에서 사용할 때는 성능 최적화를 반드시 고려해야 한다. 특히 페이지 번호가 커질수록 성능이 급격히 저하되는 문제가 있다.
성능 저하의 원인: 오프셋 기반 방식에서 OFFSET 10000 LIMIT 10을 실행하면, 데이터베이스는 실제로 10,010개의 레코드를 읽은 후 앞의 10,000개를 버리고 마지막 10개만 반환한다.
사용자는 10개만 받지만 데이터베이스는 10,010개를 처리해야 한다.
실무 최적화 전략
1.인덱스 활용: ORDER BY 절에 사용되는 컬럼에 반드시 인덱스를 생성한다.
CREATE INDEX idx_product_id ON products(product_id);
2.COUNT 쿼리 최적화: JPA의 Page 타입은 전체 개수를 조회하기 위해 추가 COUNT 쿼리를 실행한다. 데이터가 많을 경우 이 COUNT 쿼리도 부담이 될 수 있다. 전체 개수가 필요 없다면 Slice 타입을 사용하는 것이 좋다.
public interface ProductRepository extends JpaRepository<Product, Long> {
Slice<Product> findByCategory(String category, Pageable pageable);
}
Slice는 전체 개수를 조회하지 않고, "다음 페이지가 있는지"만 확인한다.
따라서 COUNT 쿼리가 실행되지 않아 성능이 개선된다.
3.깊은 페이지 접근 제한: 실무에서는 사용자가 100페이지 이상의 깊은 페이지에 접근하는 경우가 거의 없다.
검색 엔진들도 대부분 첫 10~20페이지 정도만 제공한다. 따라서 페이지 접근을 제한하는 것도 하나의 전략이다.
public Page<Product> getProducts(int pageNumber, int pageSize) {
if (pageNumber > 100) {
throw new IllegalArgumentException("페이지는 100까지만 조회 가능합니다");
}
Pageable pageable = PageRequest.of(pageNumber, pageSize);
return productRepository.findAll(pageable);
}
2-4. 오프셋 페이지네이션의 데이터 정합성 문제
오프셋 기반 페이지네이션의 가장 큰 문제 중 하나는 데이터 드리프트(Data Drift) 현상이다. 이는 사용자가 페이지를 조회하는 동안 데이터가 추가되거나 삭제될 때 발생한다.
문제 시나리오 1: 데이터 중복
- 사용자가 1페이지(1~10번)를 조회한다
- 다른 사용자가 새 상품을 등록하여 맨 앞에 추가된다
- 사용자가 2페이지(11~20번)를 조회한다
- 결과: 1페이지의 10번째 상품이 2페이지의 11번째로 밀려나서 중복 표시된다
문제 시나리오 2: 데이터 누락
- 사용자가 1페이지(1~10번)를 조회한다
- 관리자가 1번 상품을 삭제한다
- 사용자가 2페이지(11~20번)를 조회한다
- 결과: 원래 11번째였던 상품이 10번째로 당겨져서 조회되지 않는다
이러한 문제는 실시간으로 데이터 변경이 빈번한 서비스(커뮤니티, SNS, 뉴스 피드 등)에서 특히 심각하다.
하지만 데이터 변경이 적고 사용자가 페이지를 빠르게 넘기지 않는 서비스(관리자 페이지, 통계 조회 등)에서는 큰 문제가 되지 않을 수 있다.
실무 대응 방안
- 데이터 정합성이 중요하다면 커서 기반 페이지네이션을 사용한다
- 오프셋 기반을 유지하면서도 정합성을 높이려면, 조회 시점의 스냅샷을 캐싱하거나 타임스탬프 기반 필터링을 추가한다
- 사용자에게 "실시간 데이터이므로 일부 중복/누락이 있을 수 있음"을 안내한다
3. 커서 기반 페이지네이션의 이해
3-1. 커서 기반 페이지네이션의 작동 원리
커서 기반 페이지네이션은 오프셋 방식과 근본적으로 다른 접근법을 사용한다. "몇 번째부터"가 아니라 "어떤 값 이후부터"를 기준으로 데이터를 조회한다. 이때 기준이 되는 값을 **커서(Cursor)**라고 한다.
커서는 일반적으로 정렬 기준이 되는 컬럼의 값을 사용한다. 대부분의 경우 기본 키(Primary Key)인 ID를 커서로 사용하지만, 생성일시(created_at), 수정일시(updated_at) 등도 사용할 수 있다.
중요한 점은 커서로 사용되는 컬럼은 고유하고 정렬 가능해야 한다는 것이다.
Oracle에서의 커서 기반 쿼리 예시
-- 첫 페이지 조회 (커서 없음)
SELECT *
FROM products
WHERE product_id > 0
ORDER BY product_id ASC
FETCH NEXT 10 ROWS ONLY;
-- 두 번째 페이지 조회 (첫 페이지의 마지막 ID가 10이었다고 가정)
SELECT *
FROM products
WHERE product_id > 10
ORDER BY product_id ASC
FETCH NEXT 10 ROWS ONLY;
-- 세 번째 페이지 조회 (두 번째 페이지의 마지막 ID가 20이었다고 가정)
SELECT *
FROM products
WHERE product_id > 20
ORDER BY product_id ASC
FETCH NEXT 10 ROWS ONLY;
이 방식의 핵심은 WHERE 절에서 커서 값을 기준으로 필터링한다는 것이다.
데이터베이스는 인덱스를 활용하여 커서 이후의 데이터만 검색하므로, 페이지가 아무리 깊어져도 성능이 일정하게 유지된다.
3-2. JPA에서의 커서 기반 페이지네이션 구현
Spring Data JPA는 오프셋 기반과 달리 커서 기반 페이지네이션을 위한 직접적인 지원을 제공하지 않는다.
따라서 개발자가 직접 커스텀 메서드를 작성해야 한다.
Repository 커스텀 메서드
public interface ProductRepository extends JpaRepository<Product, Long> {
// 첫 페이지 조회용 (커서 없음)
List<Product> findTop20ByOrderByProductIdAsc();
// 다음 페이지 조회용 (커서 기반)
List<Product> findTop20ByProductIdGreaterThanOrderByProductIdAsc(Long lastId);
// 이전 페이지 조회용 (역방향)
List<Product> findTop20ByProductIdLessThanOrderByProductIdDesc(Long firstId);
}
Service 계층 구현
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductRepository productRepository;
public CursorPage<Product> getProducts(Long cursor, int size) {
List<Product> products;
if (cursor == null) {
// 첫 페이지
products = productRepository.findTop20ByOrderByProductIdAsc();
} else {
// 다음 페이지
products = productRepository
.findTop20ByProductIdGreaterThanOrderByProductIdAsc(cursor);
}
Long nextCursor = null;
if (!products.isEmpty()) {
nextCursor = products.get(products.size() - 1).getProductId();
}
return new CursorPage<>(products, nextCursor, products.size() == size);
}
}
커스텀 응답 DTO
@Getter
@AllArgsConstructor
public class CursorPage<T> {
private List<T> content;
private Long nextCursor; // 다음 페이지를 위한 커서
private boolean hasNext; // 다음 페이지 존재 여부
}
Controller
@RestController
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping("/api/products/cursor")
public ResponseEntity<CursorPage<Product>> getProductsByCursor(
@RequestParam(required = false) Long cursor,
@RequestParam(defaultValue = "20") int size) {
CursorPage<Product> page = productService.getProducts(cursor, size);
return ResponseEntity.ok(page);
}
}
'공부일기.. > DataBase' 카테고리의 다른 글
| [DB] Pagination 시 Offset-based와 Cursor-based 고려조건 (0) | 2025.12.01 |
|---|---|
| [mySql 인덱스 설계] 기본원칙, 설계방법 , 주의사항!!! (3) | 2025.07.28 |
| [인덱스 용어정리] 클러스터 인덱스 ,보조인덱스, 단일 인덱스,복합 인덱스, 커버링 인덱스 (1) | 2025.07.27 |
| [DB 인덱스 개념 정리] B+Tree, 클러스터드 인덱스, 카디널리티, 커버링 인덱스, 디스크와 메모리 (2) | 2025.07.27 |
| [ERD] 이커머스 ERD 설계 과정: 정규화, 관계 설계, 제약 조건 (1) | 2025.07.18 |