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

Mar.31.2022 이윤재

Backend Data

안녕하세요, 우아한형제들 검색플랫폼팀 이윤재입니다. 저희 팀에서는 배달의민족 검색 시스템을 담당하고 있고, 사용자가 원하는 실시간 결과를 빠르게 제공하기 위해 노력하고 있습니다.

지난 블로그 글에서 늘어나는 이벤트에 대응하여, 배민 검색 시스템에 bulk processing을 도입한 경험을 공유드렸습니다. 도입 후 한동안 평화가 찾아왔지만, 기능 고도화 및 가게 수 증가로 이벤트 발행량이 예상보다 빠르게 증가하였고, 저희는 새로운 성능 문제를 맞이하게 되었습니다.

이번 글에서는 이와 관련하여 Elasticsearch (ES) 데이터 구조를 개편한 내용을 공유드리고자 합니다. 자세한 설명에 앞서 시스템 환경 및 개편된 구조를 간단히 요약하였습니다.

  1. 환경
    • 데이터 (가게) 수: 약 100만 개
    • 데이터 변경 빈도: 수천 건 (초당)
  2. 개편된 구조
    • 기존 인덱스를 필드의 변경 빈도에 따라 두 인덱스로 분리
    • 검색 시 두 인덱스에 검색 쿼리 요청하고 결과를 종합

배경

작년 말, 가게 변경 이벤트가 빠르게 증가하면셔 저희 검색시스템 ES가 트래픽에 버거워하는 모습이 간혹 보였습니다. 피크 시간대에는 간간이 검색 timeout이 발생하기도 하였고, 가게 변경 이벤트 처리가 지연되는 현상이 관찰되기도 하였습니다.

문제 분석

성능 지표를 통해서 ES가 병목임은 확인할 수 있었으나, 왜 병목이 되는가에 대해 저희 데이터 모델와 연관지어 분석해 보았습니다.

데이터 모델

저희는 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 으로 경합은 해결한다고 쳐도, 여전히 해결되지 않는 문제가 있습니다. 문서들이 불필요하게 새 segment로 이동함에 따라 Query cache cold miss 가 발생하게 되고 (일정 크기 이상의 segment는 고유 query cache를 가지고 있습니다), 이는 검색 쿼리의 높은 tail latency 를 유발합니다. Replica를 늘릴수록 오히려 cold miss 비율이 높아지기 때문에, 이는 근본적인 원인을 제거하지 않는 이상 해결하기 어렵습니다.

데이터 구조 개편

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

새로운 데이터 모델

기존 데이터 모델에서 자주 변경되는 필드는 가게 정보 (예: 배달 예상 시간) 였기 때문에, 가게와 메뉴를 개별 인덱스로 분리하였습니다. 이를 각각 가게 인덱스, 메뉴 인덱스라 하겠습니다.

변경 후, 가게 정보 인덱싱 성능이 기존보다 10배 이상 증가하였습니다.

성능이 무려 10배나 증가하였으니 축배를 들러 갈까요? 아직 아닙니다! 분리된 인덱스에 어떻게 쿼리를 요청해야 기존과 같은 검색 결과를 얻을 수 있을까요? 데이터 모델이 변경됨에 따라, 다음 두 문제가 발생하게 됩니다.

  1. 기존과 동일한 검색 결과를 어떻게 얻어낼 것인가?
  2. 검색 성능이 기존보다 악화되진 않을 것인가?

이 문제는 꽤 복잡한 문제입니다. 하지만 여러분이 이 글을 읽고 있다는 것은 해결 방법이 존재하기 때문이겠죠? 🙂 이제부터 그 방법에 대해 설명하겠습니다.

검색 쿼리 분리

1. 검색 요청의 일반적 구조

일반적으로, 한 검색 요청은 세 단계 (step) 로 나눌 수 있습니다.

  1. Query step
    원하는 조건에 맞는 문서만 필터링하는 단계로, 개념 상 ES 에서는 query phase 에 대응됩니다.
  2. Decision step
    필터링 된 문서를 대상으로, 정렬 및 페이징을 진행하여 최종 문서를 결정하는 과정입니다.
  3. Fetch step
    최종 문서의 필드를 가져오고, highlighting 등을 진행하여 최종 결과를 만드는 과정입니다.
    Decision step + fetch step이 ES 에서는 fetch phase 에 대응됩니다.

각 step 은 개별 요청으로도 구현 가능합니다. 문서 필터링 조건만 포함된 검색 쿼리를 요청하고 (query step), 필터링된 문서에 대해 정렬 및 페이징을 해주는 검색 쿼리를 요청한 뒤 (decision step), 최종 문서에 대해 결과를 만들어주는 식으로 (fetch step), 한 검색 요청을 세 개로 분리할 수 있습니다.

{
    "from": 2,
    "size": 2,
    "_source": ["shopName", "shopLocation"],
    "query": {
        "match": {
            "shopName": {
                "query": "우아한 피자"
            }
        }
    },
    "sort": [
        "_score",
        { "shopId": "asc" }
    ]
}

예를 들어 위 ES 요청은 아래 세 요청으로 분리 가능합니다.

  1. Filter step
    {
        "from": 0,
        "size": 10000,
        "_source": false,
        "query": {
            "match": {
                "shopName": {
                    "query": "우아한 피자"
                }
            }
        },
        "track_scores": true
    }
    • 결과 shopId (score): 1001 (10), 1002 (21), 1003 (16), 1004 (31)
  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)
  3. Fetch step
    {
        "from": 0,
        "size": 2,
        "_source": ["shopName", "shopLocation"],
        "query": {
            "bool": {
                "filter": {
                    "terms": {
                        "shopId": [
                            "1003",
                            "1001",
                        ]
                    }
                }
            }
        },
    }

Query step에서는 우선 매칭된 결과를 모두 가져와야 하기 때문에, size 에 ES 최대치인 10,000 을 명시하였습니다. 매칭될 문서 수가 이를 넘을 수 있다면, search_after 를 사용하면 됩니다.

Decision step에서는 이전 step 결과를 쿼리에 명시하여, 해당 문서만을 대상으로 정렬 및 페이징을 거쳐 최종 문서를 확정하게 됩니다. 쿼리에서 bool.filter 에 필터링 된 문서들을, bool.must 에 문서들의 점수를 명시하여, 이전 step의 결과를 이어 처리할 수 있습니다.

Fetch step에서는 확정된 문서의 필드를 가져와 최종 결과를 리턴해줍니다. 최종 결과를 decision step에서 결정된 순서에 따라 재정렬함으로써 검색 결과를 완성하게 됩니다.

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

각 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(메뉴 필드) 로 분리할 수 없는 경우이고, 이런 경우 해당 필드들은 같은 인덱스에 존재해야 합니다. 저희도 비슷한 경우가 있었고, 결과적으로 일부 가게 필드를 메뉴 인덱스에도 추가해 주었습니다.

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이 왜 이렇게 느릴까요? 저희는 aggregation이 ES 클러스터에서 어떻게 진행될 지 그림을 그려보았습니다.

  1. Coordinating nodedata 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를 사용함으로써 검색 성능을 개선할 수 있다는 것을 알게 되었습니다.

2. Step 순서 변경 및 병합

앞의 최적화를 통해, latency가 크게 높은 step은 더 이상 관찰되지 않았습니다. 이제 새로운 구조를 검색시스템에 반영하여 API latency를 측정해 보았는데요, 기존의 1.5~2배 정도로 나오는 것으로 확인되었습니다. 🤔

최적화 전 step 순서는 위와 같았는데요, 대부분의 문서는 shop query step이 아닌 menu query step에서 걸러지는 것을 관찰하였습니다. 검색 후보군을 초기에 줄일수록 효율적이기 때문에, 저희는 menu query, shop query step의 순서를 바꿔줬습니다. 그러고 나니, 같은 인덱스를 사용하는 shop query, shop decision, fetch step이 인접하게 되었고, 이를 하나의 step으로 합쳐 주었습니다.

저희 검색 쿼리는 최종적으로 다음과 같은 step 구성을 갖추게 되었습니다.

Step 최적화 전/후 성능을 비교해 봤는데요, 이제부턴 평균 latency가 아닌 p50, p99 등의 latency 분포를 관찰하였습니다.

p50이 24%, p99가 15% 개선되었습니다. 실시간 인덱싱으로 인한 영향은 고려되지 않았기 때문에, 운영 환경에서는 기존과 동일한 성능을 노려볼 수 있을 것 같기도 합니다. 하지만 이왕 최적화 하는 겸, 해보고 싶었던 건 다 해보자! 하는 마음으로, 다음 최적화 작업을 진행하였습니다.

3. 메뉴 인덱스 sharding 최적화

저희 검색 API의 성능은 여러 변수의 영향을 받습니다. 예를 들면 가게 수가 많은 지역에서는 타 지역에 비해 latency가 높을 수 있습니다. 이와 같이 검색 요청 시 최악의 상황을 가정하였을 때, 메뉴 인덱스를 사용하는 menu query, menu fetch step에서 대부분의 시간이 소요되는 현상이 관찰되었습니다.

ES 쿼리가 높은 latency를 보일 때, 개선 방법 중 하나는 더 작은 shard를 사용하는 것인데요, 이는 요청이 여러 shard로 전달되어 병렬적으로 처리되기 때문입니다. 저희는 메뉴 인덱스의 shard 크기를 줄여, 기존 4개에서 8개로 늘리는 작업을 진행하였습니다.

기존과 비교하였을 때, p50에서 11%, p99에서 15% 성능 개선을 확인할 수 있었습니다.

최종 구조

최적화를 거친 뒤 최종 구조는 위와 같습니다. 기존 인덱스를 가게 인덱스, 메뉴 인덱스로 분리하였고, 3-step으로 검색 쿼리를 구성해 주었습니다.

기존보다 10배 높은 인덱싱 성능과,

기존과 비슷하거나 그 이상의 성능을 보이는 검색 성능이 확인되었습니다.

여기까지는 베타 환경에서 인덱싱, 검색 성능을 각각 측정한 결과입니다. 이 둘이 실시간으로 뒤섞여 발생하는 운영 환경에서는 성능이 어떨까요? 떨리는 마음으로 검색시스템 운영 서버에 반영해 주었고, 그 결과는..!

결과

검색 성능, 이벤트 처리 성능 모두에서 큰 개선 폭을 확인할 수 있었습니다.

검색 성능

  1. Timeout: 99% 감소
  2. Latency: 23% (p99), 14% (p50) 감소

1초를 초과하여 timeout의 원인이 되었던 p99.9 latency가, timeout 80% 아래까지 내려왔습니다. 뿐만 아니라 p50을 포함하여 전반적인 개선이 있었는데, 이는 인덱스를 분리함으로써 실시간 인덱싱이 검색 성능에 주는 악영향을 최소화하였기 때문입니다. 특히 tail latency의 큰 개선 폭은, 앞부분에서 언급했던 다음 문제가 실제로 발생하고 있었음을 넌지시 말하고 있습니다.

중요한 점은 이 문제가 인덱싱 성능 뿐만 아니라 검색 성능에도 영향을 줄 수 있다는 점입니다. … 이는 검색 쿼리의 높은 tail latency를 유발합니다.

이벤트 처리 성능

인덱싱 성능이 크게 향상됨에 따라, 이벤트 처리 성능도 덩달아 개선되었습니다. 기존에 성능 이슈로 설정했던 이벤트 발행 속도 제한도 해제하여 이벤트 수신량을 두 배 늘려주었고, 성능을 측정해 보았습니다. (이벤트 처리 구조에 대해서는 이 글을 참고해주세요 🙂 )

이벤트 처리가 밀리는 현상이 사라졌고, 처리되기까지 걸리는 최대 시간이 98% 감소하였습니다. 중요한 점은 총 이벤트 수가 두 배가 되었음에도 이 정도로 개선되었다는 점입니다.

ES disk write 지표를 한번 볼까요? 인덱싱되는 문서 수는 두 배가 되었음에도, write bandwidth 사용량은 절반으로 줄었습니다. 앞부분에서 지적되었던 쓰기 증폭 문제가 완화된 것으로 보입니다.

가게 정보를 변경할 때마다 가게가 가진 모든 메뉴가 새로 쓰이게 됩니다. … 쓰기 증폭이 발생하게 되고, … 전체적으로 낮은 인덱싱 성능 (throughput) 을 초래하게 됩니다.

이 외에도, ES data node CPU 지표가 40%를 넘나들며 큰 변동폭을 보였던 모습에서, 20%대의 안정적인 모습으로 바뀐 것 등, 다양한 성능 지표에서 개선된 모습을 확인할 수 있었습니다.

결론

ES 인덱스 구조를 개선함으로써, 비효율적인 인덱싱으로 발생하는 성능 문제를 해결하는 데 성공하였습니다. 이번 작업을 통해 다음 두 가지 통찰을 얻을 수 있었습니다.

  1. 효율적인 데이터 구조는 인덱싱 성능 뿐만 아니라 검색 성능의 개선도 불러올 수 있다.
  2. ES 쿼리를 검색 과정의 한 step으로 볼 수 있고, 이를 활용하면 데이터가 여러 인덱스에 나뉜 경우에도 검색을 구현할 수 있다.

데이터 구조를 실제로 개편하는 과정은 간단하지만은 않았습니다. 작업에는 대략 한달정도가 소요되었고, 저희 시스템에서 제공하는 수십개의 API 코드가 변경되었습니다. 장애 확률을 최대한 낮추기 위해 배포 전 자세하게 테스트를 진행하였고, 성능을 보다 정밀하고 간편하게 테스트하기 위해 latency 테스트 도구를 직접 제작하여 사용하였습니다. 소개한 것 외에 실패한 최적화도 있고 하지만, 글이 너무 길어질까 모두 담진 못했네요. 검색플랫폼팀 모두가 작업에 너무나 많은 도움을 주셨고, 바쁜 일정속에서 작업을 추진할 수 있도록 도와주신 테크 리드 주보님께 감사의 말씀을 전합니다.

ES를 사용하는 개발자 분들, 특히 저희와 같이 다량의 데이터를 실시간으로 적재하는 환경에서 고민 중인 분들께 도움이 되고자 글을 남깁니다.

긴 글 읽어주셔서 감사합니다.


검색플랫폼팀은 배민 검색 뿐만 아니라 다양한 서비스의 검색을 책임지고 있으며, 최근에는 이를 플랫폼화하는 프로젝트를 진행 중에 있습니다. 이 과정에서 다양한 문제들을 접하면서 같이 성장해나갈 개발자 분들을 찾고 있으니, 많은 지원 부탁드립니다. 지원 링크