Ver2.5 Spring Boot와 Elasticsearch 완전 정복: 🚀 실전 전문 검색 기능 구현 가이드

Spring Boot와 Elasticsearch 완전 정복: 🚀 실전 전문 검색 기능 구현 가이드
DB의 `LIKE` 검색에 지친 당신을 위해, 검색의 신세계를 열어줄 Elasticsearch와의 첫 만남을 주선합니다!
들어가며: "검색... 너 혹시... 느리니? 🤔"
개발자라면 누구나 한 번쯤은 이런 경험이 있을 겁니다. 열심히 만든 서비스의 데이터가 점점 쌓여가고, 사용자가 늘어날수록 유독 한 기능이 말썽을 부리기 시작하죠. 바로 '검색' 기능입니다.
처음에는 RDB(관계형 데이터베이스)의 `LIKE '%검색어%'` 쿼리 하나면 충분했습니다. 하지만 데이터가 수만, 수십만 건을 넘어가면서 검색 버튼을 누를 때마다 서버는 비명을 지르고 사용자는 인내심의 한계를 느끼기 시작합니다. 인덱스를 걸어봐도 `LIKE`의 양방향 와일드카드(`%검색어%`) 앞에서는 속수무책이죠. 😭
"구글이나 네이버, 아마존 같은 거대 서비스들은 어떻게 그렇게 방대한 데이터 속에서 0.1초 만에 검색 결과를 뚝딱 내놓는 걸까?"
"단순 키워드 매칭을 넘어, 오타를 보정해주거나 연관성이 높은 결과를 먼저 보여주는 똑똑한 검색은 어떻게 구현하는 거지?"
이런 고민의 종착역에 바로 오늘의 주인공, Elasticsearch(엘라스틱서치)가 있습니다. 그리고 우리에게 너무나도 친숙한 Spring Boot와 함께라면, 이 강력한 검색 엔진을 우리 서비스에 탑재하는 것은 생각보다 훨씬 간단하고 즐거운 여정이 될 수 있습니다.
이 글은 Elasticsearch를 처음 접하는 분들부터, Spring Boot와 연동하여 실전적인 검색 기능을 구현하고 싶은 분들까지 모두를 위한 종합 안내서입니다. 딱딱한 이론만 나열하는 것이 아니라, 왜 필요한지, 어떤 원리로 동작하는지, 그리고 실제 코드는 어떻게 작성해야 하는지까지! 친구에게 설명하듯 쉽고 재미있게, 하지만 깊이 있게 파고들어 보겠습니다.
자, 이제 커피 한 잔 준비하시고, 답답했던 `LIKE` 검색의 시대에 작별을 고할 준비, 되셨나요? 🚀
1. Elasticsearch 넌 누구냐: 핵심 개념 뽀개기 💡
Elasticsearch(이하 ES)를 본격적으로 사용하기 전에, 이 친구가 어떤 원리로 일하는지부터 알아야겠죠? "그냥 빠른 NoSQL DB 아니야?" 라고 생각했다면, 반은 맞고 반은 틀립니다. ES는 단순한 데이터 저장소가 아니라, 처음부터 '검색'을 위해 태어난, 루씬(Lucene) 기반의 강력한 분산 검색 및 분석 엔진입니다.
ES의 경이로운 속도와 기능 뒤에는 몇 가지 핵심적인 개념들이 숨어있습니다. 이것들만 이해하면 앞으로의 여정이 훨씬 수월해질 거예요!
1.1. 역색인 (Inverted Index): 속도의 비밀
ES를 이해하는 가장 중요한 키워드, 바로 역색인입니다. 우리가 일반적으로 생각하는 '색인'은 책의 목차처럼 '페이지 번호'를 기준으로 '어떤 내용'이 있는지 찾아가는 방식이죠. 하지만 책 맨 뒤에 있는 '찾아보기'를 생각해보세요. '특정 단어'가 '어떤 페이지'에 있는지 바로 알려주죠? 이게 바로 역색인의 기본 아이디어입니다.
ES는 데이터를 저장할 때, 문서를 통째로 저장하는 것과 별개로, 문서 안의 텍스트를 작은 단위(Term, 용어)로 쪼개서 이 용어들이 어떤 문서에 등장하는지를 기록하는 '역색인' 파일을 만듭니다.
예를 들어, 이런 데이터가 있다고 해봅시다.
- 문서 1: "Spring Boot is awesome"
- 문서 2: "I love Spring Data"
- 문서 3: "Spring is powerful"
ES는 이 문서들로 다음과 같은 역색인을 만듭니다.
✨ 역색인 예시 ✨
Term (용어) Documents (등장 문서)
------------------------------------
"spring" → [문서 1, 문서 2, 문서 3]
"boot" → [문서 1]
"is" → [문서 1, 문서 3]
"awesome" → [문서 1]
"i" → [문서 2]
"love" → [문서 2]
"data" → [문서 2]
"powerful" → [문서 3]
이제 우리가 "spring"이라는 단어로 검색하면 어떻게 될까요? RDB의 `LIKE`처럼 모든 문서를 하나하나 스캔하는 게 아니라, 그냥 역색인에서 "spring"을 찾습니다. 그리고 그 옆에 적힌 `[문서 1, 문서 2, 문서 3]`을 바로 반환해주는 거죠. 데이터가 수억 건이라도 이 과정은 거의 동일하게 빠릅니다. 이게 바로 ES 검색 속도의 핵심 비결입니다! 🤯
1.2. 용어 정리: RDB와 비교하며 이해하기
ES를 처음 접하면 생소한 용어들 때문에 혼란스러울 수 있습니다. 우리가 익숙한 RDB의 개념과 1:1로 매핑해서 이해하면 훨씬 쉬워요.
💡 ES vs RDB 용어 비교 💡
Elasticsearch ➡️ RDBMS (e.g., MySQL)
----------------------------------------------------------------
Index ➡️ Database (데이터베이스)
하나의 데이터 저장 공간. 보통 서비스별, 혹은 데이터 종류별로 인덱스를 생성합니다.
Document ➡️ Row (행)
ES에 저장되는 데이터의 기본 단위. JSON 형태로 되어 있습니다.
Field ➡️ Column (열)
Document를 구성하는 각 속성. (e.g., "name": "John Doe"에서 "name"이 필드)
Mapping ➡️ Schema (스키마)
인덱스에 포함된 Document의 구조와 각 필드의 데이터 타입, 분석 방법 등을 정의합니다.
어때요? 이렇게 보니 훨씬 친숙하게 느껴지죠? 앞으로 '인덱스'는 '데이터베이스', '도큐먼트'는 '하나의 데이터(행)'라고 생각하면서 따라오시면 됩니다.
1.3. 분석기 (Analyzer): 똑똑한 검색의 시작
역색인을 만들 때, 원본 텍스트를 어떤 규칙으로 쪼개고 가공할지 결정하는 아주 중요한 역할을 하는 친구가 바로 분석기(Analyzer)입니다. 분석기는 크게 세 가지 단계로 동작해요.
1. Character Filters (문자 필터): 텍스트에서 불필요한 HTML 태그(`<p>`, `<br>` 등)를 제거하는 등 전처리 작업을 합니다.
2. Tokenizer (토크나이저): 전처리된 텍스트를 실질적인 단어 단위(Term)로 쪼갭니다. 공백을 기준으로 쪼갤 수도 있고, 특정 문법 규칙에 따라 쪼갤 수도 있습니다.
3. Token Filters (토큰 필터): 쪼개진 토큰들을 가공합니다. 예를 들어, 모든 영문자를 소문자로 바꾸거나(lowercase), 의미 없는 단어(a, the, is 등)를 제거(stopword)하거나, 단어의 원형을 찾아주는(stemming) 등의 작업을 합니다.
특히 한국어 검색에서는 이 분석기의 역할이 절대적입니다. 영어는 띄어쓰기 단위로 단어를 구분하기 쉽지만, 한국어는 '은/는/이/가' 같은 조사가 붙고, 어미가 변형되는 등 복잡한 특성을 가지고 있기 때문이죠.
예를 들어, "재능넷에서 스프링부트를" 이라는 텍스트를 표준 분석기(Standard Analyzer)로 분석하면 "재능넷에서", "스프링부트를" 이렇게 덩어리로 쪼개집니다. 이러면 사용자가 "스프링부트"라고 검색했을 때 이 문서를 찾지 못하는 불상사가 발생할 수 있습니다.
하지만 한국어 형태소 분석기인 'Nori'를 사용하면 "재능넷", "에서", "스프링", "부트", "를" 과 같이 의미있는 명사 단위로 똑똑하게 쪼개줍니다. 덕분에 "스프링부트"나 "재능넷"으로 검색해도 문서를 정확하게 찾아낼 수 있게 되는 거죠. 이 부분은 뒤에서 실습을 통해 자세히 다뤄볼 예정이니, '아, 한국어 검색을 잘하려면 Nori 같은 한국어 분석기가 필수구나!' 정도만 기억하고 넘어가세요! 😉
1.4. 클러스터, 노드, 샤드: 확장성의 미학
ES는 처음부터 대용량 데이터를 다루기 위해 '분산'을 염두에 두고 설계되었습니다. 혼자 일하는 게 아니라, 여러 대의 서버가 팀을 이뤄 함께 일하는 방식이죠.
- Cluster (클러스터): 함께 일하는 ES 서버들의 전체 그룹을 의미합니다.
- Node (노드): 클러스터를 구성하는 개별 ES 서버 하나하나를 의미합니다.
- Shard (샤드): 하나의 거대한 인덱스를 여러 개의 작은 조각으로 나눈 것입니다. 데이터가 너무 많아서 한 노드에 다 담기 힘들 때, 인덱스를 여러 샤드로 쪼개서 여러 노드에 분산 저장합니다. 덕분에 수평적 확장이 매우 용이해집니다.
- Replica (레플리카): 샤드의 복제본입니다. 특정 노드에 장애가 발생하더라도 레플리카를 통해 데이터 유실을 막고, 검색 요청을 분산 처리하여 성능을 높이는 역할을 합니다.
지금 당장 이 모든 걸 완벽하게 이해할 필요는 없습니다. "아, ES는 혼자서도 잘하지만, 데이터가 많아지면 여러 대가 힘을 합쳐서 더 빠르고 안정적으로 일할 수 있도록 설계되었구나!" 정도로 이해하시면 충분합니다.
2. 실전 준비: 개발 환경 세팅하기 💻
자, 이제 이론 공부는 충분히 한 것 같으니 직접 우리 손으로 ES를 만져볼 시간입니다! 가장 최신 트렌드에 맞게, Docker를 사용해서 ES와 시각화 도구인 Kibana(키바나)를 간편하게 띄우고, Spring Boot 프로젝트를 설정해보겠습니다.
2.1. Docker로 Elasticsearch & Kibana 띄우기
내 컴퓨터에 직접 ES를 설치하는 방법도 있지만, 버전 관리도 어렵고 설정도 번거롭습니다. Docker를 사용하면 명령어 한 줄로 필요한 모든 환경을 깔끔하게 구성하고, 필요 없을 땐 깨끗하게 삭제할 수 있어 정말 편리합니다. (Docker가 설치되어 있지 않다면, Docker Desktop을 먼저 설치해주세요!)
프로젝트 폴더를 하나 만드시고, 그 안에 `docker-compose.yml` 파일을 아래와 같이 작성해주세요.
version: '3.8'
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.9.0
container_name: elasticsearch
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ports:
- "9200:9200"
- "9300:9300"
networks:
- elastic-net
kibana:
image: docker.elastic.co/kibana/kibana:8.9.0
container_name: kibana
ports:
- "5601:5601"
depends_on:
- elasticsearch
networks:
- elastic-net
networks:
elastic-net:
driver: bridge
잠깐! 각 설정은 무슨 뜻일까요? 🧐
image: 사용할 ES와 Kibana의 도커 이미지 버전을 지정합니다. 여기서는 8.9.0 버전을 사용했습니다. 두 서비스의 버전은 반드시 동일하게 맞춰주세요!discovery.type=single-node: 여러 노드로 클러스터를 구성하는 게 아니라, 개발용으로 노드 하나만 단독으로 실행하겠다는 설정입니다.xpack.security.enabled=false: 최신 ES 버전은 기본적으로 보안 기능이 활성화되어 있어 아이디/비밀번호 설정이 필요합니다. 개발 환경에서는 편의를 위해 이 기능을 잠시 꺼두겠습니다. (운영 환경에서는 절대 이렇게 하시면 안 됩니다!)ES_JAVA_OPTS: ES가 사용하는 자바 힙 메모리 크기를 설정합니다. 로컬 개발 환경에서는 너무 많은 메모리를 할당하지 않도록 512MB로 지정했습니다.ports: 컨테이너의 포트와 로컬 컴퓨터의 포트를 연결합니다. ES는 9200(HTTP), 9300(Transport) 포트를, Kibana는 5601 포트를 사용합니다.depends_on: Kibana가 Elasticsearch에 의존하므로, Elasticsearch가 먼저 실행된 후에 Kibana가 실행되도록 순서를 정해줍니다.networks: 두 컨테이너가 서로 통신할 수 있도록 같은 네트워크 브릿지에 묶어줍니다.
파일 작성이 끝났다면, 터미널에서 `docker-compose.yml` 파일이 있는 위치로 이동한 뒤 아래 명령어를 실행해주세요.
docker-compose up -d
`-d` 옵션은 백그라운드에서 실행하라는 의미입니다. 이제 Docker가 열심히 이미지를 다운받고 컨테이너를 실행할 겁니다. 몇 분 정도 기다린 후, 웹 브라우저에서 아래 주소로 접속해보세요.
- Elasticsearch: http://localhost:9200
- Kibana: http://localhost:5601
`localhost:9200`에 접속했을 때 아래와 비슷한 JSON 응답이 보인다면 ES가 성공적으로 실행된 것입니다.
{
"name" : "...",
"cluster_name" : "docker-cluster",
"cluster_uuid" : "...",
"version" : {
"number" : "8.9.0",
...
},
"tagline" : "You Know, for Search"
}
`localhost:5601`에 접속하면 Kibana의 멋진 대시보드가 여러분을 맞이할 겁니다. 이제 검색 엔진과 시각화 도구 준비는 끝났습니다! 🎉
2.2. Spring Boot 프로젝트 생성 및 의존성 추가
이제 우리의 주인공, Spring Boot 프로젝트를 만들어봅시다. Spring Initializr에 접속해서 아래와 같이 프로젝트를 설정해주세요.
- Project: Gradle - Groovy (혹은 Maven)
- Language: Java
- Spring Boot: 3.1.x 버전대 (ES 8.9와 호환성이 좋은 버전을 선택합니다)
- Project Metadata:
- Group: com.example
- Artifact: es-tutorial
- Packaging: Jar
- Java: 17
- Dependencies:
- Spring Web: REST API를 만들기 위해 필요합니다.
- Lombok: 반복적인 Getter, Setter, 생성자 코드를 줄여줍니다.
- Spring Data Elasticsearch: 오늘의 핵심! Spring에서 ES를 쉽게 사용하게 해주는 라이브러리입니다.
설정이 끝났다면 `GENERATE` 버튼을 눌러 프로젝트 압축 파일을 다운로드하고, 원하는 IDE(IntelliJ, Eclipse 등)에서 프로젝트를 열어주세요.
Gradle을 사용하신다면 `build.gradle` 파일의 `dependencies` 블록이 아래와 같이 구성되어 있을 겁니다.
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
implementation 'org.springframework.boot:spring-boot-starter-web'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
2.3. Spring Boot와 Elasticsearch 연결 설정
마지막으로 Spring Boot 프로젝트가 우리가 띄운 ES 서버를 찾아갈 수 있도록 연결 정보를 알려줘야 합니다. `src/main/resources/application.yml` 파일을 열고 아래와 같이 작성해주세요. (만약 `application.properties` 파일을 사용한다면 형식에 맞게 변환해서 작성하면 됩니다.)
spring:
elasticsearch:
uris: http://localhost:9200
# 만약 Docker Compose에서 보안 설정을 끄지 않았다면 아래 주석을 풀고 설정해야 합니다.
# username: elastic
# password: your_password
정말 간단하죠? `uris`에 우리가 Docker로 띄운 ES의 주소만 적어주면 됩니다. 이제 모든 준비가 끝났습니다. 프로젝트의 메인 애플리케이션 클래스(`EsTutorialApplication.java`)를 실행시켜보세요. 콘솔에 에러 없이 Spring 로고가 멋지게 출력된다면, 성공적으로 ES와 연결된 것입니다!
💡 Spring Data Elasticsearch 버전 호환성 체크는 필수! 💡
Spring Data Elasticsearch는 특정 Elasticsearch 서버 버전과 호환됩니다. 프로젝트를 시작하기 전에 항상 공식 문서의 호환성 표를 확인하는 습관을 들이는 것이 좋습니다. 버전이 맞지 않으면 연결이 안 되거나 예상치 못한 오류가 발생할 수 있습니다.
3. 첫걸음 떼기: 기본 CRUD 구현하기 ✍️
환경 설정이라는 큰 산을 넘었으니, 이제 본격적으로 코드를 작성하며 ES와 소통해볼 시간입니다. 가장 기본이 되는 데이터 생성(Create), 조회(Read), 수정(Update), 삭제(Delete), 즉 CRUD 작업을 Spring Data Elasticsearch를 통해 얼마나 우아하게 처리할 수 있는지 경험해보겠습니다.
우리는 간단한 '블로그 포스트'를 검색하는 기능을 만든다고 가정해볼게요.
3.1. Document 정의: @Document 어노테이션
먼저 ES에 저장될 데이터의 구조, 즉 'Document'를 Java 클래스로 정의해야 합니다. JPA에서 `@Entity`를 사용했던 것처럼, Spring Data Elasticsearch에서는 `@Document` 어노테이션을 사용합니다.
`com.example.estutorial.document` 패키지를 만들고, `ArticleDocument.java` 클래스를 생성해봅시다.
package com.example.estutorial.document;
import lombok.Builder;
import lombok.Getter;
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import java.time.LocalDateTime;
@Getter
@Document(indexName = "articles")
public class ArticleDocument {
@Id
private String id;
@Field(type = FieldType.Text)
private String title;
@Field(type = FieldType.Text)
private String content;
@Field(type = FieldType.Keyword)
private String author;
@Field(type = FieldType.Date, pattern = "uuuu-MM-dd'T'HH:mm:ss.SSS")
private LocalDateTime createdAt;
@Builder
public ArticleDocument(String id, String title, String content, String author, LocalDateTime createdAt) {
this.id = id;
this.title = title;
this.content = content;
this.author = author;
this.createdAt = createdAt;
}
}
🔍 어노테이션 집중 탐구 🔍
@Document(indexName = "articles"): 이 클래스가 ES의 Document와 매핑된다는 것을 알려줍니다.indexName속성으로 이 Document들이 저장될 인덱스의 이름을 'articles'로 지정했습니다. 이 이름을 지정하지 않으면 클래스 이름을 소문자로 변환한 이름(e.g., `articledocument`)으로 인덱스가 생성됩니다.@Id: 이 필드가 Document의 고유 식별자(ID)임을 나타냅니다. JPA의 `@Id`와 동일한 역할입니다.@Field: 각 필드의 속성을 정의합니다. 가장 중요한 속성은type입니다.FieldType.Text: 전문(Full-text) 검색이 필요한 필드에 사용됩니다. ES는 이 타입의 필드에 대해 '분석(Analysis)' 과정을 거쳐 역색인을 생성합니다. "스프링 부트"라고 저장해도 "스프링"이나 "부트"로 검색이 가능하게 하려면 이 타입을 사용해야 합니다.FieldType.Keyword: 정확한 값(Exact value)으로 검색이나 정렬, 집계(Aggregation)가 필요한 필드에 사용됩니다. 분석 과정을 거치지 않고, 입력된 텍스트를 통째로 하나의 토큰으로 저장합니다. 카테고리, 사용자 아이디, 태그, 이메일 주소 등에 적합합니다.FieldType.Date: 날짜/시간 데이터를 위한 타입입니다. 날짜 범위 검색 등을 효율적으로 할 수 있습니다.- 이 외에도
Integer,Long,Double,Boolean,Object,Nested등 다양한 타입이 있습니다.
`title`과 `content`는 내용 검색이 필요하므로 `Text` 타입으로, `author`는 특정 작성자의 글을 정확히 찾아야 하므로 `Keyword` 타입으로 지정했습니다.
3.2. Repository 생성: 마법의 ElasticsearchRepository
JPA의 `JpaRepository`처럼, Spring Data Elasticsearch는 `ElasticsearchRepository`라는 아주 편리한 인터페이스를 제공합니다. 이 인터페이스를 상속받기만 하면 기본적인 CRUD 메서드를 자동으로 구현해줍니다. 🤯
`com.example.estutorial.repository` 패키지를 만들고, `ArticleRepository.java` 인터페이스를 생성해봅시다.
package com.example.estutorial.repository;
import com.example.estutorial.document.ArticleDocument;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
public interface ArticleRepository extends ElasticsearchRepository<ArticleDocument, String> {
}
네, 이게 전부입니다. 정말이죠! `ElasticsearchRepository`를 상속받고 제네릭으로 Document 클래스(`ArticleDocument`)와 ID의 타입(`String`)만 지정해주면, 우리는 `save()`, `findById()`, `findAll()`, `deleteById()` 같은 메서드들을 바로 사용할 수 있게 됩니다.
3.3. Service와 Controller 구현
이제 Repository를 이용해서 실제 비즈니스 로직을 처리하는 Service와, 외부 요청을 받는 Controller를 만들어보겠습니다.
먼저 `ArticleService`입니다.
package com.example.estutorial.service;
import com.example.estutorial.document.ArticleDocument;
import com.example.estutorial.repository.ArticleRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class ArticleService {
private final ArticleRepository articleRepository;
public String saveArticle(String title, String content, String author) {
ArticleDocument article = ArticleDocument.builder()
.id(UUID.randomUUID().toString()) // ID는 유니크하게 생성
.title(title)
.content(content)
.author(author)
.createdAt(LocalDateTime.now())
.build();
ArticleDocument savedArticle = articleRepository.save(article);
return savedArticle.getId();
}
public ArticleDocument findById(String id) {
return articleRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Article not found with id: " + id));
}
public Iterable<ArticleDocument> findAll() {
return articleRepository.findAll();
}
}
다음은 외부에서 HTTP 요청을 통해 이 서비스들을 호출할 `ArticleController`입니다.
package com.example.estutorial.controller;
import com.example.estutorial.document.ArticleDocument;
import com.example.estutorial.service.ArticleService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/articles")
@RequiredArgsConstructor
public class ArticleController {
private final ArticleService articleService;
// DTO를 사용하는 것이 좋지만, 예제 편의상 파라미터를 직접 받습니다.
@PostMapping
public ResponseEntity<String> createArticle(@RequestParam String title,
@RequestParam String content,
@RequestParam String author) {
String savedId = articleService.saveArticle(title, content, author);
return ResponseEntity.ok(savedId);
}
@GetMapping("/{id}")
public ResponseEntity<ArticleDocument> getArticleById(@PathVariable String id) {
ArticleDocument article = articleService.findById(id);
return ResponseEntity.ok(article);
}
@GetMapping
public ResponseEntity<Iterable<ArticleDocument>> getAllArticles() {
Iterable<ArticleDocument> articles = articleService.findAll();
return ResponseEntity.ok(articles);
}
}
3.4. 테스트: Postman으로 데이터 넣고 확인하기
이제 애플리케이션을 실행하고, Postman이나 다른 API 테스트 도구를 사용해서 직접 테스트해봅시다.
1. 데이터 생성 (POST)
- URL: `POST http://localhost:8080/api/articles`
- Body (form-data):
- `title`: Spring Boot와 Elasticsearch 연동하기
- `content`: Elasticsearch는 정말 강력한 검색 엔진입니다. Spring Data를 사용하면 쉽게 연동할 수 있습니다.
- `author`: jaenung-dev
요청을 보내면 응답으로 생성된 Document의 ID(UUID)가 반환될 겁니다. 몇 개 더 생성해봅시다.
- `title`: 자바 개발자를 위한 Elasticsearch 가이드, `content`: 자바와 스프링은 환상의 조합입니다., `author`: java-lover
- `title`: 대용량 데이터 검색 시스템 구축, `content`: 검색 성능 최적화는 매우 중요합니다., `author`: bigdata-pro
2. 데이터 조회 (GET)
- 전체 조회: `GET http://localhost:8080/api/articles`
- ID로 조회: `GET http://localhost:8080/api/articles/{생성된_ID}`
요청을 보내면 우리가 저장한 데이터가 JSON 형태로 예쁘게 출력되는 것을 확인할 수 있습니다.
3. Kibana에서 확인하기
웹 브라우저에서 Kibana(http://localhost:5601)로 접속한 뒤, 왼쪽 메뉴에서 햄버거 아이콘(☰) > Management > Dev Tools로 이동해주세요.
콘솔 창에 아래와 같이 입력하고 실행(▶) 버튼을 눌러보세요.
GET articles/_search
오른쪽 창에 우리가 Spring Boot 애플리케이션을 통해 저장한 데이터들이 `hits` 안에 들어있는 것을 직접 눈으로 확인할 수 있습니다. `articles` 인덱스가 자동으로 생성되고, 데이터가 성공적으로 색인된 것입니다!
어떤가요? JPA를 다루는 것과 거의 차이가 없을 정도로 간단하게 ES에 데이터를 넣고 빼는 작업을 완료했습니다. Spring Data의 위대한 추상화 덕분이죠. 이제 진짜 재미있는 부분, '검색' 기능을 구현하러 가볼까요?
4. 대망의 전문 검색: Query DSL과 만나다 🔍
지금까지는 ES를 단순한 NoSQL 데이터베이스처럼 사용했습니다. 이제 ES의 심장이자 존재 이유인 '검색', 그 중에서도 텍스트 내용을 기반으로 하는 전문 검색(Full-text Search) 기능을 구현해볼 차례입니다.
ES에서 검색은 Query DSL(Domain Specific Language)이라는 JSON 기반의 강력한 쿼리 언어를 통해 이루어집니다. 처음에는 조금 복잡해 보일 수 있지만, 그 구조와 몇 가지 기본 쿼리만 익히면 RDB의 SQL로는 상상도 못 할 다채롭고 강력한 검색 로직을 구현할 수 있게 됩니다.
4.1. 가장 기본, 가장 강력한 `match` 쿼리
`match` 쿼리는 전문 검색의 가장 기본이 되는 쿼리입니다. 사용자가 입력한 검색어를 '분석(Analyze)'한 뒤, 역색인에 있는 토큰들과 비교하여 일치하는 문서를 찾아줍니다.
예를 들어, 사용자가 "스프링 엔진"이라고 검색하면, ES의 분석기는 이 검색어를 "스프링", "엔진"이라는 두 개의 토큰으로 쪼갭니다. 그리고 `title`이나 `content` 필드에 "스프링" 또는 "엔진"이라는 토큰이 포함된 문서를 모두 찾아줍니다. (기본적으로 OR 조건으로 동작합니다.)
Spring Data Elasticsearch에서는 Repository 인터페이스에 간단한 메서드를 추가하는 것만으로 `match` 쿼리를 실행할 수 있습니다.
`ArticleRepository` 인터페이스에 아래 메서드를 추가해봅시다.
// ... 기존 코드 ...
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
public interface ArticleRepository extends ElasticsearchRepository<ArticleDocument, String> {
// 메서드 이름 규칙을 이용한 쿼리 생성
// title 필드에서 keyword를 포함하는 문서를 찾는다.
Page<ArticleDocument> findByTitle(String keyword, Pageable pageable);
}
Spring Data는 메서드 이름을 분석해서(`findBy{필드이름}`) 자동으로 쿼리를 만들어줍니다. 정말 편리하죠? 이제 Service와 Controller에 이 메서드를 사용하는 코드를 추가해봅시다.
// ...
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
// ...
public Page<ArticleDocument> searchByTitle(String keyword, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
return articleRepository.findByTitle(keyword, pageable);
}
// ...
// ...
import org.springframework.data.domain.Page;
// ...
@GetMapping("/search/title")
public ResponseEntity<Page<ArticleDocument>> searchByTitle(@RequestParam String keyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
Page<ArticleDocument> results = articleService.searchByTitle(keyword, page, size);
return ResponseEntity.ok(results);
}
// ...
애플리케이션을 재시작하고, Postman으로 요청을 보내봅시다.
- URL: `GET http://localhost:8080/api/articles/search/title?keyword=Spring`
`title`에 "Spring"이 포함된 "Spring Boot와 Elasticsearch 연동하기" 문서가 검색 결과로 나올 겁니다. 이번엔 "연동"으로 검색해볼까요?
- URL: `GET http://localhost:8080/api/articles/search/title?keyword=연동`
마찬가지로 해당 문서가 잘 검색됩니다. 이것이 바로 분석기를 통해 텍스트가 토큰 단위로 쪼개져 저장되었기 때문에 가능한 일입니다.
4.2. 여러 필드를 동시에! `multi_match` 쿼리
보통 사용자들은 검색창 하나에 검색어를 입력하죠. 우리는 그 검색어를 제목에서도 찾아보고, 본문에서도 찾아봐야 합니다. 이럴 때 사용하는 것이 `multi_match` 쿼리입니다.
`multi_match`는 여러 필드를 대상으로 동시에 `match` 쿼리를 실행해줍니다. 하지만 메서드 이름만으로는 이렇게 복잡한 쿼리를 만들기 어렵습니다. 이럴 때 `@Query` 어노테이션을 사용해서 직접 Query DSL을 작성할 수 있습니다.
`ArticleRepository`에 아래 메서드를 추가해봅시다.
// ...
import org.springframework.data.elasticsearch.annotations.Query;
// ...
@Query("{\"multi_match\": {\"query\": \"?0\", \"fields\": [\"title\", \"content\"]}}")
Page<ArticleDocument> findByTitleOrContent(String keyword, Pageable pageable);
🚨 잠깐! 이게 뭐죠? 🚨
@Query(...) 안에 들어간 문자열이 바로 Elasticsearch의 Query DSL입니다. JSON 형식이죠.
"multi_match": multi_match 쿼리를 사용하겠다는 의미입니다."query": "?0": 검색할 키워드를 지정합니다.?0은 메서드의 첫 번째 파라미터(여기서는 `keyword`)가 이 위치에 들어온다는 의미의 플레이스홀더입니다.?1은 두 번째 파라미터겠죠?"fields": ["title", "content"]: 검색을 수행할 필드 목록을 지정합니다. `title`과 `content` 필드 양쪽에서 `keyword`를 찾아달라는 뜻입니다.
이제 이 새로운 메서드를 사용하는 Service와 Controller 로직을 추가하고 테스트해보세요. 예를 들어, `content`에만 있는 "성능"이라는 단어로 검색해도, `title`과 `content`를 모두 뒤지기 때문에 "대용량 데이터 검색 시스템 구축" 문서가 잘 검색될 겁니다.
4.3. 더 정교한 검색을 원해? `ElasticsearchRestTemplate`
`@Query` 어노테이션은 편리하지만, 검색 조건이 동적으로 계속 바뀌는 복잡한 상황에서는 한계가 있습니다. 예를 들어, 사용자가 키워드도 입력하고, 특정 작성자를 선택하고, 날짜 범위까지 지정해서 검색하는 경우를 생각해보세요. 모든 경우의 수에 맞춰 `@Query`를 미리 만들어 둘 수는 없겠죠?
이럴 때 사용하는 것이 바로 `ElasticsearchRestTemplate` (혹은 `ElasticsearchOperations`) 입니다. 이 템플릿을 사용하면 Java 코드로 Query DSL을 직접 조립해서 동적으로 쿼리를 생성하고 실행할 수 있습니다. 훨씬 유연하고 강력하죠.
`ArticleService`를 수정해서 `ElasticsearchRestTemplate`을 사용한 검색 메서드를 만들어 보겠습니다.
package com.example.estutorial.service;
// ... imports ...
import org.springframework.data.elasticsearch.client.elc.NativeQuery;
import org.springframework.data.elasticsearch.client.elc.NativeQueryBuilder;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.Query;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ArticleService {
private final ArticleRepository articleRepository;
private final ElasticsearchOperations elasticsearchOperations; // 주입 받기
// ... 기존 save, findById, findAll 메서드 ...
public List<ArticleDocument> searchDynamically(String keyword, String author) {
// bool 쿼리를 사용해 동적으로 쿼리를 조합합니다.
var boolQuery = new co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery.Builder();
// keyword가 존재하면 multi_match 쿼리를 must 조건에 추가
if (StringUtils.hasText(keyword)) {
boolQuery.must(m -> m
.multiMatch(mm -> mm
.query(keyword)
.fields("title", "content")
)
);
}
// author가 존재하면 term 쿼리를 filter 조건에 추가
if (StringUtils.hasText(author)) {
boolQuery.filter(f -> f
.term(t -> t
.field("author") // Keyword 타입 필드이므로 term 쿼리 사용
.value(author)
)
);
}
// NativeQuery 객체로 최종 쿼리 생성
Query query = NativeQuery.builder()
.withQuery(q -> q.bool(boolQuery.build()))
.build();
// ElasticsearchOperations를 사용해 검색 실행
SearchHits<ArticleDocument> searchHits = elasticsearchOperations.search(query, ArticleDocument.class);
// SearchHit에서 실제 Document 객체만 추출하여 리스트로 반환
return searchHits.stream()
.map(SearchHit::getContent)
.collect(Collectors.toList());
}
}
💡 `must` vs `filter` 그리고 `term` vs `match` 💡
위 코드에서 중요한 개념들이 등장했습니다.
- `bool` 쿼리: 여러 쿼리들을 조합할 때 사용하는 '컨테이너' 쿼리입니다.
- `must` 절: 여기에 포함된 모든 조건이 반드시 일치해야 합니다. 검색 결과의 '점수(score)'에 영향을 줍니다. (SQL의 `AND`와 유사)
- `filter` 절: 여기에 포함된 모든 조건이 반드시 일치해야 합니다. 하지만 점수에는 영향을 주지 않고, 단순히 결과 문서를 필터링하는 역할만 합니다. `must`보다 성능이 빠르기 때문에, 점수 계산이 필요 없는 단순 필터링 조건(카테고리, 작성자, 상태 등)은 `filter`에 넣는 것이 좋습니다.
- 이 외에도 `should`(OR 조건), `must_not`(NOT 조건) 등이 있습니다.
- `term` 쿼리: `match` 쿼리와 달리, 입력된 검색어를 분석하지 않고 정확히 일치하는 값을 찾습니다. `Keyword` 타입으로 지정된 필드를 검색할 때 주로 사용됩니다. "jaenung-dev"를 찾기 위해 `term` 쿼리를 사용하면, 역색인에 저장된 "jaenung-dev" 토큰과 정확히 일치하는 문서를 찾습니다. 만약 `match` 쿼리를 썼다면 "jaenung", "dev"로 분석될 수 있어 원치 않는 결과가 나올 수 있습니다.
이제 Controller에도 이 동적 검색 메서드를 호출하는 엔드포인트를 추가하고, 다양한 파라미터 조합으로 테스트해보세요. 키워드만 넣었을 때, 작성자만 넣었을 때, 둘 다 넣었을 때 모두 의도한 대로 동작하는 것을 확인할 수 있을 겁니다. 이것이 바로 실무에서 사용하는 유연한 검색 로직의 핵심입니다!
5. 한국어 검색 레벨업: Nori 분석기 적용하기 🇰🇷
지금까지 만든 검색 기능은 영어에서는 꽤 잘 동작하지만, 한국어에서는 아직 부족한 점이 많습니다. 앞서 잠깐 언급했듯이, ES의 기본 분석기는 한국어의 교착어 특성(조사, 어미 변화 등)을 제대로 처리하지 못하기 때문입니다.
"Elasticsearch를" 이라고 저장된 문서를 "Elasticsearch"로 검색하지 못하는 문제를 해결해봅시다. Elastic에서 공식적으로 제공하는 한국어 형태소 분석기 플러그인, Nori를 적용할 차례입니다.
5.1. Nori 분석기란?
Nori는 일본어 형태소 분석기인 Kuromoji를 기반으로 만들어진, ES 공식 한국어 분석기입니다. Nori는 다음과 같은 똑똑한 작업을 수행합니다.
- 형태소 분석: 문장을 의미를 가지는 최소 단위인 형태소로 분리합니다. ("Elasticsearch를" → "Elasticsearch", "를")
- 품사 태깅: 각 형태소의 품사를 식별합니다. (명사, 동사, 조사 등)
- 불용어 처리: "를", "은", "는", "이", "가" 와 같이 검색에 큰 의미가 없는 조사나 어미(불용어)를 제거할 수 있습니다.
- 동의어 처리: "es"를 "엘라스틱서치"로 인식하게 하는 등 동의어 사전을 구축할 수 있습니다.
이런 과정을 통해, 우리는 훨씬 더 정확하고 풍부한 한국어 검색 결과를 얻을 수 있습니다.
5.2. 커스텀 분석기(Custom Analyzer) 설정하기
Nori를 사용하려면, 먼저 인덱스를 생성할 때 "이 인덱스의 특정 필드는 Nori 분석기를 사용해서 분석해줘!" 라고 ES에게 알려줘야 합니다. 즉, 인덱스의 매핑(Mapping) 정보에 커스텀 분석기 설정을 추가해야 합니다.
Kibana의 Dev Tools를 열고, 기존에 만들었던 `articles` 인덱스를 삭제하고 새로운 설정과 함께 다시 생성해봅시다.
⚠️ 주의: 데이터가 삭제됩니다! ⚠️
아래 명령어는 `articles` 인덱스를 삭제하므로, 기존에 저장했던 데이터는 모두 사라집니다. 실습 환경이므로 과감하게 실행해주세요. 운영 환경에서는 절대 함부로 실행하면 안 됩니다!
# 1. 기존 인덱스 삭제
DELETE articles
# 2. Nori 분석기를 포함한 새로운 인덱스 생성
PUT articles
{
"settings": {
"analysis": {
"analyzer": {
"nori_analyzer": {
"type": "custom",
"tokenizer": "nori_tokenizer",
"filter": [ "nori_pos_filter" ]
}
},
"filter": {
"nori_pos_filter": {
"type": "nori_part_of_speech",
"stoptags": [
"E", "J", "XSV", "XSA", "SF", "SP", "SSC", "SSO", "SC", "SE"
]
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "nori_analyzer"
},
"content": {
"type": "text",
"analyzer": "nori_analyzer"
},
"author": {
"type": "keyword"
},
"createdAt": {
"type": "date",
"format": "uuuu-MM-dd'T'HH:mm:ss.SSS"
}
}
}
}
🧐 새로운 설정 파헤치기 🧐
"settings": 인덱스 레벨의 설정을 정의하는 곳입니다. 여기에 분석기(analyzer), 토크나이저(tokenizer), 필터(filter) 등을 커스텀하게 정의할 수 있습니다."analyzer": { "nori_analyzer": ... }: "nori_analyzer"라는 이름의 우리만의 커스텀 분석기를 정의합니다."tokenizer": "nori_tokenizer": 토크나이저는 Nori의 기본 토크나이저를 사용합니다."filter": [ "nori_pos_filter" ]: 토큰화 이후에 적용할 필터를 지정합니다.
"filter": { "nori_pos_filter": ... }: "nori_pos_filter"라는 이름의 커스텀 필터를 정의합니다."type": "nori_part_of_speech": Nori의 품사(Part-of-Speech) 필터를 사용하겠다는 의미입니다."stoptags": [...]: 여기서 지정한 품사 태그에 해당하는 토큰은 제거하라는 설정입니다. `E`(어미), `J`(조사) 등을 제거해서 "스프링을" -> "스프링"만 남도록 합니다.
"mappings": 필드별 설정을 정의하는 곳입니다."title": { "analyzer": "nori_analyzer" }: 가장 중요한 부분! `title` 필드는 앞으로 우리가 위에서 정의한 `nori_analyzer`를 사용해서 분석하라고 명시적으로 지정해줍니다. `content` 필드도 마찬가지입니다.
위 쿼리를 Kibana에서 실행해서 새로운 `articles` 인덱스를 생성해주세요.
5.3. Spring Boot 애플리케이션과의 연동
"어? 그럼 Spring Boot 코드도 다 바꿔야 하나요?" 라고 생각할 수 있지만, 놀랍게도 그럴 필요가 거의 없습니다. Spring Boot 애플리케이션은 그저 ES에 데이터를 보내고 쿼리를 날리는 역할만 할 뿐, 데이터가 '어떻게' 분석되고 저장되는지는 전적으로 ES의 인덱스 설정에 달려있기 때문입니다.
다만, 애플리케이션 실행 시점에 Spring Data Elasticsearch가 자동으로 인덱스를 생성하고 매핑을 적용하려는 동작을 막아야 합니다. 우리가 이미 Kibana에서 최적의 설정을 마친 인덱스를 덮어쓰면 안 되니까요.
`ArticleDocument.java` 클래스의 `@Document` 어노테이션에 속성을 추가해줍시다.
// ...
@Getter
@Document(indexName = "articles", createIndex = false) // createIndex = false 추가
public class ArticleDocument {
// ...
createIndex = false 옵션은 애플리케이션이 시작될 때 이 Document에 해당하는 인덱스가 없더라도 자동으로 생성하지 말라는 의미입니다. 우리는 이미 수동으로 최적의 인덱스를 만들어뒀으니, 이 옵션을 켜서 자동 생성을 방지하는 것이 안전합니다.
5.4. 결과 확인: 확연히 달라진 검색 퀄리티
이제 모든 준비가 끝났습니다. Spring Boot 애플리케이션을 재시작하고, Postman을 통해 아까와 동일한 데이터를 다시 저장해주세요.
- `POST http://localhost:8080/api/articles`
- `title`: Spring Boot와 Elasticsearch 연동하기
- `content`: Elasticsearch는 정말 강력한 검색 엔진입니다. Spring Data를 사용하면 쉽게 연동할 수 있습니다.
- `author`: jaenung-dev
데이터가 저장되었다면, 이제 검색을 테스트해봅시다.
- URL: `GET http://localhost:8080/api/articles/search/title?keyword=연동`
이전과 동일하게 "Spring Boot와 Elasticsearch 연동하기" 문서가 잘 검색됩니다. 하지만 진짜 차이는 지금부터입니다.
- URL: `GET http://localhost:8080/api/articles/search/title?keyword=연동해`
놀랍게도, "연동해" 라는, 원문에는 없는 단어로 검색했는데도 결과가 나옵니다! Nori 분석기가 "연동하기"와 "연동해" 모두에서 핵심 형태소인 "연동"을 추출해냈기 때문입니다.
Kibana Dev Tools에서 `_analyze` API를 사용하면 Nori가 어떻게 동작하는지 직접 확인할 수 있습니다.
GET articles/_analyze
{
"analyzer": "nori_analyzer",
"text": "스프링부트와 연동하기"
}
위 쿼리를 실행하면, Nori 분석기가 "스프링부트와 연동하기"라는 텍스트를 어떻게 토큰으로 분리하는지 보여줍니다. 결과는 아마 `["스프링", "부트", "연동"]` 처럼 나올 겁니다. 조사("와")와 어미("하기")가 깔끔하게 제거되고 핵심 명사만 남았죠. 이것이 바로 고품질 한국어 검색의 비밀입니다. ✨
6. 실무 스킬 장착: 고급 기능 및 고려사항 🛠️
지금까지 우리는 Spring Boot와 ES를 연동하여 기본적인 CRUD와 핵심적인 전문 검색 기능을 구현했습니다. 이 정도만으로도 많은 문제를 해결할 수 있지만, 실제 운영 환경에서는 조금 더 섬세한 기능과 고민이 필요합니다. 이번 섹션에서는 여러분의 검색 기능을 한 단계 더 업그레이드해 줄 고급 스킬과 실무적인 고려사항들을 다뤄보겠습니다.
6.1. 검색 결과 하이라이팅 (Highlighting)
구글에서 검색하면 검색어와 일치하는 부분이 굵은 글씨로 표시되죠? 이걸 하이라이팅이라고 합니다. 사용자에게 어떤 키워드 때문에 이 문서가 검색되었는지 직관적으로 보여주는 아주 중요한 기능입니다. ES는 이 기능을 아주 쉽게 구현할 수 있도록 지원합니다.
`ElasticsearchRestTemplate`을 사용하는 동적 검색 로직에 하이라이팅 기능을 추가해봅시다.
// ...
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.query.HighlightQuery;
import org.springframework.data.elasticsearch.core.query.highlight.Highlight;
import org.springframework.data.elasticsearch.core.query.highlight.HighlightField;
import org.springframework.data.elasticsearch.core.query.highlight.HighlightParameters;
import java.util.List;
import java.util.stream.Collectors;
// ...
public List<ArticleDocument> searchDynamicallyWithHighlight(String keyword, String author) {
// ... 이전 boolQuery 만드는 로직은 동일 ...
var boolQuery = new co.elastic.clients.elasticsearch._types.query_dsl.BoolQuery.Builder();
// ...
// 하이라이트 설정 추가
HighlightField titleHighlight = new HighlightField("title");
HighlightField contentHighlight = new HighlightField("content");
HighlightParameters highlightParameters = HighlightParameters.builder()
.withPreTags("<mark>") // 하이라이팅 시작 태그
.withPostTags("</mark>") // 하이라이팅 끝 태그
.build();
Highlight highlight = new Highlight(highlightParameters, List.of(titleHighlight, contentHighlight));
Query query = NativeQuery.builder()
.withQuery(q -> q.bool(boolQuery.build()))
.withHighlightQuery(new HighlightQuery(highlight, ArticleDocument.class)) // 하이라이트 쿼리 추가
.build();
SearchHits<ArticleDocument> searchHits = elasticsearchOperations.search(query, ArticleDocument.class);
// 하이라이트된 결과를 Document에 반영
return searchHits.stream()
.map(searchHit -> {
ArticleDocument document = searchHit.getContent();
List<String> titleHighlights = searchHit.getHighlightField("title");
if (!titleHighlights.isEmpty()) {
// 실제 서비스에서는 DTO를 만들어 하이라이트된 필드를 따로 관리하는 것이 좋습니다.
// 여기서는 예제 편의상 원본 title을 덮어씁니다.
document.updateTitle(String.join(" ", titleHighlights));
}
// content에 대한 하이라이트 처리도 동일하게 추가할 수 있습니다.
return document;
})
.collect(Collectors.toList());
}
// ArticleDocument 클래스에 setter 메서드 추가 필요
// public void updateTitle(String title) { this.title = title; }
위 코드처럼 `NativeQuery`를 빌드할 때 `withHighlightQuery`를 통해 어떤 필드를 어떻게 하이라이트할지 지정할 수 있습니다. `preTags`와 `postTags`를 사용해서 원하는 HTML 태그(예: ``, ``)로 감쌀 수 있습니다.
검색 결과(`SearchHits`)에는 `getHighlightField()` 메서드가 있어서, 각 문서별로 하이라이트된 텍스트 조각(snippet)을 가져올 수 있습니다. 이 값을 프론트엔드로 전달해서 화면에 렌더링하면, 사용자들은 훨씬 더 만족스러운 검색 경험을 하게 될 겁니다.
6.2. 데이터 동기화: RDB와 ES를 어떻게 친구로 만들까?
대부분의 서비스는 주 데이터 저장소로 RDB(MySQL, PostgreSQL 등)를 사용하고, 검색 기능 강화를 위해 ES를 보조적으로 사용합니다. 이때 가장 큰 숙제는 'RDB의 데이터가 변경될 때마다 어떻게 ES의 데이터를 최신 상태로 유지할 것인가?', 즉 데이터 동기화입니다.
몇 가지 대표적인 전략이 있습니다. 서비스의 특성과 규모에 맞게 적절한 방법을 선택해야 합니다.
데이터 동기화 전략들
-
Dual Write (이중 쓰기): 가장 간단한 방법입니다. RDB에 데이터를 저장/수정/삭제하는 비즈니스 로직에서, ES에 데이터를 색인/수정/삭제하는 코드를 함께 호출하는 방식입니다.
- 장점: 구현이 매우 간단하고 직관적입니다.
- 단점: 두 저장소 중 하나라도 실패했을 때 데이터 정합성을 맞추기 어렵습니다. (e.g., RDB 저장엔 성공했는데, ES 색인은 실패한 경우) 트랜잭션으로 묶을 수 없다는 치명적인 단점이 있습니다.
-
Asynchronous Event-Driven (비동기 이벤트 기반): RDB에 데이터 변경이 발생하면, 이벤트를 발행(Publish)하고, 별도의 리스너(Subscriber)가 이 이벤트를 받아서 ES에 비동기적으로 데이터를 반영하는 방식입니다. Spring의 `@TransactionalEventListener`나 메시지 큐(Kafka, RabbitMQ)를 사용할 수 있습니다.
- 장점: RDB 트랜잭션과 ES 색인 작업이 분리되어 서비스의 응답 속도에 영향을 주지 않고, 시스템 간의 결합도를 낮출 수 있습니다.
- 단점: 메시지 큐 같은 별도의 인프라 구축이 필요할 수 있고, 구현 복잡도가 약간 증가합니다. 아주 짧은 시간 동안 데이터 불일치가 발생할 수 있습니다.
-
CDC (Change Data Capture): Logstash, Debezium 같은 도구를 사용해서 RDB의 트랜잭션 로그(e.g., MySQL의 binlog)를 감시하다가, 변경 사항이 발생하면 이를 감지해서 ES로 자동으로 전송하는 방식입니다.
- 장점: 애플리케이션 코드에 동기화 로직이 전혀 들어가지 않아 비즈니스 로직이 깔끔해집니다. 데이터 정합성과 신뢰성이 매우 높습니다.
- 단점: CDC 파이프라인을 구축하고 운영해야 하는 인프라적인 부담이 있습니다.
초기 스타트업이나 소규모 프로젝트에서는 비동기 이벤트 기반 방식이 가장 합리적인 선택일 수 있습니다. 예를 들어, JPA 엔티티가 저장된 후(`@PostPersist`) 이벤트를 발행하고, 이 이벤트를 처리하는 리스너에서 `ArticleDocument`로 변환하여 ES에 저장하는 식이죠.
6.3. 그 외 고려사항들
- 인덱스 관리: 시간이 지나 데이터 스키마가 변경되어야 할 때, 기존 인덱스의 매핑은 수정할 수 없습니다. 보통 'Reindexing'이라는 과정을 거칩니다. 새로운 매핑으로 새 인덱스를 만들고, 기존 인덱스의 데이터를 새 인덱스로 옮긴 다음, 서비스가 바라보는 인덱스를 교체(Alias 사용)하는 방식입니다.
- 성능 최적화: 대량의 데이터를 처음 ES로 옮길 때는 `save()`를 하나씩 호출하는 것보다, 여러 문서를 묶어서 한 번에 요청하는 `Bulk API`를 사용하는 것이 훨씬 효율적입니다. Spring Data Elasticsearch도 `saveAll()` 메서드나 `ElasticsearchRestTemplate`의 `bulkIndex()`를 통해 이를 지원합니다.
- 운영: ES 클러스터는 생각보다 많은 메모리를 사용하며, 안정적인 운영을 위해서는 꾸준한 모니터링이 필요합니다. Kibana의 모니터링 기능이나, 클라우드 서비스(AWS Elasticsearch Service, Elastic Cloud 등)를 활용하면 운영 부담을 크게 줄일 수 있습니다.
이러한 실무적인 내용들은 그 자체로도 하나의 거대한 주제입니다. 하지만 이런 키워드들을 알고 있는 것만으로도, 앞으로 여러분이 마주할 문제들을 해결해나갈 좋은 실마리가 될 것입니다. 다양한 전문가들의 경험이 공유되는 재능넷 같은 플랫폼에서 관련 노하우를 찾아보는 것도 좋은 방법이 될 수 있겠죠.
마치며: 검색의 새로운 문을 열다 🚪
축하합니다! 여러분은 방금 Spring Boot와 Elasticsearch라는 강력한 조합을 사용해, 답답했던 `LIKE` 검색의 세계에서 벗어나 현대적이고 강력한 전문 검색 시스템의 첫걸음을 내디뎠습니다.
우리는 ES의 핵심 원리인 '역색인'부터 시작해서, Docker를 이용한 간편한 개발 환경 구축, Spring Data Elasticsearch를 활용한 우아한 CRUD 구현, 그리고 Query DSL과 `ElasticsearchRestTemplate`을 이용한 실전적인 검색 기능까지, 꽤나 긴 여정을 함께했습니다. 특히 한국어 검색의 품질을 비약적으로 향상시키는 Nori 분석기의 적용은 여러분의 서비스를 한 단계 더 높은 수준으로 이끌어 줄 것입니다.
오늘 우리가 다룬 내용은 Elasticsearch가 가진 거대한 능력의 일부에 불과합니다. 이 외에도 집계(Aggregations) 기능을 이용한 데이터 분석 및 통계, 추천 시스템을 위한 More Like This 쿼리, 지리 정보를 다루는 Geo 쿼리 등 무궁무진한 가능성이 여러분을 기다리고 있습니다.
두려워하지 마세요. 가장 중요한 첫걸음은 이미 떼었으니까요. 이제 여러분의 서비스에 검색이 필요한 곳이 어디인지 둘러보세요. 사용자들이 불편해하던 그 검색창이, 이제는 여러분의 새로운 기술을 뽐낼 수 있는 멋진 무대가 될 수 있습니다.
오늘 배운 내용을 바탕으로 직접 코드를 짜보고, 다양한 쿼리를 실험해보며 여러분의 것으로 만들어나가는 과정을 즐기시길 바랍니다. 이제 여러분은 '검색 기능'에 자신감을 가진 개발자로 거듭났습니다. Happy Coding! 🎉
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.