C++ Elasticsearch 통합 | 전문 검색·집계·실시간 인덱싱 [#52-5]
들어가며: “로그 검색이 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 호출 시)
이 글은 C++ 코드보다 Elasticsearch 쪽 개념과 요청 형식에 집중합니다. C++ 공식 클라이언트가 없어서 결국 HTTP로 JSON을 주고받게 되는데, 이때 문제의 대부분은 C++이 아니라 “어떤 요청을 보내야 하고 응답의 어디를 확인해야 하는가”에서 생기기 때문입니다. libcurl과 nlohmann/json으로 실제 클라이언트를 구현하는 과정은 C++ Elasticsearch 연동: libcurl REST API·Elasticsearch 8.x에서 이어서 다룹니다.
Elasticsearch 아키텍처와 핵심 용어
Elasticsearch 아키텍처
Elasticsearch는 역인덱스(Inverted Index) 기반 검색 엔진입니다. 관계형 DB의 “행” 대신 도큐먼트(Document) 단위로 저장하며, 텍스트를 토큰으로 분해해 빠른 검색을 지원합니다.
flowchart TB
subgraph Input[입력]
D1["도큐먼트 1: #quot;error timeout#quot;"]
D2["도큐먼트 2: #quot;connection error#quot;"]
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타입은 분석 없이 그대로 저장 (필터링·집계용)
인덱스 매핑과 문서
매핑이 필요한 이유
동적 매핑에 의존하면 날짜 형식 오류, 숫자/문자열 혼동 등이 발생합니다. 로그·검색 시스템에서는 사전 매핑 정의를 권장합니다.
로그 인덱스 매핑 예제
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)용
user_id를 long이 아니라 keyword로 둔 것은 의도적인 선택입니다. ID는 숫자처럼 보여도 범위 검색이나 합계를 낼 일이 없고 정확 일치로만 찾는데, keyword의 term 조회가 숫자 타입의 정확 일치보다 효율적인 경우가 많습니다(숫자 타입은 범위 검색에 최적화된 구조로 저장됩니다). 반대로 level처럼 값의 종류가 몇 개뿐인 필드를 text로 두면 집계가 안 되고, message처럼 긴 문장을 keyword로만 두면 부분 단어 검색이 안 됩니다. 매핑은 “이 필드로 무엇을 할 것인가(검색, 필터, 정렬, 집계)“를 먼저 정하고 그에 맞춰 고르는 것이 원칙입니다.
refresh_interval: "5s"도 알고 써야 합니다. Elasticsearch는 인덱싱한 문서를 바로 검색 가능하게 만들지 않고, refresh가 일어날 때 새 세그먼트를 열어 검색에 노출합니다. 그래서 문서를 넣은 직후 검색하면 결과가 비어 있는 것이 정상 동작이며, 테스트 코드에서 “넣고 바로 찾기”가 간헐적으로 실패하는 흔한 원인입니다. 테스트에서는 인덱싱 요청에 ?refresh=wait_for를 붙이거나 POST /logs/_refresh를 호출하고, 운영 코드에서는 매 요청마다 강제 refresh를 하지 않아야 합니다. 작은 세그먼트가 계속 생겨 병합 비용이 커지기 때문입니다.
문서 인덱싱 예제
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...", ...}
Match·Bool·Multi-Match 전문 검색
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: 날짜·숫자 범위
이 예제에서 level: error 조건은 사실 must보다 filter에 두는 편이 맞습니다. “에러 로그인가”는 예/아니오 조건이라 관련도 점수에 기여할 이유가 없고, filter에 두면 점수 계산을 건너뛰고 결과가 캐시될 수 있습니다. 경험상 가장 흔한 실수는 반대 방향으로, text 필드에 term 쿼리를 쓰는 경우입니다. message는 색인 시 소문자 토큰으로 쪼개져 저장되므로 {"term": {"message": "Connection timeout"}}은 대문자와 공백이 섞인 원문 그대로를 하나의 토큰으로 찾다가 아무것도 찾지 못합니다. 에러 없이 빈 결과만 돌아오기 때문에 원인을 찾기 어렵습니다. text 필드에는 match 계열, keyword 필드에는 term 계열이라는 짝을 기억해 두면 대부분 피할 수 있습니다. 쿼리가 어떻게 토큰화되는지 궁금할 때는 POST /logs/_analyze에 {"field": "message", "text": "Connection timeout"}을 보내 직접 확인할 수 있습니다.
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"]
}
}
]
}
}
Terms·Date Histogram·Percentiles 집계
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]
}
}
}
}
집계 결과를 대시보드에 그대로 쓰기 전에 알아 둘 점이 두 가지 있습니다. 첫째, terms 집계는 샤드가 여러 개일 때 근사값입니다. 각 샤드가 자기 상위 N개만 조정 노드로 보내고 조정 노드가 이를 합치므로, 샤드마다 순위가 달랐던 항목은 개수가 적게 잡히거나 목록에서 빠질 수 있습니다. 응답의 doc_count_error_upper_bound가 0이 아니면 오차가 있다는 뜻이고, shard_size를 키우면 정확도가 올라가는 대신 비용이 늘어납니다. 둘째, percentiles도 TDigest 알고리즘 기반의 근사치라서, 데이터가 적을 때나 극단값 근처(P99.9 등)에서는 실제 값과 차이가 날 수 있습니다. 모니터링 용도로는 충분하지만, 과금이나 SLA 판정처럼 정확한 값이 필요한 경우에는 원본 데이터로 따로 계산하는 편이 안전합니다.
벌크 인덱싱·실시간 업데이트
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
벌크 요청에서 가장 위험한 착각은 HTTP 200이면 모두 성공했다고 믿는 것입니다. _bulk는 요청 전체의 처리가 끝나면 일부 문서가 실패해도 200을 돌려주고, 대신 응답 본문 최상위에 "errors": true를 넣고 items 배열의 각 항목에 문서별 status와 error를 담습니다. 이 필드를 확인하지 않는 로그 수집기는 매핑 에러로 거부된 문서를 조용히 잃어버리고, 한참 뒤에 “특정 서비스의 로그만 비어 있다”는 형태로 발견됩니다. C++ 쪽에서는 응답을 파싱해 errors가 참이면 items를 돌며 실패한 항목만 골라, 429(es_rejected_execution_exception)처럼 재시도 가능한 것은 다시 보내고 400 계열은 별도 파일이나 큐로 빼 두는 처리가 필요합니다.
본문 형식도 까다롭습니다. NDJSON이라 마지막 줄에도 줄바꿈이 있어야 하고(The bulk request must be terminated by a newline 에러), 문서 JSON 안에 줄바꿈 문자가 그대로 들어가면 한 문서가 두 줄로 쪼개져 형식이 깨집니다. 문서를 nlohmann::json::dump()처럼 한 줄로 직렬화하는 라이브러리로 만들면 문자열 안의 줄바꿈이 \n으로 이스케이프되어 이 문제가 사라집니다. Content-Type은 application/x-ndjson으로 보냅니다. 배치 크기는 문서 수보다 바이트 크기로 제한하는 편이 안정적인데, 로그 한 줄 크기가 제각각이라 “2000건”이 어떤 때는 1MB, 어떤 때는 50MB가 되기 때문입니다. 공식 문서도 요청당 수 MB~수십 MB 범위에서 측정해 정하라고 권합니다.
mapper_parsing_exception, text 필드 집계, 요청 거부: 에러 해결
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 외부화 (getenv는 없으면 nullptr을 돌려줌)
const char* env = std::getenv("ELASTICSEARCH_URL");
std::string es_url = env ? env : "http://localhost:9200";
Elasticsearch 8.x를 처음 띄우면 기본으로 보안(TLS와 인증)이 켜져 있어서, http://localhost:9200으로 접속하면 연결은 되지만 curl: (52) Empty reply from server처럼 응답 없이 끊깁니다. Connection refused가 아니라 이 증상이 보이면 서버는 살아 있고 프로토콜이 맞지 않는 것입니다. https://와 elastic 사용자 비밀번호, 그리고 설치 시 자동 생성된 CA 인증서(http_ca.crt)를 함께 써야 합니다. 로컬 실습에서만 xpack.security.enabled=false로 끄고, 운영에서는 끄지 않는 것이 원칙입니다.
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}
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" }
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;
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();
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
refresh_interval·Search After로 성능 올리기
인덱싱 성능
| 항목 | 권장 | 비고 |
|---|---|---|
| 벌크 배치 크기 | 1000~5000 | 너무 크면 메모리·타임아웃 |
| refresh_interval | 5s~30s | 실시간 검색 필요 시 1s |
| number_of_shards | 노드 수 이하 | 과도한 샤드는 오버헤드 |
검색 성능
| 항목 | 권장 | 비고 |
|---|---|---|
| _source | 필요한 필드만 | _source: ["field1","field2"] |
| size | 기본 10, 필요 시 확대 | from+size가 10000을 넘으면 search_after 사용 |
| 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+ 에서는 PIT(Point in Time)와 함께 쓰는 것이 권장 방식입니다.
POST /logs/_pit?keep_alive=1m
// 응답: { "id": "<pit_id>" }
POST /_search
{
"size": 100,
"query": { "match_all": {} },
"pit": { "id": "<pit_id>", "keep_alive": "1m" },
"sort": [
{ "timestamp": "desc" }
],
"search_after": [1705312800000, 4294967298]
}
from/size 페이지네이션은 from + size가 인덱스 설정 index.max_result_window(기본 10000)를 넘으면 Result window is too large 에러로 거부됩니다. 깊은 페이지를 요청할 때마다 각 샤드가 from + size개를 정렬해 올려야 하므로 비용이 급격히 커지기 때문에 막아 둔 것입니다. search_after는 “직전 페이지 마지막 문서의 정렬 값 다음부터”를 요청하므로 몇 번째 페이지든 비용이 일정합니다. 정렬 값이 같은 문서가 여러 개면 순서가 흔들려 문서가 누락되거나 중복될 수 있어 유일한 동점 처리(tiebreaker)가 필요한데, 예전 예제처럼 _id로 정렬하면 8.x에서는 _id 필드데이터 접근이 기본으로 막혀 있어 에러가 납니다. PIT를 쓰면 응답의 sort 배열 끝에 암묵적 _shard_doc 값이 붙어 이 역할을 대신하므로, 응답의 마지막 문서 sort 값을 그대로 다음 요청의 search_after에 넣으면 됩니다. PIT는 그 시점의 스냅숏을 유지하므로 페이지를 넘기는 동안 새로 들어온 문서 때문에 결과가 밀리지도 않습니다. 다 쓴 PIT는 DELETE /_pit로 닫아 자원을 돌려줍니다.
ILM·인덱스 템플릿·날짜 기반 인덱스
인덱스 라이프사이클 (ILM)
오래된 로그는 삭제하거나 콜드 스토리지로 이동.
PUT _ilm/policy/logs_policy
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": {
"max_size": "50gb",
"max_age": "7d"
}
}
},
"delete": {
"min_age": "30d",
"actions": { "delete": {} }
}
}
}
}
rollover는 인덱스 이름을 직접 쓰는 방식으로는 동작하지 않습니다. 쓰기용 별칭(alias)이 logs-000001 같은 실제 인덱스를 가리키고 애플리케이션은 항상 별칭으로 쓰거나, 8.x에서 권장하는 데이터 스트림을 써야 롤오버가 새 인덱스를 만들고 쓰기 대상을 넘길 수 있습니다. 이 전제를 빠뜨리면 ILM 설명(GET logs/_ilm/explain)에 rollover target is not an alias 같은 에러가 남고 인덱스는 끝없이 커집니다. 최신 버전에서는 max_size보다 샤드 크기 기준인 max_primary_shard_size를 쓰는 것이 권장됩니다. delete 단계의 min_age는 인덱스 생성 시점이 아니라 롤오버 시점부터 계산된다는 점도 보존 기간을 계산할 때 헷갈리기 쉽습니다.
인덱스 템플릿
새 인덱스 생성 시 자동으로 매핑·설정 적용.
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
Elasticsearch 연동 점검 항목
인덱스 설계
- 매핑 사전 정의 (text vs keyword, date 형식)
- refresh_interval 설정 (쓰기 부하에 따라)
- 날짜 기반 인덱스 또는 ILM 정책 검토
검색
-
_source필드 제한 (필요한 필드만) - size 제한 (기본 10, 최대 10000)
- 대용량 결과 시 Search After + PIT
에러 처리
- Connection refused 재시도
- 400/500 응답 파싱 및 로깅
- JSON 파싱 실패 처리
프로덕션
- 연결 재사용 (클라이언트 풀/싱글톤)
- 헬스 체크 (
/_cluster/health) - 환경 변수로 URL·타임아웃 외부화
- TLS/SSL (HTTPS)
기능별 요약
| 항목 | 요약 |
|---|---|
| 역인덱스 | 토큰 → 도큐먼트 매핑, 전문 검색 핵심 |
| 매핑 | 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. 문서를 인덱싱할 때 mapper_parsing_exception이 나는 이유는 무엇인가요?
A. 이 에러는 들어온 값이 해당 필드에 이미 정해진 매핑 타입과 맞지 않을 때 발생합니다. 예를 들어 date나 long으로 매핑된 필드에 형식이 다른 문자열이 들어오면 파싱에 실패합니다. 동적 매핑을 쓰면 처음 들어온 문서의 값으로 필드 타입이 고정되기 때문에, 로그처럼 형식이 섞일 수 있는 데이터는 인덱스를 만들 때 명시적으로 매핑을 정의해야 합니다. 이미 만들어진 필드의 타입은 바꿀 수 없으므로, 수정하려면 새 매핑으로 인덱스를 만들고 reindex해야 합니다.
Q. text 필드로 terms 집계를 했더니 Fielddata is disabled on text fields 에러가 납니다.
A. text 타입은 분석기를 거쳐 토큰으로 쪼개져 저장되므로 기본적으로 집계에 쓸 수 없습니다. 집계할 필드는 keyword 타입으로 매핑하고, 전문 검색과 집계를 모두 해야 하는 필드라면 message.keyword 같은 keyword 서브필드를 추가한 뒤 집계에서는 서브필드를 지정합니다.
Elasticsearch 역인덱스·전문 검색·집계·벌크 인덱싱 개념을 익히면 C++에서 REST API 연동 시 설계와 에러 해결이 수월해집니다.