본문으로 건너뛰기
Database Connection Pool 고갈 디버깅 가이드
Table of Contents

금요일 오후 5시, 모든 요청이 멈췄다

퇴근 30분 전, Slack 알림이 폭주하기 시작합니다. 사용자들이 “페이지가 로딩되지 않아요”, “계속 뱅글뱅글 돌기만 해요”라는 문의를 쏟아냅니다. 모니터링 대시보드를 확인하니 API 응답 시간이 평소 100ms에서 30초로 치솟았습니다. 그런데 이상하게도 CPU 사용률은 평온합니다.

서버 로그를 열어보니 등골이 서늘해지는 메시지가 반복됩니다:

[ERROR] HikariPool-1 - Connection is not available, request timed out after 30000ms.
Pool stats (total=20, active=20, idle=0, waiting=152)

Connection Pool이 완전히 고갈되었습니다. 20개의 연결이 모두 사용 중이고, 무려 152개의 요청이 연결을 얻기 위해 줄을 서서 기다리고 있습니다.

더 끔찍한 것은 서버를 재시작해도 5분 후 다시 같은 증상이 나타난다는 점입니다. 연결이 어딘가에서 줄줄 새고 있지만, 도대체 어디서 새는지 알 수가 없습니다.

이것이 바로 “The Silent Killer” - Connection Pool 고갈의 현실입니다.

분산 시스템에서 가장 교활한 장애 유형으로, 모든 지표가 정상인 것처럼 보이다가 갑자기 전체 시스템을 무너뜨립니다.

이 글에서는 Connection Pool 고갈을 실시간으로 탐지하고, 숨겨진 원인을 찾아내고, 완벽하게 해결하는 실전 전략을 다룹니다.

Connection Pool이란 무엇인가?

기본 개념

Connection Pool은 데이터베이스 연결을 미리 만들어두고 필요할 때 빌려 쓰고 반납하는 재사용 메커니즘입니다.

// Connection Pool 없이 (나쁜 방법)
async function getUser(userId) {
 const connection = await mysql.createConnection({ // 매번 새 연결 생성 (비용 큼)
 host: 'localhost',
 user: 'root',
 database: 'mydb'
 });

 const [rows] = await connection.execute('SELECT * FROM users WHERE id = ?', [userId]);
 await connection.end(); // 연결 종료

 return rows[0];
}

// Connection Pool 사용 (좋은 방법)
const pool = mysql.createPool({
 host: 'localhost',
 user: 'root',
 database: 'mydb',
 connectionLimit: 10 // 최대 10개 연결 유지
});

async function getUser(userId) {
 const connection = await pool.getConnection(); // 풀에서 연결 빌림
 try {
 const [rows] = await connection.execute('SELECT * FROM users WHERE id = ?', [userId]);
 return rows[0];
 } finally {
 connection.release(); // 반드시 풀에 반환!
 }
}

왜 Connection Pool을 사용해야 할까요?:

DB 연결 생성 비용 (TCP Handshake + 인증):
- 소요 시간: 약 60ms

Connection Pool 사용 시:
- 연결 재사용: 약 1ms
 성능 향상: 60배!

Pool의 생명주기

[Connection Pool]
┌─────────────────────────────────────┐
│ Idle Connections (사용 가능) │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ 1 │ │ 2 │ │ 3 │ │ 4 │... │
│ └───┘ └───┘ └───┘ └───┘ │
├─────────────────────────────────────┤
│ Active Connections (사용 중) │
│ ┌───┐ ┌───┐ ┌───┐ │
│ │ 5 │ │ 6 │ │ 7 │ │
│ └───┘ └───┘ └───┘ │
└─────────────────────────────────────┘

요청 A → getConnection() → Connection 1 빌림 (Idle → Active)
쿼리 실행 → release() → Connection 1 반환 (Active → Idle)

Connection Pool 고갈이 위험한 이유

1. 연쇄 장애 (Cascading Failure)

서비스 A (Connection Pool: 20)
 ↓ 느린 쿼리 발생 (10초)
 ↓ 연결 20개 모두 점유됨
 ↓ 새 요청 대기 (timeout: 30초)

서비스 B (서비스 A 호출 timeout: 60초)
 ↓ A의 응답 대기하느라 스레드 블로킹
 ↓ Thread Pool 고갈

로드 밸런서
 ↓ Health Check 실패
 ↓ 모든 인스턴스 제거

시스템 전체 다운!

2. “보이지 않는” 실패

// 표면적으로는 문제없어 보임
app.get('/api/users', async (req, res) => {
 try {
 const connection = await pool.getConnection(); // ← 여기서 30초간 멈춤!
 // 실제 쿼리는 빠름 (10ms)
 const users = await connection.query('SELECT * FROM users LIMIT 10');
 connection.release();
 res.json(users);
 } catch (err) {
 res.status(500).json({ error: 'Server error' }); // 원인을 알 수 없는 에러
 }
});

// CPU: 정상 (10%)
// Memory: 정상 (50%)
// Disk I/O: 정상
// 그런데 모든 요청이 느림 → Connection Pool 문제!

5가지 치명적인 Connection Pool 고갈 원인

1. 연결 누수 (Connection Leak)

가장 흔한 원인으로, 빌린 연결을 반환(close or release)하지 않는 경우입니다.

// 나쁜 예 - Java/Spring
@Service
public class UserService {
 @Autowired
 private DataSource dataSource;

 public User getUser(Long id) throws SQLException {
 Connection conn = dataSource.getConnection();

 // 문제: 예외 발생 시 연결 반환 코드가 실행되지 않음!
 PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
 stmt.setLong(1, id);
 ResultSet rs = stmt.executeQuery();

 if (rs.next()) {
 return new User(rs.getLong("id"), rs.getString("name"));
 }
 return null;
 // conn.close() 호출 안 됨!
 }
}

// 좋은 예 - try-with-resources 사용
public User getUser(Long id) throws SQLException {
 try (Connection conn = dataSource.getConnection(); // 자동으로 연결 반환
 PreparedStatement stmt = conn.prepareStatement("SELECT * FROM users WHERE id = ?")) {

 stmt.setLong(1, id);
 ResultSet rs = stmt.executeQuery();
 //...
 }
}

Node.js/Sequelize 예시:

// 나쁜 예
async function updateUserPoints(userId, points) {
 const transaction = await sequelize.transaction(); // 트랜잭션 시작 (연결 점유)

 try {
 await User.update(..., { transaction });

 if (points < 0) {
 throw new Error('Invalid points'); // 예외 발생!
 }

 await transaction.commit();
 } catch (error) {
 // rollback 호출 안 함! 연결이 계속 열려 있음
 throw error;
 }
}

// 좋은 예
async function updateUserPoints(userId, points) {
 const transaction = await sequelize.transaction();

 try {
 //...
 await transaction.commit();
 } catch (error) {
 await transaction.rollback(); // 반드시 롤백하여 연결 반환
 throw error;
 }
}

2. 과도한 Pool 크기 설정

# 나쁜 예 - 너무 큰 Pool
spring:
 datasource:
 hikari:
 maximum-pool-size: 200 # 너무 큼!

# 문제:
# - DB 서버의 연결 수 제한(max_connections) 초과
# - 과도한 Context Switching 오버헤드
# - 메모리 낭비

올바른 Pool 크기 계산 공식:

pool_size = (core_count × 2) + effective_spindle_count

예시:
CPU 코어: 4개
디스크: 1개 SSD (spindle count ≈ 1)
pool_size = (4 × 2) + 1 = 9

실제 권장: 10-20개 (생각보다 작습니다!)

3. 느린 쿼리가 연결 점유 (Slow Query)

-- 나쁜 쿼리 - 10초 소요
SELECT * FROM users WHERE created_at > '2024-01-01'; -- 인덱스 없음!

-- 영향:
-- 쿼리 하나가 10초 동안 연결을 점유합니다.
-- 동시 요청 100개 = 모든 연결 고갈
-- Pool Size: 20 → 대기열 폭주

4. 트랜잭션 내부에서 외부 API 호출

// 치명적 실수
async function processOrder(orderId) {
 const transaction = await sequelize.transaction(); // DB 연결 획득

 try {
 const order = await Order.findByPk(orderId, { transaction });

 // 트랜잭션 잡고 외부 API 호출!
 // 결제 API가 5초 걸리면, DB 연결도 5초간 마비됨
 const paymentResult = await fetch('https://payment-gateway.com/charge',...);

 await order.update({ status: 'paid' }, { transaction });
 await transaction.commit();
 } catch (error) {
 await transaction.rollback();
 }
}

해결책: 외부 API 호출은 트랜잭션 밖에서 하세요.

5. N+1 쿼리 문제

// 나쁜 예 - N+1 쿼리
List<User> users = userRepository.findAll(); // 1 query

for (User user : users) {
 List<Order> orders = user.getOrders(); // 100 queries!
 // 각 조회마다 커넥션을 획득/반환하며 오버헤드 발생
}

HikariCP 디버깅 완벽 가이드

1. Leak Detection 활성화

HikariCP는 연결 누수를 감지하는 기능을 제공합니다.

# application.yml
spring:
 datasource:
 hikari:
 leak-detection-threshold: 15000 # 15초
 # 연결이 15초 이상 반환되지 않으면 경고 로그 + Stack Trace 출력

로그 예시:

[WARN] HikariPool-1 - Connection leak detection triggered for conn0
on thread http-nio-8080-exec-5, stack trace follows

java.lang.Exception: Apparent connection leak detected
 at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
 at com.example.UserService.getUser(UserService.java:45) ← 범인은 여기!

2. Pool Metrics 모니터링

// Micrometer를 사용한 HikariCP 메트릭 수집
@Configuration
public class MetricsConfig {
 @Bean
 public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags(
 @Value("${spring.application.name}") String applicationName) {
 return registry -> registry.config().commonTags("application", applicationName);
 }
}

확인해야 할 핵심 지표 (Prometheus):

  • hikaricp_connections_active: 현재 사용 중인 연결 수
  • hikaricp_connections_pending: 연결을 기다리는 요청 수 (이게 0이어야 함)
  • hikaricp_connections_timeout_total: 타임아웃 발생 횟수

Node.js/Sequelize 디버깅 가이드

1. Connection Pool 설정

const sequelize = new Sequelize('db', 'user', 'pass', {
 host: 'localhost',
 dialect: 'postgres',

 pool: {
 max: 20, // 최대 연결 수
 min: 5, // 최소 유지 연결 수
 acquire: 30000, // 연결 획득 대기 시간 (30초)
 idle: 10000 // 유휴 연결 해제 시간 (10초)
 },

 // 느린 쿼리 로깅
 logging: (sql, timing) => {
 if (timing > 1000) { // 1초 이상 걸린 쿼리만 로그
 console.warn(`Slow query (${timing}ms): ${sql}`);
 }
 }
});

2. Pool 상태 모니터링 미들웨어

app.use((req, res, next) => {
 const pool = sequelize.connectionManager.pool;

 // 풀 상태 확인
 const status = {
 size: pool.size, // 현재 연결 수
 available: pool.available, // 사용 가능 연결 수
 using: pool.using, // 사용 중인 연결 수
 waiting: pool.waiting // 대기 중인 요청 수
 };

 if (status.waiting > 0) {
 console.warn('️ Connection pool congestion:', status);
 }

 next();
});

프로덕션 체크리스트

배포 전 필수 점검

  • Pool 크기 설정: 너무 크지도 작지도 않게 (CPU 코어 * 2 + 1 기준)
  • Timeout 설정:
  • connection-timeout: 30초 이하 (무한대 금지)
  • query-timeout: 적절히 설정
  • Leak Detection: 개발/스테이징 환경에서 활성화하여 누수 사전 탐지
  • 코드 리뷰: 트랜잭션 범위 내 외부 API 호출 여부 확인
  • 모니터링: Grafana 대시보드에 Active Connection 및 Pending Request 지표 추가

장애 대응 절차

  1. 즉시 대응: 서버 재시작 (임시 조치)
  2. 원인 파악:
  • Leak Detection 로그 확인
  • Slow Query 로그 확인
  • Thread Dump 분석 (어디서 대기 중인지)
  1. 영구 해결:
  • 연결 누수 코드 수정
  • 쿼리 튜닝 및 인덱스 추가
  • Pool 설정 최적화

결론

Connection Pool 고갈은 단순한 리소스 부족이 아니라, 시스템의 비효율을 알리는 신호입니다. 무작정 Pool 크기를 늘리는 것은 답이 아닙니다.

핵심 원칙 3가지:

  1. 항상 연결을 반환하라 (try-finally, try-with-resources)
  2. 트랜잭션은 짧게 유지하라 (외부 API 호출 금지)
  3. 모니터링하라 (누수 감지, 대기열 확인)

이 원칙들만 지켜도 “The Silent Killer”로부터 여러분의 서비스를 안전하게 지킬 수 있습니다.

이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트