모든 글
약 19분 분량 개인 프로젝트/lakehouse

창고가 데이터를 내리는 데서 판정을 내리는 데까지, 여섯 가지 판정을 채우다

목차

0. 데이터는 내렸는데 판정을 안 하고 있었다

지난 편들에서 원천 세 테이블(백업 이력, 플랜 변경, 대기 이벤트)을 창고로 내리고,
용량 예측까지 붙였습니다. 그런데 어느 순간 이상했습니다. 재료는 다 창고에 있는데,
정작 DBA가 매일 겪는 몇 가지 병목에는 아무 답도 못 내고 있었습니다.

세 장면을 떠올렸습니다.

새벽 3시에 “특정 쿼리가 갑자기 느려졌다”는 신고가 옵니다. 단골 원인은 옵티마이저가
통계 갱신이나 분포 변화로 플랜을 갈아탄 건데, 새 플랜이 더 느린 경우입니다. 저는
plan_snapshot(플랜이 언제 어떤 해시로 바뀌었나)과 fct_query_daily(그 쿼리의
일별 평균 지연)를 둘 다 갖고 있으면서, 이 둘을 한 번도 겹쳐 보지 않았습니다.
그래서 이 상황이 오면 사람이 플랜 이력과 지연 그래프를 눈으로 대조합니다. 건당 30분입니다.

복구하려고 보니 백업이 3주째 조용히 안 돌고 있었습니다. fct_backup_daily에는 일별
성공·실패 집계가 있는데, “이 인스턴스는 마지막 성공 백업이 며칠 전이다”라는 판정 컬럼이
없었습니다. 실패는 시끄럽습니다. FAILED 행이 남으니까요. 그런데 공백은 조용합니다.
행 자체가 없기 때문입니다. 아무도 그 부재를 묻지 않습니다.

월요일 오전은 보고서 만드는 날입니다. 용량 D-day, top 대기 이벤트, 플랜 변경 건수,
백업 상태를 화면 네 곳에서 긁어모아 문서로 만듭니다. 재료는 전부 창고에 있는데, 그걸
한 장으로 접는 계층이 없었습니다.

세 가지 모두 사람의 시간이 새는 지점입니다. 그리고 세 가지 모두 신규 수집이 필요
없었습니다.
이미 내린 데이터를 판정 컬럼까지 밀고 가는 일이었습니다.

1. 왜 이게 창고의 몫인가

DBTower의 라이브 시야는 7일입니다. 그 창으로는 구조적으로 못 하는 판정이 있습니다.

플랜 회귀는 “며칠치 전후 비교”가 있어야 합니다. 뒤집힌 그날 하루만 보면 이게 개선인지
악화인지 알 수 없습니다. 뒤집힘 전 며칠과 후 며칠의 평균 지연을 비교해야 판정이 섭니다.

백업 공백은 “분기 내내 0회”와 “지난주 이후 0회”를 구분해야 합니다. 순간 관측으로는
둘이 똑같아 보입니다. 장기 이력이 있어야 마지막 성공일을 찾고 경과일을 잽니다.

이건 이미 이 프로젝트가 서 있는 자리(용량 D-day)와 같은 계열입니다. 미래 외삽, 시점
전후 상관, 부재 감지. 셋 다 7일 창으로는 못 보고 장기 창고라야 보이는 판정입니다.
이번 편에서 이 셋을 마트 세 개로 채웁니다. 발화는 여전히 안 합니다. 앞서 정한
원칙대로, 창고는 판정 컬럼까지만 계산하고 알림은 Metabase(사람이 pull) 또는
DBTower(기계가 push)의 몫으로 둡니다. 두 번째 알림 시스템을 만들지 않습니다.

2. 플랜 회귀: 뒤집힘을 지연과 겹친다

먼저 뒤집힘을 정의해야 했습니다. plan_snapshot은 한 쿼리에 대해 하루에도 여러 플랜
해시를 담을 수 있어서(쿼리당 최신 20개 보존), 관측 하나하나로 뒤집힘을 세면 같은 하루
안의 출렁임까지 뒤집힘으로 잡힙니다. 그래서 크기 마트에서 “그날 마지막 관측이 대푯값”을
쓴 것과 같은 방식으로, 하루의 대표 플랜을 그날 마지막 관측의 해시로 잡았습니다.
이제 grain이 fct_query_daily와 같은 일 단위가 됩니다.

뒤집힘은 대표 플랜을 날짜순으로 놓고 lag로 직전과 비교해, 해시가 실제로 바뀐 행만
이벤트로 남깁니다. 같은 해시가 반복되면 뒤집힘이 아니고, 첫 관측은 직전이 없으니 등장이지
뒤집힘이 아닙니다. 둘 다 걸러집니다.

뒤집힘이 잡히면 그 시점을 지연과 겹칩니다. 뒤집힘 전 N일과 후 N일(기본 3일)의 평균
지연을 비교합니다.

플랜 회귀 판정의 개념. 대표 플랜이 A에서 B로 뒤집힌 날을 기준으로, 전 N일과 후 N일의 평균 지연을 비교한다. 뒤집힘 당일은 전후 플랜이 섞이므로 양쪽에서 제외하고, 후/전 비율이 임계를 넘으면 REGRESSED로 판정한다

세 가지를 조심했습니다.

뒤집힘 당일은 전후가 섞입니다. 그날은 오전엔 옛 플랜, 오후엔 새 플랜일 수 있어서
지연이 혼합됩니다. 그래서 당일 dt는 전 창에서도 후 창에서도 뺍니다.

지연이 NULL인 날을 0으로 접으면 안 됩니다. fct_query_daily는 그날 호출이 0이면
평균 지연을 0이 아니라 NULL로 둡니다. 나눌 게 없으니까요. 전후 평균을 낼 때 NULL을 0으로
접으면, 활동이 없던 날이 “0ms로 빨라진 날”로 둔갑해 회귀를 개선으로 오판합니다. 평균은
NULL을 자연 제외하도록 뒀습니다.

판정을 지어내지 않습니다. 후 N일이 아직 안 지났으면 비교할 데이터가 없으니 PENDING으로
두고, 매일 재계산되며 시간이 차면 확정됩니다. 같은 쿼리가 비교창 안에서 또 뒤집혔으면
전후가 다른 뒤집힘에 오염되므로 AMBIGUOUS로 둡니다. 억지로 REGRESSED라고 답하는 것보다
“지금은 판단 못 함”이 정직합니다.

비율 판정에는 앞선 CI에서 배운 걸 그대로 적용했습니다. 후/전 비율을 소수 둘째 자리로
반올림한 값으로 판정해서, 플랫폼마다 최하위 비트가 흔들리는 부동소수 때문에 맥과 CI
러너가 다른 답을 내는 일을 막았습니다.

산식은 unit test 네 개로 못박았습니다. 10ms에서 30ms로 세 배가 된 뒤집힘은 REGRESSED,
후 관측이 없으면 PENDING, 2일 간격 재뒤집힘은 AMBIGUOUS, 그리고 백업 쪽까지. 실데이터가
없어도 이 판정 로직이 조용히 바뀌면 CI에서 깨집니다.

3. 백업 공백: 침묵을 행으로 만든다

백업 공백 마트에서 제일 중요한 결정은 인스턴스 유니버스를 어디서 잡느냐였습니다.
백업 테이블에서 인스턴스 목록을 뽑으면, 백업 기록이 아예 없는 인스턴스가 목록에서 사라집니다.
정작 제일 위험한 대상인데 조용히 빠지는 겁니다. 그래서 유니버스를 fct_query_daily
distinct 인스턴스(모든 기종이 공통으로 남기는 관측)에서 잡고, 백업 팩트를 left join
했습니다. 이러면 기록 부재가 사라지지 않고 행으로 드러납니다. 원천 계약을 건드리지 않고
database_instance 신규 추출 없이 전수를 확보하는 방법이기도 합니다.

두 번째는 기준일을 벽시계로 잡지 않는 것입니다. “오늘로부터 며칠 전”으로 잡으면
파이프라인이 죽어도 gap이 자랍니다. 백업은 멀쩡히 도는데 창고가 갱신을 못 받으면
백업 공백처럼 보인다는 뜻입니다. 그래서 기준을 창고의 최신 dt로 잡았습니다. 파이프라인
신선도는 게이트와 deadman이 이미 보는 관심사라, 여기서 섞지 않습니다.

세 번째가 이 마트의 정직함입니다. “성공 백업 관측이 없음”은 두 경우가 섞여 있습니다.
백업이 정말 안 도는 경우와, 그 기종의 백업 이력을 아직 수집 못 한 경우입니다. 이 창고에는
기종 축이 없어서(대기 마트와 같은 한계입니다) 둘을 구분할 수 없습니다. 그래서 breach라고
단정하지 않고 no_backup_observed라는 사실만 싣습니다. 소비자가 DBTower 인스턴스 화면에서
기종을 알고 해석합니다.

4. 주간 보고: 네 판정을 한 장으로

앞의 둘에 용량 예측과 대기 top을 더하면, 월요일 보고서의 재료가 다 모입니다. 인스턴스당
한 행에 용량 위험도와 D-day, 이번 주 top 대기, 이번 주 플랜 뒤집힘 수와 그중 회귀 수,
백업 경과일과 상태를 접었습니다. 용량·대기·백업은 지금 시점의 스냅샷이고, 플랜 뒤집힘만
flip_dt가 있어 주 단위로 셉니다. 이력이 한 주가 안 차는 초기에는 반쪽 주라서, week_start에
is_partial_week 플래그를 둬 소비자가 온전한 주와 헷갈리지 않게 했습니다.

metabase_bootstrap.py에 대시보드 하나를 더 얹었습니다. 앞서 대시보드에서 쓴 그 멱등 패턴 그대로,
보고표 한 장과 백업 공백 목록, 플랜 회귀 목록을 카드로 만듭니다.

Metabase 주간 운영 보고 대시보드 실화면. 위쪽 보고표는 인스턴스별로 용량 위험도(capacity_risk)·top 대기·플랜 변경 수·백업 경과일과 상태를 한 줄로 보여준다. 기종마다 top 대기가 다르다. MySQL은 binlog, PG는 WalSenderMain, MSSQL은 RESOURCE_SEMAPHORE_QUERY_COMPILE이다. 아래 왼쪽은 백업 공백 목록(rpo_status가 ok가 아닌 4개 인스턴스), 오른쪽은 플랜 회귀 목록인데 지금은 비어 있다

top 대기 컬럼이 기종마다 다른 게 눈에 띕니다. MySQL(1번)은 wait/io/file/sql/binlog,
PG(4번)는 WalSenderMain, Oracle(8번)은 resmgr:cpu quantum, MSSQL(3·32번)은
RESOURCE_SEMAPHORE_QUERY_COMPILE입니다. 창고에는 기종 축이 없어도, 대기 마트가
누적/스냅샷 의미를 뭉개지 않고 실은 덕분에 한 화면에서 기종별 성격이 그대로 보입니다.

5. 라이브에서 드러난 것

dev 창고에 세 마트를 돌렸습니다.

백업 공백 마트는 7행이 나왔습니다. 인스턴스 1·2·7은 마지막 성공 백업이 하루 전이라 ok고,
3·4·8·32는 no_backup_observed입니다. 여기서 착수 명세에 미리 적어둔 함정을 확인했습니다.
원천(DBTower의 PG)의 backup_run 테이블을 직접 열어 보니, 7기종 전부 성공 백업 이력이
있었습니다. 그런데 창고의 fct_backup_daily에는 1·2·7의 07-16 하루치만 들어와 있었습니다.
보조 원천 오프로드가 최근에 신설된 터라 아직 그만큼만 실린 겁니다. 그러니 이 4건은 “백업이
안 돎”이 아니라 “창고가 아직 그 백업을 안 받음”이었고, 마트가 breach로 단정하지 않고
no_backup_observed로 사실만 실은 게 정확했습니다. 판정이 조심스러운 값이라 오히려 맞았습니다.

플랜 회귀 마트는 0행이었습니다. 창고의 플랜 이력이 07-16 하루뿐이라, 날짜 간 뒤집힘을
잡을 최소 조건(이틀)이 안 됩니다. 로직이 틀린 게 아니라 재료가 아직 하루입니다. CI
픽스처에는 교차일 뒤집힘을 심어 PENDING 한 행이 나오는 걸 확인해 뒀는데, 이게 초기
실데이터가 앞으로 보일 모습입니다. 하루가 이틀이 되는 내일이면 뒤집힘이 잡히기 시작합니다.

주간 보고 마트는 7행으로, 앞의 두 판정과 용량·대기를 한 줄씩 묶어 위 대시보드가 됐습니다.

6. 판정은 “무엇이 언제”까지였고, “왜”가 빠져 있었다

이걸로 “장기 창고라야 가능한 판정” 세 개가 갖춰졌습니다. 용량 D-day는 미래를 외삽하고,
플랜 회귀는 시점 전후를 상관시키고, 백업 공백은 부재를 감지합니다. 셋 다 라이브 7일
창으로는 구조적으로 못 하는 판정이라, 이 창고가 존재하는 이유를 그대로 보여줍니다. 그
사이에 미사용 인덱스 판정도 하나 더 붙었습니다(그건 뒤에서 기종 축과 함께 다시 나옵니다).

그런데 이 판정들을 보다가 한 가지가 걸렸습니다. 전부 “무엇이 언제”까지만 답합니다.
“이 쿼리가 느려졌다”, “이 인스턴스 백업이 3주째 없다” 까지는 말하는데, “왜 그렇게
됐나”의 첫 후보
는 아무도 안 짚어 줍니다.

DBA 장애의 흔한 숨은 원인 하나가 사람이 조용히 바꾼 파라미터입니다. work_mem을 줄였거나
max_connections를 건드렸는데, 그게 며칠 뒤 특정 쿼리의 플랜을 뒤집습니다. DBTower는
이 설정 변경을 1시간마다 감지해서 쌓습니다. 그런데 그 이력을 7일류로 지웁니다(자체 정리
잡이 돕니다). “3개월 전 언제부터 이 인스턴스 설정이 달라졌나”와 “그 변경 뒤로 성능이
나빠졌나”는 장기 이력이 있어야 답하는데, 그게 매일 사라지고 있었습니다. 그래서 이 판정
층에 “왜”의 첫 후보로 설정 드리프트를 붙이기로 했습니다.

7. 원천은 DBTower에 이미 있었다

이번엔 착수가 가벼웠습니다. 앞선 단계들(대기 이벤트, 인덱스 통계)은 DBTower 쪽에 테이블과
수집 잡을 새로 만들어야 했는데, 설정 드리프트는 producer가 이미 완성돼 있었습니다.
DBTower가 config_snapshot(매 수집 1행, 무변경도 해시로 증명)과 config_param_change
(바뀐 파라미터만 append)를 1시간 주기로 쌓고, 기존 파라미터 조회 기능을 5기종에서
재사용합니다. 그래서 이 단계는 lakehouse 단독 작업이었습니다. DBTower 쪽은 읽기 권한
한 줄만 주면 됩니다.

내릴 테이블을 고를 때 하나는 뺐습니다. config_current_param은 “현재 전량 거울”이라
값이 바뀔 때마다 덮어쓰고 지우는 변이 테이블입니다. 이 창고는 불변 append만 내린다는
계약을 지켜야 해서, 시간이 지나도 안 바뀌는 두 테이블만 오프로드 대상으로 삼았습니다.

8. 무변경도 기록한다

첫 마트 fct_config_change_daily에서 신경 쓴 건 “조용한 날”과 “빈 날”의 구분입니다.
설정이 안 바뀐 날은 정상입니다. 하지만 수집 자체가 안 돈 날은 문제입니다. 이 둘을 뭉개면
“변경 0”이 두 가지 다른 상태를 가립니다.

그래서 팩트의 스파인을 config_snapshot으로 잡았습니다. 이 테이블은 무변경 사이클에도
1행을 남기니까(해시로 “그 시각 설정은 이랬다”를 증명), 이 행이 있으면 “수집됐다”는 뜻입니다.
여기에 실제 변경 상세를 config_param_change에서 붙입니다. 결과적으로 한 인스턴스의 하루가
“23사이클 수집됐고 변경은 0” 인지 “수집 자체가 없음” 인지 구분됩니다. 백업 공백 마트에서
“기록 없음”을 행으로 드러냈던 그 정직함과 같은 결입니다.

9. 변경 타임라인, 그리고 DuckDB가 낸 내부 오류

두 번째 마트 mart_config_change는 최근 90일 변경을 시간순으로 서빙합니다. “언제 무엇이
어떻게 바뀌었나”의 답입니다.

여기서 예상 못한 벽을 만났습니다. 처음엔 스테이징 뷰에서 select *로 전체 컬럼을 가져와
최신 dt 기준으로 창을 잘랐는데, DuckDB가 INTERNAL Error: Attempted to access index 8 within vector of size 8을 냈습니다. 원인은 파티션 규약이었습니다. 원천 parquet는
dt=.../instance_id=.../ 로 나뉘는데, instance_id는 파일 안에도 컬럼으로 있고 경로에도
있습니다. select *가 hive 파티션 뷰 위에서 자기 자신을 참조하는 max(dt) 서브쿼리와
겹치자, 바인더가 중복 컬럼을 세다 벡터 범위를 넘었습니다. 컬럼을 명시로 나열하고 창 기준을
별도 CTE(anchor)로 뽑아 날짜 - 정수 산수로 바꾸니 사라졌습니다. 롤링 회귀 마트에서 쓰던
그 안정적인 패턴 그대로입니다.

10. 원인 후보로 겹친다

세 번째 마트가 이 설정 아크의 핵심입니다. mart_config_impact는 설정 변경 이벤트와 플랜
뒤집힘을 시간축으로 겹칩니다. 어떤 파라미터가 바뀐 뒤 N일(기본 7일) 안에, 그 인스턴스에서
플랜이 뒤집히거나 회귀(REGRESSED)가 관측됐나를 셉니다.

설정 변경 영향 상관의 개념. 위 레인의 설정 변경(work_mem 4MB→1MB)에서 아래 레인의 플랜 뒤집힘·지연 증가로 이어지는 화살표. 변경 뒤 하루 안에 회귀가 뒤따르면 correlation=followed_by_regression으로 판정한다. 상관이지 인과가 아니라는 것을 판정 박스에 명시했다

이건 장기 설정 이력과 장기 성능 이력이 같은 창고에 있어야만 가능한 판정입니다. DBTower의
7일 창은 설정 변경도 성능 이력도 7일치뿐이라 이 상관을 못 봅니다. 용량 D-day, 플랜 회귀,
백업 공백에 이은 “창고라야 가능한 판정”이고, 앞의 판정들에 “왜 그렇게 됐나”의 첫
후보를 붙이는 층입니다.

정직함을 두 군데 박았습니다. 첫째, 상관은 인과가 아닙니다. “이 설정이 원인”이라고
단정하지 않고 followed_by_regression / followed_by_plan_flip / no_flip_observed
라는 관측 사실만 조언 어휘로 싣습니다. 최종 판단은 사람의 몫입니다. 미사용 인덱스 마트에서
“삭제 지시”가 아니라 “후보”라고만 했던 그 절제와 같습니다. 둘째, “누가” 바꿨는지는
못 담습니다.
파라미터를 누가 바꿨는지는 대상 DB가 주지 않는 정보라 DBTower도 저장하지
않고, 이 창고도 “언제 무엇이 어떻게” 까지만 압니다.

11. 라이브에서 나온 설정 드리프트

dev 창고에서 돌렸더니 원천에 진짜 드리프트가 있었습니다. 인스턴스 2와 4의 work_mem
4096에서 8192로 올랐다가 다시 4096으로 내려와 있었습니다. 누군가 올렸다가 되돌린 흔적입니다.

Metabase 설정 드리프트 대시보드 실화면. 왼쪽 위는 변경 타임라인으로 인스턴스 2·4의 work_mem이 4096과 8192 사이를 오간 기록이다. 오른쪽 위는 영향 상관 표다. 아래는 일별 수집·변경 표인데, 인스턴스 2·4는 change_events가 2이고 나머지 다섯은 0이지만 cycles_collected가 모두 23이라 무변경과 미수집이 구분된다

fct_config_change_daily가 정확히 의도대로 나왔습니다. 일곱 인스턴스 전부 그날 23사이클
수집됐고(수집됨의 증거), 그중 2와 4만 변경 이벤트 2건에 파라미터 1종이 바뀌었으며, 나머지 다섯은 변경 0입니다. “무변경”과 “미수집”이 한 표에서 갈립니다.

상관 마트는 네 변경 이벤트 모두 no_flip_observed로 나왔습니다. 창고의 플랜 이력이 아직
하루뿐이라 겹칠 뒤집힘이 없기 때문입니다. 정직한 결과입니다. 그래서 로직 자체는 CI 픽스처로
증명했습니다. 설정 변경(1월 1일)과 플랜 뒤집힘(1월 2일)을 일부러 겹쳐 두니
followed_by_plan_flip이 나옵니다. 실데이터에서 이 상관이 켜지는 건 플랜 이력이 쌓인
뒤이고, 그 조건과 예상 시점은 로드맵의 “시간이 해제하는 체크리스트”에 적어 뒀습니다.

12. 남은 것

이걸로 창고의 판정이 다섯이 됐고, 그중 하나는 앞의 판정들에 “왜”의 첫 후보를 붙입니다.
설정 변경은 지금도 실물로 잡히고, 그 변경이 성능 회귀와 겹쳤는지는 이력이 쌓이며 켜집니다.

발화 경계는 이번에도 지켰습니다. 창고는 판정 컬럼과 타임라인, 상관까지만 계산하고, “이
설정 바뀐 뒤 회귀가 시작됐다” 같은 급변 알림은 DBTower의 reverse ETL이나 Metabase 구독이
쏩니다. 두 번째 알림 시스템을 만들지 않습니다.

한 가지 더 정직하게 적자면, 이 상관은 플랜 뒤집힘 하나만 본다는 한계가 있습니다. 볼륨
증가로 같은 플랜이 느려지는 회귀는 롤링 랭킹 마트의 몫이라 여기서는 안 겹칩니다. 원인의
축이 여럿이라는 뜻이고, 지금 겹친 건 그중 “설정 변경 → 플랜 변경” 한 갈래입니다. 나머지 축을 이 상관 층에 어떻게 더할지가 다음 이야기입니다. 바로 이어서 넓혔습니다.

13. 상관이 계속 안 켜졌다

이 판정 층 앞부분에서 설정 변경을 성능 회귀의 원인 후보로 겹치는 마트를 만들었습니다. 그런데 실데이터에서
계속 no_flip_observed가 나왔습니다. 창고의 플랜 이력이 하루뿐이라 변경 뒤에 겹칠 뒤집힘이
없었기 때문입니다. 정직한 결과이긴 한데, 그걸 계속 보다 보니 설계의 한계가 눈에 들어왔습니다.
상관을 플랜 뒤집힘 하나에만 묶어 둔 것입니다.

원인의 축은 하나가 아닙니다. 파라미터가 바뀌면 플랜을 뒤집지 않고도 그냥 느려질 수 있습니다.
그리고 바뀌는 대상도 파라미터만이 아닙니다. 개발자가 인덱스를 추가하거나 컬럼 타입을 바꾸는
스키마 변경도 성능을 흔듭니다. 한 소스, 한 신호에 묶인 상관은 반쪽입니다.

14. 안 지을 것과 넓힐 것

여기서 유혹이 하나 있었습니다. DBTower에는 이미 스키마 변경 리뷰 기능(change_review)이
있습니다. 개발자가 DDL을 배포 전 리뷰 요청하면 규칙 엔진이 위험을 지적하고 ADMIN이 승인하는
워크플로입니다. 이걸 창고로 내리면 “승인된 스키마 변경 뒤 느려졌나”까지 상관을 넓힐 수 있습니다.

그런데 냉정하게 보니 이건 안 짓는 게 맞았습니다. 이 프로젝트의 전제는 “DBTower가 7일 뒤
버리는 데이터의 두 번째 삶”입니다. 그런데 리뷰 요청은 저빈도 감사 데이터라 스냅샷처럼 공간
때문에 지울 이유가 약합니다. DBTower가 이미 장기로 들고 있을 가능성이 높으니, 창고가 구할
“버려지는 데이터”가 별로 없습니다. 게다가 리뷰는 월 몇 건 수준이라 상관 신호도 성깁니다.
전체 오프로드 아크(계약·CI·블로그)를 짊어질 값이 안 나옵니다.

그래서 갈랐습니다. change_review는 안 짓되, 나중에 끼울 자리는 연다. 상관 자체를 특정
소스에 묶지 않고 일반화해 두면, 정말 필요해지는 날 소스 하나만 붙이면 됩니다.

15. 변경을 한 형태로 모은다

int_change_events라는 통합 스트림을 만들었습니다. 변경을 종류에 상관없이 한 형태
(instance, 언제, 무엇을, 어떻게)로 모읍니다. 지금은 설정 변경만 흘러 들어오고, 스키마 변경은
union all 자리를 코드 주석으로 남겨 두었습니다.

변경 영향 상관의 확장 구조. 왼쪽은 변경 소스로, 설정 변경(config)은 실선 상자로 연결돼 있고 스키마 변경(schema)은 점선 상자로 자리만 열려 있다. 둘이 int_change_events 통합 스트림으로 모이고, 변경 뒤 N일 안의 신호를 두 축(플랜 뒤집힘·평균 지연 상승)으로 본다. 아래는 우선순위 판정 사다리로 followed_by_regression, followed_by_plan_flip, followed_by_latency_rise, no_signal 순이다

여기서 작은 함정을 하나 밟았습니다. 스키마 변경 자리를 주석으로 남기면서 union all 예시에
ref() 문법을 그대로 적었더니, dbt가 주석 안의 ref()까지 의존으로 파싱해서 존재하지
않는 모델을 찾다 빌드가 깨졌습니다. dbt의 참조 탐지는 정규식 기반이라 주석을 구분하지 않습니다.
자리 표시용 코드에서는 ref() 문법을 일부러 안 쓰는 걸로 바꿨습니다.

16. 지연 축을 더한다

두 번째로 상관의 신호 축을 넓혔습니다. 기존엔 변경 뒤 플랜이 뒤집혔나만 봤는데, 이제 변경
전후로 그 인스턴스의 평균 지연이 올랐나
도 봅니다. 변경 전 N일과 후 N일의 평균 지연을
비교해서, 비율이 임계 이상이면 followed_by_latency_rise로 잡습니다. 플랜이 안 뒤집혀도
“변경 뒤 느려졌다”가 켜집니다.

판정은 우선순위 사다리입니다. 회귀가 뒤따르면 가장 강한 신호이고, 그다음이 플랜 뒤집힘,
그다음이 지연 상승, 아무것도 없으면 신호 없음입니다. 지연을 계산할 때 호출이 0이던 날의
지연은 NULL이라 평균에서 자연히 빠집니다. 이걸 0으로 접으면 활동 없던 날이 “빨라진 날”로
둔갑해 회귀를 개선으로 오판하는데, 그 함정은 앞에서 이미 배운 것이라 그대로 피했습니다.

17. 지평 경계를 못박는다

여기서 코드가 아니라 문서로 정리한 게 하나 있습니다. 미사용 인덱스 판정을 놓고 보면,
DBTower에도 라이브 분석기(UnusedIndexAnalyzer)가 있고 lakehouse에도 90일 마트
(mart_index_verdict)가 있습니다. 둘 다 “미사용 인덱스”를 판정하니, 셀프호스터 입장에서
어느 걸 믿어야 하는지 헷갈릴 수 있습니다.

용량 D-day에서 세운 분업을 그대로 적용했습니다. 단기·즉답이 필요하면 DBTower 라이브,
“분기 내내 정말 안 쓰였나”의 확정은 장기 마트.
DBTower의 순간 판정은 방금 재기동한 서버의
0회도 미사용처럼 보이는 약점이 있고, 90일 창은 그 노이즈에 안 흔들립니다. 원천(인덱스 사용
통계)은 같고 창만 다른 상호보완이지 경쟁이 아닙니다. 이 경계를 마트 헤더와 로드맵에 못박아,
“지워도 되나”를 확정할 때는 마트를, “지금 훑어보기”는 DBTower 화면을 보게 했습니다.

18. 상관 라이브

상관 마트를 실데이터에 다시 돌렸습니다. 이제 change_source가 config로 채워지고, 변경 전
지연이 실측으로 붙습니다. 인스턴스 2는 변경 전 평균 3.1ms, 인스턴스 4는 35.04ms였습니다.

Metabase 설정 드리프트 대시보드의 영향 카드. change_source와 change_key 컬럼이 채워져 변경 소스가 일반화됐음을 보여준다. 인스턴스 2와 4의 work_mem 변경이 config 소스로 기록돼 있다

변경 후 지연은 아직 비어 있습니다. work_mem 변경이 오늘 일어났는데, “변경 후 N일”의 창이
아직 미래라 채울 데이터가 없기 때문입니다. 그래서 상관은 no_signal입니다. 이건 버그가 아니라
정직한 상태입니다. 내일부터 데이터가 쌓이면 후 창이 차고, 지연이 올랐다면 상관이 켜집니다.
두 축(플랜·지연) 모두 변경 이후의 관측이 필요하다는 성질이라, 당일 변경이 아직 no_signal인 건
당연합니다.

19. 같은 각주가 마트마다 반복됐다

상관을 넓히다 눈에 걸리는 게 또 있었습니다. 여러 마트가 같은 각주를 달고 있었습니다.
“이 창고엔 기종 축이 없다, 기종은 소비자가 DBTower 인스턴스 화면에서 안다.”

대기 마트(mart_wait_top)에서는 delta_mslast_ms의 의미가 기종마다 다릅니다. MySQL은
누적, PostgreSQL은 현재 스냅샷, Mongo는 대기 큐입니다. 그런데 마트엔 기종이 없어서 “누적인지
스냅샷인지는 저쪽에서 보세요”라고 적어 두었습니다. 백업 공백 마트에서는 no_backup_observed
정말 백업이 안 도는 건지, 그 기종을 아직 수집 못 한 건지 못 갈랐습니다. 대시보드도 마찬가지로
instance_id 4 같은 숫자만 나와서 안 읽혔습니다.

각주가 한 곳이면 한계지만, 여러 곳에서 반복되면 신호입니다. 뭔가 빠져 있다는 신호입니다.

20. 원천은 이미 읽고 있었다

그런데 정작 기종이 든 테이블은 새로 만들 필요가 없었습니다. database_instance이미 매
오프로드가 읽고 있었습니다.
“어느 인스턴스를 훑을지” 목록을 뽑느라 매번 읽던 그 테이블에,
name(local-mysql, local-postgres…)과 type(MYSQL, POSTGRESQL, MSSQL, MONGODB, ORACLE)이
나란히 있었습니다. 컬럼 두 개만 더 실으면 되는 거였습니다.

한 가지는 조심해야 했습니다. database_instance는 지금까지 내리던 팩트와 성질이
다릅니다.
팩트는 시계열 스냅샷이라 날짜와 인스턴스로 파티션했는데, database_instance
인스턴스 목록 자체입니다. 느린 변화 차원이고, instance_id로 파티션할 수도 없습니다(그게 곧
행이니까요). 그래서 팩트 오프로드 기계를 재사용하지 않고, 전량을 날짜별로 한 파일에
스냅샷하는 작은 오프로드를 따로 만들었습니다. 이름과 기종이 나중에 바뀌면 그 이력까지
남습니다. 그리고 차원 마트(dim_instance)는 그중 최신 스냅샷만 취해 현재 상태를
만듭니다. 변화 이력이 필요하면 스테이징을 직접 보면 됩니다.

21. 각주가 사라진다

이 차원을 대기 마트와 주간 보고에 조인했습니다.

기종 축이 흩어진 각주를 회수하는 구조. 왼쪽의 팩트 마트들은 instance_id 숫자만 갖고 있었는데, dim_instance를 조인하면 instance_id 1이 local-mysql (MYSQL)로 읽힌다. 이 조인 하나로 대기 마트의 delta/last 의미 각주, 백업 마트의 미백업 대 미수집 각주, 대시보드의 숫자 표기가 모두 해소된다. 새 기능이라기보다 흩어져 있던 각주의 회수다

결과가 바로 읽힙니다. instance_id 1local-mysql (MYSQL)이 되고, top 대기 이벤트가 기종과
나란히 섭니다. wait/io/file/sql/binlog는 MySQL의 것이고, WalSenderMain은 PostgreSQL 복제,
RESOURCE_SEMAPHORE_QUERY_COMPILE은 MSSQL, resmgr:cpu quantum은 Oracle이라는 게 한눈에
보입니다. 예전엔 이 대기 이름만 보고 기종을 짐작해야 했는데, 이제 옆 칸에 기종이 있습니다.

Metabase 주간 운영 보고 대시보드. instance_id 옆에 instance_name과 engine 컬럼이 붙어, 1은 local-mysql (MYSQL), 2는 local-postgres (POSTGRESQL), 3은 local-mssql (MSSQL), 4는 dbtower-self (POSTGRESQL)로 읽힌다. top 대기 이벤트가 기종과 나란히 서 있다

이건 새 기능이라기보다 흩어져 있던 “기종은 저쪽에서 보세요”의 회수입니다. 원천에 이미
있던 걸 안 실었을 뿐이었습니다.

22. 상관에 볼륨 축을 더한다

기종 축을 붙인 김에, 앞에서 열어 둔 상관 층에 볼륨 축을 마저 더했습니다. 상관에 플랜
뒤집힘과 지연 두 축을 뒀는데, 여기에 변경 전후로 용량이 급증했나를 더했습니다. 설정
변경 뒤 인스턴스의 총 크기가 임계 이상 커지면 followed_by_size_growth로 잡습니다.

다만 이 축은 정직하게 약한 신호입니다. 설정 변경이 디스크를 직접 키우는 경우는 드뭅니다.
로깅을 켜거나 WAL 보존을 늘리는 정도입니다. 그래서 우선순위 사다리의 맨 아래에 두고,
회귀나 지연 상승이 있으면 그쪽을 먼저 봅니다. 세 축(플랜·지연·볼륨)을 한 번에 조인하면
행이 곱해져 부풀 수 있어서, 지연과 볼륨을 인스턴스와 날짜 단위로 먼저 접어 조인 폭발을
막았습니다. 평균과 합의 의미는 그래도 그대로입니다.

23. 판정이 다섯인데 “떠 있었나”가 없었다

창고의 판정이 어느새 다섯이 됐습니다. 용량 D-day, 플랜 회귀, 백업 공백, 미사용 인덱스,
설정 변경 상관. 그런데 대시보드를 보다가 이상한 걸 느꼈습니다. 정작 제일 기본적인 질문인
“이 DB 이번 주, 이번 분기 얼마나 떠 있었나”는 못 답하고 있었습니다.

DBTower에는 health_sample이라는 테이블이 있습니다. 1분마다 각 인스턴스에 핑을 날려
up 여부와 ping_millis를 쌓습니다. SloService가 이걸로 30일 가용성 SLO와 에러버짓을
계산합니다. 그런데 이 테이블은 35일만 보존하고 지웁니다. 에러버짓 회계 기간보다 조금
길게만 잡아 두는 겁니다. 그래서 30일 넘는 장기 가용성은 어디에도 남지 않았습니다.

24. 정말 lakehouse의 몫인지부터 확인했다

바로 짓기 전에, DBTower의 테이블 29개를 창고 오프로드 현황과 전부 대조했습니다. 아무거나
가져오면 경계가 무너지니까요. 잣대는 둘이었습니다. DBTower가 그걸 지우는가(그래야 구할
데이터가 있음), 그리고 7일이나 30일로는 못 하는 장기 판정인가.

대조 결과 health_sample이 두 잣대를 다 통과하는 유일한 미착수 원천이었습니다. 게다가
확인해보니 이 up 여부는 Prometheus 게이지로 노출되지도 않습니다. 호스트 디스크 메트릭은
Prometheus로 가는데, 이 가용성 샘플은 DBTower의 PG 테이블에만 있고 SloService가 30일 창으로만
계산합니다. 즉 메트릭 경로(Prometheus에서 장기는 Thanos로)에도 없는 빈 자리라, 이걸 가져와도
“메트릭은 Prometheus, 쿼리 분석은 lakehouse”라는 경계를 안 침범합니다.

가용성 SLO 파이프라인. health_sample(1분 폴링, 35일 보존)에서 fct_uptime_daily(up 샘플 나누기 전체가 uptime 퍼센트)로, 다시 mart_uptime_slo(최근 30일 대 목표로 에러버짓)로 흐른다. 아래는 에러버짓 개념과 라이브 막대로, local-postgres는 99.9퍼센트로 meets, local-mysql은 98.9퍼센트로 breach, local-mssql은 63.4퍼센트로 breach다

나머지 테이블은 안 가져오는 게 맞았습니다. audit_eventlogin_attempt는 DBTower 콘솔
자체의 감사 로그라 관리 대상 DB 이야기가 아니고, 나머지는 인프라나 설정 테이블입니다.

25. 1분 샘플을 하루로 접는다

fct_uptime_daily는 하루치 1분 샘플을 하나로 접습니다. uptime_pct는 up 샘플을 전체 샘플로
나눈 값입니다. 여기서 작은 함정이 하나 있었습니다. 다운일 때의 ping_millis는 타임아웃
입니다. 이걸 평균에 넣으면 가끔 다운된 인스턴스의 평균 지연이 실제보다 훨씬 나빠 보입니다.
그래서 ping의 평균과 p95는 up인 샘플로만 냈습니다. 가용성은 전체 샘플로, 지연은 살아 있던
샘플로 봅니다.

그리고 mart_uptime_slo는 최근 30일 가용성을 목표와 견줍니다. 목표는 seed로 인스턴스별로
정하거나(기본 99.5%), 전역 기본을 씁니다. 그리고 SRE 에러버짓을 냅니다. 목표 99.5%는 한 달에
다운을 약 3.6시간까지 허용한다는 뜻인데, 그중 얼마가 남았는지를 잔량 퍼센트로 계산합니다.
남은 게 음수면 이미 목표를 넘긴 겁니다. 판정은 목표 미달이면 breach, 버짓을 거의 소진했으면
at_risk, 그 외는 meets입니다. 발화는 여기서도 안 합니다. SLO 판정까지만 계산하고, 다운 알림은
DBTower나 Metabase의 몫입니다.

26. 라이브에서 바로 켜졌다

지금까지의 상관 마트들은 후행 관측이 필요해서 실데이터로 켜지려면 시간이 걸렸는데, 가용성은
연속 측정이라 바로 의미 있게 채워졌습니다.

Metabase 가용성 SLO 대시보드 실화면. instance_name과 engine이 붙어 있고, local-mssql은 63.45퍼센트에 worst day 41.19퍼센트, 평균 ping 2132밀리초, mssql-pitr는 64.28퍼센트에 평균 ping 6897밀리초로 목표 99.9에 한참 못 미친다. local-mysql은 98.86퍼센트, PostgreSQL과 Mongo와 Oracle은 99.9퍼센트대다

결과가 선명합니다. MSSQL 두 인스턴스가 63퍼센트대 가용성에 평균 ping이 2초에서 7초입니다.
worst_day_uptime_pct를 보면 최악의 날은 41퍼센트까지 떨어졌습니다. 반면 PostgreSQL, Mongo,
Oracle은 99.9퍼센트로 목표를 지키고, DBTower 자기 자신(dbtower-self)은 100퍼센트입니다.
기종 축을 앞에서 붙여 둔 덕에 “어느 기종이 몇 퍼센트 떴나”가 한 줄로 읽힙니다. MSSQL이
지금 문제라는 게 숫자로 바로 보입니다.

주간 보고에도 this_week_uptime_pct 한 칸을 더했습니다. 이제 주간 보고 한 줄이 “이 DB
이번 주 어땠나”를 느림, 용량, 백업에 더해 떠 있었나까지 답합니다.

27. 마지막 빈 자리

이걸로 창고의 판정이 여섯이 됐습니다. 용량, 플랜, 백업, 인덱스, 설정 상관, 그리고 가용성입니다.
전수 대조에서 남았던 유일한 빈 자리가 채워졌고, 그게 마침 제일 기본적인 “떠 있었나”였습니다.

이번 편을 관통한 방식은 하나였습니다. 축을 미리 다 벌려 놓기보다, 신호가 실제로 도는 걸 보고
하나씩 붙였습니다. change_review는 자리만 열어 두었고, 볼륨 축은 우선순위 맨 아래에 뒀고,
가용성은 마지막에야 확인 절차를 거쳐 가져왔습니다. 기종 축처럼 “짓기”보다 “회수”에 가까운
것도 있었습니다.

여기서 남은 건 정말로 시간이 해제하는 것들뿐입니다. 플랜 이력이 쌓이면 플랜 회귀와 설정 상관이
실데이터로 켜지고, 그건 코드가 아니라 날짜가 하는 일입니다. DBTower의 나머지 테이블은 콘솔
감사거나 인프라거나 이미 다른 집(Prometheus)이 있어서, 창고가 더 가져올 게 없습니다. 관리 대상
DB에 관해 창고가 답할 수 있는 판정은 여기서 한 바퀴가 찼습니다.

Author
작성자 @범수

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

댓글

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