지난 포스트에서 Redis + Webflux를 통해 대규모 트래픽을 처리할 수 있는 API를 제작했으니 이제는 실제로 Apache JMeter를 이용해서 여러 상황의 트래픽을 가정하고 테스트해보겠습니다.
들어가기 앞서 Apache JMeter가 무엇인지 간단히 알아보겠습니다.
Apache JMeter는 웹 애플리케이션, API, DB, 서버 등의 성능, 부하 테스트 도구이며 다음과 같은 특징들을 갖습니다.
Apache JMeter는 공식 페이지에서 다운받으실 수 있습니다.
이때 Java 8 버전 이상을 요구하기 때문에 자바 버전에 주의해주세요.
.zip을 다운로드 후 원하시는 위치에 압축 해제하시면 됩니다.
설치 후 실행은 \apache-jmeter-5.6.3\bin\jmeter.bat을 통해 GUI 기반 실행을 하거나 터미널에서 jmeter -n -t 명령을 통해 CLI 테스트를 수행할 수 있습니다.
프로젝트 루트 위치에 jmeter라는 이름의 디렉토리를 새로 생성하고 그 아래 profiles, report, results, .jmx 파일을 두었습니다, (memo.txt는 무시해주세요.)
각 구성 요소의 역할은 다음과 같습니다.
테스트 시나리오를 작성한 xml 형태의 파일입니다. 전문이 다음과 같이 길기 때문에 전문이 필요없으시면 앵커 사용하셔서 구획별 설명으로 넘어가시면 될 것 같습니다.
내용이 살짝 복잡한데 요약하자면 다음 세 가지 업무를 순차적으로 수행합니다.
0. 프로토콜 등의 정보 자동 설정
1. 서버 헬스 체크
2. 외부 csv로부터 clientId를 읽음
3. clientId 기반으로POST /api/products/purchase요청
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="scale-mall Test Plan"
enabled="true">
<stringProp name="TestPlan.comments">Scale Mall - WebFlux/Redis 부하 테스트 기본 플랜</stringProp>
<boolProp name="TestPlan.functional_mode">false</boolProp>
<boolProp name="TestPlan.tearDown_on_shutdown">true</boolProp>
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
<elementProp name="TestPlan.user_defined_variables" elementType="Arguments"
guiclass="ArgumentsPanel" testclass="Arguments" enabled="true">
<collectionProp name="Arguments.arguments">
<elementProp name="baseUrl" elementType="Argument">
<stringProp name="Argument.name">baseUrl</stringProp>
<stringProp name="Argument.value">${__P(baseUrl,http://localhost:8080)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="purchasePath" elementType="Argument">
<stringProp name="Argument.name">purchasePath</stringProp>
<stringProp name="Argument.value">${__P(purchasePath,/api/products/purchase)}
</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="healthPath" elementType="Argument">
<stringProp name="Argument.name">healthPath</stringProp>
<stringProp name="Argument.value">${__P(healthPath,/actuator/health)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="users" elementType="Argument">
<stringProp name="Argument.name">users</stringProp>
<stringProp name="Argument.value">${__P(users,100)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="rampUpSec" elementType="Argument">
<stringProp name="Argument.name">rampUpSec</stringProp>
<stringProp name="Argument.value">${__P(rampUpSec,10)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="durationSec" elementType="Argument">
<stringProp name="Argument.name">durationSec</stringProp>
<stringProp name="Argument.value">${__P(durationSec,60)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="csvPath" elementType="Argument">
<stringProp name="Argument.name">csvPath</stringProp>
<stringProp name="Argument.value">${__P(csvPath,clients.csv)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="productId" elementType="Argument">
<stringProp name="Argument.name">productId</stringProp>
<stringProp name="Argument.value">${__P(productId,1)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="quantity" elementType="Argument">
<stringProp name="Argument.name">quantity</stringProp>
<stringProp name="Argument.value">${__P(quantity,1)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="thinkTimeMs" elementType="Argument">
<stringProp name="Argument.name">thinkTimeMs</stringProp>
<stringProp name="Argument.value">${__P(thinkTimeMs,250)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
<elementProp name="thinkJitterMs" elementType="Argument">
<stringProp name="Argument.name">thinkJitterMs</stringProp>
<stringProp name="Argument.value">${__P(thinkJitterMs,150)}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="TestPlan.user_define_classpath"></stringProp>
</TestPlan>
<hashTree>
<!-- HTTP Defaults -->
<ConfigTestElement guiclass="HttpDefaultsGui" testclass="ConfigTestElement"
testname="HTTP Request Defaults" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.domain"></stringProp>
<stringProp name="HTTPSampler.port"></stringProp>
<stringProp name="HTTPSampler.protocol"></stringProp>
<stringProp name="HTTPSampler.contentEncoding">UTF-8</stringProp>
<stringProp name="HTTPSampler.path"></stringProp>
<boolProp name="HTTPSampler.concurrentDwn">false</boolProp>
<stringProp name="HTTPSampler.connect_timeout">3000</stringProp>
<stringProp name="HTTPSampler.response_timeout">10000</stringProp>
</ConfigTestElement>
<hashTree/>
<!-- 공통 헤더 -->
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager"
enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="Content-Type" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="Accept" elementType="Header">
<stringProp name="Header.name">Accept</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<!-- ========== Setup Thread Group : Health Only ========== -->
<SetupThreadGroup guiclass="SetupThreadGroupGui" testclass="SetupThreadGroup"
testname="setup: health only" enabled="true">
<stringProp name="ThreadGroup.num_threads">1</stringProp>
<stringProp name="ThreadGroup.ramp_time">1</stringProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController"
guiclass="LoopControlPanel" testclass="LoopController">
<boolProp name="LoopController.continue_forever">false</boolProp>
<stringProp name="LoopController.loops">1</stringProp>
</elementProp>
<boolProp name="ThreadGroup.scheduler">false</boolProp>
<stringProp name="ThreadGroup.duration"></stringProp>
<stringProp name="ThreadGroup.delay">0</stringProp>
</SetupThreadGroup>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy"
testname="GET ${healthPath}" enabled="true">
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments"/>
</elementProp>
<stringProp name="HTTPSampler.path">${__groovy(vars.get('healthPath')?.trim())}</stringProp>
<stringProp name="HTTPSampler.method">GET</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
</HTTPSamplerProxy>
<hashTree>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion"
testname="Assert: 200 or 201" enabled="true">
<collectionProp name="Assertion.test_strings">
<stringProp name="code200">200</stringProp>
<stringProp name="code201">201</stringProp>
</collectionProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">16</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
</hashTree>
<!-- CSV Data Set -->
<CSVDataSet guiclass="TestBeanGUI" testclass="CSVDataSet" testname="CSV Data Set (clientId)"
enabled="true">
<stringProp name="filename">${csvPath}</stringProp>
<stringProp name="fileEncoding">UTF-8</stringProp>
<stringProp name="variableNames">clientId</stringProp>
<stringProp name="delimiter">,</stringProp>
<boolProp name="quotedData">false</boolProp>
<boolProp name="recycle">false</boolProp>
<boolProp name="stopThread">true</boolProp>
<stringProp name="shareMode">shareMode.all</stringProp>
</CSVDataSet>
<hashTree/>
<!-- ========== Main Thread Group : Purchase Only ========== -->
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup"
testname="Users - Purchase Load" enabled="true">
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController"
guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller">
<boolProp name="LoopController.continue_forever">false</boolProp>
<stringProp name="LoopController.loops">1</stringProp>
</elementProp>
<stringProp name="ThreadGroup.num_threads">${users}</stringProp>
<stringProp name="ThreadGroup.ramp_time">${rampUpSec}</stringProp>
<boolProp name="ThreadGroup.scheduler">true</boolProp>
<stringProp name="ThreadGroup.duration">${durationSec}</stringProp>
<stringProp name="ThreadGroup.delay">0</stringProp>
</ThreadGroup>
<hashTree>
<!-- Think Time -->
<UniformRandomTimer guiclass="UniformRandomTimerGui" testclass="UniformRandomTimer"
testname="Think Time (uniform)" enabled="true">
<stringProp name="ConstantTimer.delay">${thinkTimeMs}</stringProp>
<stringProp name="RandomTimer.range">${thinkJitterMs}</stringProp>
</UniformRandomTimer>
<hashTree/>
<!-- 구매 요청 -->
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy"
testname="POST ${purchasePath}" enabled="true">
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="body" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">
{
"clientId": "${__groovy(vars.get('clientId')?.trim())}",
"productId": ${__intSum(${productId},0)},
"quantity": ${__intSum(${quantity},0)}
}
</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.path">${__groovy(vars.get('purchasePath')?.trim())}</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">false</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
</HTTPSamplerProxy>
<hashTree>
<ResponseAssertion guiclass="AssertionGui" testclass="ResponseAssertion" testname="Assert: 200 or 201" enabled="true">
<collectionProp name="Assertion.test_strings">
<stringProp name="code200">200</stringProp>
<stringProp name="code201">201</stringProp>
</collectionProp>
<stringProp name="Assertion.test_field">Assertion.response_code</stringProp>
<boolProp name="Assertion.assume_success">false</boolProp>
<intProp name="Assertion.test_type">16</intProp>
</ResponseAssertion>
<hashTree/>
</hashTree>
<!-- (GUI 디버깅용) Summary Report는 비활성 -->
<ResultCollector guiclass="SummaryReport" testclass="ResultCollector"
testname="Summary Report (GUI only)" enabled="false">
<boolProp name="ResultCollector.error_logging">false</boolProp>
<objProp>
<name>saveConfig</name>
<value class="SampleSaveConfiguration">
<time>true</time>
<latency>true</latency>
<timestamp>true</timestamp>
<success>true</success>
<label>true</label>
<code>true</code>
<message>false</message>
<threadName>false</threadName>
<dataType>false</dataType>
<encoding>false</encoding>
<assertions>true</assertions>
<subresults>false</subresults>
<responseData>false</responseData>
<samplerData>false</samplerData>
<xml>false</xml>
<fieldNames>true</fieldNames>
<responseHeaders>false</responseHeaders>
<requestHeaders>false</requestHeaders>
<responseDataOnError>false</responseDataOnError>
<saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
<assertionsResultsToSave>0</assertionsResultsToSave>
<bytes>true</bytes>
<sentBytes>true</sentBytes>
<url>true</url>
<threadCounts>true</threadCounts>
<idleTime>true</idleTime>
<connectTime>true</connectTime>
</value>
</objProp>
<stringProp name="filename"></stringProp>
</ResultCollector>
<hashTree/>
</hashTree>
<!-- baseUrl 적용 PreProcessor -->
<JSR223PreProcessor guiclass="TestBeanGUI" testclass="JSR223PreProcessor"
testname="Apply baseUrl to all HTTP samplers" enabled="true">
<stringProp name="scriptLanguage">groovy</stringProp>
<stringProp name="script">
import org.apache.jmeter.protocol.http.sampler.HTTPSamplerProxy
def base = vars.get('baseUrl')
if (!base) return
def u = new java.net.URL(base)
def proto = u.getProtocol()
def host = u.getHost()
def port = (u.getPort() != -1) ? u.getPort() : ("https".equalsIgnoreCase(proto) ? 443 :
80)
def s = ctx.getCurrentSampler()
if (s instanceof HTTPSamplerProxy) {
s.setProtocol(proto)
s.setDomain(host)
s.setPort(port)
}
</stringProp>
</JSR223PreProcessor>
<hashTree/>
</hashTree>
</hashTree>
</jmeterTestPlan>
<TestPlan testname="scale-mall Test Plan" ...>
...
<elementProp name="TestPlan.user_defined_variables" ...>
<collectionProp name="Arguments.arguments">
...
</collectionProp>
</elementProp>
</TestPlan>
elementProp에서 기본 변수를 정의합니다.*.props를 이용한다면 해당 props를 이용하면 기본값을 사용합니다.<ConfigTestElement testname="HTTP Request Defaults">
...
</ConfigTestElement>
<HeaderManager testname="HTTP Header Manager">
<elementProp name="Content-Type">application/json</elementProp>
<elementProp name="Accept">application/json</elementProp>
</HeaderManager>
<SetupThreadGroup testname="setup: health only">
...
<HTTPSamplerProxy testname="GET ${healthPath}" method="GET">
...
</HTTPSamplerProxy>
<ResponseAssertion testname="Assert: 200 or 201">
</ResponseAssertion>
</SetupThreadGroup>
healthcheck 수행<CSVDataSet testname="CSV Data Set (clientId)">
<stringProp name="filename">${csvPath}</stringProp>
<stringProp name="variableNames">clientId</stringProp>
</CSVDataSet>
<ThreadGroup testname="Users - Purchase Load">
<stringProp name="ThreadGroup.num_threads">${users}</stringProp>
<stringProp name="ThreadGroup.ramp_time">${rampUpSec}</stringProp>
<stringProp name="ThreadGroup.duration">${durationSec}</stringProp>
</ThreadGroup>
<UniformRandomTimer testname="Think Time (uniform)">
<stringProp name="ConstantTimer.delay">${thinkTimeMs}</stringProp>
<stringProp name="RandomTimer.range">${thinkJitterMs}</stringProp>
</UniformRandomTimer>
<HTTPSamplerProxy testname="POST ${purchasePath}" method="POST">
body:
{
"clientId": "${clientId}",
"productId": ${productId},
"quantity": ${quantity}
}
</HTTPSamplerProxy>
<ResponseAssertion testname="Assert: 200 or 201">
</ResponseAssertion>
POST /api/products/purchase 요청<ResultCollector testname="Summary Report (GUI only)" enabled="false">
</ResultCollector>
<JSR223PreProcessor testname="Apply baseUrl to all HTTP samplers" language="groovy">
def base = vars.get('baseUrl')
(...)
s.setProtocol(proto)
s.setDomain(host)
s.setPort(port)
</JSR223PreProcessor>
테스트 설정 파일의 내용을 살펴보겠습니다. 당장은 인원수만 변경하면 되기 때문에 100.props하나만 가지고 이야기하겠습니다.
# --- 부하 인원/기동 ---
users=100
rampUpSec=10
durationSec=5 # 원샷에서는 durationSec은 의미 없지만 남겨둠(미사용)
# --- 타겟/파라미터 ---
baseUrl=http://localhost:8080
productId=1
quantity=1
csvPath=scalemall\\clients.csv
# --- 생각시간(원샷이므로 0 권장) ---
thinkTimeMs=0
thinkJitterMs=0
이 기본적인 틀에서 상황별 또는 효율 개선을 위해 조금씩 변동시켜 나가며 테스트를 하게됩니다.
그러면 작성된 .jmx, 100.props를 기반으로 테스트를 수행해보겠습니다. (Redis 및 어플리케이션 반드시 실행)
터미널을 열고 cd 명령을 통해 /Apache JMeter/bin의 위치로 이동해주셔야 합니다.
Redis는 이 포스트의 도커를 통한 실행을 하였으며 해당 설정을 그대로 따라갑니다.
기본 설정으로 키 및 재고를 설정시켰으나 혹시 모를 경우를 대비해 다음 명령을 수행해주세요.
docker exec -it redis-local redis-cli -n 0 SET stock:product:1 100
그리고 다음 명령으로 테스트를 수행합니다. (프로젝트 루트는 개인마다 다름에 주의!!!)
.\jmeter.bat -n `
-t "scalemall\jmeter\scale-mall.jmx" `
-q "scalemall\jmeter\profiles\100.props" `
-JresultFile="scalemall\jmeter\results\100.jtl" `
-JsetupResultFile="scalemall\jmeter\results\setup.jtl"
만약 report에 HTML 리포트를 받고싶으시다면 다음 명령을 수행해주세요.
.\jmeter.bat -g "scalemall\jmeter\results\100.jtl" ` -o "scalemall\jmeter\report\100"
수행 결과 이상이 없다면 다음과 같이 메세지가 뜹니다. (Err: 0이여야 합니다!)

이 결과를 분석하면 다음과 같습니다.
100건 테스트인데 총합 101건인 이유는
헬스체크 1건 + 구매 요청 100건이므로 총합이 101건이 됩니다.
이렇게 결과가 아무 이상 없이 나타난다면 Redis + Spring Webflux + Apache JMeter를 이용한 대규모 트래픽 처리 시스템 구현 자체는 성공한 것 입니다.
다만 효율성은 크게 생각하지 않고 만든 것이기 때문에 다음에는 어떻게 하면 더 빠르고 안정적인 대규모 트래픽을 구현할 수 있을지 알아보도록 하겠습니다.