[모두의 플리] OpenSearch로 콘텐츠 한글 초성 검색 구현하기
[모두의 플리] OpenSearch로 콘텐츠 한글 초성 검색 구현하기
왜 일반 키워드 검색으로는 초성을 찾을 수 없을까
콘텐츠 제목과 설명은 OpenSearch의 ngram 분석기로 부분 검색할 수 있었다.
하지만 사용자가 ㅌㅇㅅㅌㄹ처럼 초성만 입력하면 원문 제목 토이 스토리와 직접 일치하지 않는다. PostgreSQL의 LIKE '%검색어%'나 일반 ngram 색인도 원문에 존재하지 않는 초성 문자열을 자동으로 만들어주지는 않는다.
초성 검색을 지원하려면 색인할 때 제목에서 초성을 미리 추출하고, 검색 시 해당 필드를 함께 조회해야 했다.
검색 문서에 초성 전용 필드 추가하기
ContentDocument에 initials 필드를 추가했다.
제목: 토이 스토리
초성 추출: ㅌㅇ ㅅㅌㄹ
공백과 비초성 문자 제거: ㅌㅇㅅㅌㄹ
프로젝트의 공통 InitialUtils.extractInitial()을 사용한 뒤, 한글 자음 범위가 아닌 문자는 제거했다.
InitialUtils.extractInitial(title)
.replaceAll("[^\\u3131-\\u314E]", "");
initials는 형태소 분석이 필요한 문장이 아니라 정확한 초성 문자열이므로 OpenSearch에서는 keyword 타입으로 저장했다.
이렇게 하면 API 조회 시 제목마다 초성을 다시 계산하지 않고 색인된 값을 바로 검색할 수 있다.
일반 검색과 초성 검색을 한 요청에서 처리하기
검색어가 들어오면 기본적으로 제목과 설명을 조회한다.
title match
OR description match
검색어가 ㄱ부터 ㅎ까지의 자음으로만 구성되었다면 초성 조건을 하나 더 추가한다.
title match
OR description match
OR initials wildcard
초성은 제목 중간부터 입력할 수도 있으므로 *검색어* 형태의 wildcard 검색을 사용했다.
QueryBuilders.wildcardQuery("initials", "*" + keyword + "*")
모든 일반 검색에 wildcard를 적용하면 검색 비용이 커질 수 있다. 따라서 초성으로만 구성된 검색어에 한해 이 조건을 추가했다.
한 글자 초성도 OpenSearch로 보내기
기존 검색 정책에서는 너무 짧은 검색어를 OpenSearch로 보내지 않았다.
하지만 ㄱ, ㅅ 같은 초성 한 글자 검색은 기능상 유효하다. 그래서 다음 예외를 두었다.
일반 검색어
-> 2~20글자만 OpenSearch 사용
초성 한 글자
-> OpenSearch 사용 허용
이를 구분하지 않으면 한 글자 초성은 DB fallback으로 이동한다. DB에는 초성 전용 컬럼이 없으므로 결과가 나오지 않는다.
인기순 구현 중 발생한 400 오류
초성 검색과 인기순 정렬을 함께 개선하는 과정에서 인기순의 내부 기준을 리뷰 수로 바꾸며 API 정렬 키도 reviewCount로 변경한 적이 있었다.
하지만 프론트엔드는 기존 명세대로 다음 요청을 보내고 있었다.
sortBy=watcherCount
백엔드가 reviewCount만 허용하자 인기순 요청은 컨트롤러 검증에서 400으로 거절되었다.
문제는 정렬 로직이 아니라 외부 API 계약을 내부 구현명과 함께 바꾼 것이었다.
최종적으로 외부 요청 키는 watcherCount를 유지하고, 실제 정렬 기준은 서비스 내부 정책으로 관리하도록 복구했다.
외부 API 계약
-> sortBy=watcherCount 유지
내부 인기순 정책
-> watcherCount
-> reviewCount
-> averageRating
외부에 공개된 문자열은 단순한 변수명이 아니라 프론트엔드와의 계약이라는 점을 확인한 사례였다.
OpenSearch 장애 시의 한계와 fallback
OpenSearch 조회에서 예외가 발생하면 콘텐츠 API 전체가 실패하지 않도록 DB 조회로 fallback한다.
일반 제목·설명 검색과 정렬은 DB에서도 처리할 수 있다. 하지만 초성 전용 값은 OpenSearch 문서에만 있으므로 OpenSearch가 중단된 동안에는 초성 검색 결과까지 동일하게 보장할 수 없다.
OpenSearch 정상
-> 일반 검색 + 초성 검색
OpenSearch 장애
-> 일반 DB 검색은 유지
-> 초성 전용 검색은 제한
초성 검색의 완전한 이중화를 위해 DB에도 초성 컬럼과 인덱스를 추가할 수 있지만, 데이터 중복과 동기화 비용이 생긴다. 이번 구현에서는 API 가용성을 DB fallback으로 유지하고 검색 고도화 기능은 OpenSearch가 담당하도록 역할을 나눴다.
테스트한 범위
실제 Docker OpenSearch를 사용한 통합 테스트에서 다음을 확인했다.
- 제목에서 추출된 초성이 문서에 저장되는지
- 여러 글자 초성으로 원하는 콘텐츠를 찾는지
- 한 글자 초성도 OpenSearch 검색으로 처리되는지
- 일반 한글 키워드 검색이 기존처럼 동작하는지
- 초성 검색 결과에 타입과 태그 필터를 함께 적용할 수 있는지
- 인기순 정렬과 초성 검색을 함께 요청할 수 있는지
- API 정렬 키
watcherCount가 유지되는지
배운 점
초성 검색은 검색 쿼리 하나를 추가하는 것으로 끝나지 않았다.
색인 시 파생 데이터를 만들고, 어떤 검색어를 OpenSearch로 보낼지 결정하며, 검색 서버가 실패했을 때 어느 수준까지 기능을 유지할지도 함께 정해야 했다.
또한 내부 정렬 정책이 바뀌더라도 공개 API의 필드명까지 무심코 바꾸면 프론트엔드 요청이 즉시 깨질 수 있다. 구현 세부사항과 외부 계약을 분리해서 생각해야 한다는 점을 배웠다.