MQTT 테스트 자동화 도구 개발기 (2) — 35개 테스트 케이스 구현기

"☕" 15 "min read"

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

테스트 설계 원칙

Part 1에서 구축한 인프라 위에 35개 테스트 케이스를 구현했습니다. 먼저 설계 원칙부터 짚고 가겠습니다:

  1. 경계값 분석 — 정확히 최대(128KB), 최대-1, 최대+1을 테스트
  2. 발행 + 수신 검증 — publish만이 아니라 subscriber가 실제로 받았는지 확인
  3. 연결 복원력 — 비정상 입력 후에도 서비스가 계속 동작하는지 검증
  4. 실제 텔레메트리 포맷 — 테스트 데이터를 프로덕션과 동일한 JSON 구조로 작성

카테고리 전체 맵

flowchart TB subgraph "35 Test Cases" direction TB subgraph PS["페이로드 크기 (3 TC / 8건)"] PS1["TC-PS-001: 2MB+ 초과"] PS2["TC-PS-002: 빈 페이로드"] PS3["TC-PS-003: 128KB 경계값"] end subgraph PF["페이로드 형식 (6 TC / 18건)"] PF1["TC-PF-001: 잘못된 JSON"] PF2["TC-PF-002: 500단계 중첩"] PF3["TC-PF-003: NULL/특수문자"] PF4["TC-PF-004: 유니코드/이모지"] PF5["TC-PF-005: 바이너리"] PF6["TC-PF-006: 타입 불일치"] end subgraph TA["토픽 이상값 (6 TC / 13건)"] TA1["TC-TA-001: 길이 초과"] TA2["TC-TA-002: 빈 토픽"] TA3["TC-TA-003: 슬래시 토픽"] TA4["TC-TA-004: 와일드카드 발행"] TA5["TC-TA-005: $SYS 토픽"] TA6["TC-TA-006: ACL 위반"] end subgraph CS["연결/세션 (6 TC / 10건)"] CS1["TC-CS-001: 중복 Client ID"] CS2["TC-CS-002: 빈 Client ID"] CS3["TC-CS-003: 긴 Client ID"] CS4["TC-CS-004: 잘못된 자격증명"] CS5["TC-CS-005: Keep-Alive 초과"] CS6["TC-CS-006: Clean Session"] end subgraph MF["메시지 흐름 (7 TC / 11건)"] MF1["TC-MF-001: QoS 2 거부"] MF2["TC-MF-002: Burst 100msg/s"] MF3["TC-MF-003: Soak 60초"] MF4["TC-MF-004: 동시 발행"] MF5["TC-MF-005: Retain"] MF6["TC-MF-006: LWT"] MF7["TC-MF-007: QoS 1 보장"] end subgraph SE["보안 (7 TC / 14건)"] SE1["TC-SE-001: 익명 연결"] SE2["TC-SE-002: 만료 인증서"] SE3["TC-SE-003: SQL/NoSQL 인젝션"] SE4["TC-SE-004: XSS"] SE5["TC-SE-005: 비MQTT 프로토콜"] SE6["TC-SE-006: 평문 연결"] SE7["TC-SE-007: 변조 패킷"] end end style PS fill:#E3F2FD,stroke:#1565C0 style PF fill:#E8F5E9,stroke:#2E7D32 style TA fill:#FFF8E1,stroke:#F9A825 style CS fill:#FFF3E0,stroke:#EF6C00 style MF fill:#FFEBEE,stroke:#C62828 style SE fill:#F3E5F5,stroke:#6A1B9A
카테고리TC 수테스트 메서드우선순위 분포
페이로드 크기38P0: 3, P1: 5
페이로드 형식618P0: 9, P1: 5, P2: 4
토픽 이상값613P0: 2, P1: 11
연결/세션610P0: 3, P1: 7
메시지 흐름711P0: 5, P1: 6
보안714P0: 8, P1: 6
합계3574P0: 30, P1: 40, P2: 4

페이로드 크기 경계값 (TC-PS)

가장 기본적이면서도 중요한 테스트입니다. AWS IoT Core의 최대 페이로드는 128KB (131,072 bytes)입니다.

TC-PS-001: 초과 페이로드 거부 확인

// PayloadSizeBoundaryTest.java — 2MB 초과 페이로드 발행
@Test
@Tag("P0")
@DisplayName("[P0] TC-PS-001-1: 2MB 초과 페이로드 발행 → 브로커 거부/연결 종료 확인")
void testOversizedPayload2MB() {
    // AWS IoT Core 최대 페이로드: 128KB → 이것보다 훨씬 큰 데이터
    byte[] oversizedPayload = TestDataGenerator.generatePayloadOfSize(
        testParams.getOversizedPayloadBytes() + 1  // 기본값: 2MB + 1byte
    );

    MqttException exception = assertThrows(MqttException.class, () -> {
        publish(publisher, testTopic, oversizedPayload);
    }, "2MB 초과 페이로드는 브로커에 의해 거부되어야 합니다");

    log.info("예상대로 예외 발생: {}", exception.getMessage());
}Code language: Java (java)

TC-PS-003: 128KB 경계값 테스트

경계값 테스트의 핵심은 -1, 정확히, +1 세 가지를 모두 검증하는 것입니다:

// 최대 -1 byte → 반드시 성공
@Test
@DisplayName("[P1] TC-PS-003-1: 최대 허용 -1 byte 페이로드 → 정상 수신")
void testMaxPayloadMinusOne() throws Exception {
    int maxSize = 128 * 1024; // 131,072 bytes
    byte[] payload = TestDataGenerator.generatePayloadOfSize(maxSize - 1);

    List<MqttMessage> received = startCollecting(subscriber, testTopic);
    publish(publisher, testTopic, payload);

    assertTrue(waitForMessages(received, 1, testParams.getExtendedWaitTimeout()),
        "최대 -1 byte 페이로드는 구독자가 수신해야 합니다");
    assertEquals(maxSize - 1, received.get(0).getPayload().length,
        "수신된 페이로드 크기 검증");
}

// 정확히 최대 → 성공 또는 정책에 따라 거부 가능
@Test
@DisplayName("[P1] TC-PS-003-2: 정확히 최대 허용 페이로드 → 정상 수신")
void testExactMaxPayload() throws Exception {
    int maxSize = 128 * 1024;
    byte[] payload = TestDataGenerator.generatePayloadOfSize(maxSize);

    List<MqttMessage> received = startCollecting(subscriber, testTopic);
    try {
        publish(publisher, testTopic, payload);
        boolean messageReceived = waitForMessages(received, 1, testParams.getExtendedWaitTimeout());
        if (messageReceived) {
            assertEquals(maxSize, received.get(0).getPayload().length);
        }
    } catch (MqttException e) {
        log.info("정확히 최대 크기에서 거부됨: {}", e.getMessage());
        // 브로커 정책에 따라 성공 또는 실패 가능
    }
}

// 최대 +1 byte → 반드시 거부
@Test
@DisplayName("[P1] TC-PS-003-3: 최대 허용 +1 byte 페이로드 → 거부 또는 연결 끊김")
void testMaxPayloadPlusOne() throws Exception {
    int maxSize = 128 * 1024;
    byte[] payload = TestDataGenerator.generatePayloadOfSize(maxSize + 1);

    List<MqttMessage> received = startCollecting(subscriber, testTopic);
    try {
        publish(publisher, testTopic, payload);
        sleep(testParams.getPostOperationSleepMs());
    } catch (MqttException e) {
        log.info("최대 +1 byte에서 예외 발생 (예상 동작): {}", e.getMessage());
    }

    // 초과 메시지는 구독자에게 전달되지 않아야 함
    boolean messageReceived = waitForMessages(received, 1, testParams.getGeneralWaitTimeout());
    if (!messageReceived) {
        log.info("초과 페이로드가 구독자에게 전달되지 않음 (예상 동작)");
    }
}Code language: Java (java)

흥미로운 점: 정확히 128KB(131,072 bytes)를 보내면 어떻게 될까요? 실제로 테스트해보면 AWS IoT Core는 정확히 128KB까지는 허용하더라고요. 하지만 +1 byte만 넘어가면 바로 연결을 끊어버립니다. 이 경계가 byte 단위로 정확하다는 걸 테스트로 확인한 것입니다.


페이로드 형식/인코딩 (TC-PF)

여기가 가장 흥미로운 카테고리입니다. MQTT 브로커는 페이로드 내용을 검증하지 않는다는 사실을 확인할 수 있습니다.

TC-PF-001: 잘못된 JSON도 그대로 전달된다

// PayloadFormatEncodingTest.java — 불완전한 JSON 발행
@Test
@Tag("P0")
@DisplayName("[P0] TC-PF-001-1: 불완전한 JSON 발행 → 브로커는 전달, 서버 파싱 에러 처리")
void testInvalidJsonUnclosed() throws Exception {
    // 닫히지 않은 중괄호: {"device_timestamp": 1234, "data": {"value":
    String invalidJson = TestDataGenerator.invalidJsonUnclosed();

    List<MqttMessage> received = startCollecting(subscriber, testTopic);
    publish(publisher, testTopic, invalidJson);

    // 💡 불완전한 JSON이라도 MQTT 브로커는 구독자에게 그대로 전달!
    assertTrue(waitForMessages(received, 1, testParams.getGeneralWaitTimeout()),
        "불완전한 JSON이라도 MQTT 브로커는 구독자에게 전달해야 합니다");
    String receivedPayload = new String(received.get(0).getPayload(), StandardCharsets.UTF_8);
    assertEquals(invalidJson, receivedPayload, "불완전한 JSON 데이터 무결성 확인");
}Code language: Java (java)

TestDataGenerator에는 5가지 유형의 잘못된 JSON을 생성하는 메서드가 있습니다:

// TestDataGenerator.java — 잘못된 JSON 생성기들
public class TestDataGenerator {

    /** 닫히지 않은 중괄호 */
    public static String invalidJsonUnclosed() {
        return "{\"device_timestamp\": " + System.currentTimeMillis()
             + ", \"data\": {\"value\": ";
    }

    /** 키만 있고 값 없음 */
    public static String invalidJsonKeyOnly() {
        return "{\"device_timestamp\": , \"data\": {}}";
    }

    /** 배열 구문 오류 */
    public static String invalidJsonMissingArray() {
        return "{\"device_timestamp\": " + System.currentTimeMillis()
             + ", \"data\": {\"items\": [1, 2, }}";
    }

    /** 따옴표 누락 */
    public static String invalidJsonMissingQuotes() {
        return "{device_timestamp: " + System.currentTimeMillis() + ", data: {}}";
    }

    /** 후행 쉼표 */
    public static String invalidJsonTrailingComma() {
        return "{\"device_timestamp\": " + System.currentTimeMillis()
             + ", \"data\": {},}";
    }
}Code language: Java (java)

⚠️ 이것이 중요한 이유: MQTT 브로커가 JSON 유효성을 검사하지 않기 때문에, 수신 측(서버 애플리케이션)에서 반드시 파싱 에러를 처리해야 합니다. 이 테스트는 "브로커가 거부하겠지"라는 잘못된 가정을 깨뜨려 줍니다.

TC-PF-002: 500단계 중첩 JSON

// TestDataGenerator.java — N단계 중첩 JSON 생성
public static String deeplyNestedJson(int depth) {
    StringBuilder sb = new StringBuilder();
    sb.append("{\"device_timestamp\": ").append(System.currentTimeMillis())
      .append(", \"data\": ");
    for (int i = 0; i < depth; i++) {
        sb.append("{\"level").append(i).append("\":");
    }
    sb.append("\"leaf\"");
    for (int i = 0; i < depth; i++) {
        sb.append("}");
    }
    sb.append("}");
    return sb.toString();
}Code language: Java (java)

200~500단계 중첩 JSON도 MQTT 레벨에서는 문제없이 전달됩니다. 다만 수신 측에서 Jackson 등으로 파싱하면 StackOverflowError가 발생할 수 있습니다.

TC-PF-004: 유니코드와 이모지

4byte UTF-8 문자(이모지)의 무결성 검증도 중요합니다:

// TestDataGenerator.java — 이모지 포함 텔레메트리 데이터
public static String emojiPayload() {
    return "{\"device_timestamp\": " + System.currentTimeMillis() + ", \"data\": {" +
            "\"emoji\": \"😀🎉🔥💯🚀\", " +
            "\"mixed\": \"테스트 🎯 data 📊 결과 ✅\", " +
            "\"complex\": \"👨‍👩‍👧‍👦 가족이모지\"}}";
}Code language: Java (java)
// 이모지 데이터 무결성 검증
@Test
@DisplayName("[P2] TC-PF-004-2: 이모지(4byte UTF-8) 포함 페이로드 발행 및 수신 검증")
void testEmojiPayload() throws Exception {
    String emojiPayload = TestDataGenerator.emojiPayload();

    List<MqttMessage> received = startCollecting(subscriber, testTopic);
    publish(publisher, testTopic, emojiPayload);

    assertTrue(waitForMessages(received, 1, testParams.getGeneralWaitTimeout()));
    String receivedPayload = new String(received.get(0).getPayload(), StandardCharsets.UTF_8);
    assertEquals(emojiPayload, receivedPayload, "이모지 데이터 무결성 확인");
}Code language: Java (java)

☕ 👨‍👩‍👧‍👦 같은 결합 이모지(ZWJ Sequence)는 실제로 25바이트나 됩니다. UTF-8 인코딩을 정확히 처리하지 않으면 중간에 잘리거나 깨질 수 있어서 테스트에 포함시켰습니다.


토픽 이상값 (TC-TA)

TC-TA-001: 토픽 길이 제한

MQTT 표준(65,535자)과 AWS IoT Core(256자)의 이중 제한을 모두 검증합니다:

MQTT 표준의 최대 토픽 길이(65,535자)와 AWS IoT Core의 제한(256자) 두 가지를 모두 검증합니다. "a".repeat(65535) 같은 긴 토픽으로 발행을 시도하면, MQTT 프로토콜 레벨 또는 브로커 레벨에서 거부되는 것을 확인합니다. AWS IoT Core는 256자 초과 토픽에 대해 연결을 끊는 방식으로 응답합니다.

TC-TA-004: 와일드카드로 발행하면?

MQTT에서 와일드카드(+, #)는 구독에서만 사용 가능합니다. 발행 시 사용하면:

// 와일드카드 토픽으로 발행 시도
@Test
@DisplayName("[P0] TC-TA-004-1: + 와일드카드 포함 토픽으로 발행 → 거부")
void testPublishWithSingleWildcard() {
    String wildcardTopic = TestDataGenerator.topicWithSingleLevelWildcard(
        mqttConfig.getTopicPrefix()  // "test/mqtt/+/data"
    );

    assertThrows(Exception.class, () -> {
        publish(publisher, wildcardTopic, normalPayload);
    }, "와일드카드 토픽 발행은 거부되어야 합니다");
}Code language: Java (java)

⚠️ AWS IoT Core는 와일드카드 토픽으로 발행을 시도하면 바로 연결을 끊어버립니다. 예외만 발생하는 게 아니라 연결이 끊기기 때문에, 테스트에서 이후 재연결이 필요했습니다.


연결/세션 (TC-CS)

TC-CS-001: 동일 Client ID 중복 연결

MQTT에서 동일한 Client ID로 두 번째 연결이 들어오면, 첫 번째 연결이 강제 종료됩니다:

// ConnectionSessionTest.java — 동일 Client ID 중복 연결
@Test
@Tag("P0")
@DisplayName("[P0] TC-CS-001-1: 동일 Client ID로 두 번째 연결 → 첫 번째 연결 종료")
void testDuplicateClientId() throws Exception {
    String sharedClientId = "test-duplicate-" + System.currentTimeMillis();
    AtomicBoolean firstClientDisconnected = new AtomicBoolean(false);

    // 첫 번째 클라이언트 연결
    MqttClient client1 = createClientOnly(sharedClientId);
    MqttConnectOptions opts1 = mqttClientManager.createDefaultConnectOptions();
    opts1.setCleanSession(true);
    client1.setCallback(new MqttCallback() {
        @Override
        public void connectionLost(Throwable cause) {
            firstClientDisconnected.set(true);
            log.info("첫 번째 클라이언트 연결 끊김: {}", cause.getMessage());
        }
        // ... messageArrived, deliveryComplete
    });
    client1.connect(opts1);

    // 두 번째 클라이언트 — 같은 Client ID로 연결
    MqttClient client2 = connectClient(sharedClientId);

    sleep(2000);
    assertTrue(firstClientDisconnected.get(),
        "동일 ID로 두 번째 연결 시 첫 번째 연결이 끊겨야 합니다");
    assertTrue(client2.isConnected(),
        "두 번째 클라이언트는 정상 연결 유지");
}Code language: Java (java)

이 동작은 IoT 환경에서 디바이스 교체 시나리오와 관련이 있습니다. 기존 디바이스가 아직 연결된 상태에서 새 디바이스가 같은 ID로 연결하면, 기존 디바이스의 연결이 끊기게 됩니다.

TC-CS-006: Clean Session=false와 오프라인 메시지

테스트 흐름은 4단계입니다:

  1. cleanSession=false로 연결 후 토픽 구독
  2. 구독자 연결 해제 (브로커에 세션 유지)
  3. 오프라인 상태에서 메시지 N건 발행
  4. 동일 Client ID + cleanSession=false로 재연결 → 미수신 메시지 수신 확인

핵심 검증 포인트는 재연결 시 동일한 Client ID와 cleanSession=false를 사용해야 브로커가 이전 세션을 복원하여 미수신 메시지를 전달한다는 것입니다.


메시지 흐름 (TC-MF)

TC-MF-001: QoS 2 — “연결이 끊긴다”

AWS IoT Core에서 QoS 2로 publish하면 어떻게 될까요?

// MessageFlowTest.java — QoS 2 발행 시도
@Test
@Tag("P0")
@DisplayName("[P0] TC-MF-001-1: QoS 2 메시지 발행 시도 → AWS IoT Core 거부 확인")
void testQoS2PublishRejected() throws Exception {
    MqttClient client = connectClient(uniqueClientId());
    String topic = testTopic("messageflow/qos2");

    try {
        MqttMessage msg = new MqttMessage(TestDataGenerator.validSensorJson().getBytes());
        msg.setQos(2); // AWS IoT Core는 QoS 2 미지원
        client.publish(topic, msg);
        sleep(testParams.getPostOperationSleepMs());

        if (!client.isConnected()) {
            log.info("AWS IoT Core가 QoS 2 발행을 거부하고 연결 종료 (예상 동작)");
        }
    } catch (MqttException e) {
        log.info("QoS 2 발행 거부 (예상 동작): {}", e.getMessage());
    }
}Code language: Java (java)

⚠️ 발견: 단순히 "QoS 2 미지원"이라고 문서에 적혀 있지만, 실제 동작은 연결 자체가 끊기는 것이에요. 에러 응답을 보내는 게 아니라 TCP 연결을 바로 닫아버립니다. 이 때문에 QoS 2 시도 후 QoS 1로 재연결하는 폴백 테스트(TC-MF-001-2)도 필수였습니다.

TC-MF-002: 초당 100건 Burst 테스트

// 고빈도 메시지 Burst 발행
@Test
@Tag("P0")
@DisplayName("[P0] TC-MF-002-1: 초당 100건 메시지 Burst 발행 → 처리량 측정")
void testHighFrequencyBurst() throws Exception {
    MqttClient publisher = connectClient(uniqueClientId());
    MqttClient subscriber = connectClient(uniqueClientId());
    String topic = testTopic("messageflow/burst");

    AtomicInteger receivedCount = new AtomicInteger(0);
    subscriber.subscribe(topic, 1, (t, msg) -> receivedCount.incrementAndGet());

    int totalMessages = testParams.getBurstMessageCount(); // 기본값: 100
    long startTime = System.currentTimeMillis();

    int failCount = 0;
    for (int i = 0; i < totalMessages; i++) {
        try {
            String payload = "{\"seq\":" + i + ",\"ts\":" + System.currentTimeMillis() + "}";
            mqttClientManager.publish(publisher, topic, payload.getBytes(), 1, false);
        } catch (MqttException e) {
            failCount++;
        }
    }

    long elapsed = System.currentTimeMillis() - startTime;
    sleep(testParams.getSoakCompletionWaitMs()); // 수신 완료 대기

    log.info("===== Burst 테스트 결과 =====");
    log.info("총 발행: {} 건, 실패: {} 건", totalMessages, failCount);
    log.info("소요 시간: {} ms", elapsed);
    log.info("처리량: {} msg/sec", (totalMessages - failCount) * 1000.0 / elapsed);
    log.info("수신율: {}%", receivedCount.get() * 100.0 / totalMessages);

    assertTrue(publisher.isConnected(), "Burst 후에도 연결 유지");
}Code language: Java (java)

TC-MF-003: 60초 Soak 테스트

장시간 안정성 검증을 위한 Soak 테스트입니다:

@Test
@Tag("P1")
@DisplayName("[P1] TC-MF-003-1: 60초 지속 발행 Soak 테스트 (초당 10건)")
void testSoakTest60Seconds() throws Exception {
    MqttClient publisher = connectClient(uniqueClientId());
    String topic = testTopic("messageflow/soak");

    int durationSeconds = testParams.getSoakDurationSeconds(); // 기본값: 60
    int ratePerSecond = testParams.getSoakRatePerSecond();     // 기본값: 10
    AtomicInteger successCount = new AtomicInteger(0);
    AtomicInteger failCount = new AtomicInteger(0);
    AtomicLong totalLatency = new AtomicLong(0);

    long endTime = System.currentTimeMillis() + (durationSeconds * 1000L);

    while (System.currentTimeMillis() < endTime) {
        long sendStart = System.currentTimeMillis();
        try {
            String payload = "{\"ts\":" + System.currentTimeMillis() + "}";
            mqttClientManager.publish(publisher, topic, payload.getBytes(), 1, false);
            totalLatency.addAndGet(System.currentTimeMillis() - sendStart);
            successCount.incrementAndGet();
        } catch (Exception e) {
            failCount.incrementAndGet();
        }

        // 초당 ratePerSecond 유지
        long sleepTime = (1000 / ratePerSecond) - (System.currentTimeMillis() - sendStart);
        if (sleepTime > 0) sleep(sleepTime);
    }

    log.info("===== Soak 테스트 결과 =====");
    log.info("성공: {} 건, 실패: {} 건", successCount.get(), failCount.get());
    log.info("에러율: {}%", failCount.get() * 100.0 / (successCount.get() + failCount.get()));

    assertTrue(publisher.isConnected(), "Soak 테스트 후에도 연결 유지");
    assertTrue(failCount.get() <= successCount.get() * 0.001, "에러율 0.1% 이하");
}Code language: Java (java)

TC-MF-006: LWT (Last Will and Testament)

클라이언트가 비정상 종료되면 브로커가 미리 등록된 "유언" 메시지를 발행하는 기능입니다:

sequenceDiagram participant Sub as LWT 구독자 participant Broker as AWS IoT Core participant Client as MQTT 클라이언트 Client->>Broker: CONNECT (LWT 메시지 등록) Broker->>Client: CONNACK Sub->>Broker: SUBSCRIBE (LWT 토픽) Note over Client: 비정상 종료! Client--xBroker: TCP 연결 끊김 (DISCONNECT 미전송) Note over Broker: Keep-Alive × 1.5 대기 Broker->>Sub: PUBLISH (LWT 메시지) Note over Sub: "Client disconnected unexpectedly"
// LWT 메시지 테스트
@Test
@Tag("P0")
@DisplayName("[P0] TC-MF-006-1: LWT 메시지 설정 후 비정상 종료 → LWT 발행 확인")
void testLwtMessageOnAbruptDisconnect() throws Exception {
    String lwtTopic = testTopic("messageflow/lwt/" + System.currentTimeMillis());
    String lwtPayload = TestDataGenerator.lwtMessage("lwt-test-client");

    // LWT 구독자 설정
    MqttClient lwtSubscriber = connectClient(uniqueClientId());
    CountDownLatch latch = new CountDownLatch(1);
    final String[] receivedLwt = {null};
    lwtSubscriber.subscribe(lwtTopic, 1, (t, msg) -> {
        receivedLwt[0] = new String(msg.getPayload());
        latch.countDown();
    });

    // LWT 포함하여 연결
    MqttConnectOptions opts = mqttClientManager.createDefaultConnectOptions();
    opts.setWill(lwtTopic, lwtPayload.getBytes(), 1, false);
    opts.setKeepAliveInterval(30);

    MqttClient lwtClient = createClientOnly(uniqueClientId());
    lwtClient.connect(opts);

    // 비정상 종료 (DISCONNECT 미전송)
    lwtClient.disconnectForcibly(0, 0, false);

    // LWT 발행 대기 (Keep-Alive × 1.5 이내)
    boolean lwtReceived = latch.await(testParams.getLwtWaitTimeout(), TimeUnit.SECONDS);
    if (lwtReceived) {
        assertNotNull(receivedLwt[0], "LWT 메시지가 수신되어야 합니다");
    }
}Code language: Java (java)

보안 (TC-SE)

TC-SE-001: 익명 연결은 어떻게 거부되나?

// SecurityTest.java — 인증서 없이 연결 시도
@Test
@Tag("P0")
@DisplayName("[P0] TC-SE-001-1: 사용자명/비밀번호 없이 연결 → 거부")
void testAnonymousConnectionNoCredentials() {
    try {
        // TLS는 사용하되 클라이언트 인증서 없이 연결
        SSLContext sslContext = SSLContext.getInstance("TLSv1.2");
        sslContext.init(null, null, null); // 인증서 없음!

        MqttConnectOptions opts = new MqttConnectOptions();
        opts.setCleanSession(true);
        opts.setSocketFactory(sslContext.getSocketFactory());
        opts.setConnectionTimeout(10);

        MqttClient client = new MqttClient(
            mqttConfig.getBrokerUrl(), uniqueClientId(), new MemoryPersistence());
        client.connect(opts);

        fail("인증서 없이 연결이 성공하면 안됩니다");
    } catch (Exception e) {
        log.info("익명 연결 거부 확인: {}", e.getMessage());
        // SSLHandshakeException 또는 MqttException 발생
    }
}Code language: Java (java)

TC-SE-003: SQL 인젝션 패턴

MQTT 브로커 자체는 SQL 인젝션에 취약하지 않지만, 수신 측 애플리케이션이 페이로드를 DB에 저장할 때 문제가 될 수 있습니다:

// TestDataGenerator.java — SQL 인젝션 패턴 생성
public static List<String> sqlInjectionPayloads() {
    long ts = System.currentTimeMillis();
    return Arrays.asList(
        "{\"device_timestamp\": " + ts + ", \"data\": {\"name\": \"' OR '1'='1\"}}",
        "{\"device_timestamp\": " + ts + ", \"data\": {\"name\": \"'; DROP TABLE devices; --\"}}",
        "{\"device_timestamp\": " + ts + ", \"data\": {\"id\": \"1 UNION SELECT * FROM users\"}}",
        "{\"device_timestamp\": " + ts + ", \"data\": {\"query\": \"admin'--\"}}",
        "{\"device_timestamp\": " + ts + ", \"data\": {\"value\": \"1; EXEC xp_cmdshell('dir')\"}}",
        "{\"device_timestamp\": " + ts + ", \"data\": {\"field\": \"' OR 1=1; --\"}}"
    );
}Code language: PHP (php)
// SQL 인젝션 패턴 발행 — MQTT에서는 통과, 수신 측에서 무력화 확인
@Test
@DisplayName("[P1] TC-SE-003-1: SQL 인젝션 패턴 포함 페이로드 발행 → 무력화 확인")
void testSqlInjectionPattern() throws Exception {
    List<String> injectionPayloads = TestDataGenerator.sqlInjectionPayloads();

    List<MqttMessage> received = startCollecting(subscriber, testTopic);

    for (int i = 0; i < injectionPayloads.size(); i++) {
        publish(publisher, testTopic, injectionPayloads.get(i));
    }

    // 💡 모든 인젝션 패턴이 그대로 전달됨 — 서버 측 무력화 필수!
    assertTrue(waitForMessages(received, injectionPayloads.size(),
        testParams.getExtendedWaitTimeout()),
        "SQL 인젝션 패턴 모두 구독자가 수신해야 합니다");
}Code language: Java (java)

TC-SE-005: MQTT 포트에 HTTP를 보내면?

// 비MQTT 프로토콜 전송
@Test
@DisplayName("[P1] TC-SE-005-1: MQTT 포트에 HTTP 요청 전송 → 거부/무시")
void testNonMqttProtocolHttp() {
    try (Socket socket = new Socket(mqttConfig.getEndpoint(), mqttConfig.getPort())) {
        OutputStream out = socket.getOutputStream();
        String httpRequest = "GET / HTTP/1.1\r\nHost: " + mqttConfig.getEndpoint() + "\r\n\r\n";
        out.write(httpRequest.getBytes());
        out.flush();
        // 브로커는 비MQTT 데이터를 거부하거나 연결을 닫음
    } catch (Exception e) {
        log.info("비MQTT 프로토콜 거부 확인: {}", e.getMessage());
    }
}Code language: Java (java)

TestDataGenerator — 테스트 데이터 팩토리

모든 테스트 데이터는 TestDataGenerator에 집중시켰습니다. 프로덕션 텔레메트리 포맷(device_timestamp + data 구조)을 기반으로 작성해서, 실제 디바이스가 보낼 수 있는 데이터와 최대한 비슷하게 만들었습니다:

public class TestDataGenerator {

    /** 프로덕션 텔레메트리 토픽 */
    public static final String TELEMETRY_TOPIC =
        "ceslink/prd/nohub/172510010056/periodic/pub/telemetry/json";

    /** 지정된 크기(bytes)의 더미 페이로드 */
    public static byte[] generatePayloadOfSize(int sizeBytes) {
        byte[] data = new byte[sizeBytes];
        Arrays.fill(data, (byte) 'A');
        return data;
    }

    /** 랜덤 바이너리 데이터 */
    public static byte[] randomBinaryData(int size) {
        byte[] data = new byte[size];
        new SecureRandom().nextBytes(data);
        return data;
    }

    /** 표준 텔레메트리 JSON (정상 기준 데이터) */
    public static String validSensorJson() {
        return "{\"device_timestamp\": " + System.currentTimeMillis() + ", \"data\": {" +
                "\"temperature\": 23.5, " +
                "\"humidity\": 65, " +
                "\"active\": true, " +
                "\"tags\": [\"indoor\", \"floor-3\"], " +
                "\"location\": {\"lat\": 37.5665, \"lng\": 126.9780}}}";
    }

    /** LWT 메시지 */
    public static String lwtMessage(String clientId) {
        return "{\"type\": \"lwt\", \"clientId\": \"" + clientId + "\", " +
                "\"message\": \"Client disconnected unexpectedly\", " +
                "\"timestamp\": " + System.currentTimeMillis() + "}";
    }
}Code language: Java (java)

테스트 데이터를 별도 클래스로 분리한 이유:

  1. 재사용성 — 여러 테스트 클래스에서 동일한 데이터 생성기 사용
  2. 일관성 — 모든 테스트 데이터가 프로덕션 텔레메트리 포맷 기반
  3. 가독성 — 테스트 코드에서 데이터 생성 로직이 분리되어 의도가 명확

흥미로운 발견들

1. MQTT 브로커는 페이로드를 “이해하지 않는다”

가장 인상적인 발견이었습니다. MQTT 브로커(AWS IoT Core 포함)는 페이로드를 순수한 바이트 배열로 취급합니다. JSON이든, 바이너리든, SQL 인젝션 패턴이든 상관없이 그대로 전달합니다. 이것은 MQTT 프로토콜의 설계 의도이지만, 보안 관점에서는 수신 측의 입력 검증이 필수라는 것을 의미합니다.

2. QoS 2 거부 = 연결 종료

문서에는 "QoS 2 미지원"이라고만 적혀 있지만, 실제로는 QoS 2 메시지를 발행하면 브로커가 TCP 연결을 즉시 끊어버립니다. 단순한 에러 응답이 아니라 연결 자체가 끊기기 때문에, 재연결 로직이 반드시 필요해요.

3. 128KB 경계는 정확하다

AWS IoT Core의 128KB(131,072 bytes) 페이로드 제한은 바이트 단위로 정확합니다. 131,072 bytes까지는 허용, 131,073 bytes부터는 즉시 거부/연결 종료.

4. 와일드카드 발행도 연결이 끊긴다

+#가 포함된 토픽으로 발행하면, AWS IoT Core는 에러 응답 대신 연결을 끊습니다. QoS 2와 마찬가지로 "규칙 위반 → 연결 종료" 패턴이에요.


마무리 — 핵심 3줄 요약

  1. MQTT 브로커는 페이로드를 검증하지 않는다 — 잘못된 JSON, SQL 인젝션, 바이너리 데이터 모두 그대로 전달되므로, 수신 측에서 반드시 입력 검증이 필요합니다.
  2. AWS IoT Core는 규칙 위반에 대해 "연결 종료"로 응답한다 — QoS 2, 와일드카드 발행, 128KB+ 페이로드 등 규칙 위반 시 에러 코드가 아닌 TCP 연결 종료로 응답하므로, 재연결 전략이 필수입니다.
  3. 경계값 테스트와 실제 텔레메트리 포맷이 핵심 — 128KB ±1 byte, 256자 토픽 등 경계값을 정확히 테스트하고, 프로덕션과 동일한 JSON 구조를 사용해야 의미 있는 결과를 얻을 수 있습니다.

다음 편 예고: Part 3: GUI 도구 개발과 배포 자동화에서는 이 테스트들을 GUI로 실행할 수 있는 JavaFX 도구를 만들고, jpackage로 독립 실행형 exe를 생성하는 과정을 다룹니다. JUnit Platform Launcher API 통합, Spring Factories 병합, Mockito 제외 등 삽질기가 기다리고 있습니다.

Y

yshyuk

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

조회수: 0