C++ Elasticsearch 통합 | 전문 검색·집계·실시간 인덱싱 [#52-5]
이 글의 핵심
Elasticsearch 핵심 개념: 역인덱스, 전문 검색, 집계, 벌크 인덱싱. 실무 문제 시나리오, 완전한 REST API 예제, 자주 발생하는 에러, 성능 최적화, 프로덕션 패턴까지 C++ 연동 전 필수 지식.
들어가며: “로그 검색이 10초 넘게 걸려요”
실제 겪는 문제 시나리오
C++ 서버에서 로그를 저장하고 검색하는 기능을 구현할 때, 관계형 DB나 단순 파일 검색의 한계에 부딪힙니다.
시나리오 1: 수백만 건 로그에서 키워드 검색이 너무 느림
상황: MySQL에 로그를 저장하고 LIKE '%error%' 또는 LIKE '%timeout%'로 검색
문제: 풀 테이블 스캔 발생, 인덱스가 와일드카드 앞부분 검색에 무력함
결과: 500만 건에서 10~30초 소요, 실시간 모니터링 불가
시나리오 2: 전문 검색(Full-Text Search) 필요
상황: 제품 설명에서 "무선 이어폰 블루투스 노이즈캔슬링"으로 검색
문제: 단어 분리, 유사어 매칭, 점수 기반 정렬이 관계형 DB에서 어려움
결과: Elasticsearch 역인덱스 + 분석기로 밀리초 단위 검색
시나리오 3: 실시간 집계·대시보드
상황: API 호출 수, 에러율, 평균 응답 시간을 1분 단위로 집계해 대시보드 표시
문제: 매분마다 COUNT, AVG 쿼리를 실행하면 DB 부하 급증
결과: Elasticsearch date_histogram, terms 집계로 실시간 집계
시나리오 4: 대량 로그 인덱싱 시 DB 부하
상황: 초당 1만 건 로그를 C++ 서버에서 저장
문제: INSERT 1만 번/초는 관계형 DB에 과부하, 커넥션 풀 고갈
결과: Elasticsearch _bulk API로 배치 인덱싱, 초당 수만 건 처리
시나리오 5: JSON 파싱·매핑 에러
상황: C++에서 JSON을 만들어 Elasticsearch에 전송했는데 400 Bad Request
문제: 날짜 형식 오류, 필드 타입 불일치, 매핑 미정의
결과: 매핑 사전 정의, 에러 응답 파싱으로 원인 파악
flowchart TB
subgraph 문제[실무 문제]
P1[느린 로그 검색] --> S1[역인덱스 전문 검색]
P2[복합 키워드 검색] --> S2[분석기·쿼리 DSL]
P3[실시간 집계] --> S3[집계 API]
P4[대량 인덱싱] --> S4[벌크 API]
end
이 글에서 다루는 것:
- Elasticsearch 핵심 개념 (인덱스, 도큐먼트, 역인덱스)
- 전문 검색 Query DSL 완전 예제
- 집계(Aggregation) 완전 예제
- 벌크 인덱싱·실시간 업데이트 패턴
- 자주 발생하는 에러와 해결법
- 성능 최적화·프로덕션 패턴
요구 환경: Elasticsearch 7.x/8.x, C++17 이상 (REST API 호출 시)
이 글을 읽으면:
- Elasticsearch API 구조를 이해하고 C++에서 REST로 호출할 준비가 됩니다.
- C++ Elasticsearch 완벽 가이드(#52-6)에서 libcurl·nlohmann/json으로 구현할 때 이 개념이 기반이 됩니다.
실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.
목차
- 기본 개념
- 인덱스 매핑과 문서
- 전문 검색 완전 예제
- 집계(Aggregation) 완전 예제
- 벌크 인덱싱·실시간 업데이트
- 자주 발생하는 에러와 해결법
- 성능 최적화
- 프로덕션 패턴
- 구현 체크리스트
- 정리
1. 기본 개념
Elasticsearch 아키텍처
Elasticsearch는 역인덱스(Inverted Index) 기반 검색 엔진입니다. 관계형 DB의 “행” 대신 도큐먼트(Document) 단위로 저장하고, 텍스트를 토큰으로 분해해 빠른 검색을 지원합니다.
flowchart TB
subgraph Input[입력]
D1[도큐먼트 1: "error timeout"]
D2[도큐먼트 2: "connection error"]
end
subgraph Analysis[분석기]
A[토크나이저·필터]
end
subgraph Index[역인덱스]
I1[error → doc1, doc2]
I2[timeout → doc1]
I3[connection → doc2]
end
D1 --> A
D2 --> A
A --> I1
A --> I2
A --> I3
핵심 용어
| 용어 | 설명 | 관계형 DB 대응 |
|---|---|---|
| 인덱스(Index) | 도큐먼트 모음 | 데이터베이스/테이블 |
| 도큐먼트(Document) | JSON 객체 | 행(Row) |
| 매핑(Mapping) | 필드 타입 정의 | 스키마 |
| 샤드(Shard) | 인덱스 분할 단위 | 파티션 |
| 역인덱스 | 토큰 → 도큐먼트 ID 매핑 | B-Tree 인덱스 |
REST API 기본 구조
C++에서 libcurl로 호출할 때 사용하는 엔드포인트 패턴입니다.
# 기본 형식
PUT /<index>/_doc/<id> # 문서 인덱싱 (ID 지정)
POST /<index>/_doc # 문서 인덱싱 (ID 자동)
GET /<index>/_doc/<id> # 문서 조회
POST /<index>/_search # 검색
POST /_bulk # 벌크 작업
GET /_cluster/health # 클러스터 상태
인덱스 vs 관계형 DB 비교
flowchart LR
subgraph RDB[관계형 DB]
T[(테이블)]
R[행]
I[B-Tree 인덱스]
end
subgraph ES[Elasticsearch]
IDX[(인덱스)]
DOC[도큐먼트]
INV[역인덱스]
end
T --> R
R --> I
IDX --> DOC
DOC --> INV
차이점:
- 스키마리스: 매핑 없이 도큐먼트를 넣으면 자동 추론 (동적 매핑)
- 전문 검색:
text타입은 토큰화되어 역인덱스에 저장 - 정확 일치:
keyword타입은 분석 없이 그대로 저장 (필터링·집계용)
2. 인덱스 매핑과 문서
매핑이 필요한 이유
동적 매핑에 의존하면 날짜 형식 오류, 숫자/문자열 혼동 등이 발생합니다. 로그·검색 시스템에서는 사전 매핑 정의를 권장합니다.
로그 인덱스 매핑 예제
PUT /logs
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"refresh_interval": "5s"
},
"mappings": {
"properties": {
"message": {
"type": "text",
"analyzer": "standard"
},
"level": {
"type": "keyword"
},
"service": {
"type": "keyword"
},
"timestamp": {
"type": "date",
"format": "strict_date_optional_time||epoch_millis"
},
"duration_ms": {
"type": "long"
},
"user_id": {
"type": "keyword"
}
}
}
}
필드 설명:
message: 전문 검색용,text타입 → 토큰화level,service: 필터·집계용,keyword→ 분석 없음timestamp:date타입, 정렬·날짜 히스토그램에 필수duration_ms: 숫자 집계(AVG, SUM)용
문서 인덱싱 예제
PUT /logs/_doc/1
{
"message": "Application started successfully",
"level": "info",
"service": "api-gateway",
"timestamp": "2024-01-15T10:00:00.000Z",
"duration_ms": 0,
"user_id": null
}
PUT /logs/_doc/2
{
"message": "Connection timeout to database",
"level": "error",
"service": "order-service",
"timestamp": "2024-01-15T10:01:23.456Z",
"duration_ms": 5000,
"user_id": "user-123"
}
C++에서 호출 시 참고
C++에서 위 요청을 보낼 때는 PUT 메서드와 JSON 본문을 그대로 사용합니다.
// libcurl 예시 (개념)
// PUT http://localhost:9200/logs/_doc/1
// Body: {"message":"Application started...", ...}
3. 전문 검색 완전 예제
Match Query: 기본 전문 검색
“timeout” 또는 “connection”이 포함된 로그 검색.
POST /logs/_search
{
"query": {
"match": {
"message": "connection timeout"
}
},
"size": 10,
"from": 0,
"_source": ["message", "level", "timestamp"]
}
동작: message 필드가 standard 분석기로 토큰화되어 “connection”, “timeout” 각각 매칭. OR 조건 (기본).
Match Phrase: 구문 검색
정확한 구문 “connection timeout”을 찾을 때.
POST /logs/_search
{
"query": {
"match_phrase": {
"message": "connection timeout"
}
}
}
Bool Query: 복합 조건
에러 레벨이면서 “timeout” 또는 “connection” 포함.
POST /logs/_search
{
"query": {
"bool": {
"must": [
{
"term": {
"level": "error"
}
},
{
"match": {
"message": "timeout connection"
}
}
],
"filter": [
{
"range": {
"timestamp": {
"gte": "2024-01-15T00:00:00Z",
"lte": "2024-01-15T23:59:59Z"
}
}
}
]
}
},
"sort": [
{ "timestamp": "desc" }
],
"size": 20
}
구성:
must: 점수에 반영, 모두 만족filter: 점수 무관, 캐시 가능, 성능 좋음term:keyword필드 정확 일치range: 날짜·숫자 범위
Multi-Match: 여러 필드 검색
message와 service에서 동시 검색.
POST /logs/_search
{
"query": {
"multi_match": {
"query": "api-gateway error",
"fields": ["message^2", "service"],
"type": "best_fields"
}
}
}
^2: message 필드에 가중치 2배.
Highlight: 검색어 하이라이트
POST /logs/_search
{
"query": {
"match": {
"message": "timeout"
}
},
"highlight": {
"fields": {
"message": {
"pre_tags": [<em>],
"post_tags": [</em>]
}
}
}
}
응답 예시:
{
"hits": {
"hits": [
{
"_source": { "message": "Connection timeout to database" },
"highlight": {
"message": ["Connection <em>timeout</em> to database"]
}
}
]
}
}
4. 집계(Aggregation) 완전 예제
Terms Aggregation: 레벨별 건수
POST /logs/_search
{
"size": 0,
"aggs": {
"by_level": {
"terms": {
"field": "level",
"size": 10,
"order": { "_count": "desc" }
}
}
}
}
응답:
{
"aggregations": {
"by_level": {
"buckets": [
{ "key": "info", "doc_count": 150 },
{ "key": "error", "doc_count": 23 },
{ "key": "warn", "doc_count": 12 }
]
}
}
}
Date Histogram: 시간별 집계
1분 단위 로그 건수.
POST /logs/_search
{
"size": 0,
"aggs": {
"over_time": {
"date_histogram": {
"field": "timestamp",
"calendar_interval": "1m",
"min_doc_count": 1
}
}
}
}
Stats Aggregation: 평균·합계·최소·최대
duration_ms 필드 통계.
POST /logs/_search
{
"size": 0,
"aggs": {
"duration_stats": {
"stats": {
"field": "duration_ms"
}
}
}
}
응답:
{
"aggregations": {
"duration_stats": {
"count": 1000,
"min": 5,
"max": 5000,
"avg": 234.5,
"sum": 234500
}
}
}
복합 집계: 서비스별·레벨별 + 평균 응답 시간
POST /logs/_search
{
"size": 0,
"aggs": {
"by_service": {
"terms": {
"field": "service",
"size": 5
},
"aggs": {
"by_level": {
"terms": {
"field": "level"
}
},
"avg_duration": {
"avg": {
"field": "duration_ms"
}
}
}
}
}
}
Percentiles: 응답 시간 백분위수
P95, P99 등 APM에서 자주 사용.
POST /logs/_search
{
"size": 0,
"aggs": {
"latency_percentiles": {
"percentiles": {
"field": "duration_ms",
"percents": [50, 95, 99]
}
}
}
}
5. 벌크 인덱싱·실시간 업데이트
Bulk API 형식
한 줄에 메타데이터, 다음 줄에 본문. NDJSON(Newline Delimited JSON) 형식.
POST /_bulk
{"index":{"_index":"logs","_id":"1"}}
{"message":"Log 1","level":"info","timestamp":"2024-01-15T10:00:00Z"}
{"index":{"_index":"logs","_id":"2"}}
{"message":"Log 2","level":"error","timestamp":"2024-01-15T10:01:00Z"}
{"index":{"_index":"logs"}}
{"message":"Log 3","level":"info","timestamp":"2024-01-15T10:02:00Z"}
메타데이터 액션:
index: 없으면 생성, 있으면 덮어쓰기create: 없을 때만 생성, 있으면 에러update: 부분 업데이트delete: 삭제 (본문 없음)
Bulk Update 예제
POST /_bulk
{"update":{"_index":"logs","_id":"1"}}
{"doc":{"level":"warn"},"doc_as_upsert":true}
doc_as_upsert: 없으면 doc 내용으로 새 문서 생성.
C++ 벌크 버퍼링 패턴
C++에서 로그를 수집해 1000건마다 한 번에 전송하는 패턴입니다.
// 개념: 버퍼에 문서 추가
std::vector<std::string> buffer;
buffer.push_back(R"({"index":{"_index":"logs"}})");
buffer.push_back(R"({"message":"...","level":"info",...})");
// 1000건 도달 시
std::string bulk_body = join(buffer, "\n") + "\n";
// POST /_bulk
6. 자주 발생하는 에러와 해결법
에러 1: Connection refused
증상:
curl: (7) Failed to connect to localhost port 9200: Connection refused
원인:
- Elasticsearch 서버 미실행
- 잘못된 호스트/포트
- Docker 네트워크: 컨테이너 내부에서
localhost사용 시 호스트 ES에 연결 안 됨
해결법:
# Elasticsearch 실행 확인
curl -X GET "http://localhost:9200/"
# Docker 내부에서 호스트 접근 (macOS/Windows)
# localhost 대신 host.docker.internal 사용
# Linux: --add-host=host.docker.internal:host-gateway
// C++에서: 환경 변수로 URL 외부화
std::string es_url = std::getenv("ELASTICSEARCH_URL");
// 기본값: "http://localhost:9200"
에러 2: mapper_parsing_exception
증상:
{
"error": {
"type": "mapper_parsing_exception",
"reason": "failed to parse field [timestamp] of type [date]"
}
}
원인: 날짜 형식이 매핑과 맞지 않음.
해결법:
// ❌ 잘못된 형식
{"timestamp": "2024/01/15 10:00:00"}
// ✅ 올바른 형식 (ISO 8601)
{"timestamp": "2024-01-15T10:00:00.000Z"}
{"timestamp": "2024-01-15T10:00:00+09:00"}
{"timestamp": 1705312800000}
에러 3: illegal_argument_exception (text 필드 집계)
증상:
{
"type": "illegal_argument_exception",
"reason": "Fielddata is disabled on text fields by default"
}
원인: text 타입 필드에 terms 집계 사용. text는 토큰화되어 집계에 부적합.
해결법:
- 집계용 필드는
keyword타입 사용 message에서 집계해야 하면message.keyword(keyword 서브필드) 사용
// 매핑에 keyword 서브필드 추가
"message": {
"type": "text",
"fields": {
"keyword": { "type": "keyword" }
}
}
// 집계 시
"terms": { "field": "message.keyword" }
에러 4: RequestTimeout / EsRejectedExecutionException
증상:
{
"type": "request_timeout_exception",
"reason": "Bulk request timed out"
}
원인: 벌크 크기가 너무 크거나, 클러스터 부하로 처리 지연.
해결법:
- 벌크 배치 크기 축소 (1000~5000 권장)
timeout파라미터 증가 (기본 30초)
POST /_bulk?timeout=60s
// C++: 배치 크기 1000~5000으로 제한
const size_t BULK_BATCH_SIZE = 2000;
에러 5: 400 Bad Request - JSON 파싱 실패
증상:
{
"error": {
"type": "json_parse_exception",
"reason": "Unexpected character..."
}
}
원인: JSON 이스케이프 누락, 줄바꿈·따옴표 미처리.
해결법:
// ❌ C++에서 잘못된 JSON
std::string doc = "{\"message\":\"error: \"timeout\"\"}"; // 따옴표 충돌
// ✅ nlohmann/json 사용
nlohmann::json j;
j[message] = "error: \"timeout\"";
std::string doc = j.dump();
에러 6: index_not_found_exception
증상:
{
"type": "index_not_found_exception",
"reason": "no such index [logs]"
}
해결법: 인덱스 생성 후 사용. 또는 인덱스 존재 여부 확인.
# 인덱스 목록 확인
curl -X GET "localhost:9200/_cat/indices?v"
# 인덱스 생성 (매핑 포함)
curl -X PUT "localhost:9200/logs" -H "Content-Type: application/json" -d @mapping.json
7. 성능 최적화
인덱싱 성능
| 항목 | 권장 | 비고 |
|---|---|---|
| 벌크 배치 크기 | 1000~5000 | 너무 크면 메모리·타임아웃 |
| refresh_interval | 5s~30s | 실시간 검색 필요 시 1s |
| number_of_shards | 노드 수 이하 | 과도한 샤드는 오버헤드 |
검색 성능
| 항목 | 권장 | 비고 |
|---|---|---|
| _source | 필요한 필드만 | _source: ["field1","field2"] |
| size | 기본 10, 필요 시 확대 | 10000 초과 시 스크롤 사용 |
| filter 컨텍스트 | 캐시 활용 | bool.filter 사용 |
| 스크롤 | 대용량 결과 | scroll 또는 search_after |
refresh_interval 조정
대량 인덱싱 시 검색 가능 시점을 늦추면 쓰기 성능 향상.
PUT /logs/_settings
{
"index": {
"refresh_interval": "30s"
}
}
인덱싱 완료 후 원복:
PUT /logs/_settings
{
"index": {
"refresh_interval": "1s"
}
}
스크롤 API (대용량 검색)
POST /logs/_search?scroll=2m
{
"size": 1000,
"query": { "match_all": {} }
}
응답의 _scroll_id로 다음 배치 요청:
POST /_search/scroll
{
"scroll": "2m",
"scroll_id": "<scroll_id>"
}
Search After (권장)
스크롤 대신 search_after로 페이지네이션. 7.10+ 권장.
POST /logs/_search
{
"size": 100,
"query": { "match_all": {} },
"sort": [
{ "timestamp": "desc" },
{ "_id": "asc" }
],
"search_after": ["2024-01-15T10:00:00Z", "doc_id_123"]
}
8. 프로덕션 패턴
인덱스 라이프사이클 (ILM)
오래된 로그는 삭제하거나 콜드 스토리지로 이동.
PUT _ilm/policy/logs_policy
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": {
"max_size": "50gb",
"max_age": "7d"
}
}
},
"delete": {
"min_age": "30d",
"actions": { "delete": {} }
}
}
}
}
인덱스 템플릿
새 인덱스 생성 시 자동으로 매핑·설정 적용.
PUT _index_template/logs_template
{
"index_patterns": [logs-*],
"template": {
"settings": {
"number_of_shards": 2,
"refresh_interval": "5s"
},
"mappings": {
"properties": {
"message": { "type": "text" },
"level": { "type": "keyword" },
"timestamp": { "type": "date" }
}
}
}
}
날짜 기반 인덱스
logs-2024-01-15 형태로 일별 인덱스. 삭제·압축 관리 용이.
# 인덱스 명명 규칙
logs-2024-01-15
logs-2024-01-16
# 검색 시 와일드카드
POST /logs-*/_search
헬스 체크
C++ 서버 시작 시 Elasticsearch 연결 확인.
GET /_cluster/health?pretty
{
"status": "green",
"number_of_nodes": 1
}
status: green(정상), yellow(레플리카 미할당), red(일부 샤드 미할당).
재시도 전략
// 개념: 지수 백오프 재시도
int max_retries = 3;
for (int i = 0; i < max_retries; ++i) {
auto result = es_client.post("/logs/_doc", doc);
if (result.success) break;
if (result.status == 503 || result.status == 429) {
sleep(1 << i); // 1s, 2s, 4s
} else {
break; // 4xx 등은 재시도 무의미
}
}
환경 변수로 설정 외부화
ELASTICSEARCH_URL=http://es-host:9200
ELASTICSEARCH_TIMEOUT=30
ELASTICSEARCH_BULK_SIZE=2000
9. 구현 체크리스트
인덱스 설계
- 매핑 사전 정의 (text vs keyword, date 형식)
- refresh_interval 설정 (쓰기 부하에 따라)
- 날짜 기반 인덱스 또는 ILM 정책 검토
검색
-
_source필드 제한 (필요한 필드만) - size 제한 (기본 10, 최대 10000)
- 대용량 결과 시 스크롤 또는 Search After
에러 처리
- Connection refused 재시도
- 400/500 응답 파싱 및 로깅
- JSON 파싱 실패 처리
프로덕션
- 연결 재사용 (클라이언트 풀/싱글톤)
- 헬스 체크 (
/_cluster/health) - 환경 변수로 URL·타임아웃 외부화
- TLS/SSL (HTTPS)
10. 정리
| 항목 | 요약 |
|---|---|
| 역인덱스 | 토큰 → 도큐먼트 매핑, 전문 검색 핵심 |
| 매핑 | text(검색), keyword(필터·집계), date(정렬·히스토그램) |
| 검색 | match, match_phrase, bool, multi_match |
| 집계 | terms, date_histogram, stats, percentiles |
| 벌크 | NDJSON 형식, 배치 1000~5000 권장 |
| 에러 | mapper_parsing, text 필드 집계, RequestTimeout |
| 성능 | 벌크, _source 제한, refresh_interval, search_after |
| 프로덕션 | ILM, 인덱스 템플릿, 헬스 체크, 재시도 |
핵심 원칙:
- 매핑 우선: 동적 매핑 의존 최소화
- 벌크 사용: 단건 인덱싱 대신 _bulk API
- 필드 타입 구분: text vs keyword, 집계 시 keyword
- 에러 파싱: HTTP 상태·JSON error.reason 확인
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 로그 분석, 전문 검색, 실시간 대시보드, APM 시스템 등에 활용합니다. C++에서 Elasticsearch REST API를 호출하기 전에 이 글에서 개념과 API 구조를 익히면 C++ Elasticsearch 완벽 가이드(#52-6) 구현이 수월합니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. Elasticsearch 공식 문서, Query DSL, Aggregations 가이드를 참고하세요. C++에서는 REST API로 모든 기능을 호출합니다.
한 줄 요약: Elasticsearch 역인덱스·전문 검색·집계·벌크 인덱싱 개념을 익히면 C++에서 REST API 연동 시 설계와 에러 해결이 수월해집니다.
관련 글
- C++ MongoDB 드라이버 고급 | 집계 파이프라인·인덱싱·레플리카셋 완벽 가이드 [#52-4]