-
Elasticsearch + Springboot🔍 elastic search 2022. 9. 27. 15:34
Docker-compose로 Elasticsearch 설치
compose 파일 생성
vi elasticsearch.yml
version: '3.7' services: es: image: docker.elastic.co/elasticsearch/elasticsearch:7.15.2 container_name: elasticsearch environment: - node.name=single-node - cluster.name=beomsic - 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 기본 호스트는 <http://elasticsearch:9200> # 현재 docker-compose 파일에 Elasticsearch 서비스 명 es로 설정 ELASTICSEARCH_HOSTS: <http://elasticsearch:9200> ports: - 5601:5601 # Elasticsearch Start Dependency depends_on: - es networks: - es-bridge networks: es-bridge: driver: bridge
실행 하기
# 실행, 데몬으로 띄우려면 맨 뒤에 -d를 붙여준다. # 기본 실행 도커파일은 docker-compose.yml인데 # elasticsearch.yml로 만들었으므로 지정해주기 위해서 -f 옵션을 사용 docker-compose -f elasticsearch.yml up # 죽이기 docker-compose -f elasticsearch.yml down
9200 포트는 HTTP 클라이언트 와 통신에 사용
9300 포트는 노드들간 통신 에 사용
이번엔 간단하게 구성하기 위해 volumne 매핑등 많은 부분을 안 함. → 자세한 내용 공식문서 확인
nori 형태소 분석기 추가하기
Dockerfile을 따로 만들어주고 elasticsearch.yml에 이미지로 추가
# dockerfile 생성 vi Dockerfile # dockerfile 작성 ARG ELK_VERSION FROM docker.elastic.co/elasticsearch/elasticsearch:${ELK_VERSION} RUN elasticsearch-plugin install analysis-nori
# elasticsearch.yml 파일 수정 version: '3.7' services: es: build: # 도커파일의 위치 알려주기 context: . # 인자 넣어주기 args: ELK_VERSION: 7.15.2 container_name: elasticsearch environment: - node.name=single-node - cluster.name=beomsic - 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://elasticsearch:9200> ports: - 5601:5601 depends_on: - es networks: - es-bridge networks: es-bridge: driver: bridge
Spring boot 설정
build.gradle
dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch' }
Elasticsearch를 사용하기 위해 추가
yml 파일
elasticsearch: host: localhost port: 9200
- 로컬에서 테스트 해볼 예정
코드 작성
AbstractElasticsearchConfig.java
public abstract class AbstractElasticsearchConfig extends ElasticsearchConfigurationSupport { @Bean public abstract RestHighLevelClient elasticsearchClient(); @Bean(name = {"elasticsearchOperations", "elasticsearchTemplate"}) public ElasticsearchOperations elasticsearchOperations( ElasticsearchConverter elasticsearchConverter, RestHighLevelClient elasticsearchClient) { ElasticsearchRestTemplate elasticsearchRestTemplate = new ElasticsearchRestTemplate( elasticsearchClient, elasticsearchConverter); elasticsearchRestTemplate.setRefreshPolicy(refreshPolicy()); return elasticsearchRestTemplate; } }
Elasticsearch 관련 작업을 할 때, 주로 ElasticsearchOperations 인터페이스의 구현체를 사용.
- AbstractElasticsearchConfig 클래스에서 ElasticsearchOperations을 Bean 으로 등록
elasticsearchClient 추상 메서드로 등록되어 있어 상속받아 구현하여 빈으로 등록을 해준다.
ElasticsearchProperties.java
@Component public class ElasticsearchProperties { @Value("${elasticsearch.host}") private String host; @Value("${elasticsearch.port}") private Integer port; public HttpHost httpHost() { return new HttpHost(host, port, "http"); } }
- Elasticsearch 을 연결하기 위한 필요한 데이터들을 설정해주는 파일
ElasticsearchConfig.java
@Configuration @EnableElasticsearchRepositories public class ElasticsearchConfig extends AbstractElasticsearchConfig { private final ElasticsearchProperties elasticsearchProperties; public ElasticsearchConfig(ElasticsearchProperties elasticsearchProperties) { this.elasticsearchProperties = elasticsearchProperties; } @Override public RestHighLevelClient elasticsearchClient() { return new RestHighLevelClient(RestClient.builder(elasticsearchProperties.httpHost())); } }
Client는 기본적으로 High Level REST Client 를 사용한다.
ElasticsearchProperties에서 가져온 host:port 에 떠있는 Elasticsearch와 연결
→ ElasticsearchClient를 사용할 수 있게 되었다.
실제로 사용할 때는 ElasticsearchOperations 또는 ElasticsearchRepository를 사용
User.java / UserDocument.java
@Entity @Getter @Builder @AllArgsConstructor @NoArgsConstructor(access = AccessLevel.PROTECTED) public class User extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String nickname; private int age; private String description; }
@Document(indexName = "user") @Mapping(mappingPath = "elastic/user-mapping.json") @Setting(settingPath = "elastic/user-setting.json") @Getter @Builder public class UserDocument { @Id private Long id; private String name; private String nickname; private int age; private String description; @Field(type = FieldType.Date, format = {DateFormat.date_hour_minute_second_millis, DateFormat.epoch_millis}) private LocalDateTime createdAt; }
User 엔티티와 ES에 매핑할 UserDocument 클래스를 따로 만들었다.
- 클래스 하나에 Entity와 ES를 매핑할 수 있지만 JPA repository와 ES repository를 사용할 때 문제가 생긴다.
해결 방법
- @EnableJpaRepository 와 @EnableElasticsearchRepositories 의 속성을 사용
- 어떤 것은 JPA에만 어떤 것은 ES에만 적용되도록 별도의 세팅을 한다.
- 이런 해결방법은 새로운 Repository를 추가할 때마다 코드가 추가되어 매우 힘들다.
ES에 데이터 타입을 매핑하는 2가지 방법
- @Field 를 사용 - 간단한 경우
- @Setting, @Mapping을 사용 - 복잡한 경우
@Field
@Field(type = FieldType.Date, format = {DateFormat.date_hour_minute_second_millis, DateFormat.epoch_millis}) private LocalDateTime createdAt;
@Setting, @Mapping
복잡한 경우 resource 부분에 json 파일을 만들어 사용한다.
// resource/elastic/user-setting.json // 노리 분석기 정의 { "analysis" : { "analyzer" : { "korean" : { "type" : "nori" } } } }
// resource/elastic/user-mapping.json // 데이터 타입을 정의 // nori 분석기를 사용할 곳에는 분석기도 등록 { "properties" : { "id" : {"type" : "keyword"}, "age" : {"type" : "keyword"}, "name" : {"type" : "keyword"}, "nickname" : {"type" : "text"}, "description" : { "type" : "text", "analyzer" : "korean" }, "createdAt" : { "type" : "date", "format" : "yyyy-MM-dd'T'HH:mm:ss.SSS||epoch_millis" } } }
UserSearchRepository.java
public interface UserSearchRepository extends ElasticsearchRepository<UserDocument, Long> { List<UserDocument> findByAge(int age); Page<UserDocument> findByNickname(String nickname, Pageable pageable); }
ElasicsearchRepository 인터페이스를 확장해 정의
UserSearchQueryRepository.java
@Repository public class UserSearchQueryRepository { private final ElasticsearchOperations operations; public UserSearchQueryRepository( ElasticsearchOperations operations) { this.operations = operations; } public List<UserDocument> findByCondition(SearchCondition searchCondition, Pageable pageable) { CriteriaQuery query = createConditionCriteriaQuery(searchCondition) .setPageable(pageable); SearchHits<UserDocument> search = operations.search(query, UserDocument.class); return search.stream() .map(SearchHit::getContent) .collect(Collectors.toList()); } public List<UserDocument> findByStartWithNickname(String nickname, Pageable pageable) { Criteria criteria = Criteria.where("nickname").startsWith(nickname); Query query = new CriteriaQuery(criteria).setPageable(pageable); SearchHits<UserDocument> search = operations.search(query, UserDocument.class); return search.stream() .map(SearchHit::getContent) .collect(Collectors.toList()); } public List<UserDocument> findByMatchedDescription(String description, Pageable pageable) { Criteria criteria = Criteria.where("description").matches(description); Query query = new CriteriaQuery(criteria).setPageable(pageable); SearchHits<UserDocument> search = operations.search(query, UserDocument.class); return search.stream() .map(SearchHit::getContent) .collect(Collectors.toList()); } public List<UserDocument> findByContainsDescription(String description, Pageable pageable) { Criteria criteria = Criteria.where("description").contains(description); Query query = new CriteriaQuery(criteria).setPageable(pageable); SearchHits<UserDocument> search = operations.search(query, UserDocument.class); return search.stream() .map(SearchHit::getContent) .collect(Collectors.toList()); } private CriteriaQuery createConditionCriteriaQuery(SearchCondition searchCondition) { CriteriaQuery query = new CriteriaQuery(new Criteria()); if (searchCondition == null) return query; if (searchCondition.getId() != null) query.addCriteria(Criteria.where("id").is(searchCondition.getId())); if(searchCondition.getAge() > 0) query.addCriteria(Criteria.where("age").is(searchCondition.getAge())); if(StringUtils.hasText(searchCondition.getName())) query.addCriteria(Criteria.where("name").is(searchCondition.getName())); if(StringUtils.hasText(searchCondition.getNickname())) query.addCriteria(Criteria.where("nickname").is(searchCondition.getNickname())); return query; } }
QueryDSL을 사용할 때 처럼 복잡한 쿼리를 처리해야 하는 경우 리포지토리를 분리했다.
검색 조건에 따라서 동적 쿼리를 만들어 처리
UserService.java
@Service @Transactional(readOnly = true) public class UserService { private final UserRepository userRepository; private final UserSearchRepository userSearchRepository; private final UserSearchQueryRepository userSearchQueryRepository; public UserService( UserRepository userRepository, UserSearchRepository userSearchRepository, UserSearchQueryRepository userSearchQueryRepository) { this.userRepository = userRepository; this.userSearchRepository = userSearchRepository; this.userSearchQueryRepository = userSearchQueryRepository; } @Transactional public void saveAllUser(SaveAllUserRequest userRequest) { List<SaveUserRequest> userRequestList = userRequest.getUserRequestList(); System.out.println(userRequestList.size()); List<User> userList = userRequest.getUserRequestList() .stream() .map(UserConverter::to) .collect(Collectors.toList()); userRepository.saveAll(userList); System.out.println("finish"); } @Transactional public void saveAllUserDocuments() { List<UserDocument> userDocumentList = userRepository .findAll() .stream() .map(UserConverter::from) .collect(Collectors.toList()); System.out.println("done1"); userSearchRepository.saveAll(userDocumentList); } public Page<UserResponse> findAllByNickname(String nickname, Pageable pageable) { return userSearchRepository.findByNickname(nickname, pageable) .map(UserConverter::toUserResponse); } public List<UserResponse> findAllByAge(int age) { return userSearchRepository.findByAge(age) .stream() .map(UserConverter::toUserResponse) .collect(Collectors.toList()); } public List<UserResponse> searchByCondition(SearchCondition searchCondition, Pageable pageable) { return userSearchQueryRepository.findByCondition(searchCondition, pageable) .stream() .map(UserConverter::toUserResponse) .collect(Collectors.toList()); } public List<UserResponse> findAllByDescription(String description, Pageable pageable) { return userSearchQueryRepository.findByMatchedDescription(description, pageable) .stream() .map(UserConverter::toUserResponse) .collect(Collectors.toList()); } public List<UserResponse> findAllByContainedDescription(String description, Pageable pageable) { return userSearchQueryRepository.findByContainsDescription(description, pageable) .stream() .map(UserConverter::toUserResponse) .collect(Collectors.toList()); } }
saveAllUser 메서드는 DB에 유저 정보를 저장하는 메서드,
saveAllUserDocuments 메서드는 DB에서 유저 정보를 꺼내 Document로 변환해서 Elasticsearch에 저장하는 메서드이다.
변환하는 과정은 UserConverter라는 클래스를 따로 만들어 처리했다.
UserConverter
public class UserConverter { public static UserDocument from(User user) { return UserDocument.builder() .id(user.getId()) .name(user.getName()) .nickname(user.getNickname()) .age(user.getAge()) .description(user.getDescription()) .createdAt(user.getCreatedAt()) .build(); } public static User to(SaveUserRequest request) { return User.builder() .name(request.getName()) .nickname(request.getNickname()) .age(request.getAge()) .description(request.getDescription()) .build(); } public static UserResponse toUserResponse(UserDocument userDocument) { return UserResponse.builder() .name(userDocument.getName()) .nickname(userDocument.getNickname()) .age(userDocument.getAge()) .description(userDocument.getDescription()) .build(); } }
UserController.java
@RestController @RequestMapping("/api/v1/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @PostMapping() public ResponseEntity<Void> saveAll(@RequestBody SaveAllUserRequest userRequest) { userService.saveAllUser(userRequest); return ResponseEntity.ok().build(); } @PostMapping("/Documents") public ResponseEntity<Void> saveAllUserDocuments() { userService.saveAllUserDocuments(); System.out.println("done2"); return ResponseEntity.ok().build(); } @GetMapping() public ResponseEntity<List<UserResponse>> searchByName( SearchCondition searchCondition, @PageableDefault(page = 0, size = 10) Pageable pageable) { List<UserResponse> userList = userService.searchByCondition(searchCondition, pageable); return ResponseEntity.ok(userList); } @GetMapping("/age") public ResponseEntity<List<UserResponse>> getByAge(@RequestParam int age) { List<UserResponse> userList = userService.findAllByAge(age); return ResponseEntity.ok(userList); } @GetMapping("/nickname") public ResponseEntity<Page<UserResponse>> getByNickname( @RequestParam String nickname, @PageableDefault(page = 0, size = 10) Pageable pageable) { Page<UserResponse> userList = userService.findAllByNickname(nickname, pageable); return ResponseEntity.ok(userList); } @GetMapping("/matchedDescription") public ResponseEntity<List<UserResponse>> getByMatchedDescription( @RequestParam String description, @PageableDefault(page = 0, size = 10) Pageable pageable) { List<UserResponse> userList = userService.findAllByDescription(description, pageable); return ResponseEntity.ok(userList); } @GetMapping("/containedDescription") public ResponseEntity<List<UserResponse>> getByContainedDescription( @RequestParam String description, @PageableDefault(page = 0, size = 10) Pageable pageable) { List<UserResponse> userList = userService.findAllByContainedDescription(description, pageable); return ResponseEntity.ok(userList); } }
실행 시키기
IntelliJ에서 제공하는 .http 를 사용
미리 저장할 유저 정보들을 JSON 파일로 만들어 사용
User.json
{ "userRequestList": [ { "name": "고범석1", "nickname": "beomsic1", "age": 26, "description": "안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 홍길도입니다. 만나서 반갑습니다." }, { "name": "고범석1", "nickname": "beomsic1", "age": 26, "description": "안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 홍길도입니다. 만나서 반갑습니다." }, { "name": "고범석1", "nickname": "beomsic1", "age": 26, "description": "안녕하세요 안녕하세요 안녕하세요 안녕하세요 안녕하세요 고범석입니다. 만나서 반갑습니다." }, { "name": "고범석2", "nickname": "beomsic2", "age": 26, "description": "안녕하세요 안녕하세요 고범석입니다. 만나서 반갑습니다." }, { "name": "고범석2", "nickname": "beomsic2", "age": 26, "description": "안녕하세요 고범석입니다. 만나서 반갑습니다." }, { "name": "고범석3", "nickname": "beomsic3", "age": 26, "description": "안녕하세요 고범석입니다. 만나서 반갑습니다." }, { "name": "고범석4", "nickname": "beomsic4", "age": 26, "description": "안녕하세요 고범석입니다. 만나서 반갑습니다." }, { "name": "고범석4", "nickname": "beomsic4", "age": 26, "description": "안녕하세요 고범석입니다. 만나서 반갑습니다." }, { "name": "고범석4", "nickname": "beomsic4", "age": 26, "description": "안녕하세요 고범석입니다. 만나서 반갑습니다." }, { "name": "고범석4", "nickname": "beomsic4", "age": 25, "description": "안녕하세요 고범석입니다. 만나서 반갑습니다." }, { "name": "고범석4", "nickname": "beomsic4", "age": 25, "description": "안녕하세요 고범석입니다. 만나서 반갑습니다." }, { "name": "고범석4", "nickname": "beomsic4", "age": 26, "description": "안녕하세요" } ] }
User.http
# 엔티티(유저 리스트) 저장 POST <http://localhost:8080/api/v1/users> Content-Type: application/json < ./User.json ### # 엔티티 document로 전환해서 ES에 저장 (user -> userDocument) POST <http://localhost:8080/api/v1/users/Documents> ### # 닉네임 검색 GET <http://localhost:8080/api/v1/users/nickname?nickname=beomsic1&size=10> ### # 나이 검색 GET <http://localhost:8080/api/v1/users/age?age=26&size=10> ### # 조건 검색 GET <http://localhost:8080/api/v1/users?id=1&name=고범석1&nickname=beomsic1&age=26&size=10> ### # 일부 조건만 넣어 검색 GET <http://localhost:8080/api/v1/users?nickname=beomsic1&age=26&size=10> ### # nickname으로 시작하는 것들 찾기 GET <http://localhost:8080/api/v1/users/nickname/startwith?nickname=beomsic1&size=10> ### # description 매치되는 것 찾기 GET <http://localhost:8080/api/v1/users/matchedDescription?description=안녕하세요&size=10> ### # 포함하는 것 찾기 # 노리 분석기가 안녕하세요를 [안녕,하,시,어요] 로 토큰화 하기 때문에 안녕하세요는 contain으로 찾을 수 없다. GET <http://localhost:8080/api/v1/users/containedDescription?description=안녕&size=10>
‘###’ 는 각 요청을 구분해주는 역할
< 를 통해서 json 파일을 지정해 전송할 수 있다.
먼저 2개의 POST 요청을 보내 데이터를 추가
- localhost:5601 로 들어가 키바나에 접속
- Discover를 클릭하고 create index pattern을 클릭해
- Name에 user를 입력 → user가 index로 등록되어 있는 것이 보인다.
- 코드에서 @Document(indexName = “user”)
- create
- 다시 discover 클릭시 데이터가 잘 들어가 있는 것을 확인할 수 있다.
다른 GET 요청을 보내 데이터가 제대로 나오는지 확인
테스트 코드 짜기
테스트 코드를 위해 다른 TEST 용 Elasticsearch가 필요하다
- TestContainer를 사용
build.gradle
testImplementation "org.testcontainers:elasticsearch"1.16.3"
ElasticTestContainer.java
// 기존에 정의한 config를 커스터마이징하고자 할 때 사용 // 테스트가 실행될 때 정의된 빈을 생성하여 등록 @TestConfiguration // ES 관련 repository 등록 @EnableElasticsearchRepositories(basePackageClasses = { UserSearchRepository.class, UserSearchQueryRepository.class }) public class ElasticSearchTestContainer extends AbstractElasticsearchConfig { private static final String ELASTICSEARCH_VERSION = "7.15.2"; private static final DockerImageName ELASTICSEARCH_IMAGE = DockerImageName .parse("docker.elastic.co/elasticsearch/elasticsearch") .withTag(ELASTICSEARCH_VERSION); private static final ElasticsearchContainer container; // test container 띄우기 static { container = new ElasticsearchContainer(ELASTICSEARCH_IMAGE); container.start(); } // ES Client 재정의 @Override public RestHighLevelClient elasticsearchClient() { ClientConfiguration clientConfiguration = ClientConfiguration.builder() .connectedTo(container.getHttpHostAddress()) .build(); return RestClients.create(clientConfiguration).rest(); } }
노리 분석기 사용
// build.gradle // testImplementation "org.testcontainers:elasticsearch:1.16.3" testImplementation("org.testcontainers:junit-jupiter:1.16.3"
ES testContainer에 Nori 분석기를 설치해주어야 한다.
- ElasticsearchContainer를 이용해서는 할 수 없다.
- 따라서 이를 해결하기 위해 상위 클래스인 GenericContainer를 사용
ES container 의존성이 필요 없다.
ElasticSearchTestContainerWithNori.java
// Nori 분석시 사용하려고 할 시 ES container에 Nori 분석기도 설치 @TestConfiguration @EnableElasticsearchRepositories(basePackageClasses = { UserSearchRepository.class, UserSearchQueryRepository.class }) public class ElasticSearchTestContainerWithNori extends AbstractElasticsearchConfig { private static final GenericContainer genericContainer; static { genericContainer = new GenericContainer( new ImageFromDockerfile() .withDockerfileFromBuilder(dockerfileBuilder -> { dockerfileBuilder .from("docker.elastic.co/elasticsearch/elasticsearch:7.15.2") // es 이미지 가져오기 .run("bin/elasticsearch-plugin install analysis-nori") // nori 분석기 설치 .build(); }) ) .withExposedPorts(9200, 9300) // 기본 포트 설정 .withEnv("discovery.type", "single-node"); // ES 가 싱글노드로 돌아가도록 설정 genericContainer.start(); } @Override public RestHighLevelClient elasticsearchClient() { // ES container에서 제공해주던 httpHostAddress를 사용할 수 없어 직접 만들어줘야 한다. String hostAddress = new StringBuilder() .append(genericContainer.getHost()) .append(":") .append(genericContainer.getMappedPort(9200)) .toString(); ClientConfiguration clientConfiguration = ClientConfiguration.builder() .connectedTo(hostAddress) .build(); return RestClients.create(clientConfiguration).rest(); } }
참고
https://backtony.github.io/spring/elk/2022-03-02-spring-elasticsearch-2/
https://docs.spring.io/spring-data/elasticsearch/docs/current/reference/html/#reference
https://veluxer62.github.io/tutorials/spring-data-elasticsearch-test-with-test-container/
'🔍 elastic search' 카테고리의 다른 글
ES - Aggregations (0) 2022.09.22 ES - 텍스트 분석 (0) 2022.09.22 ES - 데이터 검색 (1) 2022.09.21 ES - 기본 API (0) 2022.09.21 Docker로 ES 설치하기 (1) 2022.09.21