일 41TB, 200억 건의 로그를 ClickStack으로 실시간 처리하기 - 호그와트 도서관 프로젝트

일 41TB, 200억 건의 로그를 ClickStack으로 실시간 처리하기 - 호그와트 도서관 프로젝트

시작하며

안녕하세요. 카카오페이증권 DevOps 팀 Sean.baek (션), Lina.a (리나)에요.

우리는 전사 Cloud, redis, kafka, 로깅 플랫폼, AI Chatbot(춘시리), FinOps와 다양한 오픈소스 플랫폼을 운영하고 있어요.

2022년 9월에 입사했을 때, 하루에 쌓이는 로그는 약 100GB였어요. 그때는 로깅 시스템이 큰 고민거리가 아니었죠. 그런데 카카오페이증권의 서비스가 빠르게 성장하면서 상황이 달라졌어요. 로그가 폭발적으로 늘어나기 시작한 거예요.

로그 증가 추이 그래프
로그 증가 추이 그래프

지금은 하루에 41TB, 200억 건 이상의 로그가 쌓이고 있어요. 3년 만에 410배 이상 증가한 거죠. 문제는 로그량만이 아니었어요. 개발크루들로부터 피드백이 쏟아지기 시작했거든요.

“로그 조회가 너무 느려요. 5분 넘게 걸릴 때도 있어요.” > “장애 분석하려는데 로그가 아직 안 들어왔어요.”

로그 유입 지연, 조회 성능 저하… 기존 OpenSearch 기반 시스템으로는 이 성장세를 감당할 수 없었어요. 비용도 로그 양이 늘어나는 만큼 비례해서 8배 이상 증가하고 있었고요.

우리는 근본적인 질문을 던져야 했어요. PB 규모의 로그를 저비용으로, 그리고 빠르게 처리할 수 있는 방법이 뭘까?

그렇게 시작한 것이 호그와트 도서관 프로젝트에요. 결과부터 말하면:

지표BeforeAfter
로그 지연수 분 ~ 수 시간20초 이내
비용100%14.4% (85.6% 절감)

어떻게 이런 결과를 만들었을까요? 지금부터 단계별로 설명할게요.


기존 아키텍처의 한계

먼저 기존 시스템이 어떻게 구성되어 있었는지 볼게요.

기존 아키텍처 다이어그램
기존 아키텍처 다이어그램

문제점

  1. 비용: 로그량 증가와 비례해서 OpenSearch 비용이 8배 이상 증가
  2. 성능: Fluentd의 청크 처리 방식으로 메모리 사용량 증가 및 처리 지연
  3. 확장성: 서비스 단위 Topic 분리로 Kafka Topic이 300개 이상으로 증가
  4. 장기 조회: 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로그 조회 UIHyperDX 선택 이유

앞에서부터 병목을 하나씩 걷어내는 방식으로 진행했어요. 먼저 가장 앞단인 수집부터 시작할게요.


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-IsTo-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디버깅용 상세 로그최선 노력

scaling
scaling

이 구조의 핵심은 피크 시 리소스 재분배예요:

  1. 평소에는 세 Pool이 각자 처리
  2. 09:00/23:30 피크 시 → Debug Pool Scale In, Fast Pool Scale Out
  3. 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배 이상 성능이 개선된 거죠.

항목FluentdOpenTelemetry 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로 이 문제를 해결했어요.

otel내부
otel내부

핵심 설정은 다음과 같아요.

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 네 가지 개념을 알아야 해요.

clickhouse 내부구조
clickhouse 내부구조

개념설명우리의 설정
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_insert20,000파티션당 Parts가 초과하면 INSERT 지연
parts_to_throw_insert50,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_bytes1GB로 크게 잡는 대신, max_time30초로 짧게 설정했어요. 이렇게 하면:

  • 평시: 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)
순서컬럼선택 이유
1Timestamp거의 모든 쿼리에 시간 범위 조건이 포함됨
2ServiceName특정 서비스의 로그를 조회하는 경우가 많음
3paysec_cluster클러스터별로 로그를 분리해서 보는 경우
4paysec_namespacek8s namespace 기준 필터링

순서가 중요해요. WHERE Timestamp BETWEEN ... AND ServiceName = 'payment' 같은 쿼리가 대부분이기 때문에, 가장 자주 사용되는 조건부터 앞에 배치했어요.

LowCardinality 타입:

`SeverityText` LowCardinality(String) DEFAULT 'INFO',
`ServiceName` LowCardinality(String),
`phase` LowCardinality(String),
컬럼고유값 수LowCardinality 효과
SeverityText5개 (DEBUG, INFO, WARN, ERROR, FATAL)딕셔너리로 저장, 압축률 극대화
ServiceName~1000개반복되는 문자열을 숫자로 치환
phase4개 (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이유
TimestampDoubleDelta, LZ4시계열 데이터는 DoubleDelta가 효과적
BodyZSTD(3)긴 문자열은 ZSTD 압축률이 높음

View 테이블

우리는 일반적으로 로그 타입별 Distributed 테이블을 직접 조회해요.

다만 nginxtransaction처럼 서로 다른 로그 타입을 한 화면에서 같이 보고 싶을 때가 있어요. 이때만 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/sClickHouse 설정으로 운영 부하 최소화
청크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아카이빙 대기 중
processingssak3 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 ClickHouseAWS ClickHouse
역할실시간 로그 (최근 9일)장기 로그 (9일 이후)
운영 방식상시 운영이슈 발생 시에만 기동
데이터 소스OpenTelemetry → Kafka → BufferAmazon S3 Parquet 직접 조회
비용IDC 고정 비용사용한 만큼만 과금

AWS ClickHouse를 상시 운영하지 않는 이유는 비용이에요. 장기 로그 조회는 자주 발생하지 않거든요. 이슈가 생겼을 때만 필요한 규모로 기동하고, 조회가 끝나면 내려요.

통합 조회 경험

개발크루 입장에서는 IDC든 AWS든 동일한 HyperDX로 조회해요.

조회 대상연결쿼리 방식
최근 9일IDC ClickHouse일반 테이블 조회
9일 이후AWS ClickHouseAmazon S3 Engine 조회

날짜 범위만 바꾸면 장기 로그도 조회할 수 있어요. 인터페이스 학습 비용 제로죠.


Step 7. 로그 조회 UI: HyperDX

저장과 아카이빙까지 정리했으니, 마지막으로 개발크루들이 로그를 조회할 UI가 필요했어요.

플랫폼 비교

플랫폼장점단점
Grafana대시보드 기능 우수, 익숙함로그 검색 UI가 부족, Loki 중심
SignozOpenTelemetry 네이티브 통합아래 상세 설명
HyperDXClickHouse 네이티브, 컬럼 검색 최적화상대적으로 신생 프로젝트

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로 역할을 분리했어요.


성과

이제 전체 흐름(수집→처리→저장→아카이빙→조회)을 갖췄으니, 실제로 무엇이 얼마나 좋아졌는지 정리해볼게요.

그래서 결과는 어땠을까요? 세 가지 관점에서 정리해볼게요.

🧑‍💻 개발크루 관점

BeforeAfter
장애 분석하려는데 로그가 아직 안 들어왔어요로그 발생 후 20초 이내 조회 가능
OpenSearch, Amazon Athena 따로 배워야 했어요HyperDX 하나로 통합 (실시간 + 장기 보관)
조회 방식이 제각각이라 운영/학습 부담이 컸어요View로 조회 레이어를 일원화해서 같은 방식으로 확인 가능
로그를 서비스별로 나눠서 확인해야 했어요Table 통합 + User ID 기반으로 모든 Application 타임라인 조회 가능

⚡ 성능 관점

지표BeforeAfter
로그 지연수 분 ~ 수 시간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 환경에서도 클라우드 못지않은 성과를 낼 수 있었어요.

로그 시스템을 고민하고 계신 분들께 조금이나마 도움이 되었으면 좋겠어요. 궁금한 점이 있으시면 편하게 연락 주세요!

sean.baek
sean.baek

카카오페이증권 DevOps 팀에서 전사 Cloud·Redis·Kafka·로깅 플랫폼·AI Chatbot(춘시리)·FinOps와 다양한 오픈소스 플랫폼을 도입하고 운영하고 있습니다.

lina.a
lina.a

카카오페이증권 DevOps 팀에서 전사 Kafka·Redis·Cloud·로깅 플랫폼 등의 다양한 오픈소스 플랫폼을 요리조리 담당하고 있는 리나입니다:)