DB 커넥션 풀 완벽 가이드: 왜 매번 연결하면 안 되는가부터 HikariCP 내부 동작까지
DB 커넥션 풀 완벽 가이드: 왜 매번 연결하면 안 되는가부터 HikariCP 내부 동작까지
JPA를 사용하든 MyBatis를 사용하든, 결국 DB에 쿼리를 보내려면 커넥션(Connection)이 필요하다. 그리고 “커넥션은 비싸다”라는 말을 들어봤을 것이다. 왜 비싼지, 그래서 어떻게 관리하는지, HikariCP는 내부적으로 어떤 구조인지 — 이 글은 그 질문에 대한 완전한 답이다.
1. DB 커넥션이란 무엇인가
1.1 커넥션의 정체
DB 커넥션은 애플리케이션과 데이터베이스 사이의 네트워크 세션이다. JDBC에서는 java.sql.Connection 인터페이스로 추상화되어 있지만, 실제로는 TCP 소켓 연결 위에서 DB 프로토콜을 사용하는 복잡한 객체다.
1
2
3
4
5
6
7
8
9
10
11
12
┌─────────────────────┐ ┌─────────────────────┐
│ Java Application │ │ Database │
│ │ │ │
│ ┌───────────────┐ │ TCP Socket │ ┌───────────────┐ │
│ │ Connection │──┼────────────────────┼──│ Session │ │
│ │ (JDBC) │ │ DB Protocol │ │ (Server) │ │
│ └───────────────┘ │ (MySQL/PG proto) │ └───────────────┘ │
│ │ │ │
│ Driver: mysql- │ │ Process/Thread │
│ connector-j, │ │ 할당, 메모리 버퍼 │
│ postgresql-jdbc │ │ 인증 정보 유지 │
└─────────────────────┘ └─────────────────────┘
1.2 Connection 객체가 들고 있는 것
1
2
3
4
5
6
7
8
9
10
// 개념적으로 Connection이 관리하는 리소스
Connection connection = DriverManager.getConnection(url, user, password);
// 이 한 줄 뒤에 일어나는 일:
// 1. TCP 소켓 (java.net.Socket)
// 2. 입출력 스트림 (InputStream, OutputStream)
// 3. DB 세션 정보 (세션 ID, 타임존, 문자셋)
// 4. 트랜잭션 상태 (autoCommit, isolation level)
// 5. Statement 캐시 (PreparedStatement pool)
// 6. 네트워크 버퍼 (송수신 버퍼)
2. 왜 매번 커넥션을 새로 만들면 안 되는가
2.1 커넥션 생성 과정 — 숨겨진 비용
커넥션 하나를 만드는 데는 생각보다 많은 과정이 필요하다. 아래는 MySQL 기준이다.
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
┌────────────────────────────────────────────────────────────────────┐
│ DB 커넥션 생성 과정 (MySQL 기준) │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 1단계: TCP 3-Way Handshake (네트워크) │
│ ┌──────┐ SYN ┌──────┐ │
│ │ App │ ──────────────────→ │ DB │ ~0.5ms (로컬) │
│ │ │ ←────────────────── │ │ ~1-5ms (같은 DC) │
│ │ │ SYN-ACK │ │ ~10-100ms (원격) │
│ │ │ ──────────────────→ │ │ │
│ └──────┘ ACK └──────┘ │
│ │
│ 2단계: SSL/TLS Handshake (보안 — 선택적) │
│ ┌──────┐ ClientHello ┌──────┐ │
│ │ App │ ──────────────────→ │ DB │ ~2-5ms 추가 │
│ │ │ ←────────────────── │ │ 인증서 교환, │
│ │ │ ServerHello+Cert │ │ 키 합의 등 │
│ │ │ ──────────────────→ │ │ CPU 집약적 작업 │
│ └──────┘ Finished └──────┘ │
│ │
│ 3단계: DB 인증 (Authentication) │
│ ┌──────┐ username/password ┌──────┐ │
│ │ App │ ──────────────────→ │ DB │ ~1-3ms │
│ │ │ ←────────────────── │ │ 비밀번호 해시 검증 │
│ │ │ Auth OK / Error │ │ 권한 테이블 조회 │
│ └──────┘ └──────┘ │
│ │
│ 4단계: DB 서버 내부 리소스 할당 │
│ ┌──────────────────────────────────────┐ │
│ │ MySQL: 스레드 생성 또는 Thread Pool │ ~1-2ms │
│ │ PostgreSQL: 프로세스 fork │ ~5-10ms (fork 비용) │
│ │ 세션 메모리 할당 (수 MB) │ │
│ │ 세션 변수 초기화 │ │
│ │ 문자셋/타임존/sql_mode 설정 │ │
│ └──────────────────────────────────────┘ │
│ │
│ 5단계: 세션 초기화 쿼리 │
│ ┌──────┐ SET NAMES utf8mb4 ┌──────┐ │
│ │ App │ ──────────────────→ │ DB │ ~0.5-1ms │
│ │ │ SET time_zone=... │ │ 초기화 쿼리 실행 │
│ │ │ SET sql_mode=... │ │ │
│ └──────┘ └──────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 총 소요 시간 │ │
│ │ 로컬: ~5-10ms │ │
│ │ 같은 DC: ~10-30ms │ │
│ │ 원격: ~50-200ms │ │
│ │ │ │
│ │ 비교: 단순 SELECT 쿼리 실행 시간 = ~0.1-1ms │ │
│ └──────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
커넥션 하나 만드는 데 5~200ms인데, 쿼리 하나 실행하는 건 0.1~1ms다. 매번 커넥션을 만들고 끊으면, 쿼리보다 커넥션 생성/종료에 더 많은 시간을 쓰게 된다.
2.2 커넥션 해제 과정 — 이것도 공짜가 아니다
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
┌────────────────────────────────────────────────────────────┐
│ DB 커넥션 해제 과정 │
├────────────────────────────────────────────────────────────┤
│ │
│ 1. 미완료 트랜잭션 정리 │
│ → 커밋되지 않은 트랜잭션 롤백 │
│ → Undo Log 기반 원복 작업 (InnoDB) │
│ │
│ 2. 임시 테이블 / Prepared Statement 정리 │
│ → 세션 스코프 임시 테이블 DROP │
│ → 서버 사이드 Prepared Statement 해제 │
│ │
│ 3. 락 해제 │
│ → GET_LOCK()으로 획득한 User-level Lock 해제 │
│ → Table Lock / Row Lock 해제 │
│ │
│ 4. 세션 메모리 반환 │
│ → 정렬 버퍼, 조인 버퍼, 네트워크 버퍼 해제 │
│ → MySQL: ~10MB/세션, PostgreSQL: ~5-15MB/프로세스 │
│ │
│ 5. 스레드/프로세스 정리 │
│ → MySQL: 스레드를 Thread Cache로 반환 또는 종료 │
│ → PostgreSQL: 프로세스 종료 (exit + wait) │
│ │
│ 6. TCP 4-Way Handshake (연결 종료) │
│ → FIN → ACK → FIN → ACK │
│ → TIME_WAIT 상태 (2분간 포트 점유) │
│ │
│ 소요 시간: ~1-5ms (+ 롤백 시 수십ms) │
└────────────────────────────────────────────────────────────┘
2.3 매번 연결하면 생기는 문제 — 숫자로 보기
초당 100개의 API 요청이 들어오고, 요청 하나당 평균 3개의 쿼리를 실행한다고 가정하자.
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
┌──────────────────────────────────────────────────────────────────┐
│ 시나리오: 커넥션 풀 없이 매번 새로 연결 │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 초당 요청: 100 req/s │
│ 요청당 쿼리: 3개 │
│ 쿼리당 커넥션 생성/해제: 1회 │
│ │
│ 초당 커넥션 생성: 100 × 3 = 300회 │
│ 커넥션 생성 시간: ~10ms (같은 DC 기준) │
│ 커넥션 해제 시간: ~2ms │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 낭비되는 시간 (초당) │ │
│ │ 커넥션 생성: 300 × 10ms = 3,000ms = 3초 │ │
│ │ 커넥션 해제: 300 × 2ms = 600ms │ │
│ │ 총 오버헤드: 3.6초/초 ← 1초에 3.6초 낭비 (불가능!) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 실제 쿼리 실행: 300 × 0.5ms = 150ms = 0.15초 │
│ │
│ 결론: 실제 일하는 시간보다 연결/해제에 24배 더 소요 │
│ → 요청 큐가 쌓이고, 응답 지연 → 타임아웃 → 서비스 장애 │
│ │
│ DB 서버 영향: │
│ • 초당 300개 TCP 연결 + 300개 TCP 해제 │
│ • 300개 인증 처리 │
│ • MySQL: 300개 스레드 생성/종료 (or Thread Cache 경합) │
│ • PostgreSQL: 300개 fork/exit (더 심각) │
│ • TIME_WAIT 소켓: 300개/초 × 120초 = 36,000개 누적 │
│ • 포트 고갈 위험 (ephemeral port 범위: ~28,000개) │
└──────────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌──────────────────────────────────────────────────────────────────┐
│ 시나리오: 커넥션 풀 사용 (pool size = 10) │
├──────────────────────────────────────────────────────────────────┤
│ │
│ 초당 요청: 100 req/s │
│ 요청당 쿼리: 3개 │
│ 커넥션 생성: 최초 10개만 (이후 재사용) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ 커넥션 획득 시간: ~0.001ms (풀에서 빌려오기) │ │
│ │ 커넥션 반납 시간: ~0.001ms (풀에 돌려놓기) │ │
│ │ 총 오버헤드: 300 × 0.002ms = 0.6ms ≈ 0 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ 성능 향상: 3,600ms → 0.6ms (6,000배 개선) │
│ DB 서버 부하: 동시 커넥션 10개만 유지 │
└──────────────────────────────────────────────────────────────────┘
2.4 DB 서버 관점에서의 부담
클라이언트 입장만이 아니다. DB 서버도 커넥션마다 리소스를 할당한다.
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
┌─────────────────────────────────────────────────────────────────┐
│ DB 서버가 커넥션 하나에 할당하는 리소스 │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ MySQL (InnoDB) ─────────────────────────────────────────┐ │
│ │ sort_buffer_size = 256KB (정렬 작업용) │ │
│ │ join_buffer_size = 256KB (조인 작업용) │ │
│ │ read_buffer_size = 128KB (순차 읽기용) │ │
│ │ read_rnd_buffer_size = 256KB (랜덤 읽기용) │ │
│ │ thread_stack = 256KB (스레드 스택) │ │
│ │ net_buffer_length = 16KB (네트워크 버퍼) │ │
│ │ binlog_cache_size = 32KB (바이너리 로그) │ │
│ │ tmp_table_size = 16MB (임시 테이블 최대) │ │
│ │ ────────────────────────────────────────────── │ │
│ │ 세션당 최소: ~1.2MB, 쿼리에 따라: ~10-20MB │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ PostgreSQL ─────────────────────────────────────────────┐ │
│ │ Backend Process: 1개의 OS 프로세스 │ │
│ │ 프로세스 메모리: ~5-15MB │ │
│ │ work_mem = 4MB (정렬/해시 작업용) │ │
│ │ temp_buffers = 8MB (임시 테이블용) │ │
│ │ 프로세스 생성(fork) 비용: ~5-10ms │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 8GB RAM 서버에서 동시 커넥션 한계: │
│ • MySQL: ~500-800개 (실 사용 기준) │
│ • PostgreSQL: ~300-500개 (프로세스 모델이라 더 제한적) │
│ • max_connections 값을 높여도 물리 메모리의 한계가 있다 │
│ │
│ 실무 권장: │
│ • 애플리케이션 서버 3대 × 풀 크기 20 = 동시 커넥션 60개 │
│ • DB 서버 max_connections = 100 (여유분 포함) │
│ • 이 정도로도 초당 수천 요청 처리 가능 │
└─────────────────────────────────────────────────────────────────┘
3. 커넥션 풀의 원리
3.1 풀링 패턴
커넥션 풀은 Object Pool 패턴의 대표적 사례다. 비용이 큰 객체를 미리 만들어두고 빌려주고 돌려받는 구조다.
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
┌────────────────────────────────────────────────────────────────────┐
│ Connection Pool 구조 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ Application Threads Connection Pool │
│ │
│ Thread-1 ──── getConnection() ──→ ┌─────────────────────┐ │
│ │ ┌───┐ ┌───┐ ┌───┐ │ │
│ Thread-2 ──── getConnection() ──→ │ │ C1│ │ C2│ │ C3│ │ │
│ │ └───┘ └───┘ └───┘ │ │
│ Thread-3 ──── (대기 중...) │ ┌───┐ ┌───┐ │ │
│ │ │ C4│ │ C5│ │ │
│ Thread-1 ──── close() ──────────→ │ └───┘ └───┘ │ │
│ (실제 close가 아님! │ │ │
│ 풀에 반납) │ idle: C4, C5 │ │
│ │ in-use: C1, C2, C3 │ │
│ Thread-3 ──── (C1 획득!) ──────── │ │ │
│ └─────────────────────┘ │
│ │ │
│ │ 실제 TCP 커넥션 │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ Database │ │
│ │ (5개 세션 유지) │ │
│ └─────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
3.2 커넥션 생명주기
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
39
40
41
┌────────────────────────────────────────────────────────────────────────┐
│ 커넥션 하나의 생명주기 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ [생성] │
│ │ 풀 초기화 시 or 필요할 때 실제 DB 커넥션 생성 │
│ │ TCP 연결 → 인증 → 세션 초기화 │
│ ▼ │
│ [IDLE 상태] ◄───────────────────────────────────────┐ │
│ │ 풀에서 대기 중 │ │
│ │ 주기적 검증 (isValid, validation query) │ │
│ │ │ │
│ │ getConnection() 호출 │ │
│ ▼ │ │
│ [IN-USE 상태] │ │
│ │ 애플리케이션이 사용 중 │ │
│ │ 쿼리 실행, 트랜잭션 수행 │ │
│ │ │ │
│ │ connection.close() 호출 │ │
│ ▼ │ │
│ [반납 처리] │ │
│ │ autoCommit 복원 │ │
│ │ readOnly 복원 │ │
│ │ 트랜잭션 롤백 (미커밋 시) │ │
│ │ Statement 정리 │ │
│ │ 커넥션 유효성 검사 │ │
│ │ │ │
│ ├── 유효 → ────────────────────────────────────────┘ │
│ │ │
│ └── 무효 → [폐기] │
│ │ 실제 TCP 연결 종료 │
│ │ DB 서버 세션/스레드 해제 │
│ │ 새 커넥션 생성으로 풀 크기 유지 │
│ ▼ │
│ [제거됨] │
│ │
│ 추가 폐기 조건: │
│ • maxLifetime 초과 → IDLE 상태에서 조용히 제거 후 새 커넥션 생성 │
│ • idleTimeout 초과 → minimumIdle 이상이면 제거 │
│ • eviction test 실패 → DB 서버가 끊었거나 네트워크 장애 │
└────────────────────────────────────────────────────────────────────────┘
3.3 connection.close()의 진실
JDBC를 배울 때 finally { connection.close(); }를 쓰라고 배운다. 커넥션 풀을 쓸 때 이 close()가 실제로 TCP 연결을 끊는 것일까?
1
2
3
4
5
6
7
8
// 풀 없이 직접 연결했을 때
Connection conn = DriverManager.getConnection(url, user, pwd);
conn.close(); // → 실제 TCP 연결 종료. 소켓 close.
// 풀을 사용할 때 (HikariCP)
Connection conn = dataSource.getConnection();
// conn의 실제 타입: HikariProxyConnection
conn.close(); // → TCP 종료가 아니라, 풀에 반납!
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
┌────────────────────────────────────────────────────────────────────┐
│ ProxyConnection.close() 내부 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ conn.close() │
│ │ │
│ ▼ │
│ HikariProxyConnection.close() │
│ │ │
│ ├── 1. 미닫힌 Statement 자동 정리 │
│ │ → openStatements.clear() │
│ │ │
│ ├── 2. 프록시를 "닫힘" 상태로 표시 │
│ │ → delegate = ClosedConnection.CLOSED_CONNECTION │
│ │ → 이후 이 프록시로 쿼리하면 SQLException │
│ │ │
│ ├── 3. 실제 커넥션(delegate)을 풀에 반납 │
│ │ → poolEntry.recycle(lastAccess) │
│ │ → ConcurrentBag.requite(poolEntry) │
│ │ │
│ └── 4. 대기 중인 스레드에 통지 │
│ → handoffQueue 또는 직접 전달 │
│ │
│ 결과: TCP 연결은 살아있고, 다음 요청에 재사용됨 │
└────────────────────────────────────────────────────────────────────┘
4. HikariCP 깊이 파기
Spring Boot 2.0부터 기본 커넥션 풀은 HikariCP다. “빠른 것”으로 유명한데, 왜 빠른지를 알아야 면접에서 차별화된다.
4.1 HikariCP가 빠른 이유
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
┌────────────────────────────────────────────────────────────────────┐
│ HikariCP의 핵심 최적화 포인트 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 1. ConcurrentBag — Lock-Free 커넥션 관리 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 전통적 풀: synchronized (pool) { conn = pool.remove(); }│ │
│ │ → 모든 스레드가 하나의 락을 놓고 경합 │ │
│ │ │ │
│ │ HikariCP: │ │
│ │ ① ThreadLocal 리스트에서 먼저 찾기 (락 없음) │ │
│ │ ② 없으면 공유 리스트에서 CAS로 획득 (락 없음) │ │
│ │ ③ 둘 다 없으면 handoffQueue에서 대기 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 2. FastList — ArrayList 대체 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ ArrayList: get()에서 range check (if 문 + 예외 생성) │ │
│ │ FastList: range check 제거 → ~10% 빠름 │ │
│ │ Statement 추적 리스트에 사용 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 3. Proxy 최적화 — Javassist로 바이트코드 생성 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ JDK Dynamic Proxy: 매 호출마다 InvocationHandler 경유 │ │
│ │ HikariCP: Javassist로 컴파일 타임에 직접 프록시 생성 │ │
│ │ → 메서드 호출 오버헤드 제거 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ 4. 경량 설계 — 불필요한 기능 제거 │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ DBCP2: Statement 풀링, 공정 락(fair lock) 등 기능 多 │ │
│ │ HikariCP: 커넥션 풀 본연의 기능에 집중 │ │
│ │ → 코드 ~4,000줄 (DBCP2: ~18,000줄, c3p0: ~30,000줄) │ │
│ │ → 코드가 적으면 버그도 적고, GC 부담도 적다 │ │
│ └──────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
4.2 ConcurrentBag 내부 구조
이것이 HikariCP의 핵심이다.
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
┌─────────────────────────────────────────────────────────────────────┐
│ ConcurrentBag 구조 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ ThreadLocal<List<PoolEntry>> ──────────────────────────┐ │
│ │ Thread-1: [Entry-A, Entry-C] ← 이전에 사용한 커넥션 │ │
│ │ Thread-2: [Entry-B] │ │
│ │ Thread-3: [Entry-D, Entry-E] │ │
│ │ │ │
│ │ ThreadLocal에서 찾으면: │ │
│ │ • 락 불필요 (자기 스레드만 접근) │ │
│ │ • CPU 캐시 친화적 (같은 커넥션 재사용) │ │
│ │ • CAS 한 번으로 상태 전환: NOT_IN_USE → IN_USE │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ 없으면 │
│ ▼ │
│ ┌─ CopyOnWriteArrayList<PoolEntry> (sharedList) ───────────┐ │
│ │ [Entry-A, Entry-B, Entry-C, Entry-D, Entry-E, ...] │ │
│ │ │ │
│ │ 전체 Entry를 순회하며 NOT_IN_USE 상태인 것을 CAS로 획득 │ │
│ │ → compareAndSet(NOT_IN_USE, IN_USE) │ │
│ │ → 성공하면 해당 커넥션 반환 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │ 없으면 │
│ ▼ │
│ ┌─ SynchronousQueue<PoolEntry> (handoffQueue) ─────────────┐ │
│ │ 다른 스레드가 반납할 때까지 대기 │ │
│ │ 최대 connectionTimeout 밀리초 대기 │ │
│ │ 타임아웃 시 SQLException 발생 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ PoolEntry 상태: │
│ • NOT_IN_USE (0) — 풀에서 대기 중, 획득 가능 │
│ • IN_USE (1) — 스레드가 사용 중 │
│ • REMOVED (2) — 폐기 대상 (maxLifetime 초과 등) │
│ • RESERVED (3) — 예약됨 (폐기 전 임시 상태) │
└─────────────────────────────────────────────────────────────────────┘
4.3 커넥션 획득 흐름 — 코드 레벨
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
// HikariPool.getConnection() 간략화
public Connection getConnection(long hardTimeout) throws SQLException {
// 1. ConcurrentBag에서 커넥션 빌려오기
final PoolEntry poolEntry = connectionBag.borrow(timeout, MILLISECONDS);
if (poolEntry == null) {
// 타임아웃! 커넥션을 못 얻었다
throw new SQLTransientConnectionException(
"Connection is not available, request timed out after "
+ timeout + "ms."
+ " (total=" + getTotalConnections()
+ ", active=" + getActiveConnections()
+ ", idle=" + getIdleConnections()
+ ", waiting=" + getThreadsAwaitingConnection() + ")"
);
}
// 2. 커넥션 유효성 검증
if (poolEntry.isMarkedEvicted() // maxLifetime 초과?
|| !isConnectionAlive(poolEntry)) { // 살아있는 커넥션인가?
closeConnection(poolEntry); // 죽은 커넥션 폐기
timeout -= (현재시간 - 시작시간);
continue; // 다시 시도 (루프)
}
// 3. Proxy Connection 생성 후 반환
return poolEntry.createProxyConnection(leakTaskFactory.schedule(poolEntry));
}
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
39
40
41
42
43
44
// ConcurrentBag.borrow() 간략화
public T borrow(long timeout, TimeUnit timeUnit) throws InterruptedException {
// ① ThreadLocal 리스트에서 먼저 찾기 (가장 빠름)
final List<Object> list = threadList.get();
for (int i = list.size() - 1; i >= 0; i--) {
final Object entry = list.remove(i);
final T bagEntry = weakThreadLocals ? ((WeakReference<T>) entry).get() : (T) entry;
if (bagEntry != null
&& bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry; // 락 없이 획득 성공!
}
}
// ② 공유 리스트(sharedList) 순회
final int waiting = waiters.incrementAndGet();
try {
for (T bagEntry : sharedList) {
if (bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
if (waiting > 1) {
// 대기자가 있으면 풀 추가 생성 신호
listener.addBagItem(waiting - 1);
}
return bagEntry; // CAS로 획득 성공!
}
}
// 대기자가 있으면 커넥션 추가 생성 요청
listener.addBagItem(waiting);
// ③ handoffQueue에서 대기
do {
T bagEntry = handoffQueue.poll(timeout, timeUnit);
if (bagEntry == null || bagEntry.compareAndSet(STATE_NOT_IN_USE, STATE_IN_USE)) {
return bagEntry; // null이면 타임아웃
}
} while (timeout > 0);
return null;
} finally {
waiters.decrementAndGet();
}
}
4.4 HikariCP vs 다른 커넥션 풀
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
┌──────────────────────────────────────────────────────────────────────┐
│ Java 커넥션 풀 비교 │
├──────────────┬───────────────┬───────────────┬───────────────────────┤
│ │ HikariCP │ DBCP2 │ c3p0 │
├──────────────┼───────────────┼───────────────┼───────────────────────┤
│ Spring Boot │ 기본 (2.0+) │ 1.x 기본 │ 설정 필요 │
│ 기본 여부 │ │ │ │
├──────────────┼───────────────┼───────────────┼───────────────────────┤
│ 아키텍처 │ ConcurrentBag │ GenericObject │ IdentityHashMap │
│ │ (Lock-Free) │ Pool (Lock) │ + LinkedList (Lock) │
├──────────────┼───────────────┼───────────────┼───────────────────────┤
│ Proxy 방식 │ Javassist │ JDK Dynamic │ JDK Dynamic │
│ │ (컴파일타임) │ Proxy │ Proxy │
├──────────────┼───────────────┼───────────────┼───────────────────────┤
│ Statement │ 미지원 │ 지원 │ 지원 │
│ 캐싱 │ (DB 드라이버 │ │ │
│ │ 자체 캐싱에 │ │ │
│ │ 위임) │ │ │
├──────────────┼───────────────┼───────────────┼───────────────────────┤
│ 코드 크기 │ ~4,000줄 │ ~18,000줄 │ ~30,000줄 │
├──────────────┼───────────────┼───────────────┼───────────────────────┤
│ 벤치마크 │ ⭐⭐⭐⭐⭐ │ ⭐⭐⭐ │ ⭐⭐ │
│ (ops/ms) │ ~30,000 │ ~8,000 │ ~3,000 │
├──────────────┼───────────────┼───────────────┼───────────────────────┤
│ 유지보수 │ 활발 │ Apache 재단 │ 거의 중단 │
│ │ (Brett Wooldridge) │ │ │
└──────────────┴───────────────┴───────────────┴───────────────────────┘
참고: c3p0은 레거시 프로젝트에서 가끔 보이지만, 신규 프로젝트에서는 사용하지 않는다.
5. 커넥션 풀 설정 — 실무 핵심
5.1 Spring Boot에서의 HikariCP 설정
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
# application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Seoul
username: app_user
password: ${DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
hikari:
# ── 풀 크기 ──
maximum-pool-size: 10 # 최대 커넥션 수
minimum-idle: 10 # 최소 유휴 커넥션 수 (= max와 동일 권장)
# ── 타임아웃 ──
connection-timeout: 30000 # 커넥션 획득 대기 최대 시간 (30초, ms)
idle-timeout: 600000 # 유휴 커넥션 유지 시간 (10분, ms)
max-lifetime: 1800000 # 커넥션 최대 수명 (30분, ms)
# ── 검증 ──
validation-timeout: 5000 # 커넥션 유효성 검사 타임아웃 (5초)
# ── 누수 감지 ──
leak-detection-threshold: 60000 # 60초 이상 반납 안 하면 경고 로그
# ── 풀 이름 ──
pool-name: MyAppHikariPool
5.2 각 설정의 의미와 권장값
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
┌────────────────────────────────────────────────────────────────────────┐
│ HikariCP 핵심 설정 상세 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ maximum-pool-size ─────────────────────────────────────────────┐ │
│ │ 풀이 유지할 최대 커넥션 수 (idle + in-use) │ │
│ │ 기본값: 10 │ │
│ │ │ │
│ │ 너무 작으면: 요청 대기 → 응답 지연 → 타임아웃 │ │
│ │ 너무 크면: DB 서버 과부하, 컨텍스트 스위칭 증가 │ │
│ │ │ │
│ │ ⚠️ 흔한 실수: "느리니까 풀 크기를 늘리자" → 악화됨 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ minimum-idle ──────────────────────────────────────────────────┐ │
│ │ 유휴 상태로 유지할 최소 커넥션 수 │ │
│ │ 기본값: maximum-pool-size와 동일 │ │
│ │ │ │
│ │ HikariCP 공식 권장: maximum-pool-size와 동일하게 설정 │ │
│ │ → 커넥션 생성/제거 오버헤드를 아예 없앰 (고정 크기 풀) │ │
│ │ → minimum-idle < max면 트래픽에 따라 커넥션이 늘었다 줄었다 │ │
│ │ → 이 과정 자체가 성능 오버헤드 │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ connection-timeout ────────────────────────────────────────────┐ │
│ │ 풀에서 커넥션을 가져올 때 최대 대기 시간 │ │
│ │ 기본값: 30,000ms (30초) │ │
│ │ 최소값: 250ms │ │
│ │ │ │
│ │ 초과 시: SQLTransientConnectionException 발생 │ │
│ │ → "Connection is not available, request timed out after Xms" │ │
│ │ │ │
│ │ 이 에러가 나면 풀 크기를 늘리기 전에: │ │
│ │ 1. 커넥션 누수(leak)가 없는지 확인 │ │
│ │ 2. 트랜잭션이 너무 오래 걸리지 않는지 확인 │ │
│ │ 3. N+1 쿼리 등 불필요한 DB 호출이 있는지 확인 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ max-lifetime ─────────────────────────────────────────────────┐ │
│ │ 커넥션의 최대 수명 (생성 후 폐기까지의 시간) │ │
│ │ 기본값: 1,800,000ms (30분) │ │
│ │ │ │
│ │ 왜 필요한가? │ │
│ │ • DB 서버의 wait_timeout보다 짧아야 한다 │ │
│ │ MySQL wait_timeout 기본값: 28,800초 (8시간) │ │
│ │ → max-lifetime은 이보다 몇 분 짧게 설정 │ │
│ │ • 방화벽/로드밸런서가 idle 연결을 끊을 수 있음 │ │
│ │ • 너무 오래된 커넥션은 네트워크 상태가 불안정할 수 있음 │ │
│ │ │ │
│ │ HikariCP는 폐기 시점에 2.5% 지터(jitter)를 추가 │ │
│ │ → 모든 커넥션이 동시에 만료되는 것을 방지 │ │
│ │ → "커넥션 폭풍(connection storm)" 방지 │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ idle-timeout ─────────────────────────────────────────────────┐ │
│ │ 유휴 커넥션이 풀에서 제거되기까지의 시간 │ │
│ │ 기본값: 600,000ms (10분) │ │
│ │ │ │
│ │ minimumIdle == maximumPoolSize이면 이 설정은 무시됨 │ │
│ │ → 커넥션을 줄일 일이 없으므로 │ │
│ └──────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ leak-detection-threshold ─────────────────────────────────────┐ │
│ │ 커넥션 누수 감지 임계값 │ │
│ │ 기본값: 0 (비활성) │ │
│ │ 권장값: 60,000ms (개발/스테이징), 0 (프로덕션) │ │
│ │ │ │
│ │ 설정하면 커넥션을 빌려간 후 이 시간 안에 반납하지 않으면 │ │
│ │ 경고 로그 + 빌려간 시점의 스택 트레이스 출력 │ │
│ │ → 누수 원인을 정확히 찾을 수 있음 │ │
│ └──────────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
5.3 풀 크기 산정 — 공식과 원칙
커넥션 풀 크기를 정하는 것은 가장 많이 받는 질문 중 하나다.
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
39
40
41
42
43
44
45
46
47
48
49
50
┌────────────────────────────────────────────────────────────────────────┐
│ 커넥션 풀 크기 산정 가이드 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ■ 공식 (PostgreSQL wiki 기반, 대부분의 DB에 적용 가능) │
│ │
│ Pool Size = (core_count × 2) + effective_spindle_count │
│ │
│ core_count: CPU 코어 수 │
│ effective_spindle_count: HDD 디스크 수 (SSD면 0 또는 1) │
│ │
│ 예시: 4코어 서버, SSD 1개 │
│ → Pool Size = (4 × 2) + 1 = 9 ≈ 10 │
│ │
│ ■ 왜 코어 수의 2배인가? │
│ │
│ CPU 바운드 작업: 코어 수만큼의 스레드가 최적 │
│ I/O 바운드 작업: I/O 대기 중 다른 스레드가 CPU 사용 가능 │
│ DB 작업은 I/O 바운드 → 코어당 2개 스레드가 대략 최적 │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ "적을수록 좋다" 원칙 │ │
│ │ │ │
│ │ 직관에 반하지만, 풀 크기는 작을수록 성능이 좋다. │ │
│ │ │ │
│ │ 예: 10,000 동시 사용자 서비스 │ │
│ │ • 풀 크기 10으로도 9,999개 요청을 밀리초 단위로 처리 가능 │ │
│ │ • 풀 크기 100이면 오히려 더 느려질 수 있음 │ │
│ │ │ │
│ │ 이유: │ │
│ │ ① DB의 CPU 코어 수는 제한적 │ │
│ │ → 100개 쿼리가 동시에 실행되면 컨텍스트 스위칭 폭증 │ │
│ │ ② 디스크 I/O 경합 │ │
│ │ → 동시 I/O 요청이 많으면 랜덤 I/O 비율 증가 │ │
│ │ ③ 락 경합 │ │
│ │ → 동시에 같은 데이터에 접근하면 Lock Wait 증가 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ■ 실무 가이드 │
│ │
│ 서비스 규모 권장 풀 크기 비고 │
│ ───────────────────────────────────────────────────────── │
│ 소규모 (< 100 TPS) 5~10 기본값으로 충분 │
│ 중규모 (100~1K TPS) 10~20 모니터링 기반 조정 │
│ 대규모 (> 1K TPS) 20~50 DB 서버 max_connections 고려 │
│ │
│ ⚠️ 주의: 서버 인스턴스가 여러 대면 합산해야 한다 │
│ 서버 5대 × 풀 크기 20 = DB에 동시 100개 커넥션 │
│ → DB max_connections(151 기본)을 초과하지 않도록 │
└────────────────────────────────────────────────────────────────────────┘
5.4 풀 크기를 잘못 잡으면 생기는 일
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
┌────────────────────────────────────────────────────────────────────────┐
│ Case 1: 풀이 너무 작을 때 (pool-size = 2, 동시 요청 = 50) │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ Thread-1 ████████████████░░░░░░░░░░ (커넥션 C1 사용 중) │
│ Thread-2 ████████████████░░░░░░░░░░ (커넥션 C2 사용 중) │
│ Thread-3 ░░░░░░░░░░░░░░░░░░░░░░░░░░ 대기... 대기... 타임아웃! │
│ Thread-4 ░░░░░░░░░░░░░░░░░░░░░░░░░░ 대기... 대기... 타임아웃! │
│ ... │
│ Thread-50 ░░░░░░░░░░░░░░░░░░░░░░░░░░ 대기... 대기... 타임아웃! │
│ │
│ 증상: SQLTransientConnectionException 폭발 │
│ → 사용자에게 500 에러 응답 │
│ → 톰캣 스레드 풀도 꽉 참 (커넥션 대기하는 스레드가 쌓임) │
│ → 연쇄 장애 (cascading failure) │
├────────────────────────────────────────────────────────────────────────┤
│ Case 2: 풀이 너무 클 때 (pool-size = 200) │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ DB 서버 (4코어): │
│ CPU: ████████████████████ 100% │
│ → 200개 쿼리가 동시 실행 시도 │
│ → 실제로 4개만 병렬 처리, 나머지 196개는 OS 레벨에서 대기 │
│ → 컨텍스트 스위칭만 수만 번/초 │
│ → 각 쿼리의 응답 시간이 50ms → 500ms로 증가 │
│ → 모든 요청이 균등하게 느려짐 │
│ │
│ 메모리: │
│ 200 커넥션 × 10MB/세션 = 2GB (세션 메모리만) │
│ → DB 서버의 버퍼 풀(innodb_buffer_pool_size)에 쓸 메모리 부족 │
│ → 캐시 히트율 하락 → 디스크 I/O 증가 → 더 느려짐 │
└────────────────────────────────────────────────────────────────────────┘
6. 커넥션 누수(Connection Leak)
6.1 누수란 무엇인가
커넥션을 빌려갔는데 반납하지 않는 것이다. 풀의 모든 커넥션이 누수되면, 새로운 요청은 영원히 커넥션을 얻지 못한다.
1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 커넥션 누수 — 예외 발생 시 close()가 호출되지 않음
public User findUser(Long id) {
Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?");
ps.setLong(1, id);
ResultSet rs = ps.executeQuery();
User user = mapToUser(rs);
conn.close(); // 여기까지 도달 못하면? → 누수!
return user;
}
1
2
3
4
5
6
7
8
9
10
11
// ✅ try-with-resources — 예외가 발생해도 반드시 close()
public User findUser(Long id) {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("SELECT * FROM users WHERE id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return mapToUser(rs);
}
} // 자동 close() → 풀에 반납
}
6.2 Spring/@Transactional 환경에서의 누수 패턴
JPA와 Spring을 사용하면 직접 커넥션을 다루지 않으므로 누수가 없을 것 같지만, 함정이 있다.
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
// ❌ 트랜잭션 밖에서 LazyLoading — OSIV가 꺼져있으면 예외, 켜져있으면 커넥션 장기 점유
@Service
public class OrderService {
@Transactional(readOnly = true)
public Order getOrder(Long id) {
return orderRepository.findById(id).orElseThrow();
// order.getItems()는 Lazy → 여기서 로딩 안 됨
}
}
@RestController
public class OrderController {
@GetMapping("/orders/{id}")
public OrderResponse getOrder(@PathVariable Long id) {
Order order = orderService.getOrder(id);
// OSIV ON: 여기서 Lazy 로딩 → 컨트롤러에서 DB 커넥션 사용 중!
// 이 동안 외부 API 호출이 있으면?
List<OrderItem> items = order.getItems(); // 추가 쿼리 발생
externalApiClient.sendNotification(order); // 3초 걸림
// → 3초 동안 DB 커넥션을 점유하면서 아무 쿼리도 안 보냄
// → 사실상 커넥션 누수와 같은 효과
return OrderResponse.from(order, items);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
┌────────────────────────────────────────────────────────────────────┐
│ OSIV ON에서 커넥션 점유 타임라인 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 요청 시작 요청 끝 │
│ ──┬───────────────────────────────────────────┬── │
│ │ 커넥션 획득 │ 커넥션 반납 │
│ │◄────────────────────────────────────────►│ │
│ │ │ │
│ │ @Transactional │ 컨트롤러 로직 │ │
│ │ ████ (1ms) │ ░░░░░░░░░ (3,000ms) │ │
│ │ 쿼리 실행 │ 외부 API 호출 │ │
│ │ │ DB와 무관한 작업! │ │
│ │ │ 그런데 커넥션은 점유 중 │ │
│ │
│ 커넥션 실 사용: 1ms │
│ 커넥션 점유 시간: 3,001ms │
│ 낭비율: 99.97% │
└────────────────────────────────────────────────────────────────────┘
6.3 HikariCP 누수 감지 설정
1
2
3
4
spring:
datasource:
hikari:
leak-detection-threshold: 60000 # 60초
1
2
3
4
5
6
7
8
9
10
11
12
// 누수 감지 시 출력되는 로그 (매우 유용)
WARN com.zaxxer.hikari.pool.ProxyLeakTask -
Connection leak detection triggered for com.mysql.cj.jdbc.ConnectionImpl@3a1b5c,
on thread http-nio-8080-exec-7,
stack trace follows
java.lang.Exception: Apparent connection leak detected
at com.zaxxer.hikari.HikariDataSource.getConnection(HikariDataSource.java:128)
at com.example.service.UserService.findSlowUser(UserService.java:42) ← 여기!
at com.example.controller.UserController.getUser(UserController.java:28)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
...
7. 커넥션 유효성 검증
7.1 왜 검증이 필요한가
풀에 보관 중인 커넥션이 죽어있을 수 있다. DB 서버가 재시작되었거나, 방화벽이 유휴 연결을 끊었거나, 네트워크 장애가 있었을 때.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
┌────────────────────────────────────────────────────────────────────┐
│ 커넥션이 죽는 시나리오 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ 1. DB 서버의 wait_timeout 초과 │
│ MySQL 기본 wait_timeout = 28,800초 (8시간) │
│ → 8시간 동안 쿼리 안 보내면 DB가 세션을 강제 종료 │
│ → 풀의 커넥션은 TCP 소켓이 살아있다고 착각 │
│ → 쿼리 보내면 "Communications link failure" 에러 │
│ │
│ 2. 방화벽/NAT 타임아웃 │
│ AWS Security Group, 기업 방화벽 등이 │
│ 유휴 TCP 연결을 일정 시간(5분~1시간) 후 끊음 │
│ → 양쪽 모두 연결이 끊겼는지 모름 (half-open) │
│ │
│ 3. DB 서버 재시작 / Failover │
│ → 모든 커넥션이 한꺼번에 무효화 │
│ → 풀이 이를 감지하고 새 커넥션으로 교체해야 함 │
│ │
│ 4. 네트워크 순단 │
│ → TCP keepalive가 없으면 감지 못함 │
│ → 쿼리 보내야 비로소 감지 │
└────────────────────────────────────────────────────────────────────┘
7.2 검증 방식
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
┌────────────────────────────────────────────────────────────────────────┐
│ 커넥션 유효성 검증 방식 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ JDBC4 isValid() (HikariCP 기본) ────────────────────────────┐ │
│ │ connection.isValid(validationTimeout) │ │
│ │ → 드라이버가 경량 ping 패킷 전송 │ │
│ │ → MySQL: COM_PING 전송 (쿼리 파싱 없음, 매우 빠름) │ │
│ │ → PostgreSQL: 빈 쿼리 전송 │ │
│ │ → 별도 설정 불필요, JDBC4 드라이버면 자동 │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Validation Query (레거시 방식) ──────────────────────────────┐ │
│ │ connectionTestQuery: "SELECT 1" │ │
│ │ → 실제 SQL 쿼리를 실행하여 검증 │ │
│ │ → 파싱, 실행 계획, 결과 반환 등 불필요한 오버헤드 │ │
│ │ → JDBC4 이전 드라이버에서만 사용 │ │
│ │ │ │
│ │ HikariCP 로그: │ │
│ │ WARN - Driver does not support isValid(), │ │
│ │ falling back to test query: "SELECT 1" │ │
│ │ │ │
│ │ DB별 Validation Query: │ │
│ │ • MySQL/PostgreSQL: SELECT 1 │ │
│ │ • Oracle: SELECT 1 FROM DUAL │ │
│ │ • H2: SELECT 1 │ │
│ └───────────────────────────────────────────────────────────────┘ │
│ │
│ 검증 시점 (HikariCP): │
│ • 풀에서 커넥션을 꺼낼 때 (borrow 시) │
│ • maxLifetime 도래 시 │
│ • HouseKeeper가 30초마다 idle 커넥션 점검 │
└────────────────────────────────────────────────────────────────────────┘
8. Spring Boot 자동 구성과 커넥션 풀
8.1 Spring Boot가 HikariCP를 선택하는 과정
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
┌────────────────────────────────────────────────────────────────────────┐
│ DataSource 자동 구성 (DataSourceAutoConfiguration) │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ spring-boot-starter-data-jpa 또는 spring-boot-starter-jdbc │
│ │ │
│ │ 의존성에 포함: │
│ │ └── com.zaxxer:HikariCP (Spring Boot 2.0+부터 기본) │
│ │ │
│ ▼ │
│ DataSourceAutoConfiguration │
│ │ │
│ ├── @ConditionalOnClass(DataSource.class) ✅ │
│ ├── @ConditionalOnMissingBean(DataSource.class) │
│ │ → 사용자가 직접 Bean 등록 안 했으면 자동 구성 │
│ │ │
│ ▼ │
│ PooledDataSourceConfiguration │
│ │ │
│ │ 우선순위: │
│ │ ① HikariCP가 클래스패스에 있으면 → HikariDataSource │
│ │ ② 없으면 Tomcat JDBC Pool → TomcatDataSource │
│ │ ③ 없으면 DBCP2 → BasicDataSource │
│ │ ④ 아무것도 없으면 → 에러 │
│ │ │
│ ▼ │
│ HikariDataSource 생성 │
│ │ │
│ │ spring.datasource.hikari.* 프로퍼티 바인딩 │
│ │ → maximum-pool-size, connection-timeout 등 │
│ │ │
│ ▼ │
│ EntityManagerFactory가 이 DataSource를 주입받음 │
│ → JPA Repository가 이 EntityManager를 사용 │
│ → 모든 DB 접근이 HikariCP를 통해 이루어짐 │
└────────────────────────────────────────────────────────────────────────┘
8.2 JPA/Hibernate와 커넥션 풀의 상호작용
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
┌────────────────────────────────────────────────────────────────────────┐
│ @Transactional 메서드 실행 시 커넥션 풀 동작 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ @Transactional │
│ public void createOrder(OrderRequest req) { │
│ Order order = new Order(req); // ① │
│ orderRepository.save(order); // ② │
│ itemRepository.saveAll(req.items()); // ③ │
│ } │
│ │
│ ① 트랜잭션 시작 (TransactionInterceptor) │
│ │ │
│ ├── PlatformTransactionManager.getTransaction() │
│ ├── DataSourceUtils.doGetConnection(dataSource) │
│ │ └── HikariDataSource.getConnection() │
│ │ └── HikariPool.getConnection() │
│ │ └── ConcurrentBag.borrow() │
│ │ └── PoolEntry 획득! │
│ │ │
│ ├── connection.setAutoCommit(false) │
│ └── ThreadLocal에 Connection 바인딩 │
│ └── TransactionSynchronizationManager │
│ .bindResource(dataSource, connectionHolder) │
│ │
│ ② save() 호출 │
│ │ │
│ ├── EntityManager.persist(order) │
│ └── Hibernate: INSERT SQL은 아직 안 보냄 (쓰기 지연) │
│ → Connection은 이미 ThreadLocal에 있음 │
│ │
│ ③ saveAll() 호출 │
│ │ │
│ └── 같은 ThreadLocal의 같은 Connection 사용 │
│ │
│ 메서드 종료 → 트랜잭션 커밋 │
│ │ │
│ ├── EntityManager.flush() │
│ │ └── 쓰기 지연 저장소의 SQL을 한꺼번에 전송 │
│ │ → INSERT order, INSERT items (batch) │
│ │ │
│ ├── connection.commit() │
│ │ │
│ ├── ThreadLocal에서 Connection 제거 │
│ │ └── unbindResource(dataSource) │
│ │ │
│ └── connection.close() │
│ └── HikariProxyConnection.close() │
│ └── PoolEntry를 ConcurrentBag에 반납 │
│ └── 대기 스레드에 통지 │
│ │
│ 핵심: 하나의 @Transactional 메서드 = 하나의 커넥션으로 처리 │
│ 같은 트랜잭션 안에서 여러 Repository를 호출해도 │
│ ThreadLocal 덕분에 같은 커넥션을 공유 │
└────────────────────────────────────────────────────────────────────────┘
8.3 다중 DataSource 환경에서의 커넥션 풀
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
public class DataSourceConfig {
// 각 DataSource마다 별도의 HikariCP 풀이 생성됨
@Bean
@Primary
@ConfigurationProperties("spring.datasource.primary.hikari")
public HikariDataSource primaryDataSource() {
return HikariDataSourceBuilder.create().build();
}
@Bean
@ConfigurationProperties("spring.datasource.secondary.hikari")
public HikariDataSource secondaryDataSource() {
return HikariDataSourceBuilder.create().build();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
spring:
datasource:
primary:
hikari:
jdbc-url: jdbc:mysql://master-db:3306/main
maximum-pool-size: 10
pool-name: PrimaryPool
secondary:
hikari:
jdbc-url: jdbc:mysql://analytics-db:3306/analytics
maximum-pool-size: 5
pool-name: SecondaryPool
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌────────────────────────────────────────────────────────────────┐
│ 다중 DataSource = 다중 커넥션 풀 │
├────────────────────────────────────────────────────────────────┤
│ │
│ Application │
│ ┌──────────────────────────────────────────────────┐ │
│ │ │ │
│ │ ┌─ PrimaryPool (10개) ──┐ │ │
│ │ │ C1 C2 C3 ... C10 │──────→ Master DB │ │
│ │ └───────────────────────┘ │ │
│ │ │ │
│ │ ┌─ SecondaryPool (5개) ─┐ │ │
│ │ │ C1 C2 C3 C4 C5 │──────→ Analytics DB │ │
│ │ └───────────────────────┘ │ │
│ │ │ │
│ │ 총 DB 커넥션: 15개 │ │
│ └──────────────────────────────────────────────────┘ │
│ │
│ ⚠️ 주의: 각 풀의 크기 합이 DB의 max_connections을 넘지 않도록│
└────────────────────────────────────────────────────────────────┘
9. 커넥션 풀 모니터링
9.1 HikariCP 메트릭 (Micrometer)
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
// Spring Boot Actuator + Micrometer 연동 시 자동 수집되는 메트릭
// 1. 활성 커넥션 수
hikaricp_connections_active{pool="MyAppHikariPool"}
// 2. 유휴 커넥션 수
hikaricp_connections_idle{pool="MyAppHikariPool"}
// 3. 전체 커넥션 수
hikaricp_connections{pool="MyAppHikariPool"}
// 4. 대기 중인 스레드 수 ← 이것이 가장 중요!
hikaricp_connections_pending{pool="MyAppHikariPool"}
// 5. 커넥션 획득 시간 (histogram)
hikaricp_connections_acquire_seconds{pool="MyAppHikariPool"}
// 6. 커넥션 사용 시간 (histogram)
hikaricp_connections_usage_seconds{pool="MyAppHikariPool"}
// 7. 커넥션 생성 시간
hikaricp_connections_creation_seconds{pool="MyAppHikariPool"}
// 8. 타임아웃 횟수
hikaricp_connections_timeout_total{pool="MyAppHikariPool"}
9.2 모니터링 핵심 지표와 알람 기준
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
39
40
┌────────────────────────────────────────────────────────────────────────┐
│ 커넥션 풀 모니터링 대시보드 (Grafana 기준) │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ■ 필수 패널 4개 │
│ │
│ ┌─ 1. 커넥션 상태 ──────────────────────────────────────────┐ │
│ │ active: 현재 사용 중인 커넥션 수 │ │
│ │ idle: 대기 중인 커넥션 수 │ │
│ │ total: 전체 커넥션 수 │ │
│ │ │ │
│ │ 정상: active가 total의 70% 미만 │ │
│ │ 경고: active가 total의 80% 이상 → 풀 포화 임박 │ │
│ │ 위험: active == total → 더 이상 여유 없음 │ │
│ └────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 2. 대기 스레드 (pending) ─────────────────────────────────┐ │
│ │ 커넥션을 얻기 위해 대기 중인 스레드 수 │ │
│ │ │ │
│ │ 정상: 0 │ │
│ │ 경고: 1 이상이 지속 → 풀 크기 부족 또는 누수 │ │
│ │ 위험: 10 이상 → 즉시 조치 필요 │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 3. 커넥션 획득 시간 (acquire time) ───────────────────────┐ │
│ │ 풀에서 커넥션을 얻는 데 걸린 시간 │ │
│ │ │ │
│ │ 정상: < 1ms (풀에 여유 있을 때) │ │
│ │ 경고: > 100ms (풀 경합 시작) │ │
│ │ 위험: > 1,000ms (풀 포화) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ 4. 커넥션 사용 시간 (usage time) ────────────────────────┐ │
│ │ 커넥션을 빌려서 반납하기까지의 시간 │ │
│ │ │ │
│ │ 정상: < 100ms (단순 CRUD) │ │
│ │ 경고: > 1,000ms (복잡한 쿼리 또는 트랜잭션 길어짐) │ │
│ │ 위험: > 10,000ms (누수 의심) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
10. 커넥션 풀 트러블슈팅
10.1 자주 발생하는 문제와 해결
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
┌────────────────────────────────────────────────────────────────────────┐
│ Problem 1: "Connection is not available, request timed out" │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 원인 파악 순서: │
│ │
│ ① 커넥션 누수 확인 │
│ → leak-detection-threshold 설정 후 로그 확인 │
│ → 풀이 꽉 찬 상태에서 active가 줄어들지 않으면 누수 │
│ │
│ ② 느린 쿼리 확인 │
│ → slow query log 활성화 │
│ → 특정 쿼리가 커넥션을 오래 점유하고 있지 않은지 │
│ │
│ ③ 트랜잭션 범위 확인 │
│ → @Transactional이 너무 넓은 범위에 걸려있지 않은지 │
│ → 트랜잭션 안에서 외부 API 호출하고 있지 않은지 │
│ │
│ ④ N+1 쿼리 확인 │
│ → 하나의 요청에서 수백 개 쿼리가 발생하면 │
│ 커넥션 점유 시간이 길어짐 │
│ │
│ ⑤ 그래도 부족하면 풀 크기 조정 │
│ → 위 원인을 모두 해결한 뒤에, 마지막 수단으로 │
├────────────────────────────────────────────────────────────────────────┤
│ Problem 2: "Connection refused" / "Too many connections" │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ DB 서버의 max_connections 한계 초과 │
│ │
│ 확인: │
│ SHOW VARIABLES LIKE 'max_connections'; -- 151 (MySQL 기본값) │
│ SHOW STATUS LIKE 'Threads_connected'; -- 현재 연결 수 │
│ │
│ 해결: │
│ ① 각 서버의 풀 크기 합 < max_connections인지 확인 │
│ 서버 3대 × pool 20 = 60 < 151 ✅ │
│ 서버 10대 × pool 20 = 200 > 151 ❌ → 풀 줄이거나 max 늘리기 │
│ ② max_connections을 늘릴 때는 DB 서버 메모리 충분한지 확인 │
│ → max_connections × 세션 메모리 < 사용 가능 메모리 │
├────────────────────────────────────────────────────────────────────────┤
│ Problem 3: 간헐적 "Communications link failure" │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 풀에 보관 중이던 커넥션이 죽어있었음 │
│ │
│ 해결: │
│ ① max-lifetime을 DB wait_timeout보다 짧게 설정 │
│ max-lifetime: 1740000 (29분) │
│ MySQL wait_timeout: 1800 (30분) → 30분보다 짧게 │
│ │
│ ② 방화벽 idle timeout보다 짧게 설정 │
│ AWS ALB idle timeout: 60초 기본 │
│ → TCP keepalive 또는 max-lifetime으로 대응 │
└────────────────────────────────────────────────────────────────────────┘
10.2 데드락과 커넥션 풀
커넥션 풀이 작고 중첩 트랜잭션이 필요하면, 풀 레벨 데드락이 발생할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ❌ 풀 레벨 데드락 시나리오
@Transactional
public void processOrder(Long orderId) {
// 커넥션 C1 획득
Order order = orderRepository.findById(orderId).orElseThrow();
// 이 안에서 새 트랜잭션이 필요한 메서드 호출
auditService.logAction(orderId, "PROCESSING");
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void logAction(Long targetId, String action) {
// REQUIRES_NEW → 새 커넥션 C2 필요!
auditRepository.save(new AuditLog(targetId, action));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌────────────────────────────────────────────────────────────────────────┐
│ 풀 레벨 데드락 (pool size = 2, 동시 요청 2개) │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ Thread-1: processOrder() → C1 획득 │
│ └→ auditService.logAction() → C2 필요... 대기 │
│ │
│ Thread-2: processOrder() → C2 획득 │
│ └→ auditService.logAction() → C? 필요... 대기 │
│ │
│ C1은 Thread-1이 들고 있지만, Thread-1은 C2가 필요 │
│ C2는 Thread-2가 들고 있지만, Thread-2도 새 커넥션이 필요 │
│ → 풀에 남은 커넥션: 0개 │
│ → 양쪽 모두 영원히 대기 → 데드락! │
│ │
│ 해결: │
│ ① 풀 크기를 최대 동시 중첩 트랜잭션 수 이상으로 설정 │
│ ② REQUIRES_NEW 사용을 최소화 │
│ ③ 이벤트 기반으로 분리 (비동기 로깅) │
│ @TransactionalEventListener 활용 │
└────────────────────────────────────────────────────────────────────────┘
11. PostgreSQL과 PgBouncer — 외부 커넥션 풀러
PostgreSQL은 프로세스 기반이라 커넥션 생성 비용이 특히 크다. 그래서 외부 커넥션 풀러를 사용하는 경우가 많다.
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
39
40
┌────────────────────────────────────────────────────────────────────────┐
│ PgBouncer — PostgreSQL 전용 외부 커넥션 풀러 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ Without PgBouncer: │
│ ┌────────────┐ ┌─────────────────────────────┐ │
│ │ App 서버 1 │─ 20 ─→│ │ │
│ │ (pool=20) │ │ │ │
│ ├────────────┤ │ PostgreSQL │ │
│ │ App 서버 2 │─ 20 ─→│ 동시 프로세스: 60개 │ │
│ │ (pool=20) │ │ 메모리: ~600MB │ │
│ ├────────────┤ │ │ │
│ │ App 서버 3 │─ 20 ─→│ │ │
│ │ (pool=20) │ └─────────────────────────────┘ │
│ └────────────┘ │
│ │
│ With PgBouncer: │
│ ┌────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ App 서버 1 │─ 20 ─→│ │ │ │ │
│ │ (pool=20) │ │ PgBouncer │ │ PostgreSQL │ │
│ ├────────────┤ │ │─10→│ 동시 프로세스: │ │
│ │ App 서버 2 │─ 20 ─→│ 60개 클라이언│ │ 10개 │ │
│ │ (pool=20) │ │ 트 커넥션을 │ │ 메모리: ~100MB │ │
│ ├────────────┤ │ 10개 서버 │ │ │ │
│ │ App 서버 3 │─ 20 ─→│ 커넥션으로 │ │ │ │
│ │ (pool=20) │ │ 다중화 │ │ │ │
│ └────────────┘ └──────────────┘ └──────────────────┘ │
│ │
│ PgBouncer 풀링 모드: │
│ ┌─────────────┬──────────────────────────────────────────────┐ │
│ │ Session │ 클라이언트 연결 동안 서버 커넥션 1:1 매핑 │ │
│ │ (세션 모드) │ → 풀링 효과 적음, 모든 기능 사용 가능 │ │
│ ├─────────────┼──────────────────────────────────────────────┤ │
│ │ Transaction │ 트랜잭션 단위로 서버 커넥션 할당/반납 │ │
│ │ (트랜잭션) │ → 가장 많이 사용. SET 명령 주의 │ │
│ ├─────────────┼──────────────────────────────────────────────┤ │
│ │ Statement │ 쿼리 단위로 서버 커넥션 할당/반납 │ │
│ │ (문장 모드) │ → 멀티 스테이트먼트 트랜잭션 불가 │ │
│ └─────────────┴──────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
12. 커넥션 풀 워밍업과 초기화 전략
12.1 Lazy vs Eager 초기화
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
┌────────────────────────────────────────────────────────────────────────┐
│ 커넥션 풀 초기화 전략 비교 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ■ Lazy 초기화 (HikariCP 기본) │
│ 첫 번째 getConnection() 호출 시 풀 초기화 │
│ │
│ 서버 시작 ────── 첫 요청 ────── 풀 초기화(커넥션 N개 생성) │
│ ↑ │
│ │ 이 요청은 커넥션 생성 시간만큼 느림 │
│ │ ~50-200ms 지연 │
│ │
│ 장점: 서버 시작이 빠름 │
│ 단점: 첫 번째 요청(cold start)이 느림 │
│ │
│ ■ Eager 초기화 (권장) │
│ 서버 시작 시 즉시 풀 초기화 │
│ │
│ 서버 시작(풀 초기화) ────── 첫 요청 ────── 즉시 커넥션 제공 │
│ ↑ │
│ │ 시작이 약간 느리지만 │
│ │ 첫 요청부터 빠름 │
│ │
│ HikariCP 설정: │
│ initializationFailTimeout: 1 (기본값, ms) │
│ → 양수면 시작 시 커넥션 생성 시도 (eager) │
│ → -1이면 lazy 초기화 │
│ → 0이면 커넥션 생성을 비동기로 시도 (실패해도 시작) │
└────────────────────────────────────────────────────────────────────────┘
12.2 실무에서의 워밍업 패턴
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
39
40
41
42
// ① Spring Boot에서 Eager 워밍업 보장
@Component
public class ConnectionPoolWarmer implements ApplicationRunner {
private final DataSource dataSource;
public ConnectionPoolWarmer(DataSource dataSource) {
this.dataSource = dataSource;
}
@Override
public void run(ApplicationArguments args) throws Exception {
// 서버 시작 시 커넥션 풀 초기화 확인
try (Connection conn = dataSource.getConnection()) {
log.info("Connection pool warmed up. DB: {}",
conn.getMetaData().getDatabaseProductName());
}
}
}
// ② Health Check 연동 — 커넥션 풀이 준비되기 전에 트래픽을 받지 않도록
@Component
public class ConnectionPoolHealthIndicator implements HealthIndicator {
private final HikariDataSource dataSource;
@Override
public Health health() {
HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
if (pool.getTotalConnections() == 0) {
return Health.down().withDetail("reason", "Pool not initialized").build();
}
if (pool.getThreadsAwaitingConnection() > 5) {
return Health.down().withDetail("pending", pool.getThreadsAwaitingConnection()).build();
}
return Health.up()
.withDetail("active", pool.getActiveConnections())
.withDetail("idle", pool.getIdleConnections())
.withDetail("total", pool.getTotalConnections())
.build();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
┌────────────────────────────────────────────────────────────────────┐
│ Kubernetes 환경에서의 워밍업 중요성 │
├────────────────────────────────────────────────────────────────────┤
│ │
│ K8s Readiness Probe → 커넥션 풀 준비 확인 │
│ │
│ Pod 생성 → 컨테이너 시작 → 커넥션 풀 초기화 │
│ ↓ ↓ │
│ Liveness Probe OK Readiness Probe OK │
│ (프로세스 살아있음) (트래픽 받을 준비 완료) │
│ ↓ │
│ Service에 등록 → 트래픽 유입 │
│ │
│ Readiness 없이 트래픽을 받으면: │
│ → 커넥션 풀 미초기화 상태에서 요청 처리 │
│ → 첫 수십 요청이 cold start → 응답 지연 → 타임아웃 │
│ → 롤링 배포 시 순간적 서비스 품질 저하 │
└────────────────────────────────────────────────────────────────────┘
13. 클라우드 환경의 커넥션 풀 — RDS Proxy, ProxySQL
13.1 왜 클라우드에서 추가 프록시가 필요한가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌────────────────────────────────────────────────────────────────────────┐
│ 서버리스/오토스케일링 환경의 커넥션 문제 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 전통적 환경: │
│ 서버 3대 × pool 10 = DB 커넥션 30개 (예측 가능) │
│ │
│ 오토스케일링 환경: │
│ 평소: 서버 3대 × pool 10 = 30개 │
│ 피크: 서버 20대 × pool 10 = 200개 (갑자기 증가!) │
│ → DB max_connections(151) 초과 → "Too many connections" │
│ │
│ 서버리스(AWS Lambda) 환경: │
│ 동시 Lambda 500개 × 각각 커넥션 1개 = 500개 │
│ → Lambda는 함수 인스턴스마다 별도 커넥션 풀 │
│ → HikariCP 풀링이 사실상 무의미 (인스턴스당 1~2개 사용) │
│ → DB 커넥션 폭증 │
│ │
│ 해결: DB 앞에 커넥션 프록시를 두어 다중화 │
└────────────────────────────────────────────────────────────────────────┘
13.2 AWS RDS Proxy
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
┌────────────────────────────────────────────────────────────────────────┐
│ AWS RDS Proxy 아키텍처 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ │
│ │ Lambda 1 │─┐ │
│ ├──────────┤ │ ┌─────────────────┐ ┌──────────────┐ │
│ │ Lambda 2 │─┤ │ │ │ │ │
│ ├──────────┤ ├────→│ RDS Proxy │─────→│ RDS (DB) │ │
│ │ Lambda 3 │─┤ │ │ 20 │ │ │
│ ├──────────┤ │ 500 │ 500개 클라이언트│ 개 │ max_conn: │ │
│ │ ... │─┤ 개 │ 커넥션을 20개 │ 서버│ 100 │ │
│ ├──────────┤ │ │ DB 커넥션으로 │ 커넥│ │ │
│ │ Lambda N │─┘ │ 다중화 │ 션 │ │ │
│ └──────────┘ └─────────────────┘ └──────────────┘ │
│ │
│ RDS Proxy의 장점: │
│ ① 커넥션 다중화 — 수백 개 연결을 소수 DB 커넥션으로 처리 │
│ ② Failover 시간 단축 — 마스터 장애 시 ~1초 내 새 마스터로 전환 │
│ (직접 연결 시 DNS 전파 대기 = ~30초) │
│ ③ IAM 인증 지원 — DB 비밀번호 대신 IAM 역할로 인증 │
│ ④ Secrets Manager 연동 — DB 자격증명 자동 교체 │
│ │
│ 사용 시 주의: │
│ • 핀닝(Pinning): 세션 변수 변경, PREPARE 등 사용 시 │
│ 특정 DB 커넥션에 고정 → 다중화 효과 감소 │
│ • 지연 추가: 프록시 경유로 ~1-5ms 추가 지연 │
│ • 비용: DB 인스턴스 vCPU당 요금 부과 │
└────────────────────────────────────────────────────────────────────────┘
13.3 ProxySQL (MySQL 전용 오픈소스)
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
┌────────────────────────────────────────────────────────────────────────┐
│ ProxySQL — MySQL 전용 고성능 프록시 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ PgBouncer의 MySQL 버전이라고 생각하면 쉽다. │
│ │
│ ┌────────────┐ ┌──────────────────┐ ┌───────────────┐ │
│ │ App Server │─────→│ │────→│ MySQL Master │ │
│ │ (HikariCP) │ │ ProxySQL │ │ (Write) │ │
│ └────────────┘ │ │ └───────────────┘ │
│ │ ■ 쿼리 라우팅 │ │
│ │ SELECT → Slave │ ┌───────────────┐ │
│ │ INSERT → Master│────→│ MySQL Slave │ │
│ │ │ │ (Read) │ │
│ │ ■ 쿼리 캐싱 │ └───────────────┘ │
│ │ ■ 커넥션 다중화 │ │
│ │ ■ 쿼리 미러링 │ │
│ └──────────────────┘ │
│ │
│ 주요 기능: │
│ • Read/Write Splitting — 자동 읽기/쓰기 분리 │
│ • Query Caching — 자주 실행되는 SELECT 결과 캐싱 │
│ • Connection Multiplexing — PgBouncer와 동일한 다중화 │
│ • Query Firewall — 위험한 쿼리(DROP, DELETE without WHERE) 차단 │
│ • Failover 자동 처리 — 마스터 장애 시 슬레이브 승격 인식 │
└────────────────────────────────────────────────────────────────────────┘
14. 커넥션 풀과 스레드 풀의 관계
14.1 Tomcat 스레드 풀 vs HikariCP 커넥션 풀
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
39
40
41
42
43
44
45
46
┌────────────────────────────────────────────────────────────────────────┐
│ 두 풀의 관계 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ Tomcat Thread Pool ─────────┐ │
│ │ HTTP 요청을 처리하는 스레드 │ │
│ │ 기본: max 200개 │ │
│ │ server.tomcat.threads.max │ │
│ └──────────────┬───────────────┘ │
│ │ │
│ │ 각 스레드가 DB 작업 시 │
│ │ 커넥션을 빌려감 │
│ ▼ │
│ ┌─ HikariCP Connection Pool ──┐ │
│ │ DB 커넥션을 관리 │ │
│ │ 기본: max 10개 │ │
│ │ spring.datasource.hikari │ │
│ │ .maximum-pool-size │ │
│ └──────────────────────────────┘ │
│ │
│ Tomcat 스레드 200개 vs HikariCP 커넥션 10개 │
│ → 동시에 200개 요청이 들어와도 DB 작업은 최대 10개만 병렬 │
│ → 나머지 190개 스레드는 커넥션 대기 │
│ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 요청 흐름: │ │
│ │ │ │
│ │ Client → Tomcat Thread Pool → HikariCP Pool → DB │ │
│ │ (200개) (10개) (10 세션) │ │
│ │ │ │
│ │ 병목: │ │
│ │ • DB 쿼리가 빠르면 (< 10ms): 10개 커넥션으로 충분 │ │
│ │ → 200 req/s × 10ms/req = 커넥션당 초당 10개 처리 │ │
│ │ → 10개 커넥션 × 10/초 = 초당 100개 처리 가능 │ │
│ │ │ │
│ │ • DB 쿼리가 느리면 (> 100ms): 커넥션이 병목 │ │
│ │ → 10개 커넥션 × 초당 10개 = 초당 100개가 한계 │ │
│ │ → 나머지 요청은 connection-timeout까지 대기 │ │
│ └──────────────────────────────────────────────────────────┘ │
│ │
│ ⚠️ 핵심 원칙: │
│ Tomcat 스레드 풀 ≥ HikariCP 커넥션 풀 (항상) │
│ → 커넥션 풀이 스레드 풀보다 크면 초과 커넥션은 낭비 │
│ → 반대로 스레드 풀이 훨씬 크면 대기 스레드가 많아질 수 있음 │
│ → 적절한 비율: 스레드 풀 = 커넥션 풀 × 10~20 │
└────────────────────────────────────────────────────────────────────────┘
14.2 비동기/리액티브 환경에서의 커넥션 풀
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
┌────────────────────────────────────────────────────────────────────────┐
│ WebFlux/R2DBC 환경 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 전통적 (Spring MVC + JDBC): │
│ Thread-per-request 모델 │
│ → 스레드가 DB 응답 올 때까지 블로킹 │
│ → 스레드 200개 필요 │
│ │
│ 리액티브 (Spring WebFlux + R2DBC): │
│ Event Loop 모델 │
│ → 소수의 이벤트 루프 스레드 (코어 수만큼) │
│ → DB 요청을 보내고 다른 작업 수행, 응답 오면 콜백 │
│ → 스레드가 블로킹되지 않음 │
│ │
│ R2DBC Connection Pool (r2dbc-pool): │
│ → HikariCP와 동일한 개념이지만 논블로킹 커넥션 관리 │
│ → Mono<Connection> 반환 (블로킹 없이 커넥션 획득) │
│ → 설정: spring.r2dbc.pool.max-size, initial-size 등 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ MVC + HikariCP WebFlux + R2DBC Pool │ │
│ │ │ │
│ │ Thread 블로킹 대기 Mono로 비동기 획득 │ │
│ │ conn = pool.get() Mono<Conn> = pool.create() │ │
│ │ // 블로킹 // 논블로킹 │ │
│ │ rs = stmt.execute() Flux<Row> = stmt.execute() │ │
│ │ // 블로킹 // 논블로킹 스트림 │ │
│ │ conn.close() conn.close().subscribe() │ │
│ └─────────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
15. 면접 질문 & 답변
Q1. DB 커넥션을 매번 새로 생성하면 왜 문제가 되나요?
DB 커넥션 생성에는 TCP 3-Way Handshake, SSL/TLS 협상, DB 인증, 서버 측 스레드/프로세스 생성, 세션 초기화 등 5단계가 필요합니다. 이 과정은 같은 DC 기준 약 10~30ms가 걸리는데, 단순 SELECT 쿼리는 0.1~1ms면 됩니다. 매번 연결하면 실제 쿼리보다 커넥션 생성/해제에 10~100배 더 시간을 쓰게 됩니다.
DB 서버 입장에서도 커넥션마다 메모리(MySQL 기준 세션당 ~1~10MB)와 스레드를 할당해야 하므로, 초당 수백 개의 커넥션이 생겼다 사라지면 리소스 낭비와 함께 TCP TIME_WAIT 소켓 누적으로 포트 고갈까지 발생할 수 있습니다.
Q2. HikariCP가 다른 커넥션 풀보다 빠른 이유는?
네 가지 핵심 최적화가 있습니다.
첫째, ConcurrentBag 자료구조입니다. ThreadLocal 리스트에서 이전에 사용한 커넥션을 먼저 찾고, 없으면 공유 리스트에서 CAS(Compare-And-Swap)로 획득합니다. 전통적 풀처럼 synchronized 블록을 사용하지 않으므로 Lock 경합이 없습니다.
둘째, Javassist 기반 프록시입니다. JDK Dynamic Proxy는 매 호출마다 InvocationHandler를 경유하지만, HikariCP는 컴파일 타임에 직접 프록시 클래스를 생성하여 메서드 호출 오버헤드를 제거합니다.
셋째, FastList입니다. ArrayList의 range check를 제거한 경량 리스트로, Statement 추적에 사용됩니다.
넷째, 경량 설계입니다. 코드가 약 4,000줄로 DBCP2(18,000줄)나 c3p0(30,000줄)에 비해 작아서 GC 부담과 버그 가능성이 적습니다.
Q3. 커넥션 풀 크기는 어떻게 정하나요? “느리니까 풀을 늘리면” 되나요?
커넥션 풀 크기 공식은 (CPU 코어 수 × 2) + 디스크 수입니다. 4코어 SSD 서버면 약 10개가 적정합니다.
“느리니까 풀을 늘리자”는 흔한 실수입니다. DB 서버의 CPU 코어가 4개인데 커넥션을 200개로 늘리면, 200개의 쿼리가 동시에 실행을 시도하지만 실제로는 4개만 병렬 처리됩니다. 나머지 196개는 OS 레벨에서 컨텍스트 스위칭을 유발하고, 각 쿼리의 응답 시간이 오히려 늘어납니다. 또한 세션 메모리(커넥션당 ~10MB)가 DB 버퍼 풀 메모리를 잠식하여 캐시 히트율이 떨어지고 디스크 I/O가 증가합니다.
풀 크기를 늘리기 전에 N+1 쿼리, 느린 쿼리, 트랜잭션 범위, 커넥션 누수를 먼저 점검해야 합니다.
Q4. connection.close()를 호출하면 실제로 TCP 연결이 끊기나요?
커넥션 풀을 사용할 때 close()는 실제 TCP 연결을 끊지 않습니다. DataSource.getConnection()으로 얻는 것은 프록시 객체(HikariProxyConnection)이고, 이 프록시의 close()는 내부적으로 PoolEntry.recycle()을 호출하여 ConcurrentBag에 반납합니다. 실제 TCP 커넥션(delegate)은 살아있는 채로 풀에 남아 다음 요청에 재사용됩니다.
이것이 try-with-resources에서 connection.close()를 호출해도 안전한 이유입니다. 풀을 사용하면 “닫기 = 반납”이므로, 반드시 close를 호출해야 커넥션이 풀에 돌아옵니다.
Q5. 커넥션 누수(Connection Leak)란 무엇이고, 어떻게 감지하나요?
커넥션을 풀에서 빌려갔는데 반납하지 않는 것입니다. 예외 발생 시 close()가 호출되지 않거나, OSIV가 켜진 상태에서 컨트롤러에서 외부 API를 호출하여 커넥션을 장시간 점유하는 경우가 대표적입니다.
HikariCP의 leak-detection-threshold를 설정하면 지정 시간(예: 60초) 안에 반납되지 않은 커넥션에 대해 경고 로그와 함께 getConnection()을 호출한 시점의 스택 트레이스를 출력합니다. 이를 통해 누수가 발생하는 코드 위치를 정확히 파악할 수 있습니다.
예방으로는 try-with-resources 사용, OSIV OFF, 트랜잭션 안에서 외부 API 호출 금지 등이 있습니다.
Q6. Spring Boot에서 HikariCP가 기본으로 선택되는 과정을 설명해주세요.
spring-boot-starter-data-jpa 또는 spring-boot-starter-jdbc 의존성을 추가하면 HikariCP가 전이 의존성으로 포함됩니다. DataSourceAutoConfiguration 클래스가 활성화되고, @ConditionalOnMissingBean(DataSource.class) 조건으로 사용자가 직접 DataSource Bean을 등록하지 않았는지 확인합니다.
이후 PooledDataSourceConfiguration에서 클래스패스에 HikariCP → Tomcat JDBC → DBCP2 순서로 존재 여부를 확인하여 첫 번째로 발견된 풀을 사용합니다. Spring Boot 2.0부터 HikariCP가 기본 의존성이므로 별도 설정 없이 HikariDataSource가 생성되며, spring.datasource.hikari.* 프로퍼티가 자동으로 바인딩됩니다.
사용자가 @Bean으로 직접 DataSource를 등록하면 @ConditionalOnMissingBean 조건이 실패하여 자동 구성이 비활성화됩니다.
Q7. max-lifetime 설정은 왜 필요한가요?
세 가지 이유가 있습니다.
첫째, DB 서버의 wait_timeout보다 짧아야 합니다. MySQL 기본 wait_timeout은 28,800초(8시간)인데, 이보다 오래 살아있는 커넥션은 DB가 강제 종료합니다. 풀은 이를 모르고 죽은 커넥션을 빌려주게 되어 “Communications link failure” 에러가 발생합니다.
둘째, 방화벽이나 로드밸런서가 유휴 TCP 연결을 끊을 수 있습니다. AWS ALB의 idle timeout은 기본 60초이므로, 이보다 짧게 설정하거나 TCP keepalive로 보완해야 합니다.
셋째, HikariCP는 max-lifetime에 2.5%의 랜덤 지터를 추가하여 모든 커넥션이 동시에 만료되는 “커넥션 폭풍(connection storm)”을 방지합니다.
Q8. OSIV(Open Session In View)가 커넥션 풀에 미치는 영향은?
OSIV가 켜져 있으면 HTTP 요청의 시작부터 끝까지 DB 커넥션을 점유합니다. 트랜잭션이 서비스 레이어에서 끝나더라도 커넥션은 응답이 완료될 때까지 반납되지 않습니다.
문제는 컨트롤러에서 외부 API 호출, 파일 처리 등 DB와 무관한 작업을 하는 동안에도 커넥션을 잡고 있다는 것입니다. 외부 API 응답이 3초 걸리면, 실제 쿼리 시간은 1ms인데 커넥션을 3,001ms 점유합니다. 풀 크기가 10이고 동시 요청이 10개를 넘으면 커넥션 고갈 → 전체 서비스 장애로 이어집니다.
실시간 트래픽이 많은 서비스에서는 OSIV OFF(spring.jpa.open-in-view=false)로 설정하고, 필요한 데이터를 서비스 레이어에서 미리 로딩하는 것이 좋습니다.
Q9. 풀 레벨 데드락이 무엇인가요?
DB 락이 아닌, 커넥션 풀의 커넥션 부족으로 발생하는 데드락입니다. 풀 크기가 2인 상태에서 @Transactional 메서드가 커넥션 하나를 잡고, 그 안에서 REQUIRES_NEW 전파 속성의 메서드를 호출하면 새 커넥션이 필요합니다. 두 스레드가 동시에 이 패턴을 실행하면 각각 커넥션 하나를 잡고 추가 커넥션을 기다리지만, 풀에 남은 커넥션이 없어 양쪽 모두 영원히 대기합니다.
해결 방법으로는 풀 크기를 최대 동시 중첩 트랜잭션 수 이상으로 설정하거나, REQUIRES_NEW 사용을 최소화하거나, @TransactionalEventListener를 활용한 이벤트 기반 비동기 처리로 분리하는 방법이 있습니다.
Q10. PostgreSQL에서 PgBouncer를 사용하는 이유는?
PostgreSQL은 MySQL과 달리 프로세스 기반 아키텍처입니다. 새 커넥션마다 OS 프로세스를 fork하는데, 이 비용이 5~10ms로 MySQL의 스레드 생성(~1ms)보다 큽니다. 또한 프로세스당 메모리가 5~15MB로 동시 커넥션이 많아지면 메모리가 급격히 증가합니다.
PgBouncer는 외부 커넥션 풀러로, 애플리케이션의 수십~수백 개 커넥션을 DB의 소수 커넥션으로 다중화합니다. 가장 많이 사용하는 Transaction 모드에서는 트랜잭션 단위로 서버 커넥션을 할당/반납하여, 60개의 클라이언트 커넥션을 10개의 DB 커넥션으로 처리할 수 있습니다. 이를 통해 PostgreSQL 서버의 프로세스 수와 메모리 사용을 극적으로 줄일 수 있습니다.
정리
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
┌────────────────────────────────────────────────────────────────────────┐
│ 핵심 요약 │
├────────────────────────────────────────────────────────────────────────┤
│ │
│ 1. 커넥션은 비싸다 │
│ TCP + SSL + 인증 + 리소스 할당 = 5~200ms │
│ 쿼리 실행 = 0.1~1ms │
│ → 매번 만들면 쿼리보다 연결에 10~100배 더 시간 소요 │
│ │
│ 2. 커넥션 풀 = 미리 만들어두고 빌려쓰기 │
│ Object Pool 패턴 │
│ connection.close()는 반납이지 종료가 아님 │
│ │
│ 3. HikariCP가 빠른 이유 │
│ ConcurrentBag(Lock-Free) + Javassist Proxy + FastList │
│ Spring Boot 2.0+ 기본 │
│ │
│ 4. 풀 크기는 작을수록 좋다 │
│ 공식: (코어 × 2) + 디스크 수 │
│ "느리니까 늘리자" = 대부분 틀린 판단 │
│ │
│ 5. max-lifetime < DB wait_timeout │
│ 커넥션 폭풍 방지를 위한 jitter 추가 │
│ │
│ 6. 모니터링 핵심: pending(대기 스레드 수) │
│ 0이 정상, 1 이상이면 즉시 원인 파악 │
│ │
│ 7. 트러블슈팅 순서 │
│ 누수 → 느린 쿼리 → 트랜잭션 범위 → N+1 → 풀 크기 조정 │
└────────────────────────────────────────────────────────────────────────┘
Q11. 커넥션 풀 워밍업이란 무엇이고, 왜 필요한가요?
커넥션 풀 워밍업은 서버가 트래픽을 받기 전에 미리 커넥션을 생성해두는 것입니다. HikariCP는 기본적으로 initializationFailTimeout이 양수면 시작 시 커넥션 생성을 시도하지만, 서버가 시작되자마자 LB에 등록되면 첫 요청들이 커넥션 생성 대기로 느려질 수 있습니다.
Kubernetes 환경에서 특히 중요한데, Readiness Probe가 커넥션 풀 초기화를 확인한 후에만 Pod를 Service에 등록해야 합니다. 그렇지 않으면 롤링 배포 시 새 Pod가 cold start 상태에서 트래픽을 받아 순간적 응답 지연이 발생합니다.
Spring Boot에서는 ApplicationRunner를 구현하여 시작 시 dataSource.getConnection()을 호출해 풀 초기화를 보장하고, Actuator Health Indicator로 커넥션 풀 상태를 Readiness에 연동할 수 있습니다.
Q12. AWS Lambda 같은 서버리스 환경에서 커넥션 풀은 어떻게 관리하나요?
서버리스 환경에서는 함수 인스턴스마다 별도의 프로세스가 생성되므로, 동시 500개 Lambda가 실행되면 500개의 DB 커넥션이 필요합니다. 각 인스턴스 내에서 HikariCP를 사용해도 인스턴스당 1~2개만 쓰므로 풀링 효과가 사실상 없습니다.
이 문제를 해결하는 것이 AWS RDS Proxy입니다. Lambda와 DB 사이에 프록시를 두어, 500개의 클라이언트 커넥션을 20개의 실제 DB 커넥션으로 다중화합니다. PgBouncer의 Transaction 모드와 유사하게, 쿼리가 실행되는 동안만 DB 커넥션을 할당하고 완료되면 반납합니다.
추가로 RDS Proxy는 마스터 Failover 시 ~1초 내에 새 마스터로 전환하는 장점이 있습니다. 직접 연결 시 DNS 전파 대기로 ~30초가 걸리는 것과 비교됩니다.
Q13. Tomcat 스레드 풀과 HikariCP 커넥션 풀의 관계를 설명해주세요.
Tomcat 스레드 풀은 HTTP 요청을 처리하는 스레드를 관리하고(기본 max 200), HikariCP 커넥션 풀은 DB 커넥션을 관리합니다(기본 max 10). 스레드 풀이 커넥션 풀보다 항상 커야 합니다.
200개 스레드 중 DB 작업이 필요한 스레드는 HikariCP에서 커넥션을 빌려갑니다. DB 쿼리가 10ms 이내로 빠르면 10개 커넥션으로도 초당 수백 요청을 처리할 수 있습니다. 커넥션 하나당 초당 100개 쿼리를 처리하면, 10개면 초당 1,000개가 가능합니다.
하지만 DB 쿼리가 느려지면(100ms+) 커넥션이 병목이 됩니다. 커넥션을 대기하는 Tomcat 스레드가 쌓이고, connection-timeout을 초과하면 SQLException이 발생합니다. 이때 커넥션 풀을 늘리기 전에 느린 쿼리, N+1, 트랜잭션 범위를 먼저 점검해야 합니다.
참고: 기존 글에서 OSIV와 커넥션 풀 고갈 문제는 JPA 영속성 컨텍스트 글에서, 트랜잭션과 DataSource Connection 관리는 Spring AOP/@Transactional 글에서, 싱글톤 패턴으로서의 커넥션 풀은 디자인 패턴 글에서 다루고 있습니다.