이 글은 ElasticSearch, ELK 등등에 대한 지식이 전혀 없는 상태에서 시작하는 글입니다.
좀 더 나은 방법이나 잘못된 부분이 있다면 지적해주시면 감사하겠습니다.

기본적인 ES 지식

내가 MySQL을 주로 다뤄왔기 때문에 RDB와 비교해보는 식으로 알아보겠다.

참고: https://ghkdqhrbals.github.io/portfolios/docs/elasticSearch/2022-12-31-elastic-search/

간단한 쿼리 알아보기

https://esbook.kimjmin.net/04-data

Elasticsearch를 연동하기 전에 위 링크에서 다음 항목들을 Postman을 통해서 한 번씩 적용해보고 익숙해지자.

  1. Elasticsearch 데이터 처리
  2. 검색과 쿼리 - Query DSL

RDB와 비교

Type의 경우 우리는 8.10.4 버전을 사용하므로 신경쓰지 말자.
Table이 없다고 생각할 수 있지만 인덱스별로 도큐먼트 타입을 사용할 것이다.

지금은 이렇구나 하고 넘어가고 밑에서 다루는 ES(ElasticSearch) Document 생성 부분을 본다면 대략적으로 이해가 될 것이다.

ES의 경우 RestAPI를 통해서 데이터를 조회하고 저장하고 수정한다.
예를 들어 다음과 같이 조회할 수 있다.
GET http://<호스트>:<포트>/<인덱스>/_doc/<도큐먼트 id>
https://esbook.kimjmin.net/04-data/4.2-crud
이 문서를 참고해보면 감을 잡을 수 있을 것이다.

ElasticSearchs는 Json 문서를 통해 검색을 수행하므로 스키마가 없지만, RDB는 엄격한 스키마가 있다.


ElasticSearch는 DB없이 단일 저장소로 사용하지 않고,
주로 DB는 놔둔채로 검색을 위해 따로 데이터를 저장해서 사용한다거나,
ELK 스택을 통해서 로그를 한 곳으로 모으고, 원하는 데이터를 빠르게 검색한 뒤 시각화하는 작업을 한다.

역색인(Inverted Index)

ES의 가장 큰 특징은 역색인이다.
저장되는 데이터를 토큰화하고 각각의 토큰이 등장한 Document를 기록해 놓는 것이다.
이런 구조로 저장하기 때문에 저장시에 토큰화 등으로 인한 지연이 있을 수 있지만 조회시에 굉장한 성능을 가질 수 있다.

RDB에서는 String을 검색할 때 전체 데이터를 풀스캔을 하는데 비해
ES에서는 빠른 속도로 조회할 수 있는 것이다.

프로젝트 시작

lombok, elasticsearch, spring web, jpa 정도를 의존성으로 추가하고 프로젝트를 시작하자.

설정 구성

resources 폴더 하위에 다음 파일들을 추가해주자.

├── .env

├── docker-compose.yml

└── application.yml

.env

ElasticSearch 8.10.4로 진행한다

# Password for the 'elastic' user (at least 6 characters)
ELASTIC_PASSWORD=changeme

# Password for the 'kibana_system' user (at least 6 characters)
KIBANA_PASSWORD=changeme

# Version of Elastic products
STACK_VERSION=8.10.4

# Set the cluster name
CLUSTER_NAME=docker-cluster

# Set to 'basic' or 'trial' to automatically start the 30-day trial
LICENSE=basic
#LICENSE=trial

# Port to expose Elasticsearch HTTP API to the host
ES_PORT=9200

docker-compose.yml

version: "3.8"

volumes:
  esdata01:
    driver: local

networks:
  default:
    name: elastic
    external: false

services:
  es01:
    image: docker.elastic.co/elasticsearch/elasticsearch:${STACK_VERSION} # Elasticsearch 이미지 사용
    labels:
      co.elastic.logs/module: elasticsearch # 로그 모듈로 elasticsearch 사용
    volumes:
      - esdata01:/usr/share/elasticsearch/data # 데이터 디렉토리를 컨테이너에 마운트
    ports:
      - ${ES_PORT}:9200
    environment: # 환경 변수 설정
      - node.name=es01 # 노드 이름 설정
      - cluster.name=${CLUSTER_NAME} # 클러스터 이름 설정
      - discovery.type=single-node # 단일 노드 모드 설정
      - ELASTIC_PASSWORD=${ELASTIC_PASSWORD} # Elastic 사용자 비밀번호 설정
      - bootstrap.memory_lock=true # 메모리 잠금 설정
      - xpack.security.enabled=true # X-Pack 보안 활성화
      - xpack.security.http.ssl.enabled=false # HTTP SSL 비활성화
      - xpack.security.transport.ssl.enabled=false # Transport SSL 비활성화
      - xpack.license.self_generated.type=${LICENSE} # 라이선스 유형 설정

application.yml

DB는 미리 만들어두어야 한다. 이 프로젝트는 MySQL로 진행한다.

elastic:
  search:
    uri: localhost:9200
    username: elastic
    password: changeme

spring:
  output:
    ansi:
      enabled: always
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:MYSQL_포트/DB이름
    hikari:
      username: root
      password: 1234
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: update
    properties:
      hibernate:
        format_sql: true
        show_sql: true

ElasticSearchConfig.java 생성

설정 정보들을 불러와서 연결해주자.

@Configuration
public class ElasticSearchConfig extends ElasticsearchConfiguration {
    @Value("${elastic.search.uri}")
    private String uri;
    @Value("${elastic.search.username}")
    private String username;
    @Value("${elastic.search.password}")
    private String password;

    @Override
    public ClientConfiguration clientConfiguration() {
        return ClientConfiguration.builder()
                .connectedTo(uri)
                .withBasicAuth(username, password)
                .build();
    }
}

간단한 엔티티 구성

지금은 제품이라는 아주 간단한 엔티티를 구성한다.

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Data
@Entity
@NoArgsConstructor
public class Product {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    private int price;
    private int stock;
    private String name;

    public Product(int price, int stock, String name) {
        this.price = price;
        this.stock = stock;
        this.name = name;
    }

    public void buy() {
        stock--;
    }
}

ES(ElasticSearch) Document 생성

먼저 ElasticSearch와 관련한 상수를 저장할 헬퍼 클래스를 생성한다.

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class Indices {
    public static final String PRODUCT_INDEX = "product";
}

@Document를 통해서 ES에서 사용할 도큐먼트임을 선언하고
도큐먼트를 저장할 인덱스를 product로 지정한다.

@Getter
@ToString
@NoArgsConstructor
@Document(indexName = Indices.PRODUCT_INDEX)
public class ProductInfo {
    @Id
    private long id;
    private int price;
    private int stock;
    private String name;

    public ProductInfo(Product product) {
        this.id = product.getId();
        this.price = product.getPrice();
        this.stock = product.getStock();
        this.name = product.getName();
    }
}

V1. Spring 애플리케이션 코드로 ES 관리하기

Spring 코드단에서 DB에 데이터를 저장하거나 수정하거나 삭제할 때
직접 ES에도 수정사항을 반영해줄 것이다.

Repository 추가

간단한 JPA Repository이다.

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
}

ElasticSearch를 연결해서 사용할 Repository이다.
상품명에 포함여부에 따라서 검색하는 쿼리를 추가했다.

@Repository
public interface ProductSearchRepository extends ElasticsearchRepository<ProductInfo, Long> {
    /*{
        "bool": {
            "must": [
              {"match": {"name": "?0"}}
            ]
        }
    }*/
    @Query("{\"bool\": {\"must\": [{\"match\": {\"name\": \"?0\"}}]}}")
    Page<ProductInfo> findProducts(String name, Pageable pageable);
}

Controller

제품 생성, 구매, 제거 기능을 만들었다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/v1")
public class ProductControllerV1 {
    private final ProductServiceV1 productService;

    @PostMapping("/product")
    public ResponseEntity<Product> save(@RequestBody ProductRequest product) {
        return new ResponseEntity<>(productService.save(product), HttpStatus.CREATED);
    }

    @PostMapping("/product/{id}/purchase")
    public ResponseEntity<Integer> purchase(@PathVariable(name = "id") long productId) {
        return ResponseEntity.ok(productService.purchase(productId));
    }

    @DeleteMapping("/product/{id}")
    public ResponseEntity<Void> delete(@PathVariable(name = "id") long productId) {
        productService.delete(productId);
        return ResponseEntity.status(HttpStatus.NO_CONTENT).build();
    }
}

ElasticSearch에서 데이터를 검색해오는 컨트롤러이다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/v1")
public class ProductSearchControllerV1 {
    private final ProductSearchRepository repository;
    private final Pageable pageable = PageRequest.of(0, 100);

    @GetMapping("/product")
    public ResponseEntity<List<ProductInfo>> findAllProduct(String name) {
        Page<ProductInfo> list;
        if(name == null || name.isEmpty())
            list = repository.findAll(pageable);
        else
            list = repository.findProducts(name, pageable);
        return ResponseEntity.ok(list.getContent());
    }

    @GetMapping("/product/{id}")
    public ResponseEntity<ProductInfo> findAllProduct(@PathVariable(name = "id") long productId) {
        return ResponseEntity.ok(
                repository.findById(productId)
                        .orElse(null)
        );
    }
}

Service

Jpa를 통해서 데이터를 저장할 때, ElasticSearch에도 이를 반영하기 위한 코드를 함께 놓았다.

이 방식의 장점은 정말 단순하지만, 트랜잭션 처리가 애매해진다는 단점이 있다.
예를 들어 DB에서는 저장했지만 ES에는 저장이 안될 때는 전부 롤백해야 할까?
이런 고민들이 생기는 것이다.

@Service
@Transactional
@RequiredArgsConstructor
public class ProductServiceV1 {
    private final ProductRepository repository;
    private final ProductSearchRepository searchRepository;
    private final ElasticsearchTemplate elasticsearchTemplate;

    public Product save(ProductRequest product) {
        Product result = new Product(product.getPrice(), product.getStock(), product.getName());
        save(result);
        return result;
    }

    public int purchase(long productId) {
        Product product = repository.findById(productId).orElseThrow(RuntimeException::new);
        product.buy();

        UpdateQuery updateQuery = UpdateQuery.builder(Long.toString(productId))
                .withDocument(Document.from(Map.of("stock", product.getStock())))
                .build();
        elasticsearchTemplate.update(updateQuery, IndexCoordinates.of(Indices.PRODUCT_INDEX));
        return product.getStock();
    }

    public void delete(long productId) {
        repository.deleteById(productId);
        searchRepository.deleteById(productId);
    }

    private void save(Product result) {
        repository.save(result);
        searchRepository.save(new ProductInfo(result));
    }
}

docker compose up을 통해 ElasticSearch를 올리고 스프링 서버를 실행시킨다.
그리고 Postman을 통해서 직접 호출해서 실행해보면 정상적으로 동작하는것을 확인할 수 있다.

지금은 아주 단순한 엔티티로 진행했는데,
이후 글에서는 설명글, 카테고리, 해시태그 등등 복잡한 검색 조건을 넣는 방식으로 진행해보겠다.

profile
공부한 내용을 적지 말고 이해한 내용을 설명하자

0개의 댓글

Powered by GraphCDN, the GraphQL CDN