C++ Elasticsearch 통합 | 전문 검색·집계·실시간 인덱싱 [#52-5]

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++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

목차

  1. 기본 개념
  2. 인덱스 매핑과 문서
  3. 전문 검색 완전 예제
  4. 집계(Aggregation) 완전 예제
  5. 벌크 인덱싱·실시간 업데이트
  6. 자주 발생하는 에러와 해결법
  7. 성능 최적화
  8. 프로덕션 패턴
  9. 구현 체크리스트
  10. 정리

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: 여러 필드 검색

messageservice에서 동시 검색.

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_interval5s~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, 인덱스 템플릿, 헬스 체크, 재시도

핵심 원칙:

  1. 매핑 우선: 동적 매핑 의존 최소화
  2. 벌크 사용: 단건 인덱싱 대신 _bulk API
  3. 필드 타입 구분: text vs keyword, 집계 시 keyword
  4. 에러 파싱: 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]
... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3