[Spring] - Elasticsearch ์ ์šฉ ๋ฐฉ๋ฒ•

๐Ÿ› ๏ธ ๊ฐœ๋ฐœ ํ™˜๊ฒฝ

  • Kotlin : 1.9.25
  • Spring boot : 3.3.4
  • Github

์™œ Elasticsearch๋ฅผ ์‚ฌ์šฉํ• ๊นŒ?

DB์—์„œ ํŠน์ • ๋ฌธ์ž์—ด์ด ํฌํ•จ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ฒ€์ƒ‰ํ•  ๋•Œ LIKE ์—ฐ์‚ฐ์„ ์‚ฌ์šฉํ•˜๊ฒŒ ๋œ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, 5๋งŒ ๊ฐœ์˜ ํŽธ์˜์  ์ƒํ’ˆ์ด ์žˆ๋‹ค๊ณ  ์ƒ๊ฐํ•ด๋ณด์ž. ๋‚ด๊ฐ€ '์ซ€๋“'์„ ๊ฒ€์ƒ‰ํ•˜๋ ค๋ฉด, product ํ…Œ์ด๋ธ”์—์„œ LIKE '%์ซ€๋“%' ๊ฐ™์€ ์กฐ๊ฑด์œผ๋กœ ๊ฐ’์„ ์ฐพ๊ฒŒ ๋œ๋‹ค.

 

B-Tree ์ธ๋ฑ์Šค๋Š” ์ •๋ ฌ๋œ ํ‚ค๋ฅผ ๋ฐ”ํƒ•์œผ๋กœ '์–ด๋””๋ถ€ํ„ฐ ์–ด๋””๊นŒ์ง€'๋ผ๋Š” ๋ฒ”์œ„๋ฅผ ๋น ๋ฅด๊ฒŒ ์ขํ˜€ ํƒ์ƒ‰ํ•˜๋Š” ๊ตฌ์กฐ์ด๋‹ค. ๋•Œ๋ฌธ์—, LIKE '์ซ€๋“%'์˜ ๊ฒฝ์šฐ ์ธ๋ฑ์Šค๋ฅผ ํƒˆ ์ˆ˜ ์žˆ์ง€๋งŒ, LIKE '%์ซ€๋“%'(leading wildcard) ๋Š” ๋ฌธ์ž์—ด์˜ ์‹œ์ž‘(prefix)์ด ๊ณ ์ •๋˜์ง€ ์•Š๊ธฐ ๋•Œ๋ฌธ์—, ์ธ๋ฑ์Šค๋กœ ํƒ์ƒ‰ ๋ฒ”์œ„๋ฅผ ๋งŒ๋“ค๊ธฐ๊ฐ€ ์–ด๋ ต๋‹ค.

 

๊ฒฐ๊ณผ์ ์œผ๋กœ ์˜ตํ‹ฐ๋งˆ์ด์ €๋Š” ํ…Œ์ด๋ธ” ์ „์ฒด ๋˜๋Š” ์ธ๋ฑ์Šค ์ „์ฒด๋ฅผ ํ›‘์œผ๋ฉด์„œ ์กฐ๊ฑด์„ ๊ฒ€์‚ฌํ•˜๋Š” ์‹คํ–‰ ๊ณ„ํš์œผ๋กœ ๊ธฐ์šธ๊ธฐ ์‰ฝ๊ณ , ๋ฐ์ดํ„ฐ๊ฐ€ ๋งŽ์•„์งˆ์ˆ˜๋ก ๋น„์šฉ์ด ๊ธ‰๊ฒฉํžˆ ์ปค์งˆ ์ˆ˜ ์žˆ๋‹ค.

 

์ฆ‰, ๋ฐ์ดํ„ฐ๊ฐ€ ๋งŽ์€ ์ƒํ™ฉ์—์„œ LIKE '%keyword%' ๊ธฐ๋ฐ˜ ๊ฒ€์ƒ‰์€ RDB ์ž…์žฅ์—์„œ ๊ตฌ์กฐ์ ์œผ๋กœ ๋ถˆ๋ฆฌํ•ด์ง€๊ธฐ ์‰ฝ๋‹ค. ๊ทธ๋ ‡๋‹ค๋ฉด Elasticsearch๋Š” ์–ด๋–ค ์ด์ ์ด ์žˆ๊ธธ๋ž˜ ๊ฒ€์ƒ‰์— ๋งŽ์ด ์‚ฌ์šฉ๋ ๊นŒ?

๊ฒ€์ƒ‰ ๊ตฌ์กฐ

RDB์˜ B-Tree ์ธ๋ฑ์Šค๊ฐ€ ์ •๋ ฌ๋œ ๊ฐ’์—์„œ ๋ฒ”์œ„๋ฅผ ๋น ๋ฅด๊ฒŒ ์ค„์—ฌ๋‚˜๊ฐ€๋Š” ๋ฐฉ์‹์ด๋ผ๋ฉด, Elasticsearch๋Š” ํ…์ŠคํŠธ๋ฅผ ์ €์žฅ(์ƒ‰์ธ)ํ•  ๋•Œ ๋ถ„์„(analyze) ๊ณผ์ •์„ ๊ฑฐ์ณ ํ† ํฐ(term) ๋‹จ์œ„๋กœ ๋ถ„ํ•ดํ•˜๊ณ , “ํ† ํฐ → ๋ฌธ์„œ ID ๋ชฉ๋ก” ํ˜•ํƒœ์˜ ์—ญ์ƒ‰์ธ(inverted index)์„ ์ƒ์„ฑํ•œ๋‹ค.

 

์˜ˆ๋ฅผ ๋“ค์–ด "๋‘๋ฐ”์ด ์ซ€๋“ ์ฟ ํ‚ค", "์ซ€๋“์ซ€๋“ ๋‹ฌ๊ณ ๋‚˜", "์ซ€๋“ํ•œ ์ดˆ์ฝ”์นฉ ์ฟ ํ‚ค" ๊ฐ™์€ ๋ฐ์ดํ„ฐ๋ฅผ ์ƒ‰์ธํ•œ๋‹ค๊ณ  ๊ฐ€์ •ํ•ด๋ณด์ž(ํ† ํฐ ๋ถ„ํ•ด ๊ฒฐ๊ณผ๋Š” ๋ถ„์„๊ธฐ ์„ค์ •์— ๋”ฐ๋ผ ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ๋‹ค). Elasticsearch๋Š” ๊ฐœ๋…์ ์œผ๋กœ ์•„๋ž˜์™€ ๊ฐ™์€ ๊ตฌ์กฐ๋ฅผ ์œ ์ง€ํ•œ๋‹ค.

๋ฌธ์„œ ๋ฐ์ดํ„ฐ
1 ๋‘๋ฐ”์ด ์ซ€๋“ ์ฟ ํ‚ค
2 ์ซ€๋“์ซ€๋“ ๋‹ฌ๊ณ ๋‚˜
3 ์ซ€๋“ํ•œ ์ดˆ์ฝ”์นฉ ์ฟ ํ‚ค
ํ† ํฐ ๋ฌธ์„œ ID ๋ชฉ๋ก
์ซ€๋“ 1, 2, 3
์ฟ ํ‚ค 1, 3

์ฆ‰, “์ซ€๋“”์„ ๊ฒ€์ƒ‰ํ•˜๋ฉด ์ „์ฒด ๋ฌธ์„œ๋ฅผ ํ›‘๋Š” ๋Œ€์‹ , ์—ญ์ƒ‰์ธ์—์„œ “์ซ€๋“” ํ† ํฐ์˜ ๋ฌธ์„œ ํ›„๋ณด ๋ชฉ๋ก์„ ๋จผ์ € ๋น ๋ฅด๊ฒŒ ์ฐพ๊ณ , ๊ทธ ํ›„๋ณด๋“ค์— ๋Œ€ํ•ด์„œ๋งŒ ์ ์ˆ˜ ๊ณ„์‚ฐ(๊ด€๋ จ๋„)๊ณผ ์ •๋ ฌ์„ ์ˆ˜ํ–‰ํ•œ๋‹ค. ์ด ๋•Œ๋ฌธ์— ๋ฐ์ดํ„ฐ๊ฐ€ ์ปค์งˆ์ˆ˜๋ก ์Šค์บ” ๊ธฐ๋ฐ˜ ๊ฒ€์ƒ‰๋ณด๋‹ค ํ›จ์”ฌ ์œ ๋ฆฌํ•ด์งˆ ์ˆ˜ ์žˆ๋‹ค.

 

๋‹ค๋งŒ ์ด๋Ÿฐ ๊ตฌ์กฐ๋ฅผ ์œ ์ง€ํ•˜๋ ค๋ฉด ์ƒ‰์ธ ์‹œ์ ์— ํ† ํฐ ๋ถ„์„๊ณผ ์—ญ์ƒ‰์ธ ๊ฐฑ์‹  ์ž‘์—…์ด ํ•„์š”ํ•˜๋ฏ€๋กœ, ์ผ๋ฐ˜์ ์œผ๋กœ ๊ฒ€์ƒ‰ ์„ฑ๋Šฅ์„ ์–ป๋Š” ๋Œ€์‹  ์“ฐ๊ธฐ(์ƒ‰์ธ) ๋น„์šฉ์ด ์ถ”๊ฐ€๋˜๋Š” ํŠธ๋ ˆ์ด๋“œ์˜คํ”„๊ฐ€ ์กด์žฌํ•œ๋‹ค.

์ ์šฉ ๋ฐฉ๋ฒ•

Elasticsearch๋Š” ์ฃผ๋กœ Kibana์™€ ํ•จ๊ป˜ ์‚ฌ์šฉํ•œ๋‹ค. Kibana๋Š” Elasticsearch์— ์ €์žฅ๋œ ๋ฐ์ดํ„ฐ๋ฅผ ์‚ฌ์šฉ์ž ์นœํ™”์ ์ธ ๋Œ€์‹œ๋ณด๋“œ์™€ ๊ทธ๋ž˜ํ”„, ์ฐจํŠธ ๋“ฑ์œผ๋กœ ์‹œ๊ฐํ™”ํ•˜๊ณ  ํƒ์ƒ‰ํ•˜๋Š” ์˜คํ”ˆ ์†Œ์Šค ์›น ์ธํ„ฐํŽ˜์ด์Šค์ด๋‹ค.

 

์ฆ‰, Elasticsearch๊ฐ€ ๋ฐ์ดํ„ฐ ์ €์žฅ ๋ฐ ๋ถ„์„ ์—”์ง„์ด๋ผ๋ฉด, Kibana๋Š” ๊ทธ ์—”์ง„์˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋ณด๊ณ , ๋ถ„์„ํ•˜๊ณ , ๊ด€๋ฆฌํ•˜๋Š” ๋ชจ๋‹ˆํ„ฐ๋ง ์—ญํ• ์ธ ๊ฒƒ์ด๋‹ค. Kibana๊ฐ€ ๊ผญ ํ•„์š”ํ•˜์ง€ ์•Š์„ ๊ฒฝ์šฐ, Docker ์„ค์ •์—์„œ ์ œ์™ธํ•ด๋„ ๋ฌด๋ฐฉํ•˜๋‹ค.

Docker ์„ค์ •

docker-compose์—์„œ elasticsearch:build:context์—๋Š” Dockerfile์˜ ๊ฒฝ๋กœ๋ฅผ ์ ์–ด์ฃผ๋ฉด ๋œ๋‹ค.

# /elasticsearch/Dockerfile
FROM docker.elastic.co/elasticsearch/elasticsearch:8.18.1
RUN elasticsearch-plugin install --batch analysis-nori
# docker-compose.yaml
services:  
  elasticsearch:  
    build:  
      context: ./elasticsearch  
      dockerfile: Dockerfile  
    container_name: elasticsearch  
    environment:  
      - discovery.type=single-node  
      - xpack.security.enabled=false  
      - xpack.security.enrollment.enabled=false  
      - ES_JAVA_OPTS=-Xms1g -Xmx1g  
    ports:  
      - "9200:9200"  
    volumes:  
      - es_data:/usr/share/elasticsearch/data  

  kibana:  
    image: docker.elastic.co/kibana/kibana:8.18.1  
    container_name: kibana  
    environment:  
      - ELASTICSEARCH_HOSTS=http://elasticsearch:9200  
    ports:  
      - "5601:5601"  
    depends_on:  
      - elasticsearch  

volumes:  
  es_data:

๋ณดํ†ต ๋กœ์ปฌ์€ IDE๋ฅผ ํ†ตํ•ด ์•ฑ์„ ์‹คํ–‰ํ•˜๊ณ , ๋ฐฐํฌ ํ™˜๊ฒฝ์—์„œ๋Š” Docker๋กœ ๋„์šฐ๊ธฐ ๋•Œ๋ฌธ์— ๋‹ค์Œ๊ณผ ๊ฐ™์ด application.yaml์„ ์„ค์ •ํ•ด์ค˜์•ผ ํ•œ๋‹ค.

# local
spring:
  elasticsearch:
    uris: http://localhost:9200
# prod
spring:
  elasticsearch:
    uris: http://elasticsearch:9200

๋งŒ์•ฝ ๋กœ์ปฌ์—์„œ๋„ ์Šคํ”„๋ง์„ Docker์— ์˜ฌ๋ ค์„œ ์‚ฌ์šฉํ•  ๊ฒฝ์šฐ prod์™€ ๋™์ผํ•˜๊ฒŒ ๋‘๋ฉด ๋œ๋‹ค.

ํ†ตํ•ฉ yaml์„ ๊ฐ€์ง€๊ณ , env๋ฅผ ํ†ตํ•ด ์šด์˜ํ•  ๊ฒฝ์šฐ env ํŒŒ์ผ์— ์ฃผ์†Œ๋ฅผ ์ž‘์„ฑํ•˜๊ณ  uris: ${ES_URL}์ฒ˜๋Ÿผ ์ž‘์„ฑํ•ด์ฃผ๋ฉด ๋œ๋‹ค.

์˜์กด์„ฑ ์„ค์ •

Elasticsearch๋ฅผ Spring Boot์—์„œ ์‚ฌ์šฉํ•˜๋ ค๋ฉด spring-boot-starter-data-elasticsearch ์˜์กด์„ฑ์„ ์ถ”๊ฐ€ํ•˜๋ฉด ๋œ๋‹ค.

implementation("org.springframework.boot:spring-boot-starter-data-elasticsearch")

Spring Boot๋ฅผ ์“ฐ๋Š” ๊ฒฝ์šฐ, ๋Œ€๋ถ€๋ถ„์€ Boot๊ฐ€ Spring Data Elasticsearch ๋ฒ„์ „์„ ํ•จ๊ป˜ ๋งž์ถฐ์ฃผ๊ธฐ ๋•Œ๋ฌธ์— ์˜์กด์„ฑ ๋ฒ„์ „์„ ์ง์ ‘ ๊ณ ์ •ํ•  ์ผ์ด ๋งŽ์ง€ ์•Š๋‹ค. ๋ฐ˜๋Œ€๋กœ Boot๊ฐ€ ์•„๋‹Œ ์ผ๋ฐ˜ Spring ํ™˜๊ฒฝ์ด๋ผ๋ฉด, ์‚ฌ์šฉ ์ค‘์ธ Spring/Data ์กฐํ•ฉ๊ณผ ๋ฒ„์ „ ํ˜ธํ™˜ํ‘œ๋ฅผ ๋ณด๊ณ  ํ˜ธํ™˜๋˜๋Š” ๋ฒ„์ „์„ ๋งž์ถฐ์•ผ ํ•œ๋‹ค.

RDB์™€ Elasticsearch ์—ญํ•  ๋ถ„๋ฆฌ

๊ตฌ์กฐ๋ฅผ ์žก์„ ๋•Œ ํ•ต์‹ฌ์€ ์—ญํ• ์„ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒƒ์ด๋‹ค.

  • RDB: ์ง„์‹ค์˜ ์›์ฒœ(source of truth). ๋ฐ์ดํ„ฐ ์ƒ์„ฑ/์ˆ˜์ •/์‚ญ์ œ ๊ฐ™์€ ์ƒํƒœ ๋ณ€๊ฒฝ์ด ํ™•์ •๋˜๋Š” ๊ณณ
  • Elasticsearch(ES): ๊ฒ€์ƒ‰ ์ „์šฉ read model. ๊ฒ€์ƒ‰ ์„ฑ๋Šฅ/๊ฒ€์ƒ‰ ํ’ˆ์งˆ์„ ์œ„ํ•ด ํ•„์š”ํ•œ ํ•„๋“œ๋ฅผ ๋ณ„๋„๋กœ ์ €์žฅํ•ด ์กฐํšŒ์— ์‚ฌ์šฉ

์ฆ‰, ์‹ค์ œ ์ƒํƒœ ๋ณ€๊ฒฝ์€ RDB์—์„œ ์ฒ˜๋ฆฌํ•˜๊ณ  ES๋Š” ๊ทธ ๊ฒฐ๊ณผ๋ฅผ ๋”ฐ๋ผ๊ฐ€๋Š” ๋ณต์ œ๋ณธ์œผ๋กœ ์šด์˜ํ•˜๋Š” ๊ฒƒ์ด ์ข‹๋‹ค. ๋”ฐ๋ผ์„œ ์šด์˜ ๊ด€์ ์—์„œ๋Š” ๋™๊ธฐํ™” ์ง€์—ฐ์ด๋‚˜ ์‹คํŒจ๋ฅผ ๊ณ ๋ คํ•ด ์žฌ์‹œ๋„/์žฌ์ƒ‰์ธ ๊ฐ™์€ ๋ณต๊ตฌ ๊ฒฝ๋กœ๋ฅผ ์ค€๋น„ํ•˜๊ณ , ์ตœ์ข…์ ์œผ๋กœ ์ผ๊ด€๋˜๊ฒŒ(eventually consistent) ๋งž์ถฐ์ง€๋„๋ก ์„ค๊ณ„ํ•˜๋Š” ๊ฒƒ์ด ์ค‘์š”ํ•˜๋‹ค.

Document ์ •์˜

ES์— ์ €์žฅ๋˜๋Š” ํ˜•ํƒœ๋Š” RDB ํ…Œ์ด๋ธ”๊ณผ 1:1๋กœ ๋งž์ถœ ํ•„์š”๊ฐ€ ์—†๋‹ค. ๊ฒ€์ƒ‰๊ณผ ์ •๋ ฌ์— ํ•„์š”ํ•œ ํ•„๋“œ๋งŒ ๋ฝ‘์•„์„œ Document๋กœ ์ •์˜ํ•˜๋ฉด ๋œ๋‹ค.

@Document(indexName = "product", createIndex = true)
data class ProductDocument(
    @Id
    val productId: Long,
    @Field(type = FieldType.Text, analyzer = "nori")
    val title: String,
    val price: Int
)

์—ฌ๊ธฐ์„œ @Field(type = FieldType.Text, analyzer = "nori")๋Š” ํ•ด๋‹น ํ•„๋“œ๋ฅผ 'ํ•œ๊ตญ์–ด ํ˜•ํƒœ์†Œ ๋ถ„์„ ๊ธฐ๋ฐ˜์œผ๋กœ ํ† ํฐํ™”ํ•ด์„œ ์ƒ‰์ธ/๊ฒ€์ƒ‰ํ•˜๊ฒ ๋‹ค'๋Š” ์˜๋ฏธ๋‹ค. ํ•œ๊ตญ์–ด๋Š” ๊ณต๋ฐฑ๋งŒ์œผ๋กœ ๋‹จ์–ด ๊ฒฝ๊ณ„๋ฅผ ์žก๊ธฐ ์–ด๋ ค์šด ๊ฒฝ์šฐ๊ฐ€ ๋งŽ์•„์„œ, ํ•œ๊ธ€ ๊ฒ€์ƒ‰ ํ’ˆ์งˆ์„ ์œ„ํ•ด nori ๋ถ„์„๊ธฐ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ตฌ์„ฑ์ด ํ”ํ•˜๋‹ค.

 

nori๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด Elasticsearch์— analysis-nori ํ”Œ๋Ÿฌ๊ทธ์ธ์ด ์„ค์น˜๋˜์–ด ์žˆ์–ด์•ผ ํ•˜์ง€๋งŒ, ์šฐ๋ฆฌ๋Š” ์ด๋ฏธ Dockerfile์—์„œ ํ”Œ๋Ÿฌ๊ทธ์ธ ์„ค์น˜๋ฅผ ์ด๋ฏธ ํฌํ•จํ–ˆ๊ธฐ ๋•Œ๋ฌธ์—, ์ปจํ…Œ์ด๋„ˆ๊ฐ€ ๋นŒ๋“œ/์‹คํ–‰๋  ๋•Œ ํ•จ๊ป˜ ๋ฐ˜์˜๋˜๋ฉฐ ๋ณ„๋„์˜ ์ถ”๊ฐ€ ์ž‘์—…์ด ํ•„์š”ํ•˜์ง€ ์•Š๋‹ค.

 

์ฐธ๊ณ ๋กœ createIndex = true๋Š” ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์‹œ์ž‘ ์‹œ ์ธ๋ฑ์Šค ์ƒ์„ฑ/๋งคํ•‘ ์ ์šฉ์„ ์ž๋™์œผ๋กœ ์‹œ๋„ํ•˜๋Š” ์˜ต์…˜์ด๋‹ค. ๋กœ์ปฌ์—์„œ๋Š” ํŽธํ•˜์ง€๋งŒ, ์šด์˜์—์„œ๋Š” ์ธ๋ฑ์Šค ๊ด€๋ฆฌ ์ •์ฑ…(๋ฒ„์ „ ์ธ๋ฑ์Šค, ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜, alias ์šด์˜ ๋“ฑ)์— ๋”ฐ๋ผ ๋„๊ณ  ๋ณ„๋„๋กœ ๊ด€๋ฆฌํ•˜๋Š” ๊ฒฝ์šฐ๋„ ๋งŽ๋‹ค.

Repository ์ •์˜

๋งˆ์ง€๋ง‰์œผ๋กœ ElasticsearchRepository๋ฅผ ๋งŒ๋“ค๋ฉด JPA Repository์ฒ˜๋Ÿผ ES ๋ฌธ์„œ์— ๋Œ€ํ•ด ์ €์žฅ/์กฐํšŒ๊ฐ€ ๊ฐ€๋Šฅํ•ด์ง„๋‹ค.

interface ProductEsRepository : ElasticsearchRepository<ProductDocument, Long>

์‹ค์ œ ์‚ฌ์šฉ

ES๋ฅผ ์‚ฌ์šฉํ•  ๋•Œ ๊ฐ€์žฅ ์ฃผ์˜ํ•ด์•ผํ•  ์ ์€ RDB์™€ ES๋ฅผ ๋™๊ธฐํ™”ํ•˜๋Š” ๊ฒƒ์ด๋‹ค. ์ฆ‰, ES์— Document๋กœ ์ถ”๊ฐ€ํ•œ RDB ํ…Œ์ด๋ธ”์— Create, Update, Delete๊ฐ€ ์ผ์–ด๋‚œ ๊ฒฝ์šฐ, ES์—๋„ ํ•จ๊ป˜ ์ ์šฉ์„ ํ•ด์ฃผ์–ด์•ผ ํ•œ๋‹ค.

fun save(dto: ProductDto) {
    val product = dto.toEntity()

    productRdbRepository.save(product)
    productEsRepository.save(product.toDocument())
}

ElasticsearchRepository์˜ ๋Œ€๋ถ€๋ถ„ ํ•จ์ˆ˜๊ฐ€ JPA์™€ ์œ ์‚ฌํ•˜๊ธฐ ๋•Œ๋ฌธ์— ํฐ ์–ด๋ ค์›€์€ ์—†๊ฒ ์ง€๋งŒ, ํ•„์š”ํ•œ ํ•จ์ˆ˜๋ฅผ ์ฐพ์ง€ ๋ชปํ•  ๊ฒฝ์šฐ ๊ณต์‹ ๋ฌธ์„œ๋ฅผ ํ†ตํ•ด ๋ณด๋ฉด ๋„์›€์ด ๋  ๊ฒƒ์ด๋‹ค.