배경
: 기존 프로젝트는 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) 패키지로 통일하면 해결된다.
'개발일지 > SPRINGBOOT' 카테고리의 다른 글
| Spring HttpInterface 한 번에 이해하기 (0) | 2025.12.19 |
|---|---|
| springboot , record 를 통한 dto (0) | 2025.12.16 |
| UUID vs Sequential ID: 데이터베이스 설계에서 무엇을 선택할까? (0) | 2025.10.24 |
| JPA에서 @ManyToOne과 @OneToMany, 그리고 양방향 매핑의 차이 (0) | 2025.10.11 |
| Spring Data Envers와 RevisionRepository로 엔티티 변경 이력 관리하기 (0) | 2025.10.08 |