모니터링 기록 32만 행 중 22만 행을 지웠는데 status 페이지 숫자는 하나도 안 바뀌었다
헬스체크 기록이 지우는 코드 없이 1분마다 쌓여 status 페이지 집계가 매번 테이블 전체를 훑고 있었다. 일별 집계 테이블을 두고 성공 기록만 30일 뒤 지워서 쿼리를 0.68초에서 0.012초로 줄였다.
#MySQL #모니터링 #성능최적화 #Node.js #운영
모니터링 서버 100일 회고 에 고쳐야 할 것을 다섯 개 적었다. 그중 하나가 체크 기록이 끝없이 쌓인다는 거였다. 그때는 크기 자체는 아직 괜찮다고 쓰고 넘어갔는데, 막상 다시 들여다보니 크기보다 쿼리 쪽이 더 문제였다. 이 글에서는 헬스체크 기록을 어떻게 줄였는지, 그리고 줄이면서 status 페이지의 숫자가 하나라도 바뀌면 안 된다는 조건을 어떻게 확인했는지 정리해본다. 얼마나 쌓여 있었나 헬스체크는 1분마다 서비스 열 개에 요청을 보내고 결과를 health_history 테이블에 한 줄씩 넣는다. 지우는 코드는 어디에도 없었다. 6월 초에 시작했으니 넉 달 가까이 하나도 안 지운 셈이다. mysql SELECT COUNT(*), MIN(checked_at), MAX(checked_at) FROM health_history; +----------+---------------------+---------------------+ | COUNT(*) | MIN(checked_at) | MAX(checked_at) | +----------+---------------------+---------------------+ | 317971 | 2026-06-06 18:12:45 | 2026-09-29 22:29:04 | +----------+---------------------+---------------------+ data_length 14,417,920 index_length 11,173,888 31만 8천 행, 데이터와 인덱스를 합쳐 25.6MB다. 하루에 3,200행 정도씩 는다. 디스크로 보면 아무것도 아닌 크기라서 그동안 신경을 안 썼다. 크기보다 쿼리가 문제였다 이 기록을 제일 많이 읽는 건 공개 status 페이지다. 서비스마다 최근 90일을 하루 한 칸씩 막대로 보여주는데, 그 막대 하나하나가 그날의 체크 수와 성공 수다. 이걸 매번 원본에서 직접 셌다. SELECT endpoint_id, DATE_FORMAT(checked_at, '%Y-%m-%d') AS day, COUNT(*) AS total, SUM(is_healthy = 1) AS ok FROM health_history WHERE checked_at = DATE_SUB(CURDATE(), INTERVAL 89 DAY) GROUP BY endpoint_id, day 실행 계획을 보니 인덱스를 안 탔다. type key rows Extra ALL NULL 317,580 Using where; Using temporary checked_at 에 인덱스가 있긴 한데, 90일이면 테이블의 대부분이라 MySQL이 인덱스를 거치느니 그냥 처음부터 끝까지 읽는 게 낫다고 판단한 거다. 그 판단 자체는 맞다. 다섯 번 재보니 매번 0.68초 정도 걸렸다. 장애 상세용으로 실패 행만 뽑는 쿼리도 같은 이유로 전체를 읽어서 0.09초였다. status 페이지는 30초 캐시를 두고 있어서 방문자가 매번 0.68초를 기다리진 않는다. 대신 30초마다 누군가는 그 쿼리를 맞는다. 그리고 이 숫자는 기록이 쌓일수록 계속 늘어난다. 내년 이맘때면 지금의 네 배다. 90일치 막대에 원본이 다 필요한가 막대 하나에 필요한 건 그날의 체크 수와 성공 수 두 개뿐이다. 이미 끝난 날짜의 숫자는 다시 바뀔 일도 없다. 그런데 그걸 보여주려고 매번 하루 3,200행씩 90일치를 다시 세고 있었다. 그래서 끝난 날짜는 하루에 한 줄로 미리 접어두기로 했다. 서비스 열 개에 하루 한 줄이면 1년을 쌓아도 3,650줄이다. CREATE TABLE health_daily ( endpoint_id INT NOT NULL, day DATE NOT NULL, total INT NOT NULL, ok INT NOT NULL, avg_response_time INT NULL, max_response_time INT NULL, PRIMARY KEY (endpoint_id, day) ); 평균과 최대 응답 시간도 같이 넣었다. 지금 status 페이지는 안 쓰지만, 원본을 지우고 나면 나중에 다시 구할 방법이 없는 숫자라서 접을 때 같이 챙겨뒀다. 그럼 원본은 언제 지우나 여기서 조금 고민했다. 그냥 30일 지난 행을 다 지우면 간단한데, status 페이지에는 막대에 마우스를 올리면 그날 몇 시 몇 분에 어떤 이유로 실패했는지 보여주는 툴팁이 있다. 그건 실패한 행의 시각과 에러 메시지를 그대로 쓴다. 이걸 지우면 90일 막대는 남아도 왜 주황색인지는 알 수 없게 된다. 근데 실패 행은 넉 달 가까이 35개뿐이었다. 31만 행 중 0.01%다. 그래서 성공 행만 30일 뒤에 지우고 실패 행은 계속 남기기로 했다. 크기는 거의 그대로 줄이면서 장애 기록은 하나도 잃지 않는다. 100일 회고 때 적어둔 방향도 이거였다. 지우는 쪽에는 조건을 하나 더 걸었다. 일별 집계에 이미 들어간 날짜의 행만 지운다. 집계가 어떤 이유로 실패한 날에 원본까지 지워버리면 그날은 영영 복구가 안 된다. // util/healthRetention.js async function purgeOldSuccess() { let total = 0; for (;;) { const [rows] = await pool.query( `SELECT h.id FROM health_history h JOIN health_daily d ON d.endpoint_id = h.endpoint_id AND d.day = DATE(h.checked_at) WHERE h.is_healthy = 1 AND h.checked_at DATE_SUB(CURDATE(), INTERVAL 30 DAY) LIMIT 5000` ); if (!rows.length) break; const [r] = await pool.query('DELETE FROM health_history WHERE id IN (?)', [rows.map((x) = x.id)]); total += r.affectedRows; if (rows.length 5000) break; } return total; } 5,000개씩 나눠서 지우는 건 헬스체크가 1분마다 같은 테이블에 계속 쓰고 있어서다. 22만 행을 한 번에 지우면 그동안 테이블이 오래 잠길 수 있다. 집계는 끝난 날짜만, 다시 셀 땐 최근 3일만 집계는 서버가 뜰 때 한 번, 그 뒤로 1시간마다 돈다. 오늘은 아직 안 끝났으니 어제까지만 접는다. 그리고 매번 전체를 다시 세지 않고 최근 3일만 다시 센다. 이유가 있다. 성공 행을 지운 날짜를 원본으로 다시 세면 실패 행만 남아 있으니까 그날 체크 수가 확 줄어든다. 99.9%였던 날이 0%가 되는 거다. 그래서 다시 세는 범위는 지우는 기준인 30일보다 반드시 짧아야 한다. 이건 나중에 내가 숫자를 바꾸다 실수할 수 있는 부분이라 코드와 문서 양쪽에 경고를 적어뒀다. status 페이지 쪽 쿼리는 이렇게 바뀌었다. 그저께까지는 접어둔 테이블에서 읽고, 어제와 오늘만 원본에서 센다. SELECT endpoint_id, DATE_FORMAT(day, '%Y-%m-%d') AS day, total, ok FROM health_daily WHERE day = DATE_SUB(CURDATE(), INTERVAL 89 DAY) AND day DATE_SUB(CURDATE(), INTERVAL 1 DAY) UNION ALL SELECT endpoint_id, DATE_FORMAT(checked_at, '%Y-%m-%d') AS day, COUNT(*) AS total, SUM(is_healthy = 1) AS ok FROM health_history WHERE checked_at = DATE_SUB(CURDATE(), INTERVAL 1 DAY) GROUP BY endpoint_id, day 어제까지 원본에서 세는 게 좀 어색해 보일 수 있다. 처음엔 오늘만 원본으로 셌는데, 그러면 자정이 지나고 다음 집계가 돌기 전까지 최대 1시간 동안 어제 칸이 비어 보인다. 어제 하루치 원본은 3,200행 정도라 인덱스로 금방 읽으니, 어제까지 원본으로 세는 쪽이 간단하고 안전하다. 숫자가 하나라도 바뀌면 안 된다 이 작업에서 제일 신경 쓴 건 이거였다. 공개 status 페이지의 가동률은 남들이 보는 숫자고, 기록을 정리했더니 과거 가동률이 바뀌었다면 그건 정리가 아니라 조작이다. 그래서 순서를 이렇게 잡았다. 먼저 mysqldump로 테이블을 통째로 떠뒀다(gzip으로 2.1MB). 그다음 바꾸기 전의 /api/status 응답을 파일로 저장했다. 집계 테이블을 먼저 채운 다음 합계가 원본 행 수와 같은지 봤다. mysql SELECT COUNT(*), SUM(total) FROM health_daily; +----------+------------+ | COUNT(*) | SUM(total) | +----------+------------+ | 959 | 314915 | +----------+------------+ mysql SELECT COUNT(*) FROM health_history WHERE checked_at CURDATE(); +----------+ | COUNT(*) | +----------+ | 314915 | +----------+ 정확히 같았다. 그다음 서버를 재시작해서 정리를 돌렸다. [Retention] health_history 성공 행 223777개 삭제 (30일 초과) 그리고 바뀐 뒤의 /api/status 응답을 받아서 앞에 저장해둔 것과 서비스별, 날짜별로 하나하나 비교했다. 체크 수, 가동률, 장애 상세까지 전부. 포트폴리오 99.996 - 99.996 다른 날 0 모니터링 99.988 - 99.988 다른 날 0 블로그 99.977 - 99.977 다른 날 0 커플앱 API 99.984 - 99.984 다른 날 0 여행 API 99.993 - 99.993 다른 날 0 코인 트래커 99.992 - 99.992 다른 날 0 게임 99.984 - 99.984 다른 날 0 taro 99.984 - 99.984 다른 날 0 이음 100 - 100 다른 날 0 답정너(fishing) 100 - 100 다른 날 0 열 개 서비스, 89일 전부 같았다. 22만 행이 사라졌는데 status 페이지에서 바뀐 숫자는 하나도 없다. 결과 전 후 행 수 317,971 94,194 데이터 14.4MB 5.5MB 인덱스 11.2MB 9.0MB 일별 집계 쿼리 0.68초 0.012초 실패 목록 쿼리 0.09초 0.0005초 실패 행 35 35 쿼리는 50배 넘게 빨라졌다. 실행 계획도 31만 행 전체 스캔에서, 집계 테이블 959행과 최근 이틀치 원본 6,301행을 읽는 걸로 바뀌었다. 실패 목록은 (is_healthy, checked_at) 인덱스를 새로 달아서 실패 행 35개만 바로 찾아간다. 한 가지 솔직하게 적어둘 게 있다. 인덱스 크기는 생각만큼 안 줄었다. 행은 70% 줄었는데 인덱스는 11.2MB에서 9.0MB다. 쿼리를 위해 인덱스를 두 개 새로 달았기 때문이다. 하나는 실패 목록용이고, 하나는 관리 화면에서 서비스별 최근 기록을 볼 때 쓰는 (endpoint_id, checked_at) 다. 지금은 인덱스가 데이터보다 크다. 그래도 이제 행 수가 30일치에서 더 늘지 않으니 크기도 여기서 거의 고정된다. 남은 생각 처음 테이블을 만들 때 "나중에 지우는 거 넣어야지" 하고 넘어갔던 게 넉 달이 갔다. 기록이 쌓이는 동안은 아무 문제가 안 보이니까 계속 뒤로 밀린다. 근데 지우는 코드는 기록이 적을 때 넣는 게 훨씬 쉽다. 지금은 22만 행을 지우는 첫 실행 때문에 백업 뜨고, 나눠서 지우고, 전후를 비교하는 절차를 다 밟아야 했다. 그리고 기록을 줄일 때는 "무엇을 보여주는 데 쓰이는가"부터 따져보길 추천한다. 나는 그걸 따져보고 나서야 성공 행은 하루 한 줄로 충분하고, 실패 행은 하나도 버리면 안 된다는 걸 알았다.