서버의 성능을 향상하려면 현재 서버가 어느 정도의 부하를 견딜 수 있는지, 그리고 어떤 부분에서 병목 현상이 발생하는지에 대한 명확한 지표가 필요합니다. 이러한 지표를 얻기 위해서는 실제로 서버에 부하를 가해보는 부하 테스트(Load Testing)가 필수적입니다.
Spring에서 사용할 수 있는 부하 테스트 툴을 찾아보다가 Gatling에 대해 알게되었는데요, 이 글에서는 Gatling이 무엇인지, 그리고 어떻게 적용하여 서버의 부하 테스트를 수행할 수 있는지에 대해 알아보겠습니다.
Gatling이란?
Gatling은 오픈 소스 기반의 고성능 부하 테스트 도구로, 주로 웹 애플리케이션의 성능 테스트에 사용된다. Scala와 Java를 지원하며, 간단한 설정만으로도 부하 테스트를 수행할 수 있는 것이 장점이다.
Gatling의 주요 특징은 다음과 같다:
- 높은 성능: Gatling은 비동기적이고 Non-Blocking 방식으로 동작하여, 적은 자원으로도 높은 부하를 생성할 수 있다. 이를 통해 수천, 수만 명의 동시 접속자를 시뮬레이션할 수 있다.
- 사용자 친화적: Gatling은 테스트 시나리오를 DSL(Domain Specific Language) 형식으로 작성할 수 있어, 개발자들이 쉽게 이해하고 작성할 수 있다. 또한, 테스트 결과를 그래프와 Report 형태로 시각화하여 제공하므로, 성능 문제를 직관적으로 파악할 수 있다.
- 확장성: Gatling은 다양한 프로토콜을 지원하며, 플러그인을 통해 기능을 확장할 수 있다. 이를 통해 HTTP뿐만 아니라 다양한 네트워크 프로토콜에 대해 부하 테스트를 수행할 수 있다.
- 자동화: CI/CD 파이프라인에 쉽게 통합할 수 있어, 지속적인 성능 모니터링 및 테스트 자동화가 가능하다.
Gradle에서 Gatling 설치
다음과 같이 build.gradle에 plugins와 dependency를 추가하면 gatling을 추가할 수 있다.
build.gradle
plugins {
...
id 'io.gatling.gradle' version '3.9.3'
}
...
dependencies {
testImplementation 'io.gatling.highcharts:gatling-charts-highcharts:3.9.3'
testImplementation 'io.gatling:gatling-test-framework:3.9.3'
}
Gatling 시나리오 작성
Gatling을 사용하여 부하 테스트를 수행하려면, 먼저 테스트 시나리오를 작성해야 한다. Gatling의 시나리오는 Scala 또는 Java 기반의 DSL을 사용하여 작성할 수 있다.
기본적인 시나리오 작성 방법은 다음과 같다:
(gpt4o가 작성한 코드이다.)
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 BasicSimulation extends Simulation {
HttpProtocolBuilder httpProtocol = http
.baseUrl("http://localhost:8080") // 테스트할 서버의 URL
.acceptHeader("application/json"); // 요청 헤더 설정
ScenarioBuilder scn = scenario("Basic Scenario")
.exec(http("request_1")
.get("/api/test") // 테스트할 엔드포인트
.check(status().is(200))); // 응답 상태 코드 확인
{
setUp(
scn.injectOpen(atOnceUsers(100)) // 100명의 사용자가 동시에 요청
).protocols(httpProtocol);
}
}
위 예제는 간단한 테스트 시나리오로, 100명의 사용자가 동시에 "/api/test" 엔드포인트에 GET 요청을 보내는 테스트를 수행한다. 이러한 시나리오를 통해 서버의 기본적인 성능을 측정할 수 있다.
- 먼저 HttpProtocolBuilder 객체를 통해 서버 URL과 HTTP 프로토콜을 정의한다. 그리고 acceptHeader 메서드는 HTTP 요청에 Accept 헤더를 추가한다.
- ScenarioBuilder는 Simulation 할 시나리오를 작성한다. 위 코드에서는 시나리오의 이름을 "Basic Scenario"로 지정한다.
- exec 메서드는 시나리오에서 실행할 HTTP 요청을 정의한다. http("request_1")는 이 요청의 이름을 "request_1"로 지정한다.
- get 메서드는 HTTP GET 요청을 생성하고 "/api/test"는 요청할 엔드포인트를 지정한다.
- check 메서드는 요청에 대한 검사를 정의한다.
- setUp 메서드는 시나리오를 설정하고 실행할 사용자 수를 정의한다. injectOpen 메서드는 사용자가 어떻게 시나리오에 주입될지를 정의한다.
- atOnceUsers(100)은 100명의 사용자가 동시에 시나리오를 실행하도록 설정한다.
- protocols 메서드는 이 시나리오에서 사용할 프로토콜을 지정한다.
Spring에서 gatling은 아래와 같은 디렉토리 구조를 지켜야 한다.
my-spring-app/
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── com/
│ │ │ └── example/
│ │ │ └── MySpringApp.java
│ │ └── resources/
│ ├── test/
│ │ ├── java/
│ │ └── resources/
│ ├── gatling/
│ │ ├── java/
│ │ │ └── simulations/
│ │ │ └── BasicSimulation.java
│ │ └── resources/
└── build.gradle
그렇지 않으면, Gatling 테스트 실행 시 파일을 찾을 수 없다는 에러를 마주칠 수 있다.
Gatling 테스트 실행
작성한 시나리오를 실행하려면, Gatling의 실행 스크립트를 사용한다. 터미널에서 다음 명령어를 입력하여 테스트를 실행할 수 있다:
/gradlew gatlingRun-simulations.BasicSimulation
(참고로, 실제로 서버가 켜져있어야 한다. 따라서 새로운 데이터를 생성하면, 실제 서버 DB에 반영된다. 실제 배포되고 있는 서버에서 사용하면 안 된다.)
테스트가 완료되면 Gatling은 결과를 그래프와 Report 형태로 제공한다.
아래는 필자가 실행했던 Simulation의 Report 결과이다.
이러한 Report 파일은 build/reports/gatling 디렉터리에 저장된다.
서버에 부하 테스트 해보기
Spring으로 구현된 설문조사 웹 애플리케이션 서버에, 설문조사 목록 요청을 동시에 1000명이 실행시키고 점진적으로 유저 수를 증가시키는 부하테스트를 해보겠다.
public class GetAllSurveySimulation extends Simulation {
HttpProtocolBuilder httpProtocol = http
.baseUrl("http://localhost:8080/v1") // Base URL of your Spring Boot application
.acceptHeader("application/json")
.contentTypeHeader("application/json")
.userAgentHeader("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36");
Map<String, String> headers = Map.of("Content-Type", "application/json");
우선 위와 같이 기본적인 Http 설정을 해주었다.
// AtomicInteger to ensure unique counts
private static final AtomicInteger count = new AtomicInteger(1);
// Iterator to provide unique count values
private static final Iterator<Map<String, Object>> feeder = Stream.generate(() -> Collections.singletonMap("count", (Object) count.getAndIncrement())).iterator();
// Scenario to create 15 surveys
ScenarioBuilder createSurveysScn = scenario("Create Surveys")
.feed(feeder)
.exec(http("User Registration")
.post("/auth/register")
.headers(headers)
.body(StringBody(session -> {
int count = session.getInt("count");
String name = "testUser" + count;
String email = "testUser" + count + "@gmail.com";
String password = "Password40@";
String phoneNumber = "01012345678";
return String.format("{\"name\":\"%s\",\"email\":\"%s\",\"password\":\"%s\",\"phoneNumber\":\"%s\"}", name, email, password, phoneNumber);
})).asJson()
.check(status().is(200)))
.pause(1)
.exec(http("User Login")
.post("/auth/login")
.headers(headers)
.body(StringBody(session -> {
String email = "testUser" + session.getInt("count") + "@gmail.com";
String password = "Password40@";
return String.format("{\"email\":\"%s\",\"password\":\"%s\"}", email, password);
})).asJson()
.check(status().is(200))
.check(headerRegex("Set-Cookie", "JSESSIONID=(.*?);").saveAs("jsessionid")))
.pause(1)
.repeat(15, "n").on(
exec(http("Create Survey #{n}")
.post("/surveys")
.headers(headers)
.body(RawFileBody("data/survey_request.json")).asJson()
.check(status().is(200))
)
);
위 코드는 테스트에 사용할 시나리오를 작성한다.
AtomicInteger를 사용한 이유는, User를 회원가입하는데 email이 똑같으면 Unique constraint를 위배하기 때문에, User마다 다른 Email을 할당하기 위함이다.
feed(feeder)는 시나리오에 데이터를 공급하기 위해 사용된다. 이러한 공급받은 데이터는 진행될 시나리오에서 사용될 수 있다.
예를 들어, 위 코드에서 body 부분에 int count = session.getInt("count");
로 feeder의 count 값을 가져오는 것을 볼 수 있다.
session은 Gatling에서 각 가상의 사용자를 추적하는 데 사용되는 객체이다. session은 시나리오 실행 중에 사용자별로 고유한 상태를 유지한다. 그리고 session에 변수의 값을 저장하여 시나리오의 다른 부분에서 사용할 수 있도록 할 수 있다.
예를 들어, 로그인 요청에서 받은 토큰을 이후 요청에서 사용할 수 있도록 저장한다.
feed와 session은 함께 사용될 수 있다. feeder를 통해 시나리오에 데이터를 공급하면, 해당 데이터는 자동으로 session에 저장되어 시나리오의 다른 부분에서 사용할 수 있다.
register과 login을 수행한 후에는, 설문조사를 생성하는 요청을 repeat(15, "n")을 통해 15번 반복한다.RawFileBody("data/survey_request.json")
를 통해 파일 경로를 입력하여 데이터를 body에 넣을 수 있다.
위 코드를 요약하자면 register과 login을 수행한 후 survey 데이터를 생성하는 요청을 15번 한다.
// Scenario to get surveys on the second page
ScenarioBuilder getAllSurveysScn = scenario("Get All Surveys")
.exec(http("Get Surveys Page 1")
.get("/surveys")
.queryParam("page", "1")
.check(jsonPath("$.surveys").exists())
.headers(headers)
.check(status().is(200))
);
{
setUp(
createSurveysScn.injectOpen(atOnceUsers(1)),
getAllSurveysScn.injectOpen(
nothingFor(5), // wait for 5 seconds to ensure surveys are created
atOnceUsers(1000), // simulate 1000 users concurrently fetching surveys
rampUsers(5000).during(Duration.ofSeconds(300)) // ramp up to 5000 users over 5 minutes
).protocols(httpProtocol));
}
위 코드는 Pageable로 구현된 survey 목록의 1 page를 get 하는 요청 시나리오를 구현한다.
setUp 부분을 보면 createSurvey 시나리오는 한 명의 가상 User가 수행하며, getAllSurvey 시나리오는 1000명의 User가 동시에 수행한다. 그리고 300초 동안 점진적으로 5000명으로 증가시켜 수행한다.
이러한 과정을 통해 서버의 부하 테스트를 수행할 수 있다.
아래는 위 테스트의 결과 일부이다.
일부 요청(약 13.33%)이 1200 ms 이상의 응답 시간을 기록하여, 응답 시간이 긴 요청들도 존재한다. 또한 Premature close라는 예외가 24개가 발생했다.
Premature close
Premature close 에러는 주로 HTTP keep-alive과 관련되어 있다.
HTTP keep-alive는 클라이언트와 서버 간의 연결을 지속적으로 유지하여 여러 요청을 동일한 연결을 통해 처리할 수 있게 해준다. 이는 성능을 향상하고, 연결 설정 및 종료에 드는 오버헤드를 줄이는 데 도움을 준다. 하지만, 클라이언트가 서버의 응답을 기다리는 동안 서버의 응답이 설정된 timeout 보다 지연된다면, 클라이언트가 타임아웃에 도달하여 연결을 종료할 수 있다. 이로 인해 서버가 응답을 완료하기 전에 연결이 닫혀 premature close 오류가 발생할 수 있다.
즉, 현재 서버에서 부하가 걸린다면 극히 일부의 요청은 에러를 발생시킬 수 있는 것으로 파악할 수 있다.
마치며
- Gatiling을 사용한 부하 테스트를 진행해보았다. Gatling의 report를 참고하여 지속적으로 서버 부하를 개선할 필요가 있다.
- 실제 서버를 구동시키고, 데이터가 반영된다는 점에서 기존 SpringBootTest에 비해 다소 불편할 수 있지만, 부하 테스트의 목적이라면 매우 편리한 플러그인 같다.
참고
'Development > Spring' 카테고리의 다른 글
[Spring] 서버 모니터링을 위한 Spring Actuator, Prometheus, Grafana 추가하기 (0) | 2024.07.12 |
---|---|
[Spring] Redisson을 활용한 캐시 사용하기 (0) | 2024.07.11 |
[Spring] ThreadLocal에 대해 알아보자 + SecurityContextHolder, RequestContextHolder (0) | 2024.05.28 |
[Spring] Spring Security의 Authentication과 SecurityContext 동작, 그리고 Authentication을 얻는 방식 (0) | 2024.05.13 |
[Spring] 테스트 환경을 독립적으로 만들어보자 (0) | 2024.04.22 |