월 7천원 DB로 실서비스 운영하기: db-f1-micro 커넥션 최적화와 부하 테스트
가장 싼 DB를 골랐더니
GCP Cloud SQL에는 db-f1-micro라는 인스턴스 타입이 있습니다.
db-f1-micro 스펙: vCPU: 공유(shared) - 전용이 아님! RAM: 614MB (0.6GB) 비용: 약 $7/월 (약 9,500원)
말 그대로 MySQL을 돌릴 수 있는 최소 스펙입니다. vCPU도 전용이 아니라 다른 인스턴스와 공유하는 방식이고, 메모리는 614MB가 전부. 한 단계 위인 db-g1-small이 전용 vCPU + 1.7GB RAM에 약 $25/월인 걸 감안하면, 3.5배 싼 대신 성능도 그만큼 빠듯합니다.
그런데 제가 운영하는 커뮤니티(fullstackfamily.com)는 아직 초기 단계입니다. 하루 방문자가 수십 명 수준이라 월 $25를 쓰기엔 아까웠습니다. "이 트래픽에 이 돈을 쓴다고?" 하는 거죠. 그래서 db-f1-micro로 시작했는데, 문제는 설정을 잘 맞추지 않으면 이 작은 DB가 순식간에 뻗는다는 점이었습니다.
Spring Boot 기본값의 함정
Spring Boot 애플리케이션을 GCP Cloud Run에 올려서 Cloud SQL과 연결하면, 아무것도 안 건드렸을 때 기본값이 이렇습니다.
Cloud Run: max-instances = 10 (인스턴스 최대 10개까지 뜰 수 있음) concurrency = 80 (인스턴스당 동시 요청 80개) Spring Boot (HikariCP): maximum-pool-size = 10 (인스턴스당 DB 커넥션 10개) Tomcat: threads.max = 200 (요청 처리 스레드 200개) Cloud SQL (db-f1-micro): max_connections = 25 (MySQL이 받을 수 있는 총 커넥션 25개)
여기서 산수를 해봅시다.
최악의 경우: Cloud Run 인스턴스 10개 × 인스턴스당 커넥션 풀 10개 = 100개의 DB 커넥션 필요 그런데 MySQL은: max_connections = 25개만 받을 수 있음 100 vs 25. ❌ 4배 초과!
Cloud Run은 트래픽이 몰리면 자동으로 인스턴스를 늘립니다. 인스턴스가 3~4개만 떠도 커넥션 수가 DB 한계를 넘깁니다. 이때부터 "커넥션을 얻을 수 없습니다" 에러가 터지기 시작합니다. 실제로 이 에러를 보고 나서야 "아, 산식을 맞춰야 하는구나" 하고 깨달았습니다.
커넥션 산식: 곱하기 하나면 충분하다
핵심 제약식은 이 한 줄입니다.
I × P <= M - R I: Cloud Run 최대 인스턴스 수 P: 인스턴스당 HikariCP 커넥션 풀 크기 M: MySQL max_connections R: 예약 커넥션 (모니터링/관리 용도, 보통 M의 20~30%)
이걸 만족시키지 않으면 피크 시 커넥션 대기나 타임아웃이 발생합니다. 식당에 비유하면, 좌석(커넥션)이 25개인데 손님(인스턴스 × 풀)을 100명까지 받겠다고 해놓은 격입니다.
db-f1-micro의 max_connections=25에서 예약분 7개를 빼면 앱이 쓸 수 있는 커넥션은 18개입니다. 이 18개 안에서 인스턴스 수와 풀 크기를 맞춰야 합니다.
A안: 설정만으로 최적화하기
DB 업그레이드 없이 설정 조정만으로 커넥션 산식을 맞추는 방안을 A안이라 이름 붙였습니다.
변경 전후 비교
┌─────────────────────────────┬─────────────────────┬──────────────┐ │ 항목 │ 변경 전 (기본값) │ 변경 후 (A안) │ ├─────────────────────────────┼─────────────────────┼──────────────┤ │ Cloud Run max-instances │ 10 │ 2 │ │ Cloud Run concurrency │ 80 │ 20 │ │ HikariCP maximum-pool-size │ 10 │ 8 │ │ HikariCP minimum-idle │ 10 │ 2 │ │ HikariCP connection-timeout │ 30000ms │ 5000ms │ │ HikariCP leak-detection │ 비활성 │ 10000ms │ │ Tomcat threads.max │ 200 │ 24 │ │ Tomcat accept-count │ 100 │ 20 │ │ Cloud SQL innodb_lock_wait │ 50s │ 10s │ │ Cloud SQL wait_timeout │ 28800s (8시간) │ 1800s │ └─────────────────────────────┴─────────────────────┴──────────────┘
산식 검증
변경 전: I × P = 10 × 10 = 100 >> 25 - 7 = 18 ❌ (82 커넥션 초과!) 변경 후: I × P = 2 × 8 = 16 <= 25 - 7 = 18 ✅ (여유 2 커넥션)
설정 하나만 건드린 게 아니라 전체 파이프라인을 산식에 맞춰 재조정한 겁니다. 어떤 값을 왜 그렇게 잡았는지 하나씩 짚어보겠습니다.
각 설정값의 이유
Cloud Run max-instances=2: 인스턴스가 3개만 돼도 3 × 8 = 24로 예약분까지 잠식합니다. 2개로 제한해야 안전합니다.
HikariCP pool=8: 2 × 8 = 16으로 여유분 2를 남깁니다. 풀 크기를 더 줄이면 평상시에도 DB 대기가 잦아지고, 더 늘리면 산식을 맞출 수 없습니다.
Tomcat threads=24: HikariCP 풀이 8인데 Tomcat 스레드가 200이면? 192개 스레드가 DB 커넥션 없이 허탕을 칩니다. 24 = 풀 크기 × 3 정도가 적당합니다. DB가 필요 없는 요청(정적 자원, 헬스 체크 등)도 처리할 여유를 주면서 불필요한 경쟁을 줄입니다.
connection-timeout=5s: 기본값 30초는 너무 깁니다. 커넥션 못 얻으면 빠르게 에러를 돌려주는 게 낫습니다. 30초 대기하는 사용자는 없으니까요.
wait_timeout=1800s: MySQL 기본값은 8시간입니다. 8시간 동안 아무것도 안 하는 커넥션이 자리를 차지하고 있는 거죠. 30분으로 줄여서 유휴 커넥션을 빨리 정리합니다.
leak-detection=10s: 커넥션을 빌려간 뒤 10초 넘게 안 돌려주면 로그를 남깁니다. 누수를 조기에 잡는 안전망입니다.
요청이 흘러가는 경로
설정값이 실제로 어떻게 맞물리는지, 요청 하나가 흘러가는 경로를 봅시다.
사용자 → Cloud Run LB → Cloud Run Instance → Tomcat → HikariCP → MySQL │ │ │ │ │ max-inst=2 concurrency=20 threads=24 pool=8 max_conn=25 │ │ │ │ │ 분배/큐잉 게이트키퍼 실제 처리 DB 접근 최종 제약
Cloud Run concurrency=20이 진짜 게이트키퍼입니다. 한 인스턴스에 동시에 20개까지만 요청을 보냅니다. 20개를 넘으면 새 인스턴스를 띄우거나 대기열에 넣습니다.
그 안에서 Tomcat threads=24가 실제 요청을 처리하고, 24개 스레드 중 DB가 필요한 요청만 HikariCP pool=8에서 커넥션을 빌려갑니다. 모든 요청이 동시에 DB를 쓰지는 않으니 이 비율이 잘 맞습니다.
"동시 사용자 20명"의 실제 의미
여기서 흔히 오해하는 게 있습니다. "동시 접속 20명"과 "동시 요청 20개"는 전혀 다릅니다.
사용자의 실제 행동 패턴: 사용자A: [클릭]──200ms응답──────────5초 읽기──────────[클릭]──150ms응답──... 사용자B: ──────[클릭]──100ms응답────────────8초 읽기────────────[클릭]──... 사용자C: ────────────[클릭]──300ms응답──────3초 읽기──[클릭]──...
사용자는 대부분의 시간을 읽기, 스크롤, 타이핑에 씁니다. 서버에 요청이 가는 건 클릭하는 수백 밀리초뿐입니다. 경험적으로 동시 접속 20명 = 동시 요청 2~5개 수준입니다.
그러니 concurrency=20, pool=8 이라도 동시 접속 20~30명은 무리 없이 처리합니다.
K6로 진짜 한계를 측정하다
설정을 맞추고 "이 정도면 되겠지" 하고 끝내면 안 됩니다. 실제로 얼마나 버티는지 부하 테스트를 해봐야 합니다.
K6란?
k6는 Grafana에서 만든 부하 테스트 도구입니다. JavaScript로 시나리오를 작성하고, 가상 사용자(VU: Virtual User)가 동시에 API를 호출하는 상황을 시뮬레이션합니다.
JMeter 같은 도구보다 가볍고, 스크립트가 직관적이라 빠르게 테스트를 돌려볼 수 있습니다. Go로 만들어져 단일 바이너리로 실행되고, 설치도 brew install k6 한 줄이면 끝입니다.
테스트 시나리오 설계
부하 테스트에는 단계별 시나리오가 필요합니다. 처음부터 100명을 때려 넣으면 "터졌다"는 것만 알지, 어디서부터 문제가 생기는지를 알 수 없거든요.
┌───────────┬─────────┬───────────┬─────────────────────────────────┐ │ 시나리오 │ VUs │ 시간 │ 목적 │ ├───────────┼─────────┼───────────┼─────────────────────────────────┤ │ Smoke │ 2명 │ 30초 │ "일단 돌아가나?" 기본 동작 확인 │ │ Normal │ 5→10→5 │ 2분 10초 │ 평상시 트래픽 시뮬레이션 │ │ Stress │ 10→20→30│ 3분 │ 한계가 어디인지 찾기 │ │ Spike │ 2→25→2 │ 1분 15초 │ 갑작스러운 폭주에 버티나? │ └───────────┴─────────┴───────────┴─────────────────────────────────┘
Smoke 테스트: 가장 가벼운 수준의 테스트입니다. 기본 동작만 확인합니다. 여기서 실패하면 설정 자체가 잘못된 겁니다.
Normal 테스트: 실제 운영과 비슷한 트래픽입니다. 5명에서 시작해 10명까지 올렸다 내립니다. 이 구간에서 안정적이면 일상 운영은 문제없습니다.
Stress 테스트: 의도적으로 한계를 넘어봅니다. 10명에서 30명까지 밀어붙여 "어디서부터 무너지나"를 확인합니다.
Spike 테스트: 갑자기 트래픽이 몰리는 상황입니다. SNS에 링크가 퍼지거나, 수업 시간에 학생들이 동시에 접속하는 시나리오입니다.
각 VU는 Health Check API와 Home API를 번갈아 호출합니다. Health는 DB를 안 타고, Home은 여러 테이블을 JOIN하는 무거운 쿼리라서 대비가 됩니다.
합격 기준
┌──────────────────────────┬──────────┬────────────────────────────┐ │ 지표 │ 기준 │ 의미 │ ├──────────────────────────┼──────────┼────────────────────────────┤ │ http_req_duration p(95) │ < 3s │ 95% 요청이 3초 이내 │ │ error_rate │ < 5% │ 에러율 5% 미만 │ │ health_latency p(95) │ < 1s │ 헬스체크 1초 이내 │ │ home_latency p(95) │ < 3s │ Home API 3초 이내 │ │ timeout_errors │ = 0 │ 타임아웃 없음 │ └──────────────────────────┴──────────┴────────────────────────────┘
테스트 결과: 어디서 무너지나
프로덕션 환경(https://api.fullstackfamily.com)에 직접 테스트를 돌렸습니다.
Smoke (2 VUs) - PASS
error_rate: 0% (< 5%) ✅ health_latency p95: 15ms (< 1s) ✅ home_latency p95: 1.84s (< 3s) ✅ timeout_errors: 0 (= 0) ✅
2명 동시 접속. 아무 문제 없습니다. Home API가 1.84초인 건 여러 테이블 JOIN 때문인데, 기준 내이므로 합격입니다.
Normal (5→10 VUs) - PASS
error_rate: 0% (< 5%) ✅ health_latency p95: 17.85ms (< 1s) ✅ home_latency p95: 1.64s (< 3s) ✅ timeout_errors: 0 (= 0) ✅
10명까지 올려도 에러 0%. 오히려 smoke보다 Home API가 빨라졌는데, 워밍업 효과(JIT 컴파일, 커넥션 풀 안정화)입니다. pool=8로 10명 동시 접속을 문제없이 소화합니다.
Stress (10→20→30 VUs) - FAIL (예상대로)
error_rate: 42.77% (< 5%) ❌ health_latency p95: 39.46ms (< 1s) ✅ home_latency p95: 10s (< 3s) ❌ timeout_errors: 86 (= 0) ❌
여기서부터 무너집니다. 약 17 VUs 부근에서 타임아웃이 시작됐습니다.
흥미로운 건 Health 체크는 여전히 39ms로 정상이라는 점입니다. Health는 DB를 안 쓰거든요. 병목이 정확히 DB 커넥션 풀에 있다는 증거입니다.
Home API는 8개 커넥션이 모두 사용 중이면 나머지 요청이 connection-timeout(5초)까지 기다리다 타임아웃됩니다. 에러율 42.77%는 VU 30명 구간에서 집중적으로 발생했습니다.
Spike (2→25 VUs) - FAIL (예상대로)
error_rate: 25.66% (< 5%) ❌ home_latency p95: 10s (< 3s) ❌ timeout_errors: 26 (= 0) ❌
25명이 한꺼번에 몰리면 Home API 성공률이 53%(61/113)까지 떨어집니다. 하지만 stress보다 에러율이 낮은데(25.66% vs 42.77%), 폭주 시간이 짧아서 빠르게 회복했기 때문입니다. VU가 2명으로 줄어든 뒤 바로 정상 복귀했습니다.
종합 결과
A안 처리 능력 요약: ✅ ~10 VUs (동시 접속 20~30명): 완벽 처리, 여유 충분 ⚠️ ~15 VUs (동시 접속 40~50명): 간헐적 지연 시작 ❌ 20+ VUs (동시 접속 50명 이상): 타임아웃 발생, 서비스 품질 저하 병목 포인트: HikariCP pool=8 (DB 커넥션 대기) 안전 장치: Cloud Run 자동 스케일링 (2번째 인스턴스)
db-f1-micro에 설정만 맞춘 A안으로 동시 접속 20~30명까지는 완벽하게 처리합니다. 현재 일 방문자 수십 명인 사이트에 충분한 여유입니다.
"커넥션을 늘리면 안 되나요?"
당연히 이런 생각이 듭니다. pool 크기를 늘리면 더 많은 요청을 처리할 수 있지 않을까?
저도 같은 생각을 했습니다. db-f1-micro의 max_connections가 25라고 해서, "이게 한계인가?"라고 생각했는데, 실제로 확인해보니 25는 제가 A안에서 일부러 낮춘 값이었습니다.
mysql> SHOW VARIABLES LIKE 'max_connections'; +-------------------+-------+ | Variable_name | Value | +-------------------+-------+ | max_connections | 25 | ← A안에서 설정한 값 +-------------------+-------+ mysql> SHOW STATUS LIKE 'Max_used_connections'; +----------------------------+-------+ | Variable_name | Value | +----------------------------+-------+ | Max_used_connections | 59 | ← A안 적용 전 역대 최고 기록 +----------------------------+-------+
Max_used_connections=59. A안 적용 전에는 기본값이 훨씬 높았고, 실제로 59개까지 쓴 적이 있다는 뜻입니다. db-f1-micro라도 max_connections는 60~100까지 올릴 수 있습니다.
그래서 직접 테스트해봤습니다.
확장안: 커넥션 2배로 늘려보기
말로만 하면 찝찝하니까, 실제로 설정을 바꾸고 같은 Stress 테스트를 돌려봤습니다.
확장안 설정: max_connections = 60 (25 → 60) pool-size = 15 (8 → 15) concurrency = 40 (20 → 40) Tomcat threads = 45 (24 → 45) 산식: 2 × 15 = 30 <= 60 - 17 = 43 ✅
커넥션 풀을 거의 2배, Tomcat 스레드도 2배, Cloud Run이 받아들이는 동시 요청도 2배. 숫자상으로는 훨씬 넉넉해 보입니다.
Stress 테스트 비교: A안 vs 확장안
같은 Stress 시나리오(10→20→30 VUs, 3분)를 돌렸습니다.
┌────────────────────┬───────────────────────┬────────────────────────┐ │ 지표 │ A안 (pool=8, conn=25) │ 확장안 (pool=15, conn=60) │ ├────────────────────┼───────────────────────┼────────────────────────┤ │ error_rate │ 42.77% │ 25.30% │ │ timeout_errors │ 86 │ 60 │ │ home p95 │ 10s │ 10s │ │ home 성공률 │ ~50% │ 51% │ │ 총 http 요청 │ ~450 │ 649 │ │ health p95 │ 39ms │ 19ms │ │ 성공 응답 평균 │ - │ 560ms │ └────────────────────┴───────────────────────┴────────────────────────┘
에러율은 42% → 25%로 줄었고, 총 처리량도 44% 늘었습니다. 확장안이 낫지 않나?
근데 **p95는 여전히 10초(타임아웃)**입니다. 커넥션과 스레드를 2배로 늘렸는데 이 숫자가 안 변합니다.
병목은 커넥션이 아니라 CPU
두 테스트에서 Health 체크(DB 불필요)의 p95를 보면:
A안: health p95 = 39ms 확장안: health p95 = 19ms
DB를 안 쓰는 요청은 양쪽 다 빠릅니다. 그런데 Home API(DB 쿼리 필요)는 양쪽 다 p95=10초. 병목이 커넥션 수가 아니라 DB의 CPU라는 증거입니다.
db-f1-micro의 vCPU는 **공유(shared)**입니다. 전용이 아니라 다른 인스턴스와 나눠 씁니다. 이 CPU가 처리할 수 있는 총량은 커넥션을 늘려도 변하지 않습니다.
주방(CPU)이 1명인 식당: 테이블 8개: 각 손님 10분 대기 → 전원 만족 테이블 15개: 각 손님 20분 대기 → 전원 불만 테이블 30개: 각 손님 40분 대기 → 전원 불만족 + 주방 과부하
테이블(커넥션)을 아무리 늘려도 요리사(CPU)가 1명이면 같은 시간에 같은 양만 만들 수 있습니다. 오히려 주문이 동시에 쏟아지면 하나하나가 느려질 뿐입니다.
빠른 실패 vs 느린 실패
그런데 에러율이 줄었으니 확장안이 낫지 않냐고요? 반대입니다.
A안 (concurrency=20): 동시 요청 21번째 → Cloud Run이 즉시 2번째 인스턴스로 분배 → 들어간 요청은 빠르게 처리, 초과분은 새 인스턴스가 받음 확장안 (concurrency=40): 동시 요청 21~40번째 → 전부 한 인스턴스에 몰림 → 15개 DB 커넥션에 40개 요청이 경쟁 → 모두가 느려짐 → 41번째가 돼야 2번째 인스턴스 기동
고속도로 진입 통제(ramp metering)에 비유하면 이해가 쉽습니다.
A안 = 진입 차량 20대 제한 → 도로 안은 원활 → 초과분은 다른 도로(2번째 인스턴스)로 즉시 안내 → 들어간 차는 빠르게 통과 확장안 = 진입 차량 40대 허용 → 도로에 차가 몰려 전체가 정체 → 모든 차가 느려짐 → 우회 안내(2번째 인스턴스)도 한참 뒤에야 시작
A안은 한계를 넘으면 빠르게 다른 인스턴스로 넘기고, 확장안은 한계를 넘어도 한 인스턴스에서 끙끙대다가 모두가 느려집니다. 에러율이 낮아 보이는 건 "빨리 에러를 내는 대신 오래 기다리다 겨우 성공"한 것일 뿐, 사용자 체감은 더 나쁩니다.
현재 A안이 10 VUs까지 완벽하게 처리하는 건, pool=8이 db-f1-micro의 CPU 능력에 딱 맞기 때문입니다.
진짜로 성능을 올리려면
같은 db-f1-micro에서 커넥션만 늘려봤자 소용없다면, 진짜로 한계를 넘으려면?
┌──────────────────────────────┬──────────────────────────────────┬─────────┐ │ 방법 │ 효과 │ 비용 │ ├──────────────────────────────┼──────────────────────────────────┼─────────┤ │ 커넥션만 늘리기 │ 역효과 (느려짐) │ $0 │ │ Home API 쿼리 최적화/캐싱 │ 쿼리 시간 단축 → 같은 pool로 │ $0 │ │ │ 더 많이 처리 │ │ │ db-g1-small 승급 │ 전용 vCPU + RAM 1.7GB │ ~$25/월 │ │ │ → 실질적 개선 │ │ └──────────────────────────────┴──────────────────────────────────┴─────────┘
1순위: 코드 최적화 ($0). 쿼리가 50ms에서 25ms로 줄면, 같은 pool=8로도 처리량이 2배 늘어납니다. DB 업그레이드보다 효과 대비 비용이 압도적으로 좋습니다.
2순위: 설정 맞추기 ($0). 바로 이 글에서 한 작업입니다.
3순위: DB 승급 (~$25/월). 트래픽이 실제로 늘었을 때 데이터를 보고 결정합니다.
승급 시점은 어떻게 알 수 있나
"그때가 언제인데?"에 대한 답도 미리 정해뒀습니다. 아래 중 2개 이상이 10~15분 이상 지속되면 B안(db-g1-small)으로 승급합니다.
승급 트리거: □ HikariCP 대기 커넥션 > 0 □ 활성 커넥션 / 최대 > 80% □ DB CPU > 70% □ p95 API 지연 급상승 □ lock wait / timeout 로그 증가
B안의 설정은 미리 산식을 다 맞춰뒀습니다.
B안 (db-g1-small): max_connections = 100 Cloud Run: max-instances=5, concurrency=50 HikariCP: pool=12, min-idle=3 Tomcat: threads=50 산식: 5 × 12 = 60 <= 100 - 20 = 80 ✅
데이터 기반으로 판단하되, 판단이 필요해질 때 허둥대지 않도록 설정을 미리 준비해 둡니다.
최적화 순서가 곧 비용 절감 전략
결국 깨달은 건 최적화에 순서가 있다는 거였습니다.
1. 코드 병목 제거 (트랜잭션 경합, 슬로우 쿼리) ← $0 2. 커넥션 산식 맞추기 (I × P <= M - R) ← $0 3. DB 티어 업그레이드 ← $$
1번과 2번을 건너뛰고 "DB 느리니까 스펙 올리자"고 하면, 돈은 돈대로 쓰면서 코드 문제는 그대로 남습니다. 더 좋은 DB에서 더 많은 커넥션을 여는 것뿐, 근본적인 비효율은 해결되지 않습니다.
반대로 1번, 2번을 먼저 하면 생각보다 db-f1-micro로도 충분히 갈 수 있습니다. 월 $7로 동시 접속 20~30명을 안정적으로 서빙한다면, 초기 프로젝트로서는 꽤 괜찮은 효율입니다.
마무리
처음에는 "DB가 작으니까 커넥션을 줄이자" 정도로 시작했습니다. 그런데 파고들수록 Cloud Run 인스턴스 수, Tomcat 스레드, HikariCP 풀, MySQL max_connections가 전부 맞물려 있었습니다. 하나만 건드리면 다른 데서 터지고, 전체를 산식으로 맞춰야 겨우 안정됐습니다.
"커넥션을 늘리면 되지 않을까?" 하고 실제로 2배를 늘려봤더니 오히려 나빠졌습니다. 작은 DB에서는 커넥션을 넉넉하게 여는 게 답이 아니라, CPU 능력에 맞춰 적절히 제한하는 게 답이었습니다. concurrency=20이 과부하를 막고, Cloud Run이 넘치는 요청을 다른 인스턴스로 보내는 구조가 커넥션을 무작정 늘리는 것보다 나았습니다.
k6 부하 테스트를 돌리기 전에는 "아마 될 거야" 수준이었는데, 돌리고 나니 "10 VUs까지 에러율 0%, 17 VUs 부근에서 타임아웃 시작"이라는 구체적인 숫자가 나왔습니다. 한계를 숫자로 알면 "지금 괜찮다"는 확신도 생기고, 다음 단계로 넘어갈 시점도 판단할 수 있습니다.
DB 스펙을 올리기 전에 산식부터 맞춰보세요. 월 $7로 생각보다 많이 버팁니다.
댓글
댓글을 작성하려면 이 필요합니다.