[Elasticsearch 적용기] #1 Spring에서 Elasticsearch로 검색 성능 최적화하기
1. Elasticsearch가 LIKE 검색보다 나은 이유
기존 RDBMS에서는 문자열 검색을 위해 LIKE '%검색어%'를 자주 사용한다. 그러나 이 방식에는 다음과 같은 문제가 있다.
- 오타 허용 불가
- 사용자가 "강아지" 대신 "강이지"로 검색하면 매칭되지 않는다.
- 부분 검색 제한
- LIKE로 부분 문자열은 찾을 수 있지만, 접두어(Prefix)나 접미어, 중간 문자열 등 원하는 형태로 세분화하기 어렵다.
- 공간(Geo) 검색 미지원
- 위도·경도 기준으로 특정 지역만 검색하기가 쉽지 않다. 보통 BETWEEN을 써야 하지만 대규모 데이터에서 성능이 좋지 않다.
- 대규모 데이터 성능 저하
- LIKE '%검색어%'는 인덱스를 제대로 활용하기 어려워 데이터가 커질수록 매우 느려진다.
이와 달리 Elasticsearch는 역색인(Inverted Index)을 사용하고, 유사 검색(Fuzzy), Prefix 검색 같은 고급 텍스트 검색 기능을 제공한다. 또한 GeoBoundingBox로 공간 검색이 가능하며, 내부적으로 분산 처리되어 대규모 데이터에서 높은 성능을 낸다.
나는 대규모 데이터의 분실물 관련 프로젝트를 진행중이었기에, 업로드 되는 분실물 이름 중 오타가 분명이 있을 거라 생각했고, 오타까지 검색하기 위해선 elasticsearch가 필요하다고 생각하여 elasticsearch를 도입하게 되었다.
2. 최초 시도: Spring Data Elasticsearch Repository
처음에는 정말 간단하게, Spring Data Elasticsearch에서 제공하는 ElasticsearchRepository를 사용하려 했다.
public interface LostItemSearchRepository extends ElasticsearchRepository<LostItemDocument, Long> { }
ElasticsearchRepository도 jpa처럼 기본 CRUD 메서드(save, findById, deleteById 등)를 바로 사용할 수 있고, 간단한 쿼리는 메서드 이름만으로 작성할 수 있다. (ex. findByName)
그러나, 날짜, 위도·경도, 키워드, 카테고리, 지역 등 복합적인 필터가 필요했고, ElasticsearchRepository만으로는 동적 쿼리를 만들기 어려웠다. 또한, must, should, filter 등을 조합하는 고급 기능을 지원하지 않았다.
따라서 ElasticsearchRepository는 동적 쿼리와 복잡한 필터링에는 너무 제한적이라는 결론을 내리게 되었다.
3. Spring Data Elasticsearch의 Criteria API 활용 시도
다음 단계로, Spring Data Elasticsearch가 제공하는 Criteria API를 적용해보았다.
일반적으로 JPA에서는 동적 SQL을 위해 CriteriaBuilder, CriteriaQuery, Root 등을 사용하지만,
Elasticsearch에서도 Spring Data Elasticsearch의 Criteria 객체를 활용하면 동적 쿼리를 구성할 수 있었다.
public SearchHits<LostItemDocument> searchLostItems(
LocalDate dateStart, LocalDate dateEnd,
Double topLeftLat, Double topLeftLon,
Double bottomRightLat, Double bottomRightLon,
String keyword, Long categoryId, String region,
Integer size) {
Criteria criteria = new Criteria();
// ✅ 날짜 범위 필터
if (dateStart != null && dateEnd != null) {
criteria.and("date").between(dateStart, dateEnd);
}
// ✅ 키워드 검색 (부분 검색)
if (keyword != null && !keyword.isBlank()) {
criteria.and("name").contains(keyword); // LIKE 검색 효과
}
// ✅ 카테고리 필터
if (categoryId != null) {
criteria.and("categoryId").is(categoryId);
}
// ✅ 지역 필터
if (region != null && !region.isBlank()) {
criteria.and("region").is(region);
}
// ✅ 위치 기반 검색 (위도·경도 범위 체크)
if (topLeftLat != null && topLeftLon != null && bottomRightLat != null && bottomRightLon != null) {
criteria.and("location").between(bottomRightLat + "," + bottomRightLon, topLeftLat + "," + topLeftLon);
}
// ✅ 정렬 (날짜 최신순)
CriteriaQuery query = new CriteriaQuery(criteria);
query.addSort(Sort.by(Sort.Order.desc("date")));
if (size != null) {
query.setMaxResults(size);
}
return elasticsearchOperations.search(query, LostItemDocument.class);
}
🔴 3. 1 Spring Data Elasticsearch의 Criteria API 문제점
- GeoBoundingBox 검색 미지원
- criteria.and("location").between(...)을 사용했지만, Elasticsearch의 GeoBoundingBox Query를 완전히 대체할 수 없었다. (실제로 작동이 되지 않았다.)
- BETWEEN을 사용한 위경도 검색은 정확한 GeoBoundingBox 검색보다 유연성이 떨어졌다.
- Fuzzy(유사 검색) 미지원
- criteria.and("name").contains(keyword)로 부분 검색은 가능했지만, 철자 오류를 포함한 유사 검색(Fuzzy Query)이 불가능하다.
- 예를 들어 강아지와 강이지를 같은 단어로 인식하도록 만들 수 없었다.
- Query DSL과 비교하면 기능 제한
- BoolQuery 조합을 자유롭게 만들 수 없다.
- should, must, minimum_should_match 같은 조건을 활용하기 어렵다.
이 방식은 Elasticsearch의 강력한 검색 기능을 제대로 활용할 수 없다는 한계를 가지고 있었다.
결국, Elasticsearch의 Query DSL을 직접 사용해야 한다는 결론에 도달했다.
이 과정에서 Spring Data Elasticsearch의 Criteria API는 Elasticsearch의 모든 기능을 활용하기 어렵다는 사실을 깨달았고,
결국 "Native Query"가 필요하다는 결론을 내렸다.
4. Native Query만 쓰면 되지 않을까?
일반적인 RDBMS 환경이라면, 복잡한 동적 SQL을 쓰기 위해 Native Query를 직접 작성한다.
그러나 Elasticsearch는 SQL이 아니라 전용 Query DSL을 제공한다.
GeoBoundingBoxQuery, FuzzyQuery, PrefixQuery, BoolQuery 등등,
RDBMS와는 전혀 다른 형태의 쿼리 개념을 갖고 있다.
- 만약 RDBMS였다면: EntityManager.createNativeQuery("SELECT ... FROM ...") 식으로 끝낸다.
- Elasticsearch라면: “SQL”이 아니라 Elasticsearch Query DSL을 이해해야 한다.
그래서 Native Query만으로 Elasticsearch의 모든 기능 (강력한 검색 엔진 등)을 쓰기 어렵다.
5. co.elastic.clients:elasticsearch-java:8.15.5 의존성 추가
그래서 도입한 게 바로 Elasticsearch의 Java Client다.
implementation 'co.elastic.clients:elasticsearch-java:8.15.5'
이 라이브러리를 통해 Elasticsearch의 Query DSL을 자바 객체로 다룰 수 있고,
GeoBoundingBox, 유사 검색(Fuzzy), Prefix 검색 등 복잡한 기능을 직접 메서드 호출로 구성할 수 있다.
5.1. 버전 호환성
- spring-data-elasticsearch:5.4.2 환경이라면, co.elastic.clients:elasticsearch-java:8.15.5 버전을 쓰는 것이 호환된다.
- 버전이 다르면 빌드 에러나 런타임 문제가 생길 수 있으니 주의해야 한다.
- 해당 조합을 실제로 사용해본 결과, 충돌 없이 동작함을 확인했다.
6. 왜 ElasticsearchOperations를 썼나?
ElasticsearchOperations는 Spring Data Elasticsearch에서 제공하는 고수준 API로,
NativeQuery(= Query DSL)를 빌더 형태로 만들어 Elasticsearch에 전달할 수 있게 한다.
SearchHits<LostItemDocument> results = elasticsearchOperations.search(searchQuery, LostItemDocument.class);
이 한 줄이면, ES에 쿼리를 날리고, 응답을 받아, 자바 객체(LostItemDocument)로 매핑까지 해준다.
- 장점
- Query 생성은 co.elastic.clients.elasticsearch._types.query_dsl 내 DSL 객체로 진행
- search 결과는 SearchHits 형태로 깔끔하게 받을 수 있음
- NativeQuery.builder()를 통해 정렬, 페이징, BoolQuery 등을 유연하게 조합 가능
7. 최종 코드: 유사 검색, 부분 검색, 위치 검색 다 지원!
마지막으로, 다음 코드는 위 문제들을 해결하고,
날짜 범위 + 키워드 + 카테고리 + 지역 + GeoBoundingBox + 유사 검색 + 부분 검색 등
모든 요구사항을 반영한 예시다.
@Service
@RequiredArgsConstructor
public class LostItemSearchService {
private final ElasticsearchOperations elasticsearchOperations;
@Transactional(readOnly = true)
public SearchHits<LostItemDocument> searchLostItems(
LocalDate dateStart,
LocalDate dateEnd,
Double topLeftLat,
Double topLeftLon,
Double bottomRightLat,
Double bottomRightLon,
String keyword,
Long categoryId,
String region,
Integer size
) {
List<Query> mustQueries = new ArrayList<>();
List<Query> shouldQueries = new ArrayList<>();
// 날짜 범위 필터
RangeQuery dateRangeQuery = new RangeQuery.Builder()
.date(d -> d
.field("date")
.gte(dateStart.toString())
.lte(dateEnd.toString())
)
.build();
mustQueries.add(dateRangeQuery._toQuery());
// 키워드 검색(유사 검색 + 부분 검색)
boolean hasKeyword = (keyword != null && !keyword.isBlank());
if (hasKeyword) {
FuzzyQuery fuzzyQuery = FuzzyQuery.of(f -> f
.field("name")
.value(keyword)
.fuzziness("AUTO") // 오타 허용
);
shouldQueries.add(fuzzyQuery._toQuery());
PrefixQuery prefixQuery = PrefixQuery.of(p -> p
.field("name")
.value(keyword)
);
shouldQueries.add(prefixQuery._toQuery());
}
// 카테고리
if (categoryId != null) {
TermQuery termQuery = TermQuery.of(t -> t
.field("categoryId")
.value(categoryId)
);
mustQueries.add(termQuery._toQuery());
}
// 지역
if (region != null && !region.isBlank()) {
MatchQuery regionQuery = MatchQuery.of(m -> m
.field("region")
.query(region)
);
mustQueries.add(regionQuery._toQuery());
}
// 위치 필터(GeoBoundingBox)
if (topLeftLat != null && topLeftLon != null && bottomRightLat != null && bottomRightLon != null) {
GeoBoundingBoxQuery geoQuery = GeoBoundingBoxQuery.of(g -> g
.field("location")
.boundingBox(b -> b
.topLeft(t -> t.lat(topLeftLat).lon(topLeftLon))
.bottomRight(br -> br.lat(bottomRightLat).lon(bottomRightLon))
)
);
mustQueries.add(geoQuery._toQuery());
}
// BoolQuery 조합
BoolQuery.Builder boolQueryBuilder = new BoolQuery.Builder().must(mustQueries);
if (hasKeyword) {
boolQueryBuilder.should(shouldQueries);
boolQueryBuilder.minimumShouldMatch("1"); // should 중 최소 1개 만족
}
Query boolQuery = boolQueryBuilder.build()._toQuery();
// 정렬: 최근 날짜 > id 역순
List<SortOptions> sortOptions = List.of(
SortOptions.of(s -> s.field(f -> f.field("date").order(SortOrder.Desc))),
SortOptions.of(s -> s.field(f -> f.field("id").order(SortOrder.Desc)))
);
// NativeQuery 생성 (정렬 + 페이징)
NativeQuery searchQuery = NativeQuery.builder()
.withQuery(boolQuery)
.withSort(sortOptions)
.withPageable(size != null ? PageRequest.of(0, size) : Pageable.unpaged())
.build();
// 검색 실행
return elasticsearchOperations.search(searchQuery, LostItemDocument.class);
}
}
유사 검색: 예를 들어, 강아지모차를 검색하면 강아지모자도 검색될 수 있도록 설정
부분 검색: 강아지, 강아지모 등 일부 단어만 입력해도 검색 가능
한 것을 확인할 수 있다!!
7.1 nori Analyzer나 n-gram은 왜 안 썼나?
- nori Analyzer: 한국어 형태소 분석 전용
- n-gram: 부분 텍스트 검색을 세분화하여 빠르게 찾는 방식
이번 코드는 간단하게 Fuzzy + Prefix로만 검색을 시도했고, 실제 환경에서 만족할 만한 결과를 얻었다.
만약 검색 품질을 높이거나 더욱 세밀한 부분 검색이 필요하다면,
nori Analyzer나 n-gram 설정을 Elasticsearch 인덱스 매핑에 적용해보는 것도 좋을 것 같다.
7. 결론
1. ElasticsearchRepository
- 매우 간단한 CRUD는 가능하지만, 복잡한 동적 쿼리에는 한계가 있었다.
- 기본적인 findById, save 같은 CRUD 연산은 가능하지만, 다양한 필터 조합을 처리할 수 없었다.
2. Spring Data Elasticsearch의 Criteria API
- JPA의 Criteria API와 유사한 방식으로 동적 쿼리를 작성할 수 있었지만, Elasticsearch DSL을 제대로 활용하지 못했다.
- Criteria 객체를 통해 기본적인 검색은 가능했지만, 다음과 같은 제약이 있었다.
- GeoBoundingBox: 위치 기반 검색을 정밀하게 수행할 수 없었음.
- Fuzzy Search(유사 검색): 부분 검색은 가능했지만, 철자 오류까지 포함하는 검색이 불가능.
- 복잡한 BoolQuery 조합 불가: should, must, minimum_should_match 같은 조건 활용이 어려움.
- 결국, Elasticsearch의 고급 검색 기능을 활용하기에는 부족했다.
3. Native Query만으로 Query DSL을 완벽히 활용할 수 없는 이유
- RDBMS에서는 복잡한 SQL을 직접 작성하는 방식으로 해결하지만, Elasticsearch는 SQL이 아닌 Query DSL을 사용한다.
- Native Query를 사용하면 일부 기능은 구현할 수 있지만, Elasticsearch의 Query DSL 기능을 온전히 활용하기 어렵다.
- GeoBoundingBoxQuery, FuzzyQuery, PrefixQuery, BoolQuery 같은 Elasticsearch 고유 기능을 활용하려면 Query DSL을 직접 다룰 필요가 있었다.
4. co.elastic.clients:elasticsearch-java:8.15.5 + ElasticsearchOperations 도입
- Elasticsearch Query DSL을 객체지향적으로 작성할 수 있어 유지보수가 쉬워진다.
- 유사 검색(Fuzzy), 부분 검색(Prefix), GeoBoundingBox, TermQuery, MatchQuery 등 다양한 쿼리를 조합 가능하다.
- Query DSL을 그대로 사용할 수 있어, Elasticsearch의 모든 기능을 효과적으로 활용할 수 있다.
결과적으로, Elasticsearch의 강점을 100% 활용할 수 있게 되었다.
- 유연한 필터링부터 위치 기반 검색(Geo), 유사 검색(Fuzzy), 부분 검색(Prefix)까지 가능.
- 필요하다면 nori Analyzer나 n-gram 설정을 추가하여 검색 품질을 더욱 높일 수도 있음.
이 과정을 통해 최종적으로 Spring Data Elasticsearch의 ElasticsearchOperations를 사용하여 Query DSL을 빌더 형태로 작성하고, 고급 검색 기능을 완벽하게 구현했다.
이로써 성능과 기능을 모두 만족하는 최적의 검색 시스템을 구축할 수 있었다!