부하 테스트 — 몇 명까지 버티고, 어디서 먼저 막히나

2026-10-07 ~ 10-14 · 백엔드 v0.88.0 → v0.118.6 · k6 · 로컬에서 10번, AWS(EKS)로 옮긴 뒤 3단계 측정

사흘 동안 부하 테스트를 10차례 돌리며, 측정으로 확인된 병목만 하나씩 고쳤습니다. 혼합 처리량 천장은 약 185 → 약 925 RPS(5배), 응답 시간 기준을 지킨 최대 동시접속은 약 500명 → 약 2,900명이 됐습니다. 마지막 10차에서는 실제 브라우저처럼 모든 응답을 압축해 받게 해서 다시 쟀고, 천장은 약 900 RPS로 거의 같았습니다. 차수마다 바꾼 것은 하나뿐이고, 나머지 조건은 그대로 두어 그 변화의 효과만 보이게 했습니다.

서버를 AWS EKS로 옮긴 뒤(10/12) 같은 시나리오로 11차를 돌렸습니다. 처음 구성 그대로는 약 685 RPS에서 기준을 넘었고, 먼저 막힌 곳은 로컬 때의 API CPU가 아니라 DB CPU였습니다. DB를 한 단계 키우고 API 파드 상한을 20개로 늘리자 1,543 RPS(동접 4,500명)에서도 p95 51ms로 모든 기준을 지켰습니다. 눈금으로 쓴 TPS 1,500의 103%, 로컬 최고(9차)의 약 1.7배입니다.

이 페이지는 공개용 요약입니다. 목표 동접 산정에 쓴 시장 규모 가정과 서버 이전 비용은 비공개 문서에 따로 둡니다.

로컬 혼합 처리량 천장

185 → 925 RPS · 약 5배 · 실제 브라우저 조건(10차) 약 900

AWS(EKS)에서 다시 잰 처리량

1,543 RPS · 동접 4,500명 · p95 51ms · 아직 포화 전(11-3)

TPS 1,500 대비

12% → 103% 로컬 1차 → 로컬 9차 62% → AWS 11-3

차수별 혼합 천장 RPS

4차는 동접 1,000명까지 포화하지 않아 천장을 재지 못했습니다. 8차도 동접 2,000명까지 포화하지 않아 천장을 재지 못했습니다. 11차는 AWS(EKS)로 옮긴 뒤의 측정이고, 11-2·11-3은 포화 전에 계단이 끝났습니다.

  1. 1차바꾼 것: 기준선 약 185 RPS
  2. 2차바꾼 것: 지원사업 하이브리드 검색(임베딩) 추가 약 164 RPS
  3. 3차바꾼 것: 공고 목록 캐시 약 213 RPS
  4. 4차바꾼 것: 워커 4 + 분석 대기 장부 Postgres 343 이상 RPS(포화 안 함)
  5. 5차바꾼 것: (같은 코드) k6만 다른 PC로 약 470 RPS
  6. 6차바꾼 것: stores 부분 인덱스 약 505 RPS
  7. 7차바꾼 것: 지도 경계 미리 압축 약 585 RPS
  8. 8차바꾼 것: 판정 경로 캐시 689 이상 RPS(포화 안 함)
  9. 9차바꾼 것: (같은 코드) 계단을 3,000명까지 약 925 RPS
  10. 10차바꾼 것: (같은 코드) 모든 응답 압축 — 실제 브라우저 조건 약 900 RPS
  11. 11-1바꾼 것: AWS로 이전, 처음 구성 그대로(먼저 찬 곳: DB CPU) 약 685 RPS
  12. 11-2바꾼 것: DB 한 단계 키움 + 파드 상한 20 1,033 이상 RPS(포화 안 함)
  13. 11-3바꾼 것: (같은 구성) 계단을 4,500명까지 1,543 이상 RPS(포화 안 함)

1. 무엇을 쟀나

질문

  1. 동시접속 몇 명까지 버티나 — 응답 시간 기준을 넘기 전까지
  2. 어디가 먼저 막히나 — API CPU, DB, 임베딩 서버, 연결 수 중 무엇이

“TPS 1,500” 같은 업계 공통 숫자는 없습니다. 목표 처리량은 우리 서비스의 예상 동접에서 거꾸로 계산하고, 1,500 TPS는 “얼마나 가까이 갔나”를 보는 눈금으로만 썼습니다.

가상 사용자 4종 (k6 시나리오)

실제 화면이 부르는 API 순서 그대로, 화면을 읽는 시간(생각 시간)을 넣었습니다.

사용자 비중 하는 일
지도 탐색 60% 지도 진입(경계·판정 색칠) → 동 3~5곳 클릭(요약·프로필·판정·대안·점포)
질문 → AI 리포트 10% 질문 해석 → 분석 리포트(SSE 스트림). 절반은 질문 포함
자금 계획 20% 프리필·지원사업 후보·확인 질문 → 값을 바꿔 계산 2~3번
지원사업 10% 지원 정보 조회. 절반은 질문 검색(규칙 + 임베딩)

동 427곳 × 업종 12개를 무작위로 골라, 같은 동만 반복해 캐시가 다 맞는 일이 없게 했습니다. 동접 1명이 평균 약 0.34 RPS를 만듭니다.

합격 기준

대상 기준
일반 조회 GET p95 < 500ms, p99 < 1s
지도 경계(geojson) p95 < 2s
자금 계산 p95 < 300ms
분석 리포트 전체 p95 < 60s
에러율 100명 Load < 0.1%, 그 밖 < 1%
깨짐 (Stress에서 멈추는 선) 에러 5% 또는 p95 3초

응답 시간은 평균이 아니라 백분위로 봅니다. 평균은 느린 요청을 가립니다.

측정 조건 (1~10차, 로컬)

  • 테스트 전용 API·DB 컨테이너(개발 DB 복제본). 운영·개발 서버와 분리
  • CPU 고정 배치 — API 논리 코어 4개, DB 4개. 차수 사이에 바꾸지 않음
  • 가짜 LLM — 분석 해석은 고정 3초만 쉬고 고정 문장을 낸다(요금·외부 한도 없이 서버 구조의 한계만 잼). 실제 Gemini는 별도 트랙에서만
  • 단계마다 API 재시작 · DB 쿼리 통계 초기화 · 30초 안정화 뒤 시작
  • 5차부터 k6를 다른 PC에서 실행(유선 1Gbps, 왕복 1ms 이하) — 부하 도구가 서버 CPU를 나눠 쓰지 않게

2. 결과 한눈에

차수 바꾼 것 워커 · k6 위치 혼합 천장 RPS 기준 지킨 최대 동접 먼저 찬 곳
1차 기준선 1 · 같은 서버 약 185 약 500명 API CPU 1코어(워커 1개)
2차 지원사업 하이브리드 검색(임베딩) 추가 1 · 같은 서버 약 164 약 460명 같음
3차 공고 목록 캐시 1 · 같은 서버 약 213 약 550명 같음
4차 워커 4 + 분석 대기 장부 Postgres 4 · 같은 서버 343 이상 1,000명 이상 1,000명까지 안 나타남
5차 (같은 코드) k6만 다른 PC로 4 · 원격 약 470 약 1,450명 API CPU 4코어
6차 stores 부분 인덱스 4 · 원격 약 505 약 1,620명 API CPU
7차 지도 경계 미리 압축 4 · 원격 약 585 약 1,830명 API CPU
8차 판정 경로 캐시 4 · 원격 689 이상(2,000명에서 포화 안 함) 2,000명 이상 2,000명까지 안 나타남
9차 (같은 코드) 계단을 3,000명까지 4 · 원격 약 925 약 2,900명 API CPU 4코어
10차 (같은 코드) k6가 모든 응답을 압축해 받음 — 실제 브라우저 조건 4 · 원격 약 900 약 2,950명 API CPU 4코어
11-1 AWS EKS로 이전, 처음 구성 그대로 파드 2~10 · 같은 리전 EC2 약 685(포화 약 900) 약 2,000명 DB CPU(2 vCPU)
11-2 DB 4 vCPU로 한 단계 키움 + 파드 상한 20 파드 2~20 · 같은 리전 EC2 1,033 이상(3,000명에서 포화 안 함) 3,000명 이상 3,000명까지 안 나타남
11-3 (같은 구성) 계단을 4,500명까지 파드 2~20 · 같은 리전 EC2 1,543 이상(포화 전) 4,500명 이상 파드 상한 20에 닿음, DB CPU 67%
  • TPS 1,500 대비 천장 약 12% → 약 62%(로컬, 실제 브라우저 조건인 10차 기준 약 60%) → 103%(AWS 11-3)
  • 진짜 에러는 로컬 모든 차수에서 0.02% 아래. 전부 POST 요청의 연결 끊김(서버 keep-alive 만료와 겹치는 경합으로 추정)이고, HTTP 5xx는 0
  • AWS 11차의 에러는 단계마다 100만~190만 요청 중 3~13건(502, 0.001% 이하)이고, 11-1에서는 모두 포화 구간에서 났다

3. 차수별로 무엇을 봤고 왜 고쳤나

1차 — 기준선: 워커 1개가 코어 1개만 쓴다

  • API를 하나씩 단독으로 부하해 보니 판정 지도(/verdicts)·지도 경계(/regions/geojson)가 초당 약 80~125건에서 먼저 꺾였다
  • 혼합 부하 천장 약 185 RPS. API 컨테이너에 4코어를 줬지만 CPU는 1.25코어 근처에서 평평 — 파이썬 프로세스 1개(GIL)의 한계
  • 함께 본 것: 500명 급증(10초) 뒤 회복 0초 · 50명 110분 장시간에서 p95 변화 −0.4% · 실제 Gemini 동시 분석 33건·분당 약 440회에서 한도 초과(429) 0건 — 다만 이때 같은 워커의 지도 API p95가 449ms로 기준 턱밑

2차 — 임베딩이 들어왔지만 GPU는 병목이 아니다

  • 지원사업 질문 검색이 요청마다 임베딩(bge-m3) 1회를 부르게 되자 천장이 185 → 164로 떨어졌다
  • GPU 사용률은 평균 1% 이하. 떨어진 원인은 지원사업 목록을 요청마다 다시 계산하는 CPU였다 — 마감 전 공고 1,521건을 매번 통째로 읽고 파이썬에서 거름(13ms)

3차 — 공고 목록 캐시

  • 하루 한 번 바뀌는 공고 목록을 10분 동안 메모리에 둔다 → 천장 164 → 213

4차 — 워커 4개로, 그 전에 분석 대기 장부부터

  • 워커를 늘리면 “분석 시작(POST)”과 “리포트 받기(GET)”가 다른 워커로 가서 실패한다. 메모리에 두던 대기 장부를 Postgres 테이블로 옮긴 뒤 워커 4개로 — 분석 1,906건 전부 성공
  • Redis 대신 Postgres를 쓴 이유: 장부의 수명이 몇 초이고 분석은 전체 요청의 약 0.6%라, 새 서비스(새 장애 지점)를 늘릴 규모가 아니다
  • 동접 1,000명에서도 포화하지 않아 천장을 재지 못했다

5차 — 부하 도구를 다른 PC로 옮겨 천장 측정

  • 같은 코드로 천장 약 470 RPS. 4차에서 계산으로 짐작한 620은 틀렸다 — 포화 근처에서 요청당 CPU가 5.9 → 8.4ms로 커진다
  • 1,000명까지는 k6를 어디서 돌리든 결과가 같았다(343 RPS) — 같은 서버 측정도 그 범위에서는 유효
  • DB 시간의 약 66%가 점포 목록 쿼리 하나

6차 — stores 부분 인덱스

  • 동·업종 점포 목록이 업종 전체(카페 약 14.8만 행)를 읽고 동으로 거르고 있었다. 조건과 정렬을 그대로 덮는 부분 인덱스로 단건 35.7 → 1.7ms, 부하 중 평균 27.5 → 0.2ms
  • 요청당 DB CPU 4.3 → 1.8ms(−58%). 천장은 470 → 505로 조금만 올랐다 — 병목이 API CPU라서

7차 — 지도 경계 미리 압축

  • 지도 경계(439KB)를 요청마다 JSON으로 만들고 최고 수준으로 압축하고 있었다(요청당 CPU 40.8ms). 한 번만 만들어 재사용 → 0ms, 응답 내용은 그대로
  • 요청당 API CPU 5.8 → 4.2ms(예상한 1.6ms 절감과 일치), 천장 505 → 585
  • 1,500명에서 처음으로 3분 유지 동안 모든 API가 기준 안 — 6차까지 혼자 넘던 지원사업 검색 p95 542 → 318ms

8차 — 판정 경로 캐시: 동을 누를 때마다 427개 동을 읽고 있었다

  • 7차 DB 시간 1위가 업종별 판정 전체 조회(427개 동)였다. 지도 색칠뿐 아니라 동을 클릭할 때마다 부르는 대안 추천이 같은 조회와 동 목록 조회를 매번 했다. 판정은 하루 한 번 새벽 배치로만 바뀐다
  • 두 조회를 10분 동안 메모리에 둔다(판정을 새로 저장하면 바로 비운다). 단건 실측: 대안 추천 7.11 → 1.04ms, 지도 색칠 4.90 → 0.27ms, 응답 내용은 그대로
  • 요청당 API CPU 4.2 → 2.2ms(1,000명, −48%), 요청당 DB CPU 1.6 → 0.9ms. 2,000명에서도 수요(689 RPS)를 전부 처리하고 API CPU는 189%(4코어의 절반 이하) — 천장을 다시 재야 했다

9차 — 계단을 3,000명까지 늘려 천장 실측

  • 같은 코드로 2,500명·3,000명 계단을 더했다. 2,500명(860 RPS)은 수요를 전부 처리하고 3분 유지 동안 모든 API가 기준 안
  • 3,000명에서 923 RPS(수요 1,032)·API CPU 371%로 포화 → 천장 약 925 RPS. 일반 조회 p95가 처음 500ms를 넘은 것은 약 2,955명
  • 열린 파일 한도(1,024)는 3,000명(워커당 약 750 연결)에서도 벽이 아니었다(EMFILE 0). 다음 DB 부담 1위는 상권 매출 백분위 쿼리(평균 17.2ms)

10차 — 실제 브라우저처럼 모든 응답을 압축해 받기

  • 9차까지 k6는 지도 경계만 압축해서 받았다. k6는 기본으로 압축을 요청하지 않기 때문이다. 실제 브라우저는 1KB 넘는 응답을 모두 압축해서 받으므로, 서버 코드는 그대로 두고 k6만 모든 요청에 압축 헤더를 붙여 다시 쟀다
  • 요청당 받은 데이터 6.6KB → 4.3KB(−35%). 예를 들어 지도 색칠에 쓰는 판정 목록은 19KB → 1.4KB
  • 요청당 API CPU는 2,500명까지 2.0~3.0ms로 9차와 같았다(차이가 측정 흔들림 ±0.3ms 안). 서버가 꽉 찬 3,000명에서만 4.0 → 4.2ms로 조금 늘었다
  • 천장 약 925 → 약 900 RPS(−3%). 기준을 지킨 최대 동접은 약 2,950명, 모든 API가 3분 유지 동안 기준 안에 든 것은 2,500명으로 9차와 같다 — 압축 비용은 작다
  • 일반 조회 p95가 2,000명 이하에서도 3~6ms씩 낮게 나왔지만 원인은 확인하지 못했다. 전송량 차이는 1Gbps에서 0.2ms 미만이라 주원인으로 보기 어렵다

4. AWS(EKS)로 옮긴 뒤 — 11차 (10/13~10/14)

측정 조건 (로컬과 다른 점)

  • 운영 구성 그대로 — 사용자 → ALB(TLS) → EKS의 API 파드 → RDS(PostgreSQL). 파드는 1코어·워커 1개짜리를 HPA가 요청량에 따라 2개부터 늘린다(처음 상한 10개). 노드는 Graviton 2 vCPU, DB는 처음 2 vCPU
  • k6는 같은 리전의 별도 EC2에서 실행. 시나리오·생각 시간·합격 기준은 로컬과 같고, 10차처럼 모든 응답을 압축해 받는다
  • 가짜 LLM도 로컬과 같다(고정 지연, 요금 없음). 테스트 동안만 운영 API에 켰다가 끝나고 껐다
  • 측정 전 DB 스냅샷을 떴고, 끝난 뒤 테스트로 생긴 가짜 리포트 약 2만 4천 건을 지웠다(실제 리포트는 그대로)

11-1 — 처음 구성: 막힌 곳은 DB였다

동접 RPS p50 p95 p99 가장 느린 p95 DB CPU
1,000 344 14 42 112 프리필 126 31%
2,000 684 17 57 165 프리필 237 62%
2,500 849 29 128 317 프리필 561 82%
3,000 896(포화) 362 730 1,125 프리필 1,160 92~97%

(지연 단위 ms)

  • 모든 기준을 지킨 마지막 계단은 2,000명(약 685 RPS). 2,500명은 전체 p95가 128ms로 여유로웠지만 자금 계획 프리필 하나가 기준(500ms)을 넘었고, 3,000명에서 약 900 RPS로 막혔다
  • 같은 시각 API 파드 10개의 CPU 합은 약 5.4코어 — 파드마다 한도의 절반쯤만 썼다. 먼저 찬 곳은 DB CPU
  • DB 시간의 약 92%가 쿼리 하나였다. 자금 계획 프리필이 요청마다 서울 전체 점포당 매출 중앙값을 다시 계산한다(평균 103~159ms). 분기마다 바뀌는 값이라 캐시하면 DB 부담 대부분이 빠진다(아직 적용 안 함)

11-2 — DB 한 단계 키우기

DB를 2 → 4 vCPU로, 파드 상한을 10 → 20개로(노드 상한도 3 → 8대) 바꿨다. 프로필은 11-1과 같다.

동접 RPS p50 p95 p99 가장 느린 p95 DB CPU 파드 / 노드
1,000 344 11 35 87 프리필 74 15% 6 / 2
2,000 689 13 36 83 프리필 79 29% 11~13 / 4~5
2,500 858 14 36 85 프리필 85 36% 16 / 6
3,000 1,033 13 38 109 프리필 91 44% 19 / 7
  • 3,000명(약 1,030 RPS)까지 p95가 38ms로 평평했다. 포화하지 않았다
  • 같은 850 RPS에서 DB CPU가 82% → 36%. DB가 실제로 쓴 코어는 둘 다 약 1.7개로 같았다 — 11-1은 2코어 중 1.7코어를 써서 대기열이 생겼던 것이고, 그래서 프리필 쿼리 평균이 103ms → 15ms로 줄었다

11-3 — 1,500 RPS까지

11-2와 같은 구성에서 계단만 4,500명까지 늘렸다. DB CPU가 44%라 더 큰 DB(8 vCPU)로 올리지 않았다.

동접 RPS p50 p95 p99 가장 느린 p95 DB CPU 파드 / 노드
1,000 344 11 35 92 프리필 80 16% 7 / 3
2,000 688 12 38 97 프리필 86 30% 9~15 / 3~5
3,000 1,029 12 37 94 프리필 92 43% 15~18 / 5~6
3,500 1,201 14 39 92 프리필 100 50% 20 / 7
4,000 1,372 15 43 111 프리필 118 58% 20 / 7
4,500 1,543 17 51 135 프리필 150 67% 20(상한) / 7
  • 4,500명에서 1,543 RPS, p95 51ms. 약 189만 요청 중 에러 3건(502). k6 합격 기준을 전부 통과했다
  • 아직 포화 전이지만 끝이 보인다. 4,000명부터 지연이 조금씩 오르고(p95 43 → 51ms), 3,500명에서 파드가 상한 20개에 닿은 뒤로는 파드당 CPU가 0.34 → 0.42코어로 올라갔다
  • 다음 벽 후보는 둘이다 — DB CPU(선형 어림으로 80%가 약 1,850 RPS) 또는 파드 20개 × 1코어. DB 시간의 대부분은 여전히 프리필 중앙값 쿼리다

로컬과 비교

  • 로컬에서는 API CPU 4코어가 먼저 찼고, AWS에서는 DB CPU가 먼저 찼다. API는 파드를 늘려 옆으로 넓힐 수 있지만 DB는 한 대라서, 처음 구성에서 DB가 앞에 나왔다
  • 요청당 API CPU를 파드 CPU 합 ÷ RPS로 어림하면 약 5.5ms다. 로컬(포화 전 2~3ms)의 약 2배인데, 파드가 많아 놀고 있는 몫도 들어간 값이다
  • 운영에는 임베딩 서버가 없어 지원사업 질문 검색이 규칙 순서로만 돈다. 그만큼 로컬보다 가볍고, 대신 TLS·ALB를 한 번 더 거친다

5. 임베딩 서버의 한계 (별도 실험)

임베딩 서버(Ollama, GPU 1장)는 초당 약 43건에서 멈췄습니다.

시도 결과
병렬 처리 설정 켜기 효과 없음 — 임베딩 모델은 한 번에 1건씩 처리(n_seq_max = 1)
인스턴스 2대 효과 없음 — GPU 하나를 번갈아 씀
한 호출에 여러 문장 묶기 16문장 104건/초 · 64문장 190건/초 — 시간 대부분이 호출마다 드는 고정 처리 시간

혼합 트래픽에서는 임베딩이 전체 요청의 약 1.2%라 지금 서버로도 약 3,300 RPS까지 감당합니다(추정). 검색만 한꺼번에 몰릴 때가 한계이고, 그래도 5초가 넘으면 규칙 순서로 결과를 보여 줘 화면은 멈추지 않습니다.


6. 측정의 한계

  • 차수별 1회 실행 — 회차 간 흔들림은 1차의 같은 조건 2회 반복에서 ±10% 안이었다. 9차는 2,000명까지 8차를 재현했다(687 vs 689 RPS)
  • 가짜 LLM — 실제 Gemini의 지연·한도는 1차의 별도 측정(동시 분석 33건·분당 약 440회, §3 1차)에서만 쟀다
  • 압축 — 9차까지 k6는 지도 경계만 압축해서 받았다. 10차에 실제 브라우저처럼 모든 응답을 압축해 받게 해서 다시 쟀고, 천장 차이는 약 3%였다
  • 로컬 CPU — 1~10차의 테스트 서버는 클럭이 높은 데스크톱 CPU다. 서버를 옮긴 뒤 같은 시나리오로 다시 잰 것이 11차(§4)다
  • 11차는 단계마다 1회, 운영 서버에서 쟀다(사용자가 없는 오픈 전이라 가능했다). 11-3의 진짜 천장은 재지 않았다 — 1,500 RPS를 넘긴 뒤 멈췄다
  • 측정이 오염된 회차가 한 번 있었다 — 측정 중 같은 서버에서 다른 실험을 겹쳐 돌려, 그 회차는 버리고 다시 쟀다. 그 뒤로 측정 중에는 다른 작업을 하지 않는다

7. 다음

  • AWS에서 먼저 막히는 곳은 DB CPU — 자금 계획 프리필의 서울 점포당 매출 중앙값을 캐시하면 DB 여유가 크게 는다(적용 여부 결정 전)
  • 테스트가 끝나 DB 크기·파드 상한은 처음 구성으로 되돌렸다. 큰 트래픽이 예상되면 11-2·11-3 구성(DB 4 vCPU, 파드 20개)이 1,500 RPS를 받는다는 근거가 된다
  • POST 연결 끊김(keep-alive) 정리
  • 검색이 몰릴 때를 대비한 임베딩 묶어 보내기 — 효과를 재고 결정

팀 Beyond Facade — Metabole 창업 경고 서비스 개발 문서. 경고 없음은 성공·안전·추천을 보장하지 않습니다.

창업 경고의 근거와 구현 기록 전체 문서 검증 결과 최종 산출물