| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
- 알고리즘
- CoffiesVol.02
- Kafka
- Java
- AWS
- docker
- 일정관리프로젝트
- SQL
- CI/CD
- 디자인 패턴
- Redis
- LV02
- nginx
- LV03
- spring boot
- 데이터 베이스
- LV0
- 포트폴리오
- LV01
- 프로그래머스
- JPA
- 연습문제
- JMeter
- LV.02
- 일정관리 프로젝트
- mysql
- Join
- Lv.0
- 이것이 자바다
- 코테
- Today
- Total
코드 저장소.
분산 서버 전환 후 부하 테스트로 병목 구간 찾기3 본문
목차
1.이전글
2.트러블 슈팅과정
3.소감
1.이전글
지난 글에서는 분산 서버 환경을 구축하고, 60VU(Virtual Users) 조건에서 시스템이 중단 없이 안정적으로 로드밸런싱되며 트래픽을 소화하는 것까지 확인했습니다. 60VU 수준에서는 서버 두 대가 요청을 안정적으로 나누어 가졌고, 모니터링 지표상으로도 큰 무리가 없었습니다.
하지만 실제 운영 환경에서는 언제든 예측 범위를 벗어난 대규모 트래픽이 몰릴 수 있습니다. 제 프로젝트의 궁극적인 목표는 단순히 '돌아가는 분산 서버'를 넘어, 더 높은 고부하 상황에서도 데이터 정합성을 유지하며 버텨내는 '고가용성 인프라'를 검증하는 것이었습니다.
따라서 이번에는 한계를 더 밀어붙여, 성능 측정의 최종 목표치인 100VU 환경을 타깃으로 잡고 부하 테스트를 진행했습니다. (실제 테스트는 점진적 검증을 위해 90VU 설정으로 먼저 포문을 열었습니다.)
2.트러블 슈팅과정
2-1. 90vu 1차 실패




1.JMeter 지표 분석: 서비스 불능 수준의 응답 지연
- 극단적인 응답 시간 (Max Latency): 평균 응답 시간은 5.7초였으나, 최대 응답 시간(Max)이 194.2초(약 3분)에 달하는 요청이 발생했습니다.
- 에러 발생: 약 3.24%의 Error가 발생하며 안정성이 무너졌습니다.
- 부하 정체 현상: Graph Results를 보면 처리량(Throughput)은 정체되는데 편차(Deviation)와 평균 지연 시간이 지속적으로 우상향합니다. 이는 서버가 요청을 소화하지 못해 내부 큐에 요청이 쌓이며 '지연의 악순환'이 시작되었음을 의미합니다.
2. 그라파나 대시보드 분석: 병목의 원인 파악
성능 저하의 결정적인 원인은 모니터링 대시보드에서 명확히 드러났습니다.
- HTTP Status 409 Conflict 대량 발생: HTTP Traffic Analysis를 보면 정상 응답(200 OK)보다 409 Conflict 에러가 압도적으로 높게 치솟았습니다. 이는 동일한 자원에 대해 여러 쓰레드가 동시에 수정을 시도하면서 발생한 데이터 경합(Lock Contention)이 이번 실패의 핵심 원인임을 보여줍니다.
- DB 커넥션 병목: 409 에러와 함께 트랜잭션 처리가 지연되자, HikariCP Active Connections가 순식간에 20개까지 급증했습니다. 데이터 경합이 해소되지 않아 쓰레드들이 커넥션을 점유한 채 대기하는 상황이 벌어진 것입니다.
- 불안정한 메모리 상태: JVM Heap Usage가 톱니바퀴 모양으로 심하게 요동치며 빈번한 GC(Garbage Collection)가 발생하고 있었습니다.
3.대안
90VU의 실패는 단순한 성능 부족이 아니라, '자원 경합으로 인한 병목'이었음을 확인했습니다. 이를 해결하기 위해 다음 세 가지 조치를 단행했습니다.
데이터 격리: JMeter CSV Data Set 활용
- 원인: Math.random() 기반의 데이터 생성은 분산 서버 환경에서 확률적인 데이터 충돌(409 Conflict)을 유발했습니다. 이는 DB 단의 락 경합으로 이어져 전체 응답 시간을 늘리는 주범이 되었습니다.
- 적용: 테스트 유저마다 고유한 ID와 시간대를 할당한 CSV 데이터셋을 구축했습니다. 데이터 충돌이라는 '변수'를 제거함으로써, 시스템 아키텍처가 순수하게 받아낼 수 있는 '최대 처리량'을 측정할 수 있는 환경을 만들었습니다.
스레드 풀(Thread Pool) 조절
- 원인: WAS의 스레드 수가 너무 많으면 메모리 점유율이 높아지고, 컨텍스트 스위칭(Context Switching) 비용이 발생하여 오히려 처리 속도가 떨어집니다. 특히 2GB라는 한정된 메모리에서 과도한 스레드는 GC 부하를 가중시켰습니다.
- 적용: 서버당 톰캣 스레드를 60~80개 수준으로 제한했습니다. 무조건 많이 받는 것이 아니라, CPU와 메모리가 감당할 수 있는 최적의 스레드 수를 설정하여 '안정적인 처리'에 집중했습니다.
커넥션 풀(HikariCP) 최적화
- 원인: 락 경합으로 트랜잭션이 길어지자 모든 커넥션이 점유되어 새로운 요청이 고사하는 현상이 발생했습니다.
- 적용: 분산 서버 환경(Scale-Out)임을 고려하여, 각 WAS가 DB에 맺는 커넥션의 총합이 DB의 max_connections를 초과하지 않도록 조율했습니다. 또한, 스레드 풀 숫자와 커넥션 풀 숫자의 비율을 맞추어 커넥션 획득을 위한 대기 시간(Connection Wait Time)을 최소화했습니다.
2-2. 2차 시도





1차 실패 분석을 바탕으로 데이터 충돌을 완전히 격리한 고유 유저 30,000명의 CSV 데이터셋을 매핑하고, Recycle on EOF = False, Stop thread on EOF = True 설정을 적용해 2차 테스트를 가동해봤습니다.
시작 후 약 1분간은 에러 없이 깔끔하게 초록색 200 OK 지표를 유지하며 전진했습니다. 하지만 정확히 1분이 지나는 시점부터 갑자기 성공률이 곤두박질치며 스프링 부트 서버 콘솔에 수많은 예외 로그가 폭발하기 시작했습니다.

서버 단의 JSON 파싱 예외로 시작 후 약 1분간은 에러 없이 깔끔하게 초록색 200 OK 지표를 유지하며 전진했습니다. 하지만 정확히 1분이 지나는 시점부터 갑자기 성공률이 곤두박질치며 스프링 부트 서버 콘솔에 수많은 예외 로그가 폭발하기 시작했습니다.
원인 분석
처음에는 데이터 포맷 자체의 오류를 의심했으나, 1분 전까지 완벽하게 처리되던 데이터가 특정 시점에 일제히 깨질 리가 없다는 팩트에 집중했습니다. 추적 결과, 범인은 JMeter의 작동 메커니즘과 멀티스레드 간의 레이스 컨디션이었습니다. 90VU의 고하중 트래픽이 몰아치며 약 1분 만에 준비된 3만 건의 CSV 데이터를 전부 소진했고, 파일 포인터가 끝(EOF)에 도달했습니다.
설정대로라면 스레드가 안전하게 종료(Stop thread on EOF)되었어야 하지만, 90개의 가상 스레드가 미친 듯이 연타를 치며 데이터의 '마지막 끝자락'을 동시에 읽어 들이는 과정에서 문제가 발생했습니다. JMeter 내부 파일 버퍼의 임계점을 넘어가며 순간적인 렉(Hang)과 컨텍스트 스위칭 지연이 발생했고, 제어권을 잃은 유령 스레드들이 변수 치환(바인딩)을 건너뛴 채 생 문자열 포맷인 "${endTime}" 그대로 서버에 요청을 밀어 넣어 버린 것이었습니다.
이를 해결하기 위해 다음과 같은 환경 통제 조치를 단행했습니다.
- 인프라 간섭 차단: 자바 기반 툴과 파일 접근 경합을 자주 일으키는 윈도우 OneDrive 동기화 폴더에서 CSV 파일을 제거하고, 완전히 독립된 순수 로컬 영문 경로(C:\jmeter\)로 이관했습니다.
2-3. 3차 및 4차 최종 시도: 인프라 레이어의 도미노 붕괴 (90VU의 진짜 벽)
2-3-1.3차 시도 결과




- JMeter 지표: 총 샘플 수 7,019건 / 에러율 41.40% (참패) / 평균 응답 856ms / 최대 레이턴시 10,113ms (10초)
- 대시보드 상황: Under-Replicated Partitions 7.47 돌파 (카프카 브로커 복제 지연 발생), HikariCP Active Connections 풀 한도(40개) 터치 후 수평 횡보. 톰캣 스레드 포화로 Nginx에서 502/500 에러 폭발.
90VU 환경에서 초당 80건이 넘는 대량의 일정 생성(INSERT) 요청을 무자비하게 들이부었다. 약 1분 20초를 넘어가며 총 요청이 7,000건을 축적한 임계점, 시스템은 비명을 지르며 마비됐다. 에러율 41.40%. 대시보드가 보여주는 연쇄 붕괴의 실체는 처참하리만큼 명확했다.
- 500 Internal Server Error의 수직 폭발: HTTP Status 차트에서 보라색 선(500 에러)이 벽을 타고 수직으로 치솟았다. 데이터 중복으로 인한 비즈니스 예외(409)가 아니라, 서버 내부 인프라가 부하를 감당하지 못하고 퓨즈가 나가버렸음을 뜻했다.
- HikariCP 커넥션 풀 고갈: 1번(초록)·2번(노란) 서버의 Active Connections 지표가 풀 한계치인 40~50개 근처까지 수직 상승한 뒤 일자로 꽉 묶여 내려오지 않았다. 대량 INSERT 쓰기 연산에 3초 주기의 Outbox 테이블 폴링 스케줄러 배타 락(Lock) 경합이 가세하자, DB가 멱살을 잡힌 채 멈춰 선 것이다.
- 비동기 파이프라인 전 단계의 정체: Kafka의 Consumer Lag은 7~8건 수준으로 무난했다. 즉, 메시지 큐가 터진 게 아니라 그 전 단계인 [Spring Boot -> RDB (Outbox 저장)] 구간이 주범이었다. 정체가 심해지자 시스템은 1차 방어선인 Resilience4j CircuitBreaker를 'Open(1)'으로 튕겨내며 강제 차단을 시작했다.
지표를 역추적해 찾아낸 범인은 '인프라 설정의 미숙함'이었다.
- Tomcat Threads(90) vs Hikari Pool Size(60)의 불균형: 톰캣은 90개의 요청을 받아 처리하려는데 DB 커넥션은 60개뿐이니, 남은 30개의 스레드가 내부에서 기약 없이 대기했다. 이는 순식간에 톰캣 accept-count(200) 대기열 포화로 이어졌다.
- 너무 가혹했던 connection-timeout: 3000 (3초): 이게 치명타였다. DB 처리가 밀려 스레드들이 줄을 서서 기다리는데, 인내심(타임아웃)을 고작 3초로 잡아두니 대기하던 스레드들이 SQLTransientConnectionException을 뱉으며 연쇄 폭발했다. 최대 응답이 10초까지 늘어지는 극한 상황에서 3초 만에 홧김에 튕기게 만든 설정 오류였다.
인프라 툴의 버그가 아닌 순수 성능 한계임을 확인했으니, 고부하 정체 구간을 시스템이 '끈질기게 버텨내며 순차 처리'하도록 설정을 조율하고 4차 최종 검증에 돌입했다. 풀을 동적으로 늘리고 줄이는 오버헤드를 막기 위해 minimum-idle을 max와 동기화하고, 타임아웃을 15초로 늘렸다.
server:
tomcat:
threads:
max: 100 # VU 트래픽을 유연하게 받아줄 스레드 조율
accept-count: 200
spring:
datasource:
hikari:
maximum-pool-size: 40 # 서버 2대 = 총 80개 제한 (RDB max_connections 고려)
minimum-idle: 40 # 풀 생성 오버헤드 원천 차단
connection-timeout: 15000 # 3초 -> 15초로 인내심을 확장해 정체 구간을 견디도록 유도
2-3-2. 4차시도 결과




- JMeter 지표: 총 샘플 수 5,934건 / 에러율 8.04% (41%에서 대폭 폭락, 안정권 진입) / 평균 응답 1,225ms / 최대 레이턴시 10,107ms
- 대시보드 상황: Under-Replicated Partitions 0으로 완벽 수렴 (카프카 인프라 안정화). HikariCP Active Connections가 피크를 찍지만 자원을 정상 반납하며 요동침. 성공 코드(200)가 압도적이며 500번대 에러선은 바닥으로 가라앉음.
설정을 튜닝하고 JMeter 내부의 버퍼 렉 변수까지 완벽히 통제한 뒤 최종 타격에 나섰다. 효과는 확실했다. 홧김에 에러를 뱉던 500번대 내부 장애는 완전히 자취를 감췄고, 에러율은 8%대까지 꼬꾸라졌다. 카프카 인프라도 완벽히 안정화됐다.
하지만 정확히 1분 37초 지점, 시스템은 전혀 다른 양상으로 다시 무너졌다. 이번엔 내부가 아닌, 앞단 Nginx 웹서버 단에서 502 Bad Gateway와 504 Gateway Timeout이 동시에 폭발했다.
그라파나 지표 기반 분석: 왜 하필 '1분 37초'였는가?
- 스케줄러 폴링과 쓰기 스레드의 RDB 진흙탕 싸움: Transactional Outbox 패턴을 구현하기 위해 선택한 '스케줄러 주기적 폴링(SELECT)' 방식이 발목을 잡았다. 초당 80건의 INSERT가 쏟아지며 1분 만에 아웃박스 테이블은 수만 건으로 비대해졌고, 이 시점에 주기적인 폴링 쿼리가 가세하면서 RDB 디스크 I/O와 테이블 배타 락(Lock) 경합이 극에 달했다.
- 도미노 대기열 정체: 인서트가 밀리자 Hikari 풀이 한계치에 묶였다. 다만 3차 때와 다른 점은 타임아웃을 15초로 늘려둔 덕에 스레드들이 에러를 뱉지 않고 '끈질기게 줄을 서서 버텼다'는 것이다. 하지만 줄이 너무 길어지면서 톰캣 스레드와 대기열(accept-count: 200)까지 도미노처럼 가득 차버렸다.
- Nginx의 독단적 손절: 뒤쪽의 스프링 부트 서버들이 DB 병목 때문에 묵묵부답으로 버티자, 앞단의 Nginx는 인내심의 한계를 넘겨버렸다. 자체 타임아웃 한계를 넘긴 요청은 504 Gateway Timeout으로 잘라버렸고, 톰캣 대기열마저 꽉 차서 소켓 연결 자체를 거부하는 서버에 대해서는 502 Bad Gateway를 반환하며 손을 놓아버린 것이다.
3.소감
이번 부하 테스트의 본질적인 목적은 아름다운 성공 수치를 증명하는 것이 아니라, 현재 가용 가능한 내 인프라와 아키텍처의 '진짜 한계점(Breaking Point)'이 어디인지를 정확하게 파악하는 것이었다. 최종 결과 시스템의 물리적 가용성 임계점은 90VU (최대 처리량 TPS 84.3)에서 멈췄고, 목표했던 100VU 돌파라는 숫자는 달성하지 못했다. 하지만 시스템을 고의로 무너뜨려 가며 한계선을 추적한 이번 테스트는, 내게 수치상의 성공보다 훨씬 더 가치 있는 엔지니어링적 확신을 주었다.
단순히 "서버가 터졌다", "에러율이 높다" 같은 결과론적인 해석에 그치지 않았다. 대량의 WRITE 트래픽이 몰릴 때, 데이터 유실을 막기 위해 도입한 Transactional Outbox 패턴의 폴링 스케줄러가 가세하며 RDB 디스크 I/O와 배타 락(Lock) 경합을 유발하고, 이 병목이 HikariCP 커넥션 풀을 말려버린 뒤, 결국 톰캣 스레드 포화를 거쳐 앞단 Nginx의 손절(502/504)로 이어지는 '인프라 레이어의 연쇄 도미노 붕괴 메커니즘'을 내 눈으로 직접 목격하고 데이터로 증명해 냈다.
이 한계점 추적을 통해 백엔드 개발자로서 얻은 진짜 자산은 다음과 같다.
- 시스템 가용성의 마지노선 확보: 이제 나는 내 시스템이 초당 몇 건의 일정 생성 요청(TPS 84.3)까지 안전하게 받아낼 수 있고, 어느 임계점부터 모니터링 경보를 울려야 하는지 명확한 가이드라인을 가지게 되었다. 짐작이 아닌 계측으로 얻어낸 진짜 우리 서비스의 '체급'이다.
- 아키텍처의 트레이드 오프에 대한 시야: 데이터 정합성과 유실 방지(At-least-once)를 위해 선택한 아키텍처가, 역설적으로 고부하 상황에서는 RDB의 목을 죄는 병목의 주범이 될 수 있다는 트레이드 오프를 뼈저리게 배웠다. 기술을 도입할 때는 장점뿐만 아니라 시스템이 무너질 때 치러야 할 비용까지 계산해야 함을 깨달았다.
- 근거 있는 개선 방향성: 어디가 터질지 모르는 맹목적인 튜닝은 끝났다. 시스템의 한계점과 그 원인이 RDB 쓰기 병목 및 스케줄러 경합이라는 것을 정확히 짚어냈기 때문에, 다음 단계의 타격 지점은 명확해졌다.
'포폴 > 일정관리 프로젝트' 카테고리의 다른 글
| 분산 서버 전환 후 부하 테스트로 병목 구간 찾기2 (0) | 2026.04.30 |
|---|---|
| 분산 서버 전환 후 부하 테스트로 병목 구간 찾기1 (0) | 2026.04.28 |
| 일정추천 기능 고도화 - 일정추천 챗봇으로 고도화 (0) | 2026.04.27 |
| Kafka KRaft 기반 3-Broker 클러스터 전환 과정 (0) | 2026.04.19 |
| AWS EC2 기반 Spring Boot 분산 인프라 구축기 2 (0) | 2026.04.19 |