Backend · Infra

[Elasticsearch] Low Level Client 6.x에서 Java API Client 9.x 마이그레이션 트러블슈팅

devhyen 2026. 5. 8. 16:28
👑 목차 🎀
    반응형

    마이그레이션 하게된 배경

    Elasticsearch 6.x 버전은 공식 지원 종료 (EOL) 된지 오래 되었습니다. 
    더이상 보안 패치나 버그 수정을 기대할 수 없기 때문에 실제로 발견된 CVE 취약점들에 무방비로 노출될 위험이 있었습니다. 
    서비스의 지속 가능성과 보안 안정성을 위해 최신 버전인 9.x 로의 마이그레이션을 진행하게 되었습니다. 

     

    이미 돌아가는 코드를 고치게 되다 .. 왜 ? ?  

    6.x 에서 9.x 버전의 차이는 3세대간의 차이라서 호환성이 단절되어있었습니다. 

    기존 코드로는 호환이 되지 않아서 트러블 슈팅을 하며 마이그레이션을 진행하게 되었습니다. 

    또한,  새로운 Java API Client 9.x 는 최소 Java 17 이상이 필요하기 때문에 Java 버전도 올려야했습니다. 
    java 버전을 올리는 것 만으로도 많은 코드 수정과 변화가 있었지만, 이 글에서는 ES 를 올리며 마주한 큰 트러블 슈팅 2가지를 다루려고 합니다. 

     

    1. _id 필드에 대한 메모리 로딩 기본적 차단 이슈 
    문제 현상 

    "co.elastic.clients.elasticsearch._types.ElasticsearchException: [es/search] failed: [search_phase_execution_exception] all shards failed"

    기존 6.x 환경에서 _id 필드를 기준으로 정렬(Sort)하거나 집계(Aggregation)를 수행하던 쿼리들이 9.x 환경에서는 위 메세지를 보이며 에러가 발생하고 있었습니다. 즉, 최신 버전의 Elasticsearch가 시스템 안정성을 위해 _id 필드를 메모리에 직접 올려 정렬이나 집계에 사용하는 행위를 원천 차단하고 있었던 것입니다.

     

    6.x 시절에는 딥 페이징을 위해 _id 를 타이브레이커로 사용하는 것이 표준이었으나 

    ES는 성능최적화를 위해 가상 필드인 _shard_doc을 도입했습니다. 

     

    해결 방법 : _id에서 _shard_doc으로의 전환

    시스템 안정성을 해치며 _id의 fielddata 설정을 강제로 풀기보다는 9.x 정식 지원하며 성능적으로 우수한 _shard_doc을 타이브레이커로 사용하기로 결정했습니다. 

    _shard_doc 은 정수 기반의 물리적 위치값이라 메모리 사용량이 매우 적고 정렬 속도도 빨랐기 때문입니다.

     

    또한, 안정성을 위해 구현할 때 아래를 주의하며 진행했습니다. 

     

    1. 동적 정렬 값 추출 

    서비스의 로직들은 검색응답의 sort [1] 값을 추출하여 다음 search_after 요청에 사용하는 구조였습니다. 

    정렬 필드를 _id 에서 _shard_doc 으로 변경하면 추출되는 값 자체가 자동으로 숫자형으로 바뀌므로 로직의 큰 틀은 유지할 수 있었습니다. 

    2. JSON 직렬화 타입 불일치 해결 

    기존코드는 _id 가 문자열이었기때문에 g.writeString(searchAfter) 와 같이 처리하고 있었습니다. 

    shard_doc은 숫자 타입입니다. 이를 문자열로 보낼 경우 타입 물일치 에러를 던지기 때문에 정렬값의 타입에 따라 writeNumber와 writeString 을 분기 처리하여 JSON 타입을 정확히 맞추도록 수정했습니다.

    3. 기존 오프셋 호환성 

    배치가 중단된 지점부터 재시작 하기 위해 저장해 둔 오프셋 파일에는 여전히 문자열 _id 값이 남아있습니다. 

    따라서 마이그레이션 시점에 기존 오프셋 파일을 초기화하거나 첫 실행 시 타입을 체크하여 안전하게 전이할 수 있는 보완 처리를 진행했습니다. 

     

    2. typed_keys 이슈 

    문제 현상

    마이그레이션 후, 기존 잘 작동하던 집계 리포트 기능들이 응답 데이터를 파싱하는 과정에서 에러를 보였습니다. 로그 확인 결과, 응답 JSON의 집계 키 이름이 date_terms이 아닌 range#date_terms 나 sterms#type_terms 와 같이 {type}#{name} 형태로 나오고 있었습니다. 이로인해 기존 파서가 키를 찾지 못해  NullPointerException이나 IllegalStateException이 발생했습니다.

    원인 분석

    범인은 typed_keys 매개변수 입니다. 

    typed_keys=true 옵션이 켜지면, ES 서버는 집계 응답의 키 앞에 해당 집계의 타입(range, sterms, sum 등)을 접두어로 붙여줍니다. 이는 클라이언트가 응답을 역직렬화(Deserialization)할 때, 각 데이터를 어떤 Java 객체(RangeAggregate, TermsAggregate 등)로 변환할지 정확히 판단하기 위한 정보로 사용됩니다.

     

    이 기능은 6.x 부터 있던 기능이지만, 9.x 는 타입안전성을 지향하기 때문에 내부적으로 typed_keys=true를 기본값으로 강제하여 요청합니다. 

     

    해결 방법

    수십 개에 달하는 각 리포트 파서의 로직을 일일이 수정(예: # 앞을 잘라내는 로직 추가)하는 것은 비효율적일 뿐만 아니라 휴먼 에러의 위험이 컸습니다. 따라서 응답을 JSON으로 변환하는 중앙 통로를 수정하여 기존 형식을 유지하기로 결정했습니다.

     

    모든 ES 요청과 응답 직렬화가 집중되는 코드부분만 수정하기로 했습니다.

    // ESClientQueryExecutor.java 내 헬퍼 메소드 추가
    private static JsonpMapper createEsJsonpMapper() {
        return new JacksonJsonpMapper()
            .withAttribute(JsonpMapperFeatures.SERIALIZE_TYPED_KEYS, false);
    }

    Java API Client가 응답 객체를 JSON으로 바꿀 때 사용하는 JacksonJsonpMapper에 직렬화 속성을 제어하는 기능을 적용했습니다. SERIALIZE_TYPED_KEYS 기능을 비활성화(false)하도록 설정했습니다.

     

    일반적인 검색 요청 처리를 담당하는 restClientInit()과 특정 호스트에 직접 요청을 보내는 일회성 execute() 메소드 양쪽 모두에 커스텀 매퍼를 적용했습니다.

    • 기존: new Rest5ClientTransport(restClient, new JacksonJsonpMapper())
    • 변경: new Rest5ClientTransport(restClient, createEsJsonpMapper())

    이 설정을 통해 ES 서버로부터는 typed_keys가 포함된 응답을 받되, 애플리케이션 내부에서 파싱을 위해 다시 JSON으로 변환할 때는 접두어가 제거된 기존 형식(date_terms)으로 출력되게 되었습니다.

     

     

    느낀점

     마이그레이션에 무조건적인 정답은 없다는 것을 다시금 느꼈습니다. 최신 라이브러리의 표준을 무작정 따르는 것보다, 결국 우리 프로젝트의 구조를 얼마나 깊이 이해하고 있느냐가 해결의 핵심이었습니다. 덕분에 수많은 코드를 일일이 수정하며 사이드 이펙트를 키우기보다, 프로젝트의 맥락에 맞춰 영향을 최소화할 수 있는 적절한 해결책을 찾아낼 수 있었습니다.

     

    또한, 이번 과정을 통해 EOL(공식 지원 종료)을 놓치지 않고 제때 버전 업을 관리하는 것이 얼마나 중요한지도 절감했습니다. 기술 부채가 쌓이기 전에 미리 대응하는 것이 결국 시스템의 안정성과 개발자의 생산성을 지키는 길임을 배우게 된 값진 경험이었습니다.

    반응형