개발일지/SPRINGBOOT

Spring Boot 3.x + Elasticsearch 8.x 연동기 — 레거시 코드에서 타입세이프 클라이언트

recording or reCoding 2026. 4. 23. 14:06

배경 
: 기존 프로젝트는 Elasticsearch에 데이터를 조회할 때 RestTemplate으로 REST API를 직접 호출하는 방식을 사용하고 있었다.

  기존 방식 - JSON을 Map으로 직접 조립

 Map<String, Object> body = Map.of(
      "query", Map.of(
          "bool", Map.of(
              "filter", List.of(
                  Map.of("term", Map.of("user_id", userId))
              )
          )
      )
  );
  ResponseEntity<Map> response = restTemplate.exchange(url, HttpMethod.POST, new HttpEntity<>(body, headers), Map.class);


쿼리가 복잡해질수록 Map을 중첩해서 조립하는 코드가 길어지고, 오타나 필드명 실수를 컴파일 타임에 잡을 수 없다는 단점이 있었다. elasticsearch-java 공식 클라이언트를 도입해서 빌더 기반의 타입세이프한 쿼리로 전환하기로 했다.

  의존성 추가

  implementation 'co.elastic.clients:elasticsearch-java:8.13.0'
  implementation 'cohttp://m.fasterxml.jackson.core:jackson-databind:2.17.0'


elasticsearch-java 는 내부적으로 elasticsearch-rest-client 를 사용하고, JSON 직렬화는 Jackson을 통해 처리한다.

이슈 1 — SSL 설정: httpclient4 vs httpclient5 혼용 문제

문제 - ElasticsearchConfig 를 처음 작성할 때 httpclient5 패키지로 SSL을 설정했다.

  import org.apache.hc.client5.http.auth.AuthScope;           // httpclient5
  import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; // httpclient5
  import org.apache.hc.core5.http.HttpHost;                   // httpclient5

  RestClient restClient = RestClient.builder(new HttpHost(scheme, ip, port)) // 에러
      .setHttpClientConfigCallback(httpClientBuilder ->
          httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider) // 타입 불일치
      ).build();


RestClient.builder() 에서 타입 오류가 발생했다.

  원인
elasticsearch-java 8.x 의 RestClient 는 공개 API는 httpclient4 기반이고, 내부 구현만 httpclient5를 사용한다.
즉, RestClient.builder() 에 넘기는 HttpHost 와 callback 내부의 CredentialsProvider 는 모두 httpclient4 패키지여야 한다.

  해결

  import org.apache.http.HttpHost;                           // httpclient4
  import org.apache.http.auth.AuthScope;                     // httpclient4
  import org.apache.http.auth.UsernamePasswordCredentials;   // httpclient4
  import org.apache.http.conn.ssl.NoopHostnameVerifier;      // httpclient4
  import org.apache.http.impl.client.BasicCredentialsProvider; // httpclient4
  import org.apache.http.ssl.SSLContextBuilder;              // httpclient4

  @Bean
  public ElasticsearchClient elasticsearchClient() throws Exception {
      SSLContext sslContext = SSLContextBuilder.create()
              .loadTrustMaterial(null, (chain, authType) -> true) // 자체 서명 인증서 신뢰
              .build();

      BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
      credentialsProvider.setCredentials(
              new AuthScope(ip, port),
              new UsernamePasswordCredentials(user, pass)
      );

      RestClient restClient = RestClient.builder(new HttpHost(ip, port, scheme))
              .setHttpClientConfigCallback(httpClientBuilder ->
                      httpClientBuilder
                              .setSSLContext(sslContext)
                              .setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE)
                              .setDefaultCredentialsProvider(credentialsProvider)
              )
              .build();

      ElasticsearchTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
      return new ElasticsearchClient(transport);
  }

 

핵심은 모든 클래스를 httpclient4 패키지로 통일하는 것이다. 버전 혼용이 발생하면 타입 불일치로 컴파일 에러가 난다.


 이슈 2 — 제네릭 타입 소거와 @SuppressWarnings("unchecked")

  문제

esClient.search() 는 두 번째 파라미터로 Class<T> 를 요구한다. 결과를 Map<String, Object> 로 받고 싶었는데 아래 코드가 컴파일 에러가 난다.
 컴파일 에러

Class<Map<String, Object>> clazz = Map.class;

 

 

원인 — Java 제네릭 타입 소거 (Type Erasure)

Java 제네릭은 컴파일 타임에만 존재하고 런타임에는 타입 정보가 지워진다. 그래서 Map<String, Object>.class 라는 표현 자체가 Java에 존재하지 않는다. Map.class 만 있을 뿐이다.

 

컴파일 타임: Map<String, Object>  →  런타임: Map (raw type)

 

해결 — 강제 캐스팅 + @SuppressWarnings

 @SuppressWarnings("unchecked")
  public List<Map<String, Object>> search(String index, String field, String keyword) {
      SearchResponse<Map<String, Object>> response = esClient.search(s -> s
                      .index(index)
                      .query(q -> q
                              .match(m -> m.field(field).query(keyword))
                      ),
              (Class<Map<String, Object>>) (Class<?>) Map.class  // 강제 캐스팅
      );

      return response.hits().hits().stream()
              .map(Hit::source)
              .collect(Collectors.toList());
  }

 

(Class<Map<String, Object>>) (Class<?>) Map.class 는 Class<?> 를 경유해서 강제 변환하는 우회 방법이다. 컴파일러는 이 캐스팅의 타입 안전성을 보장할 수 없기 때문에 unchecked 경고를 낸다.

 

@SuppressWarnings("unchecked") 는 "이 캐스팅이 불가피하다는 걸 알고 의도적으로 작성한 것" 임을 명시하는 어노테이션이다. 무분별하게 사용하면 실제 타입 오류를 숨길 수 있으므로, 반드시 원인을 파악하고 좁은 범위에만 적용해야 한다.


  최종 서비스 구조

  @Slf4j
  @Service
  @RequiredArgsConstructor
  public class ElasticsearchService {

      private final ElasticsearchClient esClient;

      // 특정 필드 키워드 검색
      public List<Map<String, Object>> search(String index, String field, String keyword) { ... }

      // 인덱스 전체 조회 (size 제한)
      public List<Map<String, Object>> findAll(String index, int size) { ... }

      // 연결 상태 확인
      public boolean ping() { ... }
  }

 



  테스트 코드

실제 ES 서버 연결을 검증하는 통합 테스트로 작성했다. ping() 을 먼저 실행해서 연결과 버전 호환성을 확인하는 것이 핵심이다.

  @SpringBootTest(classes = {ElasticsearchConfig.class, ElasticsearchService.class})
  class ElasticsearchServiceTest {

      @Autowired
      ElasticsearchService elasticsearchService;

      @Test
      void ping() {
          boolean result = elasticsearchService.ping();
          assertThat(result).isTrue(); // false면 버전 불일치 또는 연결 실패
      }

      @Test
      void findAll() {
          List<Map<String, Object>> result = elasticsearchService.findAll("sensor_data_250101", 10);
          assertThat(result).isNotNull();
      }
  }

 

ping() 결과가 false 면 두 가지를 먼저 확인한다.
  - ES 서버 버전과 클라이언트 버전 호환 여부
  - SSL 인증서 설정 오류


  마무리
  elasticsearch-java 8.x 도입 과정에서 가장 주의해야 할 점은 httpclient 버전 혼용이다. 공개 API는 httpclient4, 내부 구현은 httpclient5라는 이중 구조 때문에 import를 잘못 선택하면 타입 불일치로 바로 에러가 난다. 모든 클래스를
  org.apache.http.* (httpclient4) 패키지로 통일하면 해결된다.