Elasticsearch 마스터 — Spring Data Elasticsearch

2026-05-03확률과 통계 마스터 노트

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 (비동기)
  • ElasticsearchOperations
  • ReactiveElasticsearchOperations
  • ElasticsearchTemplate

@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
  • ReactiveReactiveElasticsearchOperations / ReactiveElasticsearchRepository (WebFlux 친화)
  • ElasticsearchClient = 저수준 직접
  • IndexOperations = 인덱스 관리 (create·delete·매핑)
  • 통합 테스트 — Testcontainers (Embedded ES 미지원)
  • Bulk — operations.save(List) 자동 / IndexQuery 명시
  • RefreshPolicy — IMMEDIATE (테스트) / NONE (운영)
  • 운영 패턴 — DB(원본) + ES(검색) 함께
  • save 시 둘 다·조회는 검색 vs 단일 분리

시리즈 다른 편

공식 문서: Spring Data Elasticsearch / Elasticsearch Java API Client 에서 더 깊이.

다음 글(9편)에서는 검색 엔진 프로젝트 — 실전 검색 시스템 설계, Index 분리·Alias 패턴, 동기화·롤백 전략까지 풀어 갑니다.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!