신규 검색 서비스 전환기

이하윤
29CM TEAM
Published in
17 min readMar 21, 2023

안녕하세요. 29CM 검색스쿼드 이하윤입니다. 검색스쿼드는 29CM 서비스의 검색플랫폼을 개발하여 사용자가 원하는 결과를 안정적으로 제공하기 위해 최선을 다하고 있습니다. 사용자에게 양질의 검색 경험을 제공하기 위해 2022년에 검색플랫폼을 고도화하게 되었고 본 글에서는 이와 관련된 내용을 소개하고자 합니다.

배경

타 도메인에 강하게 의존되어있는 데이터 관리

29CM 아키텍처는 2020년 8월 이후로 기존의 거대한 모놀리틱 서비스를 마이크로서비스로 나누는 작업을 진행하고 있으며 각 구성원이 소속된 도메인에 대하여 강한 책임을 가지는 것을 지향하고 있습니다.

검색 API 서버는 Elasticsearch(ES) 를 통해 준 실시간성 데이터를 제공하고 있습니다. 기존에는 신규 등록된 상품이나 쿠폰, 변경된 상품 등의 데이터는 어드민을 통해 제어되고 있었으며 원천 데이터 변경 이후 검색 ES 에 sync 해주는 구조였습니다.

이 구조는 데이터 관리의 주체가 타 도메인에 있으며 ES sync 코드가 모놀리틱 서비스에 포함되어 있어 이슈 발생 시 검색 스쿼드 개발자가 빠른 대응을 하기가 어려웠습니다.

오래된 ES 버전과 은전한닢 분석기 사용

ES 5.6 버전을 사용중이었으며 스쿼드 내에서 구현하려고 하는 요구사항에 맞는 기능을 지원하지 않는 경우가 있었습니다. 5.6 버전은 2017년에 릴리즈되었고 버전을 업그레이드하면 추가된 ES API 지원, 개선된 기능, 보안 강화 등을 확보할 수 있습니다. 은전한닢 한글 형태소 분석기는 2018년부터 업데이트가 중단 되었습니다.

그 외에도 기존 ES 클러스터 사용 시 새벽마다 모든 상품을 ES에 sync 하는 배치 (풀인덱싱) 가 7시간 넘게 소요되는 성능 이슈도 안고 있었습니다.

ES 접근 서비스에 대한 관리포인트 파편화

검색 스쿼드 외에 다른 전시영역을 담당하는 일부 스쿼드에서도 API 구현시 검색 ES 를 활용하고 있습니다.

각 스쿼드마다 ES 를 구축하는 것이 아닌 검색 스쿼드와 같은 클러스터를 공유하고 있기 때문에 서비스나 ES 관련 이슈발생시 트러블슈팅을 하기가 까다롭습니다.

이 외에도 기존 검색 서비스 기술은 파이썬과 장고프레임워크로 구현되어 있었는데, 마이크로서비스로 전환하면서 팀 내에 대부분이 JVM 기반 언어 개발자들로 구성되는 등의 다양한 사유로 신규 검색플랫폼 전환 작업이 시작되었습니다.

전환

검색 인덱싱 파이프라인 구축

기존 검색플랫폼은 어드민을 통해 데이터 변경 시 ES 를 직접호출하여 색인하는 구조로 되어 있습니다. 신규 검색플랫폼은 이러한 부분을 개선하며 MSA 로 구성된 각 모듈들이 관심사에 따라 독립적으로 역할을 할 수 있게 EDA 구조로 구성 하였습니다.

데이터 변경이 일어날 경우 원천데이터를 관리하는 도메인서버에서 변경된 데이터를 카프카로 발행해주는 역할을 담당하며 검색플랫폼에서는 인덱싱 서버가 컨슈밍한 데이터를 가공 후 ES 에 반영합니다.

기존 검색플랫폼은 각 메타데이터를 관리하는 인덱스를 일자별로 별도 관리를 하지 않았습니다. 그렇기 때문에 잘못된 로직이나 데이터 정합성 이슈로 인해 인덱스 데이터에 문제가 생긴다면 로직을 수정하기전까지는 따로 대처할 방법없이 사용자에게 이슈가 있는 데이터가 그대로 노출 됩니다.

신규 검색플랫폼은 검색 매니징 서버를 통해 아래와 같은 방법을 통해 인덱스를 관리하고 있습니다.

  1. 새벽 시간에 오늘 날짜의 인덱스 생성
  2. 검색 인덱싱 서버로 증분 인덱싱 중지 요청
  3. 전날 날짜 인덱스의 데이터를 오늘 날짜 인덱스로 옮긴 후 Alias 를 오늘 날짜 인덱스로 변경(리인덱싱)
  4. 검색 인덱싱 서버로 증분 인덱싱 재개 요청
  5. 중지되었던 시간동안 색인되지 않았던 메세지 처리 이후 증분 인덱싱 재개
  6. 기존 사용중이던 인덱스 백업

이러한 구조는 현재 인덱스로 인한 이슈가 발생하더라도 Alias 변경으로 이슈 해결 전까지 과거 데이터로 정상적인 서비스 운영이 가능합니다.

# destination 인덱스 변경
POST _aliases
{
"actions": [
{
"remove": {
"index": "product-20230314",
"alias": "product-serving-alias"
}
},
{
"add": {
"index": "product-20230315",
"alias": "product-serving-alias"
}
}
]
}

파이프라인의 핵심인 인덱싱서버와 API 서버는 바로 다음 이어서 자세히 설명하겠습니다.

검색 데이터 가공 및 인덱싱 서버 구현

타 도메인에 강결합된 기존 검색플랫폼으로 인해 스쿼드의 요구사항이나 이슈발생 시 대응하는 속도가 매우 떨어지는 상황이었고, 이를 개선하기 위해서는 검색플랫폼이 스스로 자생할 수 있는 구조가 필요 했습니다.

그러기 위해선 컨슈밍한 메세지를 검색 비지니스에 맞게 가공할 수 있어야 하기 때문에 인덱싱 서버를 구현하였습니다.

메세지 컨슈밍 이후 ES 로 색인되기까지의 과정을 크게 세단계로 논리적으로 구분할 수 있습니다.

컨슈밍 이후 ES 색인 과정
1. Consumer
구독한 토픽의 메세지를 컨슈밍 하여 processor 로 전달합니다.

2. Processor
메세지를 비지니스에 맞게 가공 후 모델 테이블에 upsert 합니다. 모델은 RDBMS 의 테이블로 관리되고 있으며 ES 인덱스와 동일한 데이터를 가지고 있습니다.

3. indexer
발행된 메세지가 관리되고있는 모델 테이블을 배치를 통하여, 특정 주기마다 변경된 데이터를 ES 에 벌크 색인합니다.

모델 테이블을 통한 데이터 관리
네트워크통신 및 색인 간에 발생하는 이슈로 인해 메세지가 정상적으로 색인되지 않는 경우가 있습니다. 기존에는 타도메인으로 특정상품에 대해 다시 싱크해달라고 요청을 하는 식으로 대응을 했었고 이는 강한 의존성을 가질수 밖에 없으며 검색이 스스로 발생한 이슈를 처리하지 못한다는 뜻이 됩니다.

이러한 문제 해결을 위해 ES 와 동일한 구조를 가진 모델 테이블을 두었습니다.

발행된 데이터를 검색 스스로 관리할 수 있게 됨에 따라 이 데이터를 통해 원하는 니즈에 따라 데이터를 가공하여 사용할 수 있게 되었고 또한 엔드포인트를 추가하여 싱크되지 않은 상품 발생 시 검색 스스로 이슈를 처리할 수 있게 하였습니다.

기존 : 특정상품 ES 미색인 이슈 발생시 타도메인에 요청하여 ES로 해당 상품에 대한 데이터 재색인

현재 : 인덱싱서버의 엔드포인트로 검색 모델 테이블의 데이터를 통해 재색인

타입 구분을 통한 메세지 규격 관리
Consumer에서는 메세지를 소비할 때 다양한 도메인과 그에 해당하는 타입으로 메세지를 역직렬화하게 됩니다. 혹여나 약속되지 않은 규격으로 발행되는 메세지는 인덱싱서버에서 처리하지 않아야 하며, 알 수 없는 필드의 색인을 방지해야합니다.

다음과 같이 @JsonTypeInfo, @JsonSubTypes 를 활용하여 메세지의 공통 필드를 제외하고 다양한 타입에 대응할 수 있는 클래스를 생성할 수 있습니다. 약속되지 않은 규격의 메세지는 에러를 발생하여 ES에 인덱싱하지 않게 됩니다.

property = "type" 옵션은 subtype 객체들을 구분하는 용도입니다.

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXISTING_PROPERTY, property = "type", visible = false)
@JsonSubTypes(
JsonSubTypes.Type(value = ProductDto::class, name = PRODUCT_TYPE),
JsonSubTypes.Type(value = ProductItemImageDto::class, name = IMAGE_TYPE),
JsonSubTypes.Type(value = ProductFilterDto::class, name = FILTER_TYPE),
JsonSubTypes.Type(value = ProductFrontCategoryDto::class, name = FRONT_CATEGORY_TYPE),
...)
abstract class ProductBaseDto(
val itemNo: Long, // 모든 메세지의 공통 필드
val updatedTimestamp: ZonedDateTime, // 모든 메세지의 공통 필드
)
// product type
{
"itemNo" : 1,
"updatedTimestamp" : "2023-03-10T05:32:04.522723Z",
"itemName" : "나이키 맨투맨", // 도메인별로 다른 필드
"type" : "ProductType",
...
}

// category type
{
"itemNo" : 1,
"updatedTimestamp" : "2023-03-10T05:32:04.522723Z",
"categoryCode" : 222111, // 도메인별로 다른 필드
"type" : "CategoryType",
...
}

이러한 관리방식은 새로운 데이터를 제공받고자 할 때 발행쪽과 메세지 규격을 맞추고 타입 하나만 추가하면 되기때문에 확장성있고 빠른 개발이 가능합니다.

메세지 색인
컨슈밍 메세지는 검색의 니즈에 맞게 데이터를 가공하고 모델테이블로 관리되며 Spring Quartz 배치를 통해 Indexer가 ES로 벌크 색인합니다.

초기 설계시 Zero Payload 방식으로 식별자만 받고 이후 데이터를 요청하여 인덱싱하는 방식을 고민하였지만 구현 컨셉상 도메인별로 책임지는 데이터가 다르기 때문에 필요한 데이터를 모두 aggregation 하기 위해선 여러 도메인서버를 호출해야 한다는 한계점이 있었습니다. 또한 타입이 여러개로 구분되어 있기 때문에 상품데이터가 변경된다면 동일한 상품이 다른타입으로 여러번 발행 됩니다. 이런 경우 발행된 횟수만큼 타 도메인서버를 호출하게 되며 부하가 증가될수 있습니다. 이러한 점으로 인해 Zero Payload 방식을 채택하지 못하였습니다.

Spring Batch 를 선택하지 않은 이유는 실행 시 별도의 어플리케이션이 Boot 되는 시간이 있기 때문에 색인까지의 시간이 그만큼 지연된다고 판단하여 Quartz 를 채택하였습니다.

코틀린 + SpringBoot 기반의 신규 검색 API 서버 구현

신규 ES 클러스터와 인덱싱 파이프라인을 통해 구축된 데이터를 검색하는 API 서버도 신규로 개발하게 되었습니다. 마이크로서비스로 전환하면서 29CM의 백엔드팀은 구현 아키텍처나 네이밍 컨벤션, 기술스택 등을 최대한 통일하고 있습니다. 이러한 부분을 기존 서비스를 적용하기에는 어려움이 있었고 기능을 추가, 확장하기에도 어려운 구조여서 신규 서비스를 만들기로 결정했습니다.

기존 서비스는 파이썬과 장고프레임워크로 구현되어 있었는데 저는 파이썬 개발 경험이 없고 검색 도메인 또한 처음이었기에 기존 기능파악에 꽤 오랜시간이 걸렸습니다. 코드파악과 ES 공부를 병행하였고 API포팅과 엔드포인트 URI, 기존 쿼리 최적화, 네이밍컨벤션 통일 등을 진행 했습니다.

ES 호출 클라이언트 서버 일원화
ES 를 관리하는 메인 오너쉽을 가진 곳은 검색스쿼드 이지만 여러 도메인에서 ES 를 같이 사용하기 때문에 이슈 발생 시 root cause 를 찾는데 꽤 많은 리소스가 들어갔었고, ES 를 사용하는 API 들이 얼마나 추가되고 있는지 전혀 파악하지 못하는 문제도 있었습니다. 신규 검색플랫폼 전환과 함께 신규 API 서버를 통해서만 ES 클러스터를 호출하도록 하였습니다.

Spring Data Elasticsearch 사용
타 도메인 개발자들이 ES 의 데이터를 조회하기 위해선 신규 API 서버에서 구현하는 경우가 많을 것이기 때문에 백엔드 전사 공통 기술스택인 Kotlin & SpringBoot 를 채택하였고 Spring Data Elasticsearch(spring data es) 를 활용하여 쿼리를 생성하고 검색 결과를 처리하였습니다.

spring data es 는 @Document 어노테이션을 통해 도메인 클래스에 선언하여 index 와 동일한 스키마를 선언할 수 있기 때문에 인덱스를 코드단에서 확인할 수 있는 명세서 역할로도 활용 가능합니다.

@Document(indexName = "#{@esConfig.productIndexAlias}", createIndex = false)
class ProductDocument(
@Field(name = "item_no", type = FieldType.Long)
val itemId: Long,
@Field(name = "item_name", type = FieldType.Text)
val itemName: String,
@Field(name = "is_free_shipping", type = FieldType.Keyword)
val isFreeShipping: String
...
)

이것을 활용하여 간단한 쿼리는 Spring Data Repository 에 내장된 쿼리빌더를 통해 findById() 를 사용하였고 그 외에 복잡한 DSL 쿼리를 만들 땐 elasticsearch 의존성의 ElasticsearchRestTemplate 를 사용하여 fetch 하였습니다.

HighLevelClient 를 활용할까도 고민하였지만 데이터 fetch 이후 역직렬화하여 객체에 매핑하는 별도의 작업을 하지 않아도되고 앞서 말씀드렸듯이 간단한 쿼리는 메서드 이름을 기반으로 CURD 명령어를 생성할 수 있습니다.

데이터 Fetch 이후 객체 매핑

# HighLevelClient 사용시 코드
val searchResponse = restHighLevelClient.search(query, RequestOptions.DEFAULT)
val ProductDocuments: List<ProductDocument> = searchResponse.hits.map { hit ->
val itemId = hit.sourceAsMap["item_id"]
val itemName = hit.sourceAsMap["item_name"]
val brandName = hit.sourceAsMap["brand_name"]
ProductDocument(itemId = itemId, itemName = itemName, brandName = brandName)
}

# Spring Data Elasticsearch 의 elasticsearchRestTemplate 사용시 코드
val ProductDocuments: List<ProductDocument> =
elasticsearchRestTemplate.search(query, ProductDocument::class.java)
.searchHits.map { it.content }

Elasticsearch 버전 업그레이드와 형태소 분석기 변경(은전한닢 → Nori)

ES 버전을 7.X 로 업그레이드 하고 분석기를 Nori(노리) 분석기로 변경 했습니다.

기존 사용중인 분석기가 복합명사와 화이트스페이스로 구분된 키워드를 토크나이징하는데 이슈가 있었기 때문에 API 서버에서 로직을 통해 화이트스페이스로 구분 하고 or, and 를 concat 하여 query 를 만들어 냈었습니다.

ex ) 나이키 맨투맨 검색시 생성되는 쿼리 : (나이키 AND 맨투맨) OR (나이키 AND 맨투맨)

또한 검색 대상이 될 필드에 별도의 분석기가 없었기 때문에 검색품질 또한 좋지 않았습니다.

이러한 점을 보완하고자 노리를 형태소 분석기로 변경하고 유사어사전을 추가적용, lowercase 와 노리의 stoptags 를 적용한 커스텀 분석기를 필드에 적용 하였습니다.

# setting
"category_analyzer" : {
"filter" : [
"category_synonym",
"general_pos"
],
"char_filter" : [
"lowercase"
],
"type" : "custom",
"tokenizer" : "general_tokenizer"
},
"brand_analyzer" : {
"filter" : [
"brand_synonym",
"general_pos"
],
"char_filter" : [
"lowercase"
],
"type" : "custom",
"tokenizer" : "general_tokenizer"
}
...

결과

위와 같은 실행을 바탕으로 신규검색플랫폼을 정상적으로 오픈할 수 있었고 2022년 4분기부터 안정적으로 운영하고 있습니다. 초기 목표로 했던 것들을 이룰 수 있었고 이로 인해 많은 점들이 추가 개선되고 있습니다.

스쿼드 OKR 달성을 위한 빠른 실행 가능

검색은 사용자 볼륨이 큰 편이기 때문에 랭킹개선 실험 같은 경우 결과의 유의성을 하루~이틀안에 확인할 수 있는 장점이 있습니다. 그렇기 때문에 실험을 통해 러닝을 얻고 다음 실험을 실행하는 속도가 다른 스쿼드에 비해 호흡이 빠른편인데 레거시 검색플랫폼은 속도 부분에서 제약이 많을수 밖에 없었습니다.

이번 고도화로 인해 기능을 구현하는 물리적 시간을 줄일 수 있었고 그만큼 요구사항을 분석하고 설계하는데 시간을 쏟을 수 있게 되었고 스쿼드 내에서 중요하게 생각하는 지표 중 하나인 검색성공률을 빠른 시간 안에 끌어올리고 있습니다.

검색운영팀과의 시너지

고도화 과정 중에 검색운영팀이 빌딩되었고 검색품질개선 업무를 담당 해주셨습니다.

검색 플랫폼을 개선하면서 검색시 사용하는 필드들을 특성에 맞는 형태소 분석기를 적용하였고 이러한 부분은 검색운영팀에서 관리하는 검색 데이터사전이 검색 결과에 미치는 효과도 극대화 되었습니다.

남아있는 과제

인덱싱서버의 메세지 처리 성능 이슈

앞서 설명 드린대로 Spring Quartz 를 활용하여 메세지를 색인처리하는데 처리할 데이터 양이 많아질수록 ES 색인이 늦어지는 이슈가 있습니다. 발행되는 메세지를 실시간으로 색인하는 구조가 아니기 때문에 배치가 실행될때 보관하고 있는 데이터를 읽어 색인요청하게 되는데 이때 하나의 Quartz 스레드가 처리해야하는 메세지가 많아질수록 변경된 메세지의 ES 반영도 늦어지게 되는 문제를 안고 있습니다.

Max 값 조회를 위해 2번의 ES 커넥션

검색 API 에서는 Multi Match 쿼리를 사용하고 있고 추천순 정렬시 검색한 키워드에 대한 Similality 점수를 score에 반영하기 위해 Painless script_score 를 활용 하고 있습니다.

하지만 쿼리에 따라 score 점수가 크게 다르기 때문에 score 를 정규화하기 위해 아래와 같이 Max Score 를 확보하기 위한 한번의 쿼리를 선행하게 됩니다.

  1. Max Score Fetch 쿼리
  2. max 값을 params 로 전달하여 script_score 에서 활용

Max Score 조회쿼리가 API 응답시간에 영향을 준다는 것을 확인하였고 추후 이러한 부분을 개선하기 위하여

rescoring 기능을 커스텀하여 플러그인을 만드는 것도 고려중에 있습니다.

늘어나는 신규 필드

검색결과 품질개선을 위해 유저의 이벤트(유저 피드백)를 검색 랭킹에 반영하는 실험이 이루어지고 있습니다.

이러한 작업진행시 현재는 상품 인덱스에 필드를 추가하는 식으로 개발이 이루어지고 있습니다.

만약 상품 클릭수를 랭킹에 반영해야 한다면 상품 인덱스에 상품 클릭수 필드를 추가하는 것입니다.

이러한 방식은 실험진행마다 필드 추가가 일어나기 때문에 기존 인덱스가 점점 커지고 색인 및 검색 성능을 저하시키며 확장성이 떨어지는 방식이라고 생각하며 이를 해결할 수 있는 방법을 고민 중에 있습니다.

마치며

2022년부터 신규 검색 플랫폼 전환 작업을 시작하였고 글에 다 적지는 못했지만 크고작은 이슈들이 많이 있었습니다. 개인적으로 기억나는 가장 큰 이슈는 서비스를 오픈했다가 이슈발생으로 인해 며칠만에(3일로 기억합니다..) 롤백을 한 것이었습니다. 큰 이슈발생으로 인해 많이 불안했는데, 멘탈을 잡아주고 재정비할 시간을 주셨던 리더분들 및 검색스쿼드 동료분들과 업무에 많은 불편함이 있었음에도 불구하고 시스템개선을 위해 이해해주셨던 29CM 모든 동료분들께 감사드립니다.

[함께 성장할 동료를 찾습니다]

29CM (무신사) 는 3년 연속 거래액 2배의 성장을 이루었습니다.

더 빠르고 큰 성장을 위하여 Search Engine을 고도화 시켜 고객이 어떤 검색어를 검색하던 원하는 상품을 찾아낼 수 있도록 여러가지 검색 실험과 고객의 관점에서 깊은 분석을 시도하고 있습니다.

함께 성장하고 유저 가치를 만들어낼 동료 개발자분들을 찾습니다.
많은 지원 부탁드립니다!

🚀 29CM 채용 페이지 : https://www.29cmcareers.co.kr/

--

--