MQTT 테스트 자동화 도구 개발기 (3) — JavaFX GUI + jpackage exe 배포

"☕" 12 "min read"

시리즈: Part 1: 설계와 인프라 · Part 2: 35개 테스트 케이스 · Part 3: GUI와 배포

CLI에서 GUI로 전환한 이유

Part 1Part 2에서 만든 35개 테스트 케이스를 CLI로 실행하는 것은 개발자에겐 편하지만, 다른 팀원이 사용하기엔 장벽이 있었습니다:

CLI (gradlew)GUI 도구
JDK + Gradle 설치 필요더블클릭으로 실행
인증서 경로를 환경변수로 설정파일 선택 다이얼로그
태그 선택이 커맨드라인 옵션체크박스로 선택
테스트 파라미터 23개를 환경변수로입력 필드에서 조절
결과를 터미널에서 확인실시간 진행 표시 + 리포트 뷰어
리포트를 별도로 열어야 함앱 내에서 바로 확인

결국 "exe 하나로 끝나는 도구"를 만들기로 했습니다.


JavaFX 17 + Spring Boot 통합

TestToolLauncher — 모듈 경로 우회 패턴

JavaFX를 Fat JAR로 패키징하면 RuntimeException: Error: JavaFX runtime components are missing 에러가 발생합니다. JavaFX가 모듈 시스템(module-path)에서 실행되길 기대하는데, Fat JAR는 classpath로 실행되기 때문입니다.

해결법은 놀라울 정도로 간단합니다:

// TestToolLauncher.java — JavaFX 모듈 경로 우회
public class TestToolLauncher {
    public static void main(String[] args) {
        TestToolApp.main(args);
    }
}Code language: Java (java)
// TestToolApp.java — 실제 JavaFX Application
public class TestToolApp extends Application {
    @Override
    public void start(Stage primaryStage) {
        // ... GUI 구성
    }

    public static void main(String[] args) {
        launch(args);
    }
}Code language: Java (java)

Application을 직접 main으로 실행하면 JavaFX가 모듈 경로를 체크하지만, 별도 클래스에서 호출하면 체크를 건너뜁니다. 이 한 줄짜리 런처 클래스가 핵심입니다. 널리 알려진 패턴이지만, 처음 겪으면 한참 헤맬 수 있는 부분입니다.

4-탭 UI 구조

flowchart LR subgraph "MQTT Test Tool v1.0" direction LR T1["환경설정"] T2["테스트 파라미터"] T3["테스트 실행"] T4["결과"] end T1 -->|"다음"| T2 T2 -->|"다음"| T3 T3 -->|"완료 시"| T4 style T1 fill:#E3F2FD,stroke:#1565C0 style T2 fill:#E8F5E9,stroke:#2E7D32 style T3 fill:#FFF3E0,stroke:#EF6C00 style T4 fill:#F3E5F5,stroke:#6A1B9A
// TestToolApp.java — 4-탭 구성
public class TestToolApp extends Application {

    @Override
    public void start(Stage primaryStage) {
        AppSettings settings = AppSettings.load();
        TestExecutionService executionService = new TestExecutionService();

        // 4개 뷰 생성
        SettingsPane settingsPane = new SettingsPane(settings, primaryStage);
        TestParamsPane testParamsPane = new TestParamsPane(settings);
        ResultPane resultPane = new ResultPane(executionService);
        TestRunPane testRunPane = new TestRunPane(settings, executionService, resultPane);

        // 탭 구성
        Tab settingsTab = new Tab("환경설정", settingsPane);
        Tab paramsTab = new Tab("테스트 파라미터", testParamsPane);
        Tab testRunTab = new Tab("테스트 실행", testRunPane);
        Tab resultTab = new Tab("결과", resultPane);

        TabPane tabPane = new TabPane(settingsTab, paramsTab, testRunTab, resultTab);

        // 탭 간 내비게이션
        settingsPane.setOnNext(() -> tabPane.getSelectionModel().select(paramsTab));
        testParamsPane.setOnNext(() -> tabPane.getSelectionModel().select(testRunTab));
        testRunPane.setOnTestComplete(() -> tabPane.getSelectionModel().select(resultTab));

        Scene scene = new Scene(tabPane, 960, 680);
        primaryStage.setTitle("MQTT Test Tool v1.0");
        primaryStage.setScene(scene);
        primaryStage.show();
    }
}Code language: Java (java)

각 탭의 역할:

역할주요 컴포넌트
환경설정MQTT 브로커 연결 정보엔드포인트, 인증서 경로 (파일 선택기), Client ID
테스트 파라미터23개 테스트 강도 조절JSON 중첩 깊이, Burst 건수, Soak 시간 등
테스트 실행카테고리 선택 & 실행태그 체크박스, 실시간 로그 출력, 진행률
결과Markdown 리포트 뷰어최근 리포트 자동 로드, 파일 열기

JUnit Platform Launcher API로 프로세스 내 테스트 실행

GUI에서 테스트를 실행하는 방법은 두 가지가 있습니다:

  1. Gradle 서브프로세스gradlew testSelected 호출
  2. JUnit Platform Launcher API — 프로세스 내에서 직접 실행

처음에는 방법 1로 시작했지만, 결국 방법 2로 전환했습니다:

Gradle 서브프로세스Launcher API
JDK/Gradle 필요✅ 필요❌ Fat JAR에 내장
실행 속도Gradle 부팅 ~10초즉시 시작
출력 제어stdout 파싱 필요리스너 콜백으로 정교한 제어
exe 배포❌ 불가✅ 독립 실행
// TestExecutionService.java — JUnit Platform Launcher API 기반 테스트 실행
public class TestExecutionService {

    private Thread executionThread;
    private volatile boolean running = false;

    public void executeTests(AppSettings settings, Set<String> selectedTags,
                             Consumer<String> outputCallback, Runnable onComplete) {
        if (running) return;
        running = true;

        executionThread = new Thread(() -> {
            try {
                // 0. jpackage 환경에서 Spring이 클래스를 찾도록 context classloader 설정
                Thread.currentThread().setContextClassLoader(getClass().getClassLoader());

                // 1. GUI 설정값 → System Property (Spring이 읽을 수 있도록)
                applySystemProperties(settings);

                // 2. Spring context 설정
                System.setProperty("spring.test.context.cache.maxSize", "1");
                System.setProperty("spring.main.web-application-type", "none");
                System.setProperty("spring.profiles.active", "test");

                // 3. JUnit Platform Launcher 구성
                LauncherDiscoveryRequest request = LauncherDiscoveryRequestBuilder.request()
                        .selectors(DiscoverySelectors.selectPackage("com.mqtttest"))
                        .filters(TagFilter.includeTags(new ArrayList<>(selectedTags)))
                        .build();

                Launcher launcher = LauncherFactory.create();

                // 4. 테스트 발견
                TestPlan testPlan = launcher.discover(request);
                long testCount = testPlan.getRoots().stream()
                        .mapToLong(root -> testPlan.getDescendants(root).stream()
                                .filter(TestIdentifier::isTest).count())
                        .sum();
                outputCallback.accept("[GUI] 발견된 테스트: " + testCount + "건");

                // 5. 리스너 등록 & 실행
                GuiBridgeListener guiListener = new GuiBridgeListener(outputCallback);
                TestExecutionListener reportListener = createReportListener(
                    getReportOutputDir());

                if (reportListener != null) {
                    launcher.execute(request, guiListener, reportListener);
                } else {
                    launcher.execute(request, guiListener);
                }

            } catch (Exception e) {
                outputCallback.accept("[GUI] 오류: " + e.getMessage());
            } finally {
                running = false;
                onComplete.run();
            }
        }, "test-execution");
        executionThread.setDaemon(true);
        executionThread.start();
    }
}Code language: Java (java)

핵심 포인트가 몇 가지 있습니다:

1. Context ClassLoader 설정

Thread.currentThread().setContextClassLoader(getClass().getClassLoader());Code language: CSS (css)

jpackage 환경에서는 시스템 ClassLoader가 아닌 커스텀 ClassLoader를 사용하기 때문에, Spring이 클래스를 찾지 못할 수 있습니다. 테스트 실행 스레드에 명시적으로 ClassLoader를 설정해야 합니다.

2. System Property 매핑

GUI의 입력 필드 값을 System.setProperty()로 설정하면, Spring의 ${MQTT_ENDPOINT:기본값} 패턴이 이 값을 읽게 됩니다:

private void applySystemProperties(AppSettings settings) {
    // MQTT 연결 설정
    System.setProperty("MQTT_ENDPOINT", settings.getEndpoint());
    System.setProperty("MQTT_CLIENT_ID", settings.getClientId());
    System.setProperty("MQTT_TOPIC_PREFIX", settings.getTopicPrefix());

    // 인증서 경로 (GUI에서 선택한 절대 경로)
    if (!settings.getCertPath().isEmpty()) {
        System.setProperty("MQTT_CERT_PATH", settings.getCertPath());
    }

    // 23개 테스트 파라미터
    System.setProperty("TEST_PARAM_JSON_NESTING_DEPTH",
        String.valueOf(settings.getTestParamJsonNestingDepth()));
    System.setProperty("TEST_PARAM_BURST_MESSAGE_COUNT",
        String.valueOf(settings.getTestParamBurstMessageCount()));
    // ... 나머지 파라미터들
}Code language: Java (java)

3. MarkdownReportListener 리플렉션 생성

MarkdownReportListenertest 소스셋에 있어서 main에서 직접 참조할 수 없습니다. Fat JAR에서는 모든 클래스가 하나로 합쳐지므로, 리플렉션으로 생성합니다:

private TestExecutionListener createReportListener(Path outputDir) {
    try {
        Class<?> clazz = Class.forName("com.mqtttest.report.MarkdownReportListener");
        return (TestExecutionListener) clazz.getConstructor(Path.class).newInstance(outputDir);
    } catch (Exception e) {
        System.err.println("[GUI] MarkdownReportListener 생성 실패: " + e.getMessage());
        return null;
    }
}Code language: Java (java)

GuiBridgeListener — 실시간 출력 스트리밍

JUnit의 테스트 실행 이벤트를 GUI의 텍스트 영역으로 브릿지하는 리스너입니다:

// GuiBridgeListener.java — JUnit → GUI 브릿지
public class GuiBridgeListener implements TestExecutionListener {

    private final Consumer<String> outputCallback;
    private TestPlan testPlan;

    public GuiBridgeListener(Consumer<String> outputCallback) {
        this.outputCallback = outputCallback;
    }

    @Override
    public void executionFinished(TestIdentifier testIdentifier, TestExecutionResult result) {
        if (!testIdentifier.isTest()) {
            // 컨테이너(클래스) 레벨 실패 시 root cause 출력
            if (result.getStatus() == TestExecutionResult.Status.FAILED) {
                result.getThrowable().ifPresent(t -> {
                    outputCallback.accept("[GUI] 오류: " + t.getMessage());
                    Throwable cause = t;
                    while (cause.getCause() != null) {
                        cause = cause.getCause();
                        outputCallback.accept("[GUI]   원인: " + cause.getMessage());
                    }
                });
            }
            return;
        }

        // Gradle 호환 형식으로 출력: "ClassName > [P0] TC-PS-001-1: 설명 PASSED"
        String className = extractClassName(testIdentifier);
        String displayName = testIdentifier.getDisplayName();
        String status = switch (result.getStatus()) {
            case SUCCESSFUL -> "PASSED";
            case FAILED -> "FAILED";
            default -> "SKIPPED";
        };

        outputCallback.accept(className + " > " + displayName + " " + status);

        // 실패 시 에러 메시지도 출력
        if (result.getStatus() == TestExecutionResult.Status.FAILED) {
            result.getThrowable().ifPresent(t ->
                outputCallback.accept("    " + t.getClass().getSimpleName()
                    + ": " + t.getMessage()));
        }
    }

    private String extractClassName(TestIdentifier testIdentifier) {
        if (testPlan != null) {
            return testPlan.getParent(testIdentifier)
                    .map(parent -> parent.getSource()
                            .filter(source -> source instanceof ClassSource)
                            .map(source -> ((ClassSource) source)
                                .getJavaClass().getSimpleName())
                            .orElse(parent.getDisplayName()))
                    .orElse("UnknownClass");
        }
        return "UnknownClass";
    }
}Code language: Java (java)

출력 형식을 Gradle과 동일한 패턴으로 맞춘 이유가 있습니다:

PayloadSizeBoundaryTest > [P0] TC-PS-001-1: 2MB 초과 페이로드 발행 → 브로커 거부/연결 종료 확인 PASSED
PayloadSizeBoundaryTest > [P0] TC-PS-001-2: 10MB 대용량 데이터 반복 테스트 → 서비스 영향 없음 PASSED
SecurityTest > [P0] TC-SE-001-1: 사용자명/비밀번호 없이 연결 → 거부 PASSED

이 형식은 TestRunPane에서 정규식으로 파싱하여 PASSED/FAILED/SKIPPED 카운트를 실시간 업데이트하는 데 사용됩니다.


Markdown 리포트 자동 생성

MarkdownReportListener — SPI 패턴

테스트 실행 후 자동으로 Markdown 리포트를 생성하는 리스너입니다. 두 가지 방식으로 등록됩니다:

flowchart LR subgraph "CLI (gradlew test)" SPI["ServiceLoader SPI"] SPI -->|"자동 등록"| MRL["MarkdownReportListener"] end subgraph "GUI (exe)" REF["리플렉션 생성"] REF -->|"수동 등록"| MRL2["MarkdownReportListener"] end MRL --> RPT["test-report-20260310-143052.md"] MRL2 --> RPT style SPI fill:#4CAF50,color:#fff style REF fill:#FF9800,color:#fff style RPT fill:#2196F3,color:#fff
실행 환경등록 방식설명
gradlew testServiceLoader SPIMETA-INF/services/ 파일로 자동 등록
GUI (exe)리플렉션TestExecutionService에서 수동 생성

리포트에는 35개 TC의 메타데이터가 포함됩니다:

// MarkdownReportListener.java — TC 메타데이터 (35개)
static final Map<String, String[]> TC_META;
static {
    Map<String, String[]> m = new LinkedHashMap<>();
    // [0] = 체크항목명, [1] = 시나리오, [2] = 검증기준
    m.put("TC-PS-001", new String[]{
        "페이로드 초과 (2MB)",
        "AWS IoT Core 최대 페이로드(128KB) 초과 데이터 발행 시 브로커 거부/연결 종료 확인",
        "2MB/10MB 초과 페이로드 → 브로커 거부, 이후 정상 메시지 발행 가능"
    });
    m.put("TC-PF-001", new String[]{
        "유효하지 않은 JSON 형식",
        "불완전/잘못된 JSON 형식 페이로드 발행 시 MQTT 브로커 전달 확인",
        "MQTT 발행 자체는 성공 (브로커는 페이로드 내용 불검증)"
    });
    // ... 33개 더
    TC_META = Collections.unmodifiableMap(m);
}Code language: Java (java)

@DisplayName 패턴을 정규식으로 파싱하여 카테고리별로 결과를 분류합니다:

// @DisplayName 파싱: "[P0] TC-PS-001-1: 2MB 초과 페이로드..."
private static final Pattern DISPLAY_NAME_PATTERN =
    Pattern.compile("^\\[(P\\d)\\]\\s+(TC-([A-Z]{2})-(\\d{3}))-(\\d+):\\s+(.+)$");

@Override
public void executionFinished(TestIdentifier testIdentifier, TestExecutionResult result) {
    if (!testIdentifier.isTest()) return;

    String displayName = testIdentifier.getDisplayName();
    Matcher matcher = DISPLAY_NAME_PATTERN.matcher(displayName);
    if (!matcher.matches()) return;

    String priority = matcher.group(1);      // "P0"
    String tcId = matcher.group(2);          // "TC-PS-001"
    String categoryCode = matcher.group(3);  // "PS"
    String subNumber = matcher.group(5);     // "1"
    String description = matcher.group(6);   // "2MB 초과 페이로드..."

    // ConcurrentHashMap으로 스레드 안전하게 카테고리별 결과 수집
    CategoryResult category = categories.computeIfAbsent(
        categoryCode, code -> new CategoryResult(code, CATEGORY_NAMES.get(code))
    );
    category.addResult(new TestResultData(
        tcId, subNumber, priority, categoryCode,
        description, methodName, status, durationMs, failureMessage
    ));
}Code language: Java (java)

Fat JAR + jpackage 패키징

guiJar — 의존성 병합의 도전

Fat JAR(Uber JAR)을 만들 때 가장 큰 문제는 spring.factories 파일 충돌입니다:

flowchart TB subgraph "문제: DuplicatesStrategy.EXCLUDE" J1["spring-boot-autoconfigure.jar<br>spring.factories ✅ 채택"] J2["spring-boot-test.jar<br>spring.factories ❌ 누락!"] J3["spring-boot.jar<br>spring.factories ❌ 누락!"] end subgraph "해결: mergeSpringFactories" M["병합된 spring.factories<br>(모든 JAR의 항목 포함)"] end J1 -->|"DuplicatesStrategy.EXCLUDE"| FAT["Fat JAR ❌"] M --> FAT2["Fat JAR ✅"] style FAT fill:#F44336,color:#fff style FAT2 fill:#4CAF50,color:#fff style M fill:#2196F3,color:#fff

여러 JAR에 동일한 META-INF/spring.factories 파일이 있으면, DuplicatesStrategy.EXCLUDE첫 번째 것만 포함하고 나머지는 버립니다. 이러면 Spring Boot의 AutoConfiguration이 깨집니다.

해결책으로 mergeSpringFactories 태스크를 만들었습니다:

// build.gradle — spring.factories 병합 태스크
tasks.register('mergeSpringFactories') {
    def outputDir = layout.buildDirectory.dir("merged-spring-factories/META-INF")
    outputs.dir(outputDir)

    doLast {
        def mergedDir = outputDir.get().asFile
        mergedDir.mkdirs()

        // key → LinkedHashSet<value> 병합 맵
        def factoriesMap = new LinkedHashMap<String, LinkedHashSet<String>>()

        // 모든 의존성 JAR에서 spring.factories 수집
        (configurations.runtimeClasspath + configurations.testRuntimeClasspath).each { file ->
            if (file.name.endsWith('.jar') && file.exists()) {
                try {
                    def jar = new java.util.jar.JarFile(file)
                    def entry = jar.getEntry("META-INF/spring.factories")
                    if (entry != null) {
                        def content = jar.getInputStream(entry).getText('UTF-8')
                        parseSpringFactories(content, factoriesMap)
                    }
                    jar.close()
                } catch (Exception ignored) {}
            }
        }

        // 병합된 spring.factories 작성
        def sb = new StringBuilder()
        factoriesMap.each { key, values ->
            sb.append("${key}=\\\n")
            sb.append(values.collect { "  ${it}" }.join(",\\\n"))
            sb.append("\n\n")
        }
        new File(mergedDir, "spring.factories").text = sb.toString()
    }
}Code language: Gradle (gradle)

guiJar — 완전한 Fat JAR 태스크

// build.gradle — Fat JAR 생성
tasks.register('guiJar', Jar) {
    dependsOn classes, testClasses, mergeSpringFactories
    archiveBaseName = 'mqtt-test-tool'

    manifest {
        attributes 'Main-Class': 'com.mqtttest.gui.TestToolLauncher'
    }

    // main + test 클래스 포함
    from sourceSets.main.output
    from sourceSets.test.output

    // 💡 병합된 spring.factories를 먼저 추가 (EXCLUDE에서 우선)
    from layout.buildDirectory.dir("merged-spring-factories")

    // 모든 의존성 JAR 풀어서 포함
    from {
        (configurations.runtimeClasspath + configurations.testRuntimeClasspath).collect {
            it.isDirectory() ? it : zipTree(it)
        }
    }
    duplicatesStrategy = DuplicatesStrategy.EXCLUDE

    // 서명 파일 제외 (충돌 방지)
    exclude 'META-INF/*.SF', 'META-INF/*.DSA', 'META-INF/*.RSA', 'module-info.class'

    // 💡 ServiceLoader 자동 등록 방지 (GUI에서는 수동 등록으로 대체)
    exclude 'META-INF/services/org.junit.platform.launcher.TestExecutionListener'

    // 💡 Mockito/ByteBuddy 제외 — jpackage 환경 비호환
    exclude 'org/mockito/**', 'mockito-extensions/**'
    exclude 'net/bytebuddy/**', 'META-INF/maven/net.bytebuddy/**'
    exclude 'org/springframework/boot/test/mock/mockito/**'
}Code language: Gradle (gradle)

여기서 세 가지 exclude가 각각 중요한 이유가 있습니다:

1. ServiceLoader 자동 등록 방지

exclude 'META-INF/services/org.junit.platform.launcher.TestExecutionListener'Code language: JavaScript (javascript)

MarkdownReportListener가 SPI로 자동 등록되면, GUI에서 수동으로 등록한 것과 중복 실행됩니다. Fat JAR에서는 SPI 파일을 제외하고, TestExecutionService에서 리플렉션으로 수동 등록합니다.

2. Mockito/ByteBuddy 제외

exclude 'org/mockito/**', 'net/bytebuddy/**'Code language: JavaScript (javascript)

이것은 가장 고통스러운 삽질이었습니다. jpackage로 만든 exe에는 java.exe가 없고 독자적인 런타임을 사용하는데, ByteBuddy가 Agent를 attach하려고 java.exe를 찾다가 실패합니다. 그 결과:

// 테스트 하나마다 이런 WARN이 출력됨 (약 200줄)
WARNING: ByteBuddy agent attach failed
net.bytebuddy.agent.ByteBuddyAgent$AttachmentProvider$...
    at net.bytebuddy...
    ...
// x 74개 테스트 = 약 6000줄의 WARN 로그 🤯Code language: JavaScript (javascript)

ResetMocksTestExecutionListener가 매 테스트마다 Mockito를 초기화하려 하면서 ByteBuddy WARN이 발생하는 거예요. Mockito를 아예 제외하면 문제가 깨끗하게 해결됩니다.

packageExe — jpackage로 독립 실행형 exe

// build.gradle — jpackage exe 생성
tasks.register('packageExe', Exec) {
    dependsOn 'guiJar'

    def javaHome = System.getProperty('java.home')
    def jpackageExe = "${javaHome}/bin/jpackage"

    commandLine jpackageExe,
        '--type', 'app-image',
        '--name', 'test_tool',
        '--input', "${buildDir}/libs",
        '--main-jar', 'mqtt-test-tool.jar',
        '--main-class', 'com.mqtttest.gui.TestToolLauncher',
        '--app-version', '1.0.0',
        '--dest', "${buildDir}/package",
        '--java-options', '--add-opens=java.base/java.lang=ALL-UNNAMED',
        '--java-options', '--add-opens=java.base/java.lang.reflect=ALL-UNNAMED',
        '--java-options', '--add-opens=java.base/java.util=ALL-UNNAMED',
        '--java-options', '--add-opens=java.base/java.io=ALL-UNNAMED'
}Code language: Gradle (gradle)

--add-opens 옵션들은 Spring Boot가 리플렉션으로 내부 클래스에 접근하는 데 필요해요. Java 17의 모듈 시스템이 기본적으로 리플렉션 접근을 차단하기 때문입니다.

빌드 파이프라인 전체 흐름:

flowchart LR C["classes"] --> GJ["guiJar"] TC["testClasses"] --> GJ MSF["mergeSpringFactories"] --> GJ GJ -->|"mqtt-test-tool.jar"| PE["packageExe"] PE -->|"test_tool/"| DE["deployExe"] GJ -.->|"~45MB"| SIZE1["Fat JAR"] PE -.->|"~165MB"| SIZE2["JRE 내장 exe"] style GJ fill:#4CAF50,color:#fff style PE fill:#2196F3,color:#fff style DE fill:#FF9800,color:#fff
# 빌드 순서
./gradlew guiJar      # Fat JAR 생성 (~45MB)
./gradlew packageExe   # jpackage exe 생성 (~165MB, JRE 포함)
./gradlew deployExe    # 프로젝트 루트 test_tool/ 디렉토리에 배포Code language: Bash (bash)

☕ 165MB가 크다고 느낄 수 있지만, JDK 17 런타임이 통째로 포함된 것이기 때문에 사용자 PC에 Java가 설치되어 있지 않아도 실행됩니다. 이것이 jpackage의 핵심 가치입니다.

MQTT test tool 초기화면

삽질 기록

1. Spring Factories 중복 등록 문제

증상: Fat JAR 실행 시 NoSuchBeanDefinitionException 또는 AutoConfiguration 누락

원인: 여러 JAR에 META-INF/spring.factories가 있는데, DuplicatesStrategy.EXCLUDE가 첫 번째 것만 포함

해결: mergeSpringFactories 태스크로 모든 JAR의 factories를 하나로 병합

2. ServiceLoader 자동 등록 방지

증상: MarkdownReportListener가 2번 실행되어 리포트가 이상해짐

원인: SPI(META-INF/services/)로 한 번, TestExecutionService에서 리플렉션으로 한 번 — 중복 등록

해결: Fat JAR에서 SPI 파일을 제외 (exclude 'META-INF/services/...')

3. Mockito ByteBuddy WARN 스팸 (6000줄 로그)

증상: 테스트 하나마다 ByteBuddy 스택트레이스 ~200줄 출력

원인: jpackage 환경에서 java.exe가 없어 ByteBuddy agent attach 실패 → ResetMocksTestExecutionListener가 매번 시도

해결: Mockito 관련 패키지 전체 제외 (exclude 'org/mockito/**', 'net/bytebuddy/**')

4. Windows PowerShell 변수 확장 이슈

증상: Bash 명령에서 $variable이 PowerShell에 의해 미리 확장됨

원인: Windows에서 Git Bash 대신 PowerShell이 기본 셸일 때 발생

해결: powershell.exe -NoProfile -Command를 사용하거나, 변수를 작은따옴표로 감싸기

5. jpackage Context ClassLoader 문제

증상: Spring이 @Component 클래스를 찾지 못함

원인: jpackage 런타임의 ClassLoader가 시스템 ClassLoader와 다름

해결: 테스트 실행 스레드에서 Thread.currentThread().setContextClassLoader(getClass().getClassLoader()) 설정


최종 결과물

항목
테스트 케이스35 TC / 74 메서드
카테고리6개 (Payload, Topic, Connection, MessageFlow, Security, Report)
GUI 탭4개 (환경설정, 파라미터, 실행, 결과)
Fat JAR 크기~45MB
exe 크기~165MB (JRE 내장)
테스트 파라미터23개 (환경변수/GUI로 조절)
리포트 형식Markdown (TC별 PASS/FAIL/SKIP + 메타데이터)

실행 방법:

# 개발 환경 (CLI)
./gradlew test          # 전체 테스트
./gradlew testP0        # P0만
./gradlew runGui        # GUI 실행

# 배포 환경 (exe)
test_tool/test_tool.exe  # 더블클릭으로 실행Code language: PHP (php)

마무리 — 핵심 3줄 요약

  1. JUnit Platform Launcher API로 프로세스 내 테스트 실행이 가능하다 — Gradle 없이 Fat JAR 내부에서 직접 JUnit 테스트를 실행하고, 리스너로 실시간 결과를 GUI에 스트리밍할 수 있습니다.
  2. Fat JAR 패키징은 spring.factories 병합이 핵심이다DuplicatesStrategy.EXCLUDE만으로는 Spring AutoConfiguration이 깨지므로, 모든 JAR의 spring.factories를 직접 병합하는 태스크가 필수입니다.
  3. jpackage는 "Java 몰라도 실행 가능한 exe"를 만들어 준다 — JRE를 내장하면 165MB로 커지지만, 사용자 환경에 아무것도 설치하지 않아도 되는 독립 실행형 앱을 만들 수 있습니다. 다만 Mockito/ByteBuddy 호환성에 주의가 필요합니다.

시리즈 완결: Part 1: 설계와 인프라Part 2: 35개 테스트 케이스 → Part 3: GUI와 배포 3편에 걸쳐 MQTT 테스트 자동화 도구의 전체 개발 과정을 정리했습니다. IoT 프로젝트에서 MQTT 테스트 자동화를 검토 중이라면, 이 시리즈가 참고가 되길 바랍니다. 비슷한 경험이나 다른 접근법이 있다면 댓글로 공유해 주시면 감사하겠습니다.

Y

yshyuk

Java 백엔드 개발자 | Spring, AWS, DevOps
2020년부터 Java/Spring boot 서버 개발자로 일하면서, 인프라(AWS/NCP), DevOps 업무를 병행하였고, 현재는 OpenSource 기여에도 관심을 가지고 있습니다.

조회수: 1