ES를 콘솔에서 curl로 다룰 때는 잘 되다가, Spring Boot에 붙이는 순간 문제가 쏟아진다. 매핑이 의도와 다르게 잡히고, 대량 색인이 느리고, 검색 결과가 안 나오고, RDB의 데이터와 ES가 어긋난다.
대부분 ES의 문제가 아니라 Spring/JPA와 ES를 연결하는 방식의 문제다. ES는 RDB가 아니고 트랜잭션도 없다. 이 차이를 이해하고 다뤄야 한다.
@Document 매핑 — 자동 생성을 믿으면 안 되는 이유
Spring Data Elasticsearch는 엔티티에 @Document를 붙여 인덱스에 매핑한다.
@Document(indexName = "products")
@Setting(settingPath = "es/product-settings.json") // analyzer 정의
public class ProductDocument {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "ko_index", searchAnalyzer = "ko_search")
private String name;
@MultiField( // 하나의 필드를 text + keyword 둘 다로
mainField = @Field(type = FieldType.Text, analyzer = "ko_index"),
otherFields = @InnerField(suffix = "raw", type = FieldType.Keyword)
)
private String brand;
@Field(type = FieldType.Long)
private Long price;
@Field(type = FieldType.Date, format = DateFormat.date_time)
private Instant createdAt;
}
함정: spring.data.elasticsearch의 자동 인덱스 생성에만 의존하면, analyzer 같은 세밀한 설정이 누락되거나 운영 인덱스를 앱이 멋대로 건드린다. 운영에서는 인덱스/매핑을 명시적으로 관리(마이그레이션 스크립트, alias)하고 앱은 기존 인덱스에 읽기/쓰기만 하는 게 안전하다. name을 정렬·집계하려면 keyword 멀티필드가 있어야 한다는 점도 매핑 단계에서 잡아야 한다.
검색 — Repository로 부족할 때 NativeQuery
간단한 조회는 ElasticsearchRepository로 되지만, 실무 검색(형태소 + 동의어 + 비즈니스 부스팅 + 페이징)은 쿼리를 직접 조립해야 한다. Spring Boot 3.x / Spring Data Elasticsearch 5.x는 새 Java API Client 기반의 NativeQuery를 쓴다.
@RequiredArgsConstructor
@Service
public class ProductSearchService {
private final ElasticsearchOperations operations;
public SearchHits<ProductDocument> search(String keyword, int size) {
NativeQuery query = NativeQuery.builder()
.withQuery(q -> q
.functionScore(fs -> fs
.query(inner -> inner
.match(m -> m.field("name").query(keyword)))
// 텍스트 점수 × 판매량 부스팅 (비즈니스 시그널)
.functions(fn -> fn
.fieldValueFactor(f -> f
.field("salesCount").modifier(FieldValueFactorModifier.Log1p)))))
.withPageable(PageRequest.of(0, size))
.build();
return operations.search(query, ProductDocument.class);
}
}
순수 텍스트 매칭만 하면 재고 없는 듣보 상품이 1등이 된다. function_score로 판매량·재고·광고 같은 비즈니스 시그널을 곱해야 한다는 게 핵심이다.
깊은 페이징 — from/size 대신 search_after
무한 스크롤에서 PageRequest의 from/size는 깊어질수록 느려진다(각 샤드가 from+size를 다 모아 정렬). 커서 방식인 search_after를 쓴다.
NativeQuery query = NativeQuery.builder()
.withQuery(q -> q.match(m -> m.field("name").query(keyword)))
.withSort(s -> s.field(f -> f.field("_score").order(SortOrder.Desc)))
.withSort(s -> s.field(f -> f.field("id").order(SortOrder.Asc))) // tie-breaker
.withSearchAfter(lastSortValues) // 직전 페이지 마지막 정렬값
.withMaxResults(size)
.build();
정렬에 고유값(tie-breaker, 여기선 id)을 반드시 포함해야 커서가 안정적으로 동작한다.
대량 색인 — save() 반복은 왜 느린가
상품 100만 건을 repository.save()로 한 건씩 넣으면 매번 HTTP 요청이 나가 끔찍하게 느리다. Bulk API로 묶어야 한다.
public void bulkIndex(List<ProductDocument> products) {
int batchSize = 2000;
for (int i = 0; i < products.size(); i += batchSize) {
List<ProductDocument> batch =
products.subList(i, Math.min(i + batchSize, products.size()));
List<IndexQuery> queries = batch.stream()
.map(p -> new IndexQueryBuilder().withId(p.getId()).withObject(p).build())
.toList();
operations.bulkIndex(queries, ProductDocument.class);
}
}
배치 크기는 보통 1,0005,000건 또는 515MB 사이에서 측정해 정한다. 너무 크면 한 요청이 무거워지고, 너무 작으면 왕복이 늘어난다.
대량 색인 전후로 refresh를 제어하면 더 빨라진다.
// 색인 직전: refresh 끄기
operations.indexOps(ProductDocument.class)
.putSettings(Settings.of("index.refresh_interval", "-1"));
// ... bulk 색인 ...
// 끝나고 원복 + 강제 refresh
operations.indexOps(ProductDocument.class)
.putSettings(Settings.of("index.refresh_interval", "1s"));
operations.indexOps(ProductDocument.class).refresh();
가장 큰 함정 — JPA와 ES를 같은 트랜잭션에 묶지 마라
흔한 실수다. 상품을 저장하면서 RDB와 ES에 동시에 쓰는 코드.
@Transactional
public void createProduct(Product product) {
productJpaRepository.save(product); // RDB
productSearchRepository.save(toDoc(product)); // ES ← 위험
}
문제: ES는 @Transactional의 롤백 대상이 아니다. RDB 커밋이 실패해 롤백돼도 ES에는 이미 써졌다. 반대로 ES 쓰기가 실패하면 RDB까지 같이 롤백돼 정상 주문이 막힌다. 검색 색인 실패가 비즈니스 트랜잭션을 죽이는 건 과한 결합이다.
개선 1 — 커밋 이후에 색인 (애플리케이션 레벨). DB 커밋이 끝난 뒤에만 색인하도록 이벤트를 분리한다.
@Transactional
public void createProduct(Product product) {
productJpaRepository.save(product);
eventPublisher.publishEvent(new ProductCreatedEvent(product.getId()));
}
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCreated(ProductCreatedEvent e) {
Product p = productJpaRepository.findById(e.id()).orElseThrow();
productSearchRepository.save(toDoc(p)); // 커밋 확정 후에만 색인
}
이러면 DB는 안전하지만, 색인 단계가 실패하면 유실될 수 있어 재시도와 실패 로그가 필요하다.
개선 2 — CDC로 완전 분리 (권장). 가장 견고한 방법은 앱이 ES를 직접 안 건드리는 것이다. RDB만 단일 진실 소스로 쓰고, Debezium CDC가 binlog를 읽어 Kafka로 흘리면 별도 색인 컨슈머가 ES에 반영한다. 애플리케이션은 ES의 존재를 몰라도 되고, 색인 지연/실패가 비즈니스 로직과 완전히 분리된다.
→ 이 파이프라인의 상세는 Kafka Connect — CDC 내부 동작과 Outbox 패턴 참고.
Java 클라이언트 레벨 튜닝
연결 설정도 성능에 직결된다.
@Configuration
public class EsClientConfig extends ElasticsearchConfiguration {
@Override
public ClientConfiguration clientConfiguration() {
return ClientConfiguration.builder()
.connectedTo("es-node1:9200", "es-node2:9200") // 여러 노드 → 부하 분산
.withConnectTimeout(Duration.ofSeconds(3))
.withSocketTimeout(Duration.ofSeconds(30)) // 무거운 집계 쿼리 여유
.build();
}
}
- 여러 노드 등록: 클라이언트가 라운드로빈으로 분산하고 한 노드 장애 시 우회.
- 타임아웃 분리: connect는 짧게(빠른 실패), socket은 쿼리 성격에 맞게.
- 읽을 필드만 가져오기:
_source필터링으로 불필요한 큰 필드 제외 → 네트워크·역직렬화 절감. - count 쿼리 분리: 전체 건수가 필요 없으면
track_total_hits를 끄거나 제한해 비용 절감.
운영 정리
- 매핑은 명시적으로: 자동 생성 의존 금지. analyzer는
@Setting파일로, 운영 인덱스는 마이그레이션 + alias로 관리. - 대량 색인은 bulk + refresh 제어: save 반복 금지. 배치 1~5천 건, 색인 중 refresh off.
- JPA와 ES를 한 트랜잭션에 묶지 마라: AFTER_COMMIT 이벤트 또는 CDC로 분리. ES는 트랜잭션이 없다.
- 깊은 페이징은 search_after: tie-breaker 정렬 필수.
- 검색은 function_score: 텍스트 점수만 쓰지 말고 비즈니스 시그널 결합.
- 클라이언트 튜닝: 멀티 노드, 타임아웃 분리, _source 필터링.
운영상 주의사항
Spring Data Elasticsearch는 버전 의존성이 까다롭다. Spring Boot 3.x → Spring Data Elasticsearch 5.x → Elasticsearch 8.x Java API Client 조합이 맞아야 하고, 구버전의 RestHighLevelClient 코드는 새 클라이언트로 마이그레이션해야 한다. 또 ES는 RDB가 아니므로 join·트랜잭션·강한 일관성을 기대하면 안 된다. 원본은 RDB에 두고 ES는 검색용 사본으로 취급하는 역할 분리를 코드 구조에 반영하는 것이 가장 중요하다.