프로젝트
약 15분 분량 개인 프로젝트/레이크하우스

화면을 새로고침하면 사라지던 블로킹 관계를 남기고, 버퍼를 넣을 근거를 재보다 제 가설을 반증했습니다

Data EngineeringAirflowdbtDuckDBPostgreSQLObservability
목차

0. 제 주석이 저를 고발했습니다

DBTower의 SessionInfo 레코드에 제가 이렇게 써 두었습니다.

blockedByPid가 채워진 행이 곧 블로킹 트리의 잎, DBA가 장애 시 가장 먼저 보는 값이다.

그런데 그 값이 어디에도 남지 않고 있었습니다. activeSessions()를 부르는 곳을 전부 찾아보니 셋뿐이었습니다. 웹 컨트롤러가 화면에 지금 상태를 그리고, 운영 경보 감지기가 장기 유휴 세션을 훑고, 오버프로비저닝 분석기가 세션 개수만 셉니다. 어느 쪽도 저장하지 않습니다.

영속되는 것은 wait_event_snapshot 하나입니다. 스키마는 인스턴스, 시각, 이벤트 이름, 분류, 대기 횟수, 누적 시간입니다. 세션이 없고 쿼리가 없고 블로커가 없습니다. PostgreSQL 구현을 열어 보면 이유가 분명합니다. 원천 쿼리가 GROUP BY wait_event, wait_event_type으로 이미 접어 버립니다. 집계가 만들어지는 순간 개별 세션은 데이터베이스 안에서 사라집니다.

그래서 이 창고는 “그 5분에 락 대기가 많았다”까지만 답할 수 있었습니다. 장애가 끝난 다음 날 “그때 누가 누구를 막았느냐”고 물으면 답할 재료가 없었습니다.

1. 선례부터 확인했습니다

새 기능을 붙이기 전에 남들은 어떻게 하는지 봤습니다. 만들고 싶은 마음이 앞서면 없는 명분을 만들어 붙이게 되니까요.

Oracle의 ASH는 활성 세션을 1초마다 샘플링해 SGA의 순환 버퍼에 담습니다. AWR 스냅샷 때 열 개 중 하나만 골라 디스크로 내리고, 버퍼가 차기 전에는 미리 플러시해 유실을 막습니다. AWS RDS Performance Insights도 1초 주기이고, 활성 세션이 내부 임계를 넘으면 샘플링해서 보정값과 원값을 각각 다른 지표로 노출합니다. SolarWinds DPA는 every active session, every second를 그대로 광고 문구로 씁니다. PostgreSQL에는 pgsentinel이라는 확장이 있는데, 하는 일이 딱 이것 하나입니다.

여기서 두 가지를 얻었습니다.

첫째, 1초 세션 샘플링은 DB 관측 제품의 표준 기능입니다. 제 프로젝트에 없던 것이 결손이었습니다.

둘째, 이 제품들이 전부 짧은 보존과 긴 보존을 나눕니다. Oracle은 메모리의 V$ACTIVE_SESSION_HISTORY가 한 시간쯤 들고 있고 DBA_HIST_ACTIVE_SESS_HISTORY가 장기를 맡습니다. Datadog은 샘플을 15일, 메트릭을 15개월 둡니다. 그 구조가 바로 DBTower 7일과 이 창고입니다. 창고는 처음부터 AWR 자리에 서 있었는데, 정작 그 자리의 대표 데이터가 비어 있었던 셈입니다.

한계도 같이 확인했습니다. pgsentinel은 shared_preload_libraries에 등록하고 서버를 재시작해야 하는 확장입니다. DBTower는 밖에서 붙는 도구라 대상 DB에 확장 설치를 강제할 수 없습니다. 그래서 외부에서 주기적으로 조회하는 방식을 씁니다. Datadog과 DPA가 쓰는 방식과 같지만, 프로세스 안에서 찍는 쪽보다 정확도가 낮고 샘플 간격보다 짧은 대기는 놓칩니다. 이 한계는 사실 제가 waitEvents() 주석에 이미 적어 둔 것과 같은 내용이었습니다.

2. 계약을 먼저 썼습니다

1편에서 DAG를 짜기 전에 계약을 먼저 못박았던 순서를 그대로 따랐습니다. 이번에 정한 것은 셋입니다.

시각을 두 개 둡니다

sampled_at은 샘플러가 이 틱을 찍기로 한 시각이고, ingested_at은 실제로 저장된 시각입니다. 앞의 것이 event time이고 뒤의 것이 processing time입니다. 둘의 차가 곧 e2e 지연입니다.

하나로 합치고 싶은 유혹이 있었습니다. 컬럼이 하나 줄고 인덱스도 하나 덜 듭니다. 그런데 합치면 늦은 도착을 영원히 관측할 수 없습니다. 늦게 도착했다는 사실 자체가 두 시각의 차이로만 존재하니까요. 관측할 수 없는 것은 개선할 수도 없습니다.

결측은 결번으로만 보입니다

없는 행은 그 자체로 보이지 않습니다. 이 초에 샘플이 없다는 사실은 조회해서 알 수 있는 것이 아닙니다. 그래서 인스턴스별로 단조 증가하는 sample_seq를 넣었습니다. 연속성이 깨진 자리가 곧 결측입니다.

여기에 함정이 하나 붙습니다. 샘플러 JVM이 재기동하면 시퀀스가 1부터 다시 시작합니다. 그대로 이으면 없는 결측이 생깁니다. 그래서 기동 한 번을 sampler_run_id로 묶고, 결번 판정은 반드시 그 안에서만 하도록 계약에 적었습니다.

봤다와 남겼다와 버렸다를 나눕니다

AWS가 db.sampledload.avgdb.load.avg를 따로 내놓는 것을 보고 가져왔습니다. 부하가 높아 샘플을 떨어뜨렸을 때, 그 사실을 숨기지 않고 지표로 드러내는 방식입니다.

그래서 ash_sample_tick 테이블에 observed_sessions, retained_sessions, dropped_sessions를 각각 둡니다. 그리고 이 테이블에는 더 중요한 역할이 하나 더 있습니다. 세션이 0건인 틱도 행을 남깁니다. 샘플 테이블만 보면 비어 있을 때 활성 세션이 정말 없었는지 샘플러가 안 돌았는지 구분되지 않습니다. 0은 성공과 실패가 같은 모양이라, 0을 결과로 쓰려면 조건이 섰다는 증거가 함께 있어야 합니다.

3. 붙이면서 정한 것: 쌓지 않고 떨어뜨립니다

대상이 느려서 직전 틱이 아직 안 끝났으면 어떻게 할지를 정해야 했습니다. 큐에 쌓아 두고 나중에 처리할 수도 있고, 그냥 버릴 수도 있습니다.

버리는 쪽을 골랐습니다. 쌓으면 관측이 대상을 더 느리게 만듭니다. 그리고 그렇게 되는 시점이 하필 락 폭풍이 난 순간, 즉 커넥션이 가장 귀한 순간입니다. Oracle도 AWS도 부하가 높으면 샘플을 떨어뜨리는 쪽을 골랐습니다.

버린 사실은 남깁니다. 상태를 SKIPPED_INFLIGHT로 적어 두면 나중에 왜 이 초에 샘플이 없는지를 되짚을 수 있습니다.

세션 수가 상한을 넘으면 경과 시간 내림차순으로 자릅니다. 오래 걸린 세션이 진단 가치가 높기 때문입니다. Datadog이 느리거나 잦은 쿼리 쪽으로 편향해서 샘플링하는 것과 같은 판단입니다. 잘라낸 수는 dropped_sessions에 그대로 들어갑니다.

그리고 이 잡은 제가 세운 원칙 하나와 정면으로 부딪칩니다. WaitEventSnapshotJob 주석에 5분 주기를 고른 근거를 이렇게 적어 두었습니다.

조회 자체가 부하가 되면 안 된다.

해상도를 300배 올리면서 저 원칙을 지킬 수 있는지는 말로 답할 수 없습니다. 그래서 재기로 했습니다.

4. 재기 전에 가설을 적어 두었습니다

로그 버퍼를 넣을 근거로 이런 논거를 세워 두었습니다.

락 폭풍이 나는 순간이 곧 관측이 가장 필요한 순간이면서, 동시에 메타 DB도 같이 느려지는 순간입니다. 관측 쓰기 경로가 운영 경로와 자원을 공유하면 정확히 필요한 순간에 정확히 필요한 샘플을 잃습니다. 그러니 유입 앞에 버퍼를 두어 경로를 떼어야 합니다.

읽기에는 그럴듯합니다. Datadog이 에이전트에 버퍼링하고 비동기로 보내는 이유도 이것이라고 생각했습니다. 하지만 이건 가설입니다. 3편에서 로드맵에 적어 둔 외삽 두 개가 틀렸던 일이 있어서, 그럴듯한 문장을 그대로 믿지 않기로 했습니다.

시나리오를 넷으로 잡았습니다. 평시, 락 폭풍, 인스턴스 축 확장, 그리고 셋을 겹친 상태입니다. 마지막이 결정적입니다.

5. 쟀더니 제 가설이 틀렸습니다

PostgreSQL 16을 대상으로 삼고, 기존 메타 DB는 건드리지 않고 별도 DB를 만들어 격리해서 쟀습니다. 대상의 max_connections는 기본값 100입니다.

시나리오인스턴스기대 틱실제 틱결측률SKIPERROR드롭률수집 p95
평시181810.0000000.00002.85ms
락 폭풍(피해자 50, 45초)145450.0000000.00002.48ms
인스턴스 축2164~71동일0.0000000.00001.78~5.56ms
60대에 락 폭풍을 겹치고 커넥션을 고갈시킴6040/대40/대0.0000000.0000최대 18.34ms

중복은 0건이고 늦은 도착도 0건입니다. 전체 규모는 77,104 샘플과 7,371 틱, 23MB입니다.

마지막 줄이 핵심입니다. 60인스턴스에 부하 세션 35개를 겹치니 max_connections를 넘겼습니다. 그리고 실제로 이런 일이 벌어졌습니다.

[ash_lock_storm] 실패 victim=28 OperationalError: connection to server at
"127.0.0.1", port 15432 failed: FATAL: sorry, too many clients already

락 폭풍을 만들려던 세션들이 접속조차 못 하고 튕겼습니다. 제 가설대로라면 관측도 같이 죽어야 합니다. 그런데 같은 창에서 샘플러는 2,400틱을 전부 성공으로 마쳤습니다. 실패 0건, 스킵 0건, 결측 0건입니다.

이유는 재고 나서야 보였습니다. 샘플러의 커넥션은 이미 풀에 잡혀 있었고, 그 순간 거부된 것은 새 커넥션이었습니다. 굶어 죽으리라 예상한 쪽이 따뜻한 풀을 쥔 생존자였습니다. 저는 관측 경로와 운영 경로가 자원을 나눠 쓴다고만 생각했지, 관측 경로가 그 자원을 먼저 잡고 놓지 않는 쪽이라는 점을 못 봤습니다.

수집 시간도 마찬가지였습니다. 평시 2.85ms, 락 폭풍 중 2.48ms로 오히려 조금 빨랐고, 60인스턴스 동시 폭풍에서도 최대 18.34ms였습니다. 해상도를 300배 올린 뒤에도 조회 자체가 부하가 되면 안 된다는 원칙이 성립했습니다.

6. 그래서 버퍼를 넣지 않았습니다

3편에서 407.62초를 실제로 재고 나서야 증분 전환을 했습니다. 병목 수치가 정당화한 곳만 건드린다는 규율이었습니다. 같은 규율을 반대 방향으로도 적용해야 합니다.

수치가 정당화하지 않았으니 넣지 않습니다. Kafka도, 인메모리 링 버퍼도 넣지 않았습니다. 지금 규모에서는 커넥션 풀 하나로 충분했다는 것이 실측이 준 답입니다.

이걸 실패라고 부르고 싶지 않습니다. 만들었으면 더 그럴듯한 아키텍처 그림이 나왔겠지만, 그 그림에는 근거가 없었을 겁니다. 안 만들었다는 사실과 왜 안 만들었는지를 수치와 함께 적어 두는 편이 나중의 저에게 더 쓸모 있습니다. 규모가 커져서 이 결론이 뒤집히면, 그때는 뒤집힌 수치를 들고 다시 오면 됩니다.

7. 대신 실제 락 컨보이가 잡혔습니다

계측하는 김에 수집된 데이터를 들여다봤습니다.

피해자 pid가해자 pid샘플 수지속대기 이벤트
5085012,40039.6초transactionid
5065082,39939.6초tuple
5075082,39939.6초tuple
5095082,39839.6초tuple
5045082,39839.6초tuple

읽어 보면 2단계입니다. 501이 508을 막고, 그 508이 다시 네 세션을 막고 있습니다. 락 컨보이입니다. 전체로는 막힌 샘플이 64,568개, 피해자가 76, 가해자가 4였습니다.

이 표를 5분 집계로 만들 수 있는지 생각해 보면 답이 나옵니다. 만들 수 없습니다. 그 5분에 락 대기가 몇 건 있었다는 숫자 하나가 남고, 체인이 몇 단이었고 뿌리가 누구였는지는 원리적으로 복원되지 않습니다. 이 차이가 이 편 전체의 이유입니다.

대사도 같은 이야기를 다른 각도로 합니다. mart_ash_reconciliation은 두 소스를 절대값으로 비교하지 않습니다. 같은 5분 창에서 샘플러는 최대 300회, 기존 스냅샷은 1회 관측하니 표본 수가 다릅니다. 그래서 각 이벤트가 그 창에서 차지하는 점유 비율만 봅니다. 한쪽에만 있는 값은 0으로 채우지 않고 상태로 표기합니다. 0은 없었다는 뜻이고 NULL은 모른다는 뜻이라 같이 두면 안 됩니다.

CI 픽스처에서 이 마트가 내는 판정이 이렇습니다.

event_name ash_share snapshot_share verdict
Lock:transactionid 0.5 NULL ASH_ONLY
io/file 0.5 1.0 DIVERGED
io/file(다른 버킷) NULL 1.0 SNAPSHOT_ONLY

첫 줄이 이 편의 요약입니다. 락 대기가 있었는데 5분 집계 쪽에는 흔적이 없습니다.

8. 계측 도구가 0의 함정에 빠졌습니다

첫 계측이 0건을 냈습니다. 샘플러 로그에는 틱이 찍히고 있는데 계측기는 아무것도 못 찾았습니다.

원인은 시각이었습니다. DBTower는 JVM 기본 시간대를 UTC로 고정합니다. 노드 서버의 시간대가 달라도 메타 DB 값이 흔들리지 않게 하려고 제가 그렇게 해 둔 것입니다. 대상 PostgreSQL도 Etc/UTC였습니다. 그런데 제가 방금 쓴 계측 스크립트는 파이썬의 datetime.now()로 창을 잡았습니다. 호스트가 KST라 9시간을 헛짚었습니다.

그 0은 샘플이 없다는 뜻으로 읽혔지만, 실제로는 엉뚱한 창을 봤다는 뜻이었습니다. 0을 결과로 쓰려면 조건이 섰다는 증거가 함께 있어야 한다는 원칙을, 이번에는 계측 도구 자신에게 적용해야 했습니다.

고친 방식은 간단합니다. 창 경계를 전부 DB 시계로 계산합니다. epoch 인자도 클라이언트에서 변환하지 않고 to_timestamp로 데이터베이스에서 변환합니다. 그리고 실행할 때마다 DB 현재 시각과 시간대를 먼저 찍게 했습니다.

[ash_bench] DB 현재시각 2026-08-29 10:15:44.349954 (TZ=Etc/UTC) · 호스트 19:15:44

두 시각이 나란히 보이면 같은 함정에 다시 빠질 일이 줄어듭니다. 파이프라인에서 시각을 다룰 때의 원칙이 계측 도구에도 그대로 적용된다는 것을 이번에 배웠습니다. 시각의 기준은 데이터를 쓴 쪽에 있고, 읽는 쪽의 로컬 시계는 기준이 될 수 없습니다.

8.5. 규모로 축을 키우니 3편의 병이 재발했습니다

여기까지의 실측은 전부 며칠치였습니다. 그 규모에서는 뭘 재도 초 단위로 끝나서 “규모에서도 버틴다”고 말하고 싶어집니다. 3편에서 똑같은 착각을 한 번 했으니 이번에는 먼저 축을 키웠습니다.

셀프호스터가 인스턴스 30대를 1초 간격으로 보고 인스턴스마다 평균 5개 세션이 잡힌다고 두면 하루 12,960,000행이고 30일이면 388,800,000행입니다. 합성해서 잰 결과입니다.

행 수부피블로킹 에피소드 팩트fct_ash_5min전체
7일 10대 3세션18,144,00087.8MB0.848초0.236초6.52초
14일 20대 3세션72,576,000350.4MB2.436초0.760초9.40초
30일 30대 5세션388,800,0002,134MB8.719초3.537초20.34초

병목은 하나였습니다. 블로킹 에피소드 팩트가 나머지 셋을 합친 것보다 오래 걸립니다. 막힌 세션마다 같은 틱의 다른 행을 찾아 가해자를 식별하는 셀프 조인이 있고 그 위에 윈도우 함수가 얹히니 예상은 맞았습니다.

그런데 원인을 들여다보니 낯익었습니다. 이 마트가 table이라 매일 전체 이력을 다시 계산하고 있었습니다. 3편에서 fct_query_daily가 정확히 이래서 407.62초까지 갔고 증분 전환으로 4초로 줄였는데, 새 마트를 만들면서 같은 병을 다시 심은 겁니다.

dt 단위 증분으로 바꾸고 하루를 되돌아보게 했습니다. 자정을 넘는 에피소드는 둘로 갈리는데, 끊는 기준이 5초라 잃는 연속성은 자정 전후 몇 초뿐입니다.

여기서 흔한 실수 하나를 피했습니다. 막힌 행 쪽만 창으로 좁히고 가해자 조인의 오른쪽을 그대로 두면, 조인이 여전히 전체 이력을 훑어서 증분화의 이득이 사라집니다. 오른쪽 서브쿼리에도 같은 창을 걸었습니다.

388,800,000행 고정전체 재빌드증분 2회차
블로킹 에피소드 팩트8.719초1.008초 (8.6배)
전체20.34초8.16초 (2.5배)

이력이 1년이면 전체 재빌드는 12배로 늘어 100초에 가까워집니다. 증분 경로는 하루치만 계산하니 이력 길이와 무관합니다.

Oracle이 AWR로 내릴 때 10개 중 하나만 고르는 것을 보고, 창고도 전량을 들 필요가 있는지 같은 축에서 재봤습니다. 10:1 다운샘플은 부피를 2,134MB에서 308.9MB로 6.9배 줄이지만 빌드는 2.2배만 줄입니다. 증분화가 같은 축에서 8.8배를 줄이면서 정보는 하나도 버리지 않으니, 다운샘플은 채택하지 않았습니다. Oracle이 다운샘플을 고른 것은 AWR 스냅샷 간격이 시간 단위라 전량을 들 수 없어서이고, 이 창고는 일 배치라 전제가 다릅니다.

8.6. 같은 함정에 하루 세 번 걸렸습니다

8절에서 시각 때문에 0건이 나온 이야기를 했는데, 그게 끝이 아니었습니다. 규모 실측을 하면서 같은 유형에 두 번 더 걸렸습니다. 셋 다 숫자는 나왔는데 조건이 안 섰던 경우입니다.

두 번째는 부분 데이터였습니다. 합성 생성이 인스턴스 22에서 정수 오버플로로 죽었는데, 측정 단계는 그 부분 데이터로 그럴듯한 수치를 냈습니다. 생성 후 행 수와 파티션 수를 기대와 대조해 어긋나면 멈추도록 고쳤습니다.

세 번째가 제일 위험했습니다. 막힘 조건을 20틱마다 한 번으로 두었는데, 다운샘플 주기 10과 배수 관계였습니다. 그래서 전량과 1/10 다운샘플에서 막힌 행이 15,552,000개로 똑같이 나왔습니다. 블로킹 에피소드 팩트가 19%밖에 안 빨라졌고, 하마터면 “다운샘플이 병목에 안 듣는다”고 결론 낼 뻔했습니다. 에일리어싱입니다. 막힘을 해시로 흩어 어떤 다운샘플 주기와도 공명하지 않게 하고 다시 재니 4.3배였습니다.

앞의 둘은 0이나 예외로 드러납니다. 세 번째는 그럴듯한 숫자로 드러납니다. 합성 데이터를 쓸 때는 그 데이터의 주기성이 측정 주기와 공명하는지를 매번 확인해야 한다는 걸 이번에 배웠습니다. 검산에 막힘 비율을 넣어 0건이면 멈추게 해 두었습니다.

8.7. 창고가 자기 부피를 모르고 있었습니다

규모를 재고 나니 다른 게 걸렸습니다. 이 창고는 관리 대상 데이터베이스의 용량을 매일 재서 언제 증설해야 하는지 예측합니다. 그런데 자기 부피는 모릅니다.

더 정확히는 재고 있었는데 남기지 않았습니다. 유지보수 함수가 스냅샷 수와 오브젝트 수와 바이트를 전부 계산합니다. 유지보수 전후를 대조하려고 만든 함수라 실행하는 순간에만 값을 돌려주고 어디에도 저장하지 않습니다. 0절에서 이야기한 것과 같은 모양입니다. 조회는 되는데 안 남아서, 지나간 것에 답할 수 없습니다.

같은 방식으로 남기고 관리 대상에 쓰던 것과 같은 방법으로 판정하게 했습니다. 일별 바이트의 최소자승 기울기로 하루 증가량을 잡고 임계까지 남은 일수를 냅니다. 관측일이 일주일 미만이면 판정을 PENDING으로 두고 남은 일수를 비웁니다. 두 점으로 그은 직선은 예측이 아니니까요.

이 판정에서 제일 쓸모 있는 건 지배 원천 컬럼입니다. “창고가 큰다”는 아무 행동으로도 이어지지 않는데, “성장의 95.7퍼센트를 세션 샘플이 만든다”는 이어집니다.

실측 성장률로 축을 돌려 봤습니다. 30대에서 하루 91.6MB이고 인스턴스 한 대당 3.05MB입니다.

인스턴스하루 성장500GB 도달
30대91.6MB15.3년
100대305.2MB4.6년
300대915.6MB1.5년

300대는 규모 실측에서 설계 목표로 잡았던 숫자입니다. 그 규모면 2년을 못 버팁니다.

그리고 이 판정은 수치 하나를 더한 데서 끝나지 않았습니다. 없던 정책 하나를 드러냈습니다. 관제 도구에는 7일 보존이 걸려 있는데 창고는 전부 영구 보관입니다. 장기 이력을 만드는 게 존재 이유였으니 의도한 설계인데, 그동안은 원천이 저빈도라 무한 보관의 대가가 눈에 안 보였을 뿐입니다. 세션 샘플링이 그 대가를 보이게 만들었습니다.

보존을 어떻게 걸지는 아직 정하지 않았습니다. 오래된 샘플을 다운샘플하는 방법과 기간을 두고 지우는 방법이 있는데, 후자는 장기 이력이라는 존재 이유와 정면으로 부딪칩니다. 지금은 판정 컬럼까지만 계산하고 임계가 실제로 보일 때 수치를 들고 정하기로 했습니다. 5편에서 세운 규율, 곧 계산은 하되 발화는 남에게 맡긴다는 원칙과 같습니다.

8.8. 지울 수 없어서 해상도를 낮췄습니다

앞 절에서 창고가 자기 성장을 지목했다고 썼습니다. 그러면 보존을 걸어야 하는데, 여기서 막혔습니다. 장기 이력을 만드는 것이 이 창고의 존재 이유라서 오래된 것을 지우면 이유가 없어집니다.

그런데 이력과 해상도는 다릅니다. “지난 분기 대비 악화된 쿼리”에 초 단위 프레임은 필요 없습니다. 그래서 원본만 창을 두고 접고, 질문에 답하는 계층은 영구로 남기기로 했습니다.

계층보존답하는 질문
1초 원본30일초 단위 프레임 재구성
5분 롤업영구무엇을 기다렸나
블로킹 에피소드영구누가 누구를 막았나

이걸 정할 때 남들이 어떻게 하는지도 봤습니다. 대형 로그 플랫폼들은 비싼 실시간 저장소에서 싼 장기 저장소로 내리는 계층 분리로 비용을 크게 줄입니다. 그런데 이 창고는 처음부터 객체 스토리지의 Parquet입니다. 그 계층 분리는 1단계부터 되어 있었습니다. 제약이 다르면 답도 달라야 한다고 봤습니다.

맞는 선례는 Thanos였습니다. README에 “DBTower가 Prometheus면 이건 Thanos다”라고 써 놓고, 정작 Thanos 장기 보관의 핵심인 해상도 다운샘플이 없었습니다. 0절에서 이야기한 것과 같은 모양입니다.

성립 조건은 축소비 하나였습니다. 안 나오면 접어야 했습니다. 그리고 재는 도중에 네 번째 함정에 걸렸습니다.

첫 측정이 31.0배였습니다. 그런데 막힌 행이 254,460개인데 에피소드가 196,560개였습니다. 거의 1행 1에피소드입니다. 라이브에서는 39.6초 락이 2,400샘플에서 1행으로 접혔으니 두 자릿수가 어긋납니다. 제 합성이 락을 순간적으로 만들고 있었습니다. 대기 이벤트도 2종뿐이었는데, 5분 롤업은 이벤트별로 접히니 종류가 적으면 축소비가 과대평가됩니다.

락을 30초 연속 구간으로 바꾸고 이벤트를 실제 PostgreSQL 분포에 가까운 12종으로 늘렸습니다.

첫 측정고친 뒤
에피소드 수196,5607,980
전체 축소비31.0배113.1배

31배로 보고했으면 애매하니 접자는 결론이 나왔을 겁니다. 규모와 병목을 잴 때는 이 두 가지가 문제되지 않았습니다. 재는 축이 바뀌면 합성이 그 축에서 현실을 닮았는지 매번 다시 확인해야 합니다.

300대 규모로 환산하면 1년 부피가 326.4GB에서 29.5GB가 되고, 하루 순증이 915.6MB에서 8.1MB로 떨어집니다.

스윕은 지우기를 꺼리게 만들었습니다. 롤업 마트에 그 날짜가 없으면 원본을 지우지 않고, 가드 마트를 아예 조회하지 못하면 그 원천 전체를 건너뜁니다. 확인하지 못했으면 정지합니다. 2편에서 아카이브가 자기 유일본을 지우던 경로를 겪었으니, 롤업 없이 원본을 지우는 것도 같은 종류의 자기파괴라고 봤습니다.

장애를 주입해 확인했습니다. 롤업에서 3일치를 지우자 스윕이 정확히 그 3일을 보류하고 사유를 남겼습니다.

[보존 스윕] ash_sample: cutoff=2026-07-01 삭제=27dt 보류=3dt (롤업 보유 57dt)
보류 사유: 롤업 없음. 예시 ['2026-06-01', '2026-06-02', '2026-06-03']

마지막으로 정확성을 확인했습니다. 실제로 스윕을 돌린 뒤 원본이 사라진 날짜에 장기 질문을 다시 던졌습니다. 그날 무엇을 기다렸는지와 누가 누구를 막았는지는 그대로 답했고, 초 단위 프레임만 재구성이 불가능했습니다. 그 하나가 잃는 것이고 계약에 적었습니다.

여기서도 한 번 걸릴 뻔했습니다. 첫 확인에서 지운 날짜의 원본이 1,296,000행으로 읽혔습니다. 조회가 스윕하지 않은 다른 사본을 보고 있었습니다. 검증이 통과했다고 먼저 말하지 않고 경로부터 확인한 것이 다행이었습니다.

9. 아직 못 하는 것

한계를 먼저 적어 두는 편이 낫습니다.

샘플 간격보다 짧은 대기는 놓칩니다. 1초 간격으로 밖에서 조회하는 방식의 원리적 한계입니다. 그래서 블로킹 에피소드 팩트의 지속 시간은 최소값으로만 읽어야 합니다. 샘플 사이에 벌어진 일은 관측되지 않습니다.

가해자 세션이 같은 틱에 안 잡히면 정체를 모른 채로 남깁니다. 짧게 스치고 사라진 블로커가 그렇습니다. 이 경우 판정을 BLOCKER_UNIDENTIFIED로 적고 피해자 쪽 정보로 추측하지 않습니다.

대상은 지금 PostgreSQL 한 기종입니다. 논거를 증명하는 데는 한 기종으로 충분하다고 판단했고, 5기종으로 넓히면 부피만 늡니다. 공통 인터페이스는 손대지 않았으니 확장할 자리는 열려 있습니다.

대기 분류는 이벤트 이름으로 근사합니다. 정확히 하려면 공통 세션 모델에 분류 필드를 넣어야 하고, 그건 5기종 전부를 건드리는 일이라 이번 범위 밖입니다.

10. 남은 자리

0절에서 제 주석이 저를 고발했다고 썼습니다. 이제 그 주석의 값이 남습니다. 블로킹 트리의 잎이 화면 밖에서도 살아남아, 장애가 끝난 다음 날에도 누가 누구를 막았는지 되짚을 수 있습니다.

그리고 하나를 더 얻었습니다. 그럴듯한 논거를 세우고 그것을 재서 반증하는 절차 자체입니다. 이번에 만든 것이 로그 버퍼였다면 그림은 화려했겠지만 근거는 없었을 겁니다. 만들지 않기로 한 결정도 실측이 뒷받침하면 결과입니다.

수치와 재현 절차는 저장소에 있습니다. 실측 기록은 docs/VERIFICATION.md 22절, 계약은 docs/CONTRACT.md 1-2절, 계측 절차는 docs/RUNBOOK.md 8절입니다. 수집기 쪽 기록은 DBTower 저장소의 docs/VERIFICATION.md 124절과 docs/operations.md 6절에 나눠 두었습니다.

프로필 사진
작성자 @범수

오늘의 노력이 내일의 전문성을 만든다고 믿습니다.

댓글

댓글 수정/삭제는 GitHub Discussions에서 가능합니다.