GCP 최저사양으로 동시접속 1,000명 버티기
GCP 최저사양으로 동시접속 1,000명 버티기: 부하 테스트 4라운드의 기록
이전 글에서 db-f1-micro의 커넥션 산식을 맞추고, k6로 한계를 측정했습니다. 설정 최적화만으로 동시 접속 20~30명까지는 처리할 수 있었고요.
그런데 "다음 주 수업 20명이 동시에 접속하면?" 하는 걱정이 생기면서, 부하 테스트가 4라운드까지 이어졌습니다. 한 번 시작하니까 "좀 더 밀어보면 어디서 터질까?" 하는 호기심을 못 참겠더라고요.
Round 1: 설정 최적화 (A안) → 10 VUs 안전, 17 VUs 한계 Round 2: DB 업그레이드 (B안) → 20 VUs 안전, 40 VUs 한계 Round 3: 코드 병목 제거 → 60 VUs 에러 0% Round 4: 한계 탐색 → 200 VUs 에러 0%, TPS 90
Round 1은 이전 글에서
첫 번째 라운드는 이전 글에서 다뤘으니 간략히만 정리합니다.
db-f1-micro (공유 vCPU, 614MB) + 커넥션 산식 최적화: I × P = 2 × 8 = 16 <= 25 - 7 = 18 ✅ 결과: ~10 VUs: 에러 0% ← 안전 ~17 VUs: 타임아웃 ← 한계 30 VUs: 에러 42% ← 붕괴
병목 분석에서 커넥션을 2배로 늘려봤지만 p95는 10초 그대로였습니다. 병목이 커넥션 수가 아니라 DB CPU라는 걸 확인했습니다.
Round 2: DB 업그레이드, 진짜 빨라질까
다음 주에 20명이 동시에 사용하는 수업이 있었습니다. A안의 안전 영역이 10 VUs인데, 20명이 동시에 클릭하면 위험합니다.
db-f1-micro에서 db-g1-small로 올렸습니다.
┌─────────────────────────────┬──────────────┬──────────────┐ │ 항목 │ A안 (before) │ B안 (after) │ ├─────────────────────────────┼──────────────┼──────────────┤ │ Cloud SQL 인스턴스 │ db-f1-micro │ db-g1-small │ │ vCPU │ 공유(shared) │ 전용 1 vCPU │ │ RAM │ 614MB │ 1.7GB │ │ max_connections │ 25 │ 100 │ │ Cloud Run max-instances │ 2 │ 5 │ │ HikariCP pool │ 8 │ 12 │ │ Tomcat threads │ 24 │ 50 │ └─────────────────────────────┴──────────────┴──────────────┘ 산식: 5 × 12 = 60 <= 100 - 20 = 80 ✅ (여유 20)
테스트 결과
VU 범위도 2배로 올려서 테스트했습니다.
┌───────────┬──────────────────┬──────────────────┐ │ 시나리오 │ A안 (db-f1-micro)│ B안 (db-g1-small) │ ├───────────┼──────────────────┼──────────────────┤ │ Normal │ 10 VUs: 에러 0% │ 20 VUs: 에러 0% │ │ Stress │ ~17 VUs 붕괴 │ ~40 VUs 붕괴 │ │ Spike │ 25 VUs: 에러 25% │ 50 VUs: 에러 53% │ └───────────┴──────────────────┴──────────────────┘
안전 영역이 10 VUs → 20 VUs로 2배. 20명 수업에는 충분합니다.
근데 한 가지 신경 쓰이는 게 있었습니다. Home API의 p95가 1.46초. 기준(3초) 안이라 통과는 했는데, 홈 화면 하나 여는 데 1.5초씩 걸린다니 찝찝합니다.
Round 3: 코드를 들여다보니 N+1이 있었다
"DB를 올렸으니 됐겠지"하고 넘어갈 수도 있었는데, 1.46초가 계속 마음에 걸려서 코드를 열어봤습니다.
// HomeService.java - 변경 전 var posts = unifiedPostRepository.findByBoardIdAndStatusNot( boardId, PostStatus.DELETED, Pageable.unpaged() // ① 전체 조회 ); answerCount = posts.getContent().stream() .mapToLong(post -> unifiedCommentRepository.countByPostIdAndIsDeletedFalse(post.getId()) // ② ) .sum();
① Pageable.unpaged(): QnA 게시판의 글을 전부 가져옵니다. 100개면 100개, 본문까지 전부.
② 글마다 COUNT 쿼리: 가져온 글 하나하나마다 "댓글이 몇 개야?"를 DB에 물어봅니다.
QnA 글 100개일 때: 1번: SELECT * FROM unified_post WHERE board_id = ? AND status != 'DELETED' 2번: SELECT COUNT(*) FROM unified_comment WHERE post_id = 1 3번: SELECT COUNT(*) FROM unified_comment WHERE post_id = 2 ... 101번: SELECT COUNT(*) FROM unified_comment WHERE post_id = 100 총 101번 쿼리. 홈 화면 한 번 열 때마다.
전형적인 N+1 문제.
수정: 101번 → 1번
// 추가한 쿼리 - 보드의 전체 댓글 수를 한 번에 세기 @Query("SELECT COUNT(c) FROM UnifiedComment c " + "WHERE c.post.board.id = :boardId AND c.isDeleted = false") long countByBoardIdAndIsDeletedFalse(@Param("boardId") Long boardId); // HomeService.java - 변경 후 answerCount = unifiedCommentRepository.countByBoardIdAndIsDeletedFalse(boardId);
여기에 캐시를 추가했습니다. 한번 DB에서 가져온 데이터를 메모리에 잠깐 저장해두고, 같은 요청이 오면 DB 없이 메모리에서 바로 꺼내주는 방식입니다.
Java/Spring 진영에서 쓸 수 있는 캐시 라이브러리는 여러 가지가 있습니다.
Caffeine - 애플리케이션 메모리에 저장. 외부 서버 불필요. 단일 인스턴스에서 가장 빠름 EhCache - Caffeine과 비슷하지만 디스크 저장, 클러스터링도 지원 Redis - 별도 서버 운영. 여러 인스턴스가 캐시를 공유할 수 있음 Memcached - Redis와 비슷하지만 더 단순. 문자열 키-값 위주
Caffeine을 선택한 이유는 간단합니다. 외부 서버가 필요 없어서 추가 비용이 $0이고, Spring Boot에서 @Cacheable 어노테이션 하나로 적용됩니다. 단점은 Cloud Run 인스턴스마다 각자 캐시를 갖기 때문에 인스턴스 간 데이터가 최대 60초간 다를 수 있다는 건데, 홈 화면 통계 정도는 그 정도 차이가 문제되지 않습니다.
@Cacheable("homeData") // 60초 캐시 public HomeDataDto getHomeData() { ... } @Cacheable("homeStats") // 30초 캐시 public HomeStatsDto getHomeStats() { ... }
효과: 100배 빨라짐
┌───────────┬──────────────────┬──────────────────────┐ │ 시나리오 │ 수정 전 (N+1) │ 수정 후 (단일 쿼리+캐시)│ ├───────────┼──────────────────┼──────────────────────┤ │ Home p95 │ 1.46초 │ 14밀리초 │ │ Stress │ 40 VUs에서 붕괴 │ 60 VUs 에러 0% │ │ Spike │ 에러 53% │ 에러 0% │ └───────────┴──────────────────┴──────────────────────┘
같은 db-g1-small에서 코드만 고쳤는데 안전 영역이 20 → 60+ VUs로 3배. DB 승급(월 +$18)보다 코드 수정($0)이 3배 더 효과적이었습니다.
1편에서 세운 최적화 순서가 맞았습니다:
1순위: 코드 병목 제거 ← 효과 최대 ($0) 2순위: 커넥션 산식 ← 안정성 확보 ($0) 3순위: DB 업그레이드 ← 마지막 수단 ($$) 실제 진행 순서: 2 → 3 → 1 효과가 큰 순서: 1 → 3 → 2
그런데 테스트가 좀 이상했다
여기서 한 가지 더 걸렸습니다. 테스트가 health와 home 두 개 API만 치고 있었다는 점.
테스트한 것: GET /api/health ← DB 안 씀 GET /api/home ← 이제 캐시 때문에 DB 거의 안 씀 실제로 사용하는 것: 게시판 목록/상세, QnA, 수업 교재, 수업 게시판, 책, ...
실제 API를 안 테스트하면 "60 VUs까지 괜찮다"는 말을 믿을 수가 없습니다.
테스트용 JWT 발급 API
수업 페이지 API는 로그인이 필요한데, k6에서 구글 OAuth를 할 수는 없으니, 기존에 쓰던 X-Migration-Key 헤더로 테스트용 JWT를 발급하는 API를 만들었습니다.
POST /api/admin/test-token Headers: X-Migration-Key: {서버 환경변수} Body: { "email": "carami@gmail.com" } → JWT 토큰 반환 보안: Migration Key + IP 제한(선택) + ACTIVE 사용자만
k6의 setup()에서 토큰을 한 번 발급받아 모든 VU가 공유하는 방식입니다.
실사용 패턴으로 재테스트
실제로 사용자들이 많이 쓰는 비율대로 7가지 API를 조합했습니다.
30% - 게시판 (목록 → 상세) ← 가장 빈번 25% - 수업 (교재/게시판/상세) ← JWT 필요 15% - QnA (목록 → 상세) 10% - 홈 10% - 책/교재 10% - 헬스체크
결과: 60 VUs에서 전 시나리오 에러 0%. 가장 느린 QnA도 p95=123밀리초.
Round 4: 그래서 최대 몇 명까지?
60 VUs에서 에러 0%까지는 확인했습니다. 여기서 멈출 수도 있었는데... 또 궁금해졌습니다.
"진짜 한계가 어디야? 어디서 터져?"
시나리오 1: 점진적 증가 (30 → 200 VUs)
30명부터 시작해서 200명까지 단계적으로 올렸습니다. 사용자마다 페이지 읽는 시간(0.1~1.5초)을 넣은 현실적 패턴.
30 → 60 → 80 → 100 → 120 → 150 → 200 VUs (3분 40초)
┌────────────────┬───────────────────┐ │ 에러율 │ 0.00% │ │ p95 응답시간 │ 1.37초 │ │ 초당 요청 (TPS) │ 110 req/s │ │ 총 요청 수 │ 24,466 │ │ 타임아웃 │ 0회 │ └────────────────┴───────────────────┘
200명까지 올려도 에러가 단 1건도 발생하지 않았습니다.
API별 p95를 보면:
┌──────────────┬───────┬───────┐ │ API │ p95 │ 평균 │ ├──────────────┼───────┼───────┤ │ Home │ 1.01s │ 242ms │ ← 캐싱 효과 │ Health │ 1.07s │ 234ms │ │ Post Detail │ 1.28s │ 283ms │ │ Board List │ 1.35s │ 299ms │ │ Book │ 1.36s │ 316ms │ │ QnA │ 1.53s │ 361ms │ │ Lesson │ 1.55s │ 338ms │ ← 가장 느림 └──────────────┴───────┴───────┘
Home API가 가장 빠릅니다. N+1 제거 전에는 가장 느린 API였는데, 캐싱 달아놓으니 1등이 됐습니다.
시나리오 2: 지속 부하 (100명 2분)
100 VUs를 2분간 유지. 순간은 버텨도 시간이 지나면 무너지는 경우가 있으니까요.
에러율: 0.00% | p95: 1.12초 | TPS: 72.5 req/s
2분 내내 안정적.
시나리오 3: 최대 TPS 측정
constant-arrival-rate 실행기로 생각 시간 없이 초당 50 iteration을 쏟아부었습니다. iteration당 평균 2개 API를 호출하니 실제로는 ~90 req/s가 발생합니다.
┌─────────────────┬────────────────────┬────────────────────┐ │ │ 1차 실행 │ 2차 실행 (워밍업) │ ├─────────────────┼────────────────────┼────────────────────┤ │ 에러율 │ 7.79% │ 0.66% │ │ p95 │ 10초 (타임아웃!) │ 3.39초 │ │ TPS │ 90 req/s │ 92 req/s │ │ 타임아웃 │ 654회 │ 0회 │ │ 판정 │ FAIL │ 경계선 │ └─────────────────┴────────────────────┴────────────────────┘
TPS 90 부근이 이 구성의 천장입니다.
4라운드 종합 결과
┌──────┬────────────────────┬──────────────┬──────────────┬────────────┐ │ 단계 │ 작업 │ 안전 영역 │ 한계 │ 비용 변화 │ ├──────┼────────────────────┼──────────────┼──────────────┼────────────┤ │ 1 │ A안: 설정 최적화 │ ~10 VUs │ ~17 VUs │ $0 │ │ 2 │ B안: DB 승급 │ ~20 VUs │ ~40 VUs │ +$18/월 │ │ 3 │ N+1 제거 + 캐싱 │ 60+ VUs │ 측정 불가 │ $0 │ │ 4 │ 한계 탐색 (실사용) │ 200 VUs │ TPS ~90 │ $0 │ └──────┴────────────────────┴──────────────┴──────────────┴────────────┘
실제 사용자 수 환산
k6의 VU는 쉬지 않고 연속 요청하는 가상 사용자입니다. 실제 사용자는 페이지를 읽는 시간(10~30초)이 있으니까:
실제 동시 사용자 ≈ VU × (평균 생각시간 / 평균 요청시간) = 200 × (15초 / 1.5초) ≈ 2,000명
┌───────┬────────┬──────────────────┬────────────────────┐ │ 등급 │ VU │ 실제 동시사용자 │ 상태 │ ├───────┼────────┼──────────────────┼────────────────────┤ │ 안전 │ 100 │ ~1,000명 │ 에러 0%, 쾌적 │ │ 최대 │ 200 │ ~2,000명 │ 에러 0%, 약간 느림 │ │ 한계 │ 250+ │ ~2,500명+ │ 타임아웃 시작 │ └───────┴────────┴──────────────────┴────────────────────┘
그래서 비용은 얼마나 드나
성능은 확인했으니 돈 얘기를 해봅시다.
GCP는 "DB 얼마, 서버 얼마"로 끝나지 않습니다. 로드 밸런서, 네트워크, 컨테이너 저장소 같은 부대 비용이 생각보다 큽니다.
GCP 표준 가격표 기준으로 산출했습니다. (Gemini API, saju 프로젝트 비용은 제외)
┌───────────────────────────┬────────────┬─────────┬──────────────────────┐ │ 서비스 │ 월 비용($) │ 월 비용 │ 비고 │ ├───────────────────────────┼────────────┼─────────┼──────────────────────┤ │ Cloud SQL db-g1-small │ ~$29 │ 42,000원│ 1 vCPU + 1.7GB + 10GB│ │ Load Balancer │ ~$40 │ 58,000원│ 포워딩 규칙 4개 │ │ Cloud Run (3개 서비스) │ ~$5~15 │ 14,500원│ Backend+Frontend+Image│ │ Cloud Armor │ ~$6 │ 8,700원│ WAF 정책 + 규칙 │ │ Network egress │ ~$2~132 │ 2,900원│ ⚠️ 트래픽 비례 증가 │ │ 기타 (AR, DNS, SM, CS) │ ~$2 │ 2,900원│ 컨테이너/도메인/비밀 │ ├───────────────────────────┼────────────┼─────────┼──────────────────────┤ │ 합계 (현재 트래픽) │ ~$89 │129,000원│ 소규모 기준 │ │ 합계 (1,000명 동시접속) │ ~$220 │320,000원│ 네트워크 비용 증가 │ └───────────────────────────┴────────────┴─────────┴──────────────────────┘
의외의 비용 구조
로드 밸런서가 DB보다 비쌉니다. Cloud SQL이 ~$29인데 Load Balancer가 ~$40. GCP의 External HTTPS Load Balancer는 포워딩 규칙당 고정 비용을 부과합니다. 첫 번째 규칙 $18.25/월, 추가 규칙 $7.30/월. FullStackFamily는 HTTP→HTTPS 리다이렉트, CDN, 이미지 서비스 CDN까지 4개 규칙을 써서 이 금액이 나옵니다.
Cloud Run은 생각보다 쌉니다. Request-based billing(cpu-throttling=true)을 쓰면 요청이 없을 때는 과금이 안 됩니다. 소규모 사이트라면 무료 티어(월 200만 요청)로 거의 커버.
DB 비용만 보면 큰코다칩니다. DB($29)만 보고 "월 4만원이면 되겠네"라고 생각했다가 실제 청구서를 보면 놀랍니다. 네트워크 인프라가 전체의 절반 가까이 차지합니다.
트래픽이 늘면 네트워크 비용이 가장 크게 뜁니다. 위 표의 ~$2는 현재 소규모 트래픽 기준입니다. GCP 네트워크 이그레스는 $0.12/GB(Premium Tier)인데, 트래픽에 비례해서 올라갑니다.
현재 (소규모): ~$2/월 TPS 90 상시 유지: API 응답 5KB × 90 req/s × 30일 ≈ 1.1TB → ~$132/월
DB나 Cloud Run은 트래픽이 늘어도 비용이 크게 안 변하지만, 네트워크 이그레스는 사용자가 많아질수록 선형으로 증가합니다. 트래픽이 본격적으로 늘기 시작하면 비용 구조에서 네트워크가 가장 큰 비중을 차지하게 됩니다.
현재는 GCP 크레딧으로 전액 상쇄되어 실제 청구 금액은 $0입니다. 크레딧이 소진되면 위 금액이 실비용이 됩니다.
비용 대비 성능
인프라 기본 비용: ~$89/월 (약 129,000원) — 현재 소규모 트래픽 기준 트래픽 증가 시: ~$220/월 (약 320,000원) — 네트워크 비용 증가 반영 성능: ✅ 동시 1,000명 접속 (100 VUs 기준, 에러 0%) ✅ 초당 90건의 요청 처리 ✅ 모든 API p95 1.55초 이내 ✅ 피크 시 최대 ~2,000명까지 가능 (200 VUs)
기본 인프라 비용은 월 13만원이지만, 실제로 1,000명이 동시에 쓰는 수준이 되면 네트워크 이그레스가 늘어나서 월 30만원대까지 올라갈 수 있습니다. 그래도 20명짜리 수업 10개가 동시에 돌아가도 여유가 있는 성능이고, 현재 규모에서는 13만원 수준으로 충분합니다.
돌아보니
4라운드를 거치면서 뼈저리게 느낀 게 있습니다. 최적화 순서를 거꾸로 갔다는 것.
효과 순위: 1. 코드 병목 제거 (N+1 → 단일 쿼리) → 3배 이상 향상 ($0) 2. DB 업그레이드 (f1-micro → g1-small) → 2배 향상 (+$18/월) 3. 설정 최적화 (커넥션 산식) → 안정성 확보 ($0)
가장 비싼 선택(DB 업그레이드)이 효과가 가장 작고, 공짜인 코드 수정이 효과가 가장 컸습니다. 실제 진행은 2→3→1이었지만, 1→2→3이 맞았습니다.
테스트 범위도 문제였습니다. 처음에는 health와 home 두 개만 쳤습니다. N+1 고치고 나니 home이 캐시 덕에 14ms가 됐고, "60 VUs 통과!"라고 좋아했는데, 실제로 사용하는 API는 테스트에 들어있지도 않았습니다. 게시판, QnA, 수업 교재, 수업 게시판까지 넣고 나서야 비로소 "진짜 괜찮다"고 말할 수 있었습니다. 수업 페이지는 JWT 발급 API까지 만들어야 했지만 그만한 가치가 있었고요.
그리고 병목은 늘 예상과 다른 곳에 있었습니다.
예상: DB 커넥션이 부족한 게 문제 실제: DB CPU가 문제 (커넥션 2배로 늘려도 동일) 진짜: CPU를 잡아먹는 N+1 쿼리가 문제 "커넥션 모자라니까 DB 올리자" → $18/월 추가, 2배 향상 "쿼리를 고치자" → $0, 3배 이상 향상
부하 테스트 전에는 "아마 되겠지" 수준이었는데, 테스트 후에는 "200 VUs에서 에러 0%, TPS 90"이라는 숫자가 생겼습니다. 숫자가 있으면 "지금은 충분하다"는 확신도 생기고, "다음에 뭘 올려야 하는지"도 데이터로 결정할 수 있습니다
다음에 더 필요하다면
현재 병목은 TPS 90 이상에서 DB 커넥션 풀(60개)이 포화되는 지점.
┌──────────────────────────────┬──────────────────────────┐ │ 업그레이드 │ 예상 효과 │ ├──────────────────────────────┼──────────────────────────┤ │ max-instances 10으로 증가 │ 커넥션 풀 120개, TPS ~150│ │ DB를 db-custom-2-4096으로 │ max_conn 200, TPS ~180 │ │ Redis 캐시 추가 │ 반복 조회 대부분 캐시 │ │ Lesson/QnA 쿼리 최적화 │ 가장 느린 API 개선 ($0) │ └──────────────────────────────┴──────────────────────────┘
4라운드의 교훈대로라면, 인프라 올리기 전에 Lesson(p95=1.55s)과 QnA(p95=1.53s) 쿼리부터 봐야겠죠.

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