EC2 ubuntu, Docker, Spring Boot로 elasticsearch 검색 기능 구현하기
이번 핀테크 프로젝트 Challet 서비스에서 거래내역 검색 기능을 구현하게 되면서 elasticsearch를 적용하게 되었다.
EC2 ubuntu 환경에서 Docker를 통해 설치하는 과정부터 적용하는 과정까지 기록한다.
elasticsearch, kibana, logstash 설치(EC2 + docker compose)
위의 github에서 clone을 받는다.
그럼 내부에 .env 파일이 있는데 원하는 비밀번호로 수정한다.
나는 비밀번호를 사용하지 않으므로 따로 기입하진 않았다.
ELASTIC_VERSION=8.15.1
ELASTIC_PASSWORD=[비밀번호]
LOGSTASH_INTERNAL_PASSWORD=[비밀번호]
KIBANA_SYSTEM_PASSWORD=[비밀번호]
METRICBEAT_INTERNAL_PASSWORD=[비밀번호]
FILEBEAT_INTERNAL_PASSWORD=[비밀번호]
HEARTBEAT_INTERNAL_PASSWORD=[비밀번호]
MONITORING_INTERNAL_PASSWORD=[비밀번호]
BEATS_SYSTEM_PASSWORD=[비밀번호]
수정을 완료했으면, 함께 받은 docker-compose.yml을 사용해도 되지만, 나는 기존에 사용하던 docker-compose.yml이 있기 때문에 해당 내용을 합쳐서 사용할 예정이다.
version: '3'
services:
# Setup service to initialize users and roles
setup:
profiles:
- setup
build:
context: setup/
args:
ELASTIC_VERSION: ${ELASTIC_VERSION}
init: true
volumes:
- ./setup/entrypoint.sh:/entrypoint.sh:ro,Z
- ./setup/lib.sh:/lib.sh:ro,Z
- ./setup/roles:/roles:ro,Z
networks:
- ${NETWORK_NAME}
depends_on:
- elasticsearch
- kibana
- logstash
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:${ELASTIC_VERSION}
container_name: elasticsearch
environment:
TZ: ${TZ}
discovery.type: single-node
ES_JAVA_OPTS: '-Xms512m -Xmx512m'
volumes:
- /home/ubuntu/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml
- /home/ubuntu/elasticsearch_data:/usr/share/elasticsearch/data
networks:
- ${NETWORK_NAME}
restart: unless-stopped
ports:
- "${ELASTICSEARCH_PORT_9200}:9200"
- "${ELASTICSEARCH_PORT_9300}:9300"
kibana:
image: docker.elastic.co/kibana/kibana:${ELASTIC_VERSION}
container_name: kibana
environment:
TZ: ${TZ}
ELASTICSEARCH_HOSTS: http://elasticsearch:${ELASTICSEARCH_PORT_9200}
ELASTICSEARCH_SERVICE_TOKEN: ${ELASTICSEARCH_SERVICE_TOKEN}
NODE_OPTIONS: "--no-openssl-legacy-provider"
volumes:
- /home/ubuntu/kibana/config/kibana.yml:/usr/share/kibana/config/kibana.yml
networks:
- ${NETWORK_NAME}
restart: unless-stopped
logstash:
image: docker.elastic.co/logstash/logstash:${ELASTIC_VERSION}
container_name: logstash
environment:
TZ: ${TZ}
volumes:
- /home/ubuntu/logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml
networks:
- ${NETWORK_NAME}
restart: unless-stopped
ports:
- "${LOGSTASH_PORT}:5044"
networks:
ubuntu_default:
external: true
volumes:
elasticsearch_data:
그리고 kibana 관련 오류가 발생하는데, 해당 내용은 밑의 링크를 통해 해결 과정을 확인하면 된다.
에러를 해결하면 정상적으로 elasticsearch, kibana, logstash가 설치 및 설정된 것을 확인할 수 있고 이제 Spring Boot 환경에서 검색 기능을 구현해보자.
Spring Boot + elasticsearch
우선 build.gradle에 spring data elasticsearch 의존성을 추가한다.
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
그리고 application.yml에 spring.elasticsearch.uris를 설정한다.
나는 challet.world에 9200 포트로 열어뒀기 때문에 해당 포트로 할당하였다.
spring:
elasticsearch:
uris: "challet.world:9200"
이제 ElasticsearchConfig를 설정하여 scan할 repository를 설정한다.
이때 유의해야할 점은 기존에 jpa의 repository와 elasticsearch의 repository를 구분해야 한다.
@Configuration
@EnableElasticsearchRepositories(basePackages = "com.challet.kbbankservice.domain.elasticsearch.repository")
public class ElasticsearchConfig extends ElasticsearchConfiguration {
@Value("${spring.elasticsearch.uris}")
private String[] esHost;
@Override
public ClientConfiguration clientConfiguration() {
return ClientConfiguration.builder()
.connectedTo(esHost)
.build();
}
}
그리고 나는 Elasticsearch에 저장할 Document 용도의 record를 따로 생성하였다. 기존에 사용하던 dto를 사용하려니 순환 참조 오류가 발생하여 필요한 데이터만 따로 빼고 필드 설정을 하여 만들었고, Service 계층에서 거래내역을 생성할 때 기존의 RDB와 Elasticsearch에 함께 save를 할 수 있다. 이렇게 사용하는 이유는 elasticsearch에는 말 그대로 검색을 했을 때 조회될 데이터만을 저장하고, 수정 및 변경은 일어나지 않으며 조회 용도로만 사용하기 때문에 빠른 속도를 자랑한다.
@Builder
@Document(indexName = "kb_bank_transaction")
@Schema(description = "국민은행 거래내역 검색")
public record SearchedTransaction(
@Id
@Schema(description = "거래내역 ID")
String transactionId,
@Field(type = FieldType.Keyword)
@Schema(description = "계좌 ID")
Long accountId,
@Field(type = FieldType.Date, format = DateFormat.date_time, pattern = "uuuu-MM-dd'T'HH:mm:ss")
@Schema(description = "거래 날짜 시간")
LocalDateTime transactionDate,
@Field(type = FieldType.Text)
@Schema(description = "입금처")
String deposit,
@Field(type = FieldType.Long)
@Schema(description = "거래 후 잔액")
Long transactionBalance,
@Field(type = FieldType.Long)
@Schema(description = "거래 금액")
Long transactionAmount
) {
public static SearchedTransaction fromAccountIdAndKbBankTransaction(final Long accountId, final KbBankTransaction transaction) {
return SearchedTransaction.builder()
.transactionId(String.valueOf(transaction.getId()))
.accountId(accountId)
.transactionDate(transaction.getTransactionDatetime())
.deposit(transaction.getDeposit())
.transactionBalance(transaction.getTransactionBalance())
.transactionAmount(transaction.getTransactionAmount())
.build();
}
}
이제 document를 생성하였으니 repository를 생성하자.
public interface SearchedTransactionRepository extends ElasticsearchRepository<SearchedTransaction, String> {
Page<SearchedTransaction> findByAccountId(Long accountId, Pageable pageable);
Page<SearchedTransaction> findByAccountIdAndDepositContaining(Long accountId, String deposit,
Pageable pageable);
}
그리고 거래내역을 생성하는 결제 로직에 elasticsearch에 저장하는 로직을 추가한다.
@Transactional
@Override
public PaymentResponseDTO qrPayment(Long accountId, PaymentRequestDTO paymentRequestDTO) {
KbBank kbBank = kbBankRepository.findById(accountId)
.orElseThrow(() -> new ExceptionResponse(CustomException.ACCOUNT_NOT_FOUND_EXCEPTION));
long transactionBalance = calculateTransactionBalance(kbBank,
paymentRequestDTO.transactionAmount());
KbBankTransaction paymentTransaction = createTransaction(kbBank, paymentRequestDTO,
transactionBalance);
kbBank.addTransaction(paymentTransaction);
kbBankTransactionRepository.save(paymentTransaction);
searchedTransactionRepository.save(SearchedTransaction.fromAccountIdAndKbBankTransaction(accountId, paymentTransaction));
return PaymentResponseDTO.fromPaymentResponseDTO(paymentTransaction);
}
이렇게 하면 elasticsearch에도 함께 저장이 된다. 그리고 이렇게 저장된 데이터를 조회하기 위한 DTO는 아래와 같다.
@Builder
public record SearchedTransactionResponseDTO(int count, boolean isLastPage, List<SearchedTransaction> searchedTransactions) {
public static SearchedTransactionResponseDTO fromSearchedTransaction(List<SearchedTransaction> searchedTransactions, boolean isLastPage) {
return SearchedTransactionResponseDTO.builder()
.count(searchedTransactions.size())
.isLastPage(isLastPage)
.searchedTransactions(searchedTransactions)
.build();
}
}
우리는 데이터를 자바의 Pageable을 이용하여 Pagination 하였기 때문에 마지막 페이지인지 확인하는 정보를 함께 반환한다.
그리고 이제 이 DTO를 사용하는 Service 계층의 메서드는 아래와 같다.
@Override
public SearchedTransactionResponseDTO searchTransaction(
SearchTransactionRequestDTO searchTransactionRequestDTO) {
Pageable pageable = PageRequest.of(searchTransactionRequestDTO.page(),
searchTransactionRequestDTO.size());
Page<SearchedTransaction> searchedTransactions = getResult(searchTransactionRequestDTO,
pageable);
boolean isLastPage = searchedTransactions.isLast();
return SearchedTransactionResponseDTO.fromSearchedTransaction(
searchedTransactions.getContent(), isLastPage);
}
private Page<SearchedTransaction> getResult(
SearchTransactionRequestDTO searchTransactionRequestDTO, Pageable pageable) {
if (searchTransactionRequestDTO.deposit() != null) {
return searchedTransactionRepository.findByAccountIdAndDepositContaining(
searchTransactionRequestDTO.accountId(),
searchTransactionRequestDTO.deposit(), pageable);
}
return searchedTransactionRepository.findByAccountId(
searchTransactionRequestDTO.accountId(), pageable);
}
이제 이렇게 작성한 서비스 계층의 메서드를 아래와 같이 컨트롤러를 통해 RESTful API 통신을 하면 저장부터 검색까지 완성된다.
@GetMapping("/search")
public ResponseEntity<SearchedTransactionResponseDTO> searchTransactions(
@RequestHeader("Authorization") String header,
@RequestParam Long accountId,
@RequestParam(required = false) String deposit,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
if (kbBankService.getAccountsByPhoneNumber(header).accountCount() == 0) {
return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
}
SearchTransactionRequestDTO searchTransactionRequestDTO = SearchTransactionRequestDTO.of(
accountId, deposit, page, size);
SearchedTransactionResponseDTO searchedTransactionResponseDTO = kbBankService.searchTransaction(
searchTransactionRequestDTO);
return ResponseEntity.ok(searchedTransactionResponseDTO);
}