profile image

L o a d i n g . . .

728x90

최근에 회사에서 데이터 마이그레이션을 대비하기 위해 서버의 평균 TPS가 얼마인지 확인해하는 요구사항이 있었습니다. 회사의 대표적인 테스팅 플랫폼으로 nGrinder가 있었기 때문에 nGrinder을 활용해 테스트를 진행했습니다. nGrinder을 학습 과정에서 발견한 유용한 정보들과 nGrinder 스크립트를 작성하는 방법을 공유하기 위해서 이번 포스팅을 작성하게 됐습니다. 

nGrinder란? 

nGrinder은 네이버에서 Grinder라는 테스팅 오픈소스에 유용한 기능들을 추가한 대표적인 테스팅 오픈소스 플랫폼입니다. Jython 또는 Groovy 언어를 통해 script가 작성 가능하고 여러 agent에서(컴퓨터) 부하를 발생시킬 수 있습니다. nGrinder의 사용법을 살펴보기 전에 용어와 개념에 대해 살펴보겠습니다. 

nGrinder 메인 화면
nGrinder 테스트 화면

nGrinder 테스트 화면을 보시면 에이전트, 가상 사용자, 스크립트 등 다양한 용어가 있습니다. 위 용어들을 하나씩 살펴보겠습니다. 

  • 에이전트: 스크립트를 실행시킬 컴퓨터라고 보시면 됩니다. 에이전트의 수를 늘리면 테스트를 위한 컴퓨터의 수를 늘리는 것입니다. 
  • 가상 사용자(VUser): 에이전트 장비 당 Vuser의 수를 지정할 수 있습니다. VUser의 계산 공식은 (process 수) * (thread 수)입니다. VUser는 동시 접속자를 테스트하는 것으로 보면 됩니다. 만약 TPS를 높여도 서버가 견딜 수 있다고 예상되는 경우 VUser을 높여서 테스트를 진행하면 TPS가 증가합니다. 
  • 스크립트: 각 에이전트에서 실행하고자 하는 스크립트입니다. 테스트를 어떻게 진행할지 설정하는 곳입니다. 
  • 스크립트 리소스: 스크립트에서는 파일을 참조할 수 있습니다. 해당 파일의 경로를 기입하는 곳입니다. 
  • 테스트 대상 서버: 테스트 대상 서버의 hostname을 기입하는 곳입니다. 
  • Duration: 테스트가 실행되는 총시간을 의미합니다. 
  • 실행 횟수: 각각의 스레드가 테스트를 총 몇 번 실행할지 의미합니다. 

nGrinder 스크립트 작성법 

  • nGrinder에 대한 기본 용어에 대해 살펴봤으니 스크립트를 작성하는 방법을 살펴보겠습니다. 저는 python과 문법이 동일하면서 java의 라이브러리를 사용할 수 있고 JVM에서 실행될 수 있는 jython을 활용해 스크립트를 작성하겠습니다. nGrinder는 스크립트에서 사용할 데이터(e.g. fixture, 더미 데이터)를 resources 폴더에 저장해서 활용합니다. 

nGrinder resources 폴더

해당 resource 폴더는 다음과 같이 접근이 가능합니다. 

with open('./resources/dummy_data.csv', 'r') as csv:
	... 데이터 처리

그럼 본격적으로 스크립트를 작성해보겠습니다. 스크립트 탭에서 "+ 만들기" 버튼을 클릭하면 다음과 같이 스크립트 만들기 모달이 뜹니다. 

스크립트 만들기

위와 같이 스크립트를 만들게 되면 nGrinder에서 자동으로 스크립트를 생성합니다. 자동으로 생성된 코멘트를 지우면 스크립트의 구조는 다음과 같습니다. 

from net.grinder.script.Grinder import grinder
from net.grinder.script import Test
from java.util import Date
from HTTPClient import NVPair, Cookie, CookieModule

from net.grinder.plugin.http import HTTPRequest
from net.grinder.plugin.http import HTTPPluginControl

# 프로세스당 1회 호출하는 영역 
control = HTTPPluginControl.getConnectionDefaults()
control.timeout = 6000
test1 = Test(1, "Test1")
request1 = HTTPRequest()
headers = [] 
headers.append(NVPair("Content-Type", "application/json"))
body = "{\n  \"name\": \"seonwoo\",\n  \"age\": 27\n}"	# String of form data
cookies = []

class TestRunner:
    # 스레드당 1회 호출하는 영역 
    def __init__(self):
        test1.record(TestRunner.__call__)
        grinder.statistics.delayReports=True
        pass

    def before(self):
        request1.setHeaders(headers)
        for c in cookies: CookieModule.addCookie(c, HTTPPluginControl.getThreadHTTPClientContext())

    def __call__(self):
        self.before()
        result = request1.POST("http://please_modify_this.com", body)
        if result.getStatusCode() == 200 :
            return
        elif result.getStatusCode() in (301, 302) :
            grinder.logger.warn("Warning. The response may not be correct. The response code was %d." %  result.getStatusCode())
            return
        else :
            raise

위 스크립트를 보시면 "프로세스당 1회 호출하는 영역"과 "스레드당 1회 호출하는 영역"에 코멘트를 확인할 수 있습니다. 모든 스레드가 공통으로 사용할 코드는 프로세스당 1회 호출하는 영역에 그리고 스레드 별로 다르게 사용할 코드는 스레드당 1회 호출하는 영역에 작성합니다. 

제가 스크립트를 작성하면서 유용하게 사용한 방법 중 하나는 전체 nGrinder 에이전트에서 특정 스레드를 식별할 수 있는 id를 구하는 공식이었습니다. 

from net.grinder.script.Grinder import grinder
from net.grinder.script import Test
from java.util import Date
from HTTPClient import NVPair, Cookie, CookieModule

from net.grinder.plugin.http import HTTPRequest
from net.grinder.plugin.http import HTTPPluginControl

# 프로세스당 1회 호출하는 영역
control = HTTPPluginControl.getConnectionDefaults()
control.timeout = 6000
test1 = Test(1, "Test1")
request1 = HTTPRequest()
headers = []
headers.append(NVPair("Content-Type", "application/json"))
cookies = []

numberOfAgents = grinder.getProperties().getInt("grinder.agents", 1)
numberOfProcessesPerAgent = grinder.getProperties().getInt("grinder.processes", 1)
numberOfThreadsPerProcess = grinder.getProperties().getInt("grinder.threads", 1)
agentNumber = grinder.agentNumber
processNumber = grinder.processNumber

class TestRunner:
    # 스레드당 1회 호출하는 영역
    def __init__(self):
        test1.record(TestRunner.__call__)
        grinder.statistics.delayReports=True
        
        threadNumber = grinder.threadNumber
        self.currentThreadNumber = (agentNumber * numberOfProcessesPerAgent * numberOfThreadsPerProcess) + (processNumber * numberOfThreadsPerProcess) + threadNumber;
        pass

    def before(self):
        request1.setHeaders(headers)
        for c in cookies: CookieModule.addCookie(c, HTTPPluginControl.getThreadHTTPClientContext())

    def __call__(self):
        self.before()
        result = request1.POST("http://please_modify_this.com", body)
        if result.getStatusCode() == 200 :
            return
        elif result.getStatusCode() in (301, 302) :
            grinder.logger.warn("Warning. The response may not be correct. The response code was %d." %  result.getStatusCode())
            return
        else :
            raise

위 코드를 보시면 다음 코드가 추가된 것을 확인할 수 있습니다.

총 agent, process, thread의 수를 알고 현재 스레드가 실행되는 agent, process, thread 번호를 알기 때문에 위의 코드에서 사용한 공식으로 현재 스레드를 유일하게 식별할 수 있는 번호를 구할 수 있습니다. 이 번호를 활용하면 모든 스레드가 하나의 리소스에 접근할 때 자신에게 할당된 영역만 접근하도록 제한할 수 있습니다. 예시 코드는 다음과 같습니다. 참고로 jython의 string template 기능을 활용하면  json을 동적으로 쉽게 작성할 수 있습니다. 

from net.grinder.script.Grinder import grinder
from net.grinder.script import Test
from java.util import Date
from HTTPClient import NVPair, Cookie, CookieModule

from net.grinder.plugin.http import HTTPRequest
from net.grinder.plugin.http import HTTPPluginControl

# 프로세스당 1회 호출하는 영역
control = HTTPPluginControl.getConnectionDefaults()
control.timeout = 6000
test1 = Test(1, "Test1")
request1 = HTTPRequest()
headers = []
headers.append(NVPair("Content-Type", "application/json"))
cookies = []

numberOfAgents = grinder.getProperties().getInt("grinder.agents", 1)
numberOfProcessesPerAgent = grinder.getProperties().getInt("grinder.processes", 1)
numberOfThreadsPerProcess = grinder.getProperties().getInt("grinder.threads", 1)
agentNumber = grinder.agentNumber
processNumber = grinder.processNumber

samples = []
with open('./resources/samples.csv', 'r') as csv:
    csv_reader = reader(csv)
    for row in csv_reader:
        samples.append(row[0])

totalThreadCount = numberOfAgents * numberOfProcessesPerAgent * numberOfThreadsPerProcess
threadCurrentIndex = [0] * totalThreadCount
allocatedSizePerThread = len(samples) // totalThreadCount

class TestRunner:
    # 스레드당 1회 호출하는 영역
    def __init__(self):
        test1.record(TestRunner.__call__)
        grinder.statistics.delayReports=True

        threadNumber = grinder.threadNumber
        self.currentThreadNumber = (agentNumber * numberOfProcessesPerAgent * numberOfThreadsPerProcess) + (processNumber * numberOfThreadsPerProcess) + threadNumber;
        pass

    def before(self):
        request1.setHeaders(headers)
        for c in cookies: CookieModule.addCookie(c, HTTPPluginControl.getThreadHTTPClientContext())

    def __call__(self):
        self.before()

        index = threadCurrentIndex[self.currentThreadNumber]

        # 방어로직: 스레드에게 할당된 자원의 수보다 index의 크기가 같거나 커지면 index를 초기화 
        if (index >= allocatedSizePerThread):
            index[self.currentThreadNumber] = 0
            return

        sampleIndex = self.currentThreadNumber * allocatedSizePerThread + index
        # 방어로직
        if (sampleIndex >= len(samples)):
            return

        sample = samples[sampleIndex]
        body = '''
        {
            "name": "%s",
            "age": 27
        }
        ''' % (sample)
        result = request1.POST("http://please_modify_this.com", body)

        # sample에서 다음 자원을 사용할 수 있도록 index += 1 
        threadCurrentIndex[self.currentThreadNumber] += 1
        if result.getStatusCode() == 200 :
            return
        elif result.getStatusCode() in (301, 302) :
            grinder.logger.warn("Warning. The response may not be correct. The response code was %d." %  result.getStatusCode())
            return
        else :
            raise

nGrinder 결과 

결과 페이지

nGrinder의 테스트가 완료되면 상세 페이지에서 해당 테스트에 대한 결과를 확인할 수 있습니다. 해당 페이지에서 TPS, max TPS, 테스트 시간 등 여러 테스트 결과를 확인할 수 있습니다. 참고로 TPS와 평균 테스트 시간 그래프에서 중간중간 튀는 구간이 있는데 이는 JVM의 GC와 관련이 있다고 합니다. 

 

마무리 

테스트 없이 배포하는 애플리케이션은 항상 불안함의 근원이었습니다. 이번 계기로 배포 전 애플리케이션에 어떻게 부하 테스트를 진행해야 하는지 살펴볼 수 있었습니다. 

728x90

'Open Source > Testing Framework' 카테고리의 다른 글

[Gatling] Gatling 성능 부하 테스트  (0) 2022.11.11
복사했습니다!