ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 컨텍스트를 이해하며 알아보는 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를 구성하는 개별개별의 컴포넌트를 렌더링하고 액션 리스너를 연결하는 작업임을 알 수 있습니다:

     

    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

     

     

    반응형
Kaden Sungbin Cho