이번 핀테크 프로젝트 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);
}
'TIL' 카테고리의 다른 글
[TIL-58/240615] 기술면접(자바) (1) | 2024.06.15 |
---|---|
[TIL-57/240613] Spring 계층 구조(레이어 아키텍처), Web Server & WAS & Web Service (0) | 2024.06.13 |
[TIL-56/240416] REST API (0) | 2024.04.17 |
[TIL-55/240412] MyBatis-Spring, Dynamic SQL (0) | 2024.04.13 |
[TIL-54/240408] MyBatis (0) | 2024.04.09 |