Elasticsearch 마스터 노트 시리즈 8편. Spring Data Elasticsearch 의존성·자동 구성, @Document·@Field로 매핑 정의, ElasticsearchRepository로 자동 CRUD, ElasticsearchOperations로 세밀 제어, Java API Client (8.x)·Reactive 클라이언트, 통합 테스트 패턴까지.
이 글은 Elasticsearch 마스터 노트 시리즈의 여덟 번째 편입니다. 1~7편이 ES 자체였다면, 이번엔 Spring Boot 통합 — Spring Data Elasticsearch.
@Document + Repository = 자동 CRUD. 복잡 쿼리는 ElasticsearchOperations. Java Spring 환경에 자연스러운 통합.
처음 Spring 통합이 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, 클라이언트 종류가 헷갈립니다 — RestHighLevelClient (deprecated)·Java API Client (8.x)·Spring Data. 둘째, Repository vs Operations 어느 쪽인지 막연합니다.
해결법은 한 가지예요. "단순 CRUD = Repository / 복잡 = Operations" + "Java API Client (8.x)가 표준" 두 줄.
의존성
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
}
자동 포함:
co.elastic.clients:elasticsearch-java(8.x Java API Client)spring-data-elasticsearch
자동 구성
spring:
elasticsearch:
uris: http://localhost:9200
username: elastic
password: ${ES_PASSWORD:}
socket-timeout: 10s
connection-timeout: 5s
자동 Bean:
ElasticsearchClient(동기)ReactiveElasticsearchClient(비동기)ElasticsearchOperationsReactiveElasticsearchOperationsElasticsearchTemplate
@Document — 엔티티 매핑
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
@Document(indexName = "products")
public class Product {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "nori")
private String name;
@Field(type = FieldType.Keyword)
private String category;
@Field(type = FieldType.Integer)
private Integer price;
@Field(type = FieldType.Date, format = DateFormat.date_hour_minute_second)
private Instant createdAt;
@MultiField(
mainField = @Field(type = FieldType.Text),
otherFields = {
@InnerField(suffix = "keyword", type = FieldType.Keyword),
@InnerField(suffix = "ngram", type = FieldType.Text, analyzer = "edge_ngram")
}
)
private String title;
}
여기서 정말 중요한 시험 함정 — @Document + Spring 어노테이션으로 매핑 자동. 별도 PUT mapping 호출 X. 첫 저장 시 자동 생성. 명시 매핑 = IndexOperations.create().
ElasticsearchRepository — 자동 CRUD
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
public interface ProductRepository extends ElasticsearchRepository<Product, String> {
// 메서드 명명 규칙
List<Product> findByName(String name);
List<Product> findByCategory(String category);
List<Product> findByPriceBetween(int min, int max);
// 정렬
List<Product> findByCategory(String category, Sort sort);
// 페이징
Page<Product> findByCategory(String category, Pageable pageable);
}
@Service
public class ProductService {
@Autowired
private ProductRepository repo;
public Product save(Product p) {
return repo.save(p);
}
public List<Product> findInCategory(String category) {
return repo.findByCategory(category);
}
}
JPA Repository 비슷.
@Query — 직접 쿼리
public interface ProductRepository extends ElasticsearchRepository<Product, String> {
@Query("{\"match\": {\"name\": \"?0\"}}")
List<Product> searchByName(String name);
@Query("""
{
"bool": {
"must": [
{"match": {"name": "?0"}}
],
"filter": [
{"range": {"price": {"gte": ?1, "lte": ?2}}}
]
}
}
""")
List<Product> searchWithPriceRange(String name, int min, int max);
}
JSON Query DSL 직접. SQL 같은 자유.
ElasticsearchOperations — 세밀 제어
@Service
public class ProductSearchService {
@Autowired
private ElasticsearchOperations operations;
public List<Product> search(String query) {
Criteria criteria = new Criteria("name").matches(query);
Query searchQuery = new CriteriaQuery(criteria);
SearchHits<Product> hits = operations.search(searchQuery, Product.class);
return hits.stream()
.map(SearchHit::getContent)
.toList();
}
}
또는 NativeQuery (Java API Client 직접):
public SearchHits<Product> nativeSearch(String query) {
NativeQuery searchQuery = NativeQuery.builder()
.withQuery(q -> q
.bool(b -> b
.must(m -> m.match(t -> t.field("name").query(query)))
.filter(f -> f.range(r -> r.field("price").gte(JsonData.of(10000))))
)
)
.withPageable(PageRequest.of(0, 10))
.withSort(Sort.by("price"))
.build();
return operations.search(searchQuery, Product.class);
}
여기서 시험 함정이 하나 있어요. NativeQuery = Java API Client 빌더 그대로. 가장 강력·세밀 제어. 복잡 쿼리·집계 = NativeQuery.
Aggregations 통합
public Map<String, Long> countByCategory() {
NativeQuery query = NativeQuery.builder()
.withAggregation("by_category", Aggregation.of(a -> a
.terms(t -> t.field("category"))
))
.build();
SearchHits<Product> hits = operations.search(query, Product.class);
Aggregations aggregations = hits.getAggregations();
StringTermsAggregate buckets = aggregations.aggregations().get("by_category")
.aggregation().sterms();
return buckets.buckets().array().stream()
.collect(Collectors.toMap(
b -> b.key().stringValue(),
StringTermsBucket::docCount
));
}
집계 결과 추출.
Reactive 클라이언트
@Autowired
private ReactiveElasticsearchOperations reactiveOps;
public Flux<Product> searchReactive(String query) {
Criteria criteria = new Criteria("name").matches(query);
Query searchQuery = new CriteriaQuery(criteria);
return reactiveOps.search(searchQuery, Product.class)
.map(SearchHit::getContent);
}
WebFlux 환경 친화. Mono·Flux 반환.
Reactive Repository
public interface ProductReactiveRepository extends ReactiveElasticsearchRepository<Product, String> {
Flux<Product> findByCategory(String category);
}
ElasticsearchClient — 저수준 직접
@Autowired
private ElasticsearchClient client;
public void rawSearch() throws IOException {
SearchResponse<Product> response = client.search(s -> s
.index("products")
.query(q -> q
.match(t -> t.field("name").query("spring"))
),
Product.class
);
response.hits().hits().forEach(hit -> {
Product p = hit.source();
// ...
});
}
Spring Data 거치지 않고 직접. 최대 유연성.
IndexOperations — 인덱스 관리
@Autowired
private ElasticsearchOperations operations;
public void createIndex() {
IndexOperations indexOps = operations.indexOps(Product.class);
indexOps.create();
indexOps.putMapping(indexOps.createMapping(Product.class));
}
public void deleteIndex() {
operations.indexOps(Product.class).delete();
}
인덱스 생성·삭제·매핑.
Spring Boot 자동 구성
spring:
elasticsearch:
uris: http://localhost:9200
username: ${ES_USER:elastic}
password: ${ES_PASS:}
connection-timeout: 5s
socket-timeout: 30s
data:
elasticsearch:
repositories:
enabled: true
자동:
- ElasticsearchClient·ReactiveElasticsearchClient
- 모든 Operations
- Repository 스캔
통합 테스트 — Testcontainers
@Testcontainers
@SpringBootTest
class ProductSearchTest {
@Container
static ElasticsearchContainer es = new ElasticsearchContainer(
DockerImageName.parse("elasticsearch:8.x")
);
@DynamicPropertySource
static void esProps(DynamicPropertyRegistry registry) {
registry.add("spring.elasticsearch.uris", es::getHttpHostAddress);
}
@Autowired
private ProductRepository repo;
@BeforeEach
void setup() {
repo.deleteAll();
}
@Test
void search() {
repo.save(new Product("Spring Boot Book", 30000));
List<Product> results = repo.findByName("Spring");
assertThat(results).hasSize(1);
}
}
여기서 시험 함정이 하나 있어요. Embedded Elasticsearch 미지원 (vs Embedded Kafka). Testcontainers 또는 별도 클러스터 사용.
Bulk 작업
public void bulkSave(List<Product> products) {
operations.save(products); // 자동 Bulk
}
public void bulkIndex(List<Product> products) {
List<IndexQuery> queries = products.stream()
.map(p -> new IndexQueryBuilder()
.withId(p.getId())
.withObject(p)
.build())
.toList();
operations.bulkIndex(queries, IndexCoordinates.of("products"));
}
refresh 정책
operations.save(products, RefreshPolicy.IMMEDIATE); # 즉시
operations.save(products, RefreshPolicy.WAIT_UNTIL); # 기다림
operations.save(products, RefreshPolicy.NONE); # 기본
여기서 정말 중요한 시험 함정 — 운영 = NONE. 테스트만 IMMEDIATE.
Repository 메서드 명명 규칙
// 단순 매칭
List<Product> findByName(String name);
List<Product> findByCategoryAndPriceGreaterThan(String c, int p);
// Like
List<Product> findByNameLike(String pattern);
List<Product> findByNameContaining(String keyword);
// 범위
List<Product> findByPriceBetween(int min, int max);
List<Product> findByCreatedAtAfter(Instant date);
// In
List<Product> findByCategoryIn(Collection<String> categories);
// 정렬·제한
List<Product> findTop10ByOrderByPriceDesc();
여기서 시험 함정이 하나 있어요. JPA와 비슷하지만 일부 차이. ES 특성 (text·analyzer 등) 고려 필요.
Service Layer 패턴
@Service
@Transactional # ES는 트랜잭션 X — 그래도 명시 가능 (의미는 X)
public class ProductService {
@Autowired
private ProductRepository esRepo;
@Autowired
private ProductJpaRepository dbRepo;
public Product save(Product p) {
Product saved = dbRepo.save(p); # PostgreSQL (운영 데이터)
esRepo.save(saved); # ES (검색)
return saved;
}
public List<Product> search(String query) {
return esRepo.findByName(query);
}
public Product getById(String id) {
return dbRepo.findById(id).orElseThrow(); # 단일 조회는 DB
}
}
운영 = DB + ES 함께 (3편 시리즈 1편 권장 패턴).
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 8편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- 의존성 —
spring-boot-starter-data-elasticsearch - 자동 포함 — Java API Client (8.x) + Spring Data
- yaml —
spring.elasticsearch.uris·username·password - 자동 Bean — Client·Operations·Template (Reactive 포함)
@Document(indexName = "...")= 인덱스 매핑@Field+@MultiField+@InnerField- 매핑 자동 생성 (또는
IndexOperations.create) ElasticsearchRepository= 자동 CRUD (JPA 비슷)- 메서드 명명 규칙 —
findByName·findByPriceBetween등 @Query= JSON Query DSL 직접ElasticsearchOperations= 세밀 제어 (Criteria·NativeQuery)- NativeQuery = Java API Client 빌더 (가장 강력)
- 복잡 쿼리·집계 = NativeQuery
- Reactive —
ReactiveElasticsearchOperations/ReactiveElasticsearchRepository(WebFlux 친화) ElasticsearchClient= 저수준 직접IndexOperations= 인덱스 관리 (create·delete·매핑)- 통합 테스트 — Testcontainers (Embedded ES 미지원)
- Bulk —
operations.save(List)자동 /IndexQuery명시 - RefreshPolicy — IMMEDIATE (테스트) / NONE (운영)
- 운영 패턴 — DB(원본) + ES(검색) 함께
- save 시 둘 다·조회는 검색 vs 단일 분리
시리즈 다른 편
- 1편 — 기본 개념·Cluster·Shard
- 2편 — Mapping·데이터 타입
- 3편 — Analyzer·Tokenizer·한국어
- 4편 — Query DSL
- 5편 — Full-text Search·Relevance
- 6편 — Aggregations
- 7편 — Bulk API·Reindex
- 8편 — Spring Data Elasticsearch (현재 글)
- 9편 — 검색 엔진 프로젝트 설계
- 10편 — Security·인증·인가·TLS
공식 문서: Spring Data Elasticsearch / Elasticsearch Java API Client 에서 더 깊이.
다음 글(9편)에서는 검색 엔진 프로젝트 — 실전 검색 시스템 설계, Index 분리·Alias 패턴, 동기화·롤백 전략까지 풀어 갑니다.