코드 저장소.

분산 서버 전환 후 부하 테스트로 병목 구간 찾기1 본문

포폴/일정관리 프로젝트

분산 서버 전환 후 부하 테스트로 병목 구간 찾기1

slown 2026. 4. 28. 23:33

목차

1. 왜 다시 부하 테스트를 진행했는가?

2.테스트 환경 및 시나리오

3.측정 과정에서의 트러블 슈팅

4.회고

 

1. 왜 다시 부하 테스트를 진행했는가

1-1. 단일 서버 테스트의 한계 및 분산 전환 배경

 

기존 단일 서버 환경에서 진행했던 부하 테스트를 통해 다음과 같은 구조적 한계점을 확인하고 분산 아키텍처로 전환을 결정했습니다.

  • 인프라 스펙의 한계: 단일 노드의 낮은 CPU/RAM 자원만으로는 실 서비스 수준의 트래픽을 처리하는 데 물리적인 제약이 있음.
  • SPOF(Single Point of Failure) 발생: 서비스 서버 장애 시 전체 시스템이 중단되는 가용성 문제가 존재함.
  • 확장성 검증 부재: 트래픽 증가에 따른 스케일 아웃(Scale-out) 시, 로드밸런싱과 분산 데이터 처리 과정에서의 성능 변화를 측정할 수 없음.

1-2. 분산 서버 부하 테스트의 목표

 

서버 2대와 로드밸런서(Nginx)로 구성된 분산 환경에서 시스템의 신뢰성을 정량적으로 검증하는 것을 목표로 합니다.

  • 분산 환경 성능 및 확장성 검증: 단일 서버 대비 서버 확장 시 처리 성능(TPS)의 선형적 증가 여부 확인.
  • 시스템 가용 임계치 파악: 현재 인프라 사양에서 안정적으로 수용 가능한 최대 동시 접속자 수(VU) 산출.
  • 분산 환경 고유 병목 구간 식별: 로드밸런싱 알고리즘의 효율성, 분산 환경에서의 DB 커넥션 경합 및 네트워크 지연 지점 탐색.
  • 내결함성 및 장애 복구 확인: 고부하 상황에서 특정 노드 장애 시 proxy_next_upstream 등 안전장치의 작동 여부와 전체 시스템 영향도 평가.

2.테스트 환경 및 시나리오

2-1.아키텍처 구성

 

2-2.인프라 구성 사양

 

병목 지점을 명확히 분리하기 위해 역할별로 인스턴스를 격리하여 구성했습니다.

구분 컴포넌트 사양 주요 역할
Service Application Server (x2) AWS EC2 (2GB RAM) Spring Boot 애플리케이션, Redis(인증 캐싱)
Infra Message Broker & LB AWS EC2 (4GB RAM) Kafka (KRaft), Nginx (Load Balancer)
Monitor Observability Stack AWS EC2 (2GB RAM) Prometheus, Grafana, Loki

 

2-3.테스트 시나리오

 

1) 테스트 목표

  • 부하 타겟: 50VU에서 시작하여 최종 100 VU까지 단계적 확장 시도.
  • 분산 검증: Nginx를 통한 각 서비스 노드별 트래픽 분산의 균등성 확인.
  • 안정성 확인: 고부하 상황에서 시스템 리소스 및 메시징 파이프라인의 정상 작동 여부 검증.

2) 주요 모니터링 지표 (Observability)

 

테스트 중 발생하는 병목 현상을 입체적으로 분석하기 위해 다음 지표를 중점적으로 관찰합니다.

  • Application: JVM Heap Memory 사용량 및 GC 발생 빈도 확인.
  • Database: HikariCP Active Connections 및 Wait/Usage Time 분석을 통한 커넥션 고갈 여부 파악.
  • Messaging: Kafka Consumer Lag 및 Outbox Publish Latency를 통한 이벤트 파이프라인 정체 구간 식별.
  • Network: HTTP Status Code(2xx, 4xx, 5xx) 비율 및 API 응답 시간(p95, p99 Latency) 측정.

3.측정 과정에서의 트러블 슈팅

3-1. [1~2차] 50VU 테스트 실패  

 

[현상] 테스트 시작 직후 에러율 99.89% 기록. 평균 응답 시간(Latency)은 24ms로 비정상적으로 짧게 측정됨.

 

Grafana 지표 분석

  • JVM: 힙 메모리 100~110MB 사이에서 안정적인 톱니 패턴 유지. OOM 이슈 없음 확인.
  • HikariCP: 특정 노드(130 서버)의 Active Connection이 즉시 Max(50)에 도달 후 포화 상태 유지.
  • Kafka: DB 커넥션 마름 현상으로 인해 Outbox 테이블 Insert 속도가 저하되었고, 연쇄적으로 Outbox Publish Latency 및 Consumer Lag 정체 발생.

원인 분석

  1. 커넥션 풀의 역설: 개별 서버의 풀을 50으로 설정했으나, 서버가 2대이므로 DB(MySQL) 입장에서는 동시에 100개의 커넥션을 감당해야 함. DB 임계치 초과로 인한 Connection Timeout 발생.
  2. Nginx 로드밸런싱 불균형: keepalive 32 및 http_version 1.1 설정으로 인해 Nginx가 기존에 맺어진 130 서버와의 연결만 재사용하여 한쪽 서버에만 부하가 집중됨.
  3. 리소스 불일치: Tomcat 스레드(200) 대비 DB 커넥션(50)이 턱없이 부족하여 요청들이 커넥션 풀 대기열에 갇히며 502 에러로 이어짐.
  4. 장애 전이: proxy_next_upstream 설정으로 인해 130 서버 에러 시 145 서버로 부하가 즉시 전이되며 연쇄 장애 발생.

실패 후 조치

  • 커넥션 풀 하향 조정: maximum-pool-size: 20으로 수정. 적은 풀을 빠르게 회전시켜 Context Switching 오버헤드 감소 유도.
  • 어플리케이션 설정: connection-timeout: 3000(3초) 명시로 무한 대기 방지.
  • Nginx 최적화: least_conn 알고리즘 도입 및 테스트 중 keepalive 제거로 요청 분산 강제.
  •  

3-2.2차 진행

 

[현상] 1차 조치 후 재테스트 결과, 에러율 및 응답 속도 면에서 개선이 미미함.

 

지표 분석 

  • HikariCP: 130 서버와 145 서버 모두 설정치인 20개까지 커넥션이 균등하게 상승함. Nginx 로드밸런싱(least_conn) 조치는 정상 작동 확인.
  • 용량 산정 실패: 현재 서버 사양(EC2 t계열 등)과 DB 환경에서 서버 2대의 합산 커넥션(40개)만으로는 50VU가 쏟아내는 요청량을 처리하기에 역부족임이 판명됨.
  • 병목 구간: 커넥션 풀이 꽉 찬 상태에서 대기하던 요청들이 설정된 3초가 지나면 결국 타임아웃으로 실패 처리됨.

3-3.[3차] 30VU 테스트: 인증 필터의 DB 커넥션 점유 병목 발견

 

[현상] VU를 30으로 하향했음에도 에러율 84.78% 기록. 평균 응답 시간은 219ms이나 최대 응답 시간이 129s까지 튀는 현상 발생.

 

지표 분석

  • HikariCP: 설정값인 20개에서 'Saturation(포화)' 현상 발생. 대기열 정체로 인한 타임아웃 지속.
  • HTTP Status: 500(ConnectionTimeout)과 409(Idempotency Conflict)가 동시에 발생.
  • Consumer Lag: DB 처리 속도 저하로 인해 notification-events 토픽의 렉이 80까지 상승.

원인 분석

2026-04-27 22:05:29 [http-nio-8082-exec-43] ERROR o.a.c.c.C.[.[.[.[dispatcherServlet] - Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [eventId=, requestId=]
org.springframework.dao.DataAccessResourceFailureException: Unable to acquire JDBC Connection [HikariPool-1 - Connection is not available, request timed out after 3010ms.] [n/a]
        at org.springframework.orm.jpa.vendor.HibernateJpaDialect.convertHibernateAccessException(HibernateJpaDialect.java:274)
        at org.springframework.orm.jpa.vendor.HibernateJpaDialect.translateExceptionIfPossible(HibernateJpaDialect.java:241)
        at org.springframework.orm.jpa.AbstractEntityManagerFactoryBean.translateExceptionIfPossible(AbstractEntityManagerFactoryBean.java:550)
        at org.springframework.dao.support.ChainedPersistenceExceptionTranslator.translateExceptionIfPossible(ChainedPersistenceExceptionTranslator.java:61)
        at org.springframework.dao.support.DataAccessUtils.translateIfNecessary(DataAccessUtils.java:335)
        at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:152)
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
        at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:135)
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
        at org.springframework.aop.interceptor.ExposeInvocationInterceptor.invoke(ExposeInvocationInterceptor.java:97)
        at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:184)
        at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:220)
        at jdk.proxy2/jdk.proxy2.$Proxy188.findByUserId(Unknown Source)
        at com.example.outbound.auth.AuthOutConnector.loadUserByUsername(AuthOutConnector.java:23)
        at com.example.service.auth.jwt.JwtTokenProvider.getAuthentication(JwtTokenProvider.java:94)
        at com.example.service.auth.jwt.JwtAuthenticationFilter.doFilter(JwtAuthenticationFilter.java:41)
  • 서비스 로그 확인 결과, 비즈니스 로직 수행 전 JwtAuthenticationFilter 단계에서 DataAccessResourceFailureException 발생 확인.
  • 병목 기전: 모든 요청마다 인증을 위해 findByUserId 쿼리를 실행하는데, 부하가 쌓이며 쿼리 속도가 저하됨. 결과적으로 비즈니스 로직이 시작되기도 전에 필터 단계에서 커넥션 풀 20개를 모두 점유해버려 실제 서비스 로직은 번호표만 뽑다 타임아웃되는 구조적 문제 파악.
  • 해결 조치
    • 유저 정보 캐싱: Redis를 도입하여 인증 시 매번 발생하는 DB 조회를 제거. DB 커넥션 점유 시간을 순수 비즈니스 로직에만 집중하도록 개선.

3-4. [4차] 30VU 테스트: 인프라 계층(Nginx/OS)의 수용 한계

 

[현상] 어플리케이션 지표는 정상이지만 클라이언트(JMeter) 에러율은 94.56%로 급증.

 

원인 분석

  • 지표 괴리: Grafana(백엔드)는 200 성공을 찍고 있으나 JMeter는 502(Bad Gateway)를 수신함.
  • Nginx/OS 병목: 요청이 백엔드 서버에 도달하기 전 Nginx의 worker_connections(기본 1024) 한계 또는 OS 레벨의 파일 디스크립터(File Descriptor) 제한으로 인해 요청이 커트됨.

해결 조치 (Nginx 튜닝)

 

연결 수 확장: worker_connections를 4096으로 상향, multi_accept on 및 epoll 적용.

events {
    worker_connections 4096; # 기본 1024에서 4배로 확장
    multi_accept on;       # 한 번에 여러 연결을 수락
    use epoll;             # 리눅스 환경에서 최적화된 이벤트 모델 사용
}

 

Keep-Alive 최적화: keepalive_requests를 10,000으로 상향하여 잦은 핸드쉐이크 방지.

http {
    include       mime.types;
    default_type  application/octet-stream;
    
    # 추가: 파일 전송 최적화
    sendfile        on;
    tcp_nopush      on;
    tcp_nodelay     on;

    keepalive_timeout 65;
    keepalive_requests 10000; # 30VU면 요청이 쏟아지니까 1000에서 10000으로 상향

    upstream backend-cluster {
        # round-robin (기본값) 사용
        server 10.0.1.130:8082 max_fails=3 fail_timeout=10s;
        server 10.0.1.145:8082 max_fails=3 fail_timeout=10s;
        keepalive 100; # 커넥션 풀을 32에서 100으로 상향
    }
    # ... 이하 생략

 

타임아웃 조정: 부하 상황 대응을 위해 proxy_connect_timeout을 3초로 상향. 

 
        location /api/ {
            proxy_pass http://backend-cluster/api/;
            proxy_http_version 1.1;
            proxy_set_header Connection "";
            
            # 타임아웃 살짝 상향 (너무 짧으면 502 유발)
            proxy_connect_timeout 3s; 
            proxy_read_timeout 10s;
            proxy_send_timeout 10s;

            # ... 나머지 헤더 설정들
        }

 

3-5. [5차] 30VU 테스트: 로드밸런싱 불균형 재발 및 자원 고갈

 

[현상] 4차 대비 에러율은 39.38%로 감소했으나 여전히 비정상적인 실패율 기록.

 

 

지표 분석 및 원인

  • 커넥션 불균형: 특정 서버(145 서버)의 HikariCP가 20개(Max)에서 일직선을 그리며 포화 상태 유지.
  • Latency 편차: 130 서버와 145 서버 간 응답 속도 차이가 크게 발생. 특정 서버의 DB 커넥션 점유 시간이 길어지면서 로드밸런싱 균형이 무너짐.
  • 연쇄 정체: API 서버에서 DB 커넥션을 잡지 못해 Outbox 테이블 인서트가 안 되니, 카프카 대시보드에 데이터가 찍히지 않는 파이프라인 정체 현상 재발.

3-6. [6차] 30VU 성공

 

[성과] 에러율 39.38% → 0.18%로 급감. 사실상 모든 요청에 대해 정상 응답을 처리하며 가용성 확보.

 

최종 지표 검증 (Grafana)

  • HikariCP: 한쪽 서버로 몰리던 현상이 사라지고, 요청 스파이크 발생 시 잠시 상승했다가 즉시 유휴 상태로 복귀하는 안정적인 흐름을 보임.
  • Kafka & Consumer: Consumer Lag이 순간적으로 60~80까지 튀지만, 시스템의 자가 회복력(Self-healing)을 통해 즉시 0으로 수렴함. 이벤트 파이프라인의 정체 해소 확인.
  • Load Balancing: 130 서버와 145 서버의 응답 곡선이 거의 일치함. Nginx가 least_conn 알고리즘과 튜닝된 설정값을 바탕으로 부하를 공평하게 분산하고 있음을 증명

3-7. [7차] 50vu 1차 실패

 

[현상] 에러율이 81.4%이고 latency가 최대14초까지 걸린 부분이 있고 처리량은 124.8로 과부하가 걸린 상황

 

 

지표 분석 및 원인

  • HikariCP:  연결 개수가 최대치(25개 부근)에 도달해서 유지되고 있고, 요청은 계속 들어오는데 DB 연결을 못 잡아서 줄줄이 대기하다가 타임아웃이 난 상황.
  • Consumer Lag & Outbox Publish Latency : 요청이 몰릴 때 Lag이 10 이상 튀었고 프로듀서가 던지는 속도를 컨슈머가 못 따라가고 있는 상황. 아웃박스의 경우에는 특정 시점에 0.075ms 이상으로 튀었고 이는 DB 부하가 Outbox 테이블 기록에도 영향을 줌.
  • Latancy : 02:42분쯤에 Latency가 요동치면서 모든 지표가 나빠지기 시작했고 401(인증) 및 500으로 서버 내부 로직이 뻗었다는 증거야. DB 커넥션 부족이나 타임아웃일 확률이 매우 높음.

원인 해결

 

커넥션풀이 부족해서 디비 콘솔에서 명령어로 현재 가용이 되는 커넥션풀을 확인을 해봤습니다. 아래의 사진과 같이 디비에서 사용되고 있는 커넥션풀의 수치입니다. 

 

현재 커넥션풀의 수치가 61이라는 것은 향후 목표로 하는 수치에서 테스트를 하는데 있어서 문제가 되므로 AWS RDS콘솔에 들어가서 커넥션 풀의 가용량을 변경을 하기로 했습니다.

 

 

위의 그림에서 보았듯이 RDS 파라미터 그룹에 들어가서 커넥션풀의 수치를 200으로 변경을 했습니다.  그리고 어플리케이션의 설정에서 커넥션풀의 수치를 60으로 변경을 하고 minimum-idle을 20으로 변경을 했습니다. 

 

3-8. [8차] 50vu 성공

 

[성과] 에러율은 0.35%로 안정적으로 잡혔고 평균 latency는 1613ms이고 처리량은 30으로 분산서버가 정상 작동.

 

 

최종 지표 검증 (Grafana)

  • HikariCP: 풀이 25까지 갔고 누수없이 테스트가 끝나면서 0으로 떨어진것을 볼 수 있습니다.
  • HTTP Status : HttpLatency가 안정적으로 유지가 되고 있습니다.
  • Consumer Lag: 테스트 중간에 살짝 튀었지만(6 정도), 이내 바로 0으로 수렴했어. 네가 설정한 Kafka 컨슈머가 프로듀서의 속도를 충분히 감당하고 있다는 뜻이야.
  • Outbox Publish Latency: 0.04ms~0.06ms 수준으로 매우 안정적이야. DB에 이벤트를 쓰고 Kafka로 넘기는 과정이 병목 없이 아주 매끄러워.
  • DLQ Retry Count (0): 이게 제일 핵심이야. 5,000건 넘는 요청 중에 처리 못 해서 재처리 큐로 넘어간 게 단 하나도 없다는 거지. 데이터 정합성 설계가 아주 잘 됐어.

4.회고

이번 부하 테스트와 트러블슈팅 과정을 통해 "인프라와 어플리케이션은 유기적으로 연결되어 있다"는 것을 느꼈습니다. 단순히 코드만 잘 짠다고 해결되는 게 아니라, 요청이 들어오는 입구(Nginx)부터 끝단(DB/Kafka)까지 전체 파이프라인을 볼 줄 알아야 한다는 걸 배웠습니다.

  1. 커넥션 풀의 전략적 설정: 무조건 풀을 크게 잡는 게 능사가 아니라는 점. 오히려 적절한 풀 사이즈를 설정하고 빠르게 회전시키는 것이 Context Switching 오버헤드를 줄이고 전체 성능을 높이는 데 기여했습니다.
  2. 공통 필터의 무서움: 모든 API의 관문인 JwtAuthenticationFilter에서의 DB 조회가 부하 상황에서 얼마나 치명적인 병목이 되는지 확인했어. Redis 캐싱 하나가 전체 시스템의 생사(Liveness)를 결정할 수 있다는 점이 가장 큰 수확이었습니다.
  3. 관측성(Observability)의 가치: 로그만 봤다면 Nginx의 설정 문제나 로드밸런싱 불균형을 잡아내기 훨씬 힘들었겠지만. Grafana의 시각화된 지표 덕분에 가설을 세우고 검증하는 과정이 논리적으로 진행될 수 있었습니다.
  4. 인프라 튜닝의 필요성: 백엔드 로직이 완벽해도 Nginx나 OS 레벨의 설정(Worker Connections, FD 등)이 받쳐주지 않으면 무용지물이라는 점을 깨달았습니다.

다음 테스트 방향

이전 개선으로 50VU 구간까지의 안정성을 확보할 수 있었지만, 현재 구조가 실제로 어느 수준까지 확장 가능한지는 아직 확인되지 않았습니다. 다음 단계에서는 다음 항목을 중심으로 100VU 구간에서 추가 검증을 진행할 예정입니다.

  • 서버 간 부하 분산 유지 여부
  • 평균 응답 시간 변화
  • DB 커넥션 포화 시점
  • Kafka Consumer Lag 증가 여부
  • 장애 상황에서의 복구 동작

이를 통해 현재 구조의 안정 구간과 추가적인 병목 지점을 보다 명확히 확인할 계획입니다.