-
컨텍스트를 이해하며 알아보는 JMeter 내부구조SE General 2024. 1. 5. 00:07반응형
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
반응형'SE General' 카테고리의 다른 글
컨텍스트를 이해하며 알아보는 Nginx 내부구조 (0) 2024.01.07 콘웨이의 법칙(Conway's Law) : '자세'를 잡지못한 조직이 '프로덕트'라는 힘을 쓸 수 없는 이유 (0) 2023.11.26 도커(docker). 컨테이너 vs 가상머신(Virtual Machine) (0) 2023.11.21 도커(docker) 내부구조(internal) (0) 2023.11.19 콜드 스타트 이슈 - 어떻게 네트워크 프로덕트를 성장시킬 것인가? (0) 2023.11.12 코딩 실력을 복리로 늘리는 최고의 방법 (0) 2023.09.27 OAuth 2.0 for Native Apps, RFC-8252 (번역) (0) 2023.09.03 주니어 개발자에게 추천하는 책 TOP 12 (0) 2023.07.23