저번 글에서는 JMeter를 사용해 성능테스트를 진행해 보았다.
이후 응답 시간을 개선하고자 비동기 호출을 코드에 적용해 보았고 JMeter로 테스트 해보려 했다.
하지만 JMeter는 비동기 호출을 지원하지 않아 다른 테스트 툴을 찾아보다 Gatling을 알게 되었다.
우선 JMeter가 비동기호출이 안되는 이유부터 짚고 넘어가면,
JMeter의 각 스레드는 하나의 요청을 보내고 응답을 기다리기에 다음 요청은 이전 요청이 완료된 후에 실행된다.
따라서 각 스레드가 별도로 동작하여 비동기 처리를 모방하기 어렵다.
이번 글에서는 Gatling을 선택한 계기와 사용방법, 테스트 결과에 대한 분석을 해보겠다.
Gatling 선택 이유
다른 테스트 툴로 Locust, k6가 있었다.
Locust는 파이썬 기반으로 JVM 시스템과 거리가 멀어 선택에서 배제했고,
k6도 JS 기반이여서 JAVA 기반 백엔드에 자연스럽게 통합이 가능한 Gatling에 눈길이 갔다.
(Gatling은 자바 뿐만 아니라 Kotlin/Scalar 도 지원이 된다.)
Gatling은 다음 장점들로 인해 선택하게 되었다.
- 비동기 방식 기반으로 고성능 부하 테스트 가능하다.
- 복잡한 테스트 흐름을 유연하게 코드로 작성 가능하다.
- 내장 HTML 리포트를 제공해 결과를 시각적으로 분석하기에 용이하다.
- JVM 기반 프로젝트에 적합하다.
Gatling 사용법
Gatling은 GUI로 설치를 진행했다.
공식 홈페이지 https://gatling.io/products/download 에서 JAVA 버전을 다운로드 받아 zip파일을 압축 해제하니
gatling-maven-plugin-demo-java-main 폴더가 생성되었다.
그리고 해당 폴더에서 테스트 코드를 작성하기 위해 파일 생성을 했다.
nano src/test/java/com/gatling/simulations/GenerateQuestionsSimulation.java
그리고 생성한 파일에 테스트 코드를 작성했다.
package com.gatling.simulations;
import io.gatling.javaapi.core.*;
import io.gatling.javaapi.http.*;
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
public class GenerateQuestionsSimulation extends Simulation {
HttpProtocolBuilder httpProtocol = http
.baseUrl("https://devinterview.shop")
.acceptHeader("application/json")
.contentTypeHeader("application/json")
.header("Authorization", "Bearer {JWT 토큰}");
String requestBody = "{"
+ "\"content\": \"Performance testing sample\","
+ "\"userId\": \"550e8400-e29b-41d4-a716-446655440000\""
+ "}";
ScenarioBuilder scn = scenario("Generate Questions API Test")
.exec(
http("Post Generate Questions")
.post("/api/test/generate-questions")
.body(StringBody(requestBody)).asJson()
.check(status().is(200))
);
{
setUp(
scn.injectOpen(
rampUsers(50).during(60),
constantUsersPerSec(50).during(300)
)
).protocols(httpProtocol);
}
}
위 코드는 Java로 작성하고
이전 JMeter 테스트의 설정 값을 가져와 입력하니 매우 빠르게 작성할 수 있었다.
코드에서 setUp 부분만 짚고 넘어가겠다.
- rampUsers(50).during(60): 50명의 사용자가 60초 동안 점진적으로 증가한다.
- constantUsersPerSec(50).during(300): 5분 동안 지속된다.
파일을 생성한 후 테스트 폴더 구조를 확인해보면 다음과 같다.
gatling-maven-plugin-demo-java-main
├── pom.xml
└── src
└── test
├── java
│ └── com
│ └── gatling
│ └── simulations
│ └── GenerateQuestionsSimulation.java
└── resources
├── gatling.conf
└── logback-test.xml
이제 성능 테스트를 실행하면 되는데 빌드 및 실행 코드는 아래와 같다.
./mvnw clean install -U
./mvnw gatling:test -Dgatling.simulationClass=com.gatling.simulations.GenerateQuestionsSimulation
테스트 결과 분석
테스트가 완료되면 결과를 HTML 파일로 확인할 수 있다.
open target/gatling/**/index.html
결과를 수치화하여 정리해보면,
- 총 요청 수: 50 * 300 = 15,050건
- 성공 요청: 334건 (2.22%)
- 실패 요청: 14,716건 (97.78%)
- 평균 응답 시간: 41.01ms (성공 시 0.91ms, 실패 시 40.1ms)
- 주요 오류 코드:
- 429 (Too Many Requests): 91.54%
- 500 (Internal Server Error): 8.46%
총 15,050 건 중에 실패된 건수가 14,716건이나 된다.
에러율이 97%나 되는데 이는 비동기 호출방식을 적용하더라도 병목현상이 해결되지 않았다.
비동기 호출 처리 한계를 계산해보려 계산식을 찾아보니 TPS x 평균 응답시간이었다.
따라서 최대 동시 요청수 = 41.01 x 0.041 = 1.68 이 나왔다.
실제로는 동시 요청 수 2개도 처리하지 못하는 상황인 것이다.
문제 원인 분석
- 동시 처리 한계 도달:
- 비동기로 호출한다고 해도 서버 리소스(CPU, 메모리)가 부족하면 대기열이 증가한다.
- 커넥션 풀 한계를 초과하여 과부하가 발생했다.
- 외부 API 병목:
- 성능 테스트 중 외부 API(GPT, Qdrant) 호출이 응답 지연되었다.
- DB 처리 속도가 비동기 처리 속도를 따라가지 못해 대기 시간이 증가했다.
- 스레드 풀 과부하:
- 비동기 호출은 기본적으로 스레드 풀에서 처리된다.
- 동시 접속자가 많아지면 스레드 풀이 고갈되어 응답 시간이 급증한다.
해결방안
1. 서버 스펙 증설 (수직 확장)
- EC2 인스턴스 유형 업그레이드 (t3.small → t3.medium)
- CPU 성능과 메모리를 확장하여 비동기 처리 속도 향상시킬 수 있다.
- JVM 메모리를 설정한다.
2. 수평 확장 (서버 증설)
- 로드밸런서 사용: AWS ELB를 통해 다수의 인스턴스에 트래픽 분산시킨다.
- Auto Scaling 설정
- CPU 사용률이 70% 이상이면 인스턴스 추가하는 것이 좋다.
- 인스턴스가 많아지면 커넥션 풀 크기를 조정한다.
3. 외부 API 병목 해결
1. 캐싱 적용
- 외부 API 응답을 Redis에 캐싱하여 같은 요청에 대해 빠른 응답을 제공한다.
- TTL을 설정하여 캐싱 주기를 조절한다.
- API 호출 횟수 감소로 부하 완화시킨다.
2. 비동기 API Gateway 활용
- AWS API Gateway와 AWS Lambda 조합으로 외부 API 요청을 관리한다.
- 비동기 호출을 큐로 관리하여 갑작스러운 부하에 대처한다.
위의 방법들은 여기저기서 들어봤었던 내용이었고
성능 개선에 도움 되는 것이 확실화된 근본적인 문제를 해결할 수 있는 방법이다.
비용이 발생하기에 토이 프로젝트 수준인 내 프로젝트에 높은 인스턴스 유형 업그레이드나 로드 밸런서를 사용하는 것은
사치인것 같다.
물론 적용해봄으로써 어떤 선택지가 비용대비 성능이 좋았는지 소거법으로 확인 해보면 좋겠지만
추후에 서비스화를 목적으로 만든 프로젝트에 이와 같은 테스트를 진행해보려고 한다.
개선사항 적용
지금은 스레드 풀 과부하를 해결하고자 WebClient에 스레드 풀 크기를 명확히 설정해 고갈을 방지하도록 코드를 수정했다.
ConnectionProvider provider = ConnectionProvider.builder("custom")
.maxConnections(maxConnections)
.maxIdleTime(Duration.ofSeconds(maxIdleTime))
.pendingAcquireTimeout(Duration.ofSeconds(5)) // 커넥션 대기 시간
.maxLifeTime(Duration.ofMinutes(2)) // 커넥션 최대 생명 주기
.evictInBackground(Duration.ofSeconds(60)) // 유휴 커넥션 정리 주기
.build();
HttpClient httpClient = HttpClient.create(provider)
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
.responseTimeout(Duration.ofSeconds(5))
.doOnConnected(conn ->
conn.addHandlerLast(new ReadTimeoutHandler(5))
.addHandlerLast(new WriteTimeoutHandler(5))
);
코드 개선후 다시 Gatling 테스트를 실행해보니 결과의 변화가 미미했다.
항목 | 개선 전 | 개선 후 | 개선 여부 |
총 요청 수 | 15,050 | 15,050 | 동일 |
성공 요청 수 | 334 (2.22%) | 294 (1.95%) | ❌ 약간 감소 |
실패 요청 수 | 14,716 (97.78%) | 14,756 (98.05%) | ❌ 약간 증가 |
429 에러 비율 | 13,471건 (91.54%) | 13,485건 (91.39%) | ✅ 소폭 감소 |
500 에러 비율 | 1,245건 (8.46%) | 1,271건 (8.61%) | ❌ 소폭 증가 |
응답 시간 (평균) | 41.01ms | 41.01ms | ❌ 동일 (실패 응답이 많아서 왜곡됨) |
성공 요청 평균 시간 | 0.91ms | 0.8ms | ✅ 개선 |
실패 요청 평균 시간 | 40.1ms | 40.21ms | ❌ 약간 증가 |
99% Percentile | 7202ms | 7202ms | 동일 |
최대 응답 시간 | 9,664ms | 9,664ms | 동일 |
즉 커넥션 풀은 병목이 아님이 확인되었고 서버 내부로직 (DB, 외부 API)에 병목이거나 EC2 인스턴스 사양 문제일 가능성이 높아졌다.
마무리
이번 테스트를 진행해보며 병목 현상일 때 어떤 원인인지 알 수 있었고,
개선사항 중 하나를 적용해보며 개선여부를 확인해볼 수 있었다.
서비스 프로젝트를 진행하게되면 많은 테스트를 진행해볼 텐데, 이번 경험이 많이 도움될 것 같다.
'프로젝트 > 기술 면접 복습 플랫폼' 카테고리의 다른 글
RAG 시스템 설계 (0) | 2025.05.25 |
---|---|
JMeter를 이용한 성능 측정 - JMeter 설정, 결과 분석 (0) | 2025.05.19 |
GitHub Actions로 EC2 자동 배포 (0) | 2025.05.08 |
도메인 연결, HTTPS 적용하기 (0) | 2025.05.06 |
Nginx가 왜 필요했는가 (0) | 2025.05.05 |