이미지 추가 필요

khan·2026년 2월 6일

1. 필요성

모니터링은 서버가 문제에 생겼을시, 문제가 어느 지점인지 빠르게 파악하고 해결하기 위해서 진행한다. 예를들어 모니터링의 지표를 확인하여 DB에 병목이 생겨 문제가 생긴 것을 알았다면 쿼리를 최적화를 시켜 개수를 줄이는 방향으로 DB IO 문제를 해결할 수 있다.

  • 유진 수정 MINE 서비스는 음성 입력 → AI 분석 → 피드백 생성까지 하나의 학습 흐름이 길고, 각 단계가 Backend(Spring Boot) · AI(FastAPI) · DB(PostgreSQL) 에 걸쳐 분산되어 있다. 이로 인해 장애나 지연이 발생했을 때 단순히 “느리다 / 안 된다” 수준으로는 문제 지점을 즉시 특정하기 어렵다는 문제가 있었다. 따라서 모니터링의 목적은 다음과 같다.
    • 장애 발생 시 프론트·백엔드·DB·AI 중 어느 계층이 병목인지 즉시 구분

    • “CPU 문제인지 / DB 병목인지 / 쿼리 문제인지”를 지표 기반으로 판단

    • 사용자 학습 흐름(녹음 → 분석 → 피드백)이 끊기지 않도록 선제 대응

      예를 들어,

    • API 응답 시간이 증가했을 때

      → 백엔드 CPU 사용률은 낮고
      
      → DB Connection 수와 Cache Miss 비율이 급증한다면
      
      → **DB 병목으로 판단하고 쿼리/커넥션 풀을 우선 점검**할 수 있다.

      즉, 모니터링은 단순 상태 확인이 아니라

      장애의 원인을 빠르게 좁히기 위한 도구”로 활용하기 위해 구축하였다.

2. 필요한 데이터 지표 설정

  • 모니터링 지표는 다음 기준으로 선정하였다.
    • 실제 장애 상황에서 원인 분리에 도움이 되는가
    • 각 파트(프론트 / 백엔드 / DB)가 “우리 쪽 문제인가?”를 지표로 설명할 수 있는가
    • 학습·대결·챌린지처럼 트래픽이 몰리는 시점의 병목을 파악할 수 있는가

특히 MINE은

  • 실시간 요청(녹음, PvP)
  • AI 분석처럼 처리 시간이 긴 작업

이 혼합된 구조이기 때문에 단순 트래픽 지표보다 “지연 원인을 설명해주는 지표”를 우선적으로 선택했다.

2.1 백엔드

항목왜 필요한가
CPU 사용률특정 시점에 CPU 사용률이 급증하면
→ AI 연동 요청 폭증, 비효율적인 로직, 무한 루프 가능성을 의심API 지연이 서버 연산 문제인지 판단하기 위한 1차 지표
JVM 힙 메모리• AI 결과 처리, 대결 데이터, 피드백 생성 과정에서

• 객체 생성이 많아 GC 지연 또는 OOM 가능성 존재
• 힙 사용량 추이를 통해 → 메모리 누수 / 객체 정리 실패 여부를 확인 |
| HikariCP 커넥션 풀 | • 커넥션 풀이 고갈되면 → API 응답 대기 → 전체 서비스 지연으로 확산
DB 병목의 전조 신호로 활용 |
| Slow Query | • 특정 학습 카드 조회, 대결 기록 조회 시
• 응답 지연의 원인이 쿼리 자체인지 판단“코드 문제 vs DB 문제”를 구분하기 위한 핵심 지표 |

  • cpu : 어떤 프로세스가 cpu를 많이 잡아먹는지 판단 v
  • jvm 힙 : 힙 오버플로우 방지
  • 히카리시피 풀 : 적절한 커넥션 풀 설정으로 인한 응답 지연 방지
  • 슬로우 쿼리 : 사용자 입장 특정 쿼리 개선 필요

2.2 프론트

  • 프론트는 “서버는 정상인데 사용자는 느리다고 느끼는 상황”을 판단하기 위해 체감 성능 지표(Core Web Vitals 중심)로 선정했다.
항목설명
FCP(First Contentful Paint)학습 화면 진입 시 첫 콘텐츠 표시 시점
LCP(Largest Contentful Paint)카드/피드백 등 주요 콘텐츠 로딩 체감
CLS(Cumulative Layout Shift)피드백 카드 렌더링 중 레이아웃 흔들림 여부
TBT(Total Blocking Time)AI 결과 렌더링 시 메인 스레드 블로킹 여부
SI(Speed Index)전체 페이지 로딩 체감 속도

2.4 DB

  • DB 지표는 API 지연 발생 시 원인이 DB인지 여부를 판별하는 핵심 기준으로 사용한다.
항목지표명왜 필요한가
DB Connection (연결 수)pg_stat_activity_count• PostgreSQL은 연결 1개당 프로세스 1개를 사용

• 단일 인스턴스 환경에서 커넥션 급증 시 → 메모리 소모 → 백엔드/AI 서버 OOM 위험
• AI 서버와 백엔드가 동시에 접근하기 때문에커넥션 수는 가장 우선적으로 모니터링 |
| Cache Hit Ratio (캐시 적중률) | pg_stat_database_blks_hit / pg_stat_database_blks_read | • 캐시 적중률이 낮아지면 디스크 I/O 증가→ API 응답 시간 증가 → 학습 흐름 끊김
• 일반적으로 99% 이상을 안정 기준으로 삼아 캐시 효율 저하 시 즉각 원인 분석 |
| Transaction Throughput (트랜잭션 처리량) | pg_stat_database_xact_commit, pg_stat_database_xact_rollback | • 서비스 사용량 증가에 따른 DB 부하를 파악 가능
• 특히 롤백 비율 급증은 애플리케이션 버그, 락 경합, 타임아웃 문제의 신호일 수 있음 |

2.4 AI 서비스

위와같이 문제상황이나 서비스 성능 향상에 필요한 데이터가 필요하다고 판단되었고 해당 데이터 위주로 모니터링 환경 패널들을 구축할 예정이다.

3. 도구 선정

2에서 선정한 리소스에 병목이 혹은 부하가 생기는지 관측하기 위한 매트릭또한 얻어야했고 GUI가 필요하였다.

3.1 매트릭 수집 도구

구분Prometheus ✅CloudWatchDatadog
CPU / 메모리 / 디스크OOO
네트워크OOO
JVM / 힙O (Micrometer, JMX)O
DB 슬로우 쿼리O (Exporter)O
TPSOO
API별 응답 시간OO
힙 메모리OO
비용무료(Open Source)사용량 기반고비용

3.2 GUI 도구

구분Grafana ✅KibanaCloudWatch Dashboard
메트릭 시각화OO
실시간 관측OO
프론트 성능 지표 시각화O
클라우드 리소스 관측OO
백엔드 지표 관측OO
AI/ML 지표 관측O
비용무료(Open Source)Elasticsearch 필요사용량 기반

결론적으로 프로메테우스와 그라파나를 선정하였다.

4. 매트릭 추출

4.1 백엔드

  • 백엔드 매트릭을 acutrator를 사용하여 수집하였다.

image.png

4.1.1 매트릭 분석 예시

API별 평균 응답시간을 계산해보았다.

http_server_requests_seconds_count{application="mine",error="none",exception="none",method="GET",outcome="SUCCESS",status="200",uri="/cards"} 23
http_server_requests_seconds_sum{application="mine",error="none",exception="none",method="GET",outcome="SUCCESS",status="200",uri="/cards"} 0.738544951
  • http_server_requests_seconds_sum : GET /cards API 전체 요청 횟수
  • http_server_requests_seconds_count : GET /cards API 전체 요청에 걸린 초

그라파나 예상 쿼리문은 다음과 같다.

sum by(uri)(http_server_requests_seconds_sum{application="$app_name"}) / sum by(uri)(http_server_requests_seconds_count{application="$app_name"})

위 쿼리문을 수학공식으로 표현하면

평균 응답시간uri=http_server_requests_seconds_sumurihttp_server_requests_seconds_counturi\text{평균 응답시간}_{uri} = \frac{\sum \text{http\_server\_requests\_seconds\_sum}_{uri}}{\sum \text{http\_server\_requests\_seconds\_count}_{uri}}
📁

URI = Uniform Resource Identifier (ex. /server/cards)

  • API별 평균 응답시간은 API별 전체 응답시간에서 API별 전체 응답 횟수로 나눈 것이다.
  • 예를들어 영미, 철수가 뛰는데 1초, 2초가 걸렸다면 둘이 합친 전체시간이 6초라면 이 두사람의 평균 뛰는 시간은 (1+2)/6 = 0.5인 것이다.
  • 이러한 방식으로 방법으로 API별 평균 응답시간을 구할 수 있고 GUI와 연결하여 시각적으로 확인할 예정이다. 추가적으로 그라파나에서 제공되는 템플릿을 활용할 예정이다.

4.1.2 백엔드 서비스가 매트릭 던지는 기능 추가

해당 매트릭을 Spring Boot에서 얻어내기 위한 방식은 다음과 같다.

먼저 Prometheus 기반 메트릭 수집을 위해 Spring Boot 백엔드에 Actuator 및 Micrometer 설정을 추가하였다.

이를 통해 애플리케이션의 상태 정보와 JVM 및 HTTP 요청 관련 메트릭을 Prometheus가 직접 수집할 수 있도록 구성하였다.

  1. Prometheus 메트릭 노출을 위해 Actuator 및 Prometheus Registry 의존성을 추가하였다.
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
  1. application.yml에 Actuator 엔드포인트 노출 및 Prometheus 메트릭 설정을 추가하였다. /actuator/prometheus 엔드포인트를 통해 메트릭을 노출하고, 애플리케이션 식별을 위한 태그를 함께 설정하였다.
# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,prometheus,metrics
      base-path: /actuator
  endpoint:
    health:
      show-details: always
    prometheus:
      enabled: true
  metrics:
    export:
      prometheus:
        enabled: true
    tags:
      application: ${spring.application.name}
  1. 보안 설정에서는 Prometheus 서버가 인증 없이 메트릭을 수집할 수 있도록 Actuator 관련 엔드포인트를 허용하였다.
// SecurityConfig.java
.requestMatchers(
    "/actuator/prometheus",  // Prometheus 메트릭 수집 엔드포인트
    "/actuator/health",      // 헬스체크
    "/actuator/info"         // 애플리케이션 정보
).permitAll()

4.1.3 결론

이를 통해 Prometheus가 /actuator/prometheus 엔드포인트를 주기적으로 스크래핑하여

백엔드 애플리케이션의 상태, JVM 리소스, 요청 처리 메트릭을 수집할 수 있도록 구성하였다.

4.2 프론트

아래 사진을 보면, 매트릭 수집이 정상적으로 수집되는 모습을 볼 수 있다.

해당 매트릭을 수집하기 위해 Next.js 프론트엔드에 Web Vitals 수집 로직을 추가하고, Prometheus가 스크래핑할 수 있는 API 엔드포인트를 구현하였다.

image.png

4.2.1 프론트 매트릭 수집 로직 구현

먼저 클라이언트 단에서는 useReportWebVitals 훅을 활용하여 Web Vitals(FCP, LCP, CLS, INP)를 수집하도록 구성하였다.

// src/shared/lib/WebVitalsTracker.tsx
'use client'

import { useReportWebVitals } from 'next/web-vitals'
import { reportWebVitals as sendMetrics } from './webVitals'

export function WebVitalsTracker() {
  useReportWebVitals(sendMetrics)
  return null
}

수집된 매트릭은 sendBeacon을 우선적으로 사용하여 페이지 이탈 시에도 안정적으로 서버에 전송되도록 하였다.

// src/shared/lib/webVitals.ts
import type { NextWebVitalsMetric } from 'next/app'

export function reportWebVitals(metric: NextWebVitalsMetric) {
  const body = JSON.stringify({
    name: metric.name,
    value: metric.value,
  })

  if (navigator.sendBeacon) {
    navigator.sendBeacon('/api/metrics', body)
  } else {
    fetch('/api/metrics', {
      method: 'POST',
      body,
      keepalive: true,
    })
  }
}

서버 측에서는 prom-clientHistogram을 사용하여 각 Web Vital 지표를 버킷 단위로 집계하였으며, Prometheus가 주기적으로 스크래핑할 수 있도록 GET 엔드포인트를 함께 제공하였다.

// src/app/api/metrics/route.ts
import { NextResponse } from 'next/server'
import { register, Histogram } from 'prom-client'

const fcpHistogram = new Histogram({
  name: 'nextjs_fcp',
  help: 'First Contentful Paint (in ms)',
  buckets: [100, 200, 500, 1000, 1500, 2500, 4000],
})

const histograms: Record<string, Histogram> = {
  FCP: fcpHistogram,
}

export async function POST(request: Request) {
  const { name, value } = await request.json()
  histograms[name]?.observe(value)
  return new NextResponse('Metric received', { status: 202 })
}

export async function GET() {
  return new NextResponse(await register.metrics(), {
    headers: { 'Content-Type': register.contentType },
  })
}

마지막으로 layout.tsx에 트래커 컴포넌트를 추가하여 모든 페이지에서 Web Vitals가 자동으로 수집되도록 적용하였다.

// src/app/layout.tsx
<WebVitalsTracker />

4.2.2 결론

이를 통해 Next.js → Prometheus → Grafana 흐름으로 프론트엔드 성능 지표를 시각화할 수 있는 기반을 구축하였다.

4.3 AI @남는 사람이 진행할 예정

4.4 DB

4.4.1 PostgreSQL 메트릭 수집 아키텍처

  • PostgreSQL 자체는 Prometheus 메트릭을 직접 제공하지 않기 때문에, postgres-exporter를 통해 내부 통계 뷰(pgstat*)를 수집한다.
[ PostgreSQL (host) ]|  (SQL로 상태 조회)
        |
[ postgres-exporter (container) ]|  HTTP /metrics (9187)
        |
[ Prometheus (container) ]|  PromQL query
        |
[ Grafana ]

4.4.2 메트릭 추출 방식

1. 모니터링 전용 DB 계정 생성

  • 운영 DB 보안을 위해 읽기 전용 모니터링 계정을 생성한다.
  • 애플리케이션 계정과 분리하여 권한 최소화 원칙을 적용한다.
# 모니터링 전용 유저 생성
CREATE USER <user> WITH PASSWORD '<pw>';

# 시스템 통계 및 설정 읽기 권한 부여
GRANT pg_monitor TO <user>;

# 특정 DB(mine_project_db) 접속 권한 명시
GRANT CONNECT ON DATABASE mine_project_db TO <user>;
GRANT CONNECT ON DATABASE postgres TO <user>;

2. PostgreSQL 설정 확인 및 수정

  • Postgres가 외부(컨테이너) 접근을 허용하도록 설정한다.
  • pg_hba.conf에 들어가는 네트워크 대역은 아래 명령어로 확인할 수 있다.
    • docker compose exec postgres-exporter sh -lc "ip a; echo '---'; ip route"
# postgresql.conf
listen_addresses = '*'

# pg_hba.conf
host    all    monitor    <Docker 컨테이너 네트워크 대역>    md5

3. 방화벽(UFW) 설정

  • 우리 서버에 UFW 방화벽이 적용되어 있어, Docker 네트워크 접근을 명시적으로 허용한다.
  • 해당 설정이 없을 경우 Exporter는 접속 시도는 하나 타임아웃으로 실패한다.
sudo ufw allow from <Docker 컨테이너 네트워크 대역> to any port 5432 proto tcp

4. postgres-exporter 구성

  • Docker Compose를 통해 postgres-exporter를 실행한다.
  • Exporter는 PostgreSQL 내부 통계 정보를 주기적으로 조회하여 /metrics 엔드포인트로 노출한다.
  postgres-exporter:
    image: prometheuscommunity/postgres-exporter
    extra_hosts:
      - "host.docker.internal:host-gateway"
    ports:
      - "9187:9187"
    environment:
      DATA_SOURCE_NAME: "postgresql://user:pw@host.docker.internal:5432/postgres?sslmode=disable"
      WEB_LISTEN_ADDRESS: ":9187"
    depends_on:
      - prometheus
    networks:
      - monitoring

5. Prometheus 기반 메트릭 수집

💡

promethues.yml 코드 추가 필요

5.1 백엔드

백엔드(Spring Boot)가 /actuator/prometheus엔드포인트로 메트릭을 노출하고, Prometheus가 해당 엔드포인트를 주기적으로 scrape하여 메트릭을 수집하는 것을 확인하였다.

image.png

5.2 프론트

Prometheus가 Next.js에서 노출한 /api/metrics 엔드포인트를 통해 프론트엔드 메트릭을 정상적으로 수집하는 것을 확인하였다.

image.png

5.3 AI

5.4 DB

PostgreSQL은 postgres-exporter를 통해 메트릭을 노출하고, Prometheus가 이를 수집하여 DB 상태 지표를 모니터링한다.

image.png

6. 서비스 별 모니터링 구축

6.1 백엔드

사용법 : http://43.200.201.50:3001/d/admdjqm/mine-dashboard?var-pg_interval=10m&orgId=1&from=2026-02-05T04:58:05.313Z&to=2026-02-05T05:14:21.062Z&timezone=browser&var-application=mine&var-instance=172.17.0.1:443&var-hikaricp=HikariPool-1&var-namespace=&var-memory_pool_heap=$__all&var-memory_pool_nonheap=$__all&var-jvm_memory_pool_heap=$__all&var-jvm_memory_pool_nonheap=$__all&var-version=&var-pg_instance=&var-Database=$__all&var-query0=&var-log_keyword=&var-app_name=mine&refresh=5s ← 들어가서 BE 섹션 클릭

6.1.1 백엔드 Grafana 대시보드 구성

image.png

구분항목내용단위/비고
1. BE 서비스 CPU 사용량System CPU UsageOS 전체 CPU 사용률%
Process CPU Usage백엔드 프로세스 CPU 사용률%
Mean / Last / Max / Min평균 / 마지막 / 최대 / 최소 사용률%
2. API별 평균 응답 속도API 엔드포인트각 API의 평균 응답 시간ms / s
색상 의미초록 → 빨강 : 빠름 → 느림시각화 참고
3. JVM Heapused현재 사용 중인 HeapMiB
committedJVM이 확보한 HeapMiB
maxJVM 설정 최대 HeapMiB
4. 히카리CP 커넥션 풀 사용량Active현재 사용 중인 연결개수
Idle풀에서 대기 중인 연결개수
Pending연결 요청 대기

6.1.2 상세보기

image.png

백엔드 그라파나 전체보기에서 “다른 지표들 더보기 링크”에 들어가면 여러 리소스 상태를 관측할 수 있도록 설정했다.

  • 각 지표에는 description과 해석 기준을 함께 기재하여, 해당 지표가 어떤 것을 의미하는지를 바로 알 수 있디.

image.png

6.2 프론트 - @Halo.won(원현섭)

6.2.1 프론트 Grafana 대시보드 구성

image.png

좌측에는 현재 측정되고 있는 평균값들을 나타내게 하였고 우측에는 지속해서 시간별로 변하는 평균값들을 그래프로 표현하게 하여 프론트 팀원이 시간대별로 해당 지표를 확인하고 성능 개선에 도움이 되게 구성하였다.

6.3 DB

6.3.1 DB Grafana 대시보드 구성

  • 2.4 DB에서 정의한 핵심 지표를 중심으로, DB 병목 지점을 판단하는 데 필요한 추가 지표들을 함께 수집하여 Grafana 대시보드로 시각화하였다.
  • 각 지표에는 description과 해석 기준을 함께 기재하여, DB에 익숙하지 않은 작업자도 “정상 / 이상 상태”를 빠르게 판단할 수 있도록 구성하였다.
  • 본 대시보드는 API 응답 지연 발생 시, DB가 원인인지 여부를 빠르게 판단하는 용도로 사용한다.

image.png

image.png

6.3.2 DB 병목 판단을 위한 주요 지표 정리

  • 2.4 DB에서 정의한 지표를 위의 대시보드에서 찾아보면 아래와 같이 정리할 수 있다.
구분항목내용비고
1. DB ConnectionActive Sessions현재 DB에 연결되어 있는 활성 세션(연결) 수Max Connections에 근접하면 신규 요청이 대기 상태가 되며, API 응답 지연 또는 서비스 중단으로 이어질 수 있음
Max ConnectionsDB가 허용하는 최대 동시 연결 수단일 인스턴스 환경에서 커넥션 수는 메모리 사용량과 직결됨
2. Cache Hit RatioCache Hit Rate요청한 데이터가 메모리(Buffer Cache)에서 바로 조회된 비율일반적으로 99% 이상을 안정 상태로 판단
디스크 I/O가 발생할수록 API 응답 시간이 증가캐시 적중률 하락 시 쿼리 또는 인덱스 점검 대상
3. Transaction ThroughputTransactions (commit / rollback)초당 처리되는 트랜잭션 수 및 성공(commit) / 실패(rollback) 비율rollback 비율이 높을 경우 애플리케이션 에러, 타임아웃, 락 경합을 의심
4. Idle SessionsIdle / Idle in transactionDB에 연결은 되어 있으나 실제 쿼리를 수행하지 않는 세션idle in transaction 상태가 지속되면 커넥션 고갈 위험
5. Lock / DeadlockLock tables / Deadlocks테이블 또는 행 단위 락 발생 여부특정 시점에 급증할 경우 동시성 문제 또는 잘못된 트랜잭션 설계 가능성

7. 트러블 슈팅

7.1 프롬테일 과다 CPU 점유 문제

7.1.1 문제점

현재 EC2 CPU

이름vCPU 코어수
t4g.medium24G
  • 1차 테스트 image.png
  • 2차 테스트 image.png
  • 3차 테스트 image.png

프롬테일이 CPU는 코어 하나 기준 35%이상 잡아먹는 상황 발생하였고 램도 초반에 컨테이너가 띄워질때는 전체의 42%를 잡아먹는 현상이 발생하였다. 4G기준 42%면 1.8G라고 하였을 때, 기본 OS 메모리를 생각하면 4G까지 생각하여야 했다. 따라서 기존 프로덕션 서버 인스턴스와 동일한 , vcpu 2코어 및 Ram 4G, t4g.medium을 선정했다.

7.1.2 원인 파악

큰 용량의 로그파일을 읽는 프롬테일 프로세스 CPU 과다 점유 문제였다.

프로세스cpu(%)메모리(G)
로키115.634 (1.36G)
프롬테일 (4개의 프로세스 총합)784

현재 프롬테일이 78%나 CPU를 먹고있는 상황이라 해당 문제의 원인을 파악할 필요가 있었고 그 문제를 아래 사진의 로그 파일에서 찾았다. 따라서 로그파일을 몇일 주기로 저장해서 프롬테일이 읽을 로그 파일 크기를 줄일지 결정할 필요성이 있었다.

image.png

현재 프롬테일이 읽고 있는 백엔드 로그가 1.8G라는것을 확인하였고 프롬테일 시작시 파일 끝 위치 계산하고 stack_trace가 제한이 8192mb라 읽는 비용이 많이들고 positions이 존재하지 않아 해당 큰 로그파일을 한번에 읽는데 많은 CPU를 사용하게 된다는 문제점 또한 파악하였다.

7.1.3 해결 방법

로그를 하루 단위로 날짜이름으로 저장하기로 결정하였다. 실시간으로 하루의 로그를 프롬테일이 읽어서 실시간 로그를 그라파나를 통해서 개발자들에게 제공하기로 하였고 하루단위로 따로 저장해서 개발자들에게 제공하기로 결정하였다. 하루로 정한 이유는 날짜별로 관리하기 쉽게 하기 위해서였다.

cron + logrotate 조합을 사용하여 일단위로 명령을 실행하게 하였고 logrotage로 로그를 잘랐다.

  • logrotate 설정 파일 생성
    sudo vi /etc/logrotate.d/backend
  • 작성
    /home/ubuntu/mine/backend/shared/logs/backend.log {
        daily
        rotate 14
        missingok
        notifempty
        dateext
        dateformat -%Y%m%d
        olddir /home/ubuntu/mine/backend/shared/logs/save_logs
        copytruncate
        compress
    }
    
  • 테스트
    sudo logrotate -f /etc/logrotate.d/backend
    
  • 결과 image.png

위 결과 사진처럼 로그파일이 날짜이름으로 분리되는 것을 확인하였다.

7.1.4 결과

image.png

그 결과 프롬테일이 CPU를 0.7% 그리고 메모리를 2.1% 사용하고 로키 또한 0.7% 그리고 2.1%씩 차지하는 것을 확인할 수 있었다.

image.png

추가적으로 2026년 2월 4일에 52M의 로그가 쌓인 것을 확인가능하였고 계속해서 크기를 추적하여 인스턴스 디스크 용량 산정에 반영할 예정이다.

7.2 방화벽 설정으로 인한 PostgreSQL 메트릭 수집 실패

7.2.1 문제점

  • Grafana에서 PostgreSQL 대시보드가 No data 로 표시되었다.
  • Prometheus Targets 페이지에서는 postgres-exporter가 UP 상태이나 실제 DB 관련 메트릭(pg_stat_activity_count 등)이 수집되지 않았다.
  • postgres-exporter 로그에서 타임아웃 오류 반복 발생했다.

7.2.2 원인 파악

  • exporter 컨테이너에서 DB 호스트(host.docker.internal:5432)로의 네트워크 연결 타임아웃 발생하는것으로 보아 서버에 설정된 UFW 방화벽문제라는 것을 알았다.
    • Docker bridge 네트워크(<Docker 컨테이너 네트워크 대역>, docker0)에서 들어오는 트래픽이 PostgreSQL 포트(5432)에 대해 허용되지 않아 exporter → DB 연결이 차단되었다.

7.2.3 해결방법

  • Docker 네트워크 대역에서 PostgreSQL 포트 접근 허용했더니 문제가 해결되었다.
    sudo ufw allow from <Docker 컨테이너 네트워크 대역> to any port 5432 proto tcp
  • Prometheus → postgres-exporter → PostgreSQL 연결 정상화 확인
    • /metrics 엔드포인트에서 pg_stat_* 메트릭 노출
    • Grafana PostgreSQL 대시보드에 메트릭 정상 표시

7.3 프로메테우스 컨테이너에서 nextjs/metrics에 접근이 안되는 문제

7.3.1 문제점

curl [http://localhost:3000/api/metrics](http://172.31.39.74:3000/api/metrics) 실행시, 현재 아래와 같은 매트릭을 반환하고 있다.

# HELP nextjs_fcp First Contentful Paint (in ms)

# TYPE nextjs_fcp histogram

nextjs_fcp_bucket{le="100"} 4
nextjs_fcp_bucket{le="200"} 20
nextjs_fcp_bucket{le="500"} 25
nextjs_fcp_bucket{le="1000"} 36
nextjs_fcp_bucket{le="1500"} 36
nextjs_fcp_bucket{le="2500"} 38
nextjs_fcp_bucket{le="4000"} 40
nextjs_fcp_bucket{le="+Inf"} 40
nextjs_fcp_sum 21670.300000071526
nextjs_fcp_count 40

# HELP nextjs_lcp La

하지만 현재 프로메테우스가 컨테이너로 실행되고 있고 어째서인지 로컬호스트에서 실행중인 nextjs가 주는 매트릭에 접근이 안된다.

- job_name: 'nextjs-frontend'
    static_configs:
      - targets: ['172.31.39.74:3000']  # EC2면 자체 IP 사용 가능
    metrics_path: '/api/metrics'

현재 nextjs/metrics에 접근하는 prometheus.yml파일은 위와 같다.

7.3.2 시도

  1. 첫번째 시도 - 실패
- targets: ['172.31.39.74:3000']  # EC2면 자체 IP 사용 가능
  1. 두번째 시도 - 실패
 - targets: ['172.17.0.1:3000'] 

image.png

  1. 세번째 시도 - 실패
 - targets: ['localhost:3000'] 

image.png

7.3.3 해결 방법


서버에 있는 파일 모니터링 코드 원본

  • docker-compose.yml
    services:
      loki:
        image: grafana/loki:latest
        ports:
          - "3100:3100"
        command: -config.file=/etc/loki/local-config.yaml
        volumes:
          - loki-data:/loki
        networks:
          - monitoring
    
      promtail:
        image: grafana/promtail:latest
        volumes:
          - /home/ubuntu/mine/backend/shared/logs:/logs
          - /home/ubuntu/loki-setup/promtail-config.yml:/etc/promtail/config.yml
        command: -config.file=/etc/promtail/config.yml
        networks:
          - monitoring
    
      prometheus:
        image: prom/prometheus:latest
        ports:
          - "9090:9090"
        volumes:
          - /home/ubuntu/loki-setup/prometheus.yml:/etc/prometheus/prometheus.yml
          - prometheus-data:/prometheus
        command:
          - '--config.file=/etc/prometheus/prometheus.yml'
        networks:
          - monitoring
    
      grafana:
        image: grafana/grafana:latest
        ports:
          - "3001:3000"
        environment:
          - GF_SECURITY_ADMIN_PASSWORD=admin
        volumes:
          - grafana-data:/var/lib/grafana
        networks:
          - monitoring
    
      postgres-exporter:
        image: prometheuscommunity/postgres-exporter
        ports:
          - "9187:9187"
        environment:
          DATA_SOURCE_NAME: "postgresql://mine:305dadd728bc1dc07c1a0bde523dba47@mine-db:5432/mine_project_db?sslmode=disable"
          WEB_LISTEN_ADDRESS: ":9187"
        depends_on:
          - prometheus
        networks:
          - monitoring
    
    volumes:
      grafana-data:
      loki-data:
      prometheus-data:
    
    networks:
      monitoring:
        driver: bridge
  • prometheus.yml
    global:
      scrape_interval: 15s
    
    scrape_configs:
      - job_name: 'prometheus'
        static_configs:
          - targets: ['localhost:9090']
      
      - job_name: 'loki'
        static_configs:
          - targets: ['loki:3100']
      
      - job_name: 'promtail'
        static_configs:
          - targets: ['promtail:9080']
    
      - job_name: 'postgres'
        static_configs:
          - targets: ['postgres-exporter:9187']
    
      - job_name: 'backend'
        static_configs:
          - targets: ['172.17.0.1:443']
        scheme: https
        metrics_path: '/server/actuator/prometheus'
        tls_config:
          insecure_skip_verify: true
    
  • promtail-config.yml
    server:
      http_listen_port: 9080
    
    positions:
      filename: /tmp/positions.yaml
    
    clients:
      - url: http://loki:3100/loki/api/v1/push
    
    scrape_configs:
      - job_name: backend
        static_configs:
          - targets:
              - localhost
            labels:
              job: backend
              app: mine
              __path__: /logs/backend.log
        
        pipeline_stages:
          - json:
              expressions:
                timestamp: timestamp
                level: level
                message: message
                logger: logger
                path: path
                method: method
                clientIp: clientIp
                status: status
                userId: userId
                stack_trace: stack_trace
          
          - labels:
              level:
              logger:
              path:
              method:
              status:
              userId:
          
          - timestamp:
              source: timestamp
              format: RFC3339

8. 병목 지점 파악 사례

8.1 상황 발생

  • 서비스 오픈 이후, 일부 사용자 요청에서 간헐적인 오류가 발생
  • 초기에는 일시적인 네트워크 문제로 판단했으나, 동일 시점에 오류 로그가 반복적으로 발생하여 원인 분석을 진행했다.

8.2 백엔드 로그 기반 이상 징후 확인

  • 백엔드 로그 확인 결과, 특정 시점에 카드 생성 API (POST /server/cards) 요청이 비정상적으로 집중되고 있었다.
  • 다수의 요청이 짧은 시간 안에 반복적으로 유입되며, 일부 요청은 정상 처리되지 못하고 실패(499 등)로 종료되는 현상이 확인되었다 → 단순 요청 실패가 아닌, 트래픽 집중으로 인한 병목 가능성을 의심했다.

0205 부하주입 백엔드 499로그.png

8.3 모니터링 지표 기반 교차 분석

로그만으로는 원인을 단정할 수 없기 때문에, 기존에 구축해 둔 모니터링 지표를 함께 확인했다.

8.3.1 백엔드 관점 분석 - @Halo.won(원현섭)

  1. 문제점

가. 트래픽 부하시, 응답속도 저하 발생

image.png

image.png

운영서버 평균 응답속도가 0.4ms로 관측되지만, 부하시에는 최대 약 5초까지 나오는 것을 확인할 수 있었다. 해당이유를 아래 2가지 경우에서 찾아 볼 수 있었다.

나. CPU 코어 수 부족

image.png

용어설명
Load Average[1m]1분동안 CPU에 실행중인 task와 대기중이던 task의 합의 평균
Cpu Core Size연산가능한 vCPU 개수

CPU가 처리해야하는 실행가능한 작업(톰캣 워커 스레드, DB 쿼리 실행중인 비즈니스 로직 스레드 등)은 하나의 CPU에 할당되어야 처리가 된다. 하지만 위와 같은 지표에서 부하가 걸렸을 시, 특정 스레드들을 cpu가 바로 처리가 못하는 상황에서 병목이 생겼였다

image.png

최고치는 17:57:15 시각에 16의 실행가능한 작업들이 남아있었고 cpu를 바로 교체할 수 없기에 해당 문제들을 해결할 수 있는 방법을 지표를 활용해 찾을 예정이다.

  • x image.png
    용어설명
    Used현재 실제로 사용 중인 힙 메모리
    Commitedjvm이 os로 부터 할당받은 메모리 크기
    위 사진은 17:57:15 시각에 힙 중에서 eden에서 살아남은 데이터들이 저장되는 survivor space이다. 사용가능한 힙메모리가 0.5mib 남지않고 그 뒤에는 10mib를 풀로써 어 근데 이게 병목 이유가되나
  1. 해결 방안

가. HikariCP 늘리기

image.png

용어설명
Active현재 애플레케이션에서 사용중인 커넥션 수
idle스레드에 할당 가능한 커넥션 수
pending커넥션 할당 대기중인 스레드 수

위 그림과 표에 따르면 할당가능한 커넥션수(idle)는 10개를 넘어가지 않는데 커넥션 할당 대기중인 스레드 수(pending)가 특정 시점 188까지 오르는 것으로 보인다. 해당 시점에서 스레드는 커넥션을 할당받지 못해 DB 접근을 못하게 되고 해당 과정에서 병목이 생기는 것을 알 수 있었다.

커넥션 객체 증가에 대한 JVM 힙 메모리 할당 트레이드 오프를 생각해보면 현재 부하시, 커넥션 풀 최대 185개 필요하고 여유있게 200까지 놓는다고 하면(현재 10개 생성중), 커넥션 객체를 하나에 4kb 잡아서 200개시 약 0.7mb heap 메모리가 필요하다. 현재 관측된 바로는 jvm이 힙을 Os로부터 최대 1G , 사전 확보 256mb 이므로 0.7mb정도는 트레이드 오프관점에서 괜찮다고 판단하였다.

따라서 Spring boot에서 HikariCp 커넥션 풀을 늘리는 방법을 진행한다.

옵션명설명기본값권장 설정
maximum-pool-size풀에서 동시에 사용할 수 있는 최대 커넥션 수10200
minimum-idle유지할 최소 유휴 커넥션 수1010
idle-timeout유휴 커넥션을 유지하는 시간 (ms)600000300000
max-lifetime커넥션의 최대 수명 (ms)18000001800000
connection-timeout커넥션을 얻기까지 대기하는 최대 시간 (ms)3000030000

application.properties를 아래와 같이 변경한다.

spring.datasource.hikari.maximum-pool-size=50
spring.datasource.hikari.minimum-idle=10
spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=1800000
spring.datasource.hikari.connection-timeout=30000

application.yml일 경우는 아래와 같다.

spring:
  datasource:
    hikari:
      maximum-pool-size: 50
      minimum-idle: 10
      idle-timeout: 300000
      max-lifetime: 1800000
      connection-timeout: 30000
  1. 결론

주된 원인으로는 HikariCp 커넥션 풀 개수가 10개라서 DB쿼리가 해당 요청을 처리 못하는 것으로 나왔다. 이 때문에 IO작업을 대기하는 작업들이 생겨나고 CPU가 처리해야하지만 하지 못하는 작업들이 존재함에따라 CPU부하가 생겨나는 것이였다.

따라서 일단 HikariCp 커넥션 풀 개수를 50개로 늘려보고 부하를 한번더 주입하여 테스트 하기로 하였다.

  1. 기타

아래 링크는 부하가 걸렸을 때, 그라파나 스냅샷이다.

https://snapshots.raintank.io/dashboard/snapshot/cRALkG5IhEPJ51JbnGQv1Ik4GHVjyxkn?orgId=0&from=2026-02-05T08:40:00.000Z&to=2026-02-05T09:20:00.000Z&timezone=browser&var-application=mine&var-instance=172.17.0.1:443&var-hikaricp=HikariPool-1&var-memory_pool_heap=$__all&var-memory_pool_nonheap=$__all&refresh=5s

8.3.2 DB 관점 분석

  1. DB CPU와 Memory

    image.png

    • CPU 사용량이 최대치(100%)를 계속 점유하지 않았다는 것은, 실행된 SQL 쿼리들이 복잡한 연산(CPU Bound)을 필요로 하기보다는 대기(Wait)하는 시간이 더 많았기 때문에, 시스템의 물리적 한계치에 도달하지 않았다는 것을 알 수 있었다.
    • 전체 메모리 점유율이 매우 낮은것으로보아, 메모리 부족으로 인해 DB가 강제 종료되거나 스왑(Swap)이 발생하여 느려진 것도 아닌것으로 판단했다.
  2. 커넥션과 세션 상태

    image.png

    image.png

  • Active sessions (활성 세션 급증): 평소 0~1 수준이던 활성 세션이 특정 시점에 최대 9개(Max 9)까지 수직 상승하는 것을 보아 이는 DB가 요청을 처리하는 속도보다 새로운 요청이 들어오는 속도가 더 빠르다는 것을 의미한다. 순간 DB가 동시에 9개의 쿼리를 처리하느라 풀 가동 중이었다는 뜻이다.
  • Idle sessions (대기 세션): Idle in transaction 상태의 세션이 관찰되는데, 이는 애플리케이션이 DB에 연결을 열어놓고 쿼리를 실행한 뒤, 트랜잭션을 제대로 닫지(Commit/Rollback) 않고 붙잡고 있다는 신호입니다. 이 세션들이 커넥션 풀을 점유하여 병목을 만든다고 판단할 수 있다.
  • 결론
    • 일부 세션이 Idle in transaction 상태로 들어가며 DB 자원을 점유한 채 응답을 안 줌.
    • 남은 자원으로 요청을 처리하려다 보니 Active sessions가 9까지 치솟으며 부하 발생.
    • 트랜잭션과 락을 오래 붙잡는 요청이 누적되면서 DB 응답을 기다리는 백엔드 요청이 쌓였고, 그 결과 백엔드 워커 스레드가 고갈되어 타임아웃이 발생했다고 판단했다.
  1. 락(Lock) 경합 발생

    image.png

  • RowExclusiveLock의 의미: "나 지금 쓰는 중이야!"
    • 그래프에서 가장 눈에 띄는 빨간색 계열의 rowexclusivelock은 주로 INSERT, UPDATE, DELETE와 같은 쓰기 작업을 할 때 자연스럽게 발생한다. 다만, 동시에 너무 많이 생긴것이 문제이다.
      • 상황 해석: 카드를 생성하는 POST 요청이 들어오면 DB는 해당 데이터를 테이블에 넣기 위해 행(Row) 단위로 잠금을 건다.
      • 병목 지점: 수백 개의 API 요청이 동시에 테이블에 쓰기작업을 하러 RowExclusiveLock을 요청하면서 DB가 이 순서를 처리하느라 부하가 급증한 것으로 판단된다.
  • AccessShareLock의 동반 상승: "나도 좀 읽자!"
    • 노란색 그래프인 accesssharelock 수치도 498까지 높게 올라가있는 것을 볼 수 있고, 이는 보통 SELECT 쿼리를 실행할 때 발생한다.
      • 상황 해석: 카드 생성(INSERT)만 일어나는 게 아니라, 생성 직후에 "잘 생성됐는지 확인"하거나 "전체 카드 목록을 다시 불러오는" 등의 조회 작업이 동시에 몰렸을 수 있겠다고 판단했다.
      • 경합 발생: PostgreSQL에서 읽기(AccessShare)와 쓰기(RowExclusive)는 서로를 직접적으로 막지는 않지만, 너무 많은 요청이 한꺼번에 몰리면 CPU와 메모리 자원을 나눠 쓰느라 전체적인 처리 속도가 느려진 것이 아닌가 하는 생각이 들었다.
  • RowShareLock: "이 데이터 건들지 마"
    • rowsharelock 수치(365)도 굉장이 높은데, 이는 보통 SELECT ... FOR SHARE 같은 쿼리나 외래 키(Foreign Key) 제약 조건을 확인할 때 발생한다.
      • 결정적 원인: 카드 생성 시 유저 ID나 카테고리 ID 같은 외래 키를 참조하고 있다보니, DB는 부모 테이블의 데이터가 지워지지 않도록 락을 건것으로 보인다.
      • 결과: 수많은 카드 생성 요청이 부모 테이블(유저 테이블)의 특정 행을 동시에 참조하려고 시도하면서 여기서도 대기열이 발생한 것으로 볼수도 있을 것 같다.
  • 결론 : DB가 죽은 게 아니라, 줄이 너무 긴 상태라고 판단했다.
    • 현재 락 그래프가 0이다가 특정 시점에 수직으로 솟구친 것은 DB 하드웨어의 한계보다는 '동시성 제어'에서 병목이 온 것 같다.
    • exclusive lock처럼 테이블 전체를 꽉 막아버리는 락은 없지만, 자잘한 로우 락(Row Lock)들이 수천 개가 얽히면서 DB 세션이 포화 상태가 되어 API 응답 시간이 길어지고, 결국 백엔드 서버의 커넥션 풀이 다 차버려서 서비스가 "터지는" 현상으로 이어진 것 같다.

DB 관점 최종 결론

  • DB CPU, 메모리, 커넥션 수치는 모두 임계치에 도달하지 않았으며, DB 하드웨어 또는 쿼리 성능 자체가 병목이 된 상황은 아니었다.
  • 다만 특정 시점에 카드 생성 API 요청이 대량으로 유입되며, INSERT + SELECT가 혼합된 트랜잭션이 동시에 실행되었다.
  • 이 과정에서 다수의 세션이 Idle in transaction 상태로 전환되었고, RowExclusiveLock, RowShareLock 등 다수의 row-level lock이 단시간에 집중되었다.
  • 결과적으로 DB는 정상 동작 중이었으나, 동시성 제어 비용과 트랜잭션 유지 시간 증가로 응답 지연이 발생하였고, 이 지연이 누적되며 백엔드 요청 타임아웃으로 이어졌다.

8.4 병목 지점 결론

CPU 부하가 걸리는 이유중에 백엔드에서는 DB 커넥션이 부족해 대기 작업이 많아질수록, 대기 스레드들이 깨어나 풀 상태를 계속 확인하게 된다. 이 작업에서 작동하는 루프나 로직 및 Lock을 획득하는 과정에서 CPU를 사용하게된다. 따라서 HikariCP 커넥션 풀을 10개에서 200개로 증가할 예정이고 추가 부하테스트를 진행하여 다른 병목지점을 찾을 예정이다.

0개의 댓글