2 vCPU 서버에서 동시접속 200명 버티기: 커넥션 풀과 쿼리 튜닝 실전기

비용을 아끼려고 2 vCPU, 메모리 16GB에 4개의 서버를 설치해서 사용하기로 했습니다. 그전엔 GCP의 Cloud RUN을 사용했죠. 그런데 지속적인 연결(SSE나 WebSocket 등)을 사용하면 사용하는 시간이 길어져서 비용이 생각보다 많이 나오는거에요.
그래서 1개의 VM에 모든 서버를 넣어서 운영하기로 했습니다. 사실 GCP에 이렇게 구성해 놓은 후 좀 더 저렴한 VM으로 옮길려는 의도가 있기도 합니다.
여하튼...... Cloud RUN에서 잘 튜닝했었는데 다시한번 튜닝을 하게 되었습니다.
https://www.fullstackfamily.com/boards/logs/posts/12159
이전 튜닝에 대한 내용은 위의 글을 참고하세요.
발단: "커넥션 풀이 너무 작은 것 같은데?"
FullStackFamily는 GCE VM 한 대(e2-highmem-2, 2 vCPU / 16GB RAM)에서 돌아갑니다. Nginx, Spring Boot, Next.js, MySQL이 Docker 컨테이너로 나란히 앉아 있는 구조입니다.
┌─────────────────── VM (2 vCPU, 16GB) ───────────────────┐ │ │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │ │ Nginx │→ │ Backend │→ │ MySQL │ │Frontend │ │ │ │ (Alpine) │ │(Spring) │ │ (8.0) │ │(Next.js)│ │ │ │ 80/443 │→ │ :8080 │ │ :3306 │ │ :3000 │ │ │ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │ │ 3GB 3GB 4GB 1.5GB │ └──────────────────────────────────────────────────────────┘
어느 날 게시글 목록 API 응답이 느린 느낌이 들었습니다. DB 커넥션 풀 설정을 보니 HikariCP max-pool-size가 25로 잡혀 있었고, MySQL max_connections는 40이었습니다. "풀이 작아서 느린 건가?" 싶었는데, 막상 파보니 풀이 작은 게 아니라 오히려 너무 컸고, 진짜 문제는 다른 데 있었습니다.
먼저 측정부터: hey로 부하 테스트
뭐가 문제인지도 모르고 설정값을 바꾸면 더 나빠질 수 있으니, 먼저 숫자부터 확인했습니다.
hey라는 HTTP 부하 테스트 도구로 주요 API 4개를 돌렸습니다. 10개 동시 접속에 초당 50 요청.
테스트 조건: hey -n 200 -c 10 -q 5 (10 concurrent × 5 req/s = 초당 50 요청)
최적화 전 결과
| 엔드포인트 | p50 | p95 | p99 | 비고 |
|---|---|---|---|---|
게시글 목록 /api/posts/board/26 | 175ms | 459ms | 806ms | 가장 느림 |
홈 API /api/home | 20ms | - | - | 빠름 |
태그 API /api/tags | 37ms | - | - | 양호 |
프론트엔드 SSR / | 65ms | - | - | 양호 |
게시글 목록이 유독 느립니다. p50은 175ms로 괜찮아 보이지만, p99가 800ms를 넘깁니다. 100명 중 1명은 거의 1초를 기다린다는 뜻입니다.
더 심각한 문제도 있었습니다. 처음 부하 테스트를 돌렸을 때 100개 요청 중 45개가 503으로 돌아왔습니다. Nginx의 rate limit이 IP당 30r/s였는데, 테스트 도구가 단일 IP에서 598r/s를 보낸 거죠.
분석: 왜 느린가
1. HikariCP 25개는 과한가, 적당한가
HikariCP 공식 위키에 유명한 공식이 있습니다.
최적 커넥션 수 = (CPU 코어 수 × 2) + 유효 스핀들 수
2 vCPU 서버에 SSD라면 (2 × 2) + 1 = 5가 최적입니다. 25는 5배나 많습니다.
커넥션이 많다고 좋은 게 아닙니다. 커넥션이 늘면 MySQL 쪽에서 스레드 스케줄링 오버헤드가 생기고, 메모리도 커넥션 수 × per-session buffer 만큼 먹습니다. 안 쓰는 커넥션이 idle로 떠 있으면 MySQL max_connections 한도만 잡아먹는 셈이고요.
하지만 무작정 5로 줄이기엔 고려할 게 있었습니다.
Spring Boot 비동기 Executor 현황 ├── levelExpExecutor : core 4, max 8 ├── tagCountExecutor : core 2, max 4 ├── notificationExecutor : core 2, max 4 └── bannerClickExecutor : core 2, max 5 합계: max 21 스레드
이 비동기 작업들도 DB 커넥션을 쓸 수 있습니다. Tomcat 요청 스레드 30개와 합치면 이론상 51개 스레드가 동시에 커넥션을 요청할 수 있습니다.
현실적으로 비동기 작업이 전부 동시에 도는 일은 드물고, 대부분의 요청은 금방 커넥션을 반납합니다. 그래서 max=15, min-idle=8로 정했습니다. 이론적 최적값(5)보다 여유를 뒀지만, 이전(25)보다는 훨씬 줄인 셈입니다.
2. 게시글 목록 쿼리의 불필요한 DISTINCT
UnifiedPostRepository의 JPQL 쿼리를 열어보니 이런 패턴이 8개나 있었습니다.
SELECT DISTINCT p FROM UnifiedPost p LEFT JOIN FETCH p.qnaExtension WHERE p.board.id = :boardId AND p.status = :status
DISTINCT가 붙어 있습니다. 왜 붙었을까요?
Hibernate에서 LEFT JOIN FETCH + @OneToMany 컬렉션을 페이징하면, 메모리에서 전체 결과를 읽은 뒤 중복을 제거하는 무서운 동작이 발생합니다. 이걸 막으려고 DISTINCT를 붙이는 건 흔한 패턴입니다.
그런데 이 쿼리의 qnaExtension은 @OneToOne입니다. 1:1 관계에서는 JOIN해도 row가 늘어나지 않습니다. DISTINCT가 아무 일도 안 하면서 정렬 비용만 추가하고 있었던 겁니다.
@OneToMany (1:N) → JOIN 시 row 뻥튀기 → DISTINCT 필요 @OneToOne (1:1) → JOIN 해도 row 동일 → DISTINCT 불필요 ← 여기
8개 쿼리에서 전부 DISTINCT를 제거했습니다.
3. MySQL, Nginx 설정 불균형
| 항목 | 이전 값 | 문제 |
|---|---|---|
| MySQL max_connections | 40 | HikariCP 25개 + 여유 15개. 과다 |
| MySQL wait_timeout | 28800 (8시간) | idle 커넥션이 8시간 동안 안 끊김 |
| Nginx rate limit | 30r/s | 정상 트래픽도 503으로 떨어짐 |
| Nginx burst | 50 | 순간 트래픽에 취약 |
적용한 변경들
HikariCP (application.yml)
# Before maximum-pool-size: 25 minimum-idle: 5 # After maximum-pool-size: 15 # 2 vCPU에 맞게 축소 minimum-idle: 8 # 웜 커넥션 확보 connection-timeout: 10000 # 10초 (이전 5초) leak-detection-threshold: 15000 validation-timeout: 3000
min-idle을 5에서 8로 올린 이유는, 커넥션 생성 비용입니다. 요청이 들어왔을 때 idle 커넥션이 없으면 새로 TCP handshake + MySQL 인증을 해야 합니다. 미리 8개를 따뜻하게 데워놓으면 대부분의 요청이 기다림 없이 커넥션을 받습니다.
MySQL (production.cnf)
# Before max_connections = 40 # After max_connections = 30 # HikariCP 15 + 여유 wait_timeout = 600 # 10분 (이전 8시간) interactive_timeout = 600
wait_timeout을 8시간에서 10분으로 줄였습니다. 사고로 반납되지 않은 idle 커넥션이 있어도 10분이면 정리됩니다.
Nginx (nginx.conf + fullstackfamily.conf)
# Before limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s; limit_req zone=api burst=50 nodelay; # After limit_req_zone $binary_remote_addr zone=api:10m rate=50r/s; limit_req zone=api burst=100 nodelay;
50r/s × burst 100이면 순간적으로 150 요청까지 한 IP에서 받아줍니다. 정상 사용자가 페이지를 열 때 브라우저가 보내는 병렬 요청(API 3-5개 + 이미지 + 정적 파일)을 고려하면, 30r/s는 너무 빡빡했습니다.
OS/Docker FD(파일 디스크립터) 설정
부하 테스트를 돌리기 전에 서버 인프라를 점검했더니, 동시접속 이전에 막힐 수 있는 문제가 있었습니다.
| 레이어 | Soft Limit | 문제 |
|---|---|---|
| ff-nginx 컨테이너 | 1,024 | 동시접속 1,000명만 넘으면 FD 고갈 |
| Docker daemon (systemd) | 1,024 | 컨테이너 관리에 부족 |
| Host limits.conf | 미설정 | 기본값 의존 |
docker-compose.yml에 ulimits를 추가하고, Docker daemon systemd override와 Host limits.conf도 설정했습니다.
# docker-compose.yml - 모든 컨테이너에 추가 ulimits: nofile: soft: 65535 hard: 65535
# /etc/systemd/system/docker.service.d/override-fd.conf [Service] LimitNOFILE=65535
적용 후 Nginx를 포함한 전 컨테이너가 65535/65535로 통일되었습니다. 자세한 내용은 SSE 실시간 전환 글의 "OS/Docker FD 설정" 절에 정리해뒀습니다.
Actuator/Micrometer 활성화
설정 바꾸고 끝이 아니라, 이후에도 풀 상태를 볼 수 있어야 하니까 Actuator를 켰습니다.
management: endpoints: web: exposure: include: health,metrics # 이 두 개만 노출
보안은 3중으로 막았습니다.
외부 요청 → Nginx (/actuator → 404) → 차단! ↓ SecurityConfig (permitAll) → 어차피 Nginx에서 못 들어옴 ↓ 내부 컨테이너에서만 접근 가능 ↓ docker exec ff-backend curl localhost:8080/actuator/metrics/...
/actuator/env(환경변수 노출)이나 /actuator/heapdump(메모리 덤프) 같은 위험한 엔드포인트는 아예 안 열었고, health와 metrics만 뒀습니다. 그마저도 Nginx에서 외부 접근은 404로 돌려보냅니다.
최적화 후 결과
Before vs After
| 엔드포인트 | 지표 | Before | After | 변화 |
|---|---|---|---|---|
| 게시글 목록 | p50 | 175ms | 226ms | +29% |
| p95 | 459ms | 278ms | -39% | |
| p99 | 806ms | 306ms | -62% | |
| Home API | p50 | 20ms | 28ms | 비슷 |
| Tags API | p50 | 37ms | 56ms | 비슷 |
| Frontend SSR | p50 | 65ms | 106ms | 비슷 |
| Rate limit | 503 발생 | 45/100 | 0/100 | 제거 |
p50(중간값)은 비슷하거나 약간 올랐습니다. 재시작 직후라 InnoDB 버퍼 풀이 아직 차가운 상태였기 때문이고, 시간이 지나면 돌아옵니다.
눈여겨볼 건 테일 레이턴시 쪽입니다. p99가 806ms에서 306ms로, 62% 줄었습니다. "운 나쁜 1%의 사용자"가 체감하는 응답 속도가 확 나아진 거죠.
그리고 503 에러가 사라졌습니다. 이전에는 여러 탭을 빠르게 열거나 브라우저가 prefetch를 하면 rate limit에 걸릴 수 있었습니다.
Actuator로 본 커넥션 풀 상태
HikariCP Max: 15.0 (이전 25) HikariCP Idle: 8.0 (이전 25 전부) HikariCP Active: 0.0 (요청 없을 때) HikariCP Pending: 0.0 (대기 없음) JVM Memory Used: ~484MB
idle이 8개, active가 0. 평상시에는 8개만 데워놓고 대기하다가, 요청이 오면 그 중 하나를 쓰는 패턴입니다. max 15까지 여유가 있으니 갑자기 부하가 와도 7개를 더 만들 수 있습니다.
그래서 동시에 몇 명까지 버틸 수 있나
병목이 어디인지부터 봐야 합니다.
서버 자원별 한계
┌─────────────────────────────────────────────┐ │ 처리 파이프라인 │ │ │ │ Nginx Tomcat HikariCP MySQL │ │ 50r/s 80 thread 50 conn 80 conn │ │ per IP 동시 처리 DB 연결 전체 한도 │ │ │ │ 요청 → [큐] → [처리] → [DB] → [응답] │ └─────────────────────────────────────────────┘
| 자원 | 한도 | 요청당 점유 시간 | 초당 처리량 |
|---|---|---|---|
| Tomcat 스레드 (80개) | 게시글: 226ms | 80 × (1000/226) ≈ 354 req/s (이론) | |
| Home: 28ms | 80 × (1000/28) ≈ 2,857 req/s (이론) | ||
| HikariCP (50개) | 게시글: ~100ms (DB 시간) | 50 × (1000/100) ≈ 500 req/s (이론) | |
| Home: ~15ms (DB 시간) | 50 × (1000/15) ≈ 3,333 req/s (이론) | ||
| Nginx rate limit | IP당 50r/s | 서로 다른 IP면 제한 없음 | |
| CPU (2 vCPU) | 컨텍스트 스위칭 한계 | 실측 ~130 req/s 수준 |
이론상 스레드와 커넥션만 보면 수백 req/s가 가능해 보입니다. 하지만 k6 테스트에서 확인한 실측값은 ~37 req/s (300 VU 기준)입니다. 이론과 실측의 괴리가 큰 이유는 2 vCPU가 실제 병목이기 때문입니다. 스레드가 아무리 많아도 CPU 코어 2개가 처리할 수 있는 총량에 묶입니다.
실제 사용자 수로 환산
사용자가 페이지를 열면 API 호출이 2-5개 발생하고, 그 다음 10-30초 동안 글을 읽습니다. 평균적으로 사용자 1명 = 초당 0.2-0.3 요청입니다.
보수적 현실적 한계 서버 처리량 80 req/s 130 req/s 180 req/s 사용자당 요청률 0.3 req/s 0.2 req/s 0.15 req/s ───────── ───────── ────────── 동시 접속자 수 ~270명 ~650명 ~1,200명 응답 시간 <500ms <500ms 1초+ (저하)
| 시나리오 | 동시 접속 | 체감 |
|---|---|---|
| 쾌적한 운영 | 100-200명 | 모든 페이지 0.5초 이내 응답 |
| 부하 상태 | 200-500명 | 대부분 정상, 간헐적 지연 |
| 한계 | 500명+ | 응답 지연 증가, 타임아웃 가능 |
2 vCPU에서 동시접속 200명이면 충분히 쾌적합니다.
참고로 SSE(실시간 알림) 연결은 별도입니다. 비동기 dispatch로 동작해서 Tomcat 스레드를 점유하지 않거든요. 로그인한 사용자 200명이 각각 SSE 연결을 유지해도 처리량에는 거의 영향이 없습니다.
k6로 실제 한계점 측정
이론적 추산만으로는 부족합니다. k6 부하 테스트로 VU(가상 사용자)를 50 → 100 → 150 → 200 → 300으로 올려가며 실제 한계를 확인했습니다.
테스트 시나리오는 실제 사용자 패턴을 모방했습니다. 각 VU가 메인 페이지, 블로그, QnA, 뉴스, API Health, API Boards를 랜덤으로 방문하고, 요청 사이에 0.5~2.5초 대기합니다.
1차 테스트: 기본 설정 (Tomcat 30, HikariCP 15, MySQL 30)
먼저 50 VU로 안정성을 확인했습니다.
| 지표 | 50 VUs | 결과 |
|---|---|---|
| 성공률 | 100% (2,850/2,850) | 에러 없음 |
| p95 | 665ms | 양호 |
| 중앙값 | 54ms | 빠름 |
| RPS | ~21 req/s | - |
50명은 쾌적합니다. 300명까지 올려봤습니다.
300 VU 결과 (Tomcat=30, HikariCP=15, MySQL=30)
| 지표 | 값 |
|---|---|
| HTTP 에러 (4xx/5xx) | 0% |
| 타임아웃 (>5초) | 28% (1,627건) |
| p95 | 9.77초 |
| 중앙값 | 1.31초 |
| RPS | 35.2 req/s |
서버가 죽지는 않았습니다. HTTP 에러는 0%입니다. 하지만 300명이 동시에 접속하면 4명 중 1명이 5초 이상 기다려야 했습니다. 서버가 요청을 거부하는 게 아니라, Tomcat 스레드 30개가 꽉 차서 나머지 270개 요청이 acceptCount 큐에서 줄을 서는 거죠.
2차 테스트: 스케일업 후 (Tomcat 80, HikariCP 50, MySQL 80)
"스레드를 올리면 해결되지 않을까?"
단순하게 Tomcat만 올리면 안 됩니다. 설정 간에 의존 체인이 있습니다.
Tomcat 80 threads → 동시에 DB 요청 → HikariCP 15개밖에 없음 → 65개 스레드가 커넥션 대기 → "Connection is not available, request timed out after 5001ms"
HikariCP를 50으로 올려도 MySQL max_connections가 30이면 같은 문제가 반복됩니다. 결국 세 계층을 함께 올려야 합니다.
| 설정 | Before | After | 이유 |
|---|---|---|---|
MySQL max_connections | 30 | 80 | HikariCP 50 + admin 여유 30 |
MySQL thread_cache_size | 16 | 32 | 커넥션 재사용 성능 |
MySQL back_log | 64 | 128 | 대기 큐 여유 |
DB_POOL_MAX | 15 | 50 | Tomcat 스레드의 60~70% |
DB_POOL_MIN_IDLE | 8 | 10 | 워밍업 커넥션 |
TOMCAT_MAX_THREADS | 30 | 80 | 2 vCPU에서 현실적 최대 |
TOMCAT_MIN_SPARE | 5 | 10 | 워밍업 스레드 |
TOMCAT_ACCEPT_COUNT | 50 | 150 | 대기 큐 여유 |
메모리 영향은 미미합니다. Tomcat 스레드 50개 추가가 ~50MB, MySQL 커넥션 50개 추가가 ~150MB. 서버에 10GB 이상 여유가 있으니 문제없습니다.
적용 후 같은 300 VU 테스트를 돌렸습니다.
300 VU 결과 비교 (Before vs After)
| 지표 | Before (threads=30) | After (threads=80) | 변화 |
|---|---|---|---|
| 총 요청 | 5,870 | 6,128 | +4.4% |
| RPS | 35.2 | 36.8 | +4.5% |
| 중앙값 | 1.31s | 1.04s | -20% |
| 평균 | 2.81s | 2.64s | -6% |
| p95 | 9.77s | 9.06s | -7% |
| 타임아웃(>5s) | 27.7% | 24.6% | -3%p |
| HTTP 에러 | 0% | 0% | 동일 |
결론: 병목은 스레드가 아니라 CPU
스레드와 커넥션을 2.5배 이상 올렸는데, 개선폭은 5~20%에 그쳤습니다. 중앙값이 20% 개선된 건 의미 있지만, 300 VU에서 타임아웃 25%는 여전합니다.
이유는 분명합니다. 2 vCPU가 진짜 병목입니다.
Backend (Spring Boot) ← CPU 경쟁 Frontend (Next.js SSR) ← CPU 경쟁 → 합쳐서 2코어를 나눠 씀 MySQL ← CPU 경쟁 Nginx ← (미미)
스레드를 80개로 늘려도, 그 80개가 CPU 2코어를 번갈아 쓰면서 context switching 비용만 커집니다. 실제 처리량(초당 처리 가능한 요청 수)은 CPU가 결정하기 때문에, 스레드를 아무리 늘려도 총량은 거의 변하지 않습니다.
VU별 체감 정리
| 동시 사용자 | 체감 | p95 |
|---|---|---|
| ~50명 | 쾌적 | <700ms |
| ~100명 | 양호 | <2초 |
| ~150명 | 수용 가능 | <5초 |
| ~200명 | 느려짐 | 5~8초 |
| 300명 | 한계 초과 | 9초+ |
실사용에서는 사용자가 글을 읽는 시간(10~30초)이 있으므로, "동시 접속 200명"은 실질적으로 수백~천 명의 DAU를 수용할 수 있는 수치입니다.
그 이상이 필요하면?
200명 넘게 안정적으로 감당하려면 세 가지 방법이 있습니다.
1. VM 스펙업 (수직 확장) e2-highmem-2 (2 vCPU) → e2-highmem-4 (4 vCPU) 비용 ~2배, 처리량 ~2배 2. 쿼리 캐싱 자주 조회되는 게시글 목록을 Redis/Caffeine 캐시 DB 부하 90% 감소 가능 3. 읽기 전용 복제본 (수평 확장) MySQL 읽기 복제본 추가 + DataSource 라우팅 쓰기는 원본, 읽기는 복제본
지금 규모에서는 1번이면 충분합니다. 4 vCPU로 올리면 HikariCP도 max=20-25로 늘릴 수 있고, 동시접속 500명까지 쾌적하게 갈 수 있습니다.
마무리
정리하면 이번에 건드린 건 두 단계입니다.
1단계: 쿼리 최적화 + 적정 사이징
| 변경 | Before | After |
|---|---|---|
| HikariCP max-pool | 25 | 15 |
| MySQL max_connections | 40 | 30 |
| Nginx rate limit | 30r/s, burst 50 | 50r/s, burst 100 |
| JPQL DISTINCT | 8개 쿼리에 불필요하게 존재 | 제거 |
결과: p99가 62% 줄고, 503 에러가 사라졌습니다.
2단계: k6 부하 테스트 → 스케일업
| 변경 | Before | After |
|---|---|---|
| Tomcat maxThreads | 30 | 80 |
| Tomcat acceptCount | 50 | 150 |
| HikariCP max-pool | 15 | 50 |
| MySQL max_connections | 30 | 80 |
결과: 300 VU 기준 중앙값이 1.31s → 1.04s로 20% 개선, 타임아웃 28% → 25%로 감소. 하지만 극적인 개선은 아니었습니다.
핵심 교훈 두 가지.
첫 번째는 1단계에서 얻은 것입니다. "커넥션 풀이 작아서 느린 것 같다"는 직감이 거의 항상 틀립니다. 실제로는 풀이 작은 게 아니라 쿼리가 오래 걸리거나, 안 쓰는 커넥션이 자리만 차지하고 있거나, rate limit이 정상 트래픽을 잘못 막고 있는 경우가 더 많았습니다.
두 번째는 2단계에서 얻은 것입니다. 설정만 올린다고 성능이 비례해서 좋아지지 않습니다. 스레드를 2.5배 늘렸는데 개선이 5~20%에 그친 건, 진짜 병목이 소프트웨어 설정이 아니라 하드웨어(CPU)였기 때문입니다. 병목 지점을 정확히 파악하지 않고 설정만 만지면 복잡성만 늘어납니다.
결국 "측정 → 분석 → 변경 → 측정"을 반복하는 것만이 정답입니다. Actuator를 켜둔 건 앞으로가 더 중요합니다. 다음에 "또 느려졌다" 싶으면 /actuator/metrics/hikaricp.connections.active부터 보면 됩니다. 활성 커넥션이 50에 가까우면 DB가 병목이고, Tomcat 스레드가 80에 가까우면 CPU가 병목입니다. 느낌이 아니라 숫자로 잡으면 됩니다.






댓글
댓글을 작성하려면 이 필요합니다.