시리즈: Part 1: 설계와 인프라 · Part 2: 35개 테스트 케이스 · Part 3: GUI와 배포
Table of Contents
CLI에서 GUI로 전환한 이유
Part 1과 Part 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 구조
// 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에서 테스트를 실행하는 방법은 두 가지가 있습니다:
- Gradle 서브프로세스 —
gradlew testSelected호출 - 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 리플렉션 생성
MarkdownReportListener는 test 소스셋에 있어서 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 리포트를 생성하는 리스너입니다. 두 가지 방식으로 등록됩니다:
| 실행 환경 | 등록 방식 | 설명 |
|---|---|---|
gradlew test | ServiceLoader SPI | META-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 파일 충돌입니다:
여러 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의 모듈 시스템이 기본적으로 리플렉션 접근을 차단하기 때문입니다.
빌드 파이프라인 전체 흐름:
# 빌드 순서
./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의 핵심 가치입니다.

삽질 기록
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줄 요약
- JUnit Platform Launcher API로 프로세스 내 테스트 실행이 가능하다 — Gradle 없이 Fat JAR 내부에서 직접 JUnit 테스트를 실행하고, 리스너로 실시간 결과를 GUI에 스트리밍할 수 있습니다.
- Fat JAR 패키징은 spring.factories 병합이 핵심이다 —
DuplicatesStrategy.EXCLUDE만으로는 Spring AutoConfiguration이 깨지므로, 모든 JAR의spring.factories를 직접 병합하는 태스크가 필수입니다. - jpackage는 "Java 몰라도 실행 가능한 exe"를 만들어 준다 — JRE를 내장하면 165MB로 커지지만, 사용자 환경에 아무것도 설치하지 않아도 되는 독립 실행형 앱을 만들 수 있습니다. 다만 Mockito/ByteBuddy 호환성에 주의가 필요합니다.
시리즈 완결: Part 1: 설계와 인프라 → Part 2: 35개 테스트 케이스 → Part 3: GUI와 배포 3편에 걸쳐 MQTT 테스트 자동화 도구의 전체 개발 과정을 정리했습니다. IoT 프로젝트에서 MQTT 테스트 자동화를 검토 중이라면, 이 시리즈가 참고가 되길 바랍니다. 비슷한 경험이나 다른 접근법이 있다면 댓글로 공유해 주시면 감사하겠습니다.
조회수: 1