1. Introduction
📌 "It's only got 10,000 downloads!"
학생 때는 서버 운영비가 부족해서 OpenSearch를 만져볼 겨를이 없었고, 애초에 데이터가 만 건이나 쌓일 일도 없었다.
그래서 OpenSearch에 Pagination으로 정보를 조회하는 기능에서 "데이터가 최대 만 개밖에 다운이 안 돼요!"라는 말을 들었을 때, 지난 번에 count가 제대로 되지 않는 이슈 때문이겠거니 싶었다.
무슨 소리냐면, OpenSearch Count API Doc에서 나오듯 track_total_hits 파라미터를 true로 설정하지 않으면 count가 최대 10,000개밖에 표시되지 않는 이슈(ElasticSearch도 마찬가지라고 한다)가 있었는데,
구현해놓은 OpenSearch Pagination 로직이 DB와는 달리 totalCount를 먼저 확인한 후 그만큼 반복을 수행하도록 구현해놨으니 당연히 여기에 원인이 있다고 생각했었다.
그런데 공통으로 사용하는 API가 적용되어 있을텐데 왜 반영이 되지 않았는가?
우선 해당 이슈는 내가 처리하지 않았고, 심지어 우리는 위 문제를 조금 다른 방식으로 해결하는 덕에 해당 솔루션이 내가 만든 서비스에 적용이 되지 않은 상태였기 때문이다.
그래서 태스크를 할당받은 후, 코드를 한 번 슥 훑어보고 1분만에 이렇게 보고했다.
"읽어봤는데, 복사 붙여넣기 수준으로 해결될 문제라 오늘 내로 처리할 수 있습니다."
이후 다른 곳에서 사용 중인 코드로 정확한 totalCount를 계산하도록 LoC 3줄 정도를 고친 후, 개발 서버에 배포를 하면서 여유롭게 다음 작업의 설계를 하고 있었으나,

여전히 10,000건의 데이터밖에 조회가 되질 않았다.
실수로 병합을 안 하고 배포를 했나?
아닌데, 이번엔 제대로 쿼리가 호출되고 있는데?
뭔가 잘못 돌아가고 있음을 직감하고, 구글에 'OpenSearch result limit'을 검색하자마자 공식 문서에 친절하게 다음과 같이 적혀있는 것을 확인했다.


from + size 방식의 조회의 문제점은 `from + size ≤ 10,000`을 넘길 수 없다는 점이었다. (from은 offset이라고 보면 된다.)
이유는 밑에서 알아보도록 하자.
친절하게도 이 문제를 해결할 수 있는 방법도 아래에 상세하게 적혀있었다.
그래서 일단 채팅방에 "너무 쉽게 생각했다. ~한 문제가 있어서, 각 솔루션 장단점 분석해보고 다시 말씀드리겠습니다."라고 공지를 띄운 후 후다닥 조치를 취했다.
우려스러웠던 점은 해당 데이터를 조회하기 위한 OpenSearch가 타부서에서 운영되고 있던 터라, 해당 부서에서 추가 작업을 수행하지 않고도 가능한 솔루션의 존재 여부였다. 아니다, 우리 거였다. ㅋㅋㅋㅋㅋㅋㅋㅋ
결론적으로 잘 됐고, 딱히 어렵진 않다.
2. Reason
📌 Shard

OpenSearch는 데이터를 어떻게 저장하는가? (사실 ElasticSearch밖에 안 배워서 차이가 있을 거 같은데 잘 모름..)
우선 데이터를 shard라는 작은 조각들로 나누어 저장을 한다.
shard는 데이터를 store에 들어오고 나가는 데 있어 뒷단에서 열심히 일하는 physical instance라고 보면 된다.
더 쉽게 설명하면, 데이터의 물리적 저장과 검색을 담당하는 녀석들이다.
여기서 기본 샤드와 복제본의 차이는 다음과 같다.
- 기본 샤드: document를 보관
- 복제본: 데이터 복사본을 보관하여 시스템 중복성을 높이고 검색 쿼리 속도 높이는 목적으로 사용. (없을 수도 있는데 prod 환경에선 사실상 필수)
이번 포스팅이 OpenSearch 배우는 내용은 아니기도 하고, 이 글을 읽을 정도면 shard는 다 알고 있을테니 패스.
📌 Searching Mechanism
자, 문제는 이제 검색이다.
책 100만 권을 한 서가(shard)에 두지 않고 여러 곳에 분산시켜 저장해둠으로써 관리가 용이해지기는 했다만, 이걸 어떻게 다시 찾아올 것인가?
단순히 1~100만 권은 shard1, 101~200만 권은 shard2 이런 식으로 저장했을 수도 있지만, log같이 날짜별 인덱싱이 주를 이루는 데이터가 아닌 이상 그렇게 샤딩을 하진 않으니 여러 shard에 흩어져 있을 가능성이 농후하다.

따라서 모든 Node는 협력적이어야 한다.
검색은 Query Phase와 Fetch Phase 두 단계로 나뉘는데, 굳이 이런 용어는 신경쓰지 않아도 된다.
기본적으로 Cluster의 모든 Node는 인덱싱 요청 및 검색 요청과 같은 데이터 관련 요청을 조정하고, 문서 인덱싱 및 검색 쿼리 응답과 같은 요청을 처리하는 두 가지 역할을 동시에 수행한다.
따라서 모든 Node는 Coordinator Node가 될 수 있다.
- Round Robin 같은 알고리즘으로 요청을 처리할 수 있는 Node 중에 돌아가면서 선택할 수 있다.
- 그래서 active된 coordinator node 자신에게는 document에 대한 shard가 없을 수도 있다.
- AWS 문서에 의하면, 두 가지 역할을 담당하는 Node는 궁극적으로 리소스 부족으로 인한 장애로 이어질 수 있기에 전용 클러스터 노드(dedicated coordinator node)로 설정하는 것도 가능한 듯하다.
하여간 요청을 맡아서 처리하기로 한 Node가 active되면, 해당 document의 shard가 존재하는 node들을 결정한다.
여기서 요청을 처리하는 node를 active role을 가진 coordinator라고 하고, 참조할 node들의 집합을 복제 그룹(replication group)이라 부른다고 한다. ㅇㅇ (그냥 간지나보여서 적은 것 뿐이고 몰라도 된다.)
이제 active node가 replication group내 node들에게 질문을 던진다.

"너네한테 있는 데이터를 내놔"
그리고 모든 응답(자기 자신에게도 있다면 그것도 합쳐서)을 수집한 후 조정하여 최종 응답을 작성한다.
집계 쿼리가 포함되어 있으면 추가 동작이 있다는데 이건 지금 내 알 바가 아니라서 패스.
더 자세한 내용이 궁금하면 공식 문서를 읽자.
📌 Deep Pagination
'엥, 어차피 shard로 데이터가 분산되어 있다한들 index가 있는데 왜 페이지에 제한을 걸어둔 거지?'
물론 size가 매우 큰 경우 문제가 될 수 있으니 제한이 있는 것까지는 이해가 되는데, 왜 10,000이라는 별로 크지 않은 수치로 결정한 건지는 잘 이해가 가지 않았다.

공식 문서에도 나와있듯, 모든 shards는 각각 완전한 Apache Lucene Index다.
그렇다면 '각 shard가 동일한 index로 평가를 해서 from만큼 건너뛴 size개의 결과를 active coordinator node에게 반환하고, 이 결과 집합을 묶어서 다시 인덱싱을 한 결과의 from만큼 건너뛴 size개의 결과를 반환한다면, 10,000개보다는 더 많이 처리할 수 있는 거 아닌가?'라는 OpenSearch에 대한 환상이 있었기 때문이다.
하지만 여기서 문제점이 있다.
바로 각 node에서 평가한 독립적인 local 점수로 global 상위 O개를 추출하는 것이 정확하지 않기 때문이다.
각각 완전한 lucene index를 가지고 있을 뿐만 아니라, TF-IDF와 같은 통계로 계산한 점수마저 독립적이기 때문이다.
예를 들어, Shard1에는 IT 관련 문서들이 지배적이고, Shard2에는 음악과 관련한 문서가 더 많다고 치자.
그리고 Query를 "Java Programming"이라고 보내고 from = 9,990, size = 10으로 요청했다고 하자.
global 점수로 계산했다면 shard2에 포함된 "java programming language"문서가 그 안에 속했어야 했겠지만, 음악 관련 문서가 주를 이루는 shard2에 존재하는 덕에 너무 높은 점수를 받아서 검색이 되지 않을 수 있다.

그래서 각 shard는 from + size 길이의 우선순위 큐를 구축하여, 이 모든 큐의 문서를 active coordinator node로 전달한 후 runtime에 병합을 해야함을 의미한다.
여기서 크게 두 가지 문제가 발생한다.
- 9,990번째 부터 시작하는 10개의 검색 결과만 표시하더라도 항상 모든 검색 결과를 다시 계산하고 정렬하여 전체 Score-ID 목록을 memory에 유지해야 한다.
- from + size = 10,000개고 5개의 shard가 포함된 replication group에 요청이 전달되었다면, active coordinator node는 number_of_shards * (from + size) = 50,000개의 결과를 다시 인덱싱하고 정렬하고 점수를 매겨야 한다.
이것이 바로 deep pagination이 유발하는 문제점이며, memory 부족으로 cluster에 장애를 일으키기 충분한 사유가 된다.
그렇다면 OpenSearch에선 deep pagination 문제를 해결하기 위해 심혈을 기울였을까? 그렇지 않다.
왜냐하면, 사용자는 첫 번째, 혹은 많아봐야 두, 세 페이지만 넘겨도 피로감을 느껴 검색 조건을 바꿀 것이고, 당신이 처음부터 좋은 결과를 보여줬다면 사용자는 deep pagination의 필요성 자체를 못 느낄 것이기 때문이다.
대부분 deep pagination은 bot이나 web spider 때문이므로 이것들은 따로 처리하고, 그 시간에 검색 퀄리티를 높이는 것에 집중을 하는 게 맞다고 판단한 것이다.
BUT!!!
ML 작업이나, 나처럼 진짜 필요해서 deep pagination을 해야만 하는 경우엔 어떻게 해야 하는가?
여기엔 3가지 해결책이 존재한다.
3. Solution
📌 Search After
Paginate results
Paginate results
docs.opensearch.org
이전 페이지의 검색 결과를 사용해 다음 페이지 결과를 가져오는 방법이다.
"shakespeare"라는 index로 다음과 같은 쿼리를 호출했다고 하자.
GET shakespeare/_search
{
"size": 3,
"query": {
"match": {
"play_name": "Hamlet"
}
},
"sort": [
{ "speech_number": "asc" },
{ "line_id": "asc" }
]
}
그러면 응답에서 hits.hits 배열 내에 documents가 담겨있고, 각 document는 sort 필드를 가지고 있다.
가장 마지막 정보가 size 검색의 마지막 순번이므로, hits.hits[hits.hits.size - 1].sort 정보를 다음 쿼리의 search_after 필드에 삽입하면 이후 정보를 가져온다.
만약 마지막 document의 sort가 [1, 32636]이었다면, 다음 쿼리는 이렇게 작성하면 된다.
GET shakespeare/_search
{
"size": 10,
"query": {
"match": {
"play_name": "Hamlet"
}
},
"search_after": [ 1, 32636], # 이 필드를 추가
"sort": [
{ "speech_number": "asc" },
{ "line_id": "asc" }
]
}
sort 조건은 동일해야 함에 유의, 또한 페이지 1에서 5로 바로 이동같은 동작은 불가능.
하지만 이 방법에는 한 가지 문제점이 존재하는데, 각 요청은 stateless 하기 때문에 매번 새롭게 score를 평가한다는 점이다.
즉, 도중에 새로운 데이터가 인입되어 색인화되거나, 혹은 삭제되는 경우 document의 순서가 변경되면 중복 데이터가 조회되거나, 신규 데이터는 아예 조회되지 않을 우려가 존재한다.
이건 from + size 조회에도 동일한 현상이 발생할 수 있으며, opensearch issue RFC 문서에도 나와있다.
📌 Scroll API
Paginate results
Paginate results
docs.opensearch.org
ElasticSearch도 그렇고, OpenSearch도 검색을 수행할 때는 Execution Context를 사용한다.
scroll API는 OpenSearch의 realtime index update를 일시적으로 무시하고, 현재 버전 data snapshot으로 scroll context를 stateful하게 유지하는 전략을 취한다.
(인입이 활발한 Index라면 후폭풍을 감당할 수 있어야 할 것이다.)
(`25.12.15 수정) 오해했다. Scroll API는 index update 전체를 막는 것이 아니라, 검색 결과 상태(search context)를 메모리에 계속 유지하는 것일 뿐이다.
첫 요청에 scroll 쿼리 파라미터를 전달하여, search context를 유지할 시간을 명시하는 것으로 시작한다.
GET shakespeare/_search?scroll=10m
{
"size": 10000
}
그러면 OpenSearch는 scroll_id를 반환하는데, 이걸 사용하면 10,000개씩 결과를 일괄적으로 가져올 수가 있다.
여기에도 몇 가지 문제가 있다.
- 요청 시점에 context가 고정되긴 하나 특정 query에 연결된다.
- 검색할 때 앞으로만 이동이 가능해서, 중간에 페이지 요청에 실패해도 다음 요청에선 다음 페이지를 반환한다.
- 일반적으로 scroll id는 요청 간에 변경되지 않지만, "변경될 수도 있으므로" 항상 최신 scroll id로 갱신해주어야 한다. (구현이 귀찮다.)
그래서 공식에서 "수십억 개 결과가 예상되는 경우에나 사용해라"라고 적혀있다.
📌 Search After + PIT (Point in Time)
Point in Time
Point in Time
docs.opensearch.org

가장 권장하는 방법이라고 명시가 되어 있다.

index 집합에 대한 PIT을 생성하면, OpenSearch가 해당 index의 segment 집합을 일시적으로 고정시켜버린다.
그래서 index가 계속 데이터를 수집하고 document를 수정, 삭제하더라도 PIT은 고정 시점의 데이터를 참조하여 일관성 문제를 해결한다.
POST /<target_indexes>/_search/point_in_time?keep_alive=1h&routing=&expand_wildcards=&preference=
위와 같은 요청을 전달해서 PIT을 생성하면,
{
"pit_id": "o463QQEPbXktaW5kZXgtMDAwMDAxFnNOWU43ckt3U3IyaFVpbGE1UWEtMncAFjFyeXBsRGJmVFM2RTB6eVg1aVVqQncAAAAAAAAAAAIWcDVrM3ZIX0pRNS1XejE5YXRPRFhzUQEWc05ZTjdyS3dTcjJoVWlsYTVRYS0ydwAA",
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"creation_time": 1658146050064
}
이러한 응답을 받을 수 있다.
재밌는 점은 scroll API와 달리 특정 query에 종속되지 않는다.
그래서 실제 검색을 수행할 때는 URL에 target_indexes를 포함하지 않으며, 여러가지 쿼리를 실행해도 문제가 없다.
조회할 때 pit 필드와 search after 방식 조합을 사용하면, 거의 대부분의 pagination 문제점을 해결할 수 있다.(고 함)
GET /_search
{
"size": 10000,
"query": {
"match" : {
"user.id" : "elkbee"
}
},
"pit": { # pit id 추가
"id": "46ToAwMDaWR5BXV1aWQyKwZub2RlXzMAAAAAAAAAACoBYwADaWR4BXV1aWQxAgZub2RlXzEAAAAAAAAAAAEBYQADaWR5BXV1aWQyKgZub2RlXzIAAAAAAAAAAAwBYgACBXV1aWQyAAAFdXVpZDEAAQltYXRjaF9hbGw_gAAAAA==",
"keep_alive": "100m" # 해당 필드로 pit 유지 시간을 계속 연장할 수 있음.
},
"sort": [
{"@timestamp": {"order": "asc"}}
],
"search_after": [ # pagination을 위한 필드
"2021-05-20T05:30:04.832Z"
]
}
주의할 건, pit으로 조회할 때는 URL에 target_index 정보를 포함하지 않는다!!!
이거 때문에 다 해놓고 10분을 더 날렸다.
🤔 scroll API에서 search context 고정한 게 문제가 된다고 해놓고, PIT으로 segment를 lock하는 건 왜 괜찮은 거죠?
원문에서 "OpenSearch locks a set of segments for thoes indexes, freezing them in time"이라고 적혀있길래 든 의문인데, 그 다음에 "Even though the indexes continue to ingest data and modify or delete documents, the PIT references the data that has not changed since the PIT creation."이라는 내용이 혼란스러웠다.
아니, 똑같이 lock 걸어놓고 PIT은 왜 document가 계속 수정될 수 있다는 거지?
(`25.12.15) scroll API도 search context를 유지하는 것일 뿐, index는 계속 업데이트 가능하다.
이걸 이해하려면 좀 딥하게 파봐야 하긴 하는데..제대로 이해한 건진 아직 확신이 안 된다.
Apache Lucene Segment Doc에 다음과 같이 설명이 적혀있다.

Lucene Segment는 수정 혹은 삭제가 이루어질 때 기존의 것을 수정하는 게 아니라, 새로운 segment를 생성해야 하는 불변성을 갖는다.
'엥, 그럼 모든 segment를 관리하는 게 엄청 부담스러운거 아닌가?'라는 의문이 들어 찾아보니,
누군지는 모르겠지만 해당 블로그에 관련 내용이 매우 잘 설명되어 있으므로 읽어보면 도움이 될 듯하다. ㅇㅇ
여튼 copy and write 방식으로 동작하는 것까지는 확실하다.
그럼에도 delete marker가 찍힌 segment가 segment merge가 발생할 때 제거가 될 수 있을 법도 한데, 그래서 공식 문서에 이런 문구가 적혀있던 것이었다.

PIT으로 Index의 Segment 집합에 대한 참조를 걸어두면, 도중에 몇몇 Segment가 수정/삭제 되더라도 해당 Segment는 보존되며, PIT은 여전히 생성 시점의 데이터 집합만을 이용할 수 있는 것이다.
4. Conclusion
📌 마무리

구현 코드도 원래 추가해두려고 했는데, 개념이 다소 난해했지 구현은 전혀 어렵지 않아서 제외했다.
한 가지 더 팁을 적어놓자면, OpenSearch가 PIT + search after 페이지네이션 방식을 권장하기 시작한 건 version 2.4부터였다.
그래서 이보다 낮은 버전의 OpenSearch를 사용하면 PIT이 안 될 수도 있으니, 다음과 같이 확인해보도록 하자.
$headers = @{
"Authorization" = "Basic <token>"
}
(Invoke-WebRequest -Method GET -Uri "<opensearch_url>/" -Headers $headers).Content
Windows 기준 명령언데, 어차피 OpenSearch 만지고 있을 정도면 본인 OS에 맞는 curl 명령어 쓰는 건 문제가 없을 것이라 예상되므로 쓰지 않음.
여튼 회사 다니면 OpenSearch 반드시 만져보면서 공부해보고 싶었는데, 덕분에 지금 너무 행복하다.
업무 너무 즐거움. 🤤🫢😋