Spring boot + ElasticSearch 연동 하여 실시간 검색 순위 구현하기

정명진·2023년 2월 4일
6
post-thumbnail

사이드 프로젝트를 진행하면서 실시간 검색어 순위를 구현해야 하는 일이 생겼다.

처음에는 Redis의 sorted set 구조를 이용하면 간단하게 해결이 되겠는데 싶었다. 실시간 성격이기 때문에 캐시를 적용해도 될것이라 생각을 했었다. 실제로 구현도 간단하였다. 하지만 근본적으로 캐시를 적용하는게 맞을까라는 고민이 들었다. 검색 결과까지 같이 반환해야 하기 때문이다. 물론 현재 사용하는 RDB인 MySQL의 like 기능을 이용해 %키워드% 검색을 구현할 수 있을것이다. 만약 해당 프로젝트가 성공해서 사용자가 많아진다면...? like는 full scan이기 때문에 성능에 치명적일것이란 판단을 내렸고 유명한 검색엔진인 Elastic Search와 Spring boot를 연동해 구현해야 겠다고 결론을 내렸다.

우선 서버 또는 테스트할 PC에 elastic search를 설치해줘야한다.

제 경우에는 docker compose를 활용해 설치를 진행하였습니다.

[1] cd ~
[2] mkdir elastic
[3] vi els.yml

version: '3.7'
services:
  es:
    image: docker.elastic.co/elasticsearch/elasticsearch:{version}
    container_name: {이름 지정}
    environment:
      - node.name=single-node
      - cluster.name=backtony
      - discovery.type=single-node
    ports:
      - 9200:9200
      - 9300:9300
    networks:
      - es-bridge

  kibana:
    container_name: kibana
    image: docker.elastic.co/kibana/kibana:{version}
    environment:
      SERVER_NAME: kibana
      ELASTICSEARCH_HOSTS: http://{이름 지정}:9200
    ports:
      - 5601:5601
    # Elasticsearch Start Dependency
    depends_on:
      - {이름 지정}
    networks:
      - es-bridge

networks:
  es-bridge:
    driver: bridge

이제 docker compose를 이용해 실행합니다.

docker compose -f els.yml up -d

구현하려고 하는 검색 시스템이 한글 형태소 분석 기능이 필요하다면 nori를 추가 설치해주면 됩니다. 제가 구현하는 서비스는 한글 검색을 할 일이 더 많기 때문에 저는 nori를 추가해주었습니다.
우선 Dockerfile을 만들어 compose시 같이 실행되게끔 구성해줍니다.

[1] vi Dockerfile

ARG VERSION
FROM docker.elastic.co/elasticsearch/elasticsearch:${VERSION}
RUN elasticsearch-plugin install analysis-nori

[2] vi els.yml
version: '3.7'
services:
  es:
    build:    
      context: ./Dockerfile
      args:
        VERSION: 7.15.2
    container_name: es
    environment:
      - node.name=single-node
      - cluster.name=backtony
      - discovery.type=single-node
    ports:
      - 9200:9200
      - 9300:9300
    networks:
      - es-bridge

  kibana:
    container_name: kibana
    image: docker.elastic.co/kibana/kibana:7.15.2
    environment:
      SERVER_NAME: kibana
      ELASTICSEARCH_HOSTS: http://{이름 지정}:9200
    ports:
      - 5601:5601
    # Elasticsearch Start Dependency
    depends_on:
      - {이름 지정}
    networks:
      - es-bridge

networks:
  es-bridge:
    driver: bridge
    
// 기존 도커 down    
[3] docker compose -f els.yml down
// 재실행
[4] docker compose -f els.yml up -d

이제 재실행시 nori도 같이 설치가 진행될것입니다. 설치가 잘 되었는지 테스트를 하려면 Kibana Console에 들어가 테스트를 해보면 됩니다. Kibana 포트 설정을 변경하지 않았다면 기본 5601입니다. localhost:5601 주소로 들어갑니다. 들어가면 검색창이 보일텐데 DEV Tools 을 치면 됩니다. 그 후 console 창에 테스트할 명령어를 보내줍니다.

POST _analyze
{
  "tokenizer": "nori_tokenizer",
  "text": "노리 설치 테스트."
}

// 결과
{
  "tokens" : [
    {
      "token" : "노리",
      "start_offset" : 0,
      "end_offset" : 2,
      "type" : "word",
      "position" : 0
    },
    {
      "token" : "설치",
      "start_offset" : 3,
      "end_offset" : 5,
      "type" : "word",
      "position" : 1
    },
    {
      "token" : "테스트",
      "start_offset" : 6,
      "end_offset" : 9,
      "type" : "word",
      "position" : 2
    }
  ]
}

이제 Spring Boot에 elastic 관련 라이브러리를 설치하고 configuration을 작성해야 합니다.
build.gradle dependency에 다음을 추가하고 빌드를 해줍니다.

implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'

설치가 완료되고 나면 config를 작성해줍니다.

@Configuration
@EnableElasticsearchRepositories
public class ElasticSearchConfig extends AbstractElasticsearchConfiguration {
    @Override
    public RestHighLevelClient elasticsearchClient() {
        // http port 와 통신할 주소
        ClientConfiguration configuration = ClientConfiguration.builder().connectedTo("localhost:9200").build();
        return RestClients.create(configuration).rest();
    }
}

이제 Document로 관리할 클래스에 @Document 어노테이션을 지정해줘야 합니다. 하지만 여기서 JPA를 기존에 사용하고 계신분들은 문제가 발생합니다. Spring Boot의 AutoConfiguration이 작동중 JPA와 Elastic Search중 Repository에 어떤걸 사용할지 충돌이 발생하며 프로젝트가 실행되지 않습니다. 이를 해결하려면 Entity와 Document를 따로 관리해야합니다. 물론 Entity에 Document를 지정하여 관리할 수 있지만 하드코딩을 해야하므로 추천하지는 않습니다.
만약 하드코딩으로 해결하실 분들은 다음과 같이 설정을 해가며 적용하면 됩니다.

@EnableElasticsearchRepositories(basePackageClasses = UserSearchRepository.class)
@Configuration
public class ElasticSearchConfig extends AbstractElasticsearchConfiguration {

    //...
}

@EnableJpaRepositories(excludeFilters = @ComponentScan.Filter(
    type = FilterType.ASSIGNABLE_TYPE,
    classes = UserSearchRepository.class))
@SpringBootApplication
public class BackendApplication {

    public static void main(String[] args) {
        SpringApplication.run(BackendApplication.class, args);
    }

}

하드코딩을 하지 않고 분리를 통해 작성한다면 Entity와 동일한 Document를 만들면 됩니다.

저의 경우 검색 결과로 공연을 return 하면 되므로 공연 Document를 만들었습니다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Document(indexName = "performance")
@Mapping(mappingPath = "elastic/performance-mapping.json")
@Setting(settingPath = "elastic/performance-setting.json")
public class PerformanceDocument{
    @Id
    @Field(type = FieldType.Keyword)
    private Long id;
    /** 제목 */
    @Field(type = FieldType.Text)
    private String title;
    /** 공연 일시 */
    @Field(type = FieldType.Text)
    private String musicalDateTime;
    /** 공연장소 */
    @Field(type = FieldType.Text)
    private String place;
    /** 이미지 링크 */
    @Field(type = FieldType.Text)
    private String imageUrl;
    /** 뮤지컬 or 콘서트 */
    @Field(type = FieldType.Text)
    private PerformanceType performanceType;
    /** 공연 시작일 */
    @Field(type = FieldType.Date, format = {DateFormat.date_hour_minute_second_millis, DateFormat.epoch_millis})
    private LocalDateTime startDate;
    /** 공연 종료일 */
    @Field(type = FieldType.Date, format = {DateFormat.date_hour_minute_second_millis, DateFormat.epoch_millis})
    private LocalDateTime endDate;

    @Builder
    public PerformanceDocument(Long id, String title, String musicalDateTime, String place, String imageUrl, PerformanceType performanceType, LocalDateTime startDate, LocalDateTime endDate) {
        this.id = id;
        this.title = title;
        this.musicalDateTime = musicalDateTime;
        this.place = place;
        this.imageUrl = imageUrl;
        this.performanceType = performanceType;
        this.startDate = startDate;
        this.endDate = endDate;
    }
}

위와 같이 @Field 어노테이션을 통해 직접 타입을 설정할 수 있습니다. 객체가 연관되어 있어 복잡한 경우는 @Mapping(mappingPath = "elastic/performance-mapping.json") 어노테이션을 통해 직접 설정이 가능합니다. 제 Document에는 객체가 없지만 다음과 같이 만들어 볼 수 있습니다.

{
  "properties" : {
    "id" : {"type" : "keyword"},
    "title" : {"type" : "text", "analyzer" : "korean"},
    "musicalDateTime" : {"type" : "keyword"},
    "place" : {"type" : "text", "analyzer" : "korean"},
    "imageUrl" : {"type" : "text"},
    "performanceType" : {"type" : "text"},
    "startDate" : {"type" : "date", "format": "uuuu-MM-dd'T'HH:mm:ss.SSS||epoch_millis"},
    "endDate" : {"type" : "date", "format": "uuuu-MM-dd'T'HH:mm:ss.SSS||epoch_millis"}
  }
}

저기서 analyzer는 @Setting(settingPath = "elastic/performance-setting.json")에서 설정한 값입니다. text 타입을 지정하면 입력된 문자열을 텀 단위로 쪼개어 역 색인 구조를 만들게 되는데 이때 옵션을 세팅할 수 있습니다. 이를 세팅해주면 됩니다. 주의할점은 keyword는 analyzer 적용이 안됩니다. text만 가능하며 keyword는 normalizer를 설정해야 합니다. 저는 형태소 분석에 nori를 사용하기 위해 다음과 같이 설정했습니다.

{
  "analysis": {
    "analyzer": {
      "korean": {
        "type": "nori"
      }
    }
  }
}

자세한 사항은 https://esbook.kimjmin.net/07-settings-and-mappings/7.2-mappings/7.2.1 참고하시길 바랍니다. 이제 검색에 필요한 기능을 작성합니다.

public interface PerformanceSearchUseCase {
    void saveAllDocuments();
    List<PerformanceDocument> searchByTitle(String title);
    List<PerformanceDocument> searchByPlace(String place);
    List<PerformanceDocument> searchByPerformanceType(String type);
}

그리고 컨트롤러에 작성한 기능을 넣고 실제로 받아오는지 테스트합니다.

@PostMapping("/documents")
    public ResponseEntity<CommonResponse> saveDocuments() {
        performanceSearchService.saveAllDocuments();
        return ResponseEntity.ok(CommonResponse.builder().message("ES에 성공적으로 저장하였습니다.").status(HttpStatus.OK.value()).build());
    }

    @GetMapping("/title")
    public ResponseEntity<ResponseData<List<PerformanceDocument>>> searchByTitle(@RequestParam String title) {
        List<PerformanceDocument> documents = performanceSearchService.searchByTitle(title);
        ResponseData response = new ResponseData("SUCCESS", HttpStatus.OK.value(), documents);
        return ResponseEntity.ok().body(response);
    }

    @GetMapping("/place")
    public ResponseEntity<ResponseData<List<PerformanceDocument>>> searchByPlace(@RequestParam String place) {
        List<PerformanceDocument> documents = performanceSearchService.searchByPlace(place);
        ResponseData response = new ResponseData("SUCCESS", HttpStatus.OK.value(), documents);
        return ResponseEntity.ok().body(response);
    }

    @GetMapping("/type")
    public ResponseEntity<ResponseData<List<PerformanceDocument>>> searchByPerformanceType(@RequestParam String performanceType) {
        List<PerformanceDocument> documents = performanceSearchService.searchByPerformanceType(performanceType);
        ResponseData response = new ResponseData("SUCCESS", HttpStatus.OK.value(), documents);
        return ResponseEntity.ok().body(response);
    }

바로 Document를 반환하는것 보다 DTO 변환하여 넘기는걸 추천드립니다. 저는 추후 데이터 반환값이 변경될 수 있어서 DTO 없이 우선 바로 테스트하기 위해 원본으로 진행하였습니다.

Postman에 post 요청을 보내 데이터를 저장합니다.

POST {{host}}performance/documents

{
    "message": "ES에 성공적으로 저장하였습니다.",
    "status": 200
}

그리고 Kibana 관리 페이지인 localhost:설정포트 로 이동합니다. 그러면 Analytics 카테고리에 Discover가 있습니다. 클릭후 create pattern index를 누르고 Document에 사용했던 이름으로 인덱스를 만들어줍니다.

id:2 performanceType:CONCERT title:test1 _id:2 _index:performance _score:0.693 _type:_doc

id:4 performanceType:CONCERT title:test3 _id:4 _index:performance _score:0.693 _type:_doc

id:6 performanceType:CONCERT title:test5 _id:6 _index:performance _score:0.693 _type:_doc

id:8 performanceType:CONCERT title:test7 _id:8 _index:performance _score:0.693 _type:_doc

id:10 performanceType:CONCERT title:test9 _id:10 _index:performance _score:0.693 _type:_doc

id:12 performanceType:CONCERT title:test11 _id:12 _index:performance _score:0.693 _type:_doc

id:14 performanceType:CONCERT title:test13 _id:14 _index:performance _score:0.693 _type:_doc

id:16 performanceType:CONCERT title:test15 _id:16 _index:performance _score:0.693 _type:_doc

id:18 performanceType:CONCERT title:test17 _id:18 _index:performance _score:0.693 _type:_doc

id:20 performanceType:CONCERT title:test19 _id:20 _index:performance _score:0.693 _type:_doc

콘솔에서 공연이 CONCERT 타입인 것을 조건으로 검색한 결과입니다.

인덱싱 설정이 잘 되었는지 확인하려면 http://localhost:9200/{document_name}?format=json&pretty 으로 들어가서 확인 가능합니다.

이제 실시간 검색어를 분석하려면 ElasticSearch에 어플리케이션 액세스 로그를 넣고 분석을 해야합니다.
원래 제대로 구현하려면 Nginx, Logstash, Filebeat, ElasticSearch 구성으로 해야 하지만 우선 로컬에서 기능 테스트후 운영 배포시 구현할 것이기 때문에 저는 Spring boot -> ElasticSearch 로 액세스 로그를 전송하고 이를 현재 시간 기준 1시간으로 잡고 top 10 키워드를 표시하는 걸로 구현을 할 예정입니다.

우선 Spring boot는 기본적으로 Logback 로깅 프레임워크를 사용해 로그를 기록하는데 다행이도 ElasticSearch로 전송하는 기능이 있습니다. 해당 기능을 사용하기 위해 build.gradle에 다음을 추가합니다.

// spring boot log to elastic search
implementation 'com.internetitem:logback-elasticsearch-appender:1.6'
implementation 'dev.akkinoc.spring.boot:logback-access-spring-boot-starter:3.2.1'

그리고 logback-access-spring.xml 파일을 만들고 빌드를 해줍니다.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProperty scope="context" name="elasticsearch_uris" source="spring.elasticsearch.uris" defaultValue="http://localhost:9200"/>
    <appender name="ELASTIC" class="com.internetitem.logback.elasticsearch.ElasticsearchAccessAppender">
        <url>http://localhost:9200/_bulk</url>
        <index>application-accesslog-%date{yyyy-MM-dd}</index>
        <headers>
            <header>
                <name>Content-Type</name>
                <value>application/json</value>
            </header>
        </headers>
    </appender>
    <appender-ref ref="ELASTIC"/>
</configuration>

그리고 서버에 아무 요청이나 보낸후 kibana console 창에 들어가 다음을 실행하면

POST application-accesslog-2023-02-06/_search
{
  "query": {
    "match_all": {}
  }
}

다음과 같이 요청에 대한 로그가 기록됩니다.

{
  "took" : 1,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 2,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "application-accesslog-2023-02-06",
        "_type" : "_doc",
        "_id" : "rwsgJYYBczPF6ol6tVs1",
        "_score" : 1.0,
        "_source" : {
          "@timestamp" : "2023-02-06T14:09:05.563+0900"
        }
      },
      {
        "_index" : "application-accesslog-2023-02-06",
        "_type" : "_doc",
        "_id" : "sAsgJYYBczPF6ol6vFss",
        "_score" : 1.0,
        "_source" : {
          "@timestamp" : "2023-02-06T14:09:07.492+0900"
        }
      }
    ]
  }
}

저는 2번의 요청을 보냈기 때문에 저렇게 기록이 되었는데요. 이제 여기에 값을 지정할 계획입니다.
제 시나리오는 현재 시간이 06시 35분이라면 05~06시 사이의 기간만 뽑히도록 filter를 걸고 그리고 search 워드를 count 하여 총 10개를 보내줄 계획입니다. 우선 그러면 요청값을 기록해야 하고 시간도 기록을 해야 합니다. 이제 logback-access-spring.xml을 다음과 같이 수정합니다.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <springProperty scope="context" name="elasticsearch_uris" source="spring.elasticsearch.uris" defaultValue="http://localhost:9200"/>
    <appender name="ELASTIC" class="com.internetitem.logback.elasticsearch.ElasticsearchAccessAppender">
        <url>http://localhost:9200/_bulk</url>
        <index>application-accesslog-%date{yyyy-MM-dd}</index>
        <properties>
            <property>
                <name>contentLength</name>
                <value>%b</value>
            </property>
            <property>
                <name>remoteHost</name>
                <value>%h</value>
            </property>
            <property>
                <name>protocol</name>
                <value>%H</value>
            </property>
            <property>
                <name>referer</name>
                <value>%i{Referer}</value>
            </property>
            <property>
                <name>userAgent</name>
                <value>%i{User-Agent}</value>
            </property>
            <property>
                <name>requestMethod</name>
                <value>%m</value>
            </property>
            <property>
                <name>statusCode</name>
                <value>%s</value>
            </property>
            <property>
                <name>elapsedTime</name>
                <value>%D</value>
            </property>
      .... 생략 ....

이후 요청을 다시 보내면 지정한 값에 대한 기록이 함께 전송됩니다.

{
  "took" : 76,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 5,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
      {
        "_index" : "application-accesslog-2023-02-06",
        "_type" : "_doc",
        "_id" : "rwsgJYYBczPF6ol6tVs1",
        "_score" : 1.0,
        "_source" : {
          "@timestamp" : "2023-02-06T14:09:05.563+0900"
        }
      },
      {
        "_index" : "application-accesslog-2023-02-06",
        "_type" : "_doc",
        "_id" : "sAsgJYYBczPF6ol6vFss",
        "_score" : 1.0,
        "_source" : {
          "@timestamp" : "2023-02-06T14:09:07.492+0900"
        }
      },
      {
        "_index" : "application-accesslog-2023-02-06",
        "_type" : "_doc",
        "_id" : "sQsnJYYBczPF6ol6vVuF",
        "_score" : 1.0,
        "_source" : {
          "@timestamp" : "2023-02-06T14:16:46.531+0900",
          "contentLength" : "82",
          "remoteHost" : "0:0:0:0:0:0:0:1",
          "protocol" : "HTTP/1.1",
          "referer" : "-",
          "userAgent" : "PostmanRuntime/7.29.2",
          "requestMethod" : "POST",
          "statusCode" : "200",
          "elapsedTime" : "330",
          "date" : "2023-02-06T14:16:46",
          "user" : "-",
          "requestURI" : "/performance/documents"
        }
      },
      {
        "_index" : "application-accesslog-2023-02-06",
        "_type" : "_doc",
        "_id" : "sgsnJYYBczPF6ol6xlt-",
        "_score" : 1.0,
        "_source" : {
          "@timestamp" : "2023-02-06T14:16:48.884+0900",
          "contentLength" : "1435",
          "remoteHost" : "0:0:0:0:0:0:0:1",
          "protocol" : "HTTP/1.1",
          "referer" : "-",
          "userAgent" : "PostmanRuntime/7.29.2",
          "requestMethod" : "GET",
          "statusCode" : "200",
          "elapsedTime" : "118",
          "date" : "2023-02-06T14:16:48",
          "user" : "-",
          "queryString" : "?performanceType=MUSICAL",
          "requestURI" : "/performance/type"
        }
      },
      {
        "_index" : "application-accesslog-2023-02-06",
        "_type" : "_doc",
        "_id" : "swsnJYYBczPF6ol6zVvZ",
        "_score" : 1.0,
        "_source" : {
          "@timestamp" : "2023-02-06T14:16:50.764+0900",
          "contentLength" : "2816",
          "remoteHost" : "0:0:0:0:0:0:0:1",
          "protocol" : "HTTP/1.1",
          "referer" : "-",
          "userAgent" : "PostmanRuntime/7.29.2",
          "requestMethod" : "GET",
          "statusCode" : "200",
          "elapsedTime" : "59",
          "date" : "2023-02-06T14:16:50",
          "user" : "-",
          "queryString" : "?title=test",
          "requestURI" : "/performance/title"
        }
      }
    ]
  }
}

데이터를 쌓은후 queryDSL로 다음과 같이 요청을 날리면

POST application-accesslog-2023-02-07/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
          "requestURI": "/performance/search"
        }
        },
        {
          "range": {
            "date": {
              "gte": "2023-02-07T09:38:00",
              "lte": "2023-02-07T10:00:00"
            }
          }
        }
      ]
    }
  },
  "fields":[
      "queryString",
      "requestURI",
      "date"
      ],
      "_source": false
}

다음과 같은 결과를 얻는다.

{
  "took" : 7,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 5,
      "relation" : "eq"
    },
    "max_score" : 1.1025866,
    "hits" : [
      {
        "_index" : "application-accesslog-2023-02-07",
        "_type" : "_doc",
        "_id" : "ygtQKYYBczPF6ol6T1sh",
        "_score" : 1.1025866,
        "fields" : {
          "date" : [
            "2023-02-07T09:39:34.000Z"
          ],
          "requestURI" : [
            "/performance/search/title"
          ],
          "queryString" : [
            "?query=testaa"
          ]
        }
      },
      {
        "_index" : "application-accesslog-2023-02-07",
        "_type" : "_doc",
        "_id" : "ywtQKYYBczPF6ol6XFtF",
        "_score" : 1.1025866,
        "fields" : {
          "date" : [
            "2023-02-07T09:39:37.000Z"
          ],
          "requestURI" : [
            "/performance/search/title"
          ],
          "queryString" : [
            "?query=test1"
          ]
        }
      },
      {
        "_index" : "application-accesslog-2023-02-07",
        "_type" : "_doc",
        "_id" : "zAtQKYYBczPF6ol6ZVuA",
        "_score" : 1.1025866,
        "fields" : {
          "date" : [
            "2023-02-07T09:39:39.000Z"
          ],
          "requestURI" : [
            "/performance/search/title"
          ],
          "queryString" : [
            "?query=test"
          ]
        }
      },
      {
        "_index" : "application-accesslog-2023-02-07",
        "_type" : "_doc",
        "_id" : "zQtQKYYBczPF6ol6eVtT",
        "_score" : 1.1025866,
        "fields" : {
          "date" : [
            "2023-02-07T09:39:44.000Z"
          ],
          "requestURI" : [
            "/performance/search/title"
          ],
          "queryString" : [
            "?query=test1"
          ]
        }
      },
      {
        "_index" : "application-accesslog-2023-02-07",
        "_type" : "_doc",
        "_id" : "zgtQKYYBczPF6ol6j1us",
        "_score" : 1.1025866,
        "fields" : {
          "date" : [
            "2023-02-07T09:39:50.000Z"
          ],
          "requestURI" : [
            "/performance/search/title"
          ],
          "queryString" : [
            "?query=test"
          ]
        }
      }
    ]
  }
}

이렇게 해서 특정시간대에 range를 걸어 performance/search와 매핑되는 검색 키워드 찾기에 성공했다.
이를 가져와서 파싱을 하면 검색어를 얻을 수 있고 이를 통해 실시간 검색어 순위를 구할 수 있다.
그러면 post로 전송해서 데이터를 동일하게 가져오는지 테스트 해보겠습니다.

post 전송

@Test
    void directConnectionTest() throws JsonProcessingException {
        String str = "{\n" +
                "  \"query\": {\n" +
                "    \"bool\": {\n" +
                "      \"must\": [\n" +
                "        {\n" +
                "          \"match\": {\n" +
                "          \"requestURI\": \"/performance/search\"\n" +
                "        }\n" +
                "        },\n" +
                "        {\n" +
                "          \"range\": {\n" +
                "            \"date\": {\n" +
                "              \"gte\": \"2023-02-07T09:38:00\",\n" +
                "              \"lte\": \"2023-02-07T10:00:00\"\n" +
                "            }\n" +
                "          }\n" +
                "        }\n" +
                "      ]\n" +
                "    }\n" +
                "  },\n" +
                "  \"fields\":[\n" +
                "      \"queryString\",\n" +
                "      \"requestURI\",\n" +
                "      \"date\"\n" +
                "      ],\n" +
                "      \"_source\": false\n" +
                "}";
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.setContentType(MediaType.APPLICATION_JSON);
        RestTemplate restTemplate = new RestTemplate();
        HttpEntity<String> request =
                new HttpEntity<String>(str, httpHeaders);

        String personResultAsJsonStr =
                restTemplate.postForObject("http://localhost:9200/application-accesslog-2023-02-07/_search", request, String.class);
        System.out.println("personResultAsJsonStr = " + personResultAsJsonStr);
    }

결과는 다음과 같습니다.

{
  "took": 22,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 5,
      "relation": "eq"
    },
    "max_score": 1.1025866,
    "hits": [
      {
        "_index": "application-accesslog-2023-02-07",
        "_type": "_doc",
        "_id": "ygtQKYYBczPF6ol6T1sh",
        "_score": 1.1025866,
        "fields": {
          "date": [
            "2023-02-07T09:39:34.000Z"
          ],
          "requestURI": [
            "/performance/search/title"
          ],
          "queryString": [
            "?query=testaa"
          ]
        }
      },
      {
        "_index": "application-accesslog-2023-02-07",
        "_type": "_doc",
        "_id": "ywtQKYYBczPF6ol6XFtF",
        "_score": 1.1025866,
        "fields": {
          "date": [
            "2023-02-07T09:39:37.000Z"
          ],
          "requestURI": [
            "/performance/search/title"
          ],
          "queryString": [
            "?query=test1"
          ]
        }
      },
      {
        "_index": "application-accesslog-2023-02-07",
        "_type": "_doc",
        "_id": "zAtQKYYBczPF6ol6ZVuA",
        "_score": 1.1025866,
        "fields": {
          "date": [
            "2023-02-07T09:39:39.000Z"
          ],
          "requestURI": [
            "/performance/search/title"
          ],
          "queryString": [
            "?query=test"
          ]
        }
      },
      {
        "_index": "application-accesslog-2023-02-07",
        "_type": "_doc",
        "_id": "zQtQKYYBczPF6ol6eVtT",
        "_score": 1.1025866,
        "fields": {
          "date": [
            "2023-02-07T09:39:44.000Z"
          ],
          "requestURI": [
            "/performance/search/title"
          ],
          "queryString": [
            "?query=test1"
          ]
        }
      },
      {
        "_index": "application-accesslog-2023-02-07",
        "_type": "_doc",
        "_id": "zgtQKYYBczPF6ol6j1us",
        "_score": 1.1025866,
        "fields": {
          "date": [
            "2023-02-07T09:39:50.000Z"
          ],
          "requestURI": [
            "/performance/search/title"
          ],
          "queryString": [
            "?query=test"
          ]
        }
      }
    ]
  }
}

kibana에서 가져온 데이터와 동일함을 입증하였습니다. 하지만 로그가 넘어갈때 포맷이 깔끔하지 않습니다. 이를 깔끔하게 해결하기 위해 logstash를 적용해야합니다.
https://www.elastic.co/kr/downloads/past-releases/logstash-7-2-0
logstash를 다운로드 받고 압축을 풉니다.

${압축푼경로}/config 경로에 exmaple.config를 다음 내용으로 만듭니다.

vi example.config

input {
	stdin {}
}
output {
	stdout {}
}

그리고 나서 
${압축푼경로}/bin 에 가면 logstash 실행 파일이 있습니다.

./logstash -f ../config/example.conf

이렇게 실행을 하면 해당 에러 문구가 뜨는 경우가 있습니다.

Unrecognized VM option 'UseConcMarkSweepGC'

해당 에러는 자바 버전이 스크립트 작성 버전보다 더 높아서 발생하는 오류입니다.
자바 8 또는 11로 변경하시면 됩니다.

# check version
java -version

# if version is too high then downgrade version
# check all installed version
/usr/libexec/java_home -V

# downgrade
export JAVA_HOME=$(/usr/libexec/java_home -v 1.8)
or
export JAVA_HOME=$(/usr/libexec/java_home -v 11)

# apply
source ~/.bash_profile 

그리고 다시 실행하면 다음과 같은 결과를 얻습니다.

[2023-02-07T13:53:25,962][INFO ][logstash.setting.writabledirectory] Creating directory {:setting=>"path.queue", :path=>"/Users/a60156077/Documents/elastic/log/logstash-7.2.0/data/queue"}
[2023-02-07T13:53:25,984][INFO ][logstash.setting.writabledirectory] Creating directory {:setting=>"path.dead_letter_queue", :path=>"/Users/a60156077/Documents/elastic/log/logstash-7.2.0/data/dead_letter_queue"}
[2023-02-07T13:53:26,063][WARN ][logstash.config.source.multilocal] Ignoring the 'pipelines.yml' file because modules or command line options are specified
[2023-02-07T13:53:26,070][INFO ][logstash.runner          ] Starting Logstash {"logstash.version"=>"7.2.0"}
[2023-02-07T13:53:26,099][INFO ][logstash.agent           ] No persistent UUID file found. Generating new UUID {:uuid=>"14c1d564-d987-4327-b2d1-eb7c797c6dcc", :path=>"/Users/a60156077/Documents/elastic/log/logstash-7.2.0/data/uuid"}
[2023-02-07T13:53:28,939][WARN ][org.logstash.instrument.metrics.gauge.LazyDelegatingGauge] A gauge metric of an unknown type (org.jruby.RubyArray) has been create for key: cluster_uuids. This may result in invalid serialization.  It is recommended to log an issue to the responsible developer/development team.
[2023-02-07T13:53:28,945][INFO ][logstash.javapipeline    ] Starting pipeline {:pipeline_id=>"main", "pipeline.workers"=>8, "pipeline.batch.size"=>125, "pipeline.batch.delay"=>50, "pipeline.max_inflight"=>1000, :thread=>"#<Thread:0x2ac540e7 run>"}
[2023-02-07T13:53:29,342][INFO ][logstash.javapipeline    ] Pipeline started {"pipeline.id"=>"main"}
The stdin plugin is now waiting for input:
[2023-02-07T13:53:29,544][INFO ][logstash.agent           ] Pipelines running {:count=>1, :running_pipelines=>[:main], :non_running_pipelines=>[]}
[2023-02-07T13:53:29,961][INFO ][logstash.agent           ] Successfully started Logstash API endpoint {:port=>9600}

이제 logback.xml을 logstash에 맞게 변경해줍니다.

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- Console -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{10} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- Logstash -->
    <appender name="LOGSTASH" class="net.logstash.logback.appender.LogstashTcpSocketAppender">
        <destination>localhost:4560</destination>
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
        </encoder>
    </appender>

    <root level="info">
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="LOGSTASH" />
    </root>
</configuration>

그리고 logstash의 config 파일도 변경합니다.

input{
tcp {
    port => 4560
    codec => json_lines }
}
output {
elasticsearch {
                hosts => ["localhost:9200"]
                index => "logstash-%{+YYYY.MM.dd}"
        }
}

그리고 다시 재실행하면 이제 elastic search에 로그가 쌓입니다.

그럼 이제 logstash + elastic search + logback + kibana 연동이 끝났습니다.

이제 필터를 걸어서 데이터를 가져오는것만 마무리하면 됩니다.
우선 kibana console에서 잘 작동하는지 다시 테스트를 해봅니다.

POST logstash-2023.02.07-000001/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "match": {
            "message": "/performance/search"
          }
        },
        {
          "match_phrase": {
            "logger_name": "LOGSTASH"
          }
        }
      ]
    }
  },
  "fields": [
    "message"
  ],
  "_source": false
}

결과는 다음과 같이 잘 나옵니다.

{
  "took" : 4,
  "timed_out" : false,
  "_shards" : {
    "total" : 1,
    "successful" : 1,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 4,
      "relation" : "eq"
    },
    "max_score" : 10.071696,
    "hits" : [
      {
        "_index" : "logstash-2023.02.07-000001",
        "_type" : "_doc",
        "_id" : "lZ7wKoYBuJeut5-o7Pd2",
        "_score" : 10.071696,
        "fields" : {
          "message" : [
            "requestURI=/performance/search/type, keyword=Mu"
          ]
        }
      },
      {
        "_index" : "logstash-2023.02.07-000001",
        "_type" : "_doc",
        "_id" : "mp7xKoYBuJeut5-oU_dh",
        "_score" : 10.071696,
        "fields" : {
          "message" : [
            "requestURI=/performance/search/title, keyword=바비스"
          ]
        }
      },
      {
        "_index" : "logstash-2023.02.07-000001",
        "_type" : "_doc",
        "_id" : "np7zKoYBuJeut5-osPfY",
        "_score" : 10.071696,
        "fields" : {
          "message" : [
            "requestURI=/performance/search/title, keyword=바비스"
          ]
        }
      },
      {
        "_index" : "logstash-2023.02.07-000001",
        "_type" : "_doc",
        "_id" : "op7zKoYBuJeut5-os_c0",
        "_score" : 10.071696,
        "fields" : {
          "message" : [
            "requestURI=/performance/search/title, keyword=바비스"
          ]
        }
      }
    ]
  }
}

여기까지 구현했다면 거의 완료한 상태입니다. 이제 로그를 매핑할 클래스를 만들면 Spring boot와 ELK 연동이 가능해집니다. 이제 NativeSearchQuery를 통해 실시간 검색어에 필요한 정보를 뽑아와 구현만 하면 이제 끝입니다. 처음에 elastic search 쿼리 기능도 헤매서 시간이 조금 오래 걸렸네요...

@Repository
@RequiredArgsConstructor
public class CustomLogRepositoryImpl implements CustomLogRepository{
    private final ElasticsearchOperations elasticsearchOperations;

    /**
     * 시간을 비교할때 Long으로 넘겨줘야함..
     * 기본적으로 dynamic finder가 long으로 비교한다고 한다.
     * @return
     */
    @Override
    public List<AccessLogDocument> getRecentTop10Keywords() {
        NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
                .withQuery(boolQuery()
                        .must(matchQuery("message", "/performance/search"))
                                .must(matchPhraseQuery("logger_name", "LOGSTASH"))
                        .must(rangeQuery("@timestamp")
                                .gte(LocalDateTime.now().truncatedTo(ChronoUnit.HOURS).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())
                                .lte(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())))
                .build();
        List<AccessLogDocument> documents = elasticsearchOperations
                . search(searchQuery, AccessLogDocument.class, IndexCoordinates.of("logstash*")).stream().map(SearchHit::getContent).collect(Collectors.toList());
        return documents;
    }
}

여기서 AccessLogDocument는 실시간 검색 기능에 필요한 필드를 설정해서 매핑해주면 됩니다.
여기서 timestamp를 비교할때 중요한 사항이 있습니다. 처음에 Long으로 넘기지 않고 LocalDateTime 형식으로 값을 넣어 gte, lte에 넘겼는데 정상적으로 작동하지 않았습니다. 대체 왜 날짜 타입으로 반환해서 넘겼는데 작동을 하지 않는지 의문이었습니다... stackoverflow를 찾아보니 dynamic finder에 의해 date 타입 비교를 할 때 Long 타입으로 넘겨야 비교가 가능하다고 합니다. 다음과 같이 쿼리를 날리면 아래와 같이 설정한 상태에 따라 응답이 옵니다.

{
    "message": "SUCCESS",
    "status": 200,
    "data": [
        {
            "id": "bp-mL4YBuJeut5-ozgw5",
            "date": "2023-02-08T06:11:46.248",
            "version": "1",
            "host": "localhost",
            "level": "INFO",
            "levelValue": null,
            "loggerName": "LOGSTASH",
            "message": "requestURI=/performance/search/place, keyword=전라북 전주",
            "port": 59799,
            "stackTrace": null,
            "threadName": null,
            "keyword": "전라북 전주"
        },
        {
            "id": "cp-mL4YBuJeut5-o2gx8",
            "date": "2023-02-08T06:11:49.382",
            "version": "1",
            "host": "localhost",
            "level": "INFO",
            "levelValue": null,
            "loggerName": "LOGSTASH",
            "message": "requestURI=/performance/search/place, keyword=전주",
            "port": 59799,
            "stackTrace": null,
            "threadName": null,
            "keyword": "전주"
        },
        {
            "id": "ep-nL4YBuJeut5-oDQyV",
            "date": "2023-02-08T06:12:02.463",
            "version": "1",
            "host": "localhost",
            "level": "INFO",
            "levelValue": null,
            "loggerName": "LOGSTASH",
            "message": "requestURI=/performance/search/place, keyword=전북도 전주",
            "port": 59799,
            "stackTrace": null,
            "threadName": null,
            "keyword": "전북도 전주"
        },
        {
            "id": "dp-mL4YBuJeut5-o5wxE",
            "date": "2023-02-08T06:11:52.659",
            "version": "1",
            "host": "localhost",
            "level": "INFO",
            "levelValue": null,
            "loggerName": "LOGSTASH",
            "message": "requestURI=/performance/search/place, keyword=전북 전주",
            "port": 59799,
            "stackTrace": null,
            "threadName": null,
            "keyword": "전북 전주"
        },
        {
            "id": "ap-mL4YBuJeut5-owAzT",
            "date": "2023-02-08T06:11:42.813",
            "version": "1",
            "host": "localhost",
            "level": "INFO",
            "levelValue": null,
            "loggerName": "LOGSTASH",
            "message": "requestURI=/performance/search/place, keyword=전라북도 전주",
            "port": 59799,
            "stackTrace": null,
            "threadName": null,
            "keyword": "전라북도 전주"
        },
        {
            "id": "Zp-lL4YBuJeut5-opAyk",
            "date": "2023-02-08T06:10:30.044",
            "version": "1",
            "host": "localhost",
            "level": "INFO",
            "levelValue": null,
            "loggerName": "LOGSTASH",
            "message": "requestURI=/performance/search/place, keyword=전라북도",
            "port": 59799,
            "stackTrace": null,
            "threadName": null,
            "keyword": "전라북도"
        },
        {
            "id": "fZ-vL4YBuJeut5-ojQzP",
            "date": "2023-02-08T06:21:19.549",
            "version": "1",
            "host": "localhost",
            "level": "INFO",
            "levelValue": null,
            "loggerName": "LOGSTASH",
            "message": "requestURI=/performance/search/type, keyword=Musical",
            "port": 59799,
            "stackTrace": null,
            "threadName": null,
            "keyword": "Musical"
        }
    ]
}

이제 getRecentTop10Keywords를 고도화하여 실시간 검색어 10개를 구현하는 작업만 마치면 됩니다. 실시간 검색어 알고리즘은 찾아보니 특정 시간동안 평균 or 표준편차가 큰값을 보여주는 로직을 적용하면 된다고 하는데 아직은 사용자 유입량이 없기 때문에 간단하게 키워드로 집계하여 카운트 순으로 반환하는 로직을 우선 작성후 테스트를 해보겠습니다.

@Override
    public List<RealTimeKeywordDto> getRecentTop10Keywords() {
        // 검색어로 집계하며 10순위까지만 뽑기
        TermsAggregationBuilder agg = AggregationBuilders
                .terms("keyword")
                .field("keyword.keyword")
                .size(10);
        // now_hour:00 ~ now 동안 검색어 집계
        QueryBuilder query = QueryBuilders.boolQuery()
                .must(matchQuery("message", "/performance/search"))
                .must(matchPhraseQuery("logger_name", "LOGSTASH"))
                .must(rangeQuery("@timestamp")
                        .gte(LocalDateTime.now().truncatedTo(ChronoUnit.HOURS).atZone(ZoneId.systemDefault()).toInstant().toEpochMilli())
                        .lte(LocalDateTime.now().atZone(ZoneId.systemDefault()).toInstant().toEpochMilli()));
 ... 분량이 길어져서 생략 ...
        // DTO 변환
        List<RealTimeKeywordDto> realTimeKeywords = keyword.getBuckets().stream().map(RealTimeKeywordDto::bucketToDTO).collect(Collectors.toList());
        return realTimeKeywords;
    }

출력 결과

keyword = 전북 count 1
keyword = 뮤지컬 count 2
keyword = 전ㅜ북 count 1
keyword = 전라북도 count 2

json

{
    "message": "SUCCESS",
    "status": 200,
    "data": {
        "basedTime": "2023.02.09 00:20 기준",
        "keywords": [
            {
                "keyword": "seoul",
                "count": 4
            },
            {
                "keyword": "광주",
                "count": 3
            },
            {
                "keyword": "전주",
                "count": 3
            },
            {
                "keyword": "광",
                "count": 1
            }
        ]
    }
}

페이징처리하여 검색 결과를 반환하며 겪은 이슈

요즘 대부분 앱은 페이징 처리를 하여 다량의 데이터를 프론트로 보냅니다. 페이징을 구현하며 겪은 문제중 하나가 아래와 같이 코드를 짠후 검색 요청을 했을때 페이징 사이즈를 제가 15로 커스텀해도 기본값인 10개만 뽑혀서 나오는 문제가 있었습니다. 하지만 페이징 후 결과에 페이징 사이즈는 15로 나옵니다..

 @Override
    public SearchPage<PerformanceDocument> findByPlace(String place, String sort, Pageable pageable) {
        NativeSearchQueryBuilder searchQueryBuilder = new NativeSearchQueryBuilder().withQuery(matchQuery("place", place));
        setSort(sort, searchQueryBuilder);
        NativeSearchQuery searchQuery = searchQueryBuilder.build();
        return SearchHitSupport.searchPageFor(elasticsearchOperations.search(searchQuery, PerformanceDocument.class, IndexCoordinates.of("performance*")), pageable);
    }
{
    "message": "SUCCESS",
    "status": 200,
    "data": {
        "content": [
            {
                "id": 7,
                "imageUrl": null,
                "title": "이석훈&SG워너비 동행",
                "place": "전주",
                "startDate": "2023.02.07",
                "endDate": null,
                "performanceType": "뮤지컬"
            }
          ... 생략
            {
                "id": 56,
                "imageUrl": null,
                "title": "김민수 연말콘서트",
                "place": "전주",
                "startDate": "2023.02.07",
                "endDate": null,
                "performanceType": "콘서트"
            }
        ],
        "pageable": {
            "sort": {
                "unsorted": true,
                "sorted": false,
                "empty": true
            },
            "pageNumber": 0,
            "pageSize": 15,
            "offset": 0,
            "paged": true,
            "unpaged": false
        },
        "totalPages": 33,
        "totalElements": 494,
        "last": false,
        "numberOfElements": 15,
        "size": 15,
        "sort": {
            "unsorted": true,
            "sorted": false,
            "empty": true
        },
        "first": true,
        "number": 0,
        "empty": false
    }
}

분명 사이즈는 15인데 왜 갯수가 10개지..? 싶어서 설마 쿼리에 페이징 처리를 안해서 그런가 해서 페이징 처리를 해주고 기본 코드에서 다음과 같이 코드를 추가했습니다.

searchQueryBuilder.withPageable(pageable);
... 생략...
SearchHitSupport.searchPageFor(elasticsearchOperations.search(searchQuery, PerformanceDocument.class, IndexCoordinates.of("performance*")), searchQuery.getPageable());

그리고 검색하니 요청한 페이징 사이즈에 맞게 정상적으로 값이 출력되었습니다. 앞으로도 구현하며 겪는 이슈를 기록하겠습니다..

검색 정확도 높이기

현재 nori_tokenizer만 사용했을때 검색 성능이 떨어지는 현상이 있었다.
예를 들어 "이석"을 검색해도 이석훈, 이석진 등등 비슷한 네임들을 가져올 수 있게 구현을 하고 싶었는데 형태소 분석을 해서 그 token 안에 포함되지 않으면 검색 결과에 포함되지 않았던 것이다. 실제로 "이석훈 연말 코서트" 이렇게 검색하면 결과가 잘 나왔다. 이를 어떻게 하면 좋을까 하다가 예전에 전공시간에 배웠던 ngram 방식이 생각났다. 근데 이걸 ElasticSearch 에서 지원을 하나? 찾아봤는데... 역시나 구현이 되어 있었다.. 정말 최고다. 아무튼 ngram을 적용하기 위해 세팅 파일을 다음과 같이 변경했다.

# mapping.json
{
  "properties" : {
    "id" : {"type" : "keyword"},
    "title" : {"type" : "text", "fields": {"kor":  {"type":  "text", "analyzer": "korean"}, "ngram":  {"type" : "text", "analyzer":  "my_ngram_analyzer"}}},
    "musicalDateTime" : {"type" : "keyword"},
    "place" : {"type" : "text", "fields": {"kor":  {"type":  "text", "analyzer": "korean"}, "ngram":  {"type" : "text", "analyzer":  "my_ngram_analyzer"}}},
    "imageUrl" : {"type" : "text"},
    "performanceType" : {"type" : "text", "fields": {"kor":  {"type":  "text", "analyzer": "korean"}, "ngram":  {"type" : "text", "analyzer":  "my_ngram_analyzer"}}}
  }
}


# setting.json
{
  "index" : {
  "max_ngram_diff": 5
  },
  "analysis": {
    "analyzer": {
      "korean": {
        "type": "nori"
      },
      "my_ngram_analyzer": {
        "tokenizer": "my_ngram_tokenizer"
      }
    },
    "tokenizer": {
      "my_ngram_tokenizer": {
        "type": "ngram",
        "min_gram": "2",
        "max_gram": "5"
      }
    }
  }
}

그리고 다음과 같이 검색을 했다.

[GET] {{host}}performance/search/title?query=이석&sort=startDate

{
    "message": "SUCCESS",
    "status": 200,
    "data": {
        "content": [
            {
                "id": 1,
                "imageUrl": null,
                "title": "이석훈 2021 연말 콘서트",
                "place": "서울 특별시",
                "startDate": "2022.02.10",
                "endDate": null,
                "performanceType": "뮤지컬"
            },
            {
                "id": 2,
                "imageUrl": null,
                "title": "이석훈 2021 연말 콘서트",
                "place": "전주",
                "startDate": "2022.02.10",
                "endDate": null,
                "performanceType": "콘서트"
            },
            {
                "id": 8,
                "imageUrl": null,
                "title": "이석훈",
                "place": "서울",
                "startDate": "2022.02.10",
                "endDate": null,
                "performanceType": "콘서트"
            },
            {
                "id": 9,
                "imageUrl": null,
                "title": "이석훈 2021 연말 콘서트",
                "place": "전라북도 전주",
                "startDate": "2022.02.10",
                "endDate": null,
                "performanceType": "뮤지컬"
            },
            {
                "id": 13,
                "imageUrl": null,
                "title": "이석훈 2021 연말 콘서트",
                "place": "전주",
                "startDate": "2022.02.10",
                "endDate": null,
                "performanceType": "뮤지컬"
            },
            {
                "id": 25,
                "imageUrl": null,
                "title": "이석훈&SG워너비 동행",
                "place": "전주",
                "startDate": "2022.02.10",
                "endDate": null,
                "performanceType": "뮤지컬"
            },
            {
                "id": 28,
                "imageUrl": null,
                "title": "이석훈&SG워너비 동행",
                "place": "서울 특별시",
                "startDate": "2022.02.10",
                "endDate": null,
                "performanceType": "콘서트"
            },
            {
                "id": 35,
                "imageUrl": null,
                "title": "이석훈",
                "place": "전주",
                "startDate": "2022.02.10",
                "endDate": null,
                "performanceType": "뮤지컬"
            },
            {
                "id": 37,
                "imageUrl": null,
                "title": "이석훈",
                "place": "전라북도 전주",
                "startDate": "2022.02.10",
                "endDate": null,
                "performanceType": "뮤지컬"
            },
            {
                "id": 39,
                "imageUrl": null,
                "title": "이석훈&SG워너비 동행",
                "place": "서울 특별시",
                "startDate": "2022.02.10",
                "endDate": null,
                "performanceType": "뮤지컬"
            }
        ],
        "pageable": {
            "sort": {
                "unsorted": false,
                "sorted": true,
                "empty": false
            },
            "pageSize": 10,
            "pageNumber": 0,
            "offset": 0,
            "unpaged": false,
            "paged": true
        },
        "totalPages": 76,
        "totalElements": 751,
        "last": false,
        "numberOfElements": 10,
        "size": 10,
        "sort": {
            "unsorted": false,
            "sorted": true,
            "empty": false
        },
        "number": 0,
        "first": true,
        "empty": false
    }
}

결과가 잘 나온다!!

profile
개발자로 입사했지만 정체성을 잃어가는중... 다시 준비 시작이다..

3개의 댓글

comment-user-thumbnail
2023년 9월 13일

좋은 글 감사합니다.

답글 달기
comment-user-thumbnail
2023년 9월 14일

logstatsh 다운 후 bin에서 명령어 실행할 때
./logstash -f ../config/example.conf 라고 하셨는데
conf 가 아니라 config 아닌가요?
잘 모르겠어서 댓글 남깁니다...

1개의 답글