
이 글은 "Python으로 나만의 SIEM 만들기" 시리즈의 4편입니다.
- 1편: 시작편 - 30분만에 SIEM 구축하기
- 2편: MITRE ATT&CK 기반 위협 탐지 룰 구현
- 3편: FastAPI로 실시간 보안 이벤트 처리하기
- [현재] 4편: Elasticsearch로 대용량 로그 저장하고 검색하기
- 5편: 보안 설계 원칙을 코드로 구현하기 (예정)
"하루 1억 건의 로그를 어떻게 저장하고 검색할까요?"
일반적인 데이터베이스로는 불가능합니다.
Elasticsearch는 다릅니다.
실제 사례:
이번 글에서는 Elasticsearch를 활용해 대용량 보안 로그를 효율적으로 저장하고 검색하는 방법을 다룹니다.
분산 검색 및 분석 엔진 (Distributed Search and Analytics Engine)
┌─────────────────────────────────────────────────┐
│ Elasticsearch Cluster │
├─────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────┐ │
│ │ Index (인덱스) │ │ ← MySQL의 Database
│ │ "siem-logs-2025.11.11" │ │
│ ├───────────────────────────────────────┤ │
│ │ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Shard 0 (Primary) │ │ │ ← 데이터 분할 단위
│ │ ├─────────────────────────────┤ │ │
│ │ │ Document 1 (로그 이벤트 1) │ │ │ ← MySQL의 Row
│ │ │ Document 2 (로그 이벤트 2) │ │ │
│ │ │ Document 3 (로그 이벤트 3) │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ │ │
│ │ ┌─────────────────────────────┐ │ │
│ │ │ Shard 1 (Primary) │ │ │
│ │ ├─────────────────────────────┤ │ │
│ │ │ Document 4 │ │ │
│ │ │ Document 5 │ │ │
│ │ └─────────────────────────────┘ │ │
│ │ │ │
│ └───────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
용어 비교:
| Elasticsearch | MySQL | 설명 |
|---|---|---|
| Cluster | Database Server | 여러 노드의 집합 |
| Node | Server Instance | 단일 서버 프로세스 |
| Index | Database | 데이터 저장 단위 |
| Document | Row | 하나의 JSON 데이터 |
| Field | Column | JSON의 키 |
| Mapping | Schema | 필드 타입 정의 |
| Shard | Partition | 데이터 분할 |
일반 인덱스 (Forward Index):
Document ID → 내용
Doc 1: "Brute force attack detected"
Doc 2: "SQL injection attempt"
Doc 3: "Brute force from IP 192.168.1.100"
검색: "Brute force"를 찾으려면?
→ 모든 문서를 순회 (O(n)) 😢
역인덱스 (Inverted Index):
단어 → Document ID
"brute" → [Doc 1, Doc 3]
"force" → [Doc 1, Doc 3]
"attack" → [Doc 1]
"sql" → [Doc 2]
"injection"→ [Doc 2]
"ip" → [Doc 3]
검색: "Brute force"를 찾으려면?
→ 단어 목록에서 즉시 찾기 (O(1)) 🚀
1억 건 로그 검색
단일 서버:
└─ 1억 건 검색 → 30분
3대 클러스터 (샤딩):
├─ Node 1: 3,333만 건 검색 → 10분
├─ Node 2: 3,333만 건 검색 → 10분
└─ Node 3: 3,334만 건 검색 → 10분
병렬 실행 → 총 10분 (3배 빠름!)
┌──────────────────┐
│ FastAPI Server │
│ (로그 생성) │
└────────┬─────────┘
│
▼ (파일 쓰기)
┌──────────────────┐
│ /app/logs/ │
│ app.log │
└────────┬─────────┘
│
▼ (파일 감시)
┌──────────────────────────────────────┐
│ Filebeat (로그 수집기) │
│ ┌──────────────────────────────┐ │
│ │ 1. 파일 읽기 │ │
│ │ 2. Dissect 프로세서 (파싱) │ │
│ │ 3. 타임스탬프 변환 │ │
│ │ 4. 필드 타입 변환 │ │
│ └──────────────────────────────┘ │
└────────┬─────────────────────────────┘
│
▼ (HTTP 전송)
┌──────────────────────────────────────┐
│ Elasticsearch (로그 저장소) │
│ ┌──────────────────────────────┐ │
│ │ 인덱스: siem-logs-YYYY.MM.DD │ │
│ │ - 역인덱스 생성 │ │
│ │ - 샤드에 분산 저장 │ │
│ └──────────────────────────────┘ │
└────────┬─────────────────────────────┘
│
▼ (쿼리)
┌──────────────────┐
│ Kibana │
│ (시각화) │
└──────────────────┘
# 1. 입력 설정 (어디서 로그를 읽을까?)
filebeat.inputs:
- type: log
enabled: true
paths:
- /var/log/mini_siem/*.log
# 2. 프로세서 (로그를 어떻게 파싱할까?)
processors:
- dissect:
tokenizer: "%{timestamp} [%{log_level}] [EVENT] %{event_type} | IP=%{source_ip} | Severity=%{severity} | Threat=%{is_threat}"
field: "message"
target_prefix: "siem"
ignore_failure: true
- timestamp:
field: siem.timestamp
layouts:
- '2006-01-02 15:04:05,000'
ignore_failure: true
- convert:
fields:
- {from: "siem.is_threat", type: "boolean"}
ignore_failure: true
# 3. 출력 설정 (어디로 보낼까?)
output.elasticsearch:
hosts: ["elasticsearch:9200"]
index: "siem-logs-%{+yyyy.MM.dd}"
# 4. 인덱스 템플릿 설정
setup.ilm.enabled: false
setup.template.name: "siem-logs"
setup.template.pattern: "siem-logs-*"
2025-11-11 10:30:00,123 [INFO] [EVENT] login_failed | IP=192.168.1.100 | Severity=medium | Threat=True
tokenizer: "%{timestamp} [%{log_level}] [EVENT] %{event_type} | IP=%{source_ip} | Severity=%{severity} | Threat=%{is_threat}"
토크나이저 분석:
%{timestamp} → "2025-11-11 10:30:00,123"
[%{log_level}] → "[INFO]" → "INFO"
[EVENT] → 리터럴 (매칭만)
%{event_type} → "login_failed"
IP=%{source_ip} → "IP=192.168.1.100" → "192.168.1.100"
Severity=%{severity} → "Severity=medium" → "medium"
Threat=%{is_threat} → "Threat=True" → "True"
{
"message": "2025-11-11 10:30:00,123 [INFO] [EVENT] login_failed | IP=192.168.1.100 | Severity=medium | Threat=True",
"siem": {
"timestamp": "2025-11-11 10:30:00,123",
"log_level": "INFO",
"event_type": "login_failed",
"source_ip": "192.168.1.100",
"severity": "medium",
"is_threat": "True"
}
}
- dissect:
tokenizer: "..."
field: "message" # 입력 필드
target_prefix: "siem" # 출력 필드 접두사
ignore_failure: true # 파싱 실패 시 무시
ignore_failure의 중요성:
로그 형식 A: "2025-11-11 [INFO] [EVENT] ..." ✅ 파싱 성공
로그 형식 B: "2025-11-11 [WARNING] THREAT..." ❌ 파싱 실패
ignore_failure: true → 형식 B도 계속 처리 (다음 프로세서로)
ignore_failure: false → 형식 B에서 중단 (로그 유실!)
여러 패턴 처리:
processors:
# 패턴 1: [EVENT] 형식
- dissect:
tokenizer: "%{timestamp} [%{log_level}] [EVENT] ..."
ignore_failure: true
# 패턴 2: THREAT DETECTED 형식
- dissect:
tokenizer: "%{timestamp} [%{log_level}] %{?emoji} THREAT DETECTED: %{threat_details}"
ignore_failure: true
→ 두 패턴 모두 시도, 하나만 성공하면 OK!
- timestamp:
field: siem.timestamp # 소스 필드
layouts:
- '2006-01-02 15:04:05,000' # Go 시간 형식
ignore_failure: true
Go 시간 형식 해석:
2006-01-02 15:04:05,000
│ │ │ │ │ │ └─ 밀리초 (000)
│ │ │ │ │ └─── 초 (05)
│ │ │ │ └────── 분 (04)
│ │ │ └───────── 시 (15 = 3PM)
│ │ └──────────── 일 (02)
│ └─────────────── 월 (01)
└──────────────────── 년 (2006)
변환 전후:
// 변환 전
{
"siem": {
"timestamp": "2025-11-11 10:30:00,123" // 문자열
}
}
// 변환 후
{
"@timestamp": "2025-11-11T10:30:00.123Z", // ISO 8601 형식
"siem": {
"timestamp": "2025-11-11 10:30:00,123"
}
}
- convert:
fields:
- {from: "siem.is_threat", type: "boolean"}
ignore_failure: true
변환 규칙:
"True" → true (boolean)
"true" → true
"1" → true
"False" → false
"false" → false
"0" → false
타입 변환의 중요성:
// ❌ 타입 변환 안 함
{
"siem": {
"is_threat": "True" // 문자열!
}
}
// Elasticsearch 쿼리
GET /siem-logs/_search
{
"query": {
"term": {
"siem.is_threat": true // 매칭 실패! (문자열 vs 불린)
}
}
}
// ✅ 타입 변환 함
{
"siem": {
"is_threat": true // 불린!
}
}
// Elasticsearch 쿼리
GET /siem-logs/_search
{
"query": {
"term": {
"siem.is_threat": true // 매칭 성공!
}
}
}
인덱스 명명 규칙:
siem-logs-2025.11.11
siem-logs-2025.11.12
siem-logs-2025.11.13
...
장점:
빠른 삭제
# 30일 이전 로그 삭제
DELETE /siem-logs-2025.10.12
# 인덱스 전체 삭제 (수 초 내 완료!)
# vs. 일반 DB
DELETE FROM logs WHERE date < '2025-10-12';
# 수백만 건 삭제 (수십 분 소요)
시간 기반 검색 최적화
# 특정 날짜만 검색
GET /siem-logs-2025.11.11/_search
# 해당 날짜 데이터만 검색 (빠름!)
# 범위 검색
GET /siem-logs-2025.11.*/_search
# 2025년 11월 전체 검색
샤드 크기 관리
단일 인덱스 (1년치):
└─ 10TB → 샤드 크기 초과 → 성능 저하
일별 인덱스:
├─ 2025.11.11: 30GB ✅
├─ 2025.11.12: 28GB ✅
└─ 2025.11.13: 32GB ✅
자동 매핑 vs 명시적 매핑:
// ❌ 자동 매핑 (권장하지 않음)
// Elasticsearch가 첫 데이터로 타입 추론
{
"siem": {
"source_ip": "192.168.1.100" // → text (검색용)
}
}
// 문제: IP는 keyword여야 함!
// ✅ 명시적 매핑 (권장)
PUT /siem-logs-2025.11.11
{
"mappings": {
"properties": {
"@timestamp": {
"type": "date"
},
"siem": {
"properties": {
"timestamp": {
"type": "date"
},
"log_level": {
"type": "keyword" // 정확한 매칭
},
"event_type": {
"type": "keyword"
},
"source_ip": {
"type": "ip" // IP 전용 타입
},
"severity": {
"type": "keyword"
},
"is_threat": {
"type": "boolean"
},
"threat_details": {
"type": "text", // 전문 검색
"fields": {
"keyword": { // 정렬/집계용
"type": "keyword"
}
}
}
}
}
}
}
}
| Elasticsearch 타입 | 설명 | 예시 | 검색 방법 |
|---|---|---|---|
| keyword | 정확한 매칭 | "login_failed" | term 쿼리 |
| text | 전문 검색 | "Brute force attack" | match 쿼리 |
| date | 날짜/시간 | "2025-11-11T10:30:00Z" | range 쿼리 |
| boolean | true/false | true | term 쿼리 |
| ip | IP 주소 | "192.168.1.100" | CIDR 쿼리 |
| integer | 정수 | 5 | range 쿼리 |
| float | 실수 | 3.14 | range 쿼리 |
keyword vs text 차이:
// keyword (정확한 매칭)
{
"event_type": "login_failed"
}
GET /_search
{
"query": {
"term": {
"event_type": "login_failed" // ✅ 매칭
}
}
}
{
"query": {
"term": {
"event_type": "login" // ❌ 매칭 안 됨 (부분 매칭 불가)
}
}
}
// text (전문 검색)
{
"threat_details": "Brute force attack detected from IP"
}
// 자동으로 토큰화됨:
// ["brute", "force", "attack", "detected", "from", "ip"]
GET /_search
{
"query": {
"match": {
"threat_details": "brute" // ✅ 매칭
}
}
}
{
"query": {
"match": {
"threat_details": "attack" // ✅ 매칭 (부분 매칭 가능!)
}
}
}
자동 매핑 적용:
PUT /_index_template/siem-logs-template
{
"index_patterns": ["siem-logs-*"], // 패턴 매칭
"template": {
"settings": {
"number_of_shards": 1, // 샤드 수 (노드 수에 따라 조정)
"number_of_replicas": 1, // 복제본 수 (가용성)
"refresh_interval": "5s" // 검색 가능 시점 (실시간성)
},
"mappings": {
"properties": {
"@timestamp": {"type": "date"},
"siem": {
"properties": {
"timestamp": {"type": "date"},
"log_level": {"type": "keyword"},
"event_type": {"type": "keyword"},
"source_ip": {"type": "ip"},
"severity": {"type": "keyword"},
"is_threat": {"type": "boolean"},
"threat_details": {
"type": "text",
"fields": {
"keyword": {"type": "keyword"}
}
}
}
}
}
}
}
}
효과:
# 새 인덱스 자동 생성 시 템플릿 적용
POST /siem-logs-2025.11.14/_doc
{
"siem": {
"source_ip": "192.168.1.100"
}
}
# 자동으로 ip 타입으로 매핑됨! ✅
// 모든 위협 로그 조회
GET /siem-logs-*/_search
{
"query": {
"term": {
"siem.is_threat": true
}
}
}
// Critical 위협 중 특정 IP만
GET /siem-logs-*/_search
{
"query": {
"bool": {
"must": [ // AND 조건
{"term": {"siem.severity": "critical"}},
{"term": {"siem.is_threat": true}}
],
"filter": [ // 필터 (스코어 계산 안 함)
{"term": {"siem.source_ip": "192.168.1.100"}}
]
}
}
}
bool 쿼리 조건:
| 조건 | 의미 | 스코어 영향 |
|---|---|---|
| must | AND (반드시 매칭) | ✅ 영향 |
| must_not | NOT (매칭 안 됨) | ❌ 영향 없음 |
| should | OR (하나라도 매칭) | ✅ 영향 |
| filter | AND (반드시 매칭) | ❌ 영향 없음 |
// 최근 1시간 위협
GET /siem-logs-*/_search
{
"query": {
"bool": {
"must": [
{"term": {"siem.is_threat": true}}
],
"filter": [
{
"range": {
"@timestamp": {
"gte": "now-1h", // Greater Than or Equal
"lte": "now" // Less Than or Equal
}
}
}
]
}
}
}
// 특정 날짜 범위
{
"range": {
"@timestamp": {
"gte": "2025-11-01T00:00:00",
"lte": "2025-11-30T23:59:59",
"format": "yyyy-MM-dd'T'HH:mm:ss"
}
}
}
// 192.168.1.0/24 네트워크에서 발생한 공격
GET /siem-logs-*/_search
{
"query": {
"bool": {
"must": [
{"term": {"siem.is_threat": true}}
],
"filter": [
{
"term": {
"siem.source_ip": "192.168.1.0/24"
}
}
]
}
}
}
// "SQL Injection" 포함된 위협 찾기
GET /siem-logs-*/_search
{
"query": {
"match": {
"siem.threat_details": "SQL Injection"
}
}
}
// 여러 단어 모두 포함 (AND)
{
"query": {
"match": {
"siem.threat_details": {
"query": "brute force attack",
"operator": "and"
}
}
}
}
// 정규식 검색
{
"query": {
"regexp": {
"siem.threat_details": ".*injection.*"
}
}
}
// 이벤트 타입별 통계
GET /siem-logs-*/_search
{
"size": 0, // 문서는 반환하지 않고 집계만
"aggs": {
"by_event_type": {
"terms": {
"field": "siem.event_type",
"size": 10
}
}
}
}
// 응답:
{
"aggregations": {
"by_event_type": {
"buckets": [
{"key": "login_failed", "doc_count": 1523},
{"key": "sql_injection", "doc_count": 234},
{"key": "privilege_escalation", "doc_count": 89}
]
}
}
}
심화 집계:
// 심각도별 + 이벤트 타입별 (중첩 집계)
GET /siem-logs-*/_search
{
"size": 0,
"aggs": {
"by_severity": {
"terms": {
"field": "siem.severity"
},
"aggs": {
"by_event_type": {
"terms": {
"field": "siem.event_type"
}
}
}
}
}
}
// 시간별 추세 (히스토그램)
{
"aggs": {
"threats_over_time": {
"date_histogram": {
"field": "@timestamp",
"calendar_interval": "1h" // 1시간 단위
},
"aggs": {
"threat_count": {
"filter": {
"term": {"siem.is_threat": true}
}
}
}
}
}
}
// 상위 10개 공격 IP
GET /siem-logs-*/_search
{
"size": 0,
"query": {
"term": {"siem.is_threat": true}
},
"aggs": {
"top_attack_ips": {
"terms": {
"field": "siem.source_ip",
"size": 10,
"order": {"_count": "desc"}
}
}
}
}
샤드 수 결정:
적정 샤드 크기: 20-50GB
일일 로그 량: 100GB
→ number_of_shards: 3 (각 샤드 ~33GB)
일일 로그 량: 10GB
→ number_of_shards: 1 (단일 샤드로 충분)
과다 샤딩의 문제:
❌ number_of_shards: 100 (10GB 인덱스)
→ 각 샤드: 100MB
→ 오버헤드 증가, 성능 저하
✅ number_of_shards: 1 (10GB 인덱스)
→ 단일 샤드: 10GB
→ 효율적
{
"settings": {
"number_of_replicas": 1 // 프로덕션 권장
}
}
복제본 효과:
단점:
{
"settings": {
"refresh_interval": "5s" // 기본값: 1s
}
}
refresh_interval 의미:
1초마다 refresh → 새 데이터가 검색 가능해짐
refresh_interval: 1s → 1초 대기 (실시간성 높음)
refresh_interval: 5s → 5초 대기 (색인 성능 5배 향상)
refresh_interval: -1 → 자동 refresh 비활성화 (대량 색인 시)
대량 색인 시 최적화:
# 1. refresh 비활성화
PUT /siem-logs-2025.11.11/_settings
{
"refresh_interval": "-1"
}
# 2. 대량 데이터 색인
POST /_bulk
...
# 3. 수동 refresh
POST /siem-logs-2025.11.11/_refresh
# 4. refresh 재활성화
PUT /siem-logs-2025.11.11/_settings
{
"refresh_interval": "5s"
}
// ❌ 나쁜 예: 개별 색인 (느림)
POST /siem-logs-2025.11.11/_doc
{"siem": {"event_type": "login_failed", ...}}
POST /siem-logs-2025.11.11/_doc
{"siem": {"event_type": "sql_injection", ...}}
// 각 요청마다 HTTP 오버헤드 발생
// ✅ 좋은 예: 벌크 색인 (빠름)
POST /_bulk
{"index": {"_index": "siem-logs-2025.11.11"}}
{"siem": {"event_type": "login_failed", ...}}
{"index": {"_index": "siem-logs-2025.11.11"}}
{"siem": {"event_type": "sql_injection", ...}}
// 한 번의 HTTP 요청으로 다수 문서 색인
// 성능: 100배 이상 빠름!
{
"mappings": {
"properties": {
"siem.source_ip": {
"type": "ip",
"eager_global_ordinals": true // 집계 성능 향상
}
}
}
}
Management → Index Patterns → Create Index Pattern
Step 1: Index pattern name
siem-logs-*
Step 2: Time field
@timestamp
→ Create index pattern
필터 추가:
siem.is_threat: true
siem.severity: critical
siem.source_ip: 192.168.1.100
시간 범위 선택:
Last 15 minutes
Last 1 hour
Last 24 hours
Last 7 days
Custom (절대 시간)
1) Line Chart - 시간별 위협 추이
Visualization Type: Line
Metrics:
Y-axis: Count
Buckets:
X-axis: Date Histogram
Field: @timestamp
Interval: 1 hour
Split Series:
Field: siem.severity
2) Pie Chart - 이벤트 타입별 분포
Visualization Type: Pie
Metrics:
Slice Size: Count
Buckets:
Split Slices:
Aggregation: Terms
Field: siem.event_type
Size: 10
3) Data Table - 상위 공격 IP
Visualization Type: Data Table
Metrics:
Count
Buckets:
Split Rows:
Aggregation: Terms
Field: siem.source_ip
Order By: metric: Count
Order: Descending
Size: 10
4) Heatmap - 시간대별 공격 분포
Visualization Type: Heatmap
Metrics:
Count
Buckets:
X-axis:
Aggregation: Date Histogram
Field: @timestamp
Interval: 1 hour
Y-axis:
Aggregation: Terms
Field: siem.event_type
Dashboard → Create Dashboard → Add Visualizations
레이아웃:
┌─────────────────────────────────────────┐
│ 시간별 위협 추이 (Line Chart) │
├──────────────────┬──────────────────────┤
│ 이벤트 타입 분포 │ 상위 공격 IP │
│ (Pie Chart) │ (Data Table) │
├──────────────────┴──────────────────────┤
│ 시간대별 공격 분포 (Heatmap) │
└─────────────────────────────────────────┘
Stack Management → Alerting → Create Rule
Rule Type: Elasticsearch Query
Index: siem-logs-*
Query:
{
"query": {
"bool": {
"must": [
{"term": {"siem.is_threat": true}},
{"term": {"siem.severity": "critical"}}
],
"filter": [
{"range": {"@timestamp": {"gte": "now-5m"}}}
]
}
}
}
Threshold: count > 0
Action: Send Email / Slack / Webhook
from elasticsearch import Elasticsearch
# Elasticsearch 클라이언트 생성
es = Elasticsearch(
["http://localhost:9200"],
basic_auth=("elastic", "password")
)
# 1. 최근 1시간 Critical 위협 조회
response = es.search(
index="siem-logs-*",
body={
"query": {
"bool": {
"must": [
{"term": {"siem.is_threat": True}},
{"term": {"siem.severity": "critical"}}
],
"filter": [
{"range": {"@timestamp": {"gte": "now-1h"}}}
]
}
},
"sort": [
{"@timestamp": {"order": "desc"}}
],
"size": 100
}
)
# 결과 출력
for hit in response['hits']['hits']:
log = hit['_source']
print(f"[{log['@timestamp']}] {log['siem']['event_type']} from {log['siem']['source_ip']}")
# 2. 상위 10개 공격 IP 집계
agg_response = es.search(
index="siem-logs-*",
body={
"size": 0,
"query": {
"term": {"siem.is_threat": True}
},
"aggs": {
"top_ips": {
"terms": {
"field": "siem.source_ip",
"size": 10
}
}
}
}
)
for bucket in agg_response['aggregations']['top_ips']['buckets']:
print(f"IP: {bucket['key']}, Count: {bucket['doc_count']}")
Elasticsearch = 속도 + 확장성
Filebeat = 안정적 로그 수집
Kibana = 강력한 시각화
5편: 보안 설계 원칙을 코드로 구현하기
filebeat/filebeat.yml, docker-compose.yml질문이나 피드백은 댓글로 남겨주세요!
💡 도움이 되셨다면 GitHub Star와 좋아요 부탁드립니다!
💬 다음 편에서 만나요!