모니터링은 서버가 문제에 생겼을시, 문제가 어느 지점인지 빠르게 파악하고 해결하기 위해서 진행한다. 예를들어 모니터링의 지표를 확인하여 DB에 병목이 생겨 문제가 생긴 것을 알았다면 쿼리를 최적화를 시켜 개수를 줄이는 방향으로 DB IO 문제를 해결할 수 있다.
장애 발생 시 프론트·백엔드·DB·AI 중 어느 계층이 병목인지 즉시 구분
“CPU 문제인지 / DB 병목인지 / 쿼리 문제인지”를 지표 기반으로 판단
사용자 학습 흐름(녹음 → 분석 → 피드백)이 끊기지 않도록 선제 대응
예를 들어,
API 응답 시간이 증가했을 때
→ 백엔드 CPU 사용률은 낮고
→ DB Connection 수와 Cache Miss 비율이 급증한다면
→ **DB 병목으로 판단하고 쿼리/커넥션 풀을 우선 점검**할 수 있다.
즉, 모니터링은 단순 상태 확인이 아니라
“장애의 원인을 빠르게 좁히기 위한 도구”로 활용하기 위해 구축하였다.
특히 MINE은
이 혼합된 구조이기 때문에 단순 트래픽 지표보다 “지연 원인을 설명해주는 지표”를 우선적으로 선택했다.
| 항목 | 왜 필요한가 |
|---|---|
| CPU 사용률 | 특정 시점에 CPU 사용률이 급증하면 |
| → AI 연동 요청 폭증, 비효율적인 로직, 무한 루프 가능성을 의심API 지연이 서버 연산 문제인지 판단하기 위한 1차 지표 | |
| JVM 힙 메모리 | • AI 결과 처리, 대결 데이터, 피드백 생성 과정에서 |
• 객체 생성이 많아 GC 지연 또는 OOM 가능성 존재
• 힙 사용량 추이를 통해 → 메모리 누수 / 객체 정리 실패 여부를 확인 |
| HikariCP 커넥션 풀 | • 커넥션 풀이 고갈되면 → API 응답 대기 → 전체 서비스 지연으로 확산
• DB 병목의 전조 신호로 활용 |
| Slow Query | • 특정 학습 카드 조회, 대결 기록 조회 시
• 응답 지연의 원인이 쿼리 자체인지 판단“코드 문제 vs DB 문제”를 구분하기 위한 핵심 지표 |
| 항목 | 설명 |
|---|---|
| FCP(First Contentful Paint) | 학습 화면 진입 시 첫 콘텐츠 표시 시점 |
| LCP(Largest Contentful Paint) | 카드/피드백 등 주요 콘텐츠 로딩 체감 |
| CLS(Cumulative Layout Shift) | 피드백 카드 렌더링 중 레이아웃 흔들림 여부 |
| TBT(Total Blocking Time) | AI 결과 렌더링 시 메인 스레드 블로킹 여부 |
| SI(Speed Index) | 전체 페이지 로딩 체감 속도 |
| 항목 | 지표명 | 왜 필요한가 |
|---|---|---|
| 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에서 선정한 리소스에 병목이 혹은 부하가 생기는지 관측하기 위한 매트릭또한 얻어야했고 GUI가 필요하였다.
| 구분 | Prometheus ✅ | CloudWatch | Datadog |
|---|---|---|---|
| CPU / 메모리 / 디스크 | O | O | O |
| 네트워크 | O | O | O |
| JVM / 힙 | O (Micrometer, JMX) | △ | O |
| DB 슬로우 쿼리 | O (Exporter) | △ | O |
| TPS | O | △ | O |
| API별 응답 시간 | O | △ | O |
| 힙 메모리 | O | △ | O |
| 비용 | 무료(Open Source) | 사용량 기반 | 고비용 |
| 구분 | Grafana ✅ | Kibana | CloudWatch Dashboard |
|---|---|---|---|
| 메트릭 시각화 | O | △ | O |
| 실시간 관측 | O | O | △ |
| 프론트 성능 지표 시각화 | O | △ | △ |
| 클라우드 리소스 관측 | O | △ | O |
| 백엔드 지표 관측 | O | O | △ |
| AI/ML 지표 관측 | O | △ | △ |
| 비용 | 무료(Open Source) | Elasticsearch 필요 | 사용량 기반 |
결론적으로 프로메테우스와 그라파나를 선정하였다.
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
/cards API 전체 요청 횟수/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 = Uniform Resource Identifier (ex. /server/cards)
해당 매트릭을 Spring Boot에서 얻어내기 위한 방식은 다음과 같다.
먼저 Prometheus 기반 메트릭 수집을 위해 Spring Boot 백엔드에 Actuator 및 Micrometer 설정을 추가하였다.
이를 통해 애플리케이션의 상태 정보와 JVM 및 HTTP 요청 관련 메트릭을 Prometheus가 직접 수집할 수 있도록 구성하였다.
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
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}
// SecurityConfig.java
.requestMatchers(
"/actuator/prometheus", // Prometheus 메트릭 수집 엔드포인트
"/actuator/health", // 헬스체크
"/actuator/info" // 애플리케이션 정보
).permitAll()
이를 통해 Prometheus가 /actuator/prometheus 엔드포인트를 주기적으로 스크래핑하여
백엔드 애플리케이션의 상태, JVM 리소스, 요청 처리 메트릭을 수집할 수 있도록 구성하였다.
아래 사진을 보면, 매트릭 수집이 정상적으로 수집되는 모습을 볼 수 있다.
해당 매트릭을 수집하기 위해 Next.js 프론트엔드에 Web Vitals 수집 로직을 추가하고, Prometheus가 스크래핑할 수 있는 API 엔드포인트를 구현하였다.
먼저 클라이언트 단에서는 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-client의 Histogram을 사용하여 각 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 />
이를 통해 Next.js → Prometheus → Grafana 흐름으로 프론트엔드 성능 지표를 시각화할 수 있는 기반을 구축하였다.
[ PostgreSQL (host) ]
↑
| (SQL로 상태 조회)
|
[ postgres-exporter (container) ]
↑
| HTTP /metrics (9187)
|
[ Prometheus (container) ]
↑
| PromQL query
|
[ Grafana ]
# 모니터링 전용 유저 생성
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>;
pg_hba.conf에 들어가는 네트워크 대역은 아래 명령어로 확인할 수 있다.# postgresql.conf
listen_addresses = '*'
# pg_hba.conf
host all monitor <Docker 컨테이너 네트워크 대역> md5
sudo ufw allow from <Docker 컨테이너 네트워크 대역> to any port 5432 proto tcp
/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
promethues.yml 코드 추가 필요
백엔드(Spring Boot)가 /actuator/prometheus엔드포인트로 메트릭을 노출하고, Prometheus가 해당 엔드포인트를 주기적으로 scrape하여 메트릭을 수집하는 것을 확인하였다.
Prometheus가 Next.js에서 노출한 /api/metrics 엔드포인트를 통해 프론트엔드 메트릭을 정상적으로 수집하는 것을 확인하였다.
PostgreSQL은 postgres-exporter를 통해 메트릭을 노출하고, Prometheus가 이를 수집하여 DB 상태 지표를 모니터링한다.
| 구분 | 항목 | 내용 | 단위/비고 |
|---|---|---|---|
| 1. BE 서비스 CPU 사용량 | System CPU Usage | OS 전체 CPU 사용률 | % |
| Process CPU Usage | 백엔드 프로세스 CPU 사용률 | % | |
| Mean / Last / Max / Min | 평균 / 마지막 / 최대 / 최소 사용률 | % | |
| 2. API별 평균 응답 속도 | API 엔드포인트 | 각 API의 평균 응답 시간 | ms / s |
| 색상 의미 | 초록 → 빨강 : 빠름 → 느림 | 시각화 참고 | |
| 3. JVM Heap | used | 현재 사용 중인 Heap | MiB |
| committed | JVM이 확보한 Heap | MiB | |
| max | JVM 설정 최대 Heap | MiB | |
| 4. 히카리CP 커넥션 풀 사용량 | Active | 현재 사용 중인 연결 | 개수 |
| Idle | 풀에서 대기 중인 연결 | 개수 | |
| Pending | 연결 요청 대기 | 개 |
백엔드 그라파나 전체보기에서 “다른 지표들 더보기 링크”에 들어가면 여러 리소스 상태를 관측할 수 있도록 설정했다.
좌측에는 현재 측정되고 있는 평균값들을 나타내게 하였고 우측에는 지속해서 시간별로 변하는 평균값들을 그래프로 표현하게 하여 프론트 팀원이 시간대별로 해당 지표를 확인하고 성능 개선에 도움이 되게 구성하였다.
| 구분 | 항목 | 내용 | 비고 |
|---|---|---|---|
| 1. DB Connection | Active Sessions | 현재 DB에 연결되어 있는 활성 세션(연결) 수 | Max Connections에 근접하면 신규 요청이 대기 상태가 되며, API 응답 지연 또는 서비스 중단으로 이어질 수 있음 |
| Max Connections | DB가 허용하는 최대 동시 연결 수 | 단일 인스턴스 환경에서 커넥션 수는 메모리 사용량과 직결됨 | |
| 2. Cache Hit Ratio | Cache Hit Rate | 요청한 데이터가 메모리(Buffer Cache)에서 바로 조회된 비율 | 일반적으로 99% 이상을 안정 상태로 판단 |
| 디스크 I/O가 발생할수록 API 응답 시간이 증가 | 캐시 적중률 하락 시 쿼리 또는 인덱스 점검 대상 | ||
| 3. Transaction Throughput | Transactions (commit / rollback) | 초당 처리되는 트랜잭션 수 및 성공(commit) / 실패(rollback) 비율 | rollback 비율이 높을 경우 애플리케이션 에러, 타임아웃, 락 경합을 의심 |
| 4. Idle Sessions | Idle / Idle in transaction | DB에 연결은 되어 있으나 실제 쿼리를 수행하지 않는 세션 | idle in transaction 상태가 지속되면 커넥션 고갈 위험 |
| 5. Lock / Deadlock | Lock tables / Deadlocks | 테이블 또는 행 단위 락 발생 여부 | 특정 시점에 급증할 경우 동시성 문제 또는 잘못된 트랜잭션 설계 가능성 |
현재 EC2 CPU
| 이름 | vCPU 코어수 | 램 |
|---|---|---|
| t4g.medium | 2 | 4G |
프롬테일이 CPU는 코어 하나 기준 35%이상 잡아먹는 상황 발생하였고 램도 초반에 컨테이너가 띄워질때는 전체의 42%를 잡아먹는 현상이 발생하였다. 4G기준 42%면 1.8G라고 하였을 때, 기본 OS 메모리를 생각하면 4G까지 생각하여야 했다. 따라서 기존 프로덕션 서버 인스턴스와 동일한 , vcpu 2코어 및 Ram 4G, t4g.medium을 선정했다.
큰 용량의 로그파일을 읽는 프롬테일 프로세스 CPU 과다 점유 문제였다.
| 프로세스 | cpu(%) | 메모리(G) |
|---|---|---|
| 로키1 | 15.6 | 34 (1.36G) |
| 프롬테일 (4개의 프로세스 총합) | 78 | 4 |
현재 프롬테일이 78%나 CPU를 먹고있는 상황이라 해당 문제의 원인을 파악할 필요가 있었고 그 문제를 아래 사진의 로그 파일에서 찾았다. 따라서 로그파일을 몇일 주기로 저장해서 프롬테일이 읽을 로그 파일 크기를 줄일지 결정할 필요성이 있었다.
현재 프롬테일이 읽고 있는 백엔드 로그가 1.8G라는것을 확인하였고 프롬테일 시작시 파일 끝 위치 계산하고 stack_trace가 제한이 8192mb라 읽는 비용이 많이들고 positions이 존재하지 않아 해당 큰 로그파일을 한번에 읽는데 많은 CPU를 사용하게 된다는 문제점 또한 파악하였다.
로그를 하루 단위로 날짜이름으로 저장하기로 결정하였다. 실시간으로 하루의 로그를 프롬테일이 읽어서 실시간 로그를 그라파나를 통해서 개발자들에게 제공하기로 하였고 하루단위로 따로 저장해서 개발자들에게 제공하기로 결정하였다. 하루로 정한 이유는 날짜별로 관리하기 쉽게 하기 위해서였다.
cron + logrotate 조합을 사용하여 일단위로 명령을 실행하게 하였고 logrotage로 로그를 잘랐다.
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
위 결과 사진처럼 로그파일이 날짜이름으로 분리되는 것을 확인하였다.
그 결과 프롬테일이 CPU를 0.7% 그리고 메모리를 2.1% 사용하고 로키 또한 0.7% 그리고 2.1%씩 차지하는 것을 확인할 수 있었다.
추가적으로 2026년 2월 4일에 52M의 로그가 쌓인 것을 확인가능하였고 계속해서 크기를 추적하여 인스턴스 디스크 용량 산정에 반영할 예정이다.
No data 로 표시되었다.postgres-exporter가 UP 상태이나 실제 DB 관련 메트릭(pg_stat_activity_count 등)이 수집되지 않았다.postgres-exporter 로그에서 타임아웃 오류 반복 발생했다.host.docker.internal:5432)로의 네트워크 연결 타임아웃 발생하는것으로 보아 서버에 설정된 UFW 방화벽문제라는 것을 알았다.docker0)에서 들어오는 트래픽이 PostgreSQL 포트(5432)에 대해 허용되지 않아 exporter → DB 연결이 차단되었다.sudo ufw allow from <Docker 컨테이너 네트워크 대역> to any port 5432 proto tcp/metrics 엔드포인트에서 pg_stat_* 메트릭 노출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파일은 위와 같다.
- targets: ['172.31.39.74:3000'] # EC2면 자체 IP 사용 가능
- targets: ['172.17.0.1:3000']
- targets: ['localhost:3000']
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: bridgeglobal:
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
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: RFC3339POST /server/cards) 요청이 비정상적으로 집중되고 있었다.로그만으로는 원인을 단정할 수 없기 때문에, 기존에 구축해 둔 모니터링 지표를 함께 확인했다.
가. 트래픽 부하시, 응답속도 저하 발생
운영서버 평균 응답속도가 0.4ms로 관측되지만, 부하시에는 최대 약 5초까지 나오는 것을 확인할 수 있었다. 해당이유를 아래 2가지 경우에서 찾아 볼 수 있었다.
나. CPU 코어 수 부족
| 용어 | 설명 |
|---|---|
| Load Average[1m] | 1분동안 CPU에 실행중인 task와 대기중이던 task의 합의 평균 |
| Cpu Core Size | 연산가능한 vCPU 개수 |
CPU가 처리해야하는 실행가능한 작업(톰캣 워커 스레드, DB 쿼리 실행중인 비즈니스 로직 스레드 등)은 하나의 CPU에 할당되어야 처리가 된다. 하지만 위와 같은 지표에서 부하가 걸렸을 시, 특정 스레드들을 cpu가 바로 처리가 못하는 상황에서 병목이 생겼였다
최고치는 17:57:15 시각에 16의 실행가능한 작업들이 남아있었고 cpu를 바로 교체할 수 없기에 해당 문제들을 해결할 수 있는 방법을 지표를 활용해 찾을 예정이다.
| 용어 | 설명 |
|---|---|
| Used | 현재 실제로 사용 중인 힙 메모리 |
| Commited | jvm이 os로 부터 할당받은 메모리 크기 |
가. HikariCP 늘리기
| 용어 | 설명 |
|---|---|
| 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 | 풀에서 동시에 사용할 수 있는 최대 커넥션 수 | 10 | 200 |
minimum-idle | 유지할 최소 유휴 커넥션 수 | 10 | 10 |
idle-timeout | 유휴 커넥션을 유지하는 시간 (ms) | 600000 | 300000 |
max-lifetime | 커넥션의 최대 수명 (ms) | 1800000 | 1800000 |
connection-timeout | 커넥션을 얻기까지 대기하는 최대 시간 (ms) | 30000 | 30000 |
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
주된 원인으로는 HikariCp 커넥션 풀 개수가 10개라서 DB쿼리가 해당 요청을 처리 못하는 것으로 나왔다. 이 때문에 IO작업을 대기하는 작업들이 생겨나고 CPU가 처리해야하지만 하지 못하는 작업들이 존재함에따라 CPU부하가 생겨나는 것이였다.
따라서 일단 HikariCp 커넥션 풀 개수를 50개로 늘려보고 부하를 한번더 주입하여 테스트 하기로 하였다.
아래 링크는 부하가 걸렸을 때, 그라파나 스냅샷이다.
DB CPU와 Memory
커넥션과 세션 상태
Idle in transaction 상태의 세션이 관찰되는데, 이는 애플리케이션이 DB에 연결을 열어놓고 쿼리를 실행한 뒤, 트랜잭션을 제대로 닫지(Commit/Rollback) 않고 붙잡고 있다는 신호입니다. 이 세션들이 커넥션 풀을 점유하여 병목을 만든다고 판단할 수 있다.Idle in transaction 상태로 들어가며 DB 자원을 점유한 채 응답을 안 줌.Active sessions가 9까지 치솟으며 부하 발생.락(Lock) 경합 발생
RowExclusiveLock의 의미: "나 지금 쓰는 중이야!"rowexclusivelock은 주로 INSERT, UPDATE, DELETE와 같은 쓰기 작업을 할 때 자연스럽게 발생한다. 다만, 동시에 너무 많이 생긴것이 문제이다.POST 요청이 들어오면 DB는 해당 데이터를 테이블에 넣기 위해 행(Row) 단위로 잠금을 건다.RowExclusiveLock을 요청하면서 DB가 이 순서를 처리하느라 부하가 급증한 것으로 판단된다.AccessShareLock의 동반 상승: "나도 좀 읽자!"accesssharelock 수치도 498까지 높게 올라가있는 것을 볼 수 있고, 이는 보통 SELECT 쿼리를 실행할 때 발생한다.INSERT)만 일어나는 게 아니라, 생성 직후에 "잘 생성됐는지 확인"하거나 "전체 카드 목록을 다시 불러오는" 등의 조회 작업이 동시에 몰렸을 수 있겠다고 판단했다.AccessShare)와 쓰기(RowExclusive)는 서로를 직접적으로 막지는 않지만, 너무 많은 요청이 한꺼번에 몰리면 CPU와 메모리 자원을 나눠 쓰느라 전체적인 처리 속도가 느려진 것이 아닌가 하는 생각이 들었다.RowShareLock: "이 데이터 건들지 마"rowsharelock 수치(365)도 굉장이 높은데, 이는 보통 SELECT ... FOR SHARE 같은 쿼리나 외래 키(Foreign Key) 제약 조건을 확인할 때 발생한다.exclusive lock처럼 테이블 전체를 꽉 막아버리는 락은 없지만, 자잘한 로우 락(Row Lock)들이 수천 개가 얽히면서 DB 세션이 포화 상태가 되어 API 응답 시간이 길어지고, 결국 백엔드 서버의 커넥션 풀이 다 차버려서 서비스가 "터지는" 현상으로 이어진 것 같다.DB 관점 최종 결론
CPU 부하가 걸리는 이유중에 백엔드에서는 DB 커넥션이 부족해 대기 작업이 많아질수록, 대기 스레드들이 깨어나 풀 상태를 계속 확인하게 된다. 이 작업에서 작동하는 루프나 로직 및 Lock을 획득하는 과정에서 CPU를 사용하게된다. 따라서 HikariCP 커넥션 풀을 10개에서 200개로 증가할 예정이고 추가 부하테스트를 진행하여 다른 병목지점을 찾을 예정이다.