BindProject

페이지네이션 완전 정복: `COUNT` 내 서비스에 맞는 최적의 전략 찾기

dding-shark 2025. 7. 24. 03:59
728x90

 


페이지네이션 완전 정복: COUNT 쿼리의 함정과 내 서비스에 맞는 최적의 전략 찾기

이번에는 저희 user-profile 서비스의 유저 리스트 조회 기능을 다시 돌아보면서, 페이지네이션(Pagination)에 대해 깊이 파고들어 보겠습니다.

많은 개발자들이 Spring Data JPA가 기본 제공하는 Page 객체를 이용한 오프셋(Offset) 기반 페이지네이션으로 개발을 시작합니다. 하지만 서비스가 성장하고 데이터가 쌓이면, 이 방식은 사용자 경험과 서버 성능에 문제를 일으키는 주범이 되기도 합니다.

이 글에서는 전통적인 오프셋 방식의 숨겨진 복병, COUNT 쿼리의 함정부터, 모던 애플리케이션에서 사랑받는 커서(Cursor) 기반 페이지네이션까지, 실제 저희 서비스의 코드를 예시로 들어 명확하게 비교하고 각 방식의 트레이드오프를 분석하여 내 서비스에 맞는 최적의 전략을 찾아보겠습니다.

 

1. 오프셋(Offset) 기반 페이지네이션 (Page)

가장 고전적이고 직관적인 방식입니다. "10페이지의 20개 아이템을 보여줘"처럼, (페이지 번호 - 1) * 페이지 사이즈 공식을 이용해 건너뛸(Offset) 아이템 수를 계산하여 데이터를 조회합니다.

 

 

내 코드에서는?

 

저희 user-profile 서비스의 UserProfileController는 이 방식을 /list 엔드포인트에서 사용하고 있습니다.

@GetMapping("/list")
public ResponseEntity<BaseResponse<?>> searchProfiles(
        @ModelAttribute ProfileSearchCondition condition,
        @PageableDefault(size = 20, sort = "updatedAt", direction = Sort.Direction.DESC)
        Pageable pageable
) {
    // Pageable 객체를 통해 'page', 'size', 'sort' 정보를 받습니다.
    var profiles = userProfileQueryService.searchProfiles(condition, pageable);
    return ResponseEntity.ok(BaseResponse.success(profiles));
}

Page 객체는 조회된 데이터 목록 외에도 전체 데이터 개수(Total Elements)와 전체 페이지 수 정보를 포함하는데, 이것이 바로 오프셋 방식의 핵심 특징이자 양날의 검입니다.

 

 

 

 

2. COUNT 쿼리가 성능 재앙을 부르는 시나리오

"전체 데이터 개수"를 아는 것은 편리하지만, 이 정보를 얻기 위한 COUNT 쿼리는 대규모 테이블에서 예기치 못한 성능 병목을 일으키는 주범입니다. 상상만 해도 끔찍한 시나리오를 하나 그려보겠습니다.

  1. 상황: 저희 user-profile 서비스의 유저 검색 기능이 인기를 끌어 수백만 건의 사용자 데이터가 쌓였습니다.
  2. 요청: 한 사용자가 여러 검색 조건(지역: 서울, 관심 악기: 기타)을 걸고 검색한 후, 저기 멀리 있는 5,000번째 페이지의 결과를 보기 위해 페이지 번호를 클릭합니다.
  3. 서버의 움직임 (오프셋 방식): /list 엔드포인트가 호출되고, 서버는 DB에게 2가지 재앙적인 작업을 요청합니다.
    • 1단계: COUNT 쿼리 실행 (첫 번째 재앙)
      DB는 이 요청에 응답하기 위해, user_profile 테이블에서 검색 조건에 맞는 모든 데이터를 찾아 그 개수를 세어야 합니다. 테이블이 크고 조건이 복잡할수록, DB는 인덱스를 타더라도 수많은 데이터 블록을 읽어야 해서 엄청난 부담을 느낍니다.
-- 이 쿼리가 먼저 실행됩니다.
        SELECT COUNT(*) FROM user_profile WHERE city = 'SEOUL' AND ...;
*   **2단계: `OFFSET` 쿼리 실행 (두 번째 재앙)**
    DB는 `OFFSET 99980`을 보고 경악합니다. 99,981번째 데이터로 바로 점프하는 것이 아니라, **조건에 맞는 데이터 99,980개를 일단 다 읽어서 메모리에 올린 후, 정렬하고, 그 결과를 전부 버립니다.** 그리고 나서야 99,981번째부터 20개를 가져와 사용자에게 보여줍니다.
-- 그 다음, 실제 데이터를 가져오는 이 쿼리가 실행됩니다.
        -- (5000페이지 * 20개 = OFFSET 99980)
        SELECT * FROM user_profile WHERE city = 'SEOUL' AND ...
        ORDER BY updatedAt DESC
        LIMIT 20 OFFSET 99980;

이 두 번의 비효율적인 쿼리 때문에, 사용자는 하염없이 로딩 스피너만 바라보게 되고, DB는 과부하에 시달리게 됩니다.

 

 

 

 

3. 모던 애플리케이션의 선택: 커서(Cursor)와 슬라이스(Slice)

이 재앙을 피하기 위한 현대적인 해법이 바로 커서 기반 페이지네이션입니다. "마지막으로 본 아이템(커서) 다음부터 20개를 보여줘" 방식으로 동작하여, OFFSET 대신 WHERE 절로 조회 시작점을 명확히 합니다.

 

 

내 코드에서는?

UserProfileController/search/cursor 엔드포인트가 커서 기반 페이지네이션의 좋은 예입니다.

@GetMapping("/search/cursor")
public ResponseEntity<BaseResponse<CursorResult<UserProfileResponse>>> searchProfilesByCursor(
        @ModelAttribute ProfileSearchCondition condition,
        @Parameter(description = "이전 페이지의 마지막 프로필 ID. 첫 페이지 조회 시에는 생략합니다.")
        @RequestParam(required = false) Long lastId, // '커서' 역할을 하는 lastId
        @Parameter(hidden = true) Pageable pageable
) {
    CursorResult<UserProfileResponse> results = userProfileQueryService.searchProfilesByCursor(condition, lastId, pageable);
    return ResponseEntity.ok(BaseResponse.success(results));
}

이 방식은 앞선 시나리오를 우아하게 해결합니다. 클라이언트가 마지막으로 본 userId(예: 12345)를 lastId로 보내면, 쿼리는 다음과 같이 실행됩니다.

-- COUNT 쿼리는 아예 존재하지 않습니다.
-- OFFSET 없이, 인덱스를 타는 WHERE 조건으로 시작점을 바로 찾습니다.
SELECT *
FROM user_profile
WHERE
    user_id < 12345 -- 커서: 인덱스를 사용하여 탐색 시작점을 즉시 찾음
    AND city = 'SEOUL' -- 나머지 조건
ORDER BY user_id DESC
LIMIT 20; -- 필요한 20개만 읽음

DB는 수십만 개의 데이터를 읽고 버리는 대신, 인덱스를 통해 효율적으로 시작점을 찾아 필요한 20개(+1개)의 데이터만 읽습니다. 몇 번째 페이지를 조회하든 성능은 거의 동일합니다.

사실 이 구현의 핵심에는 Spring Data JPA의 Slice가 있습니다. SlicePage와 달리 불필요한 COUNT 쿼리를 실행하지 않고, 요청한 size보다 1개 더 많은 데이터를 조회해 다음 페이지 유무(hasNext())만 판단합니다. 저희 UserProfileQueryService는 Repository로부터 Slice를 받아, 이를 CursorResult DTO로 가공하여 반환하는 정석적인 방식을 사용하고 있습니다.

 

 

 

 

 

4. 성능 관점의 트레이드오프 최종 비교

구분 오프셋 기반 (Page) 커서/슬라이스 기반 (Slice/Cursor)
주요 쿼리 1. SELECT COUNT(...) WHERE ...
2. SELECT ... WHERE ... ORDER BY ... LIMIT ? OFFSET ?
SELECT ... WHERE cursor > ? AND ... ORDER BY cursor LIMIT ?
DB 작업 방식 1. 전체 스캔: 조건에 맞는 모든 데이터를 찾아 개수를 셈
2. 대량 읽기 후 버리기: OFFSET 만큼의 데이터를 읽고 정렬한 후 버림
1. 인덱스 탐색(Index Seek): WHERE절의 커서 조건을 이용해 시작점을 빠르게 찾음
2. 필요한 만큼만 읽기: LIMIT 만큼의 데이터만 읽음
성능 (뒷 페이지) 데이터가 많고 페이지가 깊어질수록 심각하게 저하됨 데이터 양과 상관없이 항상 일정하고 빠름
데이터 정합성 데이터 추가/삭제 시 중복/누락 발생 가능 안정적 (Stateless)
서버/DB 부하 매우 높음. 불필요한 I/O와 CPU 사용량이 많음 매우 낮음. 최소한의 리소스로 필요한 작업만 수행
핵심 사용 사례 관리자 페이지, 전체 개수와 페이지 점프가 필수적인 곳 모바일 피드, 댓글, 무한 스크롤 등 실시간 사용자 경험이 중요한 곳

 

 

 

 

결론: 상황에 맞는 최적의 전략을 선택하라

성능이 중요한 현대 애플리케이션, 특히 대용량 데이터를 다루는 서비스에서 오프셋 기반 페이지네이션은 '안티 패턴'에 가깝습니다.

  • 사용자향 서비스의 메인 기능(피드, 목록, 검색 결과 등)에는 반드시 커서/슬라이스 기반 페이지네이션을 기본으로 채택해야 합니다. 이는 서버의 안정성과 사용자 경험을 모두 지키는 길입니다.
  • 전체 개수와 페이지 점프 기능이 반드시 필요한 관리자 페이지 등 제한적인 경우에만 오프셋 기반 페이지네이션을 사용하되, 데이터 양이 많아질 경우 성능 모니터링을 필수로 해야 합니다.

결국 최고의 아키텍처는 하나의 방식만 고집하는 것이 아니라, 저희 user-profile 서비스가 두 가지 엔드포인트(/list/search/cursor)를 모두 제공하는 것처럼, 각 기능의 요구사항과 트레이드오프를 명확히 이해하고 상황에 맞는 최적의 도구를 선택하는 것입니다.

728x90