JAVA에서 엘라스틱서치 REST API 호출 방법

JAVA HttpURLConnection 방식

JDK에서 기본 지원하는 HttpURLConnection 객체로 Rest API를 호출하는 통신 방식입니다.
OkHttp를 사용하는 Retrofit 라이브러리로 개발하면 더 간결하고 가독성 좋아집니다.

포스트맨으로 엘라스틱서치 Rest API 호출이 가능하니까 시도해 볼까 했으나,
한 번에 다중 노드에 요청할 수 있는 엘라스틱서치 지원 방식 중 하나로 구현할 예정입니다.


Transport Client 방식

엘라스틱서치에서 지원하는 TransportClient 객체로 소켓을 사용해 연결하는 방식입니다.
엘라스틱서치 최신 버전부터는 지원되지 않아 사용할 수 없습니다.


JAVA High Level REST Client 방식

엘라스틱서치에서 지원하는 HighLevelRestClient 객체로 엘라스틱서치 Rest API를 호출하는 방식입니다.
Query DSL을 JSON String이 아닌 Builder 객체로 만드는 과정이 번거롭다는 단점이 있습니다.


JAVA Low Level REST Client 방식

엘라스틱서치에서 지원하는 RestClient 객체로 엘라스틱서치 Rest API를 호출하는 방식입니다.
Query DSL을 JSON String으로 만들어 요청할 수 있어 사용이 편리하다는 장점이 있습니다.

SpringBoot에서 JAVA 엘라스틱서치 유틸 생성 후 호출하도록 구현해 보았습니다.

Gradle 의존성 주입

implementation 'org.elasticsearch.client:elasticsearch-rest-client:8.4.1'

위와 같이 build.gradle 파일에 dependencies를 추가합니다.

aplcation.properties 속성 추가

elasticsearch.hosts=노드1IP,노드2IP
elasticsearch.ports=노드1HTTP포트,노드2HTTP포트
elasticsearch.apikey=ApiKey *******JTUJlcEV0N0tSQlZhc1Y6UVJ4cmNHdmJRQnVpWnYxdmRjRHdqQQ==

프로퍼티 파일에 엘라스틱서치 노드 접속 정보와 Apikey를 설정합니다.

엘라스틱서치 API 호출 유틸 생성

import lombok.extern.slf4j.Slf4j;
import org.apache.http.HttpHost;
import org.apache.http.util.EntityUtils;
import org.elasticsearch.client.Request;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.Response;
import org.elasticsearch.client.RestClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

@Slf4j
@Component
public class ElasticUtil {

    @Value("${elasticsearch.hosts}")
    private List<String> hosts;

    @Value("${elasticsearch.ports}")
    private List<String> ports;

    @Value("${elasticsearch.apikey}")
    private String apikey;

    public Map<String, Object> callElasticRestApi(String method, String url, String jsonBody) {

        Map<String, Object> result = new HashMap<>();

        try {
            // 엘라스틱서치 노드 정보 배열 생성
            int nodeCnt = hosts.size();
            HttpHost[] hostArr = new HttpHost[nodeCnt];

            for(int i = 0; i < nodeCnt; i++) {
                hostArr[i] = new HttpHost(hosts.get(i), Integer.parseInt(ports.get(i)), "http");
            }

            // 엘라스틱서치 요청 생성
            Request request = new Request(method, url);

            // 헤더에 엘라스틱서치 계정 API Key 입력
            RequestOptions.Builder builder = RequestOptions.DEFAULT.toBuilder();
            builder.addHeader("Authorization", apikey);
            request.setOptions(builder);

            // 요청 Json Body
            request.setJsonEntity(jsonBody);

            // 요청 실행 후 응답 받기
            RestClient restClient = RestClient.builder(hostArr).build();
            request.addParameter("pretty", "true");
            Response response = restClient.performRequest(request);

            // 정상 응답 코드 : 200
            result.put("resultCode", response.getStatusLine().getStatusCode());

            // 응답 Json Body
            result.put("resultBody", EntityUtils.toString(response.getEntity()));

/*
            TODO : 엘라스틱서치 에러 처리 안 하기로 하여 주석 처리

            // 응답 코드 생성
            String resultBody = EntityUtils.toString(response.getEntity());

            JSONParser parser = new JSONParser();
            JSONObject jsonObject = (JSONObject) parser.parse(resultBody);

            if("true".equals(jsonObject.get("errors").toString())) {
                result.put("resultCode", "615");
            }else {
                // 정상 응답 코드 : 200
                result.put("resultCode", response.getStatusLine().getStatusCode());
            }

            // 응답 Json Body
            result.put("resultBody", resultBody);
*/

            restClient.close();

        } catch (Exception e) {
            result.put("resultCode", -1);
            result.put("resultBody", e.toString());
        }

        return result;
    }
    
    public String convertEmptyStrIfNull(Map<String, Object> map, String key) {
        // map 데이터가 null인 경우 빈문자열로 치환하여 return
        String value = String.valueOf(map.getOrDefault(key, ""));
        return Objects.isNull(map.getOrDefault(key, null)) ? "" : value;
    }
}

엘라스틱서치 API 호출을 위한 전용 유틸을 개발하였습니다.

API Key 생성 쿼리

POST /_security/api_key
{
  "name": "유저명-api-key"
}

Heagers : “Authorization” 항목으로 “ApiKey 인코딩APIKey”를 넣어줘야 API 요청이 정상적으로 보내집니다.

포스트맨으로 엘라스틱서치 API 요청 테스트

GET http://엘라스틱서치IP:9200

Body : raw > JSON (content-type:application/json) 선택 시 쿼리 DSL의 Json 파라미터를 입력할 수 있습니다.

엘라스틱서치 API 요청 예시

import com.chunjae.archive_cms.common.util.ElasticUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Map;

@Slf4j
@Component
public class ElasticQuery {
    
    @Autowired
    private ElasticUtil elasticUtil;

    public Map<String, Object> getElasticData(Map<String,Object> paramMap) throws Exception {
        Map<String, Object> resultMap;

        String jsonBody = "";

        jsonBody += "{";
        jsonBody += 	"\"query\": {";
        jsonBody += 		"\"bool\" : {";
        jsonBody += 			"\"must\": [";
        jsonBody += 				"{\"match\": {\"필드명1\": \"" + paramMap.get필드명1() + "\"}},";
        jsonBody += 				"{\"match\": {\"필드명2\": \"" + paramMap.get필드명2() + "\"}},";
        jsonBody += 				"{\"match\": {\"필드명3\": \"" + paramMap.get필드명3() + "\"}}";
        jsonBody += 			"]";
        jsonBody += 		"}";
        jsonBody += 	"},";
        jsonBody += 	"\"size\": 1000,";
        jsonBody += 	"\"track_total_hits\": true";
        jsonBody += "}";

        resultMap = elasticUtil.callElasticRestApi("GET", "인덱스명/_search", jsonBody);

        return resultMap;
    }
}

ElasticUtil을 사용하여 엘라스틱서치 데이터를 검색하는 ElasticQuery 클래스 예시입니다.
Mapper처럼 쿼리를 따로 모아두면 관리가 편하고, 코드 재사용성을 높일 수 있습니다.

Json 문자열은 JSONObject 객체를 이용해서 생성할 수도 있지만,
Query DSL은 뎁스가 많아 복잡하기 때문에 가독성과 편의를 위해 String으로 작성하였습니다.

Query DSL을 Json String으로 만드는 방법

  1. 키바나 Console에서 완성한 Query DSL을 Notepad++에 붙여 넣습니다.
  2. “를 \“으로 모두 바꾸기 합니다.
  3. 한 줄마다 “ “;로 감싸줍니다.
  4. 텍스트 상단 좌측에서 Alt 키 누르며 세로로 드래그 후 ‘jsonBody += ‘를 입력합니다.
  5. 가독성을 위해 문자열 왼쪽에 뎁스에 맞는 들여쓰기를 넣어줍니다.

위 String += 방법은 성능이 좋지 않아서, StringBuilder를 사용하여 메모리 부하를 줄여주는 것이 좋습니다.

StringBuilder로 개선한 코드

import com.chunjae.archive_cms.common.util.ElasticUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Map;

@Slf4j
@Component
public class ElasticQuery {

    @Autowired
    private ElasticUtil elasticUtil;

    public Map<String, Object> getElasticData(Map<String,Object> paramMap) throws Exception {
        Map<String, Object> resultMap;

        StringBuilder sb = new StringBuilder();

        sb.append("{");
        sb.append("  \"query\": {");
        sb.append("    \"bool\" : {");
        sb.append("      \"must\": [");
        sb.append("        {\"match\": {\"필드명1\": \"" + paramMap.get필드명1() + "\"}},");
        sb.append("        {\"match\": {\"필드명2\": \"" + paramMap.get필드명2() + "\"}},");
        sb.append("        {\"match\": {\"필드명3\": \"" + paramMap.get필드명3() + "\"}}");
        sb.append("      ]");
        sb.append("    }");
        sb.append("  },");
        sb.append("  \"size\": 1000,");
        sb.append("  \"track_total_hits\": true");
        sb.append("}");

        resultMap = elasticUtil.callElasticRestApi("GET", "인덱스명/_search", sb.toString());

        return resultMap;
    }
}

엘라스틱서치에 엑셀 데이터 저장 예시

@Transactional(rollbackFor={Exception.class})
public void saveExcelData(MultipartFile mContentFile) throws Exception {
	try {
		ExcelReader reader = new ExcelReader();
		List<Map<Integer, Object>> excelList = reader.getExcelList(mContentFile);

		List<Map<String, Object>> contentList = new ArrayList<>();
		
		for (int i = 4; i < excelList.size(); i++) {
			Map<Integer, Object> excelMap = excelList.get(i);
			int rowNum = i + 1;
			
			if(excelMap.get(5) != null) {
				contentMap.put("type_cd", codeService.getCodeByName("2", String.valueOf(excelMap.get(5)))); // 2:공통코드그룹코드
			}else {
				throw new Exception(rowNum + "번째 row : 콘텐츠 유형이 존재하지 않아 정상적으로 완료되지 않았습니다.");
			
			SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm'Z'");
			Date nowDate = Calendar.getInstance().getTime();
			contentMap.put("now_elastic_date", format.format(nowDate));
			contentMap.put("now_date", nowDate);

			// DB insert
			Integer contentInsertResult = contentService.insertContent(contentMap);
			log.info("INSERT ID : " + contentMap.get("id"));

			if(contentInsertResult == 0) {
				throw new Exception(rowNum + "번째 row : 콘텐츠가 정상적으로 DB에 저장되지 않았습니다.");
			}else {
				contentList.add(contentMap);
			}
			
		}
		
		// 엘라스틱서치 Bulk insert
		boolean bulkRes = elasticService.insertNoriContentBulkData(contentList);
		
		if (bulkRes == false) {
			throw new Exception("엘라스틱서치 Bulk Insert가 실패하였습니다.");
		}
		
	} catch (Exception e) {
		throw e;
	}
}

JAVA에서 엑셀 데이터를 DB Insert 후 엘라스틱서치에 저장하는 서비스 함수 예시입니다.
ElasticService에서 데이터 가공 후 ElasticQuery를 호출하여 엘라스틱서치 인덱스에 도큐먼트를 생성합니다.

엘라스틱서치 검색 컨트롤러 예시

@ResponseBody
@PostMapping("/컨트롤러URL")
public Map<String, Object> getTopicList(@RequestBody Map<String,Object> paramMap) {
    HashMap<String, Object> resultMap = new HashMap<>();

    try {
        resultMap.put("topicList", topicService.getTopicList(paramMap));
        resultMap.put("success", true);
    }catch (Exception e) {
        resultMap.put("success", false);
    }
    return resultMap;
}

TopicService에서 ElasticService를 호출하여 엘라스틱서치 검색 결과를 가져옵니다.

Javascript에서 Java 요청 예시

let formData = new FormData();
formData.append("파라미터1", 값);
formData.append("파라미터2", 값);

fetch('/컨트롤러URL', {
    method: 'POST',
    body: formData
})
.then((response) => response.json())
.then((data) => {
    if(data.success == true) {
        // 정상 처리
        console.log(data.topicList);
    }else {
        // 에러 처리
    }
})
.catch((error) => {
    // 에러 처리
})

Leave a comment