Elastic Stack/Elastic Search

Spring Boot - ElasticSearch RestHighLevelClient 인기검색어(일간,주간,월간) API 구현하기

super728 2021. 12. 5. 15:53

1. 검색 시 검색 쿼리 로깅하기

제일 먼저, 검색 성공 시(검색 결과 건수가 0건 이상일 때) "query-log"라는 인덱스에 로그 데이터를 추가합니다. 

먼저, 로그를 추가하는 메서드입니다. 

@Override
public void putSearchLog(String query,String memberId) throws Exception {
	String indexName = "query-log";
	IndexRequest request = new IndexRequest(indexName);
	request.id();
		
	Map<String,Object> doc = new HashMap<>();
	doc.put("query", query);
	doc.put("memberId", memberId);
		
	Date date = new Date(System.currentTimeMillis());
	SimpleDateFormat sdf;
	sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
	sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
	String stamp = sdf.format(date);
	doc.put("@timestamp", stamp);
		
	request.source(doc);
	try {
		client.indexAsync(request, RequestOptions.DEFAULT, new ActionListener<IndexResponse>() {

			@Override
			public void onResponse(IndexResponse response) {
				log.debug("logging success");
				log.debug(response.toString());
			}

			@Override
			public void onFailure(Exception e) {
				log.debug("logging failed");
				e.printStackTrace();
			}
				
		});
	}catch (Exception e) {
		e.printStackTrace();
	}
}

해당 메서드를 통하여 query-log 인덱스의 @timestamp 필드에 현재 시간을 기록하고 검색한 사람의 아이디, 해당 검색의 검색어 또한 기록합니다. 

 검색 결과가 0건 이상일 때만 해당 메서드를 호출하며, 로그인 상태가 아닐 시에는 null로 기록합니다. 

if(totalHits.value > 0 && !"".equals(originalQuery)) { // 검색결과 있을 시 검색 로깅
	HttpSession session = request.getSession();
	String memberId = null;
	if(session.getAttribute("memberId") != null) {
		memberId = session.getAttribute("memberId").toString();
	}
	if(memberId != null) {
		putSearchLog(originalQuery, memberId);
	} else {
		putSearchLog(originalQuery, null);
	}
}

검색 성공 시에 totalHits.value 가 0보다 크면서, 검색어가 들어왔을 때에만 아이디를 구하여 각각 로깅 메서드를 호출합니다. 

결과 확인

로그인 후 "마음의 소리"라는 검색어로 검색하고 로그가 잘 쌓이는지 테스트해보겠습니다. 

한 건의 검색결과가 도출되었습니다. 로그가 정상적으로 등록되었는지 확인합니다. 

키바나를 통하여 로그가 성공적으로 등록된 것을 확인할 수 있습니다. 

2. 데이터 집계 쿼리

인기 검색어 일간, 주간, 월간을 구현하기 위해서 RDB에서 구현할 때, 약식 쿼리문으로 다음과 같이 구할 수 있습니다. (문법은 신경 안 쓰고 막 갈겨쓴 쿼리문입니다 ㅠㅠ)

(일간)

select query, count(*) as cnt, memberId from query_log where timestamp < NOW() and timestamp > NOW() - 1 day group by query order by cnt;

엘라스틱 서치 - rest high level client에서도 집계를 통하여 group by를 구현할 수 있습니다. 

query-log 인덱스에서 일간, 주간, 월간 인기 검색어를 구하는 서비스 코드입니다. 

@Override
public List<Map<String,Object>> getPopwordList(String range) throws Exception {
	List<Map<String,Object>> resultList = new ArrayList<>();
	String indexName = "query-log";
	// start popword - 현재 시점
	SearchRequest searchRequest = new SearchRequest(indexName);
	SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
	searchSourceBuilder.size(0); // 인기검색어 1~10위 
	searchSourceBuilder.timeout(new TimeValue(60,TimeUnit.SECONDS));
		
	Date date = new Date(System.currentTimeMillis());
	SimpleDateFormat sdf;
    sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
    sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
    String stamp = sdf.format(date);
	    
    Calendar cal = Calendar.getInstance();
	    
    if("d".equals(range)) { // 일간
    	cal.add(Calendar.DAY_OF_MONTH , -1);
    } else if ("w".equals(range)) { // 주간
    	cal.add(Calendar.WEEK_OF_MONTH, -1);
    } else if ("m".equals(range)) { // 월간
    	cal.add(Calendar.MONTH, -1);
    }
	    
    String rangeFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX").format(cal.getTime());
		
	searchSourceBuilder.query(QueryBuilders.rangeQuery("@timestamp").gte(rangeFormat).lte(stamp));
		
	searchRequest.source(searchSourceBuilder);
	TermsAggregationBuilder aggregation = AggregationBuilders.terms("by_query").field("query.keyword");
	searchSourceBuilder.aggregation(aggregation);
	try {
		SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
			
		Terms byQuery = searchResponse.getAggregations().get("by_query");
		int i=0;
		for(Terms.Bucket entry : byQuery.getBuckets()) {
			Map<String,Object> popwordEntry = new HashMap<>();
			popwordEntry.put("key", entry.getKeyAsString());
			popwordEntry.put("count", entry.getDocCount());
			popwordEntry.put("status", "new");
			popwordEntry.put("value", 0);
			resultList.add(popwordEntry);
			if(i==9) break; // 10개만
		}
			
	}catch (Exception e) {
		e.printStackTrace();
		Map<String,Object> errorInfo = new HashMap<>();
		errorInfo.put("error", e);
		resultList.add(errorInfo);
		return resultList;
	}
		
		// end popword - 현재시점
		
	// start popword - 과거시점
	SearchRequest searchRequestOld = new SearchRequest(indexName);
	SearchSourceBuilder searchSourceBuilder2 = new SearchSourceBuilder();
	searchSourceBuilder2.size(0); // 인기검색어 1~10위 
	searchSourceBuilder2.timeout(new TimeValue(60,TimeUnit.SECONDS));
		
	Calendar cal2 = cal;
	if("d".equals(range)) { // 일간
    	cal2.add(Calendar.DAY_OF_MONTH , -1);
    } else if ("w".equals(range)) { // 주간
    	cal2.add(Calendar.WEEK_OF_MONTH, -1);
    } else if ("m".equals(range)) { // 월간
    	cal2.add(Calendar.MONTH, -1);
    }
		
	String rangeFormat2 = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX").format(cal2.getTime());
	
	searchSourceBuilder2.query(QueryBuilders.rangeQuery("@timestamp").gte(rangeFormat2).lte(rangeFormat));
	searchRequestOld.source(searchSourceBuilder2);
	searchSourceBuilder2.aggregation(aggregation);
	List<Map<String,Object>> resultList2 = new ArrayList<>();
	try {
		SearchResponse searchResponse2 = client.search(searchRequestOld, RequestOptions.DEFAULT);
		Terms byQuery = searchResponse2.getAggregations().get("by_query");
		int i=0;
		for(Terms.Bucket entry : byQuery.getBuckets()) {
			Map<String,Object> popwordEntry = new HashMap<>();
			popwordEntry.put("key", entry.getKeyAsString());
			popwordEntry.put("count", entry.getDocCount());

			resultList2.add(popwordEntry);
			if(i==9) break; // 10개만
		}
		log.debug("resultList2.toString()");
		log.debug(resultList2.toString());
		for(int idx=0;idx<resultList.size();idx++) {
			for(int j=0;j<resultList2.size();j++) {
				if(resultList.get(idx).get("key").equals(resultList2.get(j).get("key"))) {
					Map<String,Object> m = resultList.get(idx);
					String status;
					if(idx < j) {
						status = "up";
						m.put("status", status);
					} else if (idx == j) {
						status = "-";
						m.put("status", status);
					} else {
						status = "down";
						m.put("status", status);
					}
					m.put("value", j-idx);
				}
			}
		}
	}catch (Exception e) {
		e.printStackTrace();
		Map<String,Object> errorInfo = new HashMap<>();
		errorInfo.put("error", e);
		resultList.add(errorInfo);
		return resultList;
	}
	// end popword - 과거시점
		
		
	return resultList;
}

// start - popword 현재 시점 ~ //end - popword 과거 시점을 통하여 현재 시간부터 (일간 인기 검색어 (range = d) ) 하루 전 시간까지의 인기 검색어를 구합니다. 

 querybuilder는 당연히 rangeQuery를 사용하여 timestamp 범위 검색을 사용해야겠지요??? 

데이터 집계한 결과만 필요하고 검색 결과는 필요하지 않기 때문에 size는 0으로 설정하였습니다. 

 

데이터 집계는 TermsAggregationBuilder를 사용하였습니다. by_query라는 집계 명으로 query의 keyword를 기준으로 집계하였습니다. 

SearchResponse searchResponse = client.search(searchRequest, RequestOptions.DEFAULT);
			
	Terms byQuery = searchResponse.getAggregations().get("by_query");
	int i=0;
	for(Terms.Bucket entry : byQuery.getBuckets()) {
		Map<String,Object> popwordEntry = new HashMap<>();
		popwordEntry.put("key", entry.getKeyAsString());
		popwordEntry.put("count", entry.getDocCount());
		popwordEntry.put("status", "new");
		popwordEntry.put("value", 0);
		resultList.add(popwordEntry);
		if(i==9) break; // 10개만
	}

검색 후, 집계된 데이터를 핸들링하는 부분입니다. 인기 검색어 10개까지 뽑아내기 위해서 i를 0부터 9까지 증가시키고 break; 하였습니다. 

searchResponse.getAggregations().get(집계명) 으로 최초로 데이터 집계 결과를 접근할 수 있었고, Terms.getBuckets() 메서드를 통하여 Bucket에 접근할 수 있어서, 해당 반복을 통하여 집계 결과를 맵에 담았습니다. 

key는 그룹바이 된 query이고, count는 개수입니다. 

이대로 인기 검색어를 끝낼 수도 있지만, 새로운 인기 검색어, 인기 검색어 등락 폭 등을 구하기 위해서 집계 쿼리를 한번 더 요청했습니다. 해당 기능을 개발하기 위해 status, value 필드를 map에 추가하였습니다. 

// start popword - 과거 시점부터 // end popword - 현재 시점까지

 한번 더 요청한 집계 쿼리는 일간일 경우 어제 이 시간부터 엊그제 이 시간, 주간일 경우 저번주 이시간~ 저저번주 이시간, 월간일경우 한달 전 이시간 ~ 두 달 전 이 시간까지 집계를 하여 결과를 가져왔습니다. 

 그렇게 과거 시점 데이터를 가져오고 현재 시점 데이터와 비교 분석하여 status, value 값을 설정합니다. 

for(int idx=0;idx<resultList.size();idx++) {
	for(int j=0;j<resultList2.size();j++) {
		if(resultList.get(idx).get("key").equals(resultList2.get(j).get("key"))) {
			Map<String,Object> m = resultList.get(idx);
			String status;
			if(idx < j) {
				status = "up";
				m.put("status", status);
			} else if (idx == j) {
				status = "-";
				m.put("status", status);
			} else {
				status = "down";
				m.put("status", status);
			}
			m.put("value", j-idx);
		}
	}
}

집계 결과가 count로 정렬되어 나오기 때문에 idx 값으로 비교를 합니다. 

현재 데이터의 idx가 과거 데이터 idx보다 작을 경우 up, 같을 경우 - , 클 경우 down으로 설정합니다. value에는 등락 폭을 결정하기 위해서 현재와 과거 순위의 차를 넣어줍니다. 

결과 확인

[
  {
    "count": 3,
    "value": 0,
    "key": "피카소",
    "status": "new"
  },
  {
    "count": 1,
    "value": 0,
    "key": "test",
    "status": "new"
  },
  {
    "count": 1,
    "value": 0,
    "key": "도레미곰",
    "status": "new"
  },
  {
    "count": 1,
    "value": 0,
    "key": "마음의 소리",
    "status": "new"
  },
  {
    "count": 1,
    "value": -4,
    "key": "사이언스 프랜드",
    "status": "down"
  },
  {
    "count": 1,
    "value": 0,
    "key": "아이힐링",
    "status": "new"
  },
  {
    "count": 1,
    "value": 0,
    "key": "안녕 마음아",
    "status": "new"
  },
  {
    "count": 1,
    "value": 0,
    "key": "작은 고양이 샤통",
    "status": "new"
  },
  {
    "count": 1,
    "value": 0,
    "key": "피노키오",
    "status": "new"
  },
  {
    "count": 1,
    "value": 0,
    "key": "한국사",
    "status": "new"
  }
]

테스트를 위해서 어제 날짜로 설정하여 사이언스 프랜드를 10번 검색 후에, 제가 가진 데이터 안에서 검색하여 로그 데이터를 쌓았고, range = d로 요청한 결과입니다. 이를 활용하여 제가 준비하는 토이 프로젝트의 인기 검색어를 구현할 수 있었습니다.