사이드 프로젝트로 커뮤니티 서비스를 개발하고 있습니다. AWS EC2 t2.micro에 Docker로 배포한 뒤 JMeter로 부하 테스트를 돌렸는데 에러가 폭발했습니다.
에러율: 30.7%
평균 응답시간: 13,791ms
P95: 30,010ms
전체 요청의 3건 중 1건이 실패했고, 성공한 요청도 평균 14초가 걸렸습니다.
Grafana에서 Tomcat Thread Usage와 DB Connection Usage를 함께 확인했습니다.
Tomcat Thread Usage: 25~35% ← 여유 있음
DB Connection Usage: 100% ← 완전 포화
당시 관측한 지표 기준으로는 Tomcat thread보다 DB 커넥션 풀이 먼저 포화되며 병목으로 작용하고 있었습니다.
Spring Boot는 DB 연결 관리에 HikariCP를 기본으로 사용하는데, maximumPoolSize의 기본값은 10입니다. 풀의 모든 커넥션이 사용 중이면 이후 요청은 빈 커넥션이 생길 때까지 대기합니다. 기본 connectionTimeout은 30,000ms(30초)이고, 그 안에 커넥션을 못 받으면 예외를 던집니다.
서버 로그를 보니 원인이 바로 보였습니다.
HikariPool-1 - Connection is not available, request timed out after 30000ms
(total=10, active=10, idle=0, waiting=21)
커넥션 10개가 모두 사용 중인 상태에서 21개의 요청이 줄 서있었던 겁니다.
관련 공식이 두 가지 있습니다.
HikariCP 공식: 데드락 방지 하한선
pool size = Tn × (Cm - 1) + 1
Tn: 최대 스레드 수
Cm: 하나의 트랜잭션에서 동시에 필요한 커넥션 수
Cm을 구하려면 코드를 직접 분석해야 합니다. 처음에는 트랜잭션당 커넥션 1개만 쓰니 Cm=1이라고 생각했는데, 실제 코드를 보니 예외가 있었습니다.
// ViewCountService.java
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void increaseView(Long postId) {
postRepository.increaseViewCount(postId);
}
REQUIRES_NEW는 기존 트랜잭션을 suspend 하고 새 트랜잭션을 시작합니다. 게시글 상세 조회에서 이 메서드가 호출됩니다.
// PostService.java
@Transactional(readOnly = true) // 트랜잭션 A → 커넥션 1 획득
public PostResponse getPostResponse(...) {
viewCountService.increaseView(postId);
// REQUIRES_NEW:
// 트랜잭션 A suspend (커넥션 1은 아직 반납 안 됨)
// 트랜잭션 B 시작 → 커넥션 2 획득 ← 순간 커넥션 2개 사용
// 트랜잭션 B 커밋 → 커넥션 2 반납
// 트랜잭션 A resume
Post post = getPost(postId);
likeQueryService.getReactionMap(...);
...
}
// 트랜잭션 A 커밋 → 커넥션 1 반납
현재 코드 경로에서는 REQUIRES_NEW 호출이 포함되어 있어, getPostResponse 실행 중 순간적으로 커넥션 2개가 동시에 필요할 가능성이 있었습니다. 이 테스트에서는 Cm을 2로 보는 것이 가장 보수적인 추정이었습니다.
Tomcat 스레드 200개 기준으로 공식을 적용하면 pool size = 200 × (2-1) + 1 = 201이 나오지만, 이건 모든 스레드가 동시에 최악의 경우를 겪는 시나리오라 실제 최적화 기준으로 쓰긴 어렵습니다.
PostgreSQL wiki 공식
connections = (core_count * 2) + effective_spindle_count
t2.micro (vCPU 1개, SSD) 기준으로 (1*2)+1 = 3이 나옵니다. 이 공식은 DB 전용 서버를 전제하는데, t2.micro에서는 MySQL과 Spring Boot가 같은 인스턴스에서 CPU를 공유하므로 그대로 적용하기는 어렵습니다.
두 공식 모두 이 환경에 딱 맞지 않아서 결국 실측으로 결정했습니다. 벤치마크로 적정값을 찾으라는 게 HikariCP 문서의 권장 사항이기도 합니다.
이 환경과 시나리오에서는 pool=30이 에러 없이 비교적 안정적으로 동작했습니다. 다만 적정 pool size는 애플리케이션 구조, DB 성능, 트래픽 패턴에 따라 달라질 수 있어 절대값으로 일반화하긴 어렵습니다.
# docker-compose.yml
environment:
SPRING_DATASOURCE_HIKARI_MAXIMUM_POOL_SIZE: 30
SPRING_DATASOURCE_HIKARI_CONNECTION_TIMEOUT: 30000
커넥션 풀 병목을 확인하려면 읽기·쓰기가 고르게 섞인 부하가 필요합니다. 읽기만 많으면 커넥션 점유 시간이 짧아 병목이 드러나지 않고, 쓰기만 많으면 실제 서비스 패턴과 달라집니다.
| Thread Group | 역할 | 스레드 수 | 비율 |
|---|---|---|---|
| TG1 | 게시글 상세 조회 (비로그인) | 50 | 50% |
| TG2 | 회원 로그인 + 게시글/댓글 작성 | 20 | 20% |
| TG3 | 게스트 게시글/댓글 작성 | 20 | 20% |
| TG4 | 좋아요 | 10 | 10% |
읽기 50% / 쓰기 50% 비율, 총 100개 스레드로 구성했습니다.
전체 지표
| 지표 | pool=10 (기본값) | pool=30 (변경 후) | 개선율 |
|---|---|---|---|
| 에러율 | 30.7% | 0% | -30.7%p |
| 전체 평균 응답시간 | 13,791ms | 302ms | -97.8% |
| P95 | 30,010ms | 1,811ms | -94.0% |
| P99 | 57,889ms | 2,802ms | -95.2% |
| DB Connection 사용률 | 100% (포화) | 최대 45% | 정상화 |
엔드포인트별 비교
| 엔드포인트 | pool=10 평균 | pool=30 평균 | 개선율 |
|---|---|---|---|
| GET /boards/{slug}/{postId} | 16,982ms | 137ms | -99.2% |
| POST /boards/{slug}/guest | 22,590ms | 1,262ms | -94.4% |
| POST /boards/{slug}/member | 14,238ms | 206ms | -98.6% |
| POST /login | 9,628ms | 1,372ms | -85.7% |
이번 테스트에서는 pool=30 적용 후 에러가 발생하지 않았고, DB Connection 사용률도 최대 45% 수준으로 이전보다 안정적으로 유지됐습니다.
풀이 너무 작으면 앱에서 줄 서다가 느려지고, 너무 크면 DB 앞에 요청이 한꺼번에 몰려서 DB가 힘들어집니다. t2.micro (vCPU 1개) 환경에서는 MySQL과 Spring Boot가 하나의 vCPU를 나눠 쓰기 때문에 이 임계점이 더 낮습니다. 인스턴스 자원 한계에 맞는 적정값은 실측으로 찾는 수밖에 없습니다.
Tomcat Thread Usage가 여유 있어도 DB Connection이 포화되면 장애가 납니다. 두 지표를 함께 보지 않으면 병목 지점을 특정하기 어렵고, REQUIRES_NEW 같은 전파 속성은 코드를 직접 분석하지 않으면 실제 Cm 값을 알 수 없습니다.