시작하며
안녕하세요. 카카오페이증권 DevOps 팀 Sean.baek (션), Lina.a (리나)에요.
우리는 전사 Cloud, redis, kafka, 로깅 플랫폼, AI Chatbot(춘시리), FinOps와 다양한 오픈소스 플랫폼을 운영하고 있어요.
2022년 9월에 입사했을 때, 하루에 쌓이는 로그는 약 100GB였어요. 그때는 로깅 시스템이 큰 고민거리가 아니었죠. 그런데 카카오페이증권의 서비스가 빠르게 성장하면서 상황이 달라졌어요. 로그가 폭발적으로 늘어나기 시작한 거예요.
지금은 하루에 41TB, 200억 건 이상의 로그가 쌓이고 있어요. 3년 만에 410배 이상 증가한 거죠. 문제는 로그량만이 아니었어요. 개발크루들로부터 피드백이 쏟아지기 시작했거든요.
“로그 조회가 너무 느려요. 5분 넘게 걸릴 때도 있어요.” > “장애 분석하려는데 로그가 아직 안 들어왔어요.”
로그 유입 지연, 조회 성능 저하… 기존 OpenSearch 기반 시스템으로는 이 성장세를 감당할 수 없었어요. 비용도 로그 양이 늘어나는 만큼 비례해서 8배 이상 증가하고 있었고요.
우리는 근본적인 질문을 던져야 했어요. PB 규모의 로그를 저비용으로, 그리고 빠르게 처리할 수 있는 방법이 뭘까?
그렇게 시작한 것이 호그와트 도서관 프로젝트에요. 결과부터 말하면:
| 지표 | Before | After |
|---|---|---|
| 로그 지연 | 수 분 ~ 수 시간 | 20초 이내 |
| 비용 | 100% | 14.4% (85.6% 절감) |
어떻게 이런 결과를 만들었을까요? 지금부터 단계별로 설명할게요.
기존 아키텍처의 한계
먼저 기존 시스템이 어떻게 구성되어 있었는지 볼게요.
문제점
- 비용: 로그량 증가와 비례해서 OpenSearch 비용이 8배 이상 증가
- 성능: Fluentd의 청크 처리 방식으로 메모리 사용량 증가 및 처리 지연
- 확장성: 서비스 단위 Topic 분리로 Kafka Topic이 300개 이상으로 증가
- 장기 조회: Amazon Athena로 2,000개 이상 컬럼의 테이블을 정의하기 어려움
이 문제들을 “부분 최적화”로 땜질하기보다는, 수집부터 조회까지 파이프라인 전체를 다시 설계하기로 했어요.
신규 아키텍처: 호그와트 도서관
이런 문제들을 해결하기 위해 설계한 새로운 아키텍처에요. ClickStack - The ClickHouse Observability Stack을 기반으로 구성했어요.
ClickStack: ClickHouse + OpenTelemetry + HyperDX를 조합한 Observability 스택이에요. 세 기술 모두 오픈소스이고, 대용량 로그 처리에 최적화되어 있어요.
| 기술 | 역할 |
|---|---|
| OpenTelemetry | 로그 수집 및 처리 (CNCF Observability 표준) |
| ClickHouse | 컬럼형 OLAP DB로 대용량 로그 저장 및 분석 |
| HyperDX | 개발크루용 로그 조회 UI |
ssak3: 카카오페이증권에서 직접 개발한 로그 아카이빙 애플리케이션이에요. ClickHouse 데이터를 장기 보관용 S3로 export하는 역할을 해요. 뒤에서 더 자세하게 설명할 예정이에요.
IDC의 ClickHouse에서 ssak3가 데이터를 Amazon S3로 아카이빙하고, AWS의 ClickHouse는 해당 Amazon S3를 바라보는 구조에요. 동기화가 아니라 Amazon S3를 중심으로 한 단방향 흐름이에요.
| 위치 | 역할 | 구성 |
|---|---|---|
| IDC | 실시간 수집/처리/조회 | OpenTelemetry, Kafka, ClickHouse (6샤드 × 레플리카) |
| AWS | 장기 저장 및 조회 | Amazon S3 + ClickHouse (필요 시에만 기동) |
로깅 플랫폼이 어떻게 개선됐는지, 7단계로 나눠서 살펴볼게요.
| Step | 주제 | 핵심 내용 |
|---|---|---|
| 1 | 수집기 전환 | Filebeat → OpenTelemetry (OTLP Proto, 배치 전송) |
| 2 | 메시지 큐 최적화 | Kafka Topic 300개 → 18개, 파티션 전략 |
| 3 | 처리기 전환 | Fluentd → OpenTelemetry (커스텀 로그 라우팅) |
| 4 | 저장소 전환 | OpenSearch → ClickHouse (테이블/Parts 설계) |
| 5 | 장기 보관 | Amazon S3 아카이빙 (ssak3) |
| 6 | 장기 조회 | Amazon Athena → ClickHouse |
| 7 | 로그 조회 UI | HyperDX 선택 이유 |
앞에서부터 병목을 하나씩 걷어내는 방식으로 진행했어요. 먼저 가장 앞단인 수집부터 시작할게요.
Step 1. 수집기 전환: Filebeat → OpenTelemetry
첫 번째는 로그 수집기 교체에요.
Filebeat의 문제: JSON 오버헤드
기존 Filebeat는 로그 한 줄을 JSON 형태로 Kafka 메시지 한 개씩 전송해요.
{"@timestamp":"2026-01-12T10:00:00.000Z","message":"User login","service":"auth","host":"pod-abc"}
{"@timestamp":"2026-01-12T10:00:00.001Z","message":"User login","service":"auth","host":"pod-abc"}
{"@timestamp":"2026-01-12T10:00:00.002Z","message":"User login","service":"auth","host":"pod-abc"}
동일한 키명(@timestamp, message, service, host)이 매 메시지마다 반복돼요. 1,000건 로그면 키명도 1,000번 전송되는 거죠.
OpenTelemetry의 해결책: OTLP + 배치 전송
OpenTelemetry Collector는 두 가지 방식으로 이 문제를 해결해요.
1. OTLP Proto 인코딩
Protocol Buffers는 필드명 대신 숫자 태그를 사용하는 바이너리 포맷이에요.
JSON: {"timestamp": 1704067200, "service": "auth"} // 45 bytes
Proto: [08 80 A8 D6 8B 06 12 04 61 75 74 68] // 12 bytes
↑ ↑
필드1(timestamp) 필드2(service)
timestamp(9자) →08(1바이트 태그)service(7자) →12(1바이트 태그)- 따옴표, 콜론, 중괄호 등 JSON 문법 문자도 제거
동일 데이터 기준 40~60% 용량 절감 효과가 있어요.
2. 배치 전송
여러 로그를 하나의 메시지로 묶어서 전송해요.
Filebeat: 로그 1건 = Kafka 메시지 1개 → 1,000건 = 1,000개 메시지
OpenTelemetry: 로그 N건 = Kafka 메시지 1개 → 1,000건 = ~1개 메시지 (batch size 기준)
실제로 테스트해보니 차이가 확연했어요.
| 전송 방식 | 처리량 |
|---|---|
| 메시지 1건씩 전송 | 16.5 MB/s |
| 150건씩 배치 전송 | 300 MB/s |
배치 전송만으로 약 18배 처리량이 향상됐어요. Produce Request 횟수가 줄어들면서 네트워크 왕복과 브로커 처리 비용이 대폭 감소한 결과예요. 결과적으로 Kafka CPU 사용량도 2배 이상 감소했어요.
Step 2. Kafka Topic 통합
수집기를 교체하면서 Kafka Topic 구조도 함께 손봤어요. 여기서 핵심은 “Topic을 줄였다”가 아니라, Topic을 어떤 기준으로 재구성했는지예요. 이 글에서는 그 기준을 로그 타입(std/nginx/transaction) 으로 잡고 설명할게요.
로그 타입과 거버넌스
수집·보관·조회 기준을 먼저 잡았어요. 용도에 따라 로그를 세 가지 타입으로 분류해요.
| 로그 타입 | 목적 | 활용 |
|---|---|---|
| std | 애플리케이션 표준 로그 | 장애 분석, 디버깅, 성능 모니터링 |
| nginx | 웹 서버 접근 로그 | 트래픽 분석, 보안 감사, 접근 이력 추적 |
| transaction | 애플리케이션 request, response 로그 | 트랜잭션 흐름 추적 |
Topic이 300개가 된 이유
거버넌스를 logtype으로 시작했어요. 최초에는 logtype별(std, nginx, transaction) Logstash 구조였는데, 특정 앱의 로그가 폭증하면 전체 logtype이 함께 밀렸어요. 하나가 밀리면 전부 밀리는 구조였던 거죠.
그래서 영향도를 더 잘 격리하려고 서비스 단위로 Topic을 쪼개고, 더 가벼운 Fluentd로 분리 운영했어요.
1단계: logtype별 Logstash → 특정 앱 로그 폭증 시 전체 lag
2단계: 서비스별 Fluentd → 영향도 격리 성공, 하지만 Topic 급증
결국 서비스가 늘면서 Topic은 300개, Fluentd는 1,000개 이상으로 불어났고 리소스도 폭증했어요.
다시 통합이 가능해진 이유
OpenTelemetry Collector와 ClickHouse의 성능 테스트 결과, 통합해도 성능이 충분히 확보됐어요. 여기에 Fast/Common 레벨 분리로 한정된 IDC 자원 안에서도 유연하게 대응할 수 있게 됐죠.
| 항목 | As-Is | To-Be |
|---|---|---|
| Topic 수 | ~300개 | 18개 |
| 파티션 | Topic당 3~90개 | Topic당 최대 150개 |
파티션과 컨슈머 설계
OpenTelemetry Collector가 여러 Topic을 consume할 때보다 단일 Topic을 consume할 때 6배 이상 빨랐어요. 하지만 단순히 성능만 고려한 것은 아니에요.
핵심 설계: 파티션 150개, 컨슈머는 공약수로 설정
Topic 파티션: 150개
컨슈머 수: 15, 30, 50개 (상황에 따라 조절)
왜 파티션과 컨슈머 수를 다르게 했을까요?
| 설정 | 장점 | 단점 |
|---|---|---|
| 파티션 = 컨슈머 | 처리 속도 최대 | 지연 시 파티션 추가해도 과거 로그는 그대로 |
| 파티션 > 컨슈머 | 지연 시 컨슈머만 늘리면 과거 로그까지 처리 | 평시 20% 자원 손실 |
증권업은 09:00 장 시작과 23:30 해외장 시작(썸머타임 시 22:30)에 트래픽 스파이크 발생해요. 20%의 자원 손실을 감수하더라도 장애 대응 유연성을 확보하는 것이 더 중요했어요.
Step 3. 처리기 전환: Fluentd → OpenTelemetry
이제 Kafka에서 로그를 꺼내서 ClickHouse로 보내는 처리기 차례에요.
기존 Fluentd의 문제
기존에는 서비스마다 Fluentd Deployment가 따로 있었어요. 서비스 단위로 Topic이 만들어지고, Topic이 300개였으니, Fluentd도 1,000개 이상이었죠.
As-Is: Topic당 Fluentd Deployment
─────────────────────────────────────────────────────
Topic A ──▶ Fluentd A (3 replicas)
Topic B ──▶ Fluentd B (3 replicas)
Topic C ──▶ Fluentd C (3 replicas)
... ...
Topic N ──▶ Fluentd N (3 replicas)
총 300개 Topic × 평균 3~4 replicas = 1,000개 이상의 Pod
─────────────────────────────────────────────────────
문제는 서비스별 로그 유입량이 천차만별이라는 거예요.
| 상황 | 문제 |
|---|---|
| 로그가 거의 없는 서비스 | Fluentd가 리소스만 점유한 채 대기 중 |
| 로그가 폭발하는 서비스 | Fluentd가 감당 못해서 지연 발생 |
결국 어떤 Fluentd는 놀고 있고, 어떤 Fluentd는 과부하… 리소스가 비효율적으로 분산되어 있었어요.
Topic 통합 + 로그 레벨 분리
Step 2에서 Topic을 합치면서, 이제 전체 지연 현상을 최소화하고 속도 제어를 원활하게 해야 했어요. 그래서 OpenTelemetry Collector를 로그 레벨별로 분리했어요.
| 레벨 | 용도 | SLA |
|---|---|---|
| Fast | 서비스 핵심 이벤트, 오류 등 실시간 필수 | 공식 2분 이내 |
| Common | 일반 운영 로그 | 공식 15분 이내 |
| Debug | 디버깅용 상세 로그 | 최선 노력 |
이 구조의 핵심은 피크 시 리소스 재분배예요:
- 평소에는 세 Pool이 각자 처리
- 09:00/23:30 피크 시 → Debug Pool Scale In, Fast Pool Scale Out
- Fast 로그는 빠르게, Debug 로그는 나중에 천천히
| 지표 | As-Is (Fluentd) | To-Be (OpenTelemetry) |
|---|---|---|
| Pod 수 | 1,000개 이상 | 150개 |
| 리소스 효율 | 서비스별 고정 할당 | 레벨별 Pool 공유 |
| 스케일링 | Topic별 수동 조절 | 레벨별 자동 조절 |
| 속도 제어 | 불가능 | 레벨별 우선순위 처리 |
실제로는? 모든 로그가 발생 후 20초 이내에 ClickHouse에 적재되고 있어요.
OpenTelemetry Collector 처리기 설정
실제 OpenTelemetry 주요 설정은 간단해요.
receivers:
kafka/topic_0:
brokers:
- 'kafka-headless.kafka.svc.cluster.local:9092'
topic: 'log-common-std'
# 레벨별(Fast/Common/Debug) Pool마다 다른 Consumer Group 사용
group_id: 'otel-common-std-logs-v1'
# Step 1에서 설명한 OTLP Proto 인코딩으로 Kafka 메시지를 읽어요.
encoding: 'otlp_proto'
initial_offset: 'earliest'
processors:
# OOM 방지를 위한 메모리 제한
memory_limiter:
limit_mib: 1512
check_interval: 1s
# ClickHouse에 배치로 INSERT해서 성능 최적화
batch:
timeout: 1s
send_batch_size: 2000
send_batch_max_size: 3000
exporters:
clickhouse:
endpoint: 'tcp://clickhouse:9000'
database: 'logs'
# ...
처리량: 26배 성능 향상
기존 Fluentd는 1 Core당 약 150건/초를 처리했어요. Topic 통합과 ClickHouse 전환 이후 OpenTelemetry Collector는 1 Core당 4,000건/초를 처리해요. 단순 계산으로 26배 이상 성능이 개선된 거죠.
| 항목 | Fluentd | OpenTelemetry Collector |
|---|---|---|
| 1 Core당 처리량 | 150건/초 | 4,000건/초 |
| 83만 건/초 처리 시 | ~5,533 Core 필요 | ~230 Core |
Kafka에서 초당 83만 건이 유입되는데, OpenTelemetry로는 230 Core면 충분해요. 같은 처리량을 Fluentd로 했다면 3,600 Core가 필요했을 거예요.
커스텀 로그 라우팅
표준 로그 외에도 다양한 소스의 커스텀 로그가 있어요. Application, DB(MySQL, MongoDB, Altibase), Kafka, Redis, Nginx, FEP 등 43종 이상의 로그 유형을 하나의 파이프라인에서 처리해야 했죠.
OpenTelemetry의 Routing Connector로 이 문제를 해결했어요.
핵심 설정은 다음과 같아요.
connectors:
routing:
default_pipelines: [logs/unknown]
table:
# logtype 속성에 따라 전용 파이프라인으로 분기
- statement: route() where resource.attributes["logtype"] == "mysql"
pipelines: [logs/mysql]
- statement: route() where resource.attributes["logtype"] == "redis"
pipelines: [logs/redis]
- statement: route() where resource.attributes["logtype"] == "kafka"
pipelines: [logs/kafka]
# ... 로그 유형
processors:
# 입구에서 logtype 추출
transform/intake:
log_statements:
- context: log
statements:
# JSON 파싱 후 logtype 추출
- merge_maps(attributes, ParseJSON(body), "upsert")
- set(resource.attributes["logtype"], attributes["fields"]["logtype"])
# logtype별로 ResourceLog 분리
groupbyattrs:
keys:
- logtype
service:
pipelines:
# 입구 파이프라인
logs/intake:
receivers: [kafka/topic_0, kafka/topic_1, ... kafka/topic_31]
processors: [memory_limiter, transform/intake, groupbyattrs]
exporters: [routing]
# logtype별 전용 파이프라인
logs/altibase:
receivers: [routing]
processors: [transform/mysql, batch]
exporters: [clickhouse]
이 구조의 핵심 장점은:
| 장점 | 설명 |
|---|---|
| Config 하나로 신규 유형 대응 | 새 로그 유형이 생겨도 OpenTelemetry ConfigMap만 수정하면 끝. 코드 배포 없이 처리 가능 |
| ClickHouse 테이블 생성 불필요 | 모든 커스텀 로그가 동일한 테이블(custom_logs)로 적재. DDL 변경 없음 |
| 컬럼 확장성 제약 없음 | 로그 필드는 LogAttributes Map에 저장하고, logtype만 별도 컬럼으로 추출. 어떤 필드가 추가되어도 스키마 변경 불필요 |
| 검색 편의성 | logtype 컬럼으로 빠르게 필터링 후 LogAttributes에서 세부 검색 |
-- 예시: logtype으로 빠르게 필터링
SELECT * FROM logs.custom_logs
WHERE logtype = 'mysql'
AND LogAttributes['error_code'] = '1045';
Step 4. OpenSearch에서 ClickHouse로 전환
수집과 처리 파이프라인을 정비했으니, 이제 저장소를 바꿀 차례에요. 이 부분이 가장 많은 고민이 필요했어요.
왜 ClickHouse를 선택했을까?
대안은 여러 가지가 있었어요. 각 솔루션을 우리 상황에 맞춰 검토했어요.
| 솔루션 | 강점 | 우리에게 맞지 않았던 이유 |
|---|---|---|
| Elasticsearch / OpenSearch | 전문 검색(Full-text)에 강함 | 비용이 높고, 대용량 집계 쿼리가 느림 |
| Grafana Loki | 라벨 기반 인덱싱, 저비용 | 복잡한 쿼리 불가, 집계 기능이 약함 |
| ClickHouse | 컬럼형 OLAP, 고속 집계, 압축률 우수 | 전문 검색은 약함 |
결론부터 말하면, ClickHouse를 선택했어요. 우리의 로그 조회 패턴을 분석해보니 전문 검색보다 시간 범위 + 필드 조건 검색이 90% 이상이었거든요. “어제 오후 2시에 이 서비스에서 발생한 ERROR 로그” 같은 방식이죠. 이런 쿼리에는 컬럼형 OLAP이 압도적으로 유리해요.
전문 검색이 필요한 경우에도 Primary Key로 먼저 범위를 좁히면 큰 문제가 없었어요. WHERE Timestamp BETWEEN ... AND ServiceName = '...' 조건으로 범위를 좁힌 뒤 LIKE 검색을 하면 충분히 빨랐거든요.
각 솔루션을 왜 제외했는지 좀 더 자세히 설명할게요.
OpenSearch의 한계
기존 OpenSearch(Elasticsearch)는 **전문 검색(Full-text Search)**에는 강하지만, 우리 상황에서는 여러 문제가 있었어요.
| 문제 | 상세 |
|---|---|
| 낮은 압축률 | 역인덱스(Inverted Index) 구조라 저장 공간이 많이 필요. 로그 원본보다 인덱스가 더 큰 경우도 있음 |
| 대용량 집계 비효율 | “최근 1시간 ERROR 로그 수”같은 집계 쿼리가 느림. 모든 문서를 스캔해야 함 |
| 컬럼 검색 취약 | 특정 필드만 조회해도 전체 문서를 읽어야 함. SELECT service, count(*)같은 쿼리에 불리 |
| 비용 증가 | 로그 증가에 따라 노드 수가 늘어남 |
Grafana Loki의 한계
Grafana Loki는 저비용 로그 저장소로 매력적이었어요. 인덱스를 최소화해서 저장 비용을 낮추는 방식이거든요. 하지만 우리 요구사항과 맞지 않는 부분이 있었어요.
| 문제 | 상세 |
|---|---|
| 라벨 기반 제약 | 라벨(ServiceName, level 등)로만 필터링 가능. 로그 본문 내 필드로 검색하려면 전체 스캔 필요 |
| 집계 기능 부족 | “시간대별 ERROR 추이”, “서비스별 로그 건수” 같은 집계 쿼리가 느리거나 불가능 |
| 카디널리티 한계 | 라벨 값의 종류가 많아지면 성능이 급격히 저하. 우리처럼 서비스가 1,000개 이상이면 부담 |
| 쿼리 언어 한계 | LogQL은 SQL 대비 표현력이 제한적. 복잡한 분석 쿼리 작성이 어려움 |
Loki는 “로그를 저렴하게 저장하고, 필요할 때 grep처럼 검색”하는 용도에 적합해요. 하지만 우리는 실시간 집계, 복잡한 필터링, 다양한 필드 검색이 필요했기 때문에 적합하지 않았어요.
왜 ClickHouse인가?
결국 우리의 요구사항은 명확했어요:
- 대용량 집계: 수십억 건 로그에서 빠른 집계 (시간대별, 서비스별 통계)
- 컬럼 검색: 특정 필드만 빠르게 조회
- 비용 효율: 로그량 증가에 비례해서 비용이 폭증하면 안 됨
- 유연한 스키마: 2,000개 이상 컬럼도 유연하게 처리
ClickHouse는 이 모든 조건에 가장 잘 맞았어요. 전문 검색이 약하다는 단점이 있지만, 앞서 말했듯 우리 조회 패턴에서는 큰 문제가 아니었죠.
ClickHouse가 해결하는 방식
| OpenSearch 문제 | ClickHouse 해결책 |
|---|---|
| 낮은 압축률 | 컬럼형 저장 + ZSTD 압축 → 동일 컬럼 값이 연속 저장되어 압축률 극대화. 원본 대비 ~90% 절감 |
| 대용량 집계 비효율 | 벡터화 실행(Vectorized Execution) → CPU SIMD 명령어로 배치 처리. 수십억 건 집계도 수 초 |
| 컬럼 검색 취약 | 컬럼형 저장 → SELECT service하면 service 컬럼만 읽음. I/O 최소화 |
| 비용 증가 | 위 장점들 덕분에 78% 비용 절감 |
─────────────────────────────────────────────────────────────────────────────
OpenSearch (Row 기반) ClickHouse (Column 기반)
─────────────────────────────────────────────────────────────────────────────
Row 1: [ts, svc, body, ...] Column ts: [ts1, ts2, ts3, ...]
Row 2: [ts, svc, body, ...] Column svc: [a, a, b, ...]
Row 3: [ts, svc, body, ...] Column body: [log1, log2, log3, ...]
SELECT svc → 전체 Row 스캔 SELECT svc → svc 컬럼만 읽기
─────────────────────────────────────────────────────────────────────────────
ClickHouse 클러스터링 구조
ClickHouse를 이해하려면 Shard, Replica, Partition, Part 네 가지 개념을 알아야 해요.
| 개념 | 설명 | 우리의 설정 |
|---|---|---|
| Shard | 데이터를 수평 분할. 각 샤드는 전체 데이터의 일부를 저장 | 6개 샤드 |
| Replica | 샤드 내 복제본. 고가용성과 읽기 분산 | 샤드당 2개 (총 12노드) |
| Partition | 시간/조건 기반으로 데이터를 논리적으로 분리. TTL 삭제 단위 | 시간 단위 (YYYYMMDDHH) |
| Part | 실제 디스크에 저장되는 물리적 단위. INSERT마다 생성, 백그라운드 Merge | 파티션당 ~20개 |
테이블 엔진
ClickHouse의 강점 중 하나는 용도별 특화 테이블 엔진이에요. 우리는 4가지를 조합해서 사용해요.
| 엔진 | 역할 | 특징 |
|---|---|---|
| Buffer | 메모리 버퍼링 | INSERT를 모아뒀다가 한 번에 flush. 작은 INSERT 빈도 감소 |
| ReplicatedMergeTree | 실제 저장소 | 컬럼형 저장, 자동 Merge, 샤드 간 복제 |
| Distributed | 분산 쿼리 | 모든 샤드에 쿼리를 분산하고 결과 취합 |
| View | (옵션) 통합 조회 | 서로 다른 로그 타입을 한 화면에서 보기 위한 공통 스키마 제공 |
이렇게 기능별로 특화된 테이블을 조합하면, 각 역할에 최적화된 구조를 만들 수 있어요.
Fast와 Common의 Buffer 테이블만 분리하고, Store 테이블은 통합했어요. 이렇게 하면:
- 실시간 처리는 레벨별로 독립적
- 조회할 때는 하나의 테이블만 보면 됨
- 보관 기간도 통합 관리
Buffer 테이블과 Parts 최적화
ClickHouse에서 성능을 좌우하는 핵심 요소는 Parts의 크기와 개수에요.
ClickHouse는 파티션 내 Parts 개수에 따라 INSERT 동작을 제어해요.
| 설정 | 기본값 | 동작 |
|---|---|---|
parts_to_delay_insert | 20,000 | 파티션당 Parts가 초과하면 INSERT 지연 |
parts_to_throw_insert | 50,000 | 파티션당 Parts가 초과하면 INSERT 거부 |
Part 크기와 개수의 관계
| 상황 | Part 크기 | Parts 개수 | 결과 |
|---|---|---|---|
| 크기 ↑ 개수 ↓ | 크다 (수 GB) | 적다 | Merge 시 메모리 급증, 하지만 SELECT 빠름 |
| 크기 ↓ 개수 ↑ | 작다 (수십 MB) | 많다 | Merge 빈번 → CPU 부하, SELECT 시 많은 Part 스캔 |
| 균형 (권장) | 수백 MB ~ 수 GB | 파티션당 수십 개 이하 | Merge/SELECT 모두 안정적 |
우리의 설정 전략:
-- Buffer: 1~30초 또는 500MB~1GB 도달 시 flush
ENGINE = Buffer('logs', 'store_table', 16, 1, 30, 500000, 5000000, 500000000, 1000000000)
Buffer max_bytes를 1GB로 크게 잡는 대신, max_time을 30초로 짧게 설정했어요. 이렇게 하면:
- 평시: 30초마다 flush → 적당한 크기의 Part 생성
- 트래픽 스파이크: 1GB 도달 시 즉시 flush → 큰 Part 생성, Merge 횟수 최소화
증권업 특성상 09:00, 23:30에 트래픽이 급증하는데, 이때 작은 Part가 대량 생성되면 Merge에 CPU를 많이 사용해요. Buffer 크기를 크게 잡아서 피크 시 Merge 부하를 최소화했어요.
실제 운영 데이터 (2026년 1월 12일, 노드당):
| 파티션 | Parts 수 | 총 크기 | Part당 평균 |
|---|---|---|---|
| 2026011200 (00시) | 20개 | 40 GiB | ~2GB |
| 2026010500 (00시) | 20개 | 46 GiB | ~2.3GB |
파티션당 20개 Parts, Part당 2~3GB로 권장 범위 내에서 운영 중이에요.
장애 시 데이터 유실 방지:
Buffer 테이블은 메모리에 데이터를 모아두기 때문에 노드 장애 시 유실 위험이 있어요.
Buffer 데이터는 Replica로 복제되지 않으므로, 이를 방지하기 위해:
- Buffer flush 주기를 짧게 설정하여 메모리 체류 시간 최소화
- Kafka를 앞단에 두어 장애 시 재처리 가능하게 구성
Store 테이블(ReplicatedMergeTree)에는 Replica를 구성하여, 동일 샤드의 두 노드를 서로 다른 Zone에 배치했어요. 이렇게 하면 한 노드가 죽어도 다른 노드에서 (flush된) 데이터를 보존해요.
Store 테이블
테이블 설계에서 가장 신경 쓴 부분은 PRIMARY KEY(ORDER BY)와 컬럼 타입이에요.
CREATE TABLE logs.transaction_logs_store_v1
(
`id` String DEFAULT generateUUIDv4() CODEC(ZSTD(3)),
`Timestamp` DateTime64(9, 'UTC') CODEC(DoubleDelta, LZ4),
-- LowCardinality: 카디널리티가 낮은 컬럼
`SeverityText` LowCardinality(String) DEFAULT 'INFO',
`ServiceName` LowCardinality(String),
`paysec_cluster` LowCardinality(String),
`paysec_namespace` LowCardinality(String),
`method` LowCardinality(String),
`phase` LowCardinality(String),
`Body` String CODEC(ZSTD(3)),
-- Materialized Column: JSON에서 자주 조회하는 필드 자동 추출
`app_name` String MATERIALIZED JSON_VALUE(Body, '$.app_name'),
`elapsed_time` Int32 MATERIALIZED toInt32OrZero(JSON_VALUE(Body, '$.elapsed_time')),
`request_headers_x_request_id` String MATERIALIZED JSON_VALUE(Body, '$.request.headers."x-request-id"'),
-- 로그 지연시간
`log_delay_ms` Int64 MATERIALIZED toInt64OrZero(LogAttributes['log_delay_ms']),
-- Secondary Index
INDEX url_idx url TYPE tokenbf_v1(30000, 3, 0) GRANULARITY 4,
INDEX app_name_idx app_name TYPE bloom_filter(0.01) GRANULARITY 4
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}')
PARTITION BY (toYYYYMMDD(Timestamp) * 100) + toHour(Timestamp)
ORDER BY (Timestamp, ServiceName, paysec_cluster, paysec_namespace)
TTL toStartOfHour(Timestamp) + toIntervalDay(9)
SETTINGS
parts_to_throw_insert = 30000,
parts_to_delay_insert = 15000,
ttl_only_drop_parts = 1;
ORDER BY (Primary Key) 설계
ClickHouse에서 ORDER BY는 단순히 정렬이 아니라 Primary Index를 정의해요. 쿼리 성능에 직접적인 영향을 미치기 때문에 개발크루들이 어떻게 로그를 조회하는지 분석했어요.
ORDER BY (Timestamp, ServiceName, paysec_cluster, paysec_namespace)
| 순서 | 컬럼 | 선택 이유 |
|---|---|---|
| 1 | Timestamp | 거의 모든 쿼리에 시간 범위 조건이 포함됨 |
| 2 | ServiceName | 특정 서비스의 로그를 조회하는 경우가 많음 |
| 3 | paysec_cluster | 클러스터별로 로그를 분리해서 보는 경우 |
| 4 | paysec_namespace | k8s namespace 기준 필터링 |
순서가 중요해요. WHERE Timestamp BETWEEN ... AND ServiceName = 'payment' 같은 쿼리가 대부분이기 때문에, 가장 자주 사용되는 조건부터 앞에 배치했어요.
LowCardinality 타입:
`SeverityText` LowCardinality(String) DEFAULT 'INFO',
`ServiceName` LowCardinality(String),
`phase` LowCardinality(String),
| 컬럼 | 고유값 수 | LowCardinality 효과 |
|---|---|---|
SeverityText | 5개 (DEBUG, INFO, WARN, ERROR, FATAL) | 딕셔너리로 저장, 압축률 극대화 |
ServiceName | ~1000개 | 반복되는 문자열을 숫자로 치환 |
phase | 4개 (dev, sandbox, beta, production) | 메모리/디스크 사용량 대폭 감소 |
LowCardinality는 고유값이 10,000개 이하일 때 효과적이에요. 우리 로그에서 이 조건에 맞는 컬럼들을 모두 LowCardinality로 설정했어요.
Materialized Column
`app_name` String MATERIALIZED JSON_VALUE(Body, '$.app_name'),
`elapsed_time` Int32 MATERIALIZED toInt32OrZero(JSON_VALUE(Body, '$.elapsed_time')),
Body는 JSON 형태인데, 매번 JSON_VALUE(Body, '$.app_name')로 파싱하면 느려요. 자주 조회하는 필드는 INSERT 시점에 미리 추출해서 별도 컬럼으로 저장해요. 쿼리할 때는 이미 파싱된 컬럼을 읽기만 하면 되죠.
PARTITION BY
PARTITION BY (toYYYYMMDD(Timestamp) * 100) + toHour(Timestamp)
-- 예: 2026011209 (2026년 1월 12일 09시)
시간 단위 파티션을 선택한 이유:
- 쿼리 성능 최적화: 시간 범위 조회에서 스캔 파티션 수가 줄기 때문이에요. 예: “최근 1시간” 조회 시 시간 단위는 1개 파티션만 읽지만, 일 단위는 그날 전체 파티션을 스캔해야 해요.
- 디스크 낭비 최소화: part의 크기가 “일 단위”면 TTL 경계(마지막 데이터 시점) 때문에 최대 10일치가 남는 낭비가 생길 수 있어요(예: TTL 9일). 반면 “시간 단위”면 최대 9일+1시간까지만 남아서 더 촘촘하게 삭제할 수 있어요.
- 운영 비용(리소스) 측면: ClickHouse TTL은 기본적으로 만료된 row를 merge 과정에서 정리하는 방식이라 데이터가 많을수록 CPU/IO를 많이 써요.
그래서
(설정) ttl_only_drop_parts=1로 TTL 만료된 데이터를 part 단위로 통째로 삭제(drop) 하도록 유도했어요.
CODEC:
`Timestamp` DateTime64(9, 'UTC') CODEC(DoubleDelta, LZ4),
`Body` String CODEC(ZSTD(3)),
| 컬럼 | CODEC | 이유 |
|---|---|---|
Timestamp | DoubleDelta, LZ4 | 시계열 데이터는 DoubleDelta가 효과적 |
Body | ZSTD(3) | 긴 문자열은 ZSTD 압축률이 높음 |
View 테이블
우리는 일반적으로 로그 타입별 Distributed 테이블을 직접 조회해요.
다만 nginx와 transaction처럼 서로 다른 로그 타입을 한 화면에서 같이 보고 싶을 때가 있어요. 이때만 View를 두고, 여러 테이블을 공통 스키마로 정규화한 통합 조회가 가능한 환경을 제공해요.
-- 예: 서로 다른 로그 타입을 공통 스키마로 묶는 통합 조회 View
-- (테이블명/컬럼은 예시이며, 실제 스키마에 맞게 조정)
CREATE VIEW logs.unified_logs ON CLUSTER pallas AS
SELECT
Timestamp,
ServiceName,
'transaction' AS log_type,
Body
FROM logs.transaction_logs_dist
UNION ALL
SELECT
Timestamp,
ServiceName,
'nginx' AS log_type,
Body
FROM logs.nginx_logs_dist;
권한 분리
| 용도 | 접근 테이블 |
|---|---|
| HyperDX 조회 | Distributed (기본), View (통합 조회 필요 시) |
| 로그 적재 | Buffer |
| 아카이빙 | Store 직접 접근 |
모니터링
-- 평균 지연 5분 이상 시 알람
SELECT
toStartOfMinute(Timestamp) as minute,
avg(log_delay_ms) / 1000 as avg_delay_sec
FROM logs.transaction_logs_store_v1
WHERE Timestamp > now() - INTERVAL 10 MINUTE
GROUP BY minute
HAVING avg_delay_sec > 300;
ClickHouse 모니터링에서 가장 중요한 지표는 CPU 사용률이에요. Merge 작업과 쿼리 처리에 직접적인 영향을 미치기 때문에 집중적으로 관찰하고 있어요.
Step 5. Amazon S3 아카이빙 (ssak3)
실시간 로그 처리 흐름이 안정화되자, 다음 고민은 “최근 데이터는 빠르게, 오래된 데이터는 저렴하게”였어요. 그래서 장기 보관을 S3로 분리했어요.
IDC ClickHouse에는 9일치 로그만 보관해요. 그 이상의 로그는 Amazon S3로 아카이빙해야 하죠.
기존 방식: Fluentd S3 Output
기존에는 Fluentd를 사용했어요. 선택한 이유가 있었거든요.
| 장점 | 설명 |
|---|---|
| 동적 경로 처리 | S3 path를 서비스 단위로 동적으로 구성 가능 |
| KMS 지원 | AWS KMS 암호화 네이티브 지원 |
하지만 운영하면서 문제가 드러났어요.
| 단점 | 설명 |
|---|---|
| IAM Role Anywhere 미지원 | Ruby 기반이라 AWS SDK Ruby에서 IAM Role Anywhere를 지원하지 않음 |
| 느린 처리 속도 | 유연성이 높은 대신 처리 속도가 현저하게 느림 |
| 중복 파싱 | OpenTelemetry에서 이미 파싱한 로그를 Fluentd에서 다시 파싱해야 함 |
| 메모리 과다 사용 | 서비스 단위로 로그를 묶는 과정에서 메모리 사용량 폭증 |
결국 ssak3라는 Python 기반 도구를 직접 개발했어요.
ssak3 아키텍처
ClickHouse에 이미 저장된 데이터를 쿼리로 조회해서 Amazon S3로 내보내는 방식으로 변경했어요.
설계
| 항목 | 값 | 이유 |
|---|---|---|
| 지연 | 3일 | Parts Merge 완료(2일) + 재처리 여유(1일) |
| 대역폭 | 500MB/s | ClickHouse 설정으로 운영 부하 최소화 |
| 청크 | 500만 rows/파일 | Amazon S3 조회 효율화 |
| 형식 | Parquet + ZSTD | 원본 대비 ~90% 절감 |
-- ClickHouse 설정으로 대역폭 제한
INSERT INTO FUNCTION s3(
's3://log-archive/transaction/.../*.parquet',
'Parquet'
)
SETTINGS max_network_bandwidth = 524288000 -- 500MB/s
SELECT * FROM logs.transaction_logs_store_v1
WHERE Timestamp >= '2026-01-09 00:00:00';
워터마크 테이블
CREATE TABLE logs.archive_watermark_detail ON CLUSTER pallas (
log_type String,
target_date Date,
partition String,
namespace String,
service String,
total_count UInt64,
last_offset UInt64 DEFAULT 0,
status String DEFAULT 'pending', -- pending/processing/completed/failed
worker_id String DEFAULT '',
updated_at DateTime DEFAULT now()
) ENGINE = ReplicatedReplacingMergeTree(..., updated_at)
ORDER BY (log_type, target_date, partition, namespace, service);
워터마크 테이블은 아카이빙 작업의 진행 상태를 추적해요:
| 상태 | 설명 |
|---|---|
pending | 아카이빙 대기 중 |
processing | ssak3 worker가 처리 중 (worker_id 기록) |
completed | 아카이빙 완료 |
failed | 처리 중 오류 발생 |
ssak3는 주기적으로 워터마크 테이블을 조회해서 pending 또는 failed 상태인 작업을 가져와요. failed 상태는 네트워크 오류, S3 업로드 실패 등으로 발생할 수 있는데, 별도 개입 없이 다음 스케줄에서 자동으로 재시도돼요. last_offset을 기록해두기 때문에 실패 지점부터 이어서 처리할 수 있어요.
Step 6. Amazon Athena에서 ClickHouse로 전환
S3로 오래된 로그를 쌓아두는 것만으로는 끝이 아니에요. **“필요할 때 빠르게 다시 꺼내볼 수 있느냐”**가 장기 보관의 핵심이거든요.
장기 보관 로그 조회도 개선이 필요했어요. 기존에는 Amazon S3에 저장된 로그를 Amazon Athena로 조회했는데, 여러 문제가 있었어요.
Amazon Athena의 한계
| 문제 | 상세 |
|---|---|
| 컬럼 수 제한 | 우리 로그는 컬럼이 2,000개 이상. Amazon Athena 테이블로 정의하기 어려움 |
| 쿼리 속도 | 서버리스 특성상 콜드 스타트 있고, 대용량 집계 쿼리 느림 |
| 스키마 관리 | 로그 필드가 바뀔 때마다 Glue 테이블 스키마 수정 필요 |
| 조회 정책 적용 한계 | Amazon S3 원본을 그대로 읽기 때문에, 조회용 필드 변환/정규화를 일관되게 적용하기 번거로움 |
ClickHouse S3 Engine
ClickHouse는 Amazon S3를 직접 테이블처럼 조회할 수 있어요.
-- Amazon S3에 저장된 Parquet 파일을 직접 조회
SELECT
Timestamp, ServiceName, Body
FROM s3(
's3://log-archive/transaction/2026/01/09/*.parquet',
'Parquet'
)
WHERE ServiceName = 'payment'
AND Timestamp BETWEEN '2026-01-09 09:00:00' AND '2026-01-09 10:00:00';
Amazon Athena처럼 별도 테이블 정의 없이, Parquet 파일의 스키마를 자동으로 읽어요. 컬럼이 2,000개든 3,000개든 상관없죠.
또한 AWS ClickHouse에서도 View를 얹어서 조회용 스키마를 정의할 수 있어요. 그래서 IDC/AWS처럼 데이터 소스가 달라도, 조회 레이어에서는 최대한 동일한 형태로 맞출 수 있어요.
두 가지 ClickHouse
| 항목 | IDC ClickHouse | AWS ClickHouse |
|---|---|---|
| 역할 | 실시간 로그 (최근 9일) | 장기 로그 (9일 이후) |
| 운영 방식 | 상시 운영 | 이슈 발생 시에만 기동 |
| 데이터 소스 | OpenTelemetry → Kafka → Buffer | Amazon S3 Parquet 직접 조회 |
| 비용 | IDC 고정 비용 | 사용한 만큼만 과금 |
AWS ClickHouse를 상시 운영하지 않는 이유는 비용이에요. 장기 로그 조회는 자주 발생하지 않거든요. 이슈가 생겼을 때만 필요한 규모로 기동하고, 조회가 끝나면 내려요.
통합 조회 경험
개발크루 입장에서는 IDC든 AWS든 동일한 HyperDX로 조회해요.
| 조회 대상 | 연결 | 쿼리 방식 |
|---|---|---|
| 최근 9일 | IDC ClickHouse | 일반 테이블 조회 |
| 9일 이후 | AWS ClickHouse | Amazon S3 Engine 조회 |
날짜 범위만 바꾸면 장기 로그도 조회할 수 있어요. 인터페이스 학습 비용 제로죠.
Step 7. 로그 조회 UI: HyperDX
저장과 아카이빙까지 정리했으니, 마지막으로 개발크루들이 로그를 조회할 UI가 필요했어요.
플랫폼 비교
| 플랫폼 | 장점 | 단점 |
|---|---|---|
| Grafana | 대시보드 기능 우수, 익숙함 | 로그 검색 UI가 부족, Loki 중심 |
| Signoz | OpenTelemetry 네이티브 통합 | 아래 상세 설명 |
| HyperDX | ClickHouse 네이티브, 컬럼 검색 최적화 | 상대적으로 신생 프로젝트 |
Signoz가 맞지 않았던 이유
Signoz는 OpenTelemetry와 강하게 통합된 좋은 플랫폼이에요. 하지만 우리 상황에서는 몇 가지 문제가 있었어요.
| 문제 | 상세 |
|---|---|
| 테이블 구조 고정 | Signoz는 자체 스키마를 강제해요. 우리처럼 Buffer/Store/View를 분리하는 구조를 사용할 수 없음 |
| attributes Map 접근 제한 | OpenTelemetry의 LogAttributes Map에서 개별 필드를 꺼내서 검색하기 어려움. Signoz UI에서 보여주지도 않음 |
| 커스터마이징 한계 | ClickHouse의 장점(LowCardinality, Materialized Column 등)을 활용할 수 없음 |
결국 Signoz를 쓰면 ClickHouse를 블랙박스처럼 써야 했어요. 우리가 설계한 테이블 구조의 장점을 전혀 살릴 수 없었죠.
HyperDX 선택 이유
HyperDX는 ClickHouse를 직접 쿼리하는 방식이에요. 우리가 만든 테이블을 그대로 사용할 수 있죠.
컬럼 검색 vs LIKE 검색:
-- LIKE 검색: Body 전체를 스캔
SELECT * FROM logs WHERE Body LIKE '%error%';
-- 컬럼 검색: 특정 컬럼만 스캔
SELECT * FROM logs WHERE ServiceName = 'payment';
| 검색 방식 | 원리 | 속도 |
|---|---|---|
| LIKE 검색 | Body 전체를 한 글자씩 비교 | 느림 |
| 컬럼 검색 | 컬럼형 저장소에서 해당 컬럼만 읽기 | 20배 이상 빠름 |
HyperDX는 컬럼 검색을 기본으로 해요. ServiceName:payment처럼 필드를 지정하면 해당 컬럼만 스캔하니까 훨씬 빨라요.
HyperDX 주요 기능
| 기능 | 설명 |
|---|---|
| 컬럼 기반 필터 | ServiceName:payment AND level:ERROR 형태로 빠른 검색 |
| 타임라인 뷰 | 시간 순서대로 로그 흐름 파악 |
| 세션 추적 | 동일 사용자의 요청을 타임라인으로 연결 |
| 대시보드 | 로그 기반 메트릭 시각화 |
| 알람 | 특정 조건 로그 발생 시 알림 |
Grafana와 병행
Grafana는 메트릭 대시보드 용도로 일부 영역에서 병행 사용하고 있어요. 로그 검색은 HyperDX, 시스템 메트릭 모니터링은 Grafana로 역할을 분리했어요.
성과
이제 전체 흐름(수집→처리→저장→아카이빙→조회)을 갖췄으니, 실제로 무엇이 얼마나 좋아졌는지 정리해볼게요.
그래서 결과는 어땠을까요? 세 가지 관점에서 정리해볼게요.
🧑💻 개발크루 관점
| Before | After |
|---|---|
| 장애 분석하려는데 로그가 아직 안 들어왔어요 | 로그 발생 후 20초 이내 조회 가능 |
| OpenSearch, Amazon Athena 따로 배워야 했어요 | HyperDX 하나로 통합 (실시간 + 장기 보관) |
| 조회 방식이 제각각이라 운영/학습 부담이 컸어요 | View로 조회 레이어를 일원화해서 같은 방식으로 확인 가능 |
| 로그를 서비스별로 나눠서 확인해야 했어요 | Table 통합 + User ID 기반으로 모든 Application 타임라인 조회 가능 |
⚡ 성능 관점
| 지표 | Before | After |
|---|---|---|
| 로그 지연 | 수 분 ~ 수 시간 | 20초 이내 (모든 로그) |
| 초당 처리량 | 측정 어려움 | 83만 건/초 |
| OpenTelemetry 처리 | 여러 Topic consume 시 병목 | 단일 Topic consume으로 6배 향상 |
| 장애 대응 | 파티션 추가해도 과거 로그 그대로 | Scale Out만으로 과거 로그까지 처리 |
💰 비용 효율 관점
| 항목 | 절감률 | 상세 |
|---|---|---|
| 전체 비용 | 85.6% | IDC 물리서버 기준 |
| 처리기 (Fluentd → OpenTelemetry) | 96% | 메모리 사용량 대폭 감소 |
| 저장소 (OpenSearch → ClickHouse) | 78% | 컬럼형 저장 + ZSTD 압축 |
| 저장 용량 | ~90% | Parquet + ZSTD 압축 |
| 운영 복잡도 | - | Kafka Topic 300개 → 18개, Fluentd 1,000여 개 → OpenTelemetry 150여 개 |
마치며
카카오페이증권에서 가장 데이터가 많은 플랫폼을, Sean.baek, Lina.a 단 두 명이서 전환했어요. 하루 41TB, 200억 건의 로그를 20초 이내에 처리하면서 비용을 85% 절감하기까지, 쉽지 않은 여정이었어요.
이번 프로젝트의 핵심은 한정된 자원을 최대한 효율적으로 활용하는 것이었어요. 클라우드처럼 무한히 확장할 수 있는 환경이 아니라, IDC라는 물리적 제약 안에서 최선의 결과를 내야 했거든요. 그래서 모든 설계에서 IDC와 클라우드의 특성을 함께 고려했고, 결과적으로 IDC 환경에서도 클라우드 못지않은 성과를 낼 수 있었어요.
로그 시스템을 고민하고 계신 분들께 조금이나마 도움이 되었으면 좋겠어요. 궁금한 점이 있으시면 편하게 연락 주세요!