Elasticsearch + Springboot
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/