홈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

소개JSON Formatter개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

소개|JSON Formatter|개인정보처리방침|이용약관

k6와 실시간 Pool 모니터링으로 시스템 한계점 찾기

정기창·2026년 1월 10일

들어가며

이전 글에서 Docker로 Production 환경을 재현했습니다. 이제 실제로 부하를 발생시키고 시스템의 한계점을 찾을 차례입니다.

이번 글에서 다루는 내용:

  • k6로 Breakpoint 테스트 스크립트 작성
  • NestJS에서 Connection Pool 모니터링 서비스 구현
  • 실시간 통합 모니터링 대시보드 구성
  • 결과 분석과 최적 RPS 도출
  • acquireTimeoutMillis 튜닝과 Fail-Fast 전략
  • 인프라 의사결정: 스케일업 vs 스케일 아웃

※ 이 글의 코드와 설정값은 실제 환경을 기반으로 일반화한 예시입니다. 프로젝트 상황에 맞게 조정하여 활용하세요.

k6 Breakpoint 테스트

왜 Breakpoint 테스트인가

부하테스트에는 여러 유형이 있습니다. Smoke, Load, Stress, Spike 등. 이번에는 Breakpoint 테스트를 선택했습니다.

부하테스트 유형 비교

Smoke Test
└─ 최소 부하로 기본 동작 확인

Load Test
└─ 예상 트래픽 수준에서 성능 측정

Stress Test
└─ 예상 이상의 부하에서 동작 확인

Breakpoint Test ← 선택
└─ 부하를 점진적으로 증가시켜 한계점 탐색

Breakpoint 테스트의 목적은 "시스템이 언제 무너지는가"를 찾는 것입니다. 부하를 계속 증가시키다가 에러율이 급증하거나 응답 시간이 급격히 늘어나는 지점이 바로 시스템의 한계점입니다.

테스트 시나리오 설계

30초마다 RPS(Requests Per Second)를 10씩 증가시키는 시나리오를 작성했습니다.

// k6 스크립트
import http from 'k6/http';
import { check, sleep } from 'k6';

export const options = {
  scenarios: {
    breakpoint: {
      executor: 'ramping-arrival-rate',
      startRate: 10,
      timeUnit: '1s',
      preAllocatedVUs: 300,
      maxVUs: 600,
      stages: [
        { duration: '30s', target: 10 },
        { duration: '30s', target: 20 },
        { duration: '30s', target: 30 },
        { duration: '30s', target: 50 },
        { duration: '30s', target: 80 },
        { duration: '30s', target: 100 },
        { duration: '30s', target: 150 },
        { duration: '30s', target: 200 },
        { duration: '30s', target: 0 },  // Cool-down
      ],
    },
  },
  thresholds: {
    http_req_duration: ['p(95)<15000'],  // 95% 요청이 15초 이내
    http_req_failed: ['rate<0.5'],       // 에러율 50% 미만
  },
};

ramping-arrival-rate executor를 사용하면 초당 요청 수를 정확히 제어할 수 있습니다. VU(Virtual User) 수가 아니라 RPS를 기준으로 부하를 조절하는 것이 더 직관적입니다.

실제 비즈니스 시나리오 구현

단순히 API를 호출하는 것이 아니라, 실제 주문 생성 플로우를 시뮬레이션했습니다.

// 요청 패턴 (예시 비율)
const PATTERNS = {
  single: 0.5,      // 단일 항목 50%
  multiple: 0.3,    // 복수 항목 30%
  duplicate: 0.2,   // 동일 항목 중복 20%
};

function generatePayload() {
  const pattern = selectPattern();
  const items = generateItems(pattern);
  
  return {
    items: items.map(item => ({
      itemId: item.id,
      variantId: item.variant,
      quantity: item.quantity,
    })),
    // ... 기타 요청 정보
  };
}

export default function() {
  const payload = generatePayload();
  
  const res = http.post(
    `${BASE_URL}/transactions`,
    JSON.stringify(payload),
    { headers: { 'Content-Type': 'application/json' } }
  );
  
  check(res, {
    'status is 201': (r) => r.status === 201,
    'has transaction id': (r) => r.json('transactionId') !== undefined,
  });
}

실제 사용자 행동 패턴을 반영해서 테스트의 현실성을 높였습니다. 위 비율은 예시이며, 실제 서비스의 사용 패턴에 맞게 조정해야 합니다.

Connection Pool 모니터링 서비스

왜 Pool 모니터링이 필요한가

부하테스트 중 성능이 저하되면 원인을 파악해야 합니다. CPU 부족인지, 메모리 부족인지, 아니면 DB Connection Pool이 고갈된 것인지. Pool 상태를 실시간으로 확인할 수 있어야 정확한 진단이 가능합니다.

PoolMonitorService 구현

NestJS 서비스로 Pool 상태를 조회하는 기능을 구현했습니다.

// pool-monitor.service.ts
import { Injectable } from '@nestjs/common';

export interface PoolStats {
  database: string;
  connectionType: 'read' | 'write';
  used: number;
  free: number;
  pendingAcquires: number;
  maxSize: number;
  utilization: number;  // 사용률 (%)
}

@Injectable()
export class PoolMonitorService {
  constructor(
    private readonly primaryDb: DatabaseFactory,
    private readonly secondaryDb: DatabaseFactory,
    // ... 기타 DB Factory 주입
  ) {}

  getAllPoolStats(): PoolStats[] {
    const stats: PoolStats[] = [];
    
    // 각 DB의 read/write pool 상태 수집
    stats.push(this.getPoolStats(this.primaryDb.read, 'primary', 'read'));
    stats.push(this.getPoolStats(this.primaryDb.write, 'primary', 'write'));
    stats.push(this.getPoolStats(this.secondaryDb.read, 'secondary', 'read'));
    stats.push(this.getPoolStats(this.secondaryDb.write, 'secondary', 'write'));
    
    return stats;
  }

  private getPoolStats(knex: any, database: string, type: 'read' | 'write'): PoolStats {
    const pool = knex.client.pool;
    const used = pool.numUsed();
    const free = pool.numFree();
    const maxSize = pool.max;  // 설정에서 가져옴
    
    return {
      database,
      connectionType: type,
      used,
      free,
      pendingAcquires: pool.numPendingAcquires(),
      maxSize,
      utilization: Math.round((used / maxSize) * 100 * 100) / 100,
    };
  }
}

Knex의 내부 pool 객체에서 직접 상태를 조회합니다. numUsed(), numFree(), numPendingAcquires() 메서드를 활용했습니다.

Alert 생성 로직

Pool 사용률이 임계치를 넘으면 Alert을 생성하도록 했습니다.

generateAlerts(stats: PoolStats[]): PoolAlert[] {
  const alerts: PoolAlert[] = [];
  
  stats.forEach(stat => {
    if (stat.utilization > 90) {
      alerts.push({
        severity: 'critical',
        database: stat.database,
        message: `Pool utilization very high: ${stat.utilization}%`,
      });
    } else if (stat.utilization > 80) {
      alerts.push({
        severity: 'warning',
        database: stat.database,
        message: `Pool utilization high: ${stat.utilization}%`,
      });
    }
    
    if (stat.pendingAcquires > 10) {
      alerts.push({
        severity: 'warning',
        database: stat.database,
        message: `High pending acquires: ${stat.pendingAcquires}`,
      });
    }
  });
  
  return alerts;
}

API 엔드포인트 추가

Pool 상태를 조회할 수 있는 API 엔드포인트를 추가했습니다.

// health.controller.ts
@Get('pool-stats')
async getPoolStats() {
  const stats = this.poolMonitorService.getAllPoolStats();
  const summary = this.poolMonitorService.getSummary();
  const alerts = this.poolMonitorService.generateAlerts(stats);
  
  return {
    timestamp: new Date().toISOString(),
    databases: stats,
    summary: {
      totalUsed: summary.totalUsed,
      totalFree: summary.totalFree,
      avgUtilization: summary.avgUtilization,
    },
    alerts,
  };
}

통합 모니터링 대시보드

Bash 스크립트로 실시간 모니터링

별도의 모니터링 도구 없이 Bash 스크립트로 실시간 대시보드를 구성했습니다.

#!/bin/bash
# monitor-load-test.sh

API_URL="http://localhost:3000"
CONTAINER_NAME="api-loadtest"

while true; do
  clear
  echo "═══════════════════════════════════════════════════"
  echo "       부하 테스트 통합 모니터링"
  echo "═══════════════════════════════════════════════════"
  echo ""
  date '+%Y-%m-%d %H:%M:%S'
  echo ""
  
  # Docker 리소스 모니터링
  echo "📊 Docker 컨테이너 리소스"
  echo "───────────────────────────────────────────────────"
  docker stats $CONTAINER_NAME --no-stream --format \
    "table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}"
  echo ""
  
  # Connection Pool 모니터링
  echo "🔌 Connection Pool 상태"
  echo "───────────────────────────────────────────────────"
  curl -s "$API_URL/pool-stats" | jq -r '
    .databases[] | 
    "\(.database)\t\(.connectionType)\t\(.used)/\(.maxSize)\t\(.utilization)%"
  ' | column -t
  echo ""
  
  # Alert 표시
  ALERTS=$(curl -s "$API_URL/pool-stats" | jq -r '.alerts[]?.message')
  if [ -n "$ALERTS" ]; then
    echo "⚠️  Alerts"
    echo "───────────────────────────────────────────────────"
    echo "$ALERTS"
  fi
  
  echo ""
  echo "⏱️  1초 후 갱신... (Ctrl+C로 종료)"
  sleep 1
done

모니터링 출력 예시

═══════════════════════════════════════════════════
       부하 테스트 통합 모니터링
═══════════════════════════════════════════════════

2025-01-15 14:30:45

📊 Docker 컨테이너 리소스
───────────────────────────────────────────────────
NAME           CPU %    MEM USAGE / LIMIT   MEM %
api-loadtest   78.5%    1.6GiB / 2GiB       80.0%

🔌 Connection Pool 상태
───────────────────────────────────────────────────
DATABASE    TYPE    USED/MAX   UTIL%
primary     read    28/40      70.0%
primary     write   15/20      75.0%
secondary   read    12/40      30.0%
secondary   write   8/20       40.0%

⚠️  Alerts
───────────────────────────────────────────────────
Pool utilization high: primary write 75.0%

⏱️  1초 후 갱신... (Ctrl+C로 종료)

한 화면에서 CPU, 메모리, Pool 상태를 모두 확인할 수 있어서 병목 지점을 빠르게 파악할 수 있습니다.

결과 분석

테스트 실행

세 개의 터미널을 열고 동시에 실행했습니다:

# 터미널 1: Docker 컨테이너 시작
docker-compose -f docker-compose-loadtest.yaml up

# 터미널 2: 모니터링 스크립트
./monitor-load-test.sh

# 터미널 3: k6 부하테스트
k6 run load-test.js

관찰된 패턴

RPS를 점진적으로 증가시키면서 다음과 같은 패턴을 관찰했습니다. 아래 수치는 테스트 환경에서의 예시이며, 실제 결과는 인프라 구성에 따라 다릅니다.

RPS별 시스템 상태 변화 (예시)

RPS 10 (안정)
├─ 응답시간: avg 150ms, p95 300ms
├─ Pool 사용률: 20-30%
├─ CPU: 40-50%
└─ 에러율: 0%

RPS 30 (양호)
├─ 응답시간: avg 250ms, p95 500ms
├─ Pool 사용률: 50-60%
├─ CPU: 70-80%
└─ 에러율: 0.1%

RPS 50 (주의)
├─ 응답시간: avg 800ms, p95 2000ms
├─ Pool 사용률: 80-90%
├─ CPU: 90-95%
└─ 에러율: 2%

RPS 80+ (한계 도달)
├─ 응답시간: avg 3000ms+
├─ Pool 사용률: 95%+ (pending 발생)
├─ CPU: 99%
└─ 에러율: 15%+

병목 지점 분석

테스트 결과, Connection Pool이 먼저 고갈되고 그로 인해 연쇄적인 성능 저하가 발생하는 패턴을 확인했습니다.

병목 연쇄 반응 (Pool → CPU → 전체 장애)

1단계: Pool Connection 고갈
├─ write pool 사용률 100% 도달
├─ pendingAcquires 수치 증가 시작
└─ 새 요청들이 connection 대기열에 쌓임

2단계: 대기열 누적으로 CPU 상승
├─ Event Loop가 대기 요청 관리에 바빠짐
├─ 타임아웃 체크, 재시도 로직 실행 증가
├─ GC(Garbage Collection) 빈도 증가
└─ CPU 사용률 90% → 95% → 99%

3단계: 응답시간 급증 & 타임아웃
├─ 평균 응답시간: 150ms → 3000ms → 10000ms
├─ acquireTimeout 도달 → KnexTimeoutError 발생
└─ HTTP 500 에러 반환 시작

실제 관찰 로그 (예시):
10:00:00 - Pool: used 18/20 (90%), pending 0, CPU 45%
10:00:05 - Pool: used 20/20 (100%), pending 3, CPU 55%  ← Pool 먼저 찼음
10:00:10 - Pool: used 20/20 (100%), pending 12, CPU 70% ← 대기열 급증
10:00:15 - Pool: used 20/20 (100%), pending 25, CPU 85% ← CPU 따라 올라감
10:00:20 - KnexTimeoutError 발생 시작

핵심 발견: Pool 고갈이 1차 병목이고, CPU 상승은 그로 인한 2차 영향이었습니다. 대기 중인 요청들을 관리하느라 CPU가 바빠지는 것이지, CPU 부족이 원인은 아니었습니다.

인프라 의사결정: 스케일업이 아닌 스케일 아웃

이 발견은 인프라 확장 전략에 중요한 시사점을 줍니다. 성능 문제가 발생하면 많은 개발자가 본능적으로 스케일업(CPU/메모리 증가)을 떠올립니다. 하지만 이번 테스트 결과는 다른 답을 가리킵니다.

일반적인 접근 vs 올바른 접근

[일반적인 접근: 스케일업]
성능 저하 발생 → "서버 스펙 올리자" → vCPU 증가, 메모리 증가
└─ 결과: Pool이 여전히 병목, 비용만 증가, 효과 미미

[올바른 접근: Pool 튜닝 + 스케일 아웃]
병목 분석 → "Pool이 먼저 찼네" → Pool 크기 조정 + 인스턴스 수평 확장
└─ 결과: 실제 병목 해결, 선형적 처리량 증가

스케일업이 효과 없는 이유:

Pool 병목 상황에서 스케일업의 한계 (개념 예시)

현재 상태 (vCPU N, Pool max M)
├─ CPU 45%에서 Pool 100% 도달
├─ CPU는 아직 여유 있음
└─ 병목은 Pool (DB connection 수)

스케일업 후 (vCPU 2N, Pool max M)
├─ CPU 여유 더 많아짐
├─ 그러나 Pool은 여전히 max M
├─ 동일한 지점에서 병목 발생
└─ 비용만 증가, 처리량 동일

핵심 통찰:
CPU/메모리를 아무리 늘려도
Pool max 설정이 같으면 병목 지점도 같다

올바른 대응 전략:

전략 1: Connection Pool 크기 조정
├─ write pool 크기 증가 (DB 서버 여유 범위 내에서)
├─ 동일 인스턴스에서 처리량 증가
└─ 단, DB 서버의 max_connections 한계 존재

전략 2: 스케일 아웃 (수평 확장)
├─ 인스턴스 수 증가
├─ 각 인스턴스가 독립적인 Pool 보유
├─ Load Balancer로 트래픽 분산
└─ 선형적 처리량 증가 가능

실제 적용 전략:
1단계: Pool 튜닝 (DB 서버 여유 범위 내에서 증가)
2단계: 트래픽 증가 시 스케일 아웃
3단계: DB 연결 한계 시 Read Replica 추가

이 결론에 도달할 수 있었던 것은 병목의 순서를 정확히 파악했기 때문입니다. 단순히 "CPU가 99%다"만 봤다면 스케일업을 선택했을 것입니다. Pool 모니터링을 통해 "Pool이 먼저 찼고, 그 때문에 CPU가 올라간 것"임을 확인했기에 올바른 판단을 내릴 수 있었습니다.

acquireTimeoutMillis 튜닝

Fail-Fast 전략의 필요성

초기 설정에서 acquireTimeoutMillis는 10초(10000ms)였습니다. Pool에서 connection을 얻기 위해 최대 10초까지 대기한다는 의미입니다.

// 변경 전 (예시)
poolOption: {
  read: {
    max: 40,
    acquireTimeoutMillis: 10000,  // 10초
  },
  write: {
    max: 20,
    acquireTimeoutMillis: 10000,  // 10초
  },
}

문제는 Pool이 고갈된 상황에서 긴 대기 시간이 상황을 더 악화시킨다는 것입니다:

긴 acquireTimeout의 문제점

요청 1 ──▶ Pool 대기 (10초) ──▶ 성공 또는 타임아웃
요청 2 ──▶ Pool 대기 (10초) ──▶ 대기 중...
요청 3 ──▶ Pool 대기 (10초) ──▶ 대기 중...
  ...
요청 N ──▶ Pool 대기 (10초) ──▶ 대기 중...

결과:
├─ 수십~수백 개 요청이 동시에 긴 시간 대기
├─ 메모리에 대기 요청 객체들 누적
├─ Event Loop가 대기 관리에 CPU 소모
└─ 연쇄 장애로 확대

타임아웃 단축 (Fail-Fast)

부하테스트를 통해 acquireTimeoutMillis를 단축했습니다. 적정값은 서비스 특성에 따라 다르지만, 일반적으로 3-5초 범위가 권장됩니다.

// 변경 후 (예시): Fail-Fast 적용
poolOption: {
  read: {
    max: 40,
    acquireTimeoutMillis: 3000,  // 3초로 단축
  },
  write: {
    max: 50,  // write pool 크기도 조정
    acquireTimeoutMillis: 3000,  // 3초로 단축
  },
}

Fail-Fast 전략의 핵심 아이디어:

빠른 실패가 더 나은 이유

시나리오: Pool 고갈 상황에서 100개 요청 발생

[긴 대기 전략]
├─ 100개 요청이 모두 오래 대기
├─ 긴 시간 후: 일부 성공, 나머지 타임아웃
├─ 사용자 경험: "오래 기다렸는데 실패"
└─ 시스템: 긴 시간 리소스 점유, 연쇄 장애 위험

[짧은 대기 전략 (Fail-Fast)]
├─ Pool 여유분만 즉시 처리
├─ 나머지는 빠르게 실패 응답
├─ 사용자 경험: "빠르게 실패, 재시도 가능"
└─ 시스템: 빠른 복구, 연쇄 장애 방지

튜닝 결과

타임아웃 단축 후 부하테스트 재실행 결과:

acquireTimeoutMillis 튜닝 전후 비교 (예시)

[변경 전: 긴 타임아웃]
├─ 응답시간 p95: 급격히 증가
├─ 복구 시간: 30초+
└─ 연쇄 장애 발생

[변경 후: 짧은 타임아웃]
├─ 응답시간 p95: 상대적으로 안정
├─ 복구 시간: 5초 내외
└─ 연쇄 장애 없음

에러율은 비슷해 보이지만, 중요한 차이는 복구 시간입니다. 빠르게 실패하고 빠르게 복구되는 것이 시스템 안정성에 훨씬 유리합니다.

최적 RPS 결론

안정적인 운영을 위한 최적 RPS를 도출했습니다. 구체적인 수치는 인프라 환경에 따라 다르지만, 선정 기준은 동일합니다:

최적 RPS 선정 기준

✅ 응답시간 안정: p95가 허용 범위 이내
✅ Pool 여유: 사용률 50% 이하 유지
✅ CPU 여유: 70% 이하 유지
✅ 에러율: 0%에 근접
✅ Spike 대응 여력: 순간 트래픽 증가 대비

한계점까지 부하를 줄 수 있다고 해서 그 수치로 운영하면 안 됩니다. 트래픽 급증(Spike) 상황을 고려하면 평소에 충분한 여유를 두는 것이 안전합니다.

마치며

부하테스트를 통해 "우리 시스템은 초당 몇 개의 요청을 처리할 수 있는가"에 대한 구체적인 답을 얻을 수 있었습니다. 막연히 "잘 돌아간다"가 아니라, 숫자로 된 기준이 생긴 것입니다.

특히 세 가지 핵심 발견이 있었습니다:

  1. 병목 순서 파악: Pool 고갈 → CPU 상승 → 전체 장애의 연쇄 패턴. CPU가 높아 보여도 실제 원인은 Pool일 수 있습니다.
  2. Fail-Fast의 중요성: acquireTimeoutMillis를 단축하여 시스템 복원력을 향상시켰습니다.
  3. 인프라 의사결정 기준: Pool이 1차 병목이므로 스케일업은 불필요하고, Pool 튜닝과 스케일 아웃이 올바른 대응입니다. 병목의 순서를 알아야 비용 효율적인 판단을 내릴 수 있습니다.

Pool 모니터링 서비스를 직접 구현한 것도 도움이 많이 되었습니다. 외부 APM 도구 없이도 핵심 지표를 실시간으로 확인할 수 있었고, 병목 지점의 순서까지 정확히 파악할 수 있었습니다. 이 순서 파악이 "스케일업이 아닌 스케일 아웃"이라는 결론의 근거가 되었습니다.

이번 시리즈를 통해 정리한 부하테스트 환경은 앞으로 성능 개선 작업의 기준점이 될 것 같습니다. 코드를 수정하고 다시 테스트해서 "이전보다 나아졌는가"를 객관적으로 확인할 수 있으니까요.

이 글은 NestJS 마이크로서비스 부하테스트 시리즈의 마지막 편입니다.

k6부하테스트NestJSConnection Pool모니터링스케일 아웃인프라

관련 글

NestJS 마이크로서비스 부하테스트: 전략 설계와 7가지 요구사항

Production 환경을 로컬에서 재현하여 시스템 한계점을 찾는 부하테스트 전략을 설계한 경험. 7가지 요구사항 정의부터 아키텍처 설계까지의 과정을 정리했습니다.

관련도 88%

작은 서비스에도 모니터링이 필요한 이유 - NestJS + Prometheus + Grafana Cloud 구축기

작은 서비스도 리소스 제약 때문에 모니터링이 필수입니다. NestJS에 Prometheus와 Grafana Cloud를 연동하여 효율적인 리소스 관리 시스템을 구축하는 방법을 알아보세요.

관련도 80%

Liveness와 Readiness: 컨테이너 헬스체크의 두 가지 관점

Docker나 Kubernetes 환경에서 헬스체크를 구현할 때 Liveness와 Readiness의 차이를 이해하고, NestJS Backend와 Next.js Web에서 실제로 구현한 경험을 정리했습니다.

관련도 79%