Spring Boot 모바일 백엔드(mo-back) Pod 하나에 부하를 올렸더니 초당 826건에서 더 올라가지 않았다. 오류율 0%, p95 64ms. 응답은 멀쩡한데 처리량만 벽에 붙어 있었다.

하나의 서비스를 여러 고객사가 각자 쓰는 멀티 테넌트 SaaS로 옮기는 프로젝트였고, 운영에 올리기 전에 “이 인프라는 초당 몇 건까지 버티는가”를 숫자로 답해야 했다. 같은 클러스터에서 정적 페이지만 주는 nginx는 초당 5,000건 가까이 처리하고 있었다. JVM 앱이 빈 컨테이너보다 느린 건 당연해 보였고, 826을 앱의 한계로 적고 넘어가기 쉬웠다.

느린 원인이 앱에 있는지 앞단에 있는지는 앱에 걸린 설정을 빈 컨테이너에 그대로 걸어 보면 알 수 있다. 그렇게 해서 찾은 원인은 Istio DestinationRule의 maxRequestsPerConnection: 1 한 줄이었다. 이 글은 그 값이 왜 처리량을 묶었는지, 고치고 나서 어떤 진짜 병목이 드러났는지까지 다룬다.

빈 컨테이너와 실제 앱을 따로 잰 이유

부하 대상은 처음부터 둘로 나눠 두었다.

대상무엇왜
빈 컨테이너정적 페이지를 주는 nginx앱 영향을 빼고 NLB, Istio Gateway, 사이드카의 한도만 보려고
실제 앱Spring Boot 모바일 백엔드(mo-back)실제 서비스의 한도를 보려고

트래픽은 이 길로 지나간다. 앱 Pod마다 앱 컨테이너 옆에 istio-proxy 사이드카가 붙어 있다.

flowchart LR
    L["부하 발생기"] -->|"HTTPS"| NLB["NLB<br/>(L4)"]
    NLB --> GW["Istio Gateway Pod<br/>(HPA 2~10)"]
    GW --> SVC["Service"]
    SVC --> P1["앱 Pod 1<br/>앱 + istio-proxy"]
    SVC --> P2["앱 Pod 2<br/>앱 + istio-proxy"]
    SVC --> P3["앱 Pod 3<br/>앱 + istio-proxy"]

앱만 재면 느린 원인이 앱인지 인프라인지 가를 수 없다. 이 구분이 826을 만났을 때 바로 쓸모가 있었다.

nginx의 5,000이라는 숫자도 나중에 보니 부하를 한 곳에서만 준 탓에 생긴 상한이었다. 그 과정은 부하 서버를 한 대 늘렸더니 처리량이 65% 올랐다에 따로 적었다. 다만 측정 환경은 같았으니, 같은 조건에서 nginx는 5,000 근처, mo-back은 826이었다.

같은 DestinationRule을 nginx에 걸었다

mo-back과 nginx는 앱만 다른 게 아니었다. 앱들은 공통 템플릿으로 DestinationRule(DR)을 받고 있었고, 부하테스트용 nginx에는 DR이 없었다. 부하테스트 직전까지 템플릿에 있던 값은 이랬다.

trafficPolicy:
  connectionPool:
    http:
      http1MaxPendingRequests: 100
      maxRequestsPerConnection: 1   # 요청 하나마다 연결을 닫는다
    tcp:
      maxConnections: 100
  outlierDetection:
    consecutive5xxErrors: 5
    interval: 10s
    baseEjectionTime: 30s
    maxEjectionPercent: 50

nginx에 이 DR을 그대로 걸었다가 다시 풀어 보면서, 같은 조건으로 세 단계를 쟀다.

단계DestinationRule 설정nginx 처리량오류율
A없음4,786 RPS0.02%
BmaxRequestsPerConnection: 1, 연결 풀 100 (앱과 동일)850 RPS84%
CmaxRequestsPerConnection: 0, 연결 풀 10004,559 RPS1.33%

B에서 nginx도 850에서 막혔다. mo-back의 826과 거의 같은 숫자다. 정적 페이지만 주는 nginx가 Spring Boot 앱과 같은 천장에 닿았다면, 그 천장을 만든 건 앱이 아니다. C에서 값을 풀자 4,559로 돌아왔다. 걸면 떨어지고 풀면 돌아오니 원인이 양쪽에서 확인됐다.

연결을 한 번 쓰고 버리면 사이드카가 지친다

maxRequestsPerConnection은 백엔드로 가는 연결 하나에 보낼 수 있는 최대 요청 수다. Istio 문서에 따르면 1로 두면 keepalive가 꺼지고, 기본값 0은 제한 없음이다.

1이면 요청 하나를 보내고 연결을 닫으니, 다음 요청은 TCP 연결부터 다시 맺어야 한다. 메시 안에서 사이드카끼리는 mTLS로 통신하니 TLS handshake도 매번 새로 한다.

flowchart TD
    A["maxRequestsPerConnection: 1"] --> B["요청 하나마다 연결을 끊음<br/>(HTTP keepalive 차단)"]
    B --> C["요청마다 TCP/TLS 연결을 새로 맺음"]
    C --> D["사이드카가 연결 맺는 데 CPU를 다 씀<br/>(사이드카 953m, 앱은 78m로 거의 놀고 있음)"]
    C --> E["연결 풀 100 초과분은 503으로 거절"]
    D --> F["처리량 약 850에서 정체"]
    E --> F

B 단계에서 사이드카 CPU는 최대 953m까지 올라갔고 nginx 컨테이너는 78m였다. 앱은 거의 놀고, 사이드카는 요청을 처리하는 게 아니라 연결을 맺고 끊는 데 CPU를 쓰고 있었다. 연결 풀 상한 100을 넘친 요청은 Envoy가 503(UO, upstream overflow)으로 돌려보냈다. 오류율 84%가 여기서 나왔다.

사이드카 CPU limit이나 concurrency를 올리는 쪽은 처음부터 답에서 뺐다. 바쁜 이유가 연결을 매번 새로 맺는 데 있었기 때문이다. 그 손잡이는 같은 측정 기간에 nginx로 이미 돌려 본 적도 있다. concurrency를 2에서 4로, CPU limit 4와 concurrency 8로 올렸을 때 처리량은 움직이지 않았다.

이 값은 내가 남겨 둔 것이었다

부하테스트 직전에 DR 기본값을 다듬으면서, 나는 원래 템플릿에 있던 이 1을 일부러 지우지 않았다. 당시 판단은 이랬다.

  • HTTP/1.1 keepalive를 쓰면 연결이 몇 개로 고정되고, 그 연결이 붙은 Pod에만 요청이 간다.

  • 그러면 HPA가 새로 띄운 Pod에 트래픽이 늦게 들어간다.

  • 1이면 매 요청이 새 연결이니 LEAST_REQUEST가 매번 가장 한가한 Pod를 고른다. 둘은 시너지다.

비용은 “매 요청 TCP 연결과 mTLS handshake로 50~100ms 지연”이라고 적고, dev에서는 감수할 만하다고 넘겼다. 나는 이 설정의 비용을 지연 시간으로 셈했는데, 실제 청구서는 처리량으로 왔다. 처리량이 6분의 1로 묶여 있었다.

LEAST_REQUEST와 연결 재사용은 다른 시점에 작동한다

틀린 건 “연결을 재사용하면 분산이 안 된다”는 전제였다. Envoy의 HTTP 라우터는 요청이 들어올 때마다 로드밸런서에 보낼 Pod를 묻고, 그 Pod 몫의 연결 풀에서 연결을 꺼낸다. 연결 풀은 Pod마다 따로 있다. LEAST_REQUEST는 요청을 어느 Pod로 보낼지 고르는 시점에 작동하고, maxRequestsPerConnection은 고른 Pod까지 간 연결을 닫을지 정하는 시점에 작동한다. 연결을 계속 써도 다음 요청의 목적지는 LEAST_REQUEST가 다시 고른다. 1은 LEAST_REQUEST에 기회를 더 주지 않고, 연결 비용만 요청마다 새로 물리고 있었다.

새로 뜬 Pod를 보호하는 일도 다른 손잡이가 맡는다. LEAST_REQUEST는 처리 중인 요청이 0인 새 Pod에 트래픽을 몰아주는 경향이 있어서, warmupDurationSecs: 60s로 새 Pod의 가중치를 60초에 걸쳐 올린다. 한가한 Pod를 고르는 건 LEAST_REQUEST고, 새 Pod를 천천히 데우는 건 warmup이다. 연결 비용은 maxRequestsPerConnection: 0이 줄인다.

부하테스트 중에 Pod 사이 쏠림이 실제로 있긴 했지만 원인은 이 설정이 아니었다. 쏠림을 찾아간 과정은 앞에 링크한 부하 서버 글에 있다.

공통 템플릿 한 곳을 고쳤다

DR은 앱마다 따로 쓰지 않는다. 공통 템플릿(istio-circuit-breaker.yaml) 하나를 테넌트·앱 이름으로 치환해 모든 앱에 붙인다. 고칠 곳도 그 한 곳이었다.

# ❌ 부하테스트 직전까지의 연결 설정
connectionPool:
  http:
    http1MaxPendingRequests: 100
    maxRequestsPerConnection: 1         # 요청마다 연결을 닫는다 (keepalive 차단)
  tcp:
    maxConnections: 100
# ✓ 측정 결과로 모든 앱 템플릿에 반영한 설정
trafficPolicy:
  loadBalancer:
    simple: LEAST_REQUEST                 # 요청이 적은 Pod 우선
    warmupDurationSecs: 60s               # 새 Pod는 60초에 걸쳐 서서히 트래픽 증가
    localityLbSetting: { enabled: false } # 가용 영역 쏠림 방지
  connectionPool:
    http:
      http1MaxPendingRequests: 1000       # 100 → 1000
      maxRequestsPerConnection: 0         # 1 → 0 (연결 재사용)
    tcp: { maxConnections: 1000 }         # 100 → 1000
  outlierDetection:                       # 오류를 자주 내는 Pod 일시 제외
    consecutiveGatewayErrors: 50
    baseEjectionTime: 10s

연결 풀을 100에서 1000으로 같이 올린 건, 연결을 재사용하면 동시에 열려 있는 연결 수가 늘어 100이 금방 차기 때문이다. 대기 요청 100도 순간 급증 때 대기열 자체에서 거절이 날 수 있는 값이었다. 롤백은 이 파일 하나를 되돌리면 끝난다.

넣지 않은 대안도 두 개 있다. maxRequestsPerConnection을 100~1000 정도로 두고 연결을 가끔 갈아 끼우는 안은 handshake 비용이 일부 남고, 측정도 하지 않아서 뺐다. HTTP/2 업그레이드(h2UpgradePolicy: UPGRADE)는 nginx 측정에서 Gateway CPU가 74% 줄 만큼 효율이 좋았지만, WebSocket·SSE·multipart 업로드를 쓰는 모듈과의 호환성을 검증하지 못해 보류했다.

826은 5,532가 됐고, 그다음 숫자는 25였다

설정을 반영한 뒤 mo-back을 다시 쟀다. 이번에는 헬스체크만이 아니라 실제 화면 초기화 API도 같이 쟀다.

측정Pod 수처리량평균 응답오류율
설정 변경 전, 헬스체크1826 RPS-0%
설정 변경 후, 헬스체크 5분1→175,532 RPS172ms0%
실제 화면 초기화 API, 동시 1008→1924 RPS7.9초0.01%
실제 화면 초기화 API, 동시 2008→1625 RPS15초0.06%

헬스체크는 826에서 5,532가 됐다. 나누면 6.7배지만, 같은 조건의 비교는 아니다. 826은 Pod 1개로 잰 값이고, 5,532는 5분 동안 HPA가 Pod를 17개까지 늘린 측정이다. 설정 한 줄의 효과를 깨끗하게 보여 주는 건 앞의 nginx 850 → 4,559다. 5,532가 보여 주는 건 다른 사실이다. 앞단을 막고 있던 설정을 걷어내자 mo-back 헬스체크가 인프라 상한 근처까지 따라왔다. 이 측정에서 HPA가 사이드카까지 합친 CPU로 사용률을 계산하던 문제도 같이 손봤다.

nginx (정적 페이지):          약 8,800 RPS   ← 인프라의 상한
앱 헬스체크:                  약 5,500 RPS   ← 앱 자체는 인프라 근처까지 처리
앱 실제 API (DB·외부 연동):       약 25 RPS   ← 여기가 진짜 병목

헬스체크로 잰 숫자는 운영 한도가 아니다. 실제 화면 초기화 API는 25 RPS였고, 헬스체크와 약 220배 차이가 났다. 동시 사용자를 100에서 200으로 두 배 늘려도 처리량은 4.6% 느는 데 그쳤고, 평균 응답은 7.9초에서 15초로 두 배가 됐다. 이미 포화 상태다. 이 차이는 앱 서버나 Istio가 아니라 DB 조회와 외부 연동의 응답 시간에서 온다. 이 API의 처리량을 올리려면 Pod를 더 띄울 게 아니라 쿼리를 최적화하고 캐싱을 넣어야 한다.

측정을 마치고 운영 기준선은 이렇게 정했다. 권장값은 한계의 절반 정도다.

엔드포인트 종류권장 운영(50% 여유)한계
정적 응답 / Gateway4,000 RPS8,800 RPS
앱 단순 API2,500 RPS5,500 RPS
앱 실제 API (DB·외부 연동)12 RPS25 RPS

아직 남은 문제

  • 모든 값은 dev 환경에서 쟀다. 운영 환경의 DB와 노드 사양에서는 다시 재야 한다. 특히 실제 API의 25는 DB 쪽 사정에 따라 크게 달라질 수 있는 숫자다.

  • 실제 API의 25 RPS는 인프라 설정으로 풀 수 있는 영역이 아니다. 쿼리 최적화와 캐싱은 앱 쪽 과제로 넘겼다.

  • 지금 설정은 원래 의도를 다른 설정이 대신하고 있다는 전제 위에 서 있다. 새 Pod로 트래픽이 늦게 가는 문제는 이제 LEAST_REQUEST와 warmup이 맡는다. 연결을 오래 쥐고 있는 탓에 새 Pod 합류가 늦어지는 일이 운영에서 보이는지는 지켜봐야 한다.