Search

[배민] 실시간 인덱싱을 위한 Elasticsearch 구조를 찾아서 (feat. nested 구조 최적화 과정)

실시간 인덱싱을 위한 Elasticsearch 구조를 찾아서

위 글을 이해해보면서 필사(자신의 말로 정리)했습니다.

배경

작년 말, 가게 변경 이벤트가 빠르게 증가하면셔 저희 검색시스템 ES가 트래픽에 버거워하는 모습이 발견.
피크 시간대에는 간간이 검색 timeout이 발생하기도 하였고, 가게 변경 이벤트 처리가 지연되는 현상 또한 관찰됨
문제 분석
아키텍처 내 성능 지표 분석을 통해서 ES가 병목임은 확인할 수 있었으나,
왜 병목이 되는가에 대해 데이터 모델와 연관지어 분석
기본적인 구조
가게 - 메뉴 (1개의 가게에는 여러 메뉴가 존재 ⇒ one to many 관계)
ES 문서 하나가 가게 하나에 대응되고, 가게 내부에 여러 메뉴가 nested 필드로 저장되는 데이터 모델을 가지고 있음.
공식 문서에 따르면, nested 문서는 내부적으로 parent object, nested object 로 구분되어, 각각 개별 document 로 저장됩니다.
그렇다면 가게 정보 변경 시, parent 문서만 업데이트 될 것이라 생각할 수 있지만, 실제로는 그렇지 않습니다.
우선 ES에서 segment는 불변이기 때문에, 문서가 업데이트 될 때 마치 새 문서가 인덱싱되듯 새 segment에 쓰이게 됨
 공식 문서에는 명시되어 있지 않지만 parent, nested 문서는 segment 상에서 연속된 위치에 저장되어 서로간의 연관 관계를 유지
세그먼트 관련 참고
따라서 위 그림과 같이, parent, nested 문서가 모두 새로 쓰이게 됩니다.
이 것이 의미하는 바?
가게 정보를 변경할 때마다 가게가 가진 모든 메뉴가 새로 쓰이게 됨.
가게가 30개의 메뉴를 가지고 있었다면, 31배의 쓰기 증폭이 발생하게 되고, 이는 ES 클러스터의 쓰기 대역폭을 잡아먹어 전체적으로 낮은 인덱싱 성능 (throughput) 을 초래하게 됩니다.
중요한 점은 이 문제가 인덱싱 성능 뿐만 아니라 검색 성능에도 영향을 줄 수 있다는 점입니다.
쓰기 양이 많아짐에 따라 CPU, memory, I/O 등 인덱싱-검색 간 리소스 경합이 발생할 것입니다.
Scale-out 으로 경합은 해결한다고 쳐도, 여전히 해결되지 않는 문제가 있습니다.
Scale-out
문서들이 불필요하게 새 segment로 이동함 ⇒ Query cache cold miss 가 발생 (일정 크기 이상의 segment는 고유 query cache를 가지고 있습니다) ⇒ 이는 검색 쿼리의 높은 tail latency 를 유발.
Replica를 늘릴수록 오히려 cold miss 비율이 높아지기 때문에, 이는 근본적인 원인을 제거하지 않는 이상 해결하기 어렵습니다.
참고 (세그먼트 ~ 쿼리 캐시, 콜드 미스에 대해서)

데이터 구조 개편

앞에서 낮은 인덱싱 및 검색 성능이 데이터 모델과 연관이 있음을 확인.
→ 데이터 모델을 재설계함으로써 성능 이슈를 해결

새로운 데이터 모델

기존 데이터 모델에서 자주 변경되는 필드
가게 정보 (예: 배달 예상 시간) 였기 때문에, 가게와 메뉴를 개별 인덱스로 분리하였습니다.
이를 각각 가게 인덱스, 메뉴 인덱스라 하겠습니다.
변경 후, 가게 정보 인덱싱 성능이 기존보다 10배 이상 증가하였습니다.
→ 인덱스 크기가 작아지고, parent child 구조의 인덱싱을 안하기 때문에, 빨라진 것으로 추론
성능이 무려 10배나 증가하였으니 문제가 해결?
아래와 같은 문제 발견
분리된 인덱스에 어떻게 쿼리를 요청해야 기존과 같은 검색 결과를 얻을 수 있을까요? 데이터 모델이 변경됨에 따라, 다음 두 문제가 발생하게 됩니다. - 기존과 동일한 검색 결과를 어떻게 얻어낼 것인가? - 검색 성능이 기존보다 악화되진 않을 것인가?
이 문제는 해결하기에 꽤 복잡한 문제

검색 쿼리 분리

1. 검색 요청의 일반적 구조
{ "from": 2, "size": 2, "_source": ["shopName", "shopLocation"], "query": { "match": { "shopName": { "query": "우아한 피자" } } }, "sort": [ "_score", { "shopId": "asc" } ] }
JavaScript
복사
일반적으로, 위 검색 요청은 아래와 같이 세 단계 (step) 로 나눌 수 있습니다.
Query step, Decision Step, Fetch step
검색 쿼리의 분리 이유
효율적인 필터링
첫 번째 단계인 Query step에서는 조건에 맞는 문서 ID만 가져오는 작업을 수행. 이는 필터링을 효율적으로 수행하고, 불필요한 데이터 로딩을 최소화. 또한, 최대 10,000개의 결과를 한 번에 가져올 수 있으며, 더 많은 결과가 필요할 경우 search_after를 사용하여 페이지네이션을 수행.
리소스 절약
초기 필터링 단계에서 필요한 문서 ID만 추출하고, 그 후 필요한 필드만 로드함으로써 불필요한 데이터를 처리하지 않으므로 리소스를 절약 가능. 이는 메모리 사용량을 줄이고, 쿼리 응답 시간을 단축시키는 데 도움이 됩니다.
부분 결과 재사용
전 단계의 결과를 다음 단계에서 재사용함으로써, 이미 계산된 중간 결과를 다시 계산할 필요가 없어짐. 이는 특히 복잡한 쿼리에서 매우 유용
모듈화
쿼리 단계를 모듈화하여 필요에 따라 개별 단계만 수정하거나 확장 가능. 예를 들어, 특정 단계에서만 필터 조건을 변경하거나, 정렬 방법을 변경 가능.
1.
Query step
{ "from": 0, "size": 10000, "_source": false, "query": { "match": { "shopName": { "query": "우아한 피자" } } }, "track_scores": true } ## 결과 shopId (score): 1001 (10), 1002 (21), 1003 (16), 1004 (31)
JavaScript
복사
문서 필터링 조건만 포함된 검색 쿼리를 요청
원하는 조건에 맞는 문서만 필터링하는 단계로, 개념 상 ES 에서는 query phase 에 대응됩니다.
query phase에 대해서 (GPT)
우선 매칭된 결과를 모두 가져와야 하기 때문에, size 에 ES 최대치인 10,000 을 명시
매칭될 문서 수가 이를 넘을 수 있다면, search_after 를 사용하면 됨
2.
Decision step
{ "from": 2, "size": 2, "_source": false, "query": { "bool": { "filter": { "terms": { "shopId": [ "1001", "1002", "1003", "1004" ] } }, "must": { "script_score": { "query": { "match_all": {} }, "script": { "source": "params['scores'].get(doc['shopId'].value.toString())", "lang": "painless", "params": { "scores": { "1001": 10.0, "1002": 21.0, "1003": 16.0, "1004": 31.0 } } } } } } }, "sort": [ "_score", { "shopId": "asc" } ] } ## 최종 문서: 1003 (16), 1001 (10)
JavaScript
복사
필터링된 문서에 대해 정렬 및 페이징을 해주는 검색 쿼리를 요청
필터링 된 문서를 대상으로, 정렬 및 페이징을 진행하여 최종 문서를 결정하는 과정
이전 step 결과를 쿼리에 명시하여, 해당 문서만을 대상으로 정렬 및 페이징을 거쳐 최종 문서를 확정.
쿼리 내에서 명시
bool.filter 에 필터링 된 문서, 
bool.must 에 문서들의 점수를 명시하여,
이전 step의 결과를 이어 처리
3.
Fetch step
{ "from": 0, "size": 2, "_source": ["shopName", "shopLocation"], "query": { "bool": { "filter": { "terms": { "shopId": [ "1003", "1001", ] } } } }, }
JavaScript
복사
최종 문서에 대해 결과를 만들어주는 식
최종 문서의 필드를 가져오고, highlighting 등을 진행하여 최종 결과를 만드는 과정.
Decision step + fetch step이 ES 에서는 fetch phase 에 대응.
확정된 문서의 필드를 가져와 최종 결과를 리턴.
최종 결과를 decision step에서 결정된 순서에 따라 재정렬함으로써 검색 결과를 완성
저는 처음에 이 부분이 이해가 잘 가지 않았습니다 결론적으로 하나의 쿼리를 위와 같이 3단계로 분리가 가능하다 정도로 이해했습니다 하지만 아래 데이터 모델에 반영한 내용을 보면 위와 같이 쿼리가 분리가 되어야, 데이터 모델에 반영하기 위한 쿼리를 작성할 수 있게 됩니다.
2. 데이터 모델 반영
기존 인덱스가 가게 인덱스, 메뉴 인덱스로 분리되었기 때문에, query step을 한 쿼리만에 끝낼 수 없게 됨.
가게 정보는 가게 인덱스에, 메뉴 정보는 메뉴 인덱스에 있기 때문에, query step이 분리되어 요청되어야 함.
다른 step도 마찬가지이고, 총 6개의 step이 필요.
1. Shop query 2. Menu query 3. Shop decision 4. Menu decision 5. Shop fetch 6. Menu fetch
JavaScript
복사
각 step의 결과는 앞의 예시를 참고하여 다음 step에게 넘겨주면 됨.
Shop decision step에서는 가게 정보로 계산 가능한 정렬 값들을 계산하여 전달
menu decision step은 이와 메뉴 정보로 계산 가능한 정렬 값들을 합쳐 최종 정렬 값들을 만들게 됨.
그 뒤 페이징을 통해 decision step의 결과인 최종 문서를 결정하게 됨.
Sort 값 전달은 score를 전달하는 방식과 유사하게, script param을 활용.
그런데, 다음 예시와 같이 쿼리에서 분리 불가능한 부분이 있을 수 있음
Filter by (shopDeliveryFee + menuDeliveryFee < 3000)
Sort by (shopDeliveryTime + menuCookingTime)
이를 일반화하자면 f(가게 필드, 메뉴 필드) 를 g(가게 필드) && h(메뉴 필드) 로 분리할 수 없는 경우 각 필드 쿼리 결과가 독립적이지 않은 경우가 이에 해당
f(a)f(b)f(ab)f(a)* f(b) ≠ f(ab)
⇒ 이런 경우 해당 필드들은 같은 인덱스에 존재해야 합니다.
결과적으로 일부 가게 필드를 메뉴 인덱스에도 추가하는 방향으로 진행
3. 검색 step 재구성
배달의 민족에서는 검색을 위 step들로 분리
실제로는 6개보다 더 적은 step로 구성하는 것이 가능
다음과 같은 이유에서였습니다.
1.
정렬에 메뉴 필드가 사용되지 않는다.
이는 shop decision step에서 최종 문서를 결정지을 수 있음을 의미하여, menu decision step을 제거할 수 있었음.
2.
같은 인덱스에 요청하는 연속 step은 합쳐줄 수 있다.
Menu decision step이 제거되고 나니, shop decision, shop fetch step이 연속하게 되었고, 이를 하나의 step으로 합쳐줌.
결과적으로 저희는 다음과 같은 step으로 검색 요청을 구성하게 됨.

최적화

인덱스 분리 이후, 기존과 동일한 검색 결과를 어떻게 얻어낼 것인가?
⇒ 검색 쿼리를 여러 step으로 분리함으로써, 앞에서 언급했던 첫 번째 문제는 해결.
검색 성능이 기존보다 악화되진 않을 것인가?
각 step의 latency를 한번 측정…, menu query step에서만 기존 검색의 3배를 넘는 latency가 측정됨.
왜 이렇게 높은지를 원인 분석 ~ 그 원인을 메뉴 인덱스 구조에서 찾았음.
1. 메뉴 인덱스 데이터 모델 변경
인덱스 분리 후, 메뉴 인덱스는 ES 문서 하나가 메뉴 하나에 대응되는 모델을 가지고 있게 됨.
각 step의 결과가 가게 단위여야 했기 때문에, menu query step은 메뉴를 가게별로 aggregation 해주어 결과를 내려주는 식.
그런데 분석 결과, 이 aggregation이 문제!
요청 시간 대부분이 여기에서 소요되고 있음을 확인.
Aggregation이 느렸던 이유
1.
Coordinating node가 data node들에게 쿼리를 요청.
2.
각 data node는 자신이 맡은 shard에 담긴 메뉴들을 가게 단위로 집계하여 반환.
3.
그러나 한 가게의 메뉴가 여러 shard에 퍼져있기 때문에, coordinating node는 반환받은 결과들을 한 번 더 aggregation 해줌.
Coordinating node에서 aggregation을 한번 더 해주는 것은 연산량 문제도 있지만, 이 작업으로 인해 sharding 병렬화 효율성이 감소할 수 있음.
이러한 점을 봤을 때, 성능이 낮은 근본적인 원인은 가게-메뉴 관계를 query-time에 만들어주기 때문.
우리는 이를 index-time에 만들어주는 방법을 이미 알고 있을 것.
→ 바로 nested document
메뉴를 가게 단위로 묶은 nested 모델로 변경한 결과, 80% 가까이 step latency를 개선할 수 있었음
이 작업을 통해, ES 공식 문서의 조언과 달리, 
aggregation이 필요한 경우엔 nested document를 사용함으로써 검색 성능을 개선할 수 있다는 것을 알게 되었습니다.
의문점 메뉴 항목들 중 집계가 필요한 항목 일부만 가게 - 메뉴로 nested 구조로 바꾼 것일까? 메뉴 인덱스에 존재하는 모든 메타 데이터를 넣기에는 쓰기 성능이 다시 악화되어 버리니..

2. Step 순서 변경 및 병합

앞의 최적화를 통해, latency가 크게 높은 step은 더 이상 관찰되지 않았지만. 새로운 구조를 검색시스템에 반영하여 API latency를 측정 시, 기존의 1.5~2배 정도로 나오는 것으로 확인
대부분의 문서는 shop query step이 아닌 menu query step에서 걸러지는 것을 관찰. 
검색 후보군을 초기에 줄일수록 효율적이기 때문에,
이에 menu query shop query step의 순서를 바꿔줌
같은 인덱스를 사용하는 shop query, shop decision, fetch step이 인접하게 되었고, 이를 하나의 step으로 합침
저희 검색 쿼리는 최종적으로 다음과 같은 step 구성을 갖추게 됨
Step 최적화 전/후 성능을 비교해 봤는데요, 이제부턴 평균 latency가 아닌 p50, p99 등의 latency 분포를 관찰. (p는 백분위)
p 99 : 요청의 상위 1%가 이 시간 이상 걸렸음을 나타내며, 가장 지연이 긴 요청들의 성능을 파악하는 데 유용
p50이 24%, p99가 15% 개선되었습니다. 실시간 인덱싱으로 인한 영향은 고려되지 않았기 때문에, 운영 환경에서는 기존과 동일한 성능을 노려볼 수 있을 것 같기도 함

3. 메뉴 인덱스 sharding 최적화

저희 검색 API의 성능은 여러 변수의 영향을 받습니다.
예를 들면 가게 수가 많은 지역에서는 타 지역에 비해 latency가 높을 수 있습니다. 이와 같이 검색 요청 시 최악의 상황을 가정하였을 때, 메뉴 인덱스를 사용하는 menu query, menu fetch step에서 대부분의 시간이 소요되는 현상이 관찰.
ES 쿼리가 높은 latency를 보일 때, 개선 방법 중 하나는 더 작은 shard를 사용하는 것 → 이는 요청이 여러 shard로 전달되어 병렬적으로 처리. 저희는 메뉴 인덱스의 shard 크기를 줄여, 기존 샤드 4개에서 8개로 늘리는 작업을 진행
기존과 비교하였을 때, p50에서 11%, p99에서 15% 성능 개선을 확인할 수 있었습니다.

최종 구조

1.
기존 인덱스를 가게 인덱스, 메뉴 인덱스로 분리
2.
3-step으로 검색 쿼리를 구성
이를 통해 기존보다 10배 높은 인덱싱 성능과, 기존과 비슷하거나 그 이상의 성능을 보이는 검색 성능이 확인
검색 성능, 이벤트 처리 성능 모두에서 큰 개선 폭을 확인할 수 있었습니다.

검색 성능

1.
Timeout: 99% 감소
2.
Latency: 23% (p99), 14% (p50) 감소
1초를 초과하여 timeout의 원인이 되었던 p99.9 latency가, timeout 80% 아래까지 내려옴.
뿐만 아니라 p50을 포함하여 전반적인 개선이 있었는데, 이는 인덱스를 분리함으로써 실시간 인덱싱이 검색 성능에 주는 악영향을 최소화하였기 때문.
특히 tail latency의 큰 개선 폭은, 앞부분에서 언급했던 다음 문제가 실제로 발생하고 있었음을 넌지시 말하고 있음.
중요한 점은 이 문제가 인덱싱 성능 뿐만 아니라 검색 성능에도 영향을 줄 수 있다는 점.… 이는 검색 쿼리의 높은 tail latency를 유발.
 인덱싱 성능이 향상되었으니, 이벤트 처리 성능도 올릴 수 있었음
인덱싱 성능이 크게 향상됨에 따라, 이벤트 처리 성능도 덩달아 개선되었습니다.
기존에 성능 이슈로 설정했던 이벤트 발행 속도 제한도 해제하여 이벤트 수신량을 두 배 늘려주었고, 성능을 측정
이벤트 처리 구조 ⇒ https://techblog.woowahan.com/2718/ 이 글을 참고
이벤트 처리가 밀리는 현상이 사라졌고, 처리되기까지 걸리는 최대 시간이 98% 감소.
중요한 점은 총 이벤트 수가 두 배가 되었음에도 이 정도로 개선되었다는 점
아래와 같은 레이턴시 테스트 도구 직접 제작하여 사용하셨다고 함
latte
yunjae2
인덱싱되는 문서 수는 두 배가 되었음에도, write bandwidth 사용량은 절반으로 줄었음
⇒ 쓰기 증폭 문제가 완화
ES data node CPU 지표가 40%를 넘나들며 큰 변동폭을 보였던 모습에서, 20%대의 안정적인 모습으로 바뀐 것도 발견

결론

ES 인덱스 구조를 개선함으로써, 비효율적인 인덱싱으로 발생하는 성능 문제를 해결하는 데 성공.
1.
효율적인 데이터 구조는 인덱싱 성능 뿐만 아니라 검색 성능의 개선도 불러올 수 있다.
2.
ES 쿼리를 검색 과정의 한 step으로 볼 수 있고, 이를 활용하면 데이터가 여러 인덱스에 나뉜 경우에도 검색을 구현할 수 있다.
nested 필드 최적화에 대해 다룸