Apache JMeter 테스트 수행

Bam·2025년 9월 9일
0

projects

목록 보기
6/6
post-thumbnail

지난 포스트에서 Redis + Webflux를 통해 대규모 트래픽을 처리할 수 있는 API를 제작했으니 이제는 실제로 Apache JMeter를 이용해서 여러 상황의 트래픽을 가정하고 테스트해보겠습니다.


Apache JMeter

들어가기 앞서 Apache JMeter가 무엇인지 간단히 알아보겠습니다.

Apache JMeter는 웹 애플리케이션, API, DB, 서버 등의 성능, 부하 테스트 도구이며 다음과 같은 특징들을 갖습니다.

  • GUI 기반 테스트 플로우 작성
  • 다양한 프로토콜에 대한 테스트 지원
  • 다양한 형태의 결과 도출
  • 시나리오 기반 성능 테스트 및 환경 설정

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는 무시해주세요.)

각 구성 요소의 역할은 다음과 같습니다.

  • profiles: 테스트 설정 파일들이 위치 (.props, .conf)
  • report: 테스트 실행 이후 HTML report를 저장
  • results: 테스트 실행 시 생성되는 결과 log를 저장 (.jtl)
  • .jmx: 테스트 시나리오 파일(테스트 플랜, xml 형식)

.jmx

테스트 시나리오를 작성한 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

<TestPlan testname="scale-mall Test Plan" ...>
  ...
  <elementProp name="TestPlan.user_defined_variables" ...>
    <collectionProp name="Arguments.arguments">
      ...
    </collectionProp>
  </elementProp>
</TestPlan>
  • 테스트 시나리오에서 최상위 컨테이너 역할
  • elementProp에서 기본 변수를 정의합니다.
    • 외부 *.props를 이용한다면 해당 props를 이용하면 기본값을 사용합니다.

ConfigTestElement

<ConfigTestElement testname="HTTP Request Defaults">
  ...
</ConfigTestElement>
  • 시나리오 내 모든 HTTP Sampler에 적용할 공통 기본 설정이 포함되는 컨테이너

HeaderManager

<HeaderManager testname="HTTP Header Manager">
  <elementProp name="Content-Type">application/json</elementProp>
  <elementProp name="Accept">application/json</elementProp>
</HeaderManager>
  • 모든 요청에 대해 공통 헤더를 추가

SetUpThreadGroup

<SetupThreadGroup testname="setup: health only">
  ...
  <HTTPSamplerProxy testname="GET ${healthPath}" method="GET">
    ...
  </HTTPSamplerProxy>
  <ResponseAssertion testname="Assert: 200 or 201">
  </ResponseAssertion>
</SetupThreadGroup>
  • 테스트 실행 전 Set up
  • 스레드 1개를 호출하여 실행 전 healthcheck 수행
    • 헬스체크 결과가 200 or 201 이면 서버 정상 동작 성공 아니면 실패

CSVDataSet

<CSVDataSet testname="CSV Data Set (clientId)">
  <stringProp name="filename">${csvPath}</stringProp>
  <stringProp name="variableNames">clientId</stringProp>
</CSVDataSet>
  • 외부 CSV 파일로부터 clientId를 읽기 위한 부분
  • 한 스레드에 한 줄씩 할당하고 재사용 불가 (인당 1회 한정 구매)

ThreadGroup

<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>
  • 실제 부하 테스트가 수행되는 구간
  • props 또는 기본으로 정의된 변수 값에 따라 스레드 생성 및 수행

UniformRandomTimer

<UniformRandomTimer testname="Think Time (uniform)">
  <stringProp name="ConstantTimer.delay">${thinkTimeMs}</stringProp>
  <stringProp name="RandomTimer.range">${thinkJitterMs}</stringProp>
</UniformRandomTimer>
  • 각 요청 사이에 사용자 대기시간의 부여

HTTPSamplerProxy

<HTTPSamplerProxy testname="POST ${purchasePath}" method="POST">
  body:
  {
    "clientId": "${clientId}",
    "productId": ${productId},
    "quantity": ${quantity}
  }
</HTTPSamplerProxy>
<ResponseAssertion testname="Assert: 200 or 201">
</ResponseAssertion>
  • 제작한 API에 POST /api/products/purchase 요청
  • 200 or 201을 반환해야 성공

ResultCollector

<ResultCollector testname="Summary Report (GUI only)" enabled="false">
</ResultCollector>
  • 결과를 나타낼 요약 리포트
  • GUI 모드에서만 사용되며 현재는 CLI 모드로 테스트 예정이기에 Off

JSR223PreProcessor

<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>
  • groovy를 이용한 HTTP Sampler의 protocol, domain, port를 baseUrl로부터 자동 세팅
  • .jmx 내에서 요소를 하드 코딩이 아닌 설정 값 기반으로 자동 세팅하기 위한 부분

.props

테스트 설정 파일의 내용을 살펴보겠습니다. 당장은 인원수만 변경하면 되기 때문에 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
  • users: 동시 접속 유저 수 (100명)
  • rampUpSec: 100명의 유저를 10초간 분산 투입. (초당 10명씩 10초간)
  • durationSec: 테스트 지속 시간 (지금은 1회 수행하고 끝이므로 의미는 없음)
  • baseUrl: 요청을 보낼 서버 주소
  • productId: 테스트 대상 상품의 ID (상품 하나이므로 1로 고정)
  • quantity: 주문 수량 (인당 1회 구매 가능 상품을 가정하므로 1로 고정)
  • csvPath: 외부 csv 파일 경로 (가짜 clientId.csv를 사용하고 있으므로 설정)
  • 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이여야 합니다!)

이 결과를 분석하면 다음과 같습니다.

  • 헬스체크: 평균 37ms, 최소 11ms, 최대: 301ms
  • 구매 요청: 평균 21ms, 최소 8ms, 최대: 88ms
  • 전체 (헬스 체크 + 구매 요청): 평균 24ms, 최소 8ms, 최대 301ms

100건 테스트인데 총합 101건인 이유는 헬스체크 1건 + 구매 요청 100건이므로 총합이 101건이 됩니다.

이렇게 결과가 아무 이상 없이 나타난다면 Redis + Spring Webflux + Apache JMeter를 이용한 대규모 트래픽 처리 시스템 구현 자체는 성공한 것 입니다.

다만 효율성은 크게 생각하지 않고 만든 것이기 때문에 다음에는 어떻게 하면 더 빠르고 안정적인 대규모 트래픽을 구현할 수 있을지 알아보도록 하겠습니다.

0개의 댓글