사례 연구 · 통신사 OSS 데이터 레이크

통신 단말 데이터를 사람, 서비스, AI 에이전트가 함께 쓰는 OSS 레이크

관제 화면 하나가 느렸던 데서 시작해, 데이터를 한곳에 모으고 여러 팀과 AI 에이전트가 나눠 쓰는 구조로 바꾼 이야기입니다.

합성 시나리오입니다. 가상의 통신사를 두고 흔한 통신 OSS 관행(3GPP PM/CM/트레이스, 메달리온 계층)으로 구성했으며, 이름은 지어낸 것이고 수치는 PoC에서 측정한 값입니다.

1상황

NOC · 지난 1시간

사용자 많은 셀 10

신호 약한 셀 10

통신사 OSS 팀의 관제 화면은 '지난 1시간, 사용자가 많은 셀과 신호가 약한 셀'을 보여 줍니다. 단말(UE: 휴대폰, IoT 모듈, 차량, 공유기 등)의 측정값을 매번 원천에서 다시 집계하느라 화면이 느렸습니다.

  • 파일 기반 레이크로는 대화형 응답 시간을 맞추기 어려움
  • 단계마다 스키마가 따로 정의되어 이름과 뜻이 어긋남
  • 파이프라인과 스키마를 바꾸는 데 오래 걸림
  • 예측할 수 없는 즉석 질문과 AI 에이전트의 질의가 늘어남

통신사 시스템은 크게 둘로 나뉩니다. BSS는 고객과 돈(가입, 요금, 청구)을, OSS는 네트워크 자체(장비, 장애, 품질)를 다룹니다. 이 레이크는 OSS 쪽입니다.

2업계 흐름: OSS 데이터 레이어의 가상화

데이터를 시스템마다 복사하는 대신, 하나의 데이터 계층 위에 의미와 접근 통제를 얹어 사람과 AI 에이전트가 같은 데이터를 쓰게 하는 쪽으로 가고 있습니다. 공개 자료로 정리한 층입니다.

  1. 소비자자율 운영 에이전트, 네트워크 디지털 트윈, NOC · BIManaged Postgres
  2. 접근과 통제표준 Open API, MCP 도구 카탈로그, 정책 · 감사, 쓰기 전 사람 승인Managed Postgres
  3. 의미 계층온톨로지 · 지식 그래프, 토폴로지 · 인벤토리 모델, 표준 정보 모델
  4. 데이터 제품도메인이 소유하고 계약을 붙인 정제 데이터(골드)ClickHouse
  5. 저장과 질의오픈 테이블 레이크의 이력을 옮기지 않고 그 자리에서 조회ClickHouse + Iceberg
  6. 수집RAN · 코어 · 전송 · 프로브의 스트리밍, 표준화된 망 데이터 수집
  7. 원천망 기능, 장비 관리 시스템, OSS/BSS 기록 시스템

Managed Postgres 소비자 쪽의 사용 패턴과 작업 기록(구독, API 키, 워터마크, 티켓, 에이전트의 작업 내역과 승인)은 트랜잭션 데이터라 ClickHouse의 Managed Postgres에 두고, 레이크와는 pg_clickhouse로 잇습니다.

3아키텍처

전형적인 통신사 OSS 레이크 전체입니다.

스키마 계약 (Git): 컬럼 이름, 설명, 타입을 Git의 계약 파일 한 벌에 정의하고, 각 단계의 스키마와 DDL을 거기서 자동으로 만듭니다.

컨트롤 플레인 API: 토픽, 스트림 잡, 커넥터, 스키마, 테이블을 사람 손 대신 API로 선언하고 배포하고 관측합니다. 사람과 AI 에이전트가 같은 API를 씁니다.

4누가 무엇에 쓰는가

같은 데이터를 여러 팀이 각자의 방식으로 씁니다.

관제센터 (NOC)

지금 어디가 붐비고, 어디가 약하고, 어디서 장애가 났나

그림은 JavaScript로 그립니다.

  • 사용자 많은 셀
  • 신호 약한 셀
  • 방금 신호가 떨어진 지역
  • 측정 건수
자세히

지난 1시간 동안 사용자가 가장 많은 셀(파랑), 신호가 가장 약한 셀(빨강), 방금 1분 사이 신호가 갑자기 떨어진 지역(노랑)을 한 화면에 그립니다.

어떻게: 화면에 필요한 답만 1분마다 미리 계산해 두므로 화면은 바로 뜹니다. 셀 경계는 ClickHouse가 H3 함수로 돌려줍니다.

운영 대시보드 · BI

분마다 바뀌는 KPI 추이

그림은 JavaScript로 그립니다.

자세히

대시보드 도구가 1분마다 같은 질의를 던져 선을 그립니다. 아래는 도시별 약한 신호 비율의 지난 1시간이며, 솟은 점은 그 분에 어느 지역에 장애가 있었다는 뜻입니다.

어떻게: 대시보드 도구가 ClickHouse에 직접 붙습니다. 사용자 수처럼 중복을 빼야 하는 값도 1분 요약에 담긴 상태를 합쳐 정확히 셉니다.

-- ---------------------------------------------------------------- 2 · dashboard
-- One point per city per minute for a time-series panel (Grafana, Superset, ...):
-- distinct users (uniq states merged across the city's cells) and weak-signal share.
CREATE OR REPLACE VIEW oss_lake.uc_dashboard AS
WITH (SELECT max(anchor_ts) FROM oss_lake.params) AS t
SELECT
    k.bucket                                                 AS minute,
    g.city                                                   AS city,
    uniqMerge(k.users_st)                                    AS users,
    sum(k.samples_n)                                         AS samples,
    round(100 * sum(k.poor_n) / sum(k.rsrp_n), 2)            AS poor_pct
FROM oss_lake.cell_kpi_1m AS k
INNER JOIN oss_lake.grid AS g ON g.h3 = k.cell
WHERE k.bucket >= t - INTERVAL 1 HOUR AND k.bucket < t
GROUP BY minute, city
ORDER BY minute, city;

품질 분석가

미리 준비하지 않은 질문에 바로 답하기

도시구역약한 신호 비율
A시도심0%
A시중간2.6%
A시외곽23.1%
B시도심0%
B시중간2.5%
B시외곽23.1%
C시도심0.1%
C시중간2.6%
C시외곽23.1%
자세히

약한 신호는 도심에 많을까, 외곽에 많을까? 미리 만들어 두지 않은 질문을 그 자리에서 SQL로 묻습니다.

어떻게: 1분 요약에 바로 묻고 지역 정보와 조인합니다. 파일 기반 레이크에서는 대화형 응답 시간을 맞추기 어려웠던 종류의 질문입니다.

-- ---------------------------------------------------------------- 1 · ad-hoc analysis
-- A quality analyst's question: where is weak signal concentrated, downtown or at the edge?
CREATE OR REPLACE VIEW oss_lake.uc_analysis AS
WITH (SELECT max(anchor_ts) FROM oss_lake.params) AS t
SELECT
    g.city                                                   AS city,
    multiIf(g.ring <= 10, 'downtown', g.ring <= 30, 'middle', 'edge') AS zone,
    uniqExact(k.cell)                                        AS cells,
    sum(k.samples_n)                                         AS samples,
    round(sum(k.rsrp_sum) / sum(k.rsrp_n), 1)                AS avg_rsrp,
    round(100 * sum(k.poor_n) / sum(k.rsrp_n), 1)            AS poor_pct
FROM oss_lake.cell_kpi_1m AS k
INNER JOIN oss_lake.grid AS g ON g.h3 = k.cell
WHERE k.bucket >= t - INTERVAL 1 HOUR AND k.bucket < t
GROUP BY city, zone
ORDER BY city, indexOf(['downtown', 'middle', 'edge'], zone);

온콜 담당자 · 다운스트림 시스템

구독: 일이 생기면 알려 주기

  • 10:20 · C시 지역 8730c19a5ffffff: 셀 48개 신호 21.2 dB 하락
자세히

셀의 최근 1분 신호가 자기 지난 1시간보다 크게 떨어지면 알림이 됩니다. 셀을 지역 단위로 묶어 사람이 읽을 한 줄로 만듭니다. 아래는 PoC에서 잡힌 알림입니다.

어떻게: 보통은 새 데이터가 준비되면 메시지 큐로 '지금 가져가라'는 이벤트를 보내고, 구독자가 그때 받아 갑니다. Kafka 토픽으로 결과를 바로 밀어 줄 수도 있습니다.

운영에서는 같은 행을 URL 테이블 엔진으로 웹훅에 바로 밀어 줄 수 있습니다.

-- Example, not run in this lab (it needs a Kafka broker): publish each minute's alerts to a
-- topic that an on-call app or chat bot subscribes to. Inserting into a Kafka-engine table
-- produces messages; a refreshable MV with APPEND does that once a minute.
CREATE TABLE oss_lake.alerts_topic
(
    area String, city UInt8, minute DateTime, cells UInt64, drop_db Float64, rsrp_now Float64
)
ENGINE = Kafka
SETTINGS kafka_broker_list = 'broker:9092',
         kafka_topic_list  = 'noc-alerts',
         kafka_group_name  = 'oss-lake',
         kafka_format      = 'JSONEachRow';

CREATE MATERIALIZED VIEW oss_lake.alerts_publish
REFRESH EVERY 1 MINUTE APPEND TO oss_lake.alerts_topic AS
SELECT area, city, minute, cells, drop_db, rsrp_now FROM oss_lake.uc_alert_areas;

업무 앱 · 서비스 · AI 에이전트

즉시 조회: HTTP 한 번으로 받아 가기

curl 'http://localhost:18123/?user=oss_reader' \
  --data-binary 'SELECT area, city, minute, cells, drop_db, rsrp_now FROM oss_lake.uc_alert_areas FORMAT JSONEachRow'
{"area":"8730c19a5ffffff","city":2,"minute":"2026-10-18 10:20:00","cells":48,"drop_db":21.2,"rsrp_now":-114.1}
자세히

소비자는 HTTP 요청 하나로 필요한 데이터를 받아 갑니다. 보통은 API 서비스가 ClickHouse 앞에서 인증과 카탈로그를 맡고, 원래 형식과 업계 표준 모델 형식 중 하나를 골라 줍니다. 아래는 ClickHouse의 HTTP 인터페이스에 직접 보낸 요청과 응답의 앞부분입니다.

어떻게: ClickHouse는 HTTP로 질의를 받고 결과를 만드는 대로 한 줄에 한 행씩 흘려보냅니다. 드라이버가 없어도 됩니다.

서빙 계층 · 업무 앱 (향후 확장)

Managed Postgres: 구독과 키, 업무 데이터는 Postgres에

자세히

서빙 계층에는 구독 목록, API 키, 어디까지 보냈는지(워터마크) 같은 작은 업무 데이터가 따르고, 보통 Postgres에 둡니다. ClickHouse의 Managed Postgres를 쓰면 이 업무 데이터와 레이크가 한 클라우드에 있고, pg_clickhouse 확장으로 Postgres SQL 그대로 레이크의 알림을 담당 팀, 열린 티켓과 조인할 수 있습니다.

근거: 같은 방식을 clickhouse-managed-postgres-hols 저장소의 pg_clickhouse 실습에서 검증했습니다(2026-10-01).

-- Expansion example, not run in this lab. Runs on (Managed) Postgres, where an operations app
-- keeps tickets and area owners; pg_clickhouse reads the lake's alert view in place.
-- Same statements as clickhouse-managed-postgres-hols extensions/pg-clickhouse-lab
-- (verified there 2026-10-01 on pg_clickhouse 0.11.0, PostgreSQL 18.6); host and password are placeholders.
CREATE EXTENSION IF NOT EXISTS pg_clickhouse;

CREATE SERVER lake FOREIGN DATA WRAPPER clickhouse_fdw
    OPTIONS (driver 'binary', host '<clickhouse host>', port '9000', dbname 'oss_lake');
CREATE USER MAPPING FOR CURRENT_USER SERVER lake
    OPTIONS (user 'oss_reader', password '<password>');

CREATE SCHEMA lake;
IMPORT FOREIGN SCHEMA oss_lake LIMIT TO (uc_alert_areas) FROM SERVER lake INTO lake;

-- who to call: this minute's alert areas with their owner team and open tickets
SELECT a.area, a.cells, a.drop_db, o.team, count(t.id) AS open_tickets
FROM lake.uc_alert_areas AS a
JOIN ops.area_owner AS o ON o.area = a.area
LEFT JOIN ops.ticket AS t ON t.area = a.area AND t.status = 'open'
GROUP BY a.area, a.cells, a.drop_db, o.team
ORDER BY a.cells DESC;

AI 에이전트

자연어 질문을 SQL로 바꿔 답하기

자세히

운영자가 '지난 30분 동안 B시에서 신호가 떨어진 지역은?'처럼 물으면, 에이전트가 카탈로그와 스키마 설명을 보고 SQL을 만들어 레이크에 묻습니다. 스키마 계약의 설명이 그대로 에이전트의 맥락이 됩니다.

어떻게: 에이전트는 MCP나 HTTP로 ClickHouse에 붙고, 읽기 전용 사용자로 조회합니다. 예측할 수 없는 질문이 많아도 요약 테이블 덕분에 빠르게 답합니다.

데이터 플랫폼 팀

스키마를 한곳에서 바꾸고 바로 반영하기

자세히

스키마가 단계마다 따로 정의되면 이름과 뜻이 조금씩 어긋나고, 바꿀 때마다 여러 팀을 거쳐야 합니다. 흔한 해법은 Git의 데이터 계약 파일 한 벌을 기준으로 두고, 메시지 스키마, 레이크 테이블 정의, ClickHouse DDL을 CI에서 자동으로 만드는 것입니다.

어떻게: ClickHouse는 생성된 DDL을 그대로 받습니다. 테이블과 MV가 SQL로 정의되므로 리뷰와 배포를 코드처럼 합니다.

데이터 품질 담당

들어오는 데이터의 품질을 단계마다 확인하기

자세히

셀이나 가입자가 없는 측정은 다듬기 단계에서 버리고, 신호 값이 없는 측정은 0이 아니라 '없음'으로 남깁니다. 단계마다 무엇을 버리고 남겼는지 행 수로 확인할 수 있습니다.

어떻게: 정제 규칙이 Materialized View의 SQL 한 곳에 있으므로 규칙과 결과를 함께 검토합니다. 원본은 받은 그대로 남아 규칙이 바뀌면 다시 만들 수 있습니다.

플랫폼 운영 (SRE)

파이프라인 자체를 관측하기

자세히

수집기, 메시지 버스, 스트림 잡, 커넥터가 많아지면 어디서 밀리고 어디서 실패하는지 보는 것 자체가 일입니다. 이 구성 요소들의 로그, 지표, 트레이스도 같은 ClickHouse에 모아 볼 수 있습니다.

어떻게: ClickHouse 기반 관측 스택인 ClickStack으로 OpenTelemetry 데이터를 받아 검색과 대시보드를 씁니다.

분석가 · 데이터 과학자

오래된 이력과 최근 데이터를 함께 보기

자세히

긴 이력은 오픈 테이블 포맷 레이크(Iceberg 등)에 두고, 최근 데이터는 ClickHouse에서 빠르게 씁니다. 둘을 한 질의로 이어 보면 재처리나 장기 추세 분석이 쉬워집니다.

어떻게: ClickHouse는 오브젝트 스토리지의 Iceberg 테이블을 직접 읽을 수 있어, 레이크를 옮기지 않고 골드 테이블과 조인합니다.

데이터 엔지니어 · 다른 분석 플랫폼

대량 추출: 한 번에 많이 가져가기

자세히

모델 학습이나 다른 시스템 적재처럼 큰 덩어리가 필요할 때는 기간 단위로 한꺼번에 내보냅니다.

어떻게: ClickHouse는 질의 결과를 오브젝트 스토리지에 파일로 바로 쓰거나, HTTP로 흘려보낼 수 있습니다. 즉시 조회 다음으로 흔히 붙는 서빙 방식입니다.

AI 개발 팀

에이전트가 맞게 답하는지 관측하기

자세히

에이전트가 만든 SQL과 답이 맞는지, 비용과 지연은 어떤지 기록하고 평가해야 믿고 쓸 수 있습니다.

어떻게: ClickHouse를 저장소로 쓰는 Langfuse로 에이전트의 트레이스, 평가, 비용을 같은 플랫폼에서 봅니다.

5PoC에서 검증한 기능

PoC에서 아래 기능을 실제 데이터로 하나씩 검증했습니다.

  • ✓
    받은 그대로 적재형식 변환 없이 저장, 일 단위 파티션, 3일 TTL원본 행 수 = 생성한 행 수
  • ✓
    적재 순간의 정제MV로 H3 문자열 → 숫자, IMSI 암호문 → 해시, NULL 정책측정 한 건의 전과 후 비교
  • ✓
    인벤토리 조회로 보강Dictionary(dictGet)로 셀마다 지역 이름모든 행에 지역 이름
  • ✓
    적재 시 사전 집계AggregatingMergeTree, uniq 상태 + 합계, 셀 × 1분한 셀의 1분치 여러 행 → 1행
  • ✓
    골드 위의 골드10분 요약을 1분 요약에서 연쇄 MV로원본에서 계산한 10분 집계와 일치, 대조군 검출
  • ✓
    지리 축 요약지역 × 1분 요약을 같은 실버 행에서합계 = 실버 행 수
  • ✓
    1분마다 새로 고치는 답Refreshable MV가 20행을 통째로 교체새로 고침 중에도 항상 20행이 읽힘
  • ✓
    어느 단계에 물어도 같은 답네 단계에 같은 관제 질의20행 동일
  • ✓
    급격한 하락 알림지난 1시간 대비 12 dB 이상 떨어진 셀을 지역 단위로신호가 떨어진 지역을 같은 1분 안에 검출
  • ✓
    HTTP 서빙과 읽기 전용 사용자결과를 한 줄씩 JSON으로 흘려보내기읽기 전용 사용자로 조회, 쓰기 시도는 거부
  • ✓
    H3 지리 함수셀 경계와 상위 해상도 집계를 SQL에서h3ToGeoBoundary로 그린 관제 지도

검증한 흐름: 측정 한 건이 단계마다 보이는 모습

  1. 받은 그대로 보관

    고치지 않고 저장합니다. 규칙이 바뀌면 여기서 다시 만듭니다.

    h3 '8930e1d8c0bffff' · imsi '/ZM4BSO2…'

  2. 다듬기

    형식을 맞추고 가입자 번호를 해시로 바꿉니다. 쓸 수 없는 행은 버립니다.

    h3 617853093946327039 · imsi → 624750…

  3. 1분 요약

    셀마다 1분에 한 행으로 접습니다. 어떤 기간이든 다시 합칠 수 있습니다.

    한 셀의 1분 441행 → 1행

  4. 바로 쓰는 답

    화면이 읽을 답만 1분마다 다시 계산해 둡니다.

    관제 지도에 필요한 20행

다듬기

셀 × 1분

지역 × 1분

셀 × 10분1분 요약을 이어받음

지역 요약: 지난 1시간 약한 신호 비율이 높은 지역

지역약한 신호 비율
C North-West13.3%
B South-West13.3%
A North-East13.1%
B South-East12.5%
A West12.5%
엔지니어용: 테이블, 변환, SQL, 측정값

네 단계는 데이터베이스 oss_lake의 ue_trace_raw, ue_trace, cell_kpi_1m, cell_rank_now 테이블입니다. 앞의 둘은 Materialized View로, 마지막은 1분마다 도는 Refreshable Materialized View로 이어집니다. 유즈케이스는 04-usecases.sql의 뷰입니다.

측정 한 건의 전과 후

받은 그대로다듬기바뀐 점
event_ts
2026-10-18 10:20:00.011
event_ts
2026-10-18 10:20:00.011
그대로
h3_res09
8930e1d8c0bffff
h3
617853093946327039
문자열 → 숫자 (stringToH3)
imsi
/ZM4BSO2N5g8Ovcpun3PtidkglHPB22vffMxteYPT5w=
imsi_hash
6247503676043346188
암호문 → 해시 (cityHash64). 원래 번호는 레이크에 없습니다
rsrp
-69
rsrp
-69
Float32. 값이 없으면 NULL 유지
rsrq
-4.5
rsrq
-4.5
Float32
sinr
20
sinr
20
Float32
cqi
13
cqi
13
문자열 → 숫자
pci
290
—쓰지 않아 버림
probe
probe-a
—쓰지 않아 버림
—
region1
City A
인벤토리에서 붙인 지역 이름
—
region2
A Central
인벤토리에서 붙인 지역 이름

441행이 접힌 1분 요약 한 행

bucket2026-10-18 10:20:00
cell617853093946327039
users_stuniqState(…) → 133
samples_n441
rsrp_n399
rsrp_sum-31229
poor_n0

관제 지도가 읽는 20행

사용자 많은 셀 10

#cellusers
18930e1d8c0bffff5,475
28930c1903cbffff5,449
38930c1619d7ffff5,385
48930e1d8c03ffff2,596
58930c190227ffff2,565
68930c16198bffff2,545
78930e1d8c0fffff2,016
88930c16199bffff1,969
98930c190237ffff1,910
108930c190353ffff1,777

신호 약한 셀 10

#cellavg_rsrp
18930c16ad73ffff-117.9 dBm
28930e1c302fffff-117.5 dBm
38930c16ad3bffff-117.5 dBm
48930c19221bffff-117.4 dBm
58930c16ad03ffff-117.4 dBm
68930c16ad07ffff-117.4 dBm
78930e1c3397ffff-117.3 dBm
88930c1922cfffff-117.2 dBm
98930e1c339bffff-117.2 dBm
108930c16ad0fffff-117.2 dBm

정의

-- ---------------------------------------------------------------- the inventory (CM)
-- Every cell gets a site and a fictional district: 'Central' within 10 rings, otherwise one
-- of six 60-degree sectors around the city centre. Coverage holes are known weak spots.
INSERT INTO oss_lake.cell_inventory
WITH
    [(37.5665, 126.9780), (35.1796, 129.0756), (35.8714, 128.6014)] AS centres,
    ['North-East', 'East', 'South-East', 'South-West', 'West', 'North-West'] AS sectors,
    centres[city + 1] AS c,
    h3ToGeo(h3) AS p,
    (degrees(atan2((p.2 - c.2) * cos(radians(c.1)), p.1 - c.1)) + 360) % 360 AS bearing
SELECT
    h3                                                            AS cell,
    concat(['A', 'B', 'C'][city + 1], '-', leftPad(toString(intDiv(idx, 7)), 4, '0')) AS site_id,
    ['City A', 'City B', 'City C'][city + 1]                      AS region1,
    concat(['A', 'B', 'C'][city + 1], ' ',
           if(ring <= 10, 'Central', sectors[1 + toUInt8(floor(bearing / 60)) % 6])) AS region2,
    is_hole                                                       AS known_weak
FROM oss_lake.grid;
-- Rows without a cell or a subscriber cannot be placed or counted: silver drops them.
CREATE MATERIALIZED VIEW oss_lake.ue_trace_mv TO oss_lake.ue_trace AS
SELECT
    event_ts,
    stringToH3(assumeNotNull(h3_res09))       AS h3,
    cityHash64(assumeNotNull(imsi))           AS imsi_hash,
    CAST(rsrp AS Nullable(Float32))           AS rsrp,
    toFloat32(ifNull(rsrq, 0))                AS rsrq,
    toFloat32(ifNull(sinr, 0))                AS sinr,
    toUInt8OrZero(ifNull(cqi, ''))            AS cqi,
    -- a measurement gets a place name from the inventory
    dictGet('oss_lake.cell_dict', 'region1', h3) AS region1,
    dictGet('oss_lake.cell_dict', 'region2', h3) AS region2
FROM oss_lake.ue_trace_raw
WHERE h3_res09 IS NOT NULL AND imsi IS NOT NULL;
CREATE MATERIALIZED VIEW oss_lake.cell_kpi_1m_mv TO oss_lake.cell_kpi_1m AS
SELECT
    toStartOfMinute(event_ts)  AS bucket,
    h3                         AS cell,
    uniqState(imsi_hash)       AS users_st,
    count()                    AS samples_n,
    count(rsrp)                AS rsrp_n,
    sum(ifNull(rsrp, 0))       AS rsrp_sum,
    countIf(rsrp < -100)       AS poor_n
FROM oss_lake.ue_trace
GROUP BY bucket, cell;
CREATE MATERIALIZED VIEW oss_lake.region_kpi_1m_mv TO oss_lake.region_kpi_1m AS
SELECT
    toStartOfMinute(event_ts)  AS bucket,
    region1, region2,
    uniqState(imsi_hash)       AS users_st,
    count()                    AS samples_n,
    count(rsrp)                AS rsrp_n,
    sum(ifNull(rsrp, 0))       AS rsrp_sum,
    countIf(rsrp < -100)       AS poor_n
FROM oss_lake.ue_trace
GROUP BY bucket, region1, region2;
[[sql:gold10]]
-- No APPEND: every refresh replaces the 20 rows.
CREATE MATERIALIZED VIEW oss_lake.cell_rank_rmv
REFRESH EVERY 1 MINUTE TO oss_lake.cell_rank_now AS
WITH (SELECT max(anchor_ts) FROM oss_lake.params) AS t
SELECT
    lr.1 AS list, lr.2 AS rnk, cell, users, samples, avg_rsrp, poor_pct, t AS window_end
FROM
(
    SELECT
        cell,
        uniqMerge(users_st)                              AS users,
        sum(samples_n)                                   AS samples,
        round(sum(rsrp_sum) / sum(rsrp_n), 1)            AS avg_rsrp,
        round(100 * sum(poor_n) / sum(rsrp_n), 1)        AS poor_pct,
        sum(rsrp_n) >= 30                                AS eligible,
        row_number() OVER (ORDER BY users DESC, cell)    AS r_users,
        row_number() OVER (PARTITION BY eligible ORDER BY sum(rsrp_sum) / sum(rsrp_n), cell) AS r_signal
    FROM oss_lake.cell_kpi_1m
    WHERE bucket >= t - INTERVAL 1 HOUR AND bucket < t
    GROUP BY cell
)
ARRAY JOIN [('users', r_users), ('signal', if(eligible, r_signal, 999))] AS lr
WHERE lr.2 <= 10;
-- ---------------------------------------------------------------- 3 · alerts
-- Cells whose signal in the latest minute is at least 12 dB below their own last hour.
-- Subscribers (an on-call app, a chat bot) pick these up every minute; see 4 and
-- subscribe-kafka.sql for how they are delivered.
CREATE OR REPLACE VIEW oss_lake.uc_alert_cells AS
WITH (SELECT max(anchor_ts) FROM oss_lake.params) AS t
SELECT
    cell, city, area, minute, rsrp_now, rsrp_hour,
    round(rsrp_hour - rsrp_now, 1)                           AS drop_db,
    samples_now
FROM
(
    SELECT
        k.cell                                               AS cell,
        any(g.city)                                          AS city,
        h3ToParent(k.cell, 7)                                AS area,
        t - INTERVAL 1 MINUTE                                AS minute,
        sumIf(k.rsrp_sum, k.bucket = minute) / sumIf(k.rsrp_n, k.bucket = minute) AS rsrp_now,
        sum(k.rsrp_sum) / sum(k.rsrp_n)                      AS rsrp_hour,
        sumIf(k.rsrp_n, k.bucket = minute)                   AS samples_now
    FROM oss_lake.cell_kpi_1m AS k
    INNER JOIN oss_lake.grid AS g ON g.h3 = k.cell
    WHERE k.bucket >= t - INTERVAL 1 HOUR AND k.bucket < t
    GROUP BY cell
)
WHERE samples_now >= 3 AND rsrp_hour - rsrp_now >= 12;

어느 단계에 물어도 같은 답

같은 관제 질문을 네 단계에 각각 던지면 20행이 똑같이 나옵니다(03-queries.sql이 검사). 단계마다 행 수와 읽는 양이 줄어듭니다.

  1. 받은 그대로 브론즈21,600,000
  2. 다듬기 실버21,470,680
  3. 1분 요약 골드2,431,524
  4. 바로 쓰는 답 서빙20
  • 받은 그대로 710.6 ms · 10,807,707행 읽음
  • 다듬기 229.7 ms · 10,742,997행 읽음
  • 1분 요약 185 ms · 1,219,109행 읽음
  • 바로 쓰는 답 1.2 ms · 20행 읽음

측정 2026-10-18 10:22 UTC, ClickHouse 26.9.13.15, Apple M4 Pro (macOS arm64), 컨테이너 CPU 12개 · 메모리 7.75 GiB. 워밍업 1회 뒤 5회 실행의 중앙값, 서버 측 경과 시간, 쿼리 캐시 끔. 참고용입니다.

만들며 알게 된 함정. 집계 상태 타입은 원본 컬럼의 Nullable 여부와 맞아야 합니다. 맞지 않는 MV는 CREATE에서 거부되지만, 나중에 ALTER로 원본 컬럼을 Nullable로 바꾸면 그 뒤 INSERT가 모두 실패하고 원본만 계속 쌓입니다(README에 재현 기록).

6현장에서 배운 것

aNullable의 비용은 주로 질의의 복잡도

같은 관제 질의를 브론즈와 실버에 쓴 모습입니다. 표시한 NULL 확인, 형 변환, 해시가 실버에서는 사라집니다.

받은 그대로
SELECT
    stringToH3(assumeNotNull(h3_res09))            AS cell,
    uniq(cityHash64(assumeNotNull(imsi)))          AS users,
    count()                                        AS samples,
    round(avg(rsrp), 1)                            AS avg_rsrp,
    round(100 * countIf(rsrp < -100) / count(rsrp), 1) AS poor_pct,
    count(rsrp) >= 30                              AS eligible,
    row_number() OVER (ORDER BY users DESC, cell)  AS r_users,
    row_number() OVER (PARTITION BY eligible ORDER BY avg(rsrp), cell) AS r_signal
FROM oss_lake.ue_trace_raw
WHERE event_ts >= t - INTERVAL 1 HOUR AND event_ts < t
  AND h3_res09 IS NOT NULL AND imsi IS NOT NULL
GROUP BY cell
다듬기
SELECT
    h3                                             AS cell,
    uniq(imsi_hash)                                AS users,
    count()                                        AS samples,
    round(avg(rsrp), 1)                            AS avg_rsrp,
    round(100 * countIf(rsrp < -100) / count(rsrp), 1) AS poor_pct,
    count(rsrp) >= 30                              AS eligible,
    row_number() OVER (ORDER BY users DESC, cell)  AS r_users,
    row_number() OVER (PARTITION BY eligible ORDER BY avg(rsrp), cell) AS r_signal
FROM oss_lake.ue_trace
WHERE event_ts >= t - INTERVAL 1 HOUR AND event_ts < t
GROUP BY cell

buniq 상태의 크기는 타입이 아니라 그룹당 서로 다른 값의 수

타입을 바꿔 줄어들기를 기대하기보다, 사용자 수를 어느 단위(1분, 10분)에 둘지 고르세요.

상태 하나의 크기: 입력 타입별, 서로 다른 값 수별

  • uniq · String · 서로 다른 값 300개1,203 B
  • uniq · UInt64 · 서로 다른 값 300개1,203 B
  • uniqCombined(12) · UInt64 · 서로 다른 값 300개2,651 B
  • uniq · UInt64 · 서로 다른 값 1,000개4,003 B
  • uniqCombined(12) · UInt64 · 서로 다른 값 1,000개2,651 B

PoC의 사용자 수 상태 전체: 1분 단위와 10분 단위

  • 1분: 2,431,524행 × 평균 34 B79.3 MiB
  • 10분: 263,946행 × 평균 193 B48.6 MiB

c거친 요약은 고운 요약에서 만들기

10분 요약은 1분 요약에 들어오는 행을 이어받는 MV로 채워, 원본은 한 번만 집계합니다. Refreshable MV에 APPEND로 만들면 창이 새로 고침 주기보다 넓을 때 두 번 들어갑니다.

타입 · 정제 · 해시→셀 × 1분→셀 × 10분✓ 원본에서 바로 계산한 10분 집계와 같음 (일부러 틀린 대조군은 잡힘)

d이름은 _kst, 타입은 UTC인 시간 컬럼

버킷과 TTL이 9시간 밀립니다. 시각은 시간대를 명시한 타입으로 저장하세요.

event_kst 09:00≠UTC 00:00같은 순간, 이름표만 다름

e소비자가 많아지면 사용자와 한도를 나누기

대시보드, MCP의 AI 에이전트, 데이터 카탈로그, Kafka Connect 배치는 각자 다른 방식으로 부하를 줍니다.

  • 읽기 전용 사용자에 결과 행 수와 실행 시간 한도
  • 에이전트와 카탈로그는 별도 사용자로 분리
  • 적재 쪽은 큰 배치나 async insert

측정: 2026-10-18, PoC, ClickHouse 26.9.13.15.

직접 흘려 보기

라이브 데모에서 ‘다음 1분 보내기’를 누를 때마다 1분치 측정값이 네 단계를 지나고, 관제 지도와 알림이 새로 고쳐집니다.

cd usecase/oss-network-quality-lake
ui/up.sh

라이브 데모 열기