Spring/Framework 탐방 zone

[Netty] Netty가 뭐에요? - 12: 마지막 - MQTT와의 결합 및 병목점 확인(feat. JMeter Plugin 설치)

공대키메라 2026. 5. 11. 21:21

지난 Netty 시리즈에서는 Netty서버를 구축해서 도메인 코드와 결합을 했다.

(지난 내용 여기 클릭 -11: Netty 프레임워크 확장하기)

 

이번 시간에는 MQTT를 활용해서, 수많은 처리량을 Netty가 감당하는지 확인하고 병목지점이 있다면 이를 해결하려고 한다.

 


목표

엄청난 요청이 오는 상황에서 Netty서버에 문제가 없는지 확인한다.


 

1. MQTT란? 

MQTT에 대해서는 다른 글에서도 동일하게 Gemini에게 정리를 요청했다.

 

물론 Messaging에 대해서 글을 정리했다.

 

[Messaging] MQTT이해하기 - 1탄 : MQTT란 무엇인가? + mqtt introduction
[Messaging] MQTT이해하기 - 2탄 : Pub/Sub 구조
[Messaging] MQTT이해하기 - 3탄 : Broker와 Server Connection + Subscribe & Unsubscribe
[Messaging] MQTT이해하기 - 4탄 : Wildcards & Best Practices + QoS

 

초기에는 그래도 공식문서 좀 읽는게 좋지 않을까? 하는 생각이라서 읽고, 이정도면 괜찮겠거니~ 하고 MQTT 이해하기 시리즈를 4탄까지 작성했다. 궁금하면 읽어보길 바란다. 

 

이를 다 읽기 싫다면 하단에 다른 곳에서 정리해두었던 MQTT관련 지식을 요약했다.

 

더보기

1. MQTT란 무엇인가? (Introduction)

MQTT(Message Queuing Telemetry Transport)는 제한된 대역폭과 불안정한 네트워크 환경에서 동작하는 IoT(사물인터넷) 디바이스를 위해 설계된 초경량 메시징 프로토콜입니다.

  • 동작 원리: 기본적으로 TCP/IP 프로토콜 위에서 동작하지만, HTTP와 달리 헤더 크기가 최소 2 Byte로 매우 작아 네트워크 오버헤드와 배터리 소모를 극단적으로 줄입니다.
  • 특징: 클라이언트 간의 직접적인 연결 없이 중앙의 Broker를 거치는 구조를 가지며, 네트워크가 끊겼을 때를 대비한 세션 유지 및 유언 메시지(LWT, Last Will and Testament) 기능을 제공하여 분산 환경에서의 신뢰성을 확보합니다.

 

2. Pub/Sub 구조 (Publish/Subscribe Architecture)

MQTT의 가장 큰 아키텍처적 특징은 발행-구독(Pub/Sub) 모델을 통한 결합도(Coupling) 최소화입니다.

  • 공간적 분리: 메시지를 발행하는 클라이언트(Publisher)와 수신하는 클라이언트(Subscriber)는 서로의 IP나 존재 여부를 알 필요가 없습니다. 오직 Broker의 주소와 메시지의 주제(Topic)만 알면 됩니다.
  • 시간적 분리: 두 클라이언트가 동시에 네트워크에 연결되어 있을 필요가 없습니다. (Broker가 세션과 메시지를 보관할 수 있음)
  • 동기화 분리: 메시지 발행 및 수신 작업이 비동기적으로 처리되므로, 클라이언트의 메인 스레드가 블로킹되지 않습니다.

 

3. Broker와 Connection + Subscribe & Unsubscribe

대용량 트래픽 처리 관점에서 Broker는 수많은 클라이언트의 커넥션을 유지하고 메시지를 라우팅하는 핵심 서버 역할을 합니다.

 

  • Connection: 클라이언트가 Broker에게 CONNECT 패킷을 보내고, Broker가 CONNACK 패킷으로 응답하여 TCP 연결을 맺습니다. 이때 Client ID, Clean Session(이전 세션 정보 유지 여부), Keep Alive(연결 상태 확인 주기) 파라미터가 교환됩니다.
  • Subscribe / Unsubscribe:
    • 연결이 완료된 클라이언트는 관심 있는 Topic을 수신하기 위해 SUBSCRIBE 패킷을 전송하고 SUBACK을 받습니다.
    • 구독을 해제할 때는 UNSUBSCRIBE 패킷을 전송하여 더 이상 해당 Topic의 메시지를 수신하지 않도록 Broker의 라우팅 테이블에서 자신을 제거합니다.

 

4. Wildcards & Best Practices + QoS

효율적인 메시지 라우팅과 신뢰성 보장을 위한 MQTT의 핵심 기능입니다.

Topic Wildcards (토픽 와일드카드) 토픽은 계층적 구조(/)를 가지며, 여러 토픽을 한 번에 구독하기 위해 와일드카드를 사용합니다.

 

  • 단일 레벨(+): 특정 하나의 레벨만 매칭합니다. (예: sensor/+/temperature -> sensor/room1/temperature 매칭, sensor/room1/humidity 실패)
  • 다중 레벨(#): 해당 레벨 하위의 모든 계층을 매칭합니다. 반드시 토픽의 마지막에만 사용 가능합니다. (예: sensor/room1/# -> room1 하위의 모든 센서 데이터 매칭)

 

Best Practices (설계 최우수 사례)

  • 최상위 레벨에 와일드카드(#) 사용 금지: Broker에 엄청난 부하를 유발합니다.
  • 토픽 이름의 맨 앞이나 뒤에 슬래시(/)를 붙이지 않습니다. (불필요한 공백 레벨 생성 방지)
  • 토픽에 공백 문자를 사용하지 않고, ASCII 문자만 사용하여 명확하게 구성합니다.
  • 클라이언트 ID를 토픽 트리에 포함시켜 권한 제어와 디버깅을 용이하게 합니다.

 

QoS (Quality of Service) MQTT는 네트워크 환경에 따라 메시지 전송의 신뢰성을 3단계로 보장합니다.

 

  • QoS 0 (At most once): "Fire and Forget". 메시지를 한 번만 전송하며, 수신 확인을 하지 않습니다. 유실 가능성이 있지만 성능이 가장 빠릅니다.
  • QoS 1 (At least once): 메시지가 최소 한 번은 전달됨을 보장합니다. 수신자로부터 PUBACK을 받을 때까지 재전송하므로 중복 수신이 발생할 수 있습니다. 수신 측의 멱등성(Idempotency) 처리가 중요합니다.
  • QoS 2 (Exactly once): 메시지가 정확히 한 번만 전달됨을 보장합니다. 4단계 핸드셰이크(PUBREC, PUBREL, PUBCOMP)를 거치므로 오버헤드가 가장 크지만, 과금이나 제어 명령 등 절대 중복되거나 누락되면 안 되는 시스템에 사용됩니다.

 

학습을 진행하며 가장 빠르다고 느낀 방법은 공식문서를 먼저 열어보는게 아니라 직접 까보고 써보면서 글을 읽어보는 것이라고 생각한다. 

 

막상 MQTT관련해서 글을 정리해보니 너무 이거에 힘을 많이 썼나? 하는 생각도 들지만 궁금하면 읽어보길 바란다.

 

2. 현재 상황 분석

이전까지의 상황에 대해서 요약을 해서 markdown으로 정리했다.

 

읽어보고 참고하면 현재 흐름을 이해하는데 좋을 것이다.

# Netty + MQTT + WebSocket: 실시간 차량 추적 시스템의 전체 동작 구조

> 이전 글에서 MQTT 브로커(HiveMQ CE)를 선정하고 Publisher/Subscriber를 연결했다.
> 이번 글에서는 **시스템 전체가 어떻게 연결되어 실시간으로 동작하는지** 정리한다.

---

## 전체 아키텍처

```
                        ┌──────────────────────────────────────────────────────┐
                        │            Netty Server (port 8081)                   │
                        │                                                      │
  ┌───────────────┐     │  ┌────────────────────────────────────────────────┐  │
  │ Vehicle       │     │  │              Netty Pipeline                     │  │
  │ Simulator     │     │  │                                                │  │
  │ (N대)         │     │  │  HttpServerCodec                               │  │
  │               │     │  │       ↓                                        │  │
  │ 5초마다       │     │  │  HttpObjectAggregator                          │  │
  │ GPS publish   │     │  │       ↓                                        │  │
  └───────┬───────┘     │  │  WebSocketServerProtocolHandler ──→ WS 핸들러  │  │
          │             │  │       ↓                                        │  │
          │ MQTT        │  │  HttpRoutingHandler ──→ Virtual Thread         │  │
          ▼             │  └────────────────────────────────────────────────┘  │
  ┌───────────────┐     │                                                      │
  │   HiveMQ CE   │     │  ┌────────────────────────────────────────────────┐  │
  │   (브로커)     │     │  │         VehicleTelemetrySubscriber             │  │
  │   port 1883   │────────▶│                                                │  │
  └───────────────┘     │  │  1. MQTT 수신 (vehicle/+/telemetry)            │  │
                        │  │  2. Journey 자동 생성 (첫 GPS)                  │  │
                        │  │  3. LocationSnapshot 저장                       │  │
                        │  │  4. WebSocket broadcast ───────────────────┐    │  │
                        │  └───────────────────────────────────────────┼────┘  │
                        │                                              │        │
                        └──────────────────────────────────────────────┼────────┘
                                                                       │
                    ┌──────────────────────────────────────────────────┘
                    │
                    ▼
          ┌───────────────────┐
          │    Dashboard      │
          │    (브라우저)       │
          │                   │
          │  WebSocket 수신    │
          │       ↓           │
          │  Leaflet.js 지도   │
          │  마커 실시간 이동   │
          └───────────────────┘
```

### 동작 흐름

서버가 시작되면 다음이 순차적으로 일어난다:

1. **서버 기동** — Netty가 8081 포트를 열고, MQTT Publisher/Subscriber 두 개의 클라이언트가 HiveMQ 브로커에 연결된다. 차량 10대가 자동 등록(seed data)된다.

2. **시뮬레이터 시작** — `POST /api/cartracking/simulator/start`를 호출하면 등록된 차량마다 Virtual Thread가 하나씩 생성된다. 각 스레드는 서울 영역 내 랜덤 경로를 생성하고, GPS 보간 좌표를 5초 간격으로 MQTT에 publish한다.

3. **브로커 중계** — HiveMQ CE가 `vehicle/{id}/telemetry` 토픽으로 들어온 메시지를 Subscriber에게 전달한다. 브로커는 단순 중계 역할이며, QoS 1(AT_LEAST_ONCE)로 최소 1회 전달을 보장한다.

4. **Subscriber 수신 및 도메인 처리** — `VehicleTelemetrySubscriber`가 메시지를 받으면:
   - 해당 차량에 진행 중인 Journey가 없으면 → Journey를 자동 생성한다 (첫 GPS = 운행 시작)
   - 진행 중인 Journey가 있으면 → LocationSnapshot을 저장한다
   - 이 과정에서 Vehicle 상태가 AVAILABLE → ON_TRIP으로 바뀐다

5. **WebSocket broadcast** — Subscriber는 수신한 telemetry JSON을 `ChannelGroup.writeAndFlush()`로 현재 연결된 모든 WebSocket 클라이언트에 전송한다. 브라우저는 별도 요청 없이 서버가 push해주는 데이터를 받기만 하면 된다.

6. **대시보드 렌더링** — 브라우저는 WebSocket으로 받은 좌표를 `requestAnimationFrame`으로 4초간 선형 보간하여 마커를 부드럽게 이동시킨다. 동시에 수신 위치마다 점(dot)을 찍어 궤적을 표시한다.

7. **시뮬레이터 종료** — `POST /api/cartracking/simulator/stop`을 호출하면 모든 시뮬레이터 스레드가 멈추고, 진행 중이던 Journey는 COMPLETED 상태로 전환된다. Vehicle도 다시 AVAILABLE로 돌아간다.

---

## 1. Netty 파이프라인: HTTP와 WebSocket을 한 포트에서 처리

```java
// CarTrackingChannelInitializer.java
@Override
protected void initChannel(Channel ch) {
    ChannelPipeline p = ch.pipeline();
    p.addLast(new HttpServerCodec());
    p.addLast(new HttpObjectAggregator(65536));
    p.addLast(new WebSocketServerProtocolHandler("/ws/vehicles"));
    p.addLast(new WebSocketFrameHandler(websocketClients));
    p.addLast(new HttpRoutingHandler(routeRegistry, virtualExecutor));
}
```

핵심은 `WebSocketServerProtocolHandler`다. 이 핸들러는 `/ws/vehicles` 경로로 들어온 HTTP Upgrade 요청만 WebSocket 핸드셰이크로 처리하고, 나머지 일반 HTTP 요청은 다음 핸들러(`HttpRoutingHandler`)로 그대로 통과시킨다. 별도 라우터 없이 한 포트에서 REST + WebSocket을 동시에 처리할 수 있는 이유다.

---

## 2. Virtual Thread 오프로드: EventLoop를 블로킹하지 않는 구조

```java
// HttpRoutingHandler.java — channelRead0()
virtualExecutor.submit(() -> {
    try {
        Object result = match.getEntry().handle(requestContext);
        sendJson(ctx, OK, result, method, path);
    } catch (IllegalArgumentException e) {
        sendJson(ctx, BAD_REQUEST, Map.of("error", e.getMessage()), method, path);
    } catch (Exception e) {
        sendJson(ctx, INTERNAL_SERVER_ERROR, Map.of("error", e.getMessage()), method, path);
    }
});
```

Netty의 EventLoop 스레드는 I/O 전용이다. 여기서 블로킹(DB 조회, 락 대기 등)이 발생하면 다른 모든 연결의 read/write가 멈춘다.

해결: 요청 파싱까지는 EventLoop에서 하고, 도메인 로직은 Virtual Thread로 넘긴다. Java 21의 Virtual Thread는 생성 비용이 거의 없어서 요청마다 하나씩 만들어도 부담이 없다.

**응답은 다시 EventLoop로 돌려보낸다:**

```java
// HttpRoutingHandler.java — sendJson()
ctx.channel().eventLoop().execute(() ->
    ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE)
);
```

Netty Channel의 write는 반드시 해당 Channel의 EventLoop에서 실행해야 thread-safe하다. Virtual Thread에서 직접 writeAndFlush를 호출하면 응답이 유실될 수 있다.

---

## 3. MQTT Subscriber: GPS 수신 → Journey 자동 생성 → WebSocket broadcast

```java
// VehicleTelemetrySubscriber.java — subscribe()
client.subscribeWith()
    .topicFilter("vehicle/+/telemetry")
    .qos(MqttQos.AT_LEAST_ONCE)
    .callback(publish -> {
        byte[] payload = publish.getPayloadAsBytes();
        TelemetryPayload telemetry = objectMapper.readValue(payload, TelemetryPayload.class);

        Location location = Location.of(telemetry.latitude(), telemetry.longitude());
        LocationSnapshot snapshot = tripApplicationService.recordSnapshot(telemetry.vehicleId(), location);

        // 진행 중인 Journey가 없으면 자동 생성
        if (snapshot == null) {
            tripApplicationService.startTrip(telemetry.vehicleId(), location);
        }

        // WebSocket 연결된 모든 브라우저에 broadcast
        String json = objectMapper.writeValueAsString(telemetry);
        websocketClients.writeAndFlush(new TextWebSocketFrame(json));
    })
    .send();
```

실제 세계에서 운행은 차량이 GPS를 보내기 시작하는 순간 시작된다. 서버가 REST API로 "운행 시작"을 명령하는 것은 현실과 반대다. 그래서 첫 GPS 수신 시 Journey를 자동으로 생성하는 구조를 택했다.

`websocketClients`는 Netty의 `ChannelGroup`으로, 현재 연결된 모든 WebSocket 채널을 관리한다. `writeAndFlush`를 호출하면 그룹 내 전체 채널에 메시지가 전송된다.

---

## 4. WebSocket 연결 관리

```java
// WebSocketFrameHandler.java
public class WebSocketFrameHandler extends SimpleChannelInboundHandler<WebSocketFrame> {

    private final ChannelGroup websocketClients;

    @Override
    public void handlerAdded(ChannelHandlerContext ctx) {
        websocketClients.add(ctx.channel());
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) {
        // 서버→브라우저 단방향 — 브라우저에서 보내는 메시지는 무시
    }
}
```

브라우저가 `ws://localhost:8081/ws/vehicles`로 연결하면 `handlerAdded`가 호출되어 ChannelGroup에 추가된다. 연결이 끊기면 Netty가 자동으로 그룹에서 제거한다.

이 시스템은 서버→브라우저 단방향 push다. 브라우저는 "구독"만 하고, 서버가 GPS를 수신할 때마다 알아서 보내준다.

---

## 5. 대시보드: WebSocket 수신 → 마커 애니메이션

```javascript
// dashboard.html
ws.onmessage = (event) => {
    const t = JSON.parse(event.data);
    const latlng = [parseFloat(t.latitude), parseFloat(t.longitude)];

    if (markers[t.vehicleId]) {
        // 기존 마커를 4초간 부드럽게 이동 (시뮬레이터 5초 간격에 맞춤)
        animateMarker(markers[t.vehicleId], latlng, 4000);
    }
};

function animateMarker(marker, targetLatLng, durationMs) {
    const start = marker.getLatLng();
    const startTime = performance.now();
    function step(now) {
        const t = Math.min((now - startTime) / durationMs, 1);
        const lat = start.lat + (targetLatLng[0] - start.lat) * t;
        const lng = start.lng + (targetLatLng[1] - start.lng) * t;
        marker.setLatLng([lat, lng]);
        if (t < 1) requestAnimationFrame(step);
    }
    requestAnimationFrame(step);
}
```

GPS가 5초마다 오는데, 마커가 순간이동하면 자연스럽지 않다. `requestAnimationFrame`으로 4초간 선형 보간하여 부드럽게 이동시킨다. 5초보다 짧은 4초로 설정해서 다음 GPS가 오기 전에 애니메이션이 완료되도록 했다.

---

## 6. 서버 부트스트랩: 전체 조립

```java
// CarTrackingServer.java
public void start() throws InterruptedException {
    ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();

    EventLoopGroup bossGroup   = new MultiThreadIoEventLoopGroup(1, NioIoHandler.newFactory());
    EventLoopGroup workerGroup = new MultiThreadIoEventLoopGroup(4, NioIoHandler.newFactory());

    ServerBootstrap b = new ServerBootstrap();
    b.group(bossGroup, workerGroup)
        .channel(NioServerSocketChannel.class)
        .option(ChannelOption.SO_BACKLOG, 128)
        .childOption(ChannelOption.SO_KEEPALIVE, true)
        .childOption(ChannelOption.TCP_NODELAY, true)
        .childHandler(new CarTrackingChannelInitializer(routeRegistry, virtualExecutor, websocketClients));

    ChannelFuture f = b.bind(port).sync();
    f.channel().closeFuture().sync();
}
```

| 설정 | 의미 |
|------|------|
| Boss 1 thread | 클라이언트 연결 수락 전담 |
| Worker 4 threads | 연결된 채널의 read/write 처리 |
| SO_BACKLOG 128 | OS TCP 연결 대기 큐 크기 |
| TCP_NODELAY | Nagle 알고리즘 비활성화 — 작은 패킷도 즉시 전송 |
| SO_KEEPALIVE | TCP 레벨 연결 유지 확인 |

---

## 7. 전체 흐름 요약

```
1. 서버 시작 → 차량 10대 자동 등록 (seed data)
2. MQTT Publisher/Subscriber 연결
3. 대시보드 접속 → WebSocket 연결 → ChannelGroup에 추가
4. 시뮬레이터 시작 (POST /api/cartracking/simulator/start)
5. 각 차량이 5초마다 GPS publish → HiveMQ → Subscriber 수신
6. Subscriber:
   - 첫 GPS → Journey 자동 생성
   - 이후 GPS → LocationSnapshot 저장
   - 매번 → WebSocket broadcast
7. 브라우저: GPS 수신 → 마커 애니메이션으로 실시간 이동 표시
8. 시뮬레이터 종료 → 전체 Journey COMPLETED 처리
```

---

## 다음 단계: Netty 부하 테스트

현재 시스템은 10대 차량으로 잘 동작한다. 하지만 이것만으로는 Netty의 성능 한계를 알 수 없다.

다음 글에서는 JMeter로 부하를 걸어 병목점을 찾고 개선할 예정이다:
- REST API에 동시 500~1000 요청 → TPS와 응답시간 측정
- WebSocket 1000개 연결 상태에서 broadcast 성능 측정
- 발견된 병목을 하나씩 개선하고 Before/After 비교

현재 코드에서 이미 예상되는 병목:
- HTTP Keep-Alive 미지원 (매 요청마다 TCP 연결/종료)
- WebSocket broadcast 시 같은 메시지를 N번 새로 생성
- Subscriber가 단일 스레드에서 도메인 로직 + broadcast를 동기 처리

---

## 핵심 파일 목록

| 파일 | 역할 |
|------|------|
| `CarTrackingServer.java` | Netty 서버 부트스트랩 |
| `CarTrackingChannelInitializer.java` | 파이프라인 구성 |
| `HttpRoutingHandler.java` | REST 요청 → Virtual Thread → 응답 |
| `WebSocketFrameHandler.java` | WebSocket 연결 관리 |
| `VehicleTelemetrySubscriber.java` | MQTT 수신 → 도메인 처리 → broadcast |
| `VehicleSimulator.java` | GPS 보간 + MQTT publish 루프 |
| `SimulatorBootstrap.java` | 시뮬레이터 생명주기 관리 |
| `CarTrackingAppConfig.java` | DI 조립 (Repository, Service, MQTT 클라이언트) |
| `dashboard.html` | Leaflet.js 지도 + WebSocket 실시간 수신 |

 

 

각자 읽어보면 좋을 것이다. 물론 필자가 github에도 해당 md파일을 올려놨으니 참고하길 바란다. 

주소를 첨부한다. (불법사이트 아님!) 바로가기 클릭!

 

2. JMeter 설치 및 플러그인 준비 / Visual VM 세팅하기

JMeter는 알아서 다운을 받으면 된다. 찾아보면 많이 나오니 잘 해보자!

 

다만 필자는 플러그인을 설치하려고 한다. JMeter Plugin Manager라는게 있는데, 여러 Plugin을 직접 설치할 필요 없이 JMeter에서 사용할 수 있게 지원한다.

 

https://jmeter-plugins.org/wiki/PluginsManager/

 

설명을 읽어보면 JAR file을 다운받은 후에 앞서 다운로드한 JMeter의 lib/ext 디렉토리에 넣어준다.

 

 

그리고 Apache JMeter 를 실행하고 Options 혹은 옵션들을 선택한다.

 

 

클릭하면 Plugins Manager 가 가장 하단에 나온다. 

 

WebSocket Samplers by Peter Doornbosch - WS 연결/수신/종료 부하 테스트용
Custom Thread Groups - Stepping Thread Group (점진적 부하)용
3 Basic Graphs - Response Times Over Time, TPS 그래프

 

Available Plugins에서 위를 선택해서 오른쪽 하단에 Apply Changes and Restart JMeter 를 누른다.

 

그러면 필자가 어떻게 구성햇는지 최종 설정을 보여주겠다.

 

 

Test Plan: "차량 스케일업 통합 부하 테스트"
├── HTTP Request Defaults          ← localhost, 8081, http
├── HTTP Header Manager            ← Content-Type: application/json
│
├── [Thread Group] REST-GET-vehicles       ← 시뮬레이터 도는 중에 REST 부하
│   ├── Number of Threads: 10~50 (단계별 조절)
│   ├── Ramp-Up Period: 5
│   ├── Loop Count: 100~20 (단계별 조절)
│   └── HTTP Request: GET /api/cartracking/vehicles
│
├── [Thread Group] WS-broadcast-수신       ← 시뮬레이터 도는 중에 WebSocket 수신 측정
│   ├── Number of Threads: 5~100 (단계별 조절)
│   ├── WebSocket Open: ws://localhost:8081/ws/vehicles
│   ├── WebSocket Read (Duration: 60초간 수신)
│   └── WebSocket Close
│
├── [Listener] Summary Report
├── [Listener] Response Times Over Time
└── [Listener] Transactions Per Second

 

각각 설정을 만들다가 이제... Number of Threads, loop count 같은 설정은 일일히 들어가서 저장하기 힘드니 변수로 입력받아서 실행할 수 있다. 

 

 

또 Visual VM을 다운로드할 것이다.

 

 

그리고 bin 폴더 안에서 visualvm 실행 파일을 실행한다.

 

 

현재 필자는 CarTrackingApplication을 실행했고, 이를 Visual VM으로 메모리 분석을 시각적으로 하려고 한다.

 

 

그리고 다음과 같이 sh파일을 작성했다.

 

#!/bin/bash

JMETER="D:/apache-jmeter-5.6.3/bin/jmeter.bat"
SERVER_URL="http://localhost:8081"
JMX_FILE="D:/apache-jmeter-5.6.3/result_cartracking/cartracking_test.jmx"
RESULT_DIR="D:/apache-jmeter-5.6.3/result_cartracking"
WAIT_SEC=10

mkdir -p "$RESULT_DIR"

run_stage() {
  local stage=$1
  local count=$2
  local restThreads=$3
  local duration=$4

  echo "===== $stage: 차량 ${count}대 / REST ${restThreads}t / ${duration}s ====="

  # 시뮬레이터 시작
  curl -s -X POST "${SERVER_URL}/api/cartracking/simulator/start?count=${count}"
  echo ""
  echo "${WAIT_SEC}초 대기 (워밍업)..."
  sleep $WAIT_SEC

  # 이전 결과 삭제
  rm -f "${RESULT_DIR}/${stage}.jtl"
  rm -rf "${RESULT_DIR}/${stage}/"

  # JMeter 실행
  echo "JMeter 실행 중..."
  "$JMETER" -n -t "$JMX_FILE" \
    -JrestThreads=$restThreads \
    -Jduration=$duration \
    -l "${RESULT_DIR}/${stage}.jtl" -e -o "${RESULT_DIR}/${stage}/"

  # 시뮬레이터 중지
  curl -s -X POST "${SERVER_URL}/api/cartracking/simulator/stop"
  echo ""
  echo "===== $stage 완료. 서버 재시작 후 Enter 눌러 ====="
  read -r
}

#        stage  count  restThreads  duration(s)
run_stage "S1"   10     10           60
run_stage "S2"   50     30           60
run_stage "S3"   100    50           60
run_stage "S4"   500    50           60
run_stage "S5"   1000   50           60

echo "===== 전체 완료 ====="

 

또 jmx 파일은 다음과 같이 설정했다.

 

<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
  <hashTree>
    <TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="차량 스케일업 통합 부하 테스트">
      <elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="사용자 정의 변수들">
        <collectionProp name="Arguments.arguments"/>
      </elementProp>
    </TestPlan>
    <hashTree>
      <ConfigTestElement guiclass="HttpDefaultsGui" testclass="ConfigTestElement" testname="HTTP 요청 기본 설정">
        <stringProp name="HTTPSampler.domain">localhost</stringProp>
        <stringProp name="HTTPSampler.port">8081</stringProp>
        <stringProp name="HTTPSampler.protocol">http</stringProp>
        <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="사용자 정의 변수들">
          <collectionProp name="Arguments.arguments"/>
        </elementProp>
        <stringProp name="HTTPSampler.implementation">HttpClient4</stringProp>
      </ConfigTestElement>
      <hashTree/>
      <HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP 헤더 관리자">
        <collectionProp name="HeaderManager.headers">
          <elementProp name="" elementType="Header">
            <stringProp name="Header.name">Content-Type</stringProp>
            <stringProp name="Header.value">application/json</stringProp>
          </elementProp>
        </collectionProp>
      </HeaderManager>
      <hashTree/>
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="REST-GET-vehicles">
        <stringProp name="TestPlan.comments">시뮬레이터 도는 중에 REST 부하</stringProp>
        <stringProp name="ThreadGroup.num_threads">${__P(restThreads,10)}</stringProp>
        <intProp name="ThreadGroup.ramp_time">5</intProp>
        <boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
        <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="루프 컨트롤러">
          <stringProp name="LoopController.loops">-1</stringProp>
          <boolProp name="LoopController.continue_forever">false</boolProp>
        </elementProp>
        <boolProp name="ThreadGroup.scheduler">true</boolProp>
        <stringProp name="ThreadGroup.duration">${__P(duration,60)}</stringProp>
        <stringProp name="ThreadGroup.delay">0</stringProp>
      </ThreadGroup>
      <hashTree>
        <HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="HTTP 요청 - GET /api/cartracking/vehicles" enabled="true">
          <stringProp name="TestPlan.comments">GET /api/cartracking/vehicles</stringProp>
          <stringProp name="HTTPSampler.domain">localhost</stringProp>
          <stringProp name="HTTPSampler.port">8081</stringProp>
          <stringProp name="HTTPSampler.protocol">http</stringProp>
          <stringProp name="HTTPSampler.path">/api/cartracking/vehicles</stringProp>
          <boolProp name="HTTPSampler.follow_redirects">true</boolProp>
          <stringProp name="HTTPSampler.method">GET</stringProp>
          <boolProp name="HTTPSampler.use_keepalive">true</boolProp>
          <boolProp name="HTTPSampler.postBodyRaw">false</boolProp>
          <elementProp name="HTTPsampler.Arguments" elementType="Arguments" guiclass="HTTPArgumentsPanel" testclass="Arguments" testname="사용자 정의 변수들">
            <collectionProp name="Arguments.arguments"/>
          </elementProp>
        </HTTPSamplerProxy>
        <hashTree/>
      </hashTree>
      <ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="WS-broadcast-수신" enabled="false">
        <stringProp name="TestPlan.comments">시뮬레이터 도는 중에 WebSocket 수신 측정 (duration 모드에서 hang 발생하여 비활성화 — ADR-V032)</stringProp>
        <stringProp name="ThreadGroup.num_threads">${__P(wsThreads,5)}</stringProp>
        <intProp name="ThreadGroup.ramp_time">5</intProp>
        <boolProp name="ThreadGroup.same_user_on_next_iteration">true</boolProp>
        <stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
        <elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="루프 컨트롤러">
          <stringProp name="LoopController.loops">-1</stringProp>
          <boolProp name="LoopController.continue_forever">false</boolProp>
        </elementProp>
        <boolProp name="ThreadGroup.scheduler">true</boolProp>
        <stringProp name="ThreadGroup.duration">${__P(duration,60)}</stringProp>
        <stringProp name="ThreadGroup.delay">0</stringProp>
      </ThreadGroup>
      <hashTree>
        <eu.luminis.jmeter.wssampler.OpenWebSocketSampler guiclass="eu.luminis.jmeter.wssampler.OpenWebSocketSamplerGui" testclass="eu.luminis.jmeter.wssampler.OpenWebSocketSampler" testname="WebSocket Open Connection" enabled="true">
          <boolProp name="TLS">false</boolProp>
          <stringProp name="server">ws://localhost:8081/ws/vehicles</stringProp>
          <stringProp name="port">80</stringProp>
          <stringProp name="path"></stringProp>
          <stringProp name="connectTimeout">20000</stringProp>
          <stringProp name="readTimeout">6000</stringProp>
          <stringProp name="TestPlan.comments">시뮬레이터 도는 중에 WebSocket 수신 측정</stringProp>
        </eu.luminis.jmeter.wssampler.OpenWebSocketSampler>
        <hashTree/>
      </hashTree>
      <ResultCollector guiclass="SummaryReport" testclass="ResultCollector" testname="Summary Report">
        <boolProp name="ResultCollector.error_logging">false</boolProp>
        <objProp>
          <name>saveConfig</name>
          <value class="SampleSaveConfiguration">
            <time>true</time>
            <latency>true</latency>
            <timestamp>true</timestamp>
            <success>true</success>
            <label>true</label>
            <code>true</code>
            <message>true</message>
            <threadName>true</threadName>
            <dataType>true</dataType>
            <encoding>false</encoding>
            <assertions>true</assertions>
            <subresults>true</subresults>
            <responseData>false</responseData>
            <samplerData>false</samplerData>
            <xml>false</xml>
            <fieldNames>true</fieldNames>
            <responseHeaders>false</responseHeaders>
            <requestHeaders>false</requestHeaders>
            <responseDataOnError>false</responseDataOnError>
            <saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
            <assertionsResultsToSave>0</assertionsResultsToSave>
            <bytes>true</bytes>
            <sentBytes>true</sentBytes>
            <url>true</url>
            <threadCounts>true</threadCounts>
            <idleTime>true</idleTime>
            <connectTime>true</connectTime>
          </value>
        </objProp>
        <stringProp name="filename"></stringProp>
      </ResultCollector>
      <hashTree/>
      <kg.apc.jmeter.vizualizers.CorrectedResultCollector guiclass="kg.apc.jmeter.vizualizers.ResponseTimesOverTimeGui" testclass="kg.apc.jmeter.vizualizers.CorrectedResultCollector" testname="Response Times Over Time">
        <boolProp name="ResultCollector.error_logging">false</boolProp>
        <objProp>
          <name>saveConfig</name>
          <value class="SampleSaveConfiguration">
            <time>true</time>
            <latency>true</latency>
            <timestamp>true</timestamp>
            <success>true</success>
            <label>true</label>
            <code>true</code>
            <message>true</message>
            <threadName>true</threadName>
            <dataType>true</dataType>
            <encoding>false</encoding>
            <assertions>true</assertions>
            <subresults>true</subresults>
            <responseData>false</responseData>
            <samplerData>false</samplerData>
            <xml>false</xml>
            <fieldNames>true</fieldNames>
            <responseHeaders>false</responseHeaders>
            <requestHeaders>false</requestHeaders>
            <responseDataOnError>false</responseDataOnError>
            <saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
            <assertionsResultsToSave>0</assertionsResultsToSave>
            <bytes>true</bytes>
            <sentBytes>true</sentBytes>
            <url>true</url>
            <threadCounts>true</threadCounts>
            <idleTime>true</idleTime>
            <connectTime>true</connectTime>
          </value>
        </objProp>
        <stringProp name="filename"></stringProp>
        <longProp name="interval_grouping">500</longProp>
        <boolProp name="graph_aggregated">false</boolProp>
        <stringProp name="include_sample_labels"></stringProp>
        <stringProp name="exclude_sample_labels"></stringProp>
        <stringProp name="start_offset"></stringProp>
        <stringProp name="end_offset"></stringProp>
        <boolProp name="include_checkbox_state">false</boolProp>
        <boolProp name="exclude_checkbox_state">false</boolProp>
      </kg.apc.jmeter.vizualizers.CorrectedResultCollector>
      <hashTree/>
      <kg.apc.jmeter.vizualizers.CorrectedResultCollector guiclass="kg.apc.jmeter.vizualizers.TransactionsPerSecondGui" testclass="kg.apc.jmeter.vizualizers.CorrectedResultCollector" testname="Transactions Per Second" enabled="true">
        <boolProp name="ResultCollector.error_logging">false</boolProp>
        <objProp>
          <name>saveConfig</name>
          <value class="SampleSaveConfiguration">
            <time>true</time>
            <latency>true</latency>
            <timestamp>true</timestamp>
            <success>true</success>
            <label>true</label>
            <code>true</code>
            <message>true</message>
            <threadName>true</threadName>
            <dataType>true</dataType>
            <encoding>false</encoding>
            <assertions>true</assertions>
            <subresults>true</subresults>
            <responseData>false</responseData>
            <samplerData>false</samplerData>
            <xml>false</xml>
            <fieldNames>true</fieldNames>
            <responseHeaders>false</responseHeaders>
            <requestHeaders>false</requestHeaders>
            <responseDataOnError>false</responseDataOnError>
            <saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
            <assertionsResultsToSave>0</assertionsResultsToSave>
            <bytes>true</bytes>
            <sentBytes>true</sentBytes>
            <url>true</url>
            <threadCounts>true</threadCounts>
            <idleTime>true</idleTime>
            <connectTime>true</connectTime>
          </value>
        </objProp>
        <stringProp name="filename"></stringProp>
        <longProp name="interval_grouping">1000</longProp>
        <boolProp name="graph_aggregated">false</boolProp>
        <stringProp name="include_sample_labels"></stringProp>
        <stringProp name="exclude_sample_labels"></stringProp>
        <stringProp name="start_offset"></stringProp>
        <stringProp name="end_offset"></stringProp>
        <boolProp name="include_checkbox_state">false</boolProp>
        <boolProp name="exclude_checkbox_state">false</boolProp>
      </kg.apc.jmeter.vizualizers.CorrectedResultCollector>
      <hashTree/>
    </hashTree>
  </hashTree>
</jmeterTestPlan>

 

직접 UI에서 하기보다는 그냥 Gemini에게 이거 뽑아줘. 에러 없는지 확인해줘 하면 금방 샤샤샥 만들어주는게 현실이다.


글을 적다가 중도에 멈춘다.

 

Netty는 결국 성능 관련해서 최고의 성능을 내기 위해 노력한 framework이며, 그간 열심히 혼자 끄적인거 같은데

 

내가 아닌게 아닌 꼬리에 꼬리를 물어서 AI 의존적으로 질문을 하고 있었다.

 

아무래도 질문을 하고 이를 분석하고 해결하는 과정에서 Claude code랑 Gemini의 정리와 문제점 해결 속도를 따라가지를 못하니 필자는 너무나 긴 기간을 이 둘을 통해 해결하고 있었다.

 

물론 여러 설정과 코드를 어떻게 작성하고 성능이 어떻게 상승되었는지는 간단하게 알아볼 수 있었다.

 

현재까지 작업내용으로는 사실 DB연결하지 않고 InMemory를 사용했으며, 내가 티키타카한 작업 내용들은 해당 주소에 자세히 정리를 해놨으니 읽어보면 될 듯 하다.

https://github.com/thelovemsg/netty_basecamp/tree/main/.claude/skills/work/vehicle

 

netty_basecamp/.claude/skills/work/vehicle at main · thelovemsg/netty_basecamp

netty_basecamp. Contribute to thelovemsg/netty_basecamp development by creating an account on GitHub.

github.com

 

이렇게 그냥 대충 끝낸다고?

 

물론 변경거리가 있다.  사실 하다보니 이게 무슨 의미인가 싶은 생각이 크다.

 

압도적인 속도와 엄청난 지식을 통해서 문제를 쉽게 해결할 수 있지만, 작업을 결국 내가 시켜서 하긴 한건데 이게 의미가 있는지 심히 의구심이 들었고 나 자신에 대해 회의감도 들었다.

 

그러한 반신반의가 있다보니 아무래도 이제는 다른 것을 학습할 때가 온 것이다. 

 

자연스럽게 흥미가 떨어지니, 생각하길 점점 줄이고 이를 시키기만 하고 무언가를 하는 척을 하게 되는 것이다. 

 

 

이러나 저러나 마음이 떠나면 안하는게 맞다고 생각한다. 내가 원하는건 억지로 하는게 아니지 않은가?

 

 

글의 목표는 사실 병목점을 파악하는 것인데, todo-20260505-mqtt-scaleup-loastest.md와 decisions.md 파일들을 읽어보면 해결될것이다. 

 

이래서 내가 요즘 재미가 없다. 뭔 AI가 다 알고 있으니 배우는 맛이 있어야지 참;;

 

참고:

https://jmeter-plugins.org/wiki/PluginsManager/

https://visualvm.github.io/download.html