인턴십 과제를 진행하며 프로덕트의 기능을 분석하던 중, 검색어에 띄어쓰기나 오타가 있을 경우 원하는 결과를 찾지 못하는 문제를 발견했습니다. 이는 서비스의 사용자 경험을 저해하고 이용률을 감소시킬 수 있는 중요한 이슈였습니다.
이 문제를 해결하기 위해 다양한 방법을 모색한 끝에, N-gram과 유사도 측정 기법을 활용하면 사용자가 원하는 데이터를 더 효과적으로 검색할 수 있을 것이라 판단했습니다. 이에 직접 이 기법들을 적용하여 검색 기능을 개선하였고, 그 과정에서 얻은 경험을 공유함으로써 비슷한 문제를 겪고 있는 분들에게 도움이 되고자 합니다.
0. 프로덕트 환경
- Spring Boot 3.x
- MongoDB Community 7.0.x
1. 기존 검색 시스템의 한계
기존의 검색 시스템에서는 사용자가 입력한 검색어와 데이터베이스에 저장된 단어가 정확히 일치해야만 결과를 반환하는 경우가 많았습니다.
- 띄어쓰기 문제: "티스토리"를 "티 스토리"로 검색하면 결과가 나오지 않음.
- 오타 문제: "애플리케이션"을 "어플리케이션"으로 잘못 입력하면 결과가 나오지 않음.
이러한 문제로 인해 사용자들은 원하는 정보를 얻기 어려웠고, 이는 서비스의 만족도를 저하시키는 원인이 되었습니다.
2. MongoDB의 한글 전문 검색 지원 부족 문제
MongoDB 공식 문서의 "자체 관리형 배포서버의 텍스트 검색 언어"와 "토크나이저"에 따르면, Atlas 이외의 환경에서는 한글 형태소 분석기와 N-gram을 지원하지 않습니다. "MongoDB Atlas"나 "Percona Server for MongoDB"를 사용하는 것도 고려할 수 있지만, 사내에서 라이선스와 비용 문제로 인해 "MongoDB Community" 버전을 사용하고 있었습니다. 따라서 고급 검색 기능을 구현하려면 한글 형태소 분석을 지원하는 "ElasticSearch", "Lucene Nori Analyzer" 등의 도구를 활용하거나, N-gram을 직접 구현하는 방법을 선택해야 했습니다.
3. 형태소 분석 알고리즘
MongoDB에서 텍스트 색인(인덱싱) 작업은 크게 토큰화(Tokenization), 불용어 처리(Stop Word Removal), 그리고 형태소 분석(Stemming)의 세 가지 주요 과정을 거쳐 이루어집니다. 이 과정에서 입력된 문장이 분석되고, 중요한 의미만을 담은 색인 데이터를 생성하게 됩니다.
1. 토큰화 (Tokenization)
먼저, MongoDB는 입력된 문장을 토큰으로 나누는 과정을 수행합니다. 토큰화는 문장을 단어 단위로 나누는 작업으로, 여기서 공백, 하이픈(-), 쉼표, 마침표 등 대부분의 문장 부호를 구분자로 사용합니다. 예를 들어, 다음과 같은 문장을 생각해 봅시다.
"나는 오늘 학교에 갔다."
이 문장은 공백과 마침표를 기준으로 "나는", "오늘", "학교에", "갔다"로 나누어집니다. 이렇게 나누어진 단위들을 토큰이라고 부릅니다. 토큰화 과정은 이후 검색 색인에서 단어 단위로 의미를 분석할 수 있도록 준비하는 단계입니다.
2. 불용어 처리 (Stop Word Removal)
토큰화 후, 불용어 처리가 수행됩니다. 불용어란 의미가 크지 않은 단어들로, 검색의 정확도 향상에 큰 도움이 되지 않는 단어들을 말합니다. 예를 들어, "이", "는", "그리고" 같은 단어(조사, 접미사 등)들은 실제 의미 분석을 하는데 중요한 역할을 하지 않기 때문에 이 단계에서 제거됩니다. 불용어가 제거된 후에는 의미가 중요한 단어들만 남게 되어, 색인에 포함되는 데이터의 품질이 향상됩니다. 예를 들어, 다음과 같은 문장을 생각해 봅시다.
"나는 오늘 학교에 갔다."
위 문장에서 "나는"과 "에"는 실제 의미를 분석하는데 중요한 요소가 아닙니다. 따라서 "나는", "에" 같은 불용어가 제거되면, "오늘", "학교", "갔다"라는 단어들만 남게 됩니다.
3. 형태소 분석 (Stemming)
마지막으로 형태소 분석이 이루어집니다. 형태소는 언어에서 의미를 가지는 가장 작은 단위입니다. 형태소 분석은 각 단어를 형태소 단위로 나누고, 단어의 원형으로 변환하는 과정을 의미합니다. 예를 들어, "갔다"라는 단어는 "가다"로 변환됩니다. 영어에서는 "running"이 "run"으로 변환되는 과정과 유사합니다. 이렇게 변환된 단어들을 색인에 저장함으로써, 사용자가 다른 형태의 단어로 검색하더라도 일관되게 관련 결과를 찾아낼 수 있게 됩니다.
4. N-gram 알고리즘
N-gram 알고리즘은 텍스트를 연속된 N개의 문자 단위로 나누어 검색 효율을 높이는 기법입니다. MongoDB와 같이 한국어에 대한 형태소 분석이 어려운 상황에서는 비교적 적은 투자로도 큰 효율을 얻을 수 있는 접근 방식입니다.
일반적으로 N-gram 알고리즘을 적용할 때는, 대소문자를 통일하고 구분자(공백, 하이픈 등)를 제거하는 전처리 과정을 거칩니다. 이를 통해 검색의 일관성을 유지하고, 사용자가 다양한 형태로 검색어를 입력하더라도 동일한 결과를 반환할 수 있게 됩니다. 예를 들어, "MongoDB 인덱싱"과 "mongodb 인덱싱"은 대소문자가 다르더라도 동일한 결과를 제공할 수 있도록 색인 과정에서 모두 소문자로 통일합니다. 또한, 공백이나 하이픈 같은 구분자도 제거하여 단어들 간의 불필요한 구분이 검색 결과에 영향을 주지 않도록 합니다.
예를 들어, "티스토리"이라는 단어를 2-gram(2개의 문자 단위)으로 나눈다면 다음과 같은 부분 문자열들이 만들어집니다.
"티스", "스토", "토리"
이와 같은 방식으로 문장을 N개의 문자 단위로 나누어 색인해두면, 사용자가 일부 오타를 포함하거나 불완전한 검색어를 입력해도 유사한 결과를 제공할 수 있습니다. 예를 들어, 사용자가 "티스코리"라고 잘못 입력한 경우에도 부분 문자열 "티스"와 매칭되어 "티스토리"라는 올바른 결과를 반환할 수 있습니다.
또 다른 예로, "MongoDB 인덱싱"이라는 데이터가 3-gram으로 색인되어 있다고 가정해 보겠습니다. 이 데이터를 3개의 문자 단위로 나누면 다음과 같은 부분 문자열들이 만들어집니다.
"mon", "ong", "nog", "odb", "db인", "b인덱", "인덱싱"
이러한 형태로 데이터가 색인되어 있을 때, 사용자가 "몽고디비 인덱싱"이라는 검색어를 입력했다고 가정해 봅시다. "MongoDB"가 "몽고디비"라는 텍스트로 바뀌었지만, 색인된 부분 문자열 "인덱싱"과 매칭되어 "MongoDB 인덱싱"이라는 결과를 얻을 수 있습니다.
5. 한글 형태소 분석기와 N-gram 방식의 비교
일반적으로 한글 텍스트 검색을 구현할 때, 앞서 설명드린 한글 형태소 분석기와 N-gram 두 방식을 이용합니다. 두 방식은 각각의 장단점이 있어 상황에 맞게 선택해서 사용해야 합니다.
한글 형태소 분석기는 문장의 의미를 보다 정확하게 분석할 수 있다는 장점이 있습니다. 그러나 형태소 분석기는 사전에 정의된 단어와 규칙에 따라 성능에 큰 차이가 있다는 단점이 있습니다. 이 말은 사전이 충분하지 않은 경우 적용이 어렵고, 지속적인 업데이트가 없으면 신조어에 대한 대응이 어렵다는걸 의미합니다. 또한, 형태소 분석기는 의미를 가진 최소 단위를 분해하여 검색 색인을 만들기에 오타나 띄어쓰기 문제가 있는 경우 유연하게 대응하기 어렵다는 점도 있습니다.
반면, N-gram 방식은 미리 정의된 사전이 필요없고, 단순히 N개의 연속된 문자 단위로 분할하여 사용하다보니 오타나 띄어쓰기에 유연하게 대응할 수 있다는 장점이 있습니다. "N-gram 알고리즘"의 예시에서 보였듯 검색어가 색인된 부분 문자열과 일치하는 경우 유사한 결과를 반환할 수 있기에, 사용자가 잘못 입력한 경우에도 비교적 정확한 검색 결과를 제공할 수 있습니다. 다만, 데이터가 많아지고 텍스트가 길어질수록 색인 데이터 또한 증가하기 때문에 저장 공간과 검색 성능에 영향이 있을 수 있습니다.
비교 항목 | 한글 형태소 분석기 | N-gram 방식 |
구현 복잡도 | 높음 (사전 구축 필요) | 낮음 (간단한 문자 단위 분할) |
검색 정확도 | 높음 (정확한 의미 분석 가능) | 보통 (문자 단위 검색) |
오타 대응 | 어려움 | 강함 (일부 오타에도 검색 가능) |
띄어쓰기 오류 대응 | 어려움 | 강함 (띄어쓰기 무시 가능) |
사전 의존성 | 매우 높음 (사전에 따라 성능 좌우) | 없음 (모든 문자 조합 색인) |
신조어 대응 | 어려움 (사전 업데이트 필요) | 쉬움 (신조어도 그대로 색인 가능) |
색인 크기 | 적음 (의미 단위만 색인) | 큼 (모든 문자 조합 색인) |
검색 속도 | 보통 | 낮아질 수 있음 (색인 크기 증가) |
저는 N-gram 방식을 선택했습니다. 형태소 분석기의 단점인 사전에 따라 성능의 편차가 크다는 점과 띄어쓰기를 정확히 지켜야 하는 불편함이 주요한 이유였습니다. 한국어는 띄어쓰기가 자유로운 언어이기 때문에 사용자마다 띄어쓰기가 다를 가능성이 높습니다. 이러한 특성으로 인해, 추가적인 보정 없이 검색 품질을 높게 유지하기가 어렵다고 판단했습니다.
6. 구현 시나리오
N-gram 방식은 색인된 부분 문자열과 일치하는 경우 해당 문서를 반환하기 때문에, 사용자가 원하지 않는 결과도 함께 노출될 수 있습니다. 이는 사용자의 관심사와 거리가 있는 결과가 노출되는 문제로 이어질 수 있습니다. 이러한 문제를 해결하기 위해 유사도와 같은 척도를 활용하여 사용자가 찾고자 하는 정보를 상위에 노출함으로써 검색의 적합성을 높이는 방법이 필요합니다. 본 구현에서는 색인된 부분 문자열과 검색어 간의 유사도를 측정하여, 검색 결과의 순위를 조정하는 방식을 사용했습니다.
구현 시나리오는 다음과 같습니다.
1. N-gram 관리용 Document 작성
2. N-gram 대상 필드 지정 애너테이션 작성
3. N-gram 생성 파서 작성
4. N-gram 생성 및 유효성 검증기 작성
5. 빌드 시 자동 유효성 검증 및 N-gram 재생성 설정
6. 유사도 측정기 작성
7. N-gram 기반 검색기 작성
6-1. N-gram 관리용 Document 작성
`N-gram` 데이터를 저장하고 관리하기 위해 MongoDB의 Document 클래스를 작성합니다. `NGramDocument` 클래스는 N-gram 데이터의 ID, 원본 도큐먼트 ID, 컬렉션 이름, 필드 이름, N-gram의 크기, 그리고 실제 N-gram 데이터들을 저장합니다. 복합 인덱스는 N-gram 검색과 필드 변경 감지 시 성능 최적화를 위해 설정되었습니다.
@Getter
@Setter
@Document(collection = "ngrams")
@CompoundIndexes({
@CompoundIndex(name = "collection_field_n_ngrams", def = "{'collectionName': 1, 'field': 1, 'n': 1, 'ngrams': 1}"),
@CompoundIndex(name = "document_field", def = "{'documentId': 1, 'field': 1}")
})
public class NGramDocument {
@Id
private String id;
private String documentId; // 고유 도큐먼트 ID
private String collectionName; // 원본 도큐먼트 컬렉션 이름
private String field; // n-gram이 생성된 필드 이름
private int n; // n-gram 크기
private List<String> ngrams; // n-gram 데이터 배열
}
public interface NGramRepository extends MongoRepository<NGramDocument, String> {
List<NGramDocument> findByCollectionNameAndFieldAndNgramsInAndN(
String collectionName, String field, List<String> nGrams, int n);
List<NGramDocument> findByDocumentIdAndField(String documentId, String field);
void deleteByCollectionName(String collectionName);
}
6-2. N-gram 대상 필드 지정 애너테이션 작성
`N-gram`이 생성될 필드를 지정하기 위해 `@NGramField` 애너테이션을 작성합니다. 이 애너테이션은 N-gram의 크기 (`value`)와, 사전에 설정된 크기와 다른 경우 취할 액션 (`failOnMismatch`)에 대한 설정을 제공합니다. 한국어에서는 주로 2-gram과 3-gram을 사용하기 때문에 기본값을 `2`로 설정했습니다. 필드의 `n` 값이 저장된 `NGramDocument`의 `n`과 다를 경우 데이터의 일관성 문제를 방지하기 위해 예외를 발생시킵니다.
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NGramField {
int value() default 2; // n-gram 기본값
boolean failOnMismatch() default true; // n 값이 다를 경우 기본적으로 예외 발생
}
6-3. N-gram 생성 파서 작성
텍스트를 N-gram 단위로 분할하는 파서를 작성합니다. 원본 문자열을 단순히 N개 단위로 끊는 것 외에도, 단어 간의 불필요한 구분이 검색 결과에 영향을 미치지 않도록 전처리 과정을 거칩니다. 전처리 과정에서는 유니코드 정규화, 특수 문자 제거, 영문 소문자 변환, 공백 제거 등이 이루어집니다. N-gram을 통해 만들어진 부분 문자열들 중 중복이 있는 경우 제거하여 성능을 최적화했습니다.
@Component
public class NGramParser {
/**
* 주어진 텍스트에 대해 n-gram을 생성합니다.
*
* @param text 대상 텍스트
* @param n n-gram의 크기 (n값)
* @return 생성된 n-gram의 리스트
*/
public Set<String> generateNGrams(String text, int n) {
Set<String> nGrams = new HashSet<>(); // 중복을 제거하기 위해 Set 사용
// 텍스트가 null이거나 n보다 작을 경우 빈 리스트 반환
if (text == null || text.length() < n)
return nGrams;
text = preProcessText(text);
for (int i = 0; i <= text.length() - n; i++) {
nGrams.add(text.substring(i, i + n));
}
return nGrams;
}
/**
* 텍스트 전처리를 수행합니다.
*
* @param text 원본 텍스트
* @return 전처리된 텍스트
*/
private String preProcessText(String text) {
text = Normalizer.normalize(text, Normalizer.Form.NFC); // 유니코드 정규화
text = text.replaceAll("[^가-힣a-zA-Z0-9]", ""); // 한글, 영문, 숫자만 남김
text = text.toLowerCase(); // 영문 소문자로 변환
return text.trim(); // 양쪽 공백 제거
}
}
6-4. N-gram 생성 및 유효성 검증기 작성
스프링 컨테이너에 등록된 `MongoRepository`를 이용하여 도큐먼트를 불러오고, `@NGramField`가 적용된 필드를 검사합니다. N-gram 대상 필드가 발견되면 `NGramDocument`에 저장된 정보와 비교합니다. `failOnMismatch`가 true인 경우, `IllegalStateException`을 발생시켜 사용자에게 N 값 변경이 있었음을 알립니다. `failOnMismatch`가 false인 경우, 기존 데이터를 삭제하고 애너테이션에 명시된 N 값에 따라 N-gram을 재생성하여 저장합니다. 대상 필드에 대한 N-gram 정보가 등록되지 않은 경우에도 새로 생성하여 저장합니다.
package com.internship.survey.global.ngram;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationContext;
import org.springframework.core.GenericTypeResolver;
import org.springframework.data.mongodb.repository.MongoRepository;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.stream.Stream;
@Component
@RequiredArgsConstructor
@Slf4j
public class NGramProcessor {
private final ApplicationContext applicationContext;
private final NGramRepository nGramRepository;
private final NGramParser nGramParser;
// 클래스별 어노테이션 필드 캐싱
private final Map<Class<?>, List<Field>> annotatedFieldsCache = new HashMap<>();
/**
* 모든 도큐먼트 클래스에 대해 NGramField를 검증하고 필요 시 재생성합니다.
* 각 도큐먼트 클래스의 N-그램 데이터의 일관성을 확인하고, 불일치나 누락된 데이터가 있을 경우 재생성합니다.
*/
public void validateAllNGrams() {
Map<String, MongoRepository> repositories = applicationContext.getBeansOfType(MongoRepository.class);
for (MongoRepository<?, ?> repository : repositories.values()) {
Class<?> documentClass = inferDocumentClass(repository);
if (documentClass == null) {
log.info("Skipping repository: Unable to infer document class for repository {}", repository.getClass().getSimpleName());
continue;
}
// 어노테이션이 붙은 필드를 캐싱
List<Field> annotatedFields = getAnnotatedFields(documentClass);
if (annotatedFields.isEmpty()) {
continue; // 어노테이션 없는 클래스는 건너뜀
}
processValidation(repository, documentClass, annotatedFields);
}
}
/**
* 주어진 리포지토리와 도큐먼트 클래스, 어노테이션이 적용된 필드를 기반으로 NGram 데이터를 검증하고,
* 필요 시 NGram 데이터를 재생성합니다.
*
* @param repository 검증할 도큐먼트를 관리하는 MongoRepository
* @param documentClass 검증할 도큐먼트 클래스 타입
* @param annotatedFields NGramField 어노테이션이 적용된 필드 리스트
*/
private void processValidation(MongoRepository<?, ?> repository, Class<?> documentClass, List<Field> annotatedFields) {
// 클래스 단위로 validate 수행
if (validateClass(repository, documentClass, annotatedFields)) {
regenerateAllForClass(repository, annotatedFields);
log.info("NGrams regenerated for document class: {}", documentClass.getSimpleName());
} else {
log.info("No NGram mismatches or missing data for document class: {}", documentClass.getSimpleName());
}
}
/**
* 도큐먼트 클래스에서 NGramField 어노테이션이 붙은 필드들을 반환합니다. (캐싱 활용)
*
* @param clazz 대상 도큐먼트 클래스
* @return NGramField 어노테이션이 붙은 필드들의 리스트
*/
private List<Field> getAnnotatedFields(Class<?> clazz) {
return annotatedFieldsCache.computeIfAbsent(clazz, key ->
Stream.of(key.getDeclaredFields())
.filter(field -> field.isAnnotationPresent(NGramField.class))
.toList()
);
}
/**
* 특정 도큐먼트 클래스에 대해 n 값의 불일치나 데이터 누락 여부를 확인합니다.
*
* @param repository 대상 리포지토리
* @param documentClass 도큐먼트 클래스
* @param annotatedFields 검증할 필드들의 리스트
* @return 불일치나 누락이 발견되면 true를 반환
*/
private boolean validateClass(MongoRepository<?, ?> repository, Class<?> documentClass, List<Field> annotatedFields) {
boolean mismatchFound = false;
List<?> documents = repository.findAll();
for (Object document : documents) {
for (Field field : annotatedFields) {
try {
field.setAccessible(true);
String fieldValue = (String) field.get(document);
if (fieldValue != null) {
NGramField annotation = field.getAnnotation(NGramField.class);
int n = annotation.value();
List<NGramDocument> existingNGrams = nGramRepository.findByDocumentIdAndField(getDocumentId(document), field.getName());
if (!existingNGrams.isEmpty() && existingNGrams.getFirst().getN() != n) {
if (annotation.failOnMismatch()) {
throw new IllegalStateException(String.format(
"NGram mismatch detected for document class '%s', document '%s', field '%s': stored n=%d, expected n=%d",
documentClass.getSimpleName(), getDocumentId(document), field.getName(), existingNGrams.getFirst().getN(), n
));
} else {
mismatchFound = true;
}
}
}
} catch (Exception e) {
throw new RuntimeException("Failed to validate NGramField: " + field.getName(), e);
}
}
}
// 불일치 또는 누락 발견 여부 반환
return mismatchFound || documents.stream()
.anyMatch(document -> annotatedFields.stream().anyMatch(field -> isMissingNGrams(document, field)));
}
/**
* 특정 도큐먼트와 필드에 대해 NGram 데이터의 누락 여부를 확인합니다.
*
* @param document 대상 도큐먼트 객체
* @param field 대상 필드
* @return NGram 데이터가 누락되었으면 true를 반환
*/
private boolean isMissingNGrams(Object document, Field field) {
try {
return nGramRepository.findByDocumentIdAndField(getDocumentId(document), field.getName()).isEmpty();
} catch (Exception e) {
return true;
}
}
/**
* 클래스의 모든 인스턴스에 대해 NGram 데이터를 재생성합니다.
*
* @param repository 대상 리포지토리
* @param annotatedFields NGramField 어노테이션이 붙은 필드들의 리스트
*/
private void regenerateAllForClass(MongoRepository<?, ?> repository, List<Field> annotatedFields) {
List<?> documents = repository.findAll();
List<NGramDocument> newNGrams = new ArrayList<>();
for (Object document : documents) {
for (Field field : annotatedFields) {
try {
field.setAccessible(true);
String fieldValue = (String) field.get(document);
if (fieldValue != null) {
NGramField annotation = field.getAnnotation(NGramField.class);
int n = annotation.value();
Set<String> nGrams = nGramParser.generateNGrams(fieldValue, n);
NGramDocument nGramDocument = new NGramDocument();
nGramDocument.setId(UUID.randomUUID().toString());
nGramDocument.setDocumentId(getDocumentId(document));
nGramDocument.setField(field.getName());
nGramDocument.setCollectionName(document.getClass().getSimpleName());
nGramDocument.setN(n);
nGramDocument.setNgrams(new ArrayList<>(nGrams));
newNGrams.add(nGramDocument);
}
} catch (Exception e) {
throw new RuntimeException("Failed to regenerate NGrams for field: " + field.getName(), e);
}
}
}
// 기존 데이터 삭제 후 새 데이터 저장
nGramRepository.deleteByCollectionName(repository.getClass().getSimpleName());
nGramRepository.saveAll(newNGrams);
}
/**
* 도큐먼트 객체에서 ID 값을 추출합니다.
*
* @param document 대상 도큐먼트 객체
* @return 도큐먼트의 ID 값 (문자열로 변환)
*/
private String getDocumentId(Object document) {
try {
Field idField = Stream.of(document.getClass().getDeclaredFields())
.filter(field -> field.isAnnotationPresent(org.springframework.data.annotation.Id.class))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No @Id field found in class " + document.getClass().getSimpleName()));
idField.setAccessible(true);
Object idValue = idField.get(document);
return idValue.toString();
} catch (Exception e) {
throw new RuntimeException("Failed to extract document ID using @Id annotation", e);
}
}
/**
* 리포지토리에서 관리하는 도큐먼트 클래스를 추론합니다.
*
* @param repository 대상 리포지토리
* @return 리포지토리가 관리하는 도큐먼트 클래스
*/
private Class<?> inferDocumentClass(MongoRepository<?, ?> repository) {
try {
// 리포지토리의 실제 도큐먼트 클래스를 추론
Class<?>[] generics = GenericTypeResolver.resolveTypeArguments(repository.getClass(), MongoRepository.class);
if (generics != null && generics.length > 0) {
return generics[0];
}
return null;
} catch (Exception e) {
log.error("Failed to infer document class for repository {}", repository.getClass().getSimpleName(), e);
return null;
}
}
}
`inferDocumentClass()`에서는 `GenericTypeResolver`가 사용되었습니다. 이는 `MongoRepository`가 스프링에서 프록시 객체로 관리되기 때문에, 일반적인 리플렉션 방식으로는 리포지토리의 제네릭 타입 정보를 확인할 수 없는 문제를 해결하기 위함입니다. `GenericTypeResolver`를 사용하여 런타임에 리포지토리가 관리하는 실제 도큐먼트 클래스를 정확히 추론할 수 있게 했습니다.
6-5. 빌드 시 자동 유효성 검증 및 N-gram 재생성 설정
검색 기능 준비 과정을 사용자가 직접 호출하지 않도록 하고, 빌드 시 자동으로 실행되게 하여 N-gram 관리를 간편화합니다. 이를 위해 `CommandLineRunner`를 구현하여 스프링 애플리케이션 빌드 후 자동으로 실행되도록 설정했습니다. 유효성 검증과 N-gram 생성 작업은 `NGramProcessor`에 위임하여 처리합니다.
@Component
@RequiredArgsConstructor
@Slf4j
public class NGramProcessorRunner implements CommandLineRunner {
private final NGramProcessor nGramProcessor;
/**
* 애플리케이션 시작 시 N-Gram 데이터의 유효성을 검증하고 필요 시 재생성합니다.
*
* @param args 명령행 인자
* @throws Exception 예외 발생 시
*/
@Override
public void run(String... args) throws Exception {
try {
nGramProcessor.validateAllNGrams();
log.info("NGram validation completed successfully");
} catch (Exception e) {
log.error("NGram validation failed", e);
}
}
}
6-6. 유사도 측정기 작성
검색 유사도 측정 알고리즘은 검색 조건에 따라 유동적으로 변경될 수 있습니다. 따라서 상황에 따라 적절한 알고리즘을 적용할 수 있도록 전략 패턴(Strategy Pattern)을 사용하여 구현합니다. 본 글에서는 자카드 유사도와 코사인 유사도를 다룹니다.
1. 전략 패턴 인터페이스 작성
유사도 측정을 위한 공통 인터페이스 `SimilarityStrategy`를 작성합니다. 이 인터페이스는 두 개의 N-gram 집합 간의 유사도를 계산하는 `calculate` 메서드를 정의합니다.
public interface SimilarityStrategy {
/**
* 두 N그램 집합 간의 유사도를 계산합니다.
*
* @param queryNGrams 질의(query)의 N그램 집합
* @param docNGrams 도큐먼트의 N그램 집합
* @return 계산된 유사도 값 (0과 1 사이의 실수)
*/
double calculate(Set<String> queryNGrams, Set<String> docNGrams);
}
2. 유사도 알고리즘 상수 정의
전략 패턴에서 사용할 알고리즘들을 쉽게 관리하기 위해 상수 클래스를 정의합니다. 열거형(enum)을 사용할 수도 있지만, 필드와 getter 없이 간단히 관리하기 위해 `final class`로 상수를 정의했습니다.
public final class SimilarityStrategies {
public static final String JACCARD = "jaccardSimilarity";
public static final String COSINE = "cosineSimilarity";
private SimilarityStrategies() {
// 인스턴스화 방지
}
}
3. 자카드 유사도 구현
자카드 유사도는 두 N-gram 집합의 교집합 크기를 합집합 크기로 나누어 계산됩니다. 두 집합의 요소 중 얼마나 많은 부분이 겹치는지를 평가합니다.
@Component(SimilarityStrategies.JACCARD)
public class JaccardSimilarity implements SimilarityStrategy {
@Override
public double calculate(Set<String> queryNGrams, Set<String> docNGrams) {
// 질의 또는 도큐먼트의 N그램 집합이 비어있는 경우 유사도는 0
if (queryNGrams.isEmpty() || docNGrams.isEmpty()) {
return 0.0;
}
// 교집합을 구함
Set<String> intersection = new HashSet<>(queryNGrams);
intersection.retainAll(docNGrams);
// 합집합을 구함
Set<String> union = new HashSet<>(queryNGrams);
union.addAll(docNGrams);
// 자카드 유사도 계산: 교집합 크기 / 합집합 크기
return (double) intersection.size() / union.size();
}
}
4. 코사인 유사도 구현
코사인 유사도는 두 집합의 N-gram을 이용해 벡터를 생성하고, 벡터 간 내적을 각 벡터 크기의 곱으로 나누어 계산합니다. 두 벡터 간의 각도를 이용해 유사성을 평가합니다.
@Component(SimilarityStrategies.COSINE)
public class CosineSimilarity implements SimilarityStrategy {
@Override
public double calculate(Set<String> queryNGrams, Set<String> docNGrams) {
// 모든 토큰을 합쳐 전체 벡터 공간을 정의
Set<String> allTokens = new HashSet<>();
allTokens.addAll(queryNGrams);
allTokens.addAll(docNGrams);
// 질의와 도큐먼트의 벡터를 생성
// 각 토큰이 존재하면 1, 존재하지 않으면 0으로 표시
Map<String, Integer> queryVector = allTokens.stream()
.collect(Collectors.toMap(
token -> token,
token -> queryNGrams.contains(token) ? 1 : 0
));
Map<String, Integer> docVector = allTokens.stream()
.collect(Collectors.toMap(
token -> token,
token -> docNGrams.contains(token) ? 1 : 0
));
// 두 벡터의 내적을 계산
int dotProduct = queryVector.keySet().stream()
.mapToInt(token -> queryVector.get(token) * docVector.get(token))
.sum();
// 벡터의 크기를 계산
double queryMagnitude = Math.sqrt(queryVector.values().stream()
.mapToInt(value -> value * value)
.sum());
double docMagnitude = Math.sqrt(docVector.values().stream()
.mapToInt(value -> value * value)
.sum());
// 벡터의 크기가 0인 경우 유사도는 0
if (queryMagnitude == 0 || docMagnitude == 0) {
return 0.0;
}
// 코사인 유사도 계산: 내적 / (질의 벡터 크기 * 도큐먼트 벡터 크기)
return dotProduct / (queryMagnitude * docMagnitude);
}
}
자카드 유사도와 코사인 유사도 비교
측정 기준 | 자카드 유사도 (Jaccard Similarity) | 코사인 유사도 (Cosine Similarity) |
계산 방식 | 두 집합 간의 교집합 크기를 합집합 크기로 나눈 비율 | 벡터 공간에서 두 벡터의 내적을 벡터 크기의 곱으로 나눔 |
사용 목적 | 주로 군집 분석이나 단순한 유사성 평가에 사용 | 벡터화된 표현에서 두 데이터 간의 유사도 평가 (문서, 검색 등) |
특징 | 두 데이터의 공통된 요소가 많은지를 측정 | 두 데이터 간의 방향의 유사성을 평가 |
장점 | 간단하고 계산이 빠름 | 데이터의 크기 차이를 무시하고, 방향에 따른 유사성을 잘 측정함 |
단점 | 0, 1로만 벡터를 표현하므로, 단어의 빈도수 같은 정보를 고려하지 않음 | 단어의 빈도수와 방향을 고려하지만 계산이 복잡하고 리소스를 더 많이 사용 |
5. NGram 유사도 측정 서비스 구현
검색어와 도큐먼트 간의 유사도를 측정하는 `NGramSimilarityService` 클래스를 작성합니다. 캐시를 이용해 성능을 최적화하고 전략 패턴을 이용한 유사도 계산을 제공합니다.
implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
implementation 'com.github.ben-manes.caffeine:guava:3.1.8'
implementation 'com.github.ben-manes.caffeine:jcache:3.1.8'
@Service
public class NGramSimilarityService {
// 캐시 설정을 위한 상수
private static final int MAXIMUM_QUERY_CACHE_SIZE = 1_000;
private static final int EXPIRE_AFTER_WRITE_QUERY_MINUTES = 10;
private static final int MAXIMUM_DOCUMENT_CACHE_SIZE = 10_000;
private static final int EXPIRE_AFTER_WRITE_DOCUMENT_MINUTES = 10;
private final NGramParser nGramParser;
private final SimilarityStrategy defaultStrategy;
private final Map<String, SimilarityStrategy> strategyMap;
// Caffeine Cache 설정
private final Cache<String, Set<String>> queryNGramCache;
private final Cache<String, Double> documentSimilarityCache;
public NGramSimilarityService(
NGramParser nGramParser,
Map<String, SimilarityStrategy> strategyMap
) {
this.nGramParser = nGramParser;
this.defaultStrategy = strategyMap.get(SimilarityStrategies.COSINE);
this.strategyMap = strategyMap;
this.queryNGramCache = Caffeine.newBuilder()
.maximumSize(MAXIMUM_QUERY_CACHE_SIZE)
.expireAfterWrite(EXPIRE_AFTER_WRITE_QUERY_MINUTES, TimeUnit.MINUTES)
.build();
this.documentSimilarityCache = Caffeine.newBuilder()
.maximumSize(MAXIMUM_DOCUMENT_CACHE_SIZE)
.expireAfterWrite(EXPIRE_AFTER_WRITE_DOCUMENT_MINUTES, TimeUnit.MINUTES)
.build();
}
/**
* 검색어의 N-Gram을 생성하고 캐싱합니다.
*
* @param query 검색어
* @param n N-Gram의 n 값
* @return 생성된 N-Gram 집합
*/
public Set<String> getQueryNGrams(String query, int n) {
String normalizedQuery = query.trim().toLowerCase();
String queryCacheKey = normalizedQuery + "_" + n;
return queryNGramCache.get(queryCacheKey, key -> nGramParser.generateNGrams(query, n));
}
/**
* 유사도 계산을 수행하고 캐싱합니다.
*
* @param docId 문서 ID
* @param field 필드명
* @param queryNGrams 검색어의 N-Gram 집합
* @param docNGrams 문서의 N-Gram 집합
* @param strategyName 사용할 전략 이름 (null일 경우 기본 전략 사용)
* @return 계산된 유사도 점수
*/
public double calculateSimilarity(String docId, String field, Set<String> queryNGrams, Set<String> docNGrams, String strategyName) {
SimilarityStrategy strategy = getStrategy(strategyName);
String queryNGramKey = String.join(",", queryNGrams);
String cacheKey = docId + "_" + field + "_" + strategy.getClass().getSimpleName() + "_" + queryNGramKey;
return documentSimilarityCache.get(cacheKey, key -> strategy.calculate(queryNGrams, docNGrams));
}
/**
* 사용자가 요청한 전략을 가져옵니다. 없으면 기본 전략을 반환합니다.
*
* @param strategyName 전략 이름
* @return 선택된 전략
*/
private SimilarityStrategy getStrategy(String strategyName) {
if (strategyName == null || !strategyMap.containsKey(strategyName)) {
return defaultStrategy;
}
return strategyMap.get(strategyName);
}
}
검색어 N-gram 캐싱 (`queryNGramCache`)
- 동일한 검색어에 대해 N-gram을 반복 생성하지 않도록 캐싱합니다. 캐시는 최대 1,000개의 검색어를 저장하며, 10분이 지나면 만료되어 자동으로 제거됩니다.
- 이를 통해 N-gram 생성에 드는 연산 비용을 줄이고, 전체 검색 성능을 향상시킵니다.
유사도 계산 캐싱 (`documentSimilarityCache`)
- 동일한 검색어와 도큐먼트 쌍에 대해 유사도 계산을 반복 수행하지 않도록 캐싱합니다. 최대 10,000개의 결과를 저장하며, 10분 후 자동 만료됩니다.
- 이전에 계산된 결과를 빠르게 반환하여, 전체적인 응답 지연 시간(Latency)을 크게 줄입니다.
6-7. N-gram 기반 검색기 작성
N-gram을 활용한 검색 기능을 제공하는 `NGramSearchService`를 작성합니다. 검색 대상 필드의 N-gram을 기반으로 일치하는 문서를 찾고, 유사도 계산기를 이용해 검색 결과를 유사도 순으로 정렬하여 반환합니다. 이를 통해 검색어와 가장 잘 맞는 문서를 우선적으로 제공합니다.
@Service
@RequiredArgsConstructor
@Slf4j
public class NGramSearchService {
private final NGramRepository nGramRepository;
private final ApplicationContext applicationContext;
private final NGramSimilarityService nGramSimilarityService;
/**
* 여러 필드에 대해 N-Gram 검색을 수행하고, 검색된 원본 도큐먼트를 유사도에 따라 정렬하여 반환합니다.
*
* @param documentClass 도큐먼트 클래스 타입
* @param fields 검색 대상 필드 리스트
* @param query 검색어
* @param <T> 도큐먼트 타입
* @return 검색된 원본 도큐먼트 리스트
*/
public <T> List<T> searchByNGram(Class<T> documentClass, List<String> fields, String query, String strategyName) {
Map<String, Double> documentSimilarityScores = new HashMap<>();
for (String field : fields) {
try {
Field targetField = documentClass.getDeclaredField(field);
if (!targetField.isAnnotationPresent(NGramField.class)) {
throw new IllegalArgumentException(String.format("Field '%s' is not annotated with @NGramField", field));
}
NGramField annotation = targetField.getAnnotation(NGramField.class);
int n = annotation.value();
// 검색어 N-Gram 생성 및 캐싱
Set<String> queryNGrams = nGramSimilarityService.getQueryNGrams(query, n);
// N-Gram 검색 결과 조회
List<NGramDocument> results = nGramRepository.findByCollectionNameAndFieldAndNgramsInAndN(
documentClass.getSimpleName(), field, new ArrayList<>(queryNGrams), n);
// 문서별 유사도 계산
calculateSimilarityScores(strategyName, field, results, queryNGrams, documentSimilarityScores);
} catch (Exception e) {
log.error("Failed to search by NGram for field '{}'", field, e);
throw new RuntimeException(String.format("Failed to search by NGram for field '%s'", field), e);
}
}
// 유사도에 따라 정렬하여 반환
return fetchAndSortDocuments(documentClass, documentSimilarityScores);
}
/**
* NGram 도큐먼트 리스트에 대해 유사도를 계산하고, 주어진 유사도 점수 맵에 값을 갱신합니다.
*
* @param strategyName 유사도 계산에 사용할 전략 이름
* @param field 처리 중인 필드 이름
* @param results 리포지토리에서 조회한 NGram 도큐먼트 리스트
* @param queryNGrams 검색어로부터 생성된 N-Gram 집합
* @param documentSimilarityScores 각 도큐먼트의 유사도 점수를 저장하거나 갱신할 맵
*/
private void calculateSimilarityScores(String strategyName, String field, List<NGramDocument> results, Set<String> queryNGrams, Map<String, Double> documentSimilarityScores) {
for (NGramDocument nGramDoc : results) {
String docId = nGramDoc.getDocumentId();
Set<String> docNGrams = new HashSet<>(nGramDoc.getNgrams());
// 유사도 계산 및 캐싱
double similarity = nGramSimilarityService.calculateSimilarity(docId, field, queryNGrams, docNGrams, strategyName);
documentSimilarityScores.merge(docId, similarity, Double::max);
}
}
/**
* 원본 도큐먼트를 조회하고, 유사도 점수에 따라 내림차순으로 정렬합니다.
*
* @param documentClass 조회할 도큐먼트 클래스 타입
* @param documentSimilarityScores 각 도큐먼트의 유사도 점수를 저장한 맵
* @param <T> 도큐먼트 클래스의 타입
* @return 유사도 점수에 따라 정렬된 원본 도큐먼트 리스트
*/
private <T> List<T> fetchAndSortDocuments(Class<T> documentClass, Map<String, Double> documentSimilarityScores) {
Set<String> documentIds = documentSimilarityScores.keySet();
List<T> documents = fetchOriginalDocuments(documentClass, documentIds);
documents.sort((d1, d2) -> compareDocuments(d1, d2, documentSimilarityScores));
return documents;
}
/**
* 두 도큐먼트를 비교하여 유사도 점수에 따라 정렬 순서를 반환합니다.
*
* @param d1 첫 번째 도큐먼트
* @param d2 두 번째 도큐먼트
* @param documentSimilarityScores 각 도큐먼트의 유사도 점수를 저장한 맵
* @param <T> 도큐먼트 클래스의 타입
* @return 유사도 점수에 따라 내림차순 정렬 (-1, 0, 1 중 하나)
*/
private <T> int compareDocuments(T d1, T d2, Map<String, Double> documentSimilarityScores) {
String id1 = getDocumentId(d1);
String id2 = getDocumentId(d2);
Double score1 = documentSimilarityScores.getOrDefault(id1, 0.0);
Double score2 = documentSimilarityScores.getOrDefault(id2, 0.0);
return score2.compareTo(score1); // 내림차순 정렬
}
/**
* 도큐먼트의 ID를 추출합니다.
*
* @param document 대상 도큐먼트 객체
* @return 도큐먼트의 ID 값 (문자열로 변환)
*/
private String getDocumentId(Object document) {
try {
Field idField = Stream.of(document.getClass().getDeclaredFields())
.filter(field -> field.isAnnotationPresent(org.springframework.data.annotation.Id.class))
.findFirst()
.orElseThrow(() -> new IllegalStateException("No @Id field found in class " + document.getClass().getSimpleName()));
idField.setAccessible(true);
Object idValue = idField.get(document);
return idValue.toString();
} catch (Exception e) {
throw new RuntimeException("Failed to get document ID", e);
}
}
/**
* 원본 도큐먼트를 검색합니다.
*
* @param documentClass 도큐먼트 클래스 타입
* @param documentIds 검색된 도큐먼트 ID 집합
* @param <T> 도큐먼트 타입
* @param <ID> 도큐먼트의 ID 타입
* @return 검색된 원본 도큐먼트 리스트
*/
private <T, ID> List<T> fetchOriginalDocuments(Class<T> documentClass, Set<String> documentIds) {
MongoRepository<T, ID> repository = getRepositoryForClass(documentClass);
Class<?> idType = inferIdClass(repository);
Set<ID> ids = documentIds.stream()
.map(idStr -> (ID) convertToIdType(idStr, idType))
.collect(Collectors.toSet());
return repository.findAllById(ids);
}
/**
* 리포지토리에서 관리하는 ID 타입을 추론합니다.
*
* @param repository 대상 리포지토리
* @return ID 타입 클래스
*/
private Class<?> inferIdClass(MongoRepository<?, ?> repository) {
Class<?>[] generics = GenericTypeResolver.resolveTypeArguments(repository.getClass(), MongoRepository.class);
if (generics != null && generics.length >= 2) {
return generics[1];
}
return null;
}
/**
* 문자열 ID를 해당 ID 타입으로 변환합니다.
*
* @param idStr 문자열로 표현된 ID
* @param idType ID 타입 클래스
* @return 변환된 ID 객체
*/
private Object convertToIdType(String idStr, Class<?> idType) {
if (idType.equals(UUID.class)) {
return UUID.fromString(idStr);
} else if (idType.equals(String.class)) {
return idStr;
} else {
throw new IllegalStateException("Unsupported ID type: " + idType.getName());
}
}
/**
* 도큐먼트 클래스에 해당하는 MongoRepository를 동적으로 검색합니다.
*
* @param documentClass 도큐먼트 클래스 타입
* @param <T> 도큐먼트 타입
* @param <ID> 도큐먼트의 ID 타입
* @return MongoRepository 인스턴스
*/
@SuppressWarnings("unchecked")
private <T, ID> MongoRepository<T, ID> getRepositoryForClass(Class<T> documentClass) {
Map<String, MongoRepository> repositories = applicationContext.getBeansOfType(MongoRepository.class);
for (MongoRepository<?, ?> repository : repositories.values()) {
Class<?> repositoryDomainType = inferDomainClass(repository);
if (repositoryDomainType != null && repositoryDomainType.equals(documentClass)) {
return (MongoRepository<T, ID>) repository;
}
}
throw new IllegalStateException("No MongoRepository found for class: " + documentClass.getSimpleName());
}
/**
* 리포지토리에서 관리하는 도큐먼트 클래스를 추론합니다.
*
* @param repository 대상 리포지토리
* @return 도큐먼트 클래스 타입
*/
private Class<?> inferDomainClass(MongoRepository<?, ?> repository) {
Class<?>[] generics = GenericTypeResolver.resolveTypeArguments(repository.getClass(), MongoRepository.class);
if (generics != null && generics.length >= 1) {
return generics[0];
}
return null;
}
}
7. 마치며
저는 주로 RDBMS 환경의 프로젝트에 참여해 왔기 때문에, NoSQL에 대한 경험이 부족했습니다. MySQL의 ngram Full-Text Parser 기능을 사용해 본 적은 있었지만, 이번처럼 깊이 있게 탐구해 본 적은 없었습니다. 이번 프로젝트를 통해 MongoDB와 같은 NoSQL 환경에서 작은 규모의 검색 엔진을 만들며, 새로운 방식으로 검색의 적합성을 높이는 작업을 경험할 수 있었습니다. 이는 저에게 신선한 도전이었고, 많은 배움을 얻을 수 있었습니다.
이 글이 저와 비슷한 문제를 겪고 있는 분들에게 조금이나마 도움이 되었기를 바랍니다. 긴 글 읽어주셔서 감사합니다.
'Spring' 카테고리의 다른 글
다중 플랫폼을 지원하는 웹훅 시스템 설계하기 (1) | 2025.01.06 |
---|---|
CDS와 AOT, Layered JAR로 Spring Boot 시작 시간, 메모리 최적화하기 (2) | 2024.12.23 |