대부분의 글이 그렇지만, 이번 글은 구성이 좀 이상해 보일 수 있다.
게임 이름 검색에 대해서는 개발 초기부터 생각했었고, 그 조각난 기록들을 모으다보니 이렇게 된것 같다.
# GuessTheGame의 게임 이름 자동완성
원래 내가 따라하려고 했던 GuessTheGame 서비스에서는 자동완성 기능을 제공했다.
나도 이런 기능을 꼭 만들어 보고 싶었고, 이 서비스에 꼭 필요한 기능이라고 생각했었다.
그래서 처음에는 개발자 도구로 GuessTheGame에서 제공하는 기능이 어떻게 제공되는건지 알고 싶었다.
아래를 보면 /autocomplete에 쿼리 파라미터로 유저에 입력을 넣어 보내서 관련 결과를 받아온다.
이렇게 되어있는데, q만 알아도 괜찮은것 같다.
# LIKE 쿼리를 이용해 구현
이제 내부 구현에 대한 것은 내 몫이였다.
처음으로 도입한 구현 방식은 mysql DB에 game 테이블에 쿼리를 날리는 방식이였다.
sqlmodel orm을 쓰느라 쿼리를 직접 작성하진 않았지만, 아래와 같은 쿼리를 던져서 얻는 방식이다.
SELECT name FROM game WHERE name LIKE %q%;
데이터베이스의 문자열 검색에 대해 안다면 이 방식에는 문제가 있다는 것을 바로 눈치챌 것이다.
LIKE %q%의 왼쪽부분 %때문인데, 문자열 칼럼은 인덱스로 만들어도 왼쪽에서 오른쪽 순으로 검색하기 때문에,
왼쪽에 와일드 카드(%)를 달아주면 모든 칼럼값을 전부 비교하게된다. (풀 인덱스 스캔 or 풀 테이블 스캔)
스팀 게임의 개수는 10만개, 별칭까지 더하게 된다면 아마 엄청난 오버헤드가 오래 걸릴 것이다.
그래서 ElasticSearch를 도입하는 것을 마음 한켠에 찜해두고 있었다.
하지만, 이 기술은 당장 필요하지는 않았기에 일단 요구사항에 집중했다.
그러니까, 어떤 기능이 들어가야 하는지 생각하면서 통합 테스트를 작성했다.
통합 테스트에서 요구한 기능은 아래와 같다.
[쿼리 검색]
- 부분 일치
- 쿼리가 어떤 게임의 이름을 부분적으로 포함할때 해당 게임을 반환한다. (게임이 Minecraft라면, Mine, necra, craft 검색할 수 있다.)
- 부분 검색에서 쿼리는 세글자 이상이여야 한다. (너무 짧은 경우 탐색되는 게임의 수가 너무 많기 때문)
*이후 길이 제한은 없앴다. 차라리 자동완성되는 게임의 개수를 조절하는 편이 낫다고 판단했음.
- 완전 일치
- 완전 검색에서 쿼리의 길이는 제약사항이 되지 않는다.
[한국어 지원]
- 해당 기능들은 한국어 이름으로도 검색할 수 있어야 한다. (이때 게임이 중복되서 등장하면 안된다.)
*한국어 지원은 데이터 수집이 어려워 사라지고 말았다. 나중에 별칭으로 제공할 예정이다.
# 엘라스틱 서치의 도입
나는 기술을 도입할때 각잡고 공부한 뒤 도입하는 사람이 아니다.
제대로 공부하려면 책을 봐야 하는데, 프로젝트를 하면서 보는게 여간 힘든게 아니다.
책을 보는건 어느정도 시간을 들여야 하는데, 어서 구현하고 싶으니 시간이 없다.
아무튼 그래서 무작정 poetry add elasticsearch를 입력하고, 도커 컴포즈를 이용해 엘라스틱 서치의 컨테이너를 구성하는 것으로 시작했다.
그리고 게임 이름을 index에 집어넣는 스크립트를 작성했다.
mysql에서 game 레코드를 모두 불러와서 {name: 이름, id: 게임 아이디} 형태의 document를 bulk insert 했다.
여기까지 별 문제가 없어서 기분이 좋았다.(뭐야? 짜식! 쉬운데?)
그리고 바로 자동완성기능에 엘라스틱 서치를 도입했다.
game index에서 match 방식으로 쿼리로 document내 name을 검색했다.
{match: {name: {query: q}}
# 가혹한 부분일치
아무생각 없이 테스트를 돌려보았는데.. 부분 일치에서 터졌다.
엘라스틱 서치는 전문검색의 만능이라고 생각했던 내가 바보였다.
Minecraft에서 necraft같은 가혹한 부분일치를 제공하지 않기 때문이였다
엘라스틱 서치는 Analyzer 라는 것으로 문장을 여러개의 term으로 만들어낸다고 한다.
이 기준은 정확히 찾아보지 않았지만, 구분할수 있는 단어로 나눈다는 것 같다.
띄어쓰기가 있으면 나누고, “car pick” -> “car”, “pick”
붙어있는 경우도 개별 단어로 인식되면 분리한다. “callofduty” -> “call”, “of”, “duty”
그러고 이 term들을 통해 document를 찾아낸다.
term이 key고 doc id가 value인 인덱스 구조에서 찾는 것인데, 이 인덱스를 역 인덱스라고 한다고 함.
아무튼 그래서 결국 이 term들을 검색하는 것인데, rdb의 여느 세컨더리 인덱스 구조와 같이 중간 부분일치가 힘들다. (LIKE %q%같은거; 풀 인덱스 rdb와 같은 이유다.)
결국, 부분일치는 prefix까지만으로 테스트를 수정했다.
그럼 elastic search를 왜 쓰는 걸까 고민해보았다.
전에 멧돼지 책에서 본 기억을 더듬어 보면 엘라스틱 서치의 내부 엔진인 Lucene은 trie 자료구조로 문장을 검색한다고 들었다.
trie는 알고리즘 문제중에서 내가 좋아하는 자료구조 문제라서 장점을 잘 알고 있다.
trie는 하나의 쿼리를 여러 후보군과 비교할때 유용하다. 쿼리의 길이만큼만 시간복잡도를 사용하게 된다.
그래서.. 엘라스틱 서치의 장점을 “문장을 term으로 나누어서 검색할 수 있고, 인덱스도 빠르게 검색할 수 있다”인것 같다.
그리고 게임 이름들은 단일 단어가 아닌 문장이 많다. 그래서 match 대신 match_phrase_prefix를 사용했다.
match_phrase_prefix는 문장내 단어의 순서를 고려하고 마지막 단어의 prefix로 검색한다.
# 검색이 안되는 게임
이제 다 해결된 것 같지만 그래도 검색이 안되는 것이 있다.
우연히 찾아낸 것인데, 그 주인공은 Nier:Automata다.
나는 “오토마타”로 이 게임을 기억하고 있어서 테스트를 죄다 Auto, auto, Automa 이런것으로 구성했었다.
이녀석 아무리 해도 검색이 안된다.
analyzer를 사용해 만들어진 term을 보고 문제를 알 수 있었다.
```
curl -XPOST 'localhost:9300/game_index/_analyze?pretty' -H 'Content-Type: application/json' -d '
{
"text": "NieR:Automata"
}'
{
"tokens" : [
{
"token" : "nier:automata",
"start_offset" : 0,
"end_offset" : 13,
"type" : "<ALPHANUM>",
"position" : 0
}
]
}
nier:automata를 하나의 term으로 인식하는 것이 문제였다.
당연히 :를 띄어쓰기 처럼 구분할줄 알았는데, 그렇지 않았다.
nier가 개별 단어도 인식되는것도 아니여서 구분이 잘 안되었다.
아무튼 그래서 저 특수문자들을 모조리 “ “로 바꿔버린 필드를 document에 추가했다.
{name: 게임 이름, q_name: 검색용 게임 이름, id: 게임 아이디}
물론 전문으로 검색하는 경우가 있을 수 있으니
name으로도 검색을 한다.
결국 검색 쿼리는 이렇게 되었다.
bool: {
should: [
{match_phrase_prefix: {q_name: {query: q}},
{match_phrase_prefix: {name: {query: q}},
]
}
# 프론트는 괴로워 AutoComplete 컴포넌트
개발이 오래걸리는 이유 중 하나는 역시 프론트엔드다.
프론트를 잘 아는 누군가 해주면 아주 좋으련만! 개인프로젝트라서 그런건 없다.
NextUI는 정말 좋지만, 이상한 부분에서 오류와 경고가 자꾸만 나곤한다.
그중 가장 시간을 많이 잡아먹은건 자동완성에서 사용했던 AutoComplete 컴포넌트다.
사용자가 게임 이름 쿼리를 입력하면 백엔드를 거쳐 자동완성된 게임 이름을 가져온 후,
AutoComplete 컴포넌트에 AutoCompleteItem 자식 컴포넌트를 업데이트하는 구조로 만들었다.
하지만, 애초에 이 컴포넌트는 다이나믹하게 자식이 변경되는 것을 원치 않았던 것 같다.
자꾸만 자꾸만 오류가 뿜어져 나오고, 에러메시지가 화면에 뿌려졌다.
특히, 자동완성된 이름을 마우스로 선택했을때 높은 확률로 오류를 경험해볼 수 있었다.
이런 저런 방법을 써서 해결하고자 했지만, 결국에는 “자동완성 선택 이벤트”, “인풋 핸들러”를 구현해 입력값 / 선택값 / 자식 컴포넌트를 완전히 제어하는 코드를 작성해야 했다.
프론트엔드 코드는 잘 몰라서, 코드를 가지고 설명하진 않겠다.
그냥.. 개념적으로라도 내가 한 것을 어딘가에 적고 싶었다.
'프로그래밍 > 스팀 게임 퀴즈' 카테고리의 다른 글
#25 서비스 개선 기획 (0) | 2024.03.18 |
---|---|
#24 게임 이름 별칭을 구현함 (0) | 2024.03.12 |
#22 백엔드와 프론트엔드를 배포했다! (0) | 2024.02.18 |
#20 데일리 퀴즈 생성 로직 방식에 대해서.. (0) | 2024.01.29 |
#19 게임 데이터 스크래핑 전략 수정하기 (1) | 2024.01.25 |