본문 바로가기

엘라스틱 서치

스프링부트로 엘라스틱서치 쿼리 날리기

0. 개요

스프링부트로 엘라스틱서치에 접근해서 쿼리를 날려보겠습니다.

프로젝트 구조는 아래와 같습니다.

└─es
│
└─src
    └─main
      ├─java
      │  └─com
      │      └─example
      │          └─es_springboot
      │              │  EsSpringBootApplication.java
      │              │
      │              ├─config
      │              │      AbstractElasticsearchConfiguration.java
      │              │      ElasticSearchConfig.java
      │              │
      │              ├─controller
      │              │  │  UserController.java
      │              │  │
      │              │  └─dto
      │              │      ├─port_in
      │              │      │      RequestSearchConditionDto.java
      │              │      │      RequestUserSaveDto.java
      │              │      │
      │              │      └─port_out
      │              │              ResponseUserDto.java
      │              │
      │              ├─domain
      │              │  └─es
      │              │          UserDocument.java
      │              │
      │              ├─repository
      │              │  └─es
      │              │          UserSearchCriteriaQueryRepository.java
      │              │          UserSearchNativeQueryRepository.java
      │              │          UserSearchRepository.java
      │              │
      │              └─service
      │                      UserService.java
      │
      └─resources
          │  application.properties
          │
          └─elastic
                  es-mapping.json
                  es-setting.json

💡 설명을 위해 DTO와 같은 불필요한 부분을 제거할 것입니다. 자세한 프로젝트와 코드 정보를 알고 싶다면 아래에 있는 github 레포지토리 주소로 가시면 보실 수 있습니다.

💡 https://github.com/kang-seongbeom/ES-SpringBoot

 

GitHub - kang-seongbeom/ES-SpringBoot

Contribute to kang-seongbeom/ES-SpringBoot development by creating an account on GitHub.

github.com

💡 이 글은 https://ksb-dev.tistory.com/320 와 연관됩니다.

 

 

build.gradle에 아래의 의존성을 추가하셔야 합니다.

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

 

1. 리소스 파일 설정하기

먼저 리소스 파일을 설정해 보겠습니다.

1.1 application.properties

엘라스틱서치의 로그를 날길 수 있도록 설정합니다.

logging.level.org.springframework.data.elasticsearch.client.WIRE=TRACE

1.2 es-mapping.json

해당 프로젝트가 시작될 때 엘라스틱 서치에 생성할 인덱스를 정의합니다.

{
  "properties": {
    "id": {
      "type": "keyword"
    },
    "name": {
      "type": "keyword"
    },
    "age": {
      "type": "long"
    },
    "korean": {
      "type": "text",
      "analyzer": "ksb_custom_korean_analyzer"
    },
    "english": {
      "type": "text",
      "search_analyzer": "ksb_custom_english_analyzer"
    },
    "created": {
      "type": "date",
      "format": "uuuu-MM-dd'T'HH:mm:ss"
    }
  }
}

💡 기존에 해당 인덱스가 없는 경우에만 생성합니다.

1.3 es-setting.json

인덱스가 사용할 Analizer를 정의합니다.

{
  "analysis": {
    "analyzer": {
      "ksb_custom_korean_analyzer": {
        "type": "custom",
        "tokenizer": "ksb_custom_korean_tokenizer",
        "filter": [
          "ksb_custom_ngram_filter",
          "ksb_custom_korean_stop_filter"
        ]
      },
      "ksb_custom_english_analyzer": {
        "type": "custom",
        "tokenizer": "whitespace",
        "filter": [
          "lowercase",
          "snowball",
          "ksb_custom_english_stop_filter",
          "ksb_custom_english_synonym_filter",
          "unique"
        ]
      }
    },
    "tokenizer": {
      "ksb_custom_korean_tokenizer": {
        "type": "nori_tokenizer",
        "decompound_mode": "mixed"
      }
    },
    "filter": {
      "ksb_custom_ngram_filter": {
        "type": "ngram",
        "min_gram": 2,
        "max_gram": 5
      },
      "ksb_custom_korean_stop_filter": {
        "type": "nori_part_of_speech",
        "stoptags": [
          "E",
          "IC"
        ]
      },
      "ksb_custom_english_stop_filter": {
        "type": "stop",
        "stopwords_path": "settings/stop/english.txt"
      },
      "ksb_custom_english_synonym_filter": {
        "type": "synonym",
        "synonyms_path": "settings/synonym/english.txt",
        "updateable": true
      }
    }
  },
  "max_ngram_diff": "10"
}

 

2. Config

엘라스틱서치와의 통신에 필요한 설정정보를 정의합니다.

2.1 AbstractElasticsearchConfiguration

public abstract class AbstractElasticsearchConfiguration 
                extends ElasticsearchConfigurationSupport {

    @Bean
    public abstract RestHighLevelClient elasticsearchClient();

    // repository에서 불러오는 ElasticsearchOperations 빈이 이 것입니다.
    @Bean(name = { "elasticsearchOperations", "elasticsearchTemplate" })
    public ElasticsearchOperations elasticsearchOperations(ElasticsearchConverter elasticsearchConverter,
                                                           RestHighLevelClient elasticsearchClient) {
        ElasticsearchRestTemplate template = new ElasticsearchRestTemplate(elasticsearchClient, elasticsearchConverter);
        template.setRefreshPolicy(refreshPolicy());
        return template;
    }
}

2.2 ElasticSearchConfig

2.1에서 정의한 AbstractElasticsearchConfiguration을 상속받습니다.

@Configuration
@EnableElasticsearchRepositories
public class ElasticSearchConfig extends AbstractElasticsearchConfiguration {

    @Override
    public RestHighLevelClient elasticsearchClient() {
        ClientConfiguration clientConfiguration = ClientConfiguration.builder()
                .connectedTo("localhost:9200")
                .build();
        return RestClients.create(clientConfiguration).rest();
    }
}

 

3. Domain

3.1 UserDocument

1.2에서 정의한 정보를 매핑해 인덱스 생성에 사용될 수 있게 합니다.

@Document(indexName = "user_index")
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@Getter
@Setting(settingPath = "elastic/es-setting.json")
@Mapping(mappingPath = "elastic/es-mapping.json")
public class UserDocument {

    private Long id;
    private String name;
    private Long age;
    private String korean;
    private String english;

    @Field(type = FieldType.Date, format = DateFormat.custom, pattern = "uuuu-MM-dd'T'HH:mm:ss")
    private LocalDateTime created;

    public static UserDocument of(RequestUserSaveDto dto) {
        return UserDocument.builder()
                .id(dto.getId())
                .name(dto.getName())
                .age(dto.getAge())
                .korean(dto.getKorean())
                .english(dto.getEnglish())
                .created(LocalDateTime.now())
                .build();
    }
}

 

 

4. Repository

4.1 UserSeachRepository

ElasticsearchRepositoy는 JPA 기반으로 만들어졌습니다. 때문에, 네이밍 전략을 사용할 수 있습니다.

public interface UserSearchRepository extends ElasticsearchRepository<UserDocument, Long> {
    List<UserDocument> findByName(String name);
    List<UserDocument> findByKorean(String korean);
    List<UserDocument> findByEnglish(String english);
}

4.2 UserSearchCriteriaQueryRepository

Criteria 기반으로 쿼리를 날리 수 있는 동적 쿼리입니다.

@Repository
@RequiredArgsConstructor
public class UserSearchCriteriaQueryRepository {

    private final ElasticsearchOperations operations;

    public List<UserDocument> findByCriteriaCondition(RequestSearchConditionDto dto) {
        CriteriaQuery query = createConditionCriteriaQuery(dto);

        SearchHits<UserDocument> search = operations.search(query, UserDocument.class);
        return search
                .stream()
                .map(SearchHit::getContent)
                .collect(Collectors.toList());
    }

    private CriteriaQuery createConditionCriteriaQuery(RequestSearchConditionDto dto){
        CriteriaQuery query = new CriteriaQuery(new Criteria());

        if(dto == null){
            return query;
        }

        if(dto.getId() != null){
            query.addCriteria(Criteria.where("id").is(dto.getId()));
        }

        if(dto.getAge() != null && dto.getAge() > 0){
            query.addCriteria(Criteria.where("age").is(dto.getAge()));
        }

        if(StringUtils.hasText(dto.getName())){
            query.addCriteria(Criteria.where("name").is(dto.getName()));
        }

        if(StringUtils.hasText(dto.getKorean())){
            query.addCriteria(Criteria.where("korean").is(dto.getKorean()));
        }

        if(StringUtils.hasText(dto.getEnglish())){
            query.addCriteria(Criteria.where("english").is(dto.getEnglish()));
        }

        return query;
    }
}

4.3 UserSearchNativeQueryRepository

엘라스틱서치 Native기반의 동적 쿼리입니다.

@Repository
@RequiredArgsConstructor
public class UserSearchNativeQueryRepository {

    private final ElasticsearchOperations operations;

    public List<UserDocument> findByNativeCondition(RequestSearchConditionDto dto) {
        Query query = createConditionNativeQuery(dto);

        SearchHits<UserDocument> search = operations.search(query, UserDocument.class);
        return search
                .stream()
                .map(SearchHit::getContent)
                .collect(Collectors.toList());
    }

    private NativeSearchQuery createConditionNativeQuery(RequestSearchConditionDto dto) {
        NativeSearchQueryBuilder query = new NativeSearchQueryBuilder();

        // BoolQueryBuilder mustBoolQueryBuilder = boolQuery();
        BoolQueryBuilder shouldBoolQueryBuilder = boolQuery();
        BoolQueryBuilder filterBoolQueryBuilder = boolQuery();

        if(dto == null){
            return query.build();
        }

        if(dto.getId() != null){
            filterBoolQueryBuilder.filter(matchQuery("id", dto.getId()));
        }

        if(dto.getAge() != null && dto.getAge() > 0){
            shouldBoolQueryBuilder.should(matchQuery("age", dto.getAge()));
        }

        if(StringUtils.hasText(dto.getName())){
            shouldBoolQueryBuilder.should(matchQuery("name", dto.getName()));
        }

        if(StringUtils.hasText(dto.getKorean())){
            shouldBoolQueryBuilder.should(matchQuery("korean", dto.getKorean()));
        }

        if(StringUtils.hasText(dto.getEnglish())){
            shouldBoolQueryBuilder.should(matchQuery("english", dto.getEnglish()));
        }

        query.withQuery(shouldBoolQueryBuilder)
                .withFilter(filterBoolQueryBuilder)
                .withSorts(SortBuilders.fieldSort("age")
                        .order(SortOrder.DESC));

        return query.build();
    }

}

 

5. Service

5.1 UserService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class UserService {

    private final UserSearchRepository repository;
    private final UserSearchCriteriaQueryRepository criteriaQueryRepository;
    private final UserSearchNativeQueryRepository nativeQueryRepository;

    public void saveAll(List<RequestUserSaveDto> info) {
        List<UserDocument> users = info.stream()
                .map(UserDocument::of)
                .collect(Collectors.toList());

        repository.saveAll(users);
    }

    public List<ResponseUserDto> findByName(String name) {
        return transInfo(repository.findByName(name));
    }

    public List<ResponseUserDto> findByKorean(String korean) {
        return transInfo(repository.findByKorean(korean));
    }

    public List<ResponseUserDto> findByEnglish(String english) {
        return transInfo(repository.findByEnglish(english));
    }

    public List<ResponseUserDto> findByCriteriaCondition(RequestSearchConditionDto dto){
        return transInfo(criteriaQueryRepository.findByCriteriaCondition(dto));
    }

    public List<ResponseUserDto> findByNativeCondition(RequestSearchConditionDto dto){
        return transInfo(nativeQueryRepository.findByNativeCondition(dto));
    }

    private List<ResponseUserDto> transInfo(List<UserDocument> users) {
        return users.stream()
                .map(ResponseUserDto::of)
                .collect(Collectors.toList());
    }

}



6. Controller

6.1 UserController

@RestController
@RequiredArgsConstructor
public class UserController {

    private final UserService service;

    @PostMapping("/save/users")
    public ResponseEntity<Void> saveAll(@RequestBody Map<String, List<RequestUserSaveDto>> users){
        service.saveAll(users.getOrDefault("users", List.of()));
        return ResponseEntity.ok().build();
    }

    @GetMapping("/name")
    ResponseEntity<List<ResponseUserDto>> searchByName(@RequestParam String name){
        return ResponseEntity.ok(service.findByName(name));
    }

    @GetMapping("/korean")
    ResponseEntity<List<ResponseUserDto>> searchKoren(@RequestParam String korean){
        return ResponseEntity.ok(service.findByKorean(korean));
    }

    @GetMapping("/english")
    ResponseEntity<List<ResponseUserDto>> searchEnglish(@RequestParam String english){
        return ResponseEntity.ok(service.findByEnglish(english));
    }

    @PostMapping("/criteria/condition")
    ResponseEntity<List<ResponseUserDto>> searchCriteriaCondition(@RequestBody RequestSearchConditionDto dto){
        return ResponseEntity.ok(service.findByCriteriaCondition(dto));
    }

    @PostMapping("/native/condition")
    ResponseEntity<List<ResponseUserDto>> searchNativeCondition(@RequestBody RequestSearchConditionDto dto){
        return ResponseEntity.ok(service.findByNativeCondition(dto));
    }
}



7. SpringBootApplication

7.1 EsSpringBootApplication

스프링부트 프로젝트 생성시 기본으로 만들어지는 클래스입니다.

해당 클래스에 @EnableJpaRepositories가 붙어있는 것을 보실 수 있는데, 이에 대한 설명은

https://ksb-dev.tistory.com/314 에서 확인할 수 있습니다.

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

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