컨텍스트를 이해하며 알아보는 JMeter 내부구조
Apache JMeter는 Java로 쓰여진 API 성능 테스트 도구입니다. 2011년에 첫 릴리즈 [1] 이후 꾸준하게 발전해왔는데요. 그만큼 오래되었지만, 비교적 간단한 구조로 이뤄져 있어서 소스코드를 쉽게 읽어볼 수 있습니다.
먼저 JMeter의 핵심에는 사용자가 요청한 명세에 따라서
- 여러 쓰레드를 만들어 병렬로, 반복적으로 호출을 가능하게 (Thread group)
- 각 쓰레드의 호출에서 단순하게 호출하거나, 이전 호출의 응답과 같은 조건에 따라 실행 흐름을 변경할 수 있게 (Controller)
- 각 쓰레드의 특정 호출이 HTTP, TCP 등 다양한 프로토콜을 지원할 수 있게 (Sampler)
- 전역적으로 또는 특정 Controller나 Sampler 범위에 변수를 지정하거나 설정을 할 수 있게 (Config element)
- 마지막으로 위의 설정들에 따라 실행된 테스트 결과를 목적에 맞게 보여줄 수 있게 (Listener)
지원합니다.
이번 글에서는 이러한 JMeter의 컨텍스트(요구사항, 목적, 이유 등)를 중심으로 작성된 코드를 살펴보며 구조를 알아보겠습니다.
UI 렌더링
먼저, 코드(https://github.com/apache/jmeter)를 clone 받아 'public static void main'을 전역검색하면 나오는 것들중 NewDriver를 찾을 수 있습니다. 이 함수 역시 간단한 설정들 몇 가지를 진행하고 'org.apache.jmeter.JMeter'라는 클래스의 인스턴스를 만들어 start 함수를 실행해주는데요. 해당 함수 내부에서는 여러 아규먼트에 따라 어떻게 JMeter를 실행시킬지 (RemoteServer의 실행인지, 로컬 실행의 Non-GUI인지, 로컬 실행의 GUI인지 등)을 결정하고 실행시키게 됩니다.
이번 글에서는 GUI 기반으로 로컬에서 실행하는 흐름을 알아볼 예정인데요. 그렇기에 JMeterGuiLauncher.startGui 로 넘어가는 실행을 중심으로 진행해보겠습니다.
이어지는 startGuiInternal은 대부분 Java Swing을 통해 UI를 그려주는 액션이 진행됩니다. UI가 뒷단의 실제 액션의 수행과 어떻게 연결되는지를 알아보기 위해서는 MainFrame과 액션 이벤트 발생 시 아규먼트로 전달되는 java.awt.event의 ActionEvent를 중심으로 살펴볼 수 있겠습니다 (Swing vs AWT [3]).
이후의 코드들은 실질적으로 아래와 같은 JMeter의 UI를 구성하는 개별개별의 컴포넌트를 렌더링하고 액션 리스너를 연결하는 작업임을 알 수 있습니다:
Threads 및 테스트 요소의 추가
JMeter를 통한 테스트 수행은 먼저 아래와 같이 Threads 그룹을 추가하고, Controller, Sampler, Config element를 추가하여 테스트 계획을 구성하면서 시작됩니다:
좌측의 UI는 JTree, treeModel, treeListener로 이뤄져 구성되고, 오른쪽 마우스를 클릭 시 이벤트 리스너가 선택 위치를 설정하고 팝업을 보여주는 것을 알 수 있습니다. 팝업 메뉴의 노출(displayPopUp)은 현재 위치의 노드를 꺼내서, 각 노드마다 연결된 팝업 메뉴 리스트를 보여주게 됩니다.
아래와 같이 ThreadGroup이 추가되었을 때에, ThreadGroup은 AbstractThreadGroupGui 혹은 이것을 상속한 객체로 관리되고 있음을 유추할 수 있습니다 (아래의 createPopupMenu 등의 함수를 참조해서).
@Override
public JPopupMenu createPopupMenu() {
JPopupMenu pop = new JPopupMenu();
pop.add(createAddMenu());
if (this.isEnabled() && !JMeterUtils.isTestRunning()) {
pop.addSeparator();
pop.add(createMenuItem("add_think_times", ActionNames.ADD_THINK_TIME_BETWEEN_EACH_STEP));
pop.add(createMenuItem("run_threadgroup", ActionNames.RUN_TG));
pop.add(createMenuItem("run_threadgroup_no_timers", ActionNames.RUN_TG_NO_TIMERS));
pop.add(createMenuItem("validate_threadgroup", ActionNames.VALIDATE_TG));
}
MenuFactory.addEditMenu(pop, true);
MenuFactory.addFileMenu(pop, false);
return pop;
}
위 코드에서, 우리는 실제로 테스트 플랜을 수행하면 어떤 식으로 실행되는지 참조할 수 있는 단초를 얻을 수 있습니다. 바로 ActionNames.RUN_TG가 사용되는 곳을 조사하는 것입니다.
ThreadGroup 실행의 동작
하나의 TG은 아래와 같이 TG를 오른쪽 클릭 후 'start' 버튼을 누르면서 시작됩니다:
RUN_TG라는 이벤트의 확인과 실행은 아래와 같이 AbstractAction을 상속한 Start 객체 내부에서 진행되고 있습니다:
public class Start extends AbstractAction {
...
@Override
public void doAction(ActionEvent e) {
...
} else if (e.getActionCommand().equals(ActionNames.RUN_TG)
|| e.getActionCommand().equals(ActionNames.RUN_TG_NO_TIMERS)
|| e.getActionCommand().equals(ActionNames.VALIDATE_TG)) {
popupShouldSave(e);
boolean noTimers = e.getActionCommand().equals(ActionNames.RUN_TG_NO_TIMERS);
boolean isValidation = e.getActionCommand().equals(ActionNames.VALIDATE_TG);
RunMode runMode = null;
if(isValidation) {
runMode = RunMode.VALIDATION;
} else if (noTimers) {
runMode = RunMode.IGNORING_TIMERS;
} else {
runMode = RunMode.AS_IS;
}
JMeterTreeListener treeListener = GuiPackage.getInstance().getTreeListener();
JMeterTreeNode[] nodes = treeListener.getSelectedNodes();
nodes = Copy.keepOnlyAncestors(nodes);
AbstractThreadGroup[] tg = keepOnlyThreadGroups(nodes);
if(nodes.length > 0) {
startEngine(tg, runMode);
}
else {
log.warn("No thread group selected the test will not be started");
}
}
}
...
}
startEngine 함수를 따라가다보면, 전반적으로 대상 TG 하위의 트리구조를 복사해서 StandardJMeterEngine 인스턴스를 만들어 복사한 트리를 넣어주고 실행하는 것을 알 수 있습니다. 실제 엔진을 실행시키는 runTest 함수는 내부에서 EXECUTOR_SERVICE라는 ThreadPoolExecutor 인스턴스에 submit으로 호출하게 됩니다.
private static final ExecutorService EXECUTOR_SERVICE =
new ThreadPoolExecutor(0, Integer.MAX_VALUE,
1L, TimeUnit.SECONDS,
new java.util.concurrent.SynchronousQueue<>(),
(runnable) -> new Thread(runnable, "StandardJMeterEngine-" + THREAD_COUNTER.incrementAndGet()));
그렇기에 Runnable 구현체인 StandardJMeterEngine의 run 함수는 내부를 알아보기에 유용한 흐름들이 모여있습니다:
...
@Override
public void run() {
log.info("Running the test!");
running = true;
/*
* Ensure that the sample variables are correctly initialised for each run.
*/
SampleEvent.initSampleVariables();
JMeterContextService.startTest();
try {
PreCompiler compiler = new PreCompiler();
test.traverse(compiler);
} catch (RuntimeException e) {
log.error("Error occurred compiling the tree:",e);
JMeterUtils.reportErrorToUser("Error occurred compiling the tree: - see log file", e);
return; // no point continuing
}
/*
* Notification of test listeners needs to happen after function
* replacement, but before setting RunningVersion to true.
*/
SearchByClass<TestStateListener> testListeners = new SearchByClass<>(TestStateListener.class); // TL - S&E
test.traverse(testListeners);
// Merge in any additional test listeners
// currently only used by the function parser
testListeners.getSearchResults().addAll(testList);
testList.clear(); // no longer needed
test.traverse(new TurnElementsOn());
notifyTestListenersOfStart(testListeners);
List<?> testLevelElements = new ArrayList<>(test.list(test.getArray()[0]));
removeThreadGroups(testLevelElements);
SearchByClass<SetupThreadGroup> setupSearcher = new SearchByClass<>(SetupThreadGroup.class);
SearchByClass<AbstractThreadGroup> searcher = new SearchByClass<>(AbstractThreadGroup.class);
SearchByClass<PostThreadGroup> postSearcher = new SearchByClass<>(PostThreadGroup.class);
test.traverse(setupSearcher);
test.traverse(searcher);
test.traverse(postSearcher);
TestCompiler.initialize();
// for each thread group, generate threads
// hand each thread the sampler controller
// and the listeners, and the timer
Iterator<SetupThreadGroup> setupIter = setupSearcher.getSearchResults().iterator();
Iterator<AbstractThreadGroup> iter = searcher.getSearchResults().iterator();
Iterator<PostThreadGroup> postIter = postSearcher.getSearchResults().iterator();
ListenerNotifier notifier = new ListenerNotifier();
int groupCount = 0;
JMeterContextService.clearTotalThreads();
if (setupIter.hasNext()) {
log.info("Starting setUp thread groups");
while (running && setupIter.hasNext()) {//for each setup thread group
AbstractThreadGroup group = setupIter.next();
groupCount++;
String groupName = group.getName();
log.info("Starting setUp ThreadGroup: {} : {} ", groupCount, groupName);
startThreadGroup(group, groupCount, setupSearcher, testLevelElements, notifier);
if (serialized && setupIter.hasNext()) {
log.info("Waiting for setup thread group: {} to finish before starting next setup group",
groupName);
group.waitThreadsStopped();
}
}
log.info("Waiting for all setup thread groups to exit");
//wait for all Setup Threads To Exit
waitThreadsStopped();
log.info("All Setup Threads have ended");
groupCount=0;
JMeterContextService.clearTotalThreads();
}
groups.clear(); // The groups have all completed now
/*
* Here's where the test really starts. Run a Full GC now: it's no harm
* at all (just delays test start by a tiny amount) and hitting one too
* early in the test can impair results for short tests.
*/
JMeterUtils.helpGC();
JMeterContextService.getContext().setSamplingStarted(true);
boolean mainGroups = running; // still running at this point, i.e. setUp was not cancelled
while (running && iter.hasNext()) {// for each thread group
AbstractThreadGroup group = iter.next();
//ignore Setup and Post here. We could have filtered the searcher. but then
//future Thread Group objects wouldn't execute.
if (group instanceof SetupThreadGroup ||
group instanceof PostThreadGroup) {
continue;
}
groupCount++;
String groupName = group.getName();
log.info("Starting ThreadGroup: {} : {}", groupCount, groupName);
startThreadGroup(group, groupCount, searcher, testLevelElements, notifier);
if (serialized && iter.hasNext()) {
log.info("Waiting for thread group: {} to finish before starting next group", groupName);
group.waitThreadsStopped();
}
} // end of thread groups
if (groupCount == 0){ // No TGs found
log.info("No enabled thread groups found");
} else {
if (running) {
log.info("All thread groups have been started");
} else {
log.info("Test stopped - no more thread groups will be started");
}
}
//wait for all Test Threads To Exit
waitThreadsStopped();
groups.clear(); // The groups have all completed now
if (postIter.hasNext()){
groupCount = 0;
JMeterContextService.clearTotalThreads();
log.info("Starting tearDown thread groups");
if (mainGroups && !running) { // i.e. shutdown/stopped during main thread groups
running = tearDownOnShutdown; // re-enable for tearDown if necessary
}
while (running && postIter.hasNext()) {//for each setup thread group
AbstractThreadGroup group = postIter.next();
groupCount++;
String groupName = group.getName();
log.info("Starting tearDown ThreadGroup: {} : {}", groupCount, groupName);
startThreadGroup(group, groupCount, postSearcher, testLevelElements, notifier);
if (serialized && postIter.hasNext()) {
log.info("Waiting for post thread group: {} to finish before starting next post group", groupName);
group.waitThreadsStopped();
}
}
waitThreadsStopped(); // wait for Post threads to stop
}
notifyTestListenersOfEnd(testListeners);
JMeterContextService.endTest();
if (JMeter.isNonGUI() && SYSTEM_EXIT_FORCED) {
log.info("Forced JVM shutdown requested at end of test");
System.exit(0); // NOSONAR Intentional
}
}
...
- 샘플러 변수의 init
- 각 쓰레드별 JMeterContextServer를 통한 컨택스트 관리
- 테스트 compile
- TG 하위의 트리를 이터레이션 하며 실행
- 실행 전후의 프로세서 처리
등으로 진행되는 것을 알 수 있습니다.
Reference
[1] https://github.com/apache/jmeter/releases/tag/v1_7_1a
[2] https://cwiki.apache.org/confluence/display/JMETER/Home
[3] https://stackoverflow.com/a/408830/8854614