Spring Boot - ElasticSearch RestHighLevelClient 인기검색어(일간,주간,월간) API 구현하기
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로 요청한 결과입니다. 이를 활용하여 제가 준비하는 토이 프로젝트의 인기 검색어를 구현할 수 있었습니다.