배민상회와 검색플랫폼 연동기

May.18.2023 이제현

Backend

배민상회와 사장님 그리고 그 사이에 검색

배민상회는 외식업 사장님들을 위한 쇼핑몰입니다. 즉, 쇼핑 자체가 업무의 일환인 사용자들이 이용합니다. 그러다 보니 상품을 하나하나 탐색하고 찾아가는 즐거움보다 빠르게 원하는 상품을 검색하는 것이 중요합니다.

배민상회는 사장님들의 “업무"를 돕기 위해 어떤 방식으로 검색을 하고 있었을까요?


배민상회 서비스 화면(출처: https://mart.baemin.com)

초기에 배민상회는 DB에 저장된 상품정보를 like 검색을 이용하여 찾고 있었고, 검색 성능 개선 목적으로 like 검색을 그대로 Elasticsearch라는 검색엔진을 사용하여 검색하도록 구현하였습니다.(본 글에서는 Elasticsearch에 대해서 다루지 않습니다.)

Elasticsearch를 이용한 검색을 잘 사용하고 있었지만 몇 가지 문제가 있었습니다.

첫째, 내부적으로 언어 전문가나 관련 데이터의 부족으로 적절한 동의어나 유사어 등의 사전 구축이 어려웠습니다. 검색 기능의 특성상 사전이 필요했는데, 배달의민족 서비스의 사전을 가져다 사용하였지만 배민상회와 맞지 않는 부분이 많았습니다.

둘째, Elasticsearch를 이용한 like 검색 형태로 되어 있어서 찾고 싶은 검색보다 정확한 검색에 초점을 맞추고 있었습니다.
가령 판매량, 인기 있는 상품, 유사한 제품군 등 유사하거나 연관성 있는 사장님들이 필요로 하는 상품 검색보다 상품 이름, 카테고리 이름처럼 정해진 필드에 검색어를 통한 정확도에 우선을 둔 검색을 하고 있었습니다.

셋째, 인프라 유지 부담이 있었습니다.
Elasticsearch의 node를 유지하기 위한 인스턴스 구성과 모니터링, 색인의 관리 등 Elasticsearch 자체를 유지하기 위한 개발 리소스가 필요하였습니다.
인프라 유지 부담을 줄이기 위해서 AWS의 OpenSearch를 활용할 계획도 있었지만, 배민상회에서 사용하는 한글 형태소분석기와 버전 문제로 활용하지 못하였습니다.

배민상회는 사장님들의 쇼핑 경험의 향상을 위해서 우아한형제들의 여러 서비스에서 안정적이고 효율적인 검색을 제공하고 있는 검색플랫폼과의 연동을 결정하게 됩니다.

어서와 검색플랫폼은 처음이지?

우아한형제들의 검색플랫폼은 가게나 상품 정보를 색인하여, 검색어에 대해서 서비스별 가장 적합한 결과를 검색해주는 플랫폼입니다.

검색플랫폼의 연동 방식은 매우 간단합니다.
크게 검색 부분과 색인 부분으로 구분할 수 있습니다.

검색 부분은 검색플랫폼이 제공하는 Rest API에 검색플랫폼에서 지원하는 질의문을 이용하여 검색하는 부분입니다.

색인 부분은 배민상회의 검색 대상이 되는 상품들의 정보를 검색플랫폼이 저장할 수 있도록 색인에 추가하는 부분입니다.

색인은 전체 색인과 부분 색인이 있으며, 배민상회와 검색플랫폼의 색인 과정은 이벤트 드리븐(event driven) 방식입니다. 배민상회에서 비동기 방식으로 상품정보 이벤트를 발행하고 검색플랫폼에서 이를 저장하는 방식으로 저장됩니다.

색인은 하루에 한 번 전체 상품에 대한 색인과 10분에 한 번씩 변경된 상품에 대한 색인을 실시합니다.

배민상회와 검색플랫폼 연동 개략도
배민상회와 검색플랫폼 연동 개략도(출처: 내가 그린 그림)

설명을 보면 알겠지만, 배민상회와 검색플랫폼의 연동은 매우 간단해 보입니다. 사실 기존에 Elasticsearch를 사용하고 있고, 이를 검색플랫폼으로 변경하는 작업이기 때문에 대.단.히. 간.단.할.것.이라고 예상했고 쉽게 생각하고 시작하였습니다.

하지만 실제 연동하는 과정에서 꽤 많은 문제에 직면했습니다.

구조에 대한 고민

배민상회는 다양한 비즈니스 로직(업무에 필요한 데이터를 처리하는 애플리케이션의 일부)에서 상품 목록을 검색하여 사용하고 있습니다.
단순히 키워드 검색뿐 아니라, 특정 카테고리에 속하는 상품 목록을 가져온다거나, 특정 판매자의 상품을 조회하는 등 상품 목록에 대한 조회가 필요한 부분에 다양하게 활용되고 있습니다.

배민상회에서는 이를 ‘검색 시나리오’라고 칭합니다. 검색 시나리오별로 개별 비즈니스 로직에 특화된 기능이 있기도 했고 모든 비즈니스 로직이 공통으로 사용하는 기능들도 존재했습니다.

모든 검색 시나리오는 검색플랫폼을 이용한다는 공통점이 있습니다. 하지만 개별 시나리오에서 검색플랫폼에 요청하는 질의문은 시나리오별로 다릅니다. 이를 구현하기 위해서 시나리오별 기능을 추상화하여 인터페이스화하였고 공통 로직에서 인터페이스를 통해 개별 시나리오의 로직을 호출하여 사용하도록 구현하였습니다.

배민상회의 검색기능 구현 Class Diagram의 일부
배민상회의 검색기능 구현 클래스 다이어그램의 일부(출처: 내가 그린 그림)

추상화를 하기 위해서 개별 단계별로 어느 정도까지 추상화를 해야 할지, 몇 번의 단계로 추상화를 할지, 최초의 검색 관련 정보들을 어떻게 추상화하여 조직하고 넘길지 고민 하게 됩니다.

가령, 키워드 검색의 경우 검색어는 문자열이지만, 카테고리 목록의 검색은 검색어가 숫자입니다. 또한 다른 로직에서는 서로 다른 형태의 검색어를 가지고 있습니다. 이를 포괄하고 추상화할 수 있는 개별 객체가 필요했고, 포괄하는 객체 생성과 이 객체를 이용하도록 공통화하는 작업이 어려웠습니다.

또한 개별 비즈니스 로직별로 요구하는 정렬 방법이 달라서 그것을 모두 지원하기 위해서 검색 종류를 추상화하는 인터페이스와 이를 구현하는 개별 비즈니스 로직별 검색 종류 enum을 만들었습니다.

또한 배민상회에는 검색뿐 아니라 집계 기능도 있는데, 이는 현재 검색결과에 어느 카테고리의 상품들이 있고, 가격대가 어느정도인지 같은 검색결과에 대한 집계기능인데, 검색플랫폼에서 검색과 다른 API를 통해서 제공하다 보니, 검색과 집계에 대한 추상화도 필요하였습니다.

히스토리 파악의 어려움

검색기능을 새로 만드는 것이 아니라 기존에 있던 검색기능을 검색플랫폼을 이용하도록 재설계 하였습니다. 그러다 보니 기존 코드가 파악되는 경우도 있었지만 도대체 왜 이런 로직이고, 왜 이런 식으로 구현되어 있는지 확인이 불가능한 경우도 있었습니다.
팀 Wiki에서 히스토리 파악이 가능한 경우도 있었고 과거 기능 구현자에게 직접 물어봐야 하는 경우도 있었습니다.
농담 삼아 기록이 남아 있는 것은 고대 석판, 기록이 없는 건 구전설화라고 하곤 했습니다.

석판
석판(출처: https://www.donga.com/news/Inter/article/all/20020708/7840601/1)

재미있는 구전설화
구전설화(출처: http://www.yes24.com/Product/Goods/7433163)

작년에 배민상회는 두 번의 큰 변화를 겪었습니다. 과거에 직매입 상품만을 판매하였는데, 마켓플레이스 전환으로 외부 판매자가 직접 판매할 수 있도록 변화하였습니다.

또, 직배송 도입으로 판매자가 직접 배달할 수 있는 지역이 제한됨으로써, 사용자의 위치 기반으로 배송 가능한 상품만 보여주는 기능인 ‘배송 가능한 상품만 보기’ 필터 기능이 짧은 시간에 여러 번 변경되었습니다.

여러 사람이 서로 다른 기능으로 같은 부분을 개발하다 보니 전체 로직이 이상한 부분이 있었습니다. 또 같은 부분에 대해서 여러 번에 걸쳐서 기획이 추가되다 보니 최종 모습을 파악할 수가 없었습니다.
결국 현재 운영 환경과 코드를 이용해 하나하나 경우의 수를 확인하여 기획을 다시 만들어 내는 경우도 있었습니다.

검색플랫폼 과제 때문이 아니라 모든 시스템과 모든 기능에는 문서화가 필요함을 새삼 깨달았습니다.

소통의 어려움

당연하게도 서로 다른 부서의 서로 다른 시스템을 연동하다 보니 서로의 용어와 서로의 기능 중심으로 의사소통을 하게 되어 서로 다른 의미를 말하는 경우가 있습니다.

가령 배민상회에는 검색 결과의 종류를 나열하고 그 종류를 선택해서 검색 결과를 줄여나갈 수 있는 기능이 있는데, 이 기능을 ‘필터’라고 부릅니다.

검색플랫폼에서도 ‘필터’라는 단어를 사용하는데, 키워드 검색의 추가 조건을 ‘필터’라고 합니다. 최초에 두팀이 만나 회의를 할 때, 서로 다른 필터를 이야기 하여 혼선이 있었고, 다행히 서로의 용어를 확인하고 정리하는 작업을 통해서 혼선을 막을 수 있었습니다.

당연한 게 당연한 것이 아니다.

모든 시스템과 집단은 각자의 사정이 있습니다. 그 안에서 약속한 규칙들이 생깁니다. 약속한 규칙은 그 집단에서 당연한 것이 됩니다. 하지만 그 집단을 벗어나면, 그 약속은 더 이상 당연한 게 아닙니다. 하나의 시스템 안에서의 개발에서는 당연했던 환경들이 두개 이상의 시스템 연동에서는 당연하지 않는 경우가 생깁니다.

배민상회는 항상 다양한 기능이 동시에 개발되고 있습니다. 그래서 여러 개의 개발 존을 유지하고 있고, 각각의 개발존은 보통 서로 다른 프로젝트 혹은 운영이슈를 해결하기 위해 사용되고 있습니다.

배민상회 개발자에게는 다수의 개발 존이 당연하고 자연스러운 환경입니다. 모두 격리된 독립적인 개발환경은 검색 색인이 따로 존재하였고, 개별 색인은 서로 독립적으로 존재합니다. 개발 존 1과 2에 같은 ID의 상품이 존재하지만, 두 상품은 전혀 다른 상품일 수 있습니다. 반대로 서로 같은 상품이지만 서로 다른 환경에서는 ID가 다를 수 있습니다.

검색플랫폼 연동전의 개발환경별 색인 구조
검색플랫폼 연동 전의 개발 환경별 색인 구조(출처: 내가 그린 그림)

하지만 많은 서비스나 시스템에서 다양한 이유로 인해서 개발 존이나 베타 존을 1개만 운영하는 경우도 있습니다.
검색플랫폼 연동 초기에는 이것을 생각하지 못했습니다. 서로 다른 개발 존을 대응하기 위해서 검색플랫폼에서 개발 존에 대응하는 개발 존을 만들어 줄 수 있지만, 비용과 관리 문제로 개발 존을 늘리는 것은 문제가 될 수 있었습니다.

또 배민상회에서 개발 존을 향후 추가한다면 검색플랫폼에도 늘려줘야 하는데, 이것 또한 허들이 될 수 있었습니다.

배민상회에서 다수의 개발 존은 당연한 것이었고, 검색플랫폼에서는 당연한 것이 아니었습니다. 이 문제를 해결하기 위해서 상품 색인에 환경정보를 추가하였고, 검색 질의 시 환경정보를 포함하여 질의하도록 하였습니다.

검색플랫폼 연동후의 개발환경별 색인 구조
검색플랫폼 연동 후의 개발 환경별 색인 구조(출처: 내가 그린 그림)

자체 DSL의 러닝커브

Elasticsearch를 포함하여 질의가 필요한 많은 애플리케이션, 플랫폼들은 Domain Specific Language(DSL)를 가지는 경우가 많습니다. DSL은 질의를 사용하는 분야에서 해당 분야에 적합하도록 만든 자체적인 질의 언어를 말합니다.

우아한형제들의 검색플랫폼도 자체 DSL을 가지고 있습니다. DSL은 Elasticsearch보다 짧고 간결하며, 사용하기 꽤 직관적인 문법으로 구성되어 있습니다.

검색 질의를 만들다 보면 연속적이고 계층적인 AND, OR, NOT의 Boolean query를 작성해야 하는 경우가 많은데, Elasticsearch의 DSL보다 훨씬 간단하고 이해하기 쉽다고 느꼈습니다.
미리 말하자면 Elasticsearch의 QueryDSL이 잘못되었고 부족한 DSL이라고 말하는 것이 아닙니다. Elasticsearch를 기본으로 하는 자체 DSL은 Elasticsearch의 QueryDSL을 추상화하고 간결화하기 마련입니다. 상대적으로 복잡해 보인다는 것이 Elasticsearch QueryDSL이 좋지 않다고 말하는 것이 아님을 밝힙니다.

아래는 검색플랫폼과 Elasticsearch의 QueryDSL 예시입니다. 검색플랫폼의 QueryDSL은 실제와 다르지만 비슷한 형태로 작성하였습니다.

검색플랫폼의 QueryDSL은 검색어와 관련된 질의 부분을 제외하고 판매기간이나 판매상태 등을 조건에 추가하면 됩니다.

{
    "search": "우유",
    "filter": {
        "must": [
            {
                "operation": "Equal", 
                "sale": "true"
            },
            {
                "operation": "LTE",
                "salesStartDate": "2023-03-20T16:07:29"
            },
            { 
                "not": {
                    "operation": "LT",
                    "salesEndDate": "2023-03-20T16:07:29"
                }
            }
        ]
    }
}

검색플랫폼의 QueryDSL 예시

하지만, Elasticsearch의 QueryDSL은 검색어와 관련된 부분도 질의로 만들어야 하며, 판매기간이나 판매상태 등의 조건도 개별 Boolean 문으로 만들어 줘야 합니다.

{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "goodsName": "우유"
          }
        },
        {
          "term": {
            "sale": {
              "value": "true"
            }
          }
        },
        {
          "range": {
            "salesStartDate": {
              "from": null,
              "to": "2023-03-20T16:07:29"
            }
          }
        },
        {
          "bool": {
            "must_not": [
              {
                "range": {
                  "salesEndDate": {
                    "from": null,
                    "to": "2023-03-20T16:07:29"
                  }
                }
              }
            ]
          }
        }
      ]
    }
  }
}

Elasticsearch의 QueryDSL 예시

같은 질의를 하는 DSL임에도 불구하고 JSON의 길이나 깊이에서 차이가 나는 것을 알 수 있습니다.

다만 자체 DSL은 구글링으로 확인할 수가 없습니다. 즉, 검색을 통해서 다양한 케이스를 확인할 수 없으며, 가이드 문서를 제외하면 참고할 만한 질의문을 얻기가 어렵습니다.

물론 Elasticsearch도 DSL을 가지고 있지만, Elasticsearch의 DSL은 널리 사용되어, 사실상의 표준처럼 사용되고 있는 DSL이라서 검색을 통해서 다양한 케이스를 알 수 있고, 배울 수 있었습니다. 또한 이미 여러 곳에서 사용되고 있고, 널리 사용되고 있어서 익숙합니다.

정리하자면 자체 DSL은 간단하지만 익숙해지긴 어려웠습니다.

회고

검색플랫폼 연동 이후, 검색과 관련된 여러 가지 내부 지표들을 통해서 검색 성능이 개선되었습니다.

실제 사장님들이 검색을 통해서 상품을 확인하고 구매까지 이어지는 비율이 약 20% 증가 되었음을 지표를 통해서 확인할 수 있었습니다.

구매뿐 아니라 상품을 검색한 후 장바구니에 담는 행동은 약 10% 향상되었고 검색 결과 순서 중 상위에 있는 상품을 선택하여 상품상세를 확인하는 비율이 약 20% 증가하여 실제 검색을 통한 쇼핑 경험 향상에 도움을 주었음을 확인할 수 있었습니다.

배민상회는 검색 성능 향상을 위해 상품 속성 관련 개편, 자동완성 등 추가 기능을 계획하고 있습니다.

이 글이 비단 검색영역에만 해당하는 것이 아니라 서로 다른 시스템을 연동하고자 하는 개발자에게 부디 도움이 되길 바랍니다.
현재도 앞으로도 배민상회는 검색기능을 포함한 여러 기능의 지속적인 향상과 개선으로 더욱더 사장님들의 “업무"를 덜어드리기 위해 노력하고 있습니다.

함께 사장님들의 “업무"에 도움을 드리고자 하는 분들에게도 열려 있으니 배민상회개발팀 채용 페이지도 참고하세요