포스트

고객사 RDS 통합 모니터링 플랫폼 구축 — Part 3: 어떤 지표를 어떻게 보는가

고객사 RDS 통합 모니터링 플랫폼 구축 — Part 3: 어떤 지표를 어떻게 보는가

DBA 가 매일 들여다보는 세 가지 화면 을 정리합니다. CloudWatch 시계열, Performance Insights 패널, 그리고 PI 상대 비교 분석입니다.

화면을 만들기 전에 더 일찍 결정해야 했던 질문이 있었습니다. 무엇을 볼 것인가 입니다. AWS RDS 의 CloudWatch 가 노출하는 메트릭만 해도 20개가 넘고, 같은 정보를 다른 시각에서 보여 주는 패널도 여럿입니다. 다 보면 좋지만 1분 주기로 모든 메트릭을 모든 인스턴스에 대해 가져오면 AWS API 비용이 비례해서 부풀어 오릅니다. 그래서 고른 지표최소화한 호출 패턴 이 함께 가야 했습니다.

이 글에서는 그 결정의 과정과, DBA 가 화면에서 실제로 무엇을 어떻게 보는지를 차례로 적어 봅니다.

CloudWatch 시계열에서 보는 6가지

DB 성능 지표 화면이 보여 주는 것은 인스턴스 단위로 펼친 6종 메트릭의 시계열 입니다. 한 인스턴스를 고르면 좌우로 펼쳐진 6개 그래프가 동시에 보이고, 인스턴스 여러 개를 선택하면 한 차트에 겹쳐 비교할 수 있습니다.

메트릭단위알람 방향무엇을 잡나
CPU 사용률%초과쿼리 부하 / 무한 루프 / 풀스캔 폭주
DB 연결 수초과풀 소진 / 연결 누수
여유 메모리GB미만메모리 부족 / OOM 임박
여유 스토리지GB미만디스크 풀 임박 (가장 치명적)
읽기 지연ms초과스토리지 / 인덱스 / 캐시
쓰기 지연ms초과스토리지 / WAL·redo / 복제

기간은 1시간 부터 30일 까지 프리셋으로 끊어 둡니다. 1분 단위 수집이라 단기는 거의 실시간에 가깝고, 30일은 일별 평균/최대/최소 세 선으로 표시해 전월 동일 기간을 점선 오버레이 로 같이 보여 줍니다. 같은 화면 안에서 지금한 달 전 같은 날 을 한눈에 비교할 수 있게 한 셈입니다.

화면 우측에는 임계치 가 같이 표시됩니다. 임계치는 전체 고객사 공통 으로 한 벌을 두고, 인스턴스마다 다른 값을 두고 싶을 때만 인스턴스 단위 override 를 따로 등록합니다. 둘 다 4편의 알림 관리 UI 에서 같이 다룰 예정입니다.

왜 이 메트릭들만

RDS 가 CloudWatch 로 노출하는 메트릭은 훨씬 많습니다. ReadIOPS / WriteIOPS / ReadThroughput / WriteThroughput 같은 I/O 처리량 계열, NetworkReceiveThroughput / NetworkTransmitThroughput 같은 네트워크 계열, T 시리즈 인스턴스의 CPUCreditBalance / CPUCreditUsage, 그리고 엔진별로 BinLogDiskUsage (MySQL), MaximumUsedTransactionIDs (PostgreSQL), OldestReplicationSlotLag (PG), ReplicaLag, AuroraReplicaLag 등이 더 있습니다. 다 합치면 20여 종이 넘습니다.

처음에는 다 가져오는 쪽 으로 시작했습니다. 그런데 비용을 계산해 보니 그게 쉽지 않았습니다. CloudWatch GetMetricDataMetric 단위 로 과금되고, 1분 주기로 30개 고객사 × 100여 대 RDS × 20여 메트릭을 호출하면 수집만으로 적지 않은 비용 이 매월 부풀어 오릅니다. 그리고 더 큰 문제는 DBA 가 매일 다 보지 않는다는 점 이었습니다. 메트릭이 20개여도 정작 알람을 걸어야 하는 것은 손에 꼽힙니다.

그래서 알람으로 가치 있는 것만 골라 6종 으로 좁혔습니다.

  • CPU 사용률: 거의 모든 부하의 1차 신호
  • DB 연결 수: 풀 소진 / 연결 누수의 거의 유일한 가시화 지표
  • 여유 스토리지: 디스크 풀은 서비스 자체를 멈춥니다. 1순위
  • 여유 메모리: OOM 임박은 응답 지연으로 이미 늦은 신호
  • 읽기 지연: 스토리지 / 인덱스 / 캐시 어디가 막혔는지 한 번에
  • 쓰기 지연: WAL / redo / 복제 어디가 막혔는지 한 번에

여기에 수집은 하되 알람은 걸지 않는 보조 1종 으로 DiskQueueDepth 를 더해, 실제 수집은 7종 입니다. DiskQueueDepth왜 지연이 늘었는지 단서로 보조하지만, 수치 자체로는 정상/이상 임계가 인스턴스마다 달라서 전역 임계치 를 정하지 못해 알람에서 빠졌습니다.

엔진별 분기는 다음과 같이 조건부로만 추가합니다.

추가 메트릭추가 대상 엔진이유
MaximumUsedTransactionIDsPostgreSQL / Aurora PGwraparound 위험 감지
OldestReplicationSlotLagPostgreSQL / Aurora PG미회수 slot 디스크 점유
BinLogDiskUsageMySQL / MariaDB / Aurora MySQLbinlog 디스크 누적
ReplicaLagMySQL / MariaDB / RDS PGRead Replica 지연
AuroraReplicaLagAurora MySQL / Aurora PGAurora reader 지연

엔진 분류는 inventory_sync 가 모은 db_instances.engine 컬럼에서 그대로 가져옵니다. 인스턴스 단위로 그 엔진에만 의미 있는 메트릭만 호출합니다. PG 인스턴스에 binlog 메트릭을 묻지 않고, MySQL 인스턴스에 wraparound 메트릭을 묻지 않습니다.

비용을 어떻게 줄였나

지표를 추리는 것 외에 호출 자체 를 줄이는 노력도 같이 진행했습니다. 같은 7종 수집이라도 어떻게 호출하냐 에 따라 비용이 크게 달라지는 영역이라서 그렇습니다.

가장 큰 효과는 고객사당 1회 호출 입니다. GetMetricData 는 한 호출에 여러 메트릭을 한꺼번에 묶을 수 있는 배치 API 입니다. 우리는 한 고객사의 모든 cw_enabled 인스턴스 × 그 인스턴스에 해당하는 메트릭 을 한 리스트로 만들어 한 호출에 다 묶었습니다. 코드로는 다음과 같이 짧습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
async def collect_cw_metrics(
    customer: Customer,
    instances: list[DbInstance],
    db: AsyncSession,
) -> None:
    """고객사의 cw_enabled 인스턴스 CloudWatch 메트릭 수집 후 DB 저장.

    - GetMetricData 단일 호출로 모든 인스턴스 × 7 메트릭 배치 조회 (과금 최소화)
    - 수집 범위: now-20min ~ now (RDS CW 게시 지연 최대 5분 + 여유)
    """
    session = get_boto3_session(aws_role_arn, external_id, aws_region)
    cw = session.client("cloudwatch", region_name=aws_region)

    now = datetime.now(timezone.utc)
    start = now - timedelta(minutes=20)

    ## MetricDataQueries 빌드: 인스턴스 × 메트릭 조합 (엔진별 분기)
    queries = []
    for i, inst_id in enumerate(inst_ids):
        metrics_for_inst = list(CW_METRICS)        # 공통 7종
        if engine in PG_ENGINES:    metrics_for_inst += CW_PG_METRICS
        if engine in MYSQL_ENGINES: metrics_for_inst += CW_MYSQL_METRICS
        # ... ReplicaLag / AuroraReplicaLag 도 같은 패턴

        for m in metrics_for_inst:
            queries.append({
                "Id": f"m{i}_{m['key']}",
                "MetricStat": {
                    "Metric": {"Namespace": "AWS/RDS",
                               "MetricName": m["name"],
                               "Dimensions": [{"Name": "DBInstanceIdentifier",
                                               "Value": inst_id}]},
                    "Period": 60, "Stat": m["stat"]},
                "ReturnData": True})

    ## CloudWatch GetMetricData 호출 (고객사당 1 API call)
    response = cw.get_metric_data(
        MetricDataQueries=queries, StartTime=start, EndTime=now)

portal/backend/app/services/cw_collector.pycollect_cw_metrics() 핵심. 인스턴스 × 메트릭이 모두 한 queries 리스트로 묶입니다.

한 고객사 안에 인스턴스가 10개이고 각 7종이라면 70개의 MetricStat 쿼리를 한 번에 보냅니다. 이게 인스턴스마다 따로 호출 하는 방식과 비용 차이가 큽니다. 그 위에서 고객사 단위로 묶어 STS AssumeRole 도 고객사당 한 번씩만 부르도록 했습니다. STS / boto3 세션은 45분 동안 캐시되어 있어서, 같은 고객사를 짧은 간격으로 두 번 호출해도 추가 STS 비용은 들지 않습니다.

호출 비용 외에 수집되지 않게 막는 장치 도 같이 두었습니다.

  • cw_enabled 토글: 인스턴스 단위로 OFF 하면 그 인스턴스는 queries 빌드 단계에서 빠집니다. 개발용 인스턴스 / 모니터링 안 하기로 한 인스턴스가 호출 자체에서 빠집니다.
  • is_dev 플래그: cw_enabled 와 별개로 알람 평가에서 빼는 플래그입니다. 수집은 하지만 알람 안 가는 형태로, 부하 실험용 인스턴스에 자주 씁니다.
  • 20분 윈도우: GetMetricData 의 시간 범위를 짧게 잡습니다. Oracle 의 CPUUtilization 처럼 게시 지연이 긴 메트릭을 잡으면서도 호출당 처리량은 최소화.
  • 저장 70일 자동 정리: cw_metric_snapshots 는 70일이 지나면 자동 정리됩니다. 저장 비용도 같이 관리.

이 네 가지가 합쳐져서, 한 달 동안 30개 고객사 × 100여 대 RDS 를 1분 주기로 들여다보는 비용이 기대보다 한참 작아진 영역 으로 들어왔습니다.

CW 수집 토글 화면

개발 DB(is_dev) 지정 화면

왼쪽: 인스턴스별 CW 수집 ON/OFF 토글. 오른쪽: 개발 DB(is_dev) 지정 — 수집은 하되 알람에서만 제외.

Performance Insights 패널

CW 시계열이 얼마나 였다면, Performance Insights 패널은 어디서 그 부하가 나왔는지 를 분해해 줍니다. PI 패널은 세 가지 정보를 한 화면에 같이 둡니다.

  • DB Load (AAS): Average Active Sessions. 인스턴스의 vCPU 수 가 정상 상한선입니다. 예를 들어 db.m5.large(2 vCPU) 인스턴스에서 DB Load 가 2.0 을 넘기면 과부하 의심.
  • Wait Event 스택: DB Load 를 어떤 종류의 기다림 인지로 분해합니다. CPU / I/O / Lock / 기타 색상으로 위로 쌓입니다. CPU 색이 크면 CPU 부족, I/O 색이 크면 스토리지 / 디스크 큐, Lock 색이 크면 잠금 경합 의심.
  • Top SQL: DB Load 에 가장 많이 기여한 상위 20개 쿼리 를 SQL ID, 토크나이즈 쿼리 미리보기, Load 기여도 순으로 나열합니다.

수집은 두 갈래로 나누어 두었습니다. 1시간 주기의 pi_collectionDB Load + Wait Event + Top SQL 전체를 한꺼번에 모으고, 3분 주기의 pi_load_collectionDB Load 만 가볍게 가져옵니다. 후자는 2편에서 다룬 수집 직후 즉시 알람 체크 용으로 빠르게 도는 잡이고, 전자는 화면에 풀어 보여 줄 전체 컨텍스트 를 모읍니다.

화면 자체는 30초마다 최신 데이터를 폴링하고, 60초마다 시간 범위가 자동으로 한 칸씩 미끄러집니다. 지금 으로 고정해 두고 다른 일을 하다 돌아오면 화면이 그 사이의 변화를 따라와 있는 형태입니다. 시간 범위 프리셋은 1시간 / 3시간 / 6시간 / 24시간 / 3일 / 7일 여섯 가지에 사용자 지정 구간을 더한 일곱 갈래 로 두었습니다.

캐시가 미세하게 비는 케이스도 처리해 두었습니다. 화면이 지금까지의 데이터 를 보여 줄 때, 가장 최근 캐시 포인트가 현재 시각보다 너무 뒤 라면 그 간격만큼만 AWS PI 를 다시 부르고 캐시를 채웁니다. 전체를 다시 부르지 않고 비어 있는 끝부분만 채우는 방식입니다.

PI 상대 비교

세 화면 중에서 DBA 가 가장 자주 켜 두는 화면이 PI 상대 비교입니다. 지금의 부하 만 봐도 충분한 케이스가 있지만, 지금이 정상인지 아닌지 는 사실 과거의 같은 시간대 와 비교해야 알 수 있는 경우가 많기 때문입니다.

PI 상대 비교 — Top SQL 변화율

PI 상대 비교 화면. 우측에 “1일 전 대비” 변화율과 “급증 / 신규” 배지가 같이 보입니다.

PI 상대 비교는 선택한 구간N일 전 동일 구간 의 Top SQL 을 나란히 놓고 변화를 표시합니다. N 은 1 / 3 / 7 일 중 고를 수 있고, 7일 까지 둔 이유는 주간 패턴 을 잡기 위함입니다. 예를 들어 매주 월요일 09:00 부터 30분간 도는 배치 잡이 있다면, 지난주 같은 월요일 같은 시간 과 비교하는 게 가장 정직합니다.

비교 결과는 SQL ID 단위로 정렬되어 다음 세 가지 시그널이 같이 나옵니다.

  • 신규 SQL (NEW 배지): 이전 구간에는 없던 SQL 이 현재 Top 20 에 새로 올라온 경우. 배포 직후의 새 쿼리데이터 분포 변화로 갑자기 비싸진 쿼리 가 여기에 잡힙니다.
  • 급증 (ORANGE 배지): 이전 구간 대비 DB Load 가 50% 이상 늘어난 SQL. 천천히 악화되는 쿼리의 시그널입니다.
  • 변화율 (%): 50% 미만의 일반 변화는 +/− 퍼센트만 표시합니다.

이 비교의 핵심은 AWS PI API 를 직접 호출 한다는 점입니다. 캐시된 Top SQL 만 가지고 비교하면 차트에서 드래그해서 작은 구간만 선택한 경우나 우리가 캐시하지 못한 시점 에는 비교가 안 됩니다. 그래서 그때그때 AWS 에 물어보는 형태로 두었습니다.

호출하는 동안 한 가지 까다로운 분기가 있었습니다. AWS PI 의 describe_dimension_keys API 에는 SQL ID 가 토크나이즈 단위인 그룹 (db.sql_tokenized)원본 SQL 단위 그룹 (db.sql) 두 가지가 있는데, 엔진에 따라 어느 쪽이 지원되는지가 다릅니다. 그리고 AdditionalMetrics 옵션으로 호출 횟수 까지 같이 받아 오려고 하면 그 옵션 자체 가 일부 엔진에서 예외를 던집니다.

이 두 가지 분기를 다음과 같이 이중 fallback 으로 처리했습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def fetch_top_sql(s: datetime, e: datetime) -> dict:
    """주어진 구간의 Top SQL을 AWS PI에서 직접 조회.
    {sql_id: {sql_text, db_load, call_count}} 반환.
    """
    period = _period_seconds(s, e)
    for sql_group in ["db.sql_tokenized", "db.sql"]:    # 그룹 fallback
        try:
            try:
                resp = pi_client.describe_dimension_keys(
                    ServiceType="RDS", Identifier=inst.dbi_resource_id,
                    StartTime=s, EndTime=e,
                    Metric="db.load.avg",
                    GroupBy={"Group": sql_group, "Limit": 20},
                    PeriodInSeconds=period,
                    AdditionalMetrics=[f"{sql_group}.count"])   # 호출 횟수 같이
                use_count = True
            except Exception:
                # AdditionalMetrics 미지원 엔진 → count 없이 재시도
                resp = pi_client.describe_dimension_keys(
                    ServiceType="RDS", Identifier=inst.dbi_resource_id,
                    StartTime=s, EndTime=e,
                    Metric="db.load.avg",
                    GroupBy={"Group": sql_group, "Limit": 20},
                    PeriodInSeconds=period)
                use_count = False
            # ... 응답에서 sql_id → {load, count} 매핑 추출
            if sql_map: return sql_map
        except Exception:
            continue
    return {}

바깥 루프는 sql_tokenized → sql 그룹 fallback, 안쪽 try/except 는 AdditionalMetrics 미지원 fallback. 이중 fallback 으로 어떤 엔진에서도 *최소한 그룹 단위 비교* 는 가능.

변화율은 단순합니다. 현재 구간 Load 에서 이전 구간 Load 를 빼고 이전 값으로 나눠 백분율로 만듭니다. 이전 값이 0이면서 현재가 0보다 크면 ORANGE 로, 이전 값이 아예 없으면 NEW 로 분류합니다.

1
2
3
4
5
6
7
8
9
if not no_prev_data:
    if prev is None:
        highlight = "RED"          # 이전 구간엔 없고 현재 구간에 새로 등장
    elif prev["db_load"] > 0:
        change_pct = round((curr["db_load"] - prev["db_load"]) / prev["db_load"] * 100, 1)
        if change_pct >= 50:
            highlight = "ORANGE"   # 50% 이상 급증
    elif prev["db_load"] == 0 and curr["db_load"] > 0:
        highlight = "ORANGE"       # 이전 부하 0 → 현재 부하 발생

화면 디자인이 추가로 한 가지 더 도와 줍니다. 호출 횟수 도 같이 표시한다는 점입니다. DB Load 가 비슷한데 호출 횟수가 급증 했다면 짧고 자주 도는 쿼리 가 늘어난 것이고, 호출 횟수는 비슷한데 Load 가 늘었다쿼리 한 번의 비용이 늘어난 것 (인덱스 효과가 떨어진다거나, 데이터가 늘었다거나) 입니다. 이 두 신호를 같이 두면 왜 비싸졌는지 의 1차 가설이 잡힙니다.

상대 비교 흐름을 그림으로 보면 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
       (지금)                                (과거: 1 / 3 / 7일 전)
        │                                            │
   ┌────┴────┐                                  ┌────┴────┐
   │ current │  ←──── same length range ────→   │  prev   │
   │  range  │                                  │  range  │
   └────┬────┘                                  └────┬────┘
        ↓                                            ↓
  describe_dimension_keys                    describe_dimension_keys
  (current Top SQL)                          (prev Top SQL by SQL ID)
        │                                            │
        └──────────────────┬─────────────────────────┘
                           ↓
          [SQL ID 매칭 + 변화율 / call count diff]
                           ↓
        ┌────────┬──────────────┬──────────────┐
        │  NEW   │ +50% or more │ rate change  │
        └────────┴──────────────┴──────────────┘

실시간 쿼리

세 화면 중에 지금 이 순간 도는 쿼리 만 보고 싶을 때를 위해 최근 N분 (기본 5분) 의 Top SQL 을 보여 주는 보조 패널도 같이 두었습니다. 30초마다 폴링하고 SQL ID 가 부여된 쿼리만 표시합니다.

이 패널은 알람이 울린 직후 가장 자주 봅니다. CW 시계열에서 CPU 가 튀었고 Telegram 으로 알람이 왔다면, 이 패널을 펴서 지금 도는 쿼리 가 무엇인지 곧장 확인하는 흐름입니다. PI 상대 비교가 시간 축으로 보는 도구라면, 이 패널은 지금만 보는 도구입니다.

마무리

세 화면을 다 만들고 한 달쯤 운영해 보니, DBA 가 매일 켜 두는 순서가 자연스럽게 잡혔습니다. 메인 대시보드를 한 번 훑고, 이상한 인스턴스 가 있으면 PI 패널로 들어가고, 왜 어제와 다른지 가 궁금하면 PI 상대 비교로 넘어갑니다. 알람이 울리면 실시간 쿼리 패널을 같이 켭니다.

세 화면을 같이 만들기 잘했다고 느낀 순간은 보통 PI 상대 비교에서 신규 SQL 이 잡혔을 때 였습니다. CW 시계열만 보고 있었으면 왜 CPU 가 올랐는지 까지는 알아도 어떤 쿼리가 새로 들어왔는지 는 한참 더 헤맸을 일이었습니다.

다음 편에서는 이 화면들이 만들어 내는 알람을 시끄럽지 않게 그리고 덜 귀찮게 다루는 관리 기능들 을 풀어 봅니다.

× CW 수집 토글 — 원본 크기
× 개발 DB(is_dev) 지정 화면 — 원본 크기
× PI 상대 비교 — Top SQL — 원본 크기
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.