Docker · Elasticsearch · Spring Boot로 검색 기능 구현하기
개념은 알겠고, 이제 붙여보자
역색인부터 집계, MultiMatch까지 봤으니, 실제로 Spring Boot + Docker Elasticsearch로 검색을 붙여봅니다.
그런데 이 글의 목표는 “동작하는 코드”가 아닙니다. 검색은 돌아가게 만들기는 쉽고 원하는 결과가 나오게 만들기가 어렵습니다. 대부분의 “왜 검색이 안 잡히지?”는 RDB 감각으로 Elasticsearch를 다뤄서 생기는 문제 — 분석기(analyzer)와 역색인(inverted index)이 색인·검색 양쪽에서 무슨 일을 하는지를 모르면 평생 헤맵니다. 그래서 코드보다 그 원리를 먼저 깔고 갑니다.
핵심 원리: 색인할 때와 검색할 때 “같은 분석기”가 돈다
RDB의 LIKE '%spring%'는 저장된 문자열을 그대로 훑습니다. Elasticsearch는 다릅니다. text 필드는 색인 시점에 분석기를 거쳐 토큰(term)으로 쪼개져 역색인(term → 그 term을 가진 문서 목록)에 저장됩니다. 그리고 match 쿼리는 검색어도 같은 분석기로 쪼갠 뒤, 그 term들을 역색인에서 찾습니다.
이 “양쪽에서 같은 분석”이 모든 것의 핵심입니다. 아래 흐름을 움직임으로 먼저 보세요 — 초록은 색인되는 문서, 파랑은 검색어, 주황은 매칭 결과입니다.
여기서 단번에 풀리는 함정 하나: text 필드에 term(정확 매칭) 쿼리를 날리면 거의 안 잡힙니다. term은 검색어를 분석하지 않는데, 색인된 값은 이미 소문자·토큰으로 쪼개져 있기 때문입니다. "Spring Boot"를 색인하면 역색인엔 spring, boot가 들어가는데 term: "Spring Boot"로 찾으니 매칭 0. 이게 입문자 검색 버그 1위입니다.
전체 구조
flowchart LR
C[클라이언트] -->|검색 요청| A["Spring Boot<br/>(REST API)"]
A -->|Query DSL| E["Elasticsearch<br/>(Docker)"]
E -->|역색인 검색 결과| A
A -->|JSON 응답| C
1. Docker로 Elasticsearch 띄우기
로컬 개발용으로는 단일 노드면 충분합니다.
1
2
3
4
5
6
7
8
9
10
# docker-compose.yml
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:8.15.0
environment:
- discovery.type=single-node
- xpack.security.enabled=false # 로컬 개발 한정! 운영은 보안 켜기
- ES_JAVA_OPTS=-Xms512m -Xmx512m
ports:
- "9200:9200"
1
2
docker compose up -d
curl localhost:9200 # 응답 오면 OK
xpack.security.enabled=false는 로컬 개발 전용입니다. 운영에서는 보안을 켜고 인증(API key/TLS)을 설정하세요.
2. 의존성과 클라이언트 — 무엇이 실제로 호출되나
1
implementation 'org.springframework.boot:spring-boot-starter-data-elasticsearch'
1
2
3
spring:
elasticsearch:
uris: http://localhost:9200
여기서 버전 정합이 중요합니다. Spring Data Elasticsearch 5.x(Spring Boot 3.x)부터는 내부 전송 계층이 새 Elasticsearch Java API Client(co.elastic.clients.elasticsearch.ElasticsearchClient)로 교체됐습니다. 예전 글에서 보던 RestHighLevelClient/TransportClient는 제거됐으니, 옛 예제의 QueryBuilders.matchQuery(...) 같은 코드는 그대로 복붙하면 컴파일조차 안 됩니다.
우리가 직접 다루는 추상화 계층은 두 개입니다.
| 계층 | 타입 | 언제 쓰나 |
|---|---|---|
| 선언적 리포지토리 | ElasticsearchRepository<T, ID> | 단순 CRUD·파생 쿼리 |
| 템플릿(명령형) | ElasticsearchOperations (구현 ElasticsearchTemplate) | 복잡 쿼리·집계·하이라이트 |
| 저수준 클라이언트 | ElasticsearchClient | 매핑/인덱스 관리 등 직접 호출 |
ElasticsearchRepository는 우리가 인터페이스만 선언하면 런타임 프록시(RepositoryFactorySupport → SimpleElasticsearchRepository)로 구현체가 만들어집니다. Spring Data JPA 리포지토리가 프록시로 동작하는 것과 완전히 같은 메커니즘입니다(자세한 프록시 원리는 Spring Data JPA 글 참고).
3. 문서 매핑 — text vs keyword가 전부다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Document(indexName = "posts")
public class Post {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "standard") // 전문 검색(분석됨)
private String title;
@MultiField( // 한 필드를 두 방식으로
mainField = @Field(type = FieldType.Text),
otherFields = @InnerField(suffix = "raw", type = FieldType.Keyword)
)
private String name; // name → match, name.raw → 정렬/집계
@Field(type = FieldType.Keyword) // 정확매칭/필터/집계/정렬
private String status;
@Field(type = FieldType.Date)
private Instant createdAt;
// getters/setters
}
두 타입의 차이가 검색 동작을 가릅니다.
text | keyword | |
|---|---|---|
| 분석 | O (토큰화) | X (값 그대로) |
| 쿼리 | match(전문) | term(정확) |
| 정렬·집계 | 기본 불가 | 가능 |
| 용도 | 본문·제목 검색 | 상태·코드·태그·정렬키 |
@MultiField로 한 필드를 text(검색용) + keyword(정렬/집계용)로 동시에 색인하는 패턴이 실무 표준입니다.
4. Repository와 명령형 검색
1
2
3
public interface PostRepository extends ElasticsearchRepository<Post, String> {
List<Post> findByTitle(String title); // 내부적으로 match 쿼리로 변환
}
복잡한 검색(다중 필드·정렬·하이라이트)은 ElasticsearchOperations + NativeQuery로 작성합니다. 새 클라이언트는 람다 빌더 DSL입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Service
@RequiredArgsConstructor
public class PostSearchService {
private final ElasticsearchOperations operations;
public SearchHits<Post> search(String keyword) {
var query = NativeQuery.builder()
.withQuery(q -> q.multiMatch(mm -> mm
.query(keyword)
.fields("title^3", "body"))) // title에 가중치 3배
.withSort(so -> so.field(f -> f.field("createdAt").order(SortOrder.Desc)))
.withHighlightQuery(new HighlightQuery( // 매칭 구간 하이라이트
new Highlight(List.of(new HighlightField("title"))), Post.class))
.withPageable(PageRequest.of(0, 20))
.build();
return operations.search(query, Post.class);
}
}
SearchHits를 그대로 반환하면 점수(score)·하이라이트 조각까지 꺼낼 수 있습니다. SearchHit::getContent로 본문만 뽑는 건 그 정보를 버리는 것이니, 검색 UI라면 SearchHits를 살려 쓰세요.
프로덕션 함정 4가지
① text 필드 정렬/집계 → fielddata 폭탄 text 필드로 정렬하거나 집계하면 Fielddata is disabled 에러가 납니다. 굳이 켜면(fielddata=true) 힙 메모리를 통째로 먹어 노드가 죽습니다. 정답은 keyword 서브필드(name.raw)로 정렬·집계하는 것.
② 매핑은 한 번 정해지면 못 바꾼다 → reindex 이미 있는 필드의 타입(예: text→keyword)은 변경 불가입니다. 새 인덱스를 만들고 _reindex API로 옮긴 뒤 별칭(alias)을 교체하는 게 무중단 마이그레이션 패턴입니다. 운영 인덱스는 처음부터 posts 대신 별칭 posts(→ posts-v1)로 시작하세요.
③ 색인 직후 검색이 안 됨 → near-real-time Elasticsearch는 색인 즉시 검색되지 않습니다. refresh(기본 1초)가 일어나야 보입니다. 테스트에서 저장 직후 검색이 0건이면 버그가 아니라 정상 — operations.indexOps(Post.class).refresh()로 강제하거나 잠깐 기다리면 됩니다. 반대로 대량 색인 시엔 refresh_interval을 -1로 껐다가 끝나고 되돌려야 throughput이 수 배 오릅니다.
④ 한글이 통째로 한 토큰이 됨 → 분석기 기본 standard 분석기는 한글을 형태소로 못 쪼갭니다. “검색엔진을”이 한 토큰이 되어 “검색”으로 안 잡히죠. nori 플러그인(analysis-nori)을 이미지에 설치하고 인덱스 분석기로 지정해야 합니다.
1
2
3
4
5
6
@Document(indexName = "posts")
@Setting(settingPath = "es/nori-settings.json") // nori analyzer 정의
public class Post {
@Field(type = FieldType.Text, analyzer = "korean")
private String title;
}
1
2
# nori는 기본 미포함 → 이미지에 설치
RUN bin/elasticsearch-plugin install --batch analysis-nori
디버깅: 분석 결과를 직접 눈으로 확인
“왜 안 잡히지?”의 99%는 _analyze로 끝납니다. 색인된 term이 무엇인지 직접 봅니다.
1
2
3
curl -X POST "localhost:9200/posts/_analyze" -H 'Content-Type: application/json' -d'
{ "analyzer": "korean", "text": "스프링 부트 검색엔진" }'
# → 토큰들이 나옴: 색인된 term과 검색어 term이 일치하는지 비교
매핑이 의도대로 됐는지는 GET /posts/_mapping, 어떤 쿼리가 매칭했는지는 검색에 "explain": true.
면접/리뷰 단골 질문
- Q.
match와term의 차이는? →match는 검색어를 분석기로 토큰화해 역색인과 비교(전문 검색),term은 분석 없이 정확히 비교.text필드에term을 쓰면 보통 안 잡힌다. - Q.
text로 정렬이 안 되는 이유는? →text는 토큰화되어 역색인에만 들어가고 원본 정렬 자료구조(doc values)가 없다. 정렬·집계는keyword(doc values)로 해야 한다. - Q. 색인 직후 검색이 0건인데 버그인가? → 아니다. NRT라 refresh(기본 1s) 전엔 안 보인다. 대량 색인은 refresh를 끄고 throughput을 올린다.
정리
text는 색인·검색 양쪽에서 같은 분석기로 토큰화되어 역색인에 들어간다 →match로 검색.keyword는 분석 없이 정확 매칭·정렬·집계용.- Spring Data Elasticsearch 5.x는 새
ElasticsearchClient기반. 리포지토리는 JPA와 같은 프록시, 복잡 쿼리는ElasticsearchOperations+NativeQuery람다 DSL. - 함정:
text정렬/집계(fielddata)·매핑 변경 불가(reindex+alias)·NRT(refresh)·한글(nori). - 막히면 코드 말고
_analyze로 토큰을 직접 확인하라.
