포스트

딥 페이지네이션은 왜 막혀 있는가: from+size의 비용과 search_after

from+size가 샤드마다 from+size를 힙에 쌓는 구조와 max_result_window 제한의 근거, search_after·PIT·scroll의 선택 기준, 그리고 UI 결정이 곧 아키텍처 결정인 이유.

딥 페이지네이션은 왜 막혀 있는가: from+size의 비용과 search_after

from=100000&size=20을 던지면 Elasticsearch가 거부한다.

1
Result window is too large, from + size must be less than or equal to: [10000]

설정을 올리면 되는 것처럼 보이지만, 이 제한은 임의로 정한 숫자가 아니라 분산 검색의 구조에서 나오는 비용을 막는 안전장치다. 그 구조를 알면 올리는 게 답이 아니라는 것도 같이 보인다.

from + size는 샤드마다 from + size를 쌓는다

핵심은 이것이다. 어느 샤드에 상위 문서가 있는지 미리 알 수 없다. 그래서 코디네이터 노드는 모든 샤드에 “네 기준 상위 from + size개를 달라”고 요청해야 한다.

요청: from = 9,980 size = 20 → 사용자에게 보여줄 건 20건 샤드 1 상위 10,000건을 힙에 쌓는다 샤드 2 상위 10,000건 샤드 3 상위 10,000건 … 샤드 5개면 총 50,000건이 네트워크로 코디네이터에 모인다 코디네이터 노드 50,000건을 병합 정렬해서 9,981번째부터 20건만 남기고 버린다 즉 20건을 위해 50,000건을 옮기고 정렬한다. 비용은 from에 비례해 선형으로 는다. 힙 사용량도 같이 늘어서, 깊은 페이지 요청이 몇 개만 겹쳐도 클러스터가 위험해진다.

index.max_result_window의 기본값 10,000은 그 선형 증가를 어디서 끊을지를 정한 값이다. 올리면 당장은 되지만 비용 구조는 그대로다. 페이지가 깊어질수록 느려지고, 동시 요청이 겹치면 OOM으로 간다.

정렬을 뒤집는 우회 — 되지만 절반만

접근 가능 범위를 넓히는 영리한 우회가 하나 있다. 뒤쪽 페이지를 요청하면 정렬을 뒤집어서 앞쪽에서 접근하는 것이다.

1
2
3
4
5
6
7
if (inLastWindow) {
    long startFromEnd = totalHits - offset - size;
    from = (int) startFromEnd;
    reverseSort = true;              // 정렬 방향을 뒤집는다
}
...
if (reverseSort) Collections.reverse(results);   // 받은 뒤 다시 뒤집어 원래 순서로
전체 결과 (예: 50,000건) 앞 10,000건 정방향 정렬 접근 불가 구간 어느 방향으로도 window를 넘는다 뒤 10,000건 역방향 정렬 후 결과 뒤집기 접근 범위를 1만에서 2만으로 넓히는 실용적인 우회다. 다만 비용 구조는 그대로고, 전체 건수가 2만을 넘는 순간 가운데는 여전히 못 간다. 근본 해법이 아니라 완화책이다.

정식 해법 — search_after

search_after는 오프셋 대신 커서를 쓴다. 직전 페이지 마지막 문서의 정렬 키 값을 넘기면, 그 값 뒤부터 이어서 읽는다. from은 항상 0이다.

페이지 깊이에 따라 각 샤드가 힙에 쌓는 문서 수 — 가로축 로그 눈금 from + size 1 페이지 20 100 페이지 2,020 500 페이지 10,020 여기서 max_result_window에 걸려 거부된다 search_after 1 페이지 20 500 페이지 20 from은 항상 0 — 커서 뒤부터 20건만 읽는다 10 100 1,000 10,000

비용이 페이지 깊이와 무관해진다. 대가는 임의 페이지 점프 불가 — 순차 이동만 가능하다.

1
2
3
4
{ "size": 20,
  "sort": [ { "created_at": "desc" }, { "_id": "asc" } ],
  "search_after": [ 1735689600000, "doc-8842" ]
}

두 가지가 필수다.

  • 정렬 키가 유일해야 한다. 마지막 정렬 필드로 _id처럼 고유한 값을 덧붙인다(tie-breaker). 안 그러면 같은 값에서 경계가 흔들려 누락·중복이 생긴다.
  • 커서는 정렬 값이지 오프셋이 아니다. 그래서 “3페이지에서 17페이지로 점프”가 불가능하다. 다음/이전만 된다.

정렬 안정성 자체는 검색 결과 정렬의 안정성에서 따로 다뤘다.

네 가지 방식 비교

방식깊이 비용임의 점프스냅샷 일관성용도
from + size선형 증가 (샤드마다 from+size)가능없음얕은 페이지, 페이지 번호 UI
search_after일정불가 (순차만)없음 (PIT과 조합하면 있음)무한 스크롤, 더 보기
search_after + PIT일정불가있음페이지를 넘기는 동안 결과가 흔들리면 안 될 때
scroll일정불가있음전량 추출(배치·재색인). 실시간 UI용 아님

scroll은 커서 컨텍스트를 서버가 들고 있어 자원을 점유한다. 지금은 사용자 페이지네이션에 권장되지 않고, 대량 추출 용도로만 쓴다. 실시간 UI에는 search_after(+ 필요하면 PIT)가 표준이다.

PIT(Point In Time) 은 특정 시점의 인덱스 상태를 고정한다. 페이지를 넘기는 사이에 색인이 일어나도 결과 집합이 변하지 않는다. 대신 유지 기간 동안 세그먼트가 병합되지 못하므로 자원을 쓴다. 짧게 잡고 확실히 닫아야 한다.

1
POST /my-index/_pit?keep_alive=1m

UI 결정이 곧 아키텍처 결정이다

여기가 이 주제의 결론이다. 어떤 방식을 쓸 수 있는지는 화면이 어떻게 생겼는지가 정한다.

화면필요한 것쓸 수 있는 방식
페이지 번호를 눌러 점프 (1 2 3 … 17)임의 오프셋 접근, 총 페이지 수from + size — window 제한과 함께 살아야 한다
“더 보기” / 무한 스크롤다음 묶음만search_after — 제한 없음

“페이지네이션을 개선해 달라”는 요구는 대개 UI를 바꾸자는 제안과 함께 가야 한다. 페이지 번호 UI를 유지한 채 깊은 페이지를 열어 달라는 건, 비용 구조상 열어 주면 안 되는 문을 열어 달라는 뜻이다.

현실적인 타협도 있다. 페이지 번호 UI를 유지하되 접근 가능한 페이지 수를 제한하고(검색 결과 상위 N건만), 더 깊이 보려면 필터를 좁히도록 유도하는 방식이다. 실제로 대부분의 검색 서비스가 이렇게 한다 — 깊은 페이지를 진짜로 보는 사용자는 거의 없고, 있다면 그건 검색이 실패했다는 신호에 가깝다.

운영 함정

함정 1 — max_result_window를 올려서 해결하기. 제한은 원인이 아니라 방어선이다. 올리면 비용이 그대로 드러날 뿐이고, 동시 요청이 겹치면 힙이 터진다.

함정 2 — search_after에 tie-breaker를 안 넣기. 정렬 값이 같은 문서가 있으면 경계에서 누락·중복이 생긴다. _id 같은 고유 필드를 마지막 정렬 키로 붙인다.

함정 3 — scroll을 사용자 페이지네이션에 쓰기. 서버가 컨텍스트를 유지해 자원을 점유한다. 전량 추출 전용이다.

함정 4 — 총 건수를 정확한 값으로 믿기. track_total_hits는 기본적으로 10,000에서 끊긴다("relation": "gte"). 정확한 값이 필요하면 track_total_hits: true를 켜야 하고, 그건 다시 비용이다.

면접 한 줄 Q&A

  • Q. from + size가 깊어지면 왜 느린가? A. 어느 샤드에 상위 문서가 있는지 모르므로 모든 샤드가 각자 from + size개를 힙에 쌓아 코디네이터로 보낸다. 코디네이터는 그걸 다 병합 정렬한 뒤 size개만 남기고 버린다. 비용이 from에 선형으로 는다.
  • Q. max_result_window 10,000을 올리면 되지 않나? A. 제한은 원인이 아니라 방어선이다. 올려도 비용 구조는 같고, 깊은 요청이 몇 개만 겹쳐도 힙이 위험하다.
  • Q. search_after는 어떻게 다른가? A. 오프셋 대신 직전 페이지 마지막 문서의 정렬 값을 커서로 넘긴다. from이 항상 0이라 비용이 페이지 깊이와 무관하다. 대가는 임의 페이지 점프 불가다.
  • Q. search_after를 쓸 때 주의할 점은? A. 정렬 키가 유일해야 한다. _id 같은 tie-breaker를 마지막 정렬 키로 붙이지 않으면 경계에서 누락·중복이 생긴다.
  • Q. scroll과 PIT의 차이는? A. scroll은 서버가 커서 컨텍스트를 유지하는 전량 추출용이고, 지금 사용자 페이지네이션에는 권장되지 않는다. PIT는 시점을 고정하는 것이고 search_after와 조합해 페이지 간 일관성을 준다.
  • Q. 그래서 무엇을 골라야 하나? A. UI가 정한다. 페이지 번호 점프 UI면 from + size와 window 제한을 받아들여야 하고, 무한 스크롤·더 보기면 search_after가 답이다.
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.