F1MOA는 한국 F1 팬을 위한 데이터 분석, 라이브 타이밍 플랫폼입니다. Oracle Cloud ARM 서버 한대로 돌리고있는 소규모 트래픽의 웹 서비스 인데요. PLK(Prometheus + Loki + Grafana)로 모니터링 서비스를 구축하고 장애 상황에 대해 Alert rules를 설정 하고 슬랙 웹훅으로 알림을 받게해서 장애나 여러가지 상황에대해 대응하도록 시스템을 구축해놨습니다.


(미친듯이 슬랙에 알림이 와있었다..)
알람이 미친듯이 울렸지만.. 4월 20일 저녁 휴대폰 배터리는없고 지방에 일정때문에 내려가있어서 확인을 못했는데요. 다음날 사이트를 방문해주시는 분이 알려줘서 휴대폰을 켜보니 디스크 사용률초과와 레디스가 다운되는 이슈가 발생했었습니다.. 덕분에 사이트는 먹통이되어서 접속해보니
OperationalError at /
connection to server at "localhost" (127.0.0.1), port 5432 failed:
FATAL: the database system is in recovery mode
이런식으로 템플릿 에러가 떠있더라구요.
자세히 살펴보니까
PostgreSQL이 recovery mode에 빠져서 모든 DB 연결을 거부하고 있었어요.
SSH 서버에 접속해서 가장 먼저 확인한 건 디스크였습니다.
systemctl status postgresql을 확인해봤는데 active로 표시되어 정상처럼 보였습니다. 하지만 이건 "프로세스가 떠있는가"만 보여주는 거지, DB가 실제로 쿼리를 받을 수 있는 상태인지는 알 수 없어요.
직접 PostgreSQL을 확인해봐야 했습니다.
(로그는 아래와 같았다.)
PANIC: could not write to file ... No space left on device
FATAL: could not write init file
FATAL: could not write init file
FATAL: could not write init file (이게 수백 번 반복)
PostgreSQL은 주기적으로 checkpoint를 기록하는데, 디스크에 쓸 공간이 없어서 PANIC 시그널과 함께 종료된 거였어요. Recovery mode 이후에도 디스크가 여전히 가득 차 있어서, init 파일조차 생성하지 못해 모든 새 연결이 거부되었습니다.
그러면 45GB를 누가 먹고 있는 걸까요?

(Prometheus가 32GB로 전체 디스크의 71%를 모니터링 데이터가 차지하고 있었다)
먼저 Prometheus가 뭔지 간단히 설명하면, 서버의 상태를 숫자로 기록하는 모니터링 도구입니다. "지금 CPU가 몇 % 쓰이고 있는지", "HTTP 요청이 초당 몇 건 들어오는지", "응답 시간이 몇 초인지" 같은 것들을 주기적으로 수집해서 저장해요. 이 데이터를 Grafana라는 대시보드 도구로 시각화하면, 서버에 문제가 생겼을 때 언제부터 이상했는지를 그래프로 확인할 수 있습니다.
문제는 이 데이터를 얼마나 오래 보관하느냐예요. Prometheus에는 retention이라는 설정이 있는데, "수집한 데이터를 며칠 동안 보관할 것인가"를 정하는 겁니다.
Prometheus retention 설정을 확인해봤습니다.
$ sudo systemctl cat prometheus | grep -i retention
--storage.tsdb.retention.time=30d
리텐션 설정을 보니 30일 보관으로 설정되어 있었네요..
32GB / 30일 = 하루 약 1GB. DAU 100~300 규모의 서비스에서 이건 비정상적으로 많습니다. 비슷한 규모라면 하루 수십 MB가 일반적이에요. 뭔가 메트릭이 과도하게 생성되고 있다는 뜻이죠
어떤 메트릭이 문제인지 확인해보니 django_http_request_duration_seconds_bucket 히스토그램의 시계열이 26,675개였습니다.
Prometheus에서 "시계열(time series)"이란, 하나의 고유한 측정 항목을 말합니다. 예를 들어 이런 것 하나하나가 각각 별도의 시계열이에요.
http_requests_total{method="GET", path="/", status="200"}
http_requests_total{method="GET", path="/news/", status="200"}
http_requests_total{method="POST", path="/login/", status="302"}
같은 메트릭 이름(http_requests_total)이라도 레이블(method, path, status) 조합이 다르면 별개의 시계열로 저장됩니다. 시계열이 많아질수록 Prometheus가 저장해야 할 데이터도 비례해서 늘어나요.
그런데 왜 하필 히스토그램 메트릭이 26,675개나 됐을까요? 이걸 이해하려면 Prometheus 메트릭 타입의 차이를 알아야 합니다.
가장 단순한 메트릭 타입은 카운터(Counter)입니다. "요청이 몇 번 왔는지"를 세는 거예요.
# 카운터: 레이블 조합당 시계열 1개
http_requests_total{method="GET", path="/"} → 시계열 1개
반면 히스토그램(Histogram)은 "요청 응답 시간이 어떤 분포를 갖는지"를 측정합니다. 0.01초 이하가 몇 건, 0.025초 이하가 몇 건, 0.05초 이하가 몇 건... 이런 식으로 구간(버킷)별로 나눠서 세는 거예요. Prometheus 기본 버킷이 11개이고, 여기에 _sum(전체 합), _count(전체 건수), _created(생성 시각)까지 붙으면
# 히스토그램: 레이블 조합당 시계열 14개
request_duration_bucket{path="/", le="0.01"} ← 버킷 1
request_duration_bucket{path="/", le="0.025"} ← 버킷 2
request_duration_bucket{path="/", le="0.05"} ← 버킷 3
... ← 버킷 4~11
request_duration_sum{path="/"} ← 합계
request_duration_count{path="/"} ← 건수
request_duration_created{path="/"} ← 생성 시각
레이블 조합 하나당 14개의 시계열이 생깁니다.
여기서 핵심은 레이블 조합 수가 몇 개냐? 입니다. path 레이블에 들어가는 고유 경로가 10개면 14 x 10 = 140개로 전혀 문제가 안 돼요. 하지만 고유 경로가 2,400개면 14 x 2,400 = 33,600개로 문제가 됩니다.
그러면 왜 고유 경로가 2,400개나 됐는가?
PrometheusMiddleware에는 URL 경로를 정규화하는 로직이 있었습니다. /race/2026/1/과 /race/2026/2/를 /race/{id}/{id}/로 묶어서 시계열 수를 줄이기 위한 장치였어요.
def _normalize_path(self, path):
parts = path.strip('/').split('/')
normalized = []
for part in parts:
if part.isdigit():
normalized.append('{id}')
else:
normalized.append(part)
return '/' + '/'.join(normalized) if normalized else '/'
숫자만 {id}로 치환하는 방식이었는데, 처음에는 이게 합리적이라 생각했습니다. 하지만 현실에서는 이런 경로들이 전부 별도 시계열을 만들고 있었습니다.
/race/{id}/{id}/ — 레이스 결과
/en/race/{id}/{id}/ — 영어 버전 레이스 결과 (경로가 다르니까 별도 시계열)
/ko/news/{id}/ — 한국어 뉴스
/en/news/{id}/ — 영어 뉴스
크롤러와 봇이 요청하는 존재하지 않는 경로들 — /wp-admin/, /.env, /xmlrpc.php 같은 것들도 전부 개별 시계열
F1MOA는 한국어/영어 2개 언어를 지원하기 때문에 같은 페이지도 URL이 2개씩 존재하고, 여기에 봇 트래픽까지 더해지면서 고유 경로가 통제 불능으로 늘어난 거였습니다.
고유 (method, path) 조합이 약 2,400개 존재했고, 각각 14개 시계열을 만들어서 총 ~33,600개. 이게 매 scrape 주기(15초)마다 수집되니 디스크 사용량이 하루 1GB를 넘긴 거였습니다.
즉시 복구 자체는 단순했습니다. 용량 확보 후 PostgreSQL을 재시작 해주니 해결됐습니다.
디스크 사용률이 100%에서 38%로 내려갔고, 사이트는 정상으로 돌아왔습니다. 하지만 이대로 두면 또 언젠가는 디스크가 쌓이고 터지는일이 발생하겠죠?
시간 기반 retention의 한계
--storage.tsdb.retention.time=7d 로 줄였지만, 이것만으로는 안전하지 않습니다. 만약 7일치 데이터가 디스크를 초과하면? 같은 장애가 반복돼요.
그래서 용량 기반 retention을 병행했습니다.
--storage.tsdb.retention.time=7d
--storage.tsdb.retention.size=5GB
retention.size=5GB는 "시간이 얼마나 지났든, Prometheus 데이터 총량이 5GB를 넘으면 오래된 블록부터 삭제하라 는 설정이에요. 이렇게 하면 디스크 포화가 구조적으로 불가능해집니다.
45GB 디스크에서 Prometheus에 5GB를 할당한 건, 전체의 약 11%로 적절하다고 판단했어요. 나머지 40GB는 OS, PostgreSQL, 애플리케이션, 로그가 사용합니다.
retention을 줄여서 당장의 디스크 문제는 해결했지만, 근본 원인인 "시계열이 너무 많이 생성되는 문제"도 해결해야 합니다. 안 그러면 7일치만으로도 5GB를 금방 채워서, 최근 데이터만 남고 오래된 데이터는 바로바로 삭제되는 상황이 올 수 있어요. 모니터링 데이터의 의미가 퇴색되겠죠.
기존의 "숫자만 치환" 방식 대신, 화이트리스트 방식 으로 변경했습니다.
"무한히 늘어날 수 있는 경로를 정규화하자"가 아니라, "추적할 경로를 미리 정해놓고, 나머지는 전부 하나로 묶자"는 접근입니다.
class PrometheusMiddleware:
# 개별 추적할 경로 (유한 집합)
KNOWN_PATHS = {
'/', '/races/', '/standings/', '/compare/', '/news/',
'/live/', '/predictions/', '/columns/',
'/accounts/login/', '/accounts/signup/',
'/sitemap.xml', '/robots.txt',
}
# 하위 경로가 있는 동적 경로 (prefix 단위로 묶음)
DYNAMIC_PREFIXES = [
'/race/', # /race/2026/1/ → /race/{path}
'/news/', # /news/123/ → /news/{path}
'/predictions/', # /predictions/1/ → /predictions/{path}
'/columns/', # /columns/slug/ → /columns/{path}
'/en/', # /en/... → /en/{path}
'/ko/', # /ko/... → /ko/{path}
]
def _normalize_path(self, path):
# 1. 알려진 경로면 그대로 사용
if path in self.KNOWN_PATHS:
return path
# 2. 동적 경로면 prefix만 남기고 나머지는 {path}로 묶음
for prefix in self.DYNAMIC_PREFIXES:
if path.startswith(prefix) and path != prefix:
return prefix + '{path}'
# 3. 위 어디에도 안 걸리면 전부 /other로
return '/other'
이렇게 하면 알려진 경로만 개별 추적하고, 동적 경로는 prefix 단위 로 묶고, 나머지는 전부 /other로 보내요.
경로 레이블 값이 유한 집합으로 고정되었기 때문에, 트래픽이 아무리 늘어도 시계열 수는 더 이상 증가하지 않습니다.
대신에
개별 경로의 성능 데이터를 잃게 됩니다.
예를 들어:
변경 전: /news/123/이 느린지 /news/456/이 느린지 개별로 볼 수 있었음
변경 후: /news/{path}로 묶이니까 "뉴스 상세 페이지가 전체적으로 느리다" 까지만 알 수 있고, 어떤 기사가 느린지는 모름
하지만 이정도는 F1MOA 규모에서는 크게 문제가 되지 않는다고 판단했습니다.
솔직히 말하면, 이번 장애는 예고된 거였습니다.
Prometheus retention을 30일로 설정해놓고 디스크 용량 계산을 해본 적이 없었습니다. 히스토그램 메트릭의 cardinality가 폭발하고 있다는 것도 몰랐습니다. 심지어 Grafana alert이 Slack으로 왔는데 확인을 못했습니다.
전부 "나중에 해야지" 하면서 미뤘던 것들이에요.
1인 개발·운영을 하다 보면 이런 일이 생깁니다. 기능 개발, 버그 수정, 데이터 수집 파이프라인 관리, 서버 모니터링, SEO, 사용자 문의 대응까지 혼자 하다 보면 "지금 당장 안 터지는 것" 은 우선순위에서 계속 밀려요. 모니터링 설정을 점검하는 건 항상 "다음 주에"였고, 그 다음 주는 끝내 오지 않았습니다.
이번 장애로 약 18시간 동안 사이트가 죽어있었습니다. 그 사이에 사이트를 방문한 사용자들은 디버그 에러 페이지를 봤겠죠?
그래도 하나 다행인 건, 장애가 터지고 나니까 드디어 미뤄왔던 것들을 전부 처리했다는 점이에요. Prometheus retention에 용량 상한, 히스토그램 cardinality를 90% 이상 줄였고, journal 로그에 상한도 설정했습니다. 장애가 아니었으면 아마 아직도 안 했을거 같다는 생각을 하게 되네요.
장애는 안 터지는 게 가장 좋지만, 터졌을 때 얼마나 빠르게 복구하고 같은 일이 다시 일어나지 않도록 막느냐가 더 현실적인 목표인 것 같습니다.