SE General

컨텍스트를 이해하며 알아보는 JMeter 내부구조

Kaden Sungbin Cho 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를 구성하는 개별개별의 컴포넌트를 렌더링하고 액션 리스너를 연결하는 작업임을 알 수 있습니다:

 

JMeter UI - Image from Author

 

 

 

Threads 및 테스트 요소의 추가

JMeter를 통한 테스트 수행은 먼저 아래와 같이 Threads 그룹을 추가하고, Controller, Sampler, Config element를 추가하여 테스트 계획을 구성하면서 시작됩니다:

 

Inserting Threads - Image from Author

 

좌측의 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' 버튼을 누르면서 시작됩니다:

 

Start TG - Image from Author

 

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

 

 

반응형