TIL

EC2 ubuntu, Docker, Spring Boot로 elasticsearch 검색 기능 구현하기

prao 2024. 10. 4. 17:31
반응형

이번 핀테크 프로젝트 Challet 서비스에서 거래내역 검색 기능을 구현하게 되면서 elasticsearch를 적용하게 되었다.

EC2 ubuntu 환경에서 Docker를 통해 설치하는 과정부터 적용하는 과정까지 기록한다.

 

elasticsearch, kibana, logstash 설치(EC2 + docker compose)

 

GitHub - deviantony/docker-elk: The Elastic stack (ELK) powered by Docker and Compose.

The Elastic stack (ELK) powered by Docker and Compose. - deviantony/docker-elk

github.com

위의 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 관련 오류가 발생하는데, 해당 내용은 밑의 링크를 통해 해결 과정을 확인하면 된다.

 

[Error] 키바나 설정 오류 - value of "elastic" is forbidden

elk 환경을 구성하면서 .env 파일을 작성하고 docker-compose.yml을 통해서 elasticsearch가 제대로 동작하는 것을 확인하였고 kibana를 확인하는데 502 에러가 발생해서 로그를 살펴보니 아래와 같은 에러가

prao.tistory.com

에러를 해결하면 정상적으로 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);
}

 

반응형