MongoDB 검색 기능 활용하기

어플리케이션을 만들다 보면, 검색 기능은 빠질 수 없는 기능 중 하나이다. 하지만 실제로 구글처럼 키워드나 문장을 넣어서 검색되는 기능을 구현하려면 매우 복잡해진다.

일반적으로 검색엔진에서 검색 기능은 다음과 같이 구현된다.

  1. 이용자가 넣은 키워드 혹은 문장을 읽어들인다.
  2. 문장이라면 키워드를 뽑아서 나눈다
  3. 데이터베이스에서 해당 키워드와 문장을 이용하여 검색을 돌린다.
  4. 해당 키워드 혹은 문장이 어디에 얼마나 포함되어 있는지에 따라 가중치를 적용하여 각 검색 결과에 점수를 메긴다.
  5. 점수에 따라 데이터를 정렬하여 결과 값을 이용자에게 보여준다.

다행히도 MongoDB에는 Full Text Search (FTS) 라는 기능이 탑재되어있어서, 위와 비슷한 검색을 비교적 간단하게 구현할 수 있다. 물론 어디까지나 아주 기본적인 검색이 가능한 것일 뿐, 구글처럼 여러 요소를 복합적으로 복잡하게 계산하거나 하는건 아니다.

하지만 일반적인 어플리케이션에서는 이정도의 기능만 구현되도 충분할 수 있다. 이번 포스트에선 MongoDB에서 이걸 어떻게 구현하는지에 대해 알아본다.

좀 더 정교한 검색 기능을 구현하고 싶다면 아파치의 SOLR 같은 솔루션이 있지만, 이건 이 자체가 전문적인 지식에 속하기 때문에, 따로 전문적인 공부와 연구가 필요하다.


예제 컬렉션 만들기

우선 FTS를 어떤식으로 구현하는지 보기 위해 예제 컬렉션을 만들어 본다. 예제에서는 제목과 내용이 있는 간단한 메세지의 Messages 컬렉션을 만들어 보도록 하겠다.

Messages 컬렉션 스키마

{
    "subject": <메세지 제목>,
    "content": <메세지 내용>,
    "year": <메세지를 보낸 년도>,
    "month": <메세지를 보낸 달>,
    "day": <메세지를 보낸 일자>
}

샘플 메세지 입력

db.messages.insert({"subject": "내일 뭐하지?", "content": "내일 5시에 피시방 갈래?", "year":2018, "month": 7 , "day": 10})
db.messages.insert({"subject": "흠 피시방 말고..", "content": "피시방 말고 보드 게임이나 하러 가자", "year":2018, "month": 7 , "day": 10})
db.messages.insert({"subject": "알았다", "content": "그럼 내일 강남역 4번 출구 앞에서 보자", "year":2018, "month": 7 , "day": 10})
db.messages.insert({"subject": "미안", "content": "야 오늘 못나갈거 같다. 그냥 다음에 보자", "year":2018, "month": 7 , "day": 11})

#컬렉션에 인덱스를 만들기

인덱스는 간단하게 말하면 어느 속성에 있는 값에 대해서 검색을 할건지 지정하는 것. 주의 할 점은, 한 컬렉션 당 하나의 text 인덱스만 생성할 수 있다. 그렇기 때문에 이전에 한번 인덱스를 설정했다가, 추가적으로 인덱스를 설정하고 싶을 경우, 이전에 만든 인덱스를 한번 지우고 새롭게 다시 생성해줘야한다.

1.한개의 인덱스만 만들 때

db.messages.createIndex({"subject":"text"})

createIndex를 이용해서 자기가 인덱싱 하려는 속성 값에 text 라고 지정해주면 된다. 위의 예제에서는 제목 부분만 인덱스를 한 경우.

2.한개 이상의 인덱스를 만들 때

db.messages.createIndex({"subject":"text", "content": "text"})

만약 1번 방법으로 이미 인덱스를 만들었다면, 위의 커맨드를 쳐도 에러가 날 뿐 인덱스가 새로 생성되지 않는다. 이유는 text 인덱스는 하나의 컬렉션 당 하나만 만들 수 있기 때문.

그렇기 때문에 이미 만든 인덱스를 한번 drop 해주어야한다.

db.messages.dropIndex("subject_text")

subject_text가 1번에서 만든 인덱스의 이름이 된다. 인덱스의 이름이 뭔지 알고 싶을 땐 우선 아래 커맨드를 쳐보면 된다.

db.messages.getIndexes()

그 다음 나온 결과 값에서 아래와 같은 부분을 찾으면 된다.

"key" : {
	"_fts" : "text",
	"_ftsx" : 1
},
"name" : "subject_text_content_text",

name 부분에 나와있는 값이 본인이 생성한 인덱스의 이름이 된다. drop 할 때는 이 이름을 사용하면 된다.

3. 모든 속성에 인덱스를 만들 때

db.messages.createIndex({"$**":"text"})

위에서 보이는 것처럼 와일드카드 $**를 사용하면 된다. 해당 컬렉션의 모든 속성에 검색 인덱스를 설정해야할 때 이걸 쓰면 된다. 하지만 너무 많은 속성을 인덱스하면 그만큼 검색 속도에 영향을 주기 때문에, 반드시 필요한 속성에만 인덱스를 생성하는 것이 좋다.

#검색해보기

검색은 다음과 같이 실행하면 된다.

db.messages.find({$text: {$search: "내일 피시방"}},{score:{$meta: "textScore"}}).sort({score:{$meta:"textScore"}})

$search에 검색에 사용할 키워드/문장을 넣어주면 된다. 각 검색 결과의 점수를 score 라는 새로운 필드에 $meta를 이용해서 저장한 후, 점수가 높은 것부터 정렬해서 결과 값을 보여준다.

{
	"_id" : ObjectId("5b46c46e3b2aac00e616d0e7"),
	"subject" : "내일 뭐하지?",
	"content" : "내일 5시에 피시방 갈래?",
	"year" : 2018,
	"month" : 7,
	"day" : 10,
	"score" : 2
}
{
	"_id" : ObjectId("5b46c4bf3b2aac00e616d0e8"),
	"subject" : "흠 피시방 말고..",
	"content" : "피시방 말고 보드 게임이나 하러 가자",
	"year" : 2018,
	"month" : 7,
	"day" : 10,
	"score" : 1.25
}
{
	"_id" : ObjectId("5b46c4fd3b2aac00e616d0e9"),
	"subject" : "알았다",
	"content" : "그럼 내일 강남역 4번 출구 앞에서 보자",
	"year" : 2018,
	"month" : 7,
	"day" : 10,
	"score" : 0.5714285714285714
}

결과에서 보이는 것 처럼, 각 단어가 제목과 내용부분에 얼마나 포함되었는지에 따라서 점수가 달라진 것을 알 수 있다. 기본적으로 검색어를 넣으면 MongoDB에서는 검색어에 따라 키워드를 생성한다.

예를 들어 이번 예제처럼 "내일 피시방" 이라는 검색어를 넣으면 MongoDB는 "내일"과 "피시방" 이라는 키워드를 만들어서, 각 인덱스에 대해 검색을 돌리게 된다.

#문장 검색

"내일 피시방"을 "내일"과 "피시방" 이라는 키워드로 나누어서 검색하는 것도 좋지만, 어떨 땐 검색어로 입력한 문장 자체를 포함하고 있는 걸 검색하고 싶은 경우가 있을 수 있다.

그럴 땐 검색어를 다음과 같이 넣어주면 된다.

db.messages.find({$text: {$search: "\"내일 강남역\""}}, {score: {$meta: "textScore"}}).sort({score:{$meta:"textScore"}})

위 처럼 "\"내일 강남역\"" 이라고 검색어를 지정해주면, "내일 강남역" 이라는 문장을 포함하고 있는 것만 검색결과에 포함되게 된다.

{
	"_id" : ObjectId("5b46c4fd3b2aac00e616d0e9"),
	"subject" : "알았다",
	"content" : "그럼 내일 강남역 4번 출구 앞에서 보자",
	"year" : 2018,
	"month" : 7,
	"day" : 10,
	"score" : 1.1428571428571428
}

#특정 키워드를 포함하지 않는 걸 검색

검색을 할 때 어떤 키워드는 포함시키고, 어떤 키워드는 배제하고 싶은 경우가 있다. 그럴 땐 다음과 같이 하면 된다.

db.messages.find({$text: {$search: "피시방 -내일"}}, {score: {$meta: "textScore"}}).sort({score:{$meta:"textScore"}})

배제하고 싶은 키워드 앞에 -를 붙여주면 된다. 그럼 위의 커맨드는 "피시방"이라는 키워드가 포함된 컬렉션 중에 "내일" 이라는 키워드가 포함되지 않은 결과치를 보여준다.

#세가지 모두 사용하고 싶을 때

키워드, 문장, 키워드 배제 이 3가지 모두를 사용하는 것도 가능하다.

db.messages.find({$text: {$search: "피시방 미안 \"내일 강남\" -가자"}}, {score: {$meta: "textScore"}}).sort({score:{$meta:"textScore"}})

위의 검색은 "피시방"과 "미안" 이라는 키워드가 포함된 것들 중에서 "내일 강남" 이라는 문장이 포함된 결과 중, "가자" 라는 키워드가 포함되지 않은 컬렉션을 보여준다.

#가중치 설정

검색을 할 때, 기본적으로 따로 설정하지 않으면 모든 속성에 대한 가중치는 1로 설정이 된다. 하지만 상황에 따라서는 제목에 특정 키워드가 들어있는 것을 내용에 키워드가 들어있는 것보다 더 우선시 하고 싶은 경우도 있다. 그럴 때는 가중치를 다르게 설정해주면 된다.

가중치는 인덱스를 설정할 때 함께 설정해주어야한다.

db.messages.createIndex({"$**":"text"}, {"weights": {subject: 3, content: 1}})

weights라는 옵션에 각 속성에 어떤 가중치를 줄 것인지 설정하면 된다. 위 예제에서는 제목에 키워드가 포함되어 있으면 내용에 키워드가 포함되어 있는 것 보다 3배 높은 점수를 주게 설정한 것.

#인덱스 파티션

켈렉션의 크기가 커지면 그만큼 검색결과도 느려지게 된다. 그럴 땐 컬렉션의 인덱스를 파티션해서 나누는 것이 하나의 해결책이 될 수 있다.

예를 들면, 메세지 같은 경우, 메세지의 송신자 별로 컬렉션을 파티션한다던지, 수신자 별로 컬렉션을 파티션한다던지, 메세지가 생성된 연도 별로 컬렉션을 파티션한다던지 하는 방법이 있다.

인덱스를 파티션하기 위해선, 역시나 인덱스를 처음 생성할 때 설정해주어야한다.

db.messages.createIndex( { "year":1, "subject": "text"} )

파티션 할 속성에 1을 설정해주면 된다. 위 코드는 메세지가 생성된 연도별로 파티션 하는 것. 대신 파티션을 하면 FTS를 이용할 때 반드시 파티션 속성의 값을 지정해주어야한다.

db.messages.find({year: 2018, $text: {$search: "내일 강남"}}, {score: {$meta: "textScore"}}).sort({score:{$meta:"textScore"}})

year의 값을 설정하지 않으면 에러가 난다.


이상으로 MongoDB에서 검색 기능을 활용하는 방법에 대해 알아보았다. 사실 아주 간단한 것만 가능하기 때문에 한계점도 있다.

예를 들면 특정 키워드 혹은 특정 문장을 포함하고 있는 걸 한번에 검색하는 것이 불가능하다. "내일" 이라는 키워드 혹은 "내일 강남" 이라는 문장을 포함하고 있는 컬렉션을 검색하지 못한다는 것. "내일" 이라는 키워드**"와"** "내일 강남"이라는 문장 둘 다 포함하고 있는 컬렉션에 대한 검색이 가능하다.

물론 해결책이 없는 건 아니다. 검색 쿼리를 두번 따로 실행해서 결과물을 합치면 된다.

그 외에는 오타를 걸러내는 기능이라든지, 유의어까지 검색한다든지 하는 기능은 없다. 애초에 이런 전문 기능을 쓰고 싶다면 SOLR 같은 특화된 오픈소스를 활용해야한다.

기본적인 사용법과 그 외 간략한 사용법은 공식 문서를 통해 알아볼 수 있다.
https://docs.mongodb.com/manual/text-search/