Spring/Framework 탐방 zone

[Netty] Netty가 뭐에요? - 10: 정말 Netty는 빠를까? 2탄 - 대용량 데이터 처리 속도 비교(feat. Visual VM 활용하기)

공대키메라 2026. 2. 15. 23:25

지난 시간에는 실제로 Netty 가 일반 Blocking 으로 요청을 했을 때 보다 속도가 빠른지 성능 테스트를 해 보았다.

(지난 내용이 궁금하면 여기 클릭!)

 

이번 시간에는 시나리오 2를 통해 성능을 좀 더 테스트해보려고 한다.

 

참고로 필자는 window 11 환경의 cmd 창에서 테스트를 진행했다.

 

 


0. 지난 내용을 복기해본다.

1. 대용량 페이로드 테스트를 진행한다.

2. 결과를 비교해서 어느것의 성능이 뛰어난지 확인한다.

3. JVM 의 구조에 대해 이해한다. 

4. VisualVM을 도입해서 JVM 현황을 파악한다.

5. 결론

 


0.  지난 내용 복기

지난 시간의 내용을 복기 + 정리하고 가려고 한다.

 

Apache JMeter 를 다운로드 해서 세팅하는 법도 알아봤고

 

시나리오 1 대량 연결 유지 테스트 (The "Resource Efficiency" Proof) 를 테스트해보았다.

 

결과적으로 지연 테스트를 통해 Non-Blocking 구조가 스레드 대기 시간을 없애는 것을 입증하였다.

 

1. 대용량 페이로드 테스트

이번에는 시나리오 2번을 통해서 실제로 이를 테스트 할 수 있는 환경을 생성하고,

 

이전과 마찬가지로 테스트를 진행할 예정이다.

 

참고로 Zero-Copy 관련해서는 이전에 정리한 글이 있으니 참고하면 좋다. (여기 클릭!)

 

시나리오 2: 대용량 페이로드 에코 테스트 (The "Zero-Copy" Proof)

Netty의 Zero-Copy 장점은 데이터가 커널 영역에서 유저 영역(Heap)으로 복사되는 횟수를 줄일 때 가장 극적으로 드러납니다. 요청 데이터가 작으면(예: "Hello") 복사 비용이 미미하여 차이가 안 보일 수 있습니다.

  • 목표: 대용량 데이터를 주고받을 때 힙(Heap) 메모리 사용량과 GC 빈도 비교.
  • 시나리오 구성:
    • 행동: 클라이언트(JMeter)가 **1MB ~ 10MB 크기의 더미 데이터(JSON 또는 파일)**를 POST로 전송하고, 서버는 이를 그대로 반환(Echo)한다. 
      • 부하 패턴 (Step Load):
        • Ramp-up: 60초 동안 동시 접속자(Thread) 100명까지 서서히 증가.
        • Steady State: 100명 상태에서 2분간 지속. (GC가 발생할 충분한 시간을 줌)
  • 비교 포인트 (Netty vs Tomcat/Blocking):
    • Tomcat: 요청마다 1MB 데이터를 힙 메모리에 복사해야 하므로, Young Gen GC가 미친 듯이 발생하고 힙 메모리 사용량이 톱니바퀴처럼 요동칩니다.
    • Netty: DirectBuffer를 사용하므로 힙 메모리 증가 폭이 낮고, GC 횟수가 현저히 적음을 확인해야 합니다.

 

핵심은 Netty를 사용하면 힙 메모리 증가가 낮고, GC횟수가 현저히 적어야 한다는 것이다

 

왜냐? Zero-Copy 을 사용해서 Heap으로의 복사 횟수를 줄이기 때문이다.

 

그러면... 테스트용으로 우선 파일을 생성하겠다.

 

dummy 이지만 용량은 10485760byte의 크기로 파일을 생성한다.

 

fstuil file createnew dummy-10mb.dat 10485760

 

10,485,760 Bytes로 10MB의 파일을 생성했다.

 

100명의 가상 유저가 10MB 페이로드를 주고받는 테스트를 버텨내려면 JMeter의 힙 메모리를 늘려야 한다.

set HEAP=-Xms4g -Xmx4g -XX:MaxMetaspaceSize=256m

 

 

해당 명령어를 jmeter.bat 파일에서 찾아서 수정해준다.

 

힙 메모리의 처음과 끝(최소/최대)을 똑같이 4GB로 미리 꽉 잡아두고,

 

클래스 정보가 저장되는 메타스페이스 공간은 256MB로 제한! 한다는말이다.

 

 

 

그리고 실행할 명령어롤 보도록 하겠다.

 

// ==========================================
// 시나리오 2: 대용량 페이로드 에코 테스트 (Tomcat vs Netty)
// ==========================================

// 1. Spring Boot + Tomcat 서버 요청
del /q "C:\work\apache-jmeter-5.6.3\result_tomcat\scenario2_echo_tomcat.jtl"
rmdir /s /q "C:\work\apache-jmeter-5.6.3\result_tomcat\report_scenario2_echo_tomcat"

C:\work\apache-jmeter-5.6.3\bin\jmeter.bat -n -t "C:\work\apache-jmeter-5.6.3\bin\Netty_vs_Tomcat_Echo.jmx" ^
-l "C:\work\apache-jmeter-5.6.3\result_tomcat\scenario2_echo_tomcat.jtl" ^
-e -o "C:\work\apache-jmeter-5.6.3\result_tomcat\report_scenario2_echo_tomcat" ^
-Jthreads=100 -Jramp_up_time=60 -Jduration=180 ^
-Jconstant_timer_delay=10000 ^
-Jhost=127.0.0.1 -Jport=8080 ^
-Jpath=/echo


// 2. Netty 서버 요청
del /q "C:\work\apache-jmeter-5.6.3\result_netty\scenario2_echo_netty.jtl"
rmdir /s /q "C:\work\apache-jmeter-5.6.3\result_netty\report_scenario2_echo_netty"

C:\work\apache-jmeter-5.6.3\bin\jmeter.bat -n -t "C:\work\apache-jmeter-5.6.3\bin\Netty_vs_Tomcat_Echo.jmx" ^
-l "C:\work\apache-jmeter-5.6.3\result_netty\scenario2_echo_netty.jtl" ^
-e -o "C:\work\apache-jmeter-5.6.3\result_netty\report_scenario2_echo_netty" ^
-Jthreads=100 -Jramp_up_time=60 -Jduration=180 ^
-Jconstant_timer_delay=10000 ^
-Jhost=127.0.0.1 -Jport=8082 ^
-Jpath=/echo

 

 

이를 위한 코드를 보도록 하자. 코드는 Gemini에게 작성을 부탁했다.

 

코드를 매주... 같은 구조로 보다보니 그냥 끄덕여진다. 

 

TomcatEchoController.java

package com.example.loadtest.bigdata.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class TomcatEchoController {

    @PostMapping(value = "/echo", consumes = MediaType.APPLICATION_OCTET_STREAM_VALUE)
    public ResponseEntity<byte[]> echoPayload(@RequestBody byte[] payload) {
        // Spring의 HttpMessageConverter가 HTTP Body 전체를 읽어 Heap 영역의 byte[]로 변환하여 주입합니다.
        // 여기서 정확히 payload 크기(예: 10MB)만큼의 Heap 메모리 할당이 발생합니다.

        log.info("echo test call!");
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
        headers.setContentLength(payload.length);

        // 할당받은 byte[]를 그대로 응답으로 반환합니다.
        return new ResponseEntity<>(payload, headers, HttpStatus.OK);
    }
}

 

EchoHttpHandler.java

package com.example.loadtest.bigdata.netty;

import io.netty.buffer.ByteBuf;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.*;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class EchoHttpHandler extends SimpleChannelInboundHandler<FullHttpRequest> {

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest msg) {
        if (!msg.method().equals(HttpMethod.POST)) {
            sendError(ctx, HttpResponseStatus.METHOD_NOT_ALLOWED);
            return;
        }

        log.info("channel read coming!");

        // 요청 Payload가 담긴 DirectBuffer
        ByteBuf content = msg.content();

        // 수신된 버퍼를 그대로 응답 객체에 담습니다.
        // write 과정에서 버퍼가 release 되므로, retain()을 호출하여 참조 카운트를 유지합니다.
        FullHttpResponse response = new DefaultFullHttpResponse(
                msg.protocolVersion(), 
                HttpResponseStatus.OK, 
                content.retain() 
        );

        response.headers().set(HttpHeaderNames.CONTENT_TYPE, msg.headers().get(HttpHeaderNames.CONTENT_TYPE));
        response.headers().set(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes());

        // Keep-Alive 처리
        boolean keepAlive = io.netty.handler.codec.http.HttpUtil.isKeepAlive(msg);
        if (keepAlive) {
            response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
            ctx.writeAndFlush(response);
        } else {
            ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
        }
    }

    private void sendError(ChannelHandlerContext ctx, HttpResponseStatus status) {
        FullHttpResponse response = new DefaultFullHttpResponse(
                io.netty.handler.codec.http.HttpVersion.HTTP_1_1, status);
        ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        ctx.close();
    }
}

 

NettyEchoServer.java

package com.example.loadtest.bigdata.netty;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;

public class NettyEchoServer {

    private final int port;

    public NettyEchoServer(int port) {
        this.port = port;
    }

    public void start() throws InterruptedException {
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup(); // CPU 코어 수 * 2 기본값

        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
             .childHandler(new ChannelInitializer() {
                 @Override
                 protected void initChannel(Channel ch) throws Exception {
                     ch.pipeline().addLast(new HttpServerCodec());
                     // 10MB(10485760 bytes)까지 메모리에 Aggregation
                     ch.pipeline().addLast(new HttpObjectAggregator(10 * 1024 * 1024));
                     ch.pipeline().addLast(new EchoHttpHandler());
                 }
             })
             .option(ChannelOption.SO_BACKLOG, 1024)
             .childOption(ChannelOption.SO_KEEPALIVE, true);

            ChannelFuture f = b.bind(port).sync();
            System.out.println("Echo Server started on port " + port);
            f.channel().closeFuture().sync();
        } finally {
            workerGroup.shutdownGracefully();
            bossGroup.shutdownGracefully();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new NettyEchoServer(8082).start();
    }
}

 

1. cmd 창에서 실행 시 - Tomcat : /echo

Starting standalone test @ 2026 Feb 15 21:55:45 KST (1771160145903)
Waiting for possible Shutdown/StopTestNow/HeapDump/ThreadDump message on port 4445
summary +   2837 in 00:00:14 =  202.2/s Avg:   313 Min:   300 Max:   447 Err:     0 (0.00%) Active: 100 Started: 100 Finished: 0
summary + 190748 in 00:00:30 = 6359.1/s Avg:     6 Min:     0 Max:   519 Err: 187133 (98.10%) Active: 100 Started: 100 Finished: 0
summary = 193585 in 00:00:44 = 4397.0/s Avg:    11 Min:     0 Max:   519 Err: 187133 (96.67%)
summary + 348083 in 00:00:30 = 11602.8/s Avg:     0 Min:     0 Max:    29 Err: 348083 (100.00%) Active: 100 Started: 100 Finished: 0
summary = 541668 in 00:01:14 = 7317.2/s Avg:     4 Min:     0 Max:   519 Err: 535216 (98.81%)
summary + 262139 in 00:00:30 = 8738.0/s Avg:     2 Min:     0 Max:  1016 Err: 262139 (100.00%) Active: 100 Started: 100 Finished: 0
summary = 803807 in 00:01:44 = 7726.9/s Avg:     3 Min:     0 Max:  1016 Err: 797355 (99.20%)
summary + 213992 in 00:00:30 = 7133.1/s Avg:     6 Min:     0 Max:  1032 Err: 213992 (100.00%) Active: 100 Started: 100 Finished: 0
summary = 1017799 in 00:02:14 = 7594.0/s Avg:     4 Min:     0 Max:  1032 Err: 1011347 (99.37%)
summary + 309558 in 00:00:30 = 10318.6/s Avg:     2 Min:     0 Max:  1017 Err: 309558 (100.00%) Active: 100 Started: 100 Finished: 0
summary = 1327357 in 00:02:44 = 8092.3/s Avg:     3 Min:     0 Max:  1032 Err: 1320905 (99.51%)

 

2. cmd 창에서 실행 시 - Netty: /echo

Waiting for possible Shutdown/StopTestNow/HeapDump/ThreadDump message on port 4445
summary +    133 in 00:00:06 =   21.7/s Avg:   239 Min:    71 Max:   435 Err:     0 (0.00%) Active: 11 Started: 11 Finished: 0
summary +    635 in 00:00:30 =   21.1/s Avg:  1553 Min:   395 Max:  2748 Err:     0 (0.00%) Active: 61 Started: 61 Finished: 0
summary =    768 in 00:00:36 =   21.2/s Avg:  1325 Min:    71 Max:  2748 Err:     0 (0.00%)
summary +    614 in 00:00:30 =   20.5/s Avg:  3861 Min:  2731 Max:  4891 Err:     0 (0.00%) Active: 100 Started: 100 Finished: 0
summary =   1382 in 00:01:06 =   20.9/s Avg:  2452 Min:    71 Max:  4891 Err:     0 (0.00%)
summary +    601 in 00:00:30 =   20.1/s Avg:  4988 Min:  4728 Max:  5418 Err:     0 (0.00%) Active: 100 Started: 100 Finished: 0
summary =   1983 in 00:01:36 =   20.6/s Avg:  3221 Min:    71 Max:  5418 Err:     0 (0.00%)
summary +    577 in 00:00:30 =   19.2/s Avg:  5224 Min:  4729 Max:  5984 Err:     0 (0.00%) Active: 100 Started: 100 Finished: 0
summary =   2560 in 00:02:06 =   20.3/s Avg:  3672 Min:    71 Max:  5984 Err:     0 (0.00%)
summary +    573 in 00:00:30 =   19.1/s Avg:  5145 Min:  4675 Max:  5927 Err:     0 (0.00%) Active: 100 Started: 100 Finished: 0
summary =   3133 in 00:02:36 =   20.1/s Avg:  3942 Min:    71 Max:  5984 Err:     0 (0.00%)
summary +    584 in 00:00:26 =   22.2/s Avg:  4841 Min:  2354 Max:  5844 Err:     0 (0.00%) Active: 0 Started: 100 Finished: 100
summary =   3717 in 00:03:02 =   20.4/s Avg:  4083 Min:    71 Max:  5984 Err:     0 (0.00%)
Tidying up ...    @ 2026 Feb 15 22:13:56 KST (1771161236281)
... end of run

 

사실 Netty에서는 어떤 경로로 들어오던... 현재 코드에서는 작업을 필터링해주는 코드는 없기에, EchoHttpHandler가 잘 작동하는것을 확인할 수 있다.

 

 

2. 성능 비교 (Tomcat vs Netty)

실제 결과 파일을 보면서 비교를 하려고 한다.

 

Tomcat 실행 결과

 

 

Netty 실행 결과

 

 

 

두 눈을 부릅뜨고, 확대해서 별로 차이가 없다.

 

사실 처리량은 별 차이가 없다고 한다. 

 

힙 메모리 증가가 낮고, GC횟수가 현저히 적어야 한다는 사실을 보려면 사실 JVM 을 분석해야 하는것이다.

 

생각해보면 힙 메모리, GC같은것은 사실... JVM쪽에서 봐야하는게 맞다.

 

그러면 이건 어떻게 보는데?

 

VisualVM 이라는 프로그램을 다운받아서 보면 된다고 한다.

 

 

그런데 혹시 이 글을 보고 있는 당신! (사실은 나...)

 

JVM의 구조에 대해 기억하는가? 필자는 그렇게 열심히 봤었는데 하나도 기억이 안난다.

 

다시 이를 복기하고 가고자 한다.

4. JVM에 대해 이해하기

JVM 구조

 

다른 분들의 그림을 열심히 보고 직접 그려보았다. 누군가가 그린 그림을 그냥 긁어다가 붙여넣는것 보다는

 

내가 직접 그려서 이를 이해하면서 보는게 학습에는 좋다고 생각한다.

 

먼저 Class Loader, Execution Engine과 Runtime Data Areas에 대해 이해하고 실제 예시를 통해서 어떤 흐름으로 작동하는지 알아보자.

 

이를 위해 Gemini에게 JDK 8 기준으로 설명을 해달라 요청했고 답변은 다음과 같다.

 

4.1) Class Loader Subsystem (클래스 로더 시스템)

자바는 동적 로드(Dynamic Load)를 지원합니다. 애플리케이션 실행 시 모든 클래스를 한 번에 메모리에 올리지 않고, 필요한 순간에 바이트코드(.class)를 찾아 메모리에 적재합니다.

  • Loading (로딩):
    • 클래스 파일을 읽어 바이트코드를 가져옵니다.
    • Bootstrap (JRE 핵심 라이브러리) -> Extension (확장 디렉토리) -> Application (클래스패스) 순의 계층적 위임 모델(Delegation Model)을 통해 클래스를 찾습니다.

 

  • Linking (링크):
    • Verification: 로드된 바이트코드가 자바 언어 명세 및 JVM 규격을 준수하는지, 악의적인 코드가 없는지 철저히 검증합니다.
    • Preparation: 클래스 변수(static 변수)를 위한 메모리를 할당하고 기본값(디폴트 값)으로 초기화합니다.
    • Resolution: 상수 풀(Constant Pool) 내의 심볼릭 레퍼런스(논리적 주소)를 다이렉트 레퍼런스(실제 메모리 주소)로 변환합니다.

 

  • Initialization (초기화):
    • 이 단계에서 비로소 클래스의 static 블록이 실행되고, static 변수들에 코드에 명시된 실제 초기값이 할당됩니다.

 

4.2) Runtime Data Area (런타임 데이터 영역 - JDK 8 기준)

애플리케이션이 실행되면서 데이터를 저장하는 메모리 공간입니다. 고가용성/대용량 트래픽 처리 시 메모리 누수(Memory Leak) 방지와 GC 최적화의 주 무대가 됩니다.

 

스레드 공유 영역 (Thread-Shared)

  • Metaspace (JDK 8 핵심 변화): 기존 PermGen이 대체된 공간입니다. 클래스의 메타데이터(이름, 필드, 메서드 정보), 상수 풀(Constant Pool) 등이 저장됩니다. JVM Heap이 아닌 OS의 Native Memory를 직접 사용하므로, EC2나 컨테이너 환경(ECS)에서 인스턴스 메모리 크기를 산정할 때 JVM Heap 크기뿐만 아니라 Metaspace가 사용할 OS 메모리 여유분까지 반드시 고려해야 OOM(Out of Memory) 프로세스 킬을 방지할 수 있습니다.

 

  • Heap: 애플리케이션 런타임에 동적으로 생성되는 모든 객체(Instance)와 배열이 저장됩니다. Young Generation(Eden, Survivor 0/1)과 Old Generation으로 나뉘며, GC(Garbage Collector)의 집중 관리 대상입니다.

 

스레드 전용 영역 (Thread-Private)

  • JVM Stack: 스레드마다 하나씩 생성되며, 메서드가 호출될 때마다 스택 프레임(Stack Frame)이 푸시됩니다. 지역 변수(Local Variable), 매개변수, 리턴 값, 연산 중 발생하는 임시 데이터가 저장됩니다.

 

  • PC Register: 현재 스레드가 실행 중인 JVM 명령(Instruction)의 주소를 가리킵니다. 멀티스레드 환경에서 컨텍스트 스위칭이 발생할 때 실행 위치를 보장합니다.

 

  • Native Method Stack: 자바 외의 언어(C/C++)로 작성된 코드를 실행할 때 사용됩니다. 고성능 I/O 처리를 위해 NIO의 Direct Buffer를 사용하거나 Netty 등에서 JNI(Java Native Interface)를 호출할 때 이 영역을 거치게 됩니다.

 

4.3) Execution Engine (실행 엔진)

메모리(Runtime Data Area)에 적재된 바이트코드를 기계어로 변환하여 명령어 단위로 실행합니다.

  • Interpreter: 바이트코드를 한 줄씩 읽고 해석하여 OS가 실행할 수 있는 기계어로 번역합니다. 단발성 실행은 빠르지만, 동일한 메서드를 반복 호출할 때는 매번 번역해야 하므로 성능이 저하됩니다.

 

  • JIT (Just-In-Time) Compiler: 인터프리터의 한계를 극복하기 위한 핵심 엔진입니다.
    • 런타임 시 특정 메서드가 얼마나 자주 호출되는지 카운트합니다.
    • 임계치를 넘는 코드(Hot Spot)를 발견하면, 백그라운드 스레드에서 해당 바이트코드 전체를 기계어로 컴파일(C1, C2 컴파일러 활용)하고 Code Cache(Native Memory 영역) 에 저장합니다.
    • 이후 해당 코드가 호출되면 인터프리터를 거치지 않고 캐싱된 네이티브 코드를 직접 실행하여 엄청난 성능 향상을 가져옵니다. 대용량 트래픽 서버 배포 직후 '웜업(Warm-up)' 과정이 필요한 이유가 바로 이 JIT 컴파일을 유도하기 위함입니다.

 

  • Garbage Collector (GC): Heap 영역을 모니터링하며 참조되지 않는(Unreachable) 객체를 판별하여 메모리를 회수합니다.

 

4.4) 프로그램 실행 예시 및 동작 흐름

 

실제 코드를 통해서 다양한 JVM의 구성 요소들이 어떻게 작동하는지 알아보자.

 

public class PaymentProcessor {
    // 1. static 변수
    private static final double TAX_RATE = 0.1;

    public void processPayment(int amount) {
        // 2. 지역 변수 및 객체 생성
        Payment payment = new Payment(amount);
        double finalAmount = payment.calculateTotal(TAX_RATE);
        System.out.println("Total: " + finalAmount);
    }

    public static void main(String[] args) {
        PaymentProcessor processor = new PaymentProcessor();
        processor.processPayment(10000);
    }
}

class Payment {
    private int amount;

    public Payment(int amount) {
        this.amount = amount;
    }

    public double calculateTotal(double taxRate) {
        return amount + (amount * taxRate);
    }
}

 

실행 흐름 분석:

  1. 실행 트리거 및 Loading:
    • 사용자가 java PaymentProcessor 커맨드를 실행합니다.
    • Class Loader가 PaymentProcessor.class와 Payment.class를 디스크에서 찾아 메모리로 로드합니다.
  2. 메모리 적재 (Metaspace):
    • 두 클래스의 메타데이터(클래스 이름, 메서드 정보, TAX_RATE와 같은 상수 정보)가 OS의 Native Memory인 Metaspace에 저장됩니다.
  3. main 스레드 생성 (Stack & PC Register):
    • JVM이 main 스레드를 생성하고, 이에 대한 JVM StackPC Register를 할당합니다. (Frame 1 Push)
    • main 메서드를 위한 첫 번째 스택 프레임(Stack Frame)이 JVM Stack에 푸시됩니다.
  4. 객체 생성 및 참조 (Heap & Stack):
    • new PaymentProcessor()가 실행 엔진의 인터프리터에 의해 해석됩니다.
    • Heap 영역에 PaymentProcessor 인스턴스를 위한 메모리가 할당됩니다.
    • 이 인스턴스의 참조값(메모리 주소)이 JVM Stack의 main 메서드 프레임 내 지역 변수 processor에 저장됩니다.
  5. 메서드 호출 (Stack Frame 추가):
    • processor.processPayment(10000)가 호출됩니다. (Frame 2 Push)
    • processPayment를 위한 새로운 스택 프레임이 JVM Stack에 푸시됩니다. 매개변수 amount (값: 10000)가 이 프레임에 저장됩니다.
  6. 내부 비즈니스 로직 실행:
    • new Payment(amount) 호출로 인해 Heap에 Payment 인스턴스가 생성되고, 스택 프레임 내 지역 변수 payment에 참조값이 할당됩니다.
    • calculateTotal 메서드가 호출되며 다시 스택 프레임이 푸시되고 (Frame 3 Push) , 연산이 끝난 후 스택 프레임이 팝 되면서 (Frame 3 Pop) finalAmount 결과가 processPayment 프레임에 저장됩니다. 
  7. JIT 컴파일러의 개입 (가정):
    • 만약 이 processPayment 메서드가 대용량 트래픽 환경에서 수만 번 호출된다면, Execution Engine 내부의 JIT 컴파일러가 이를 감지(Hot Spot)합니다.
    • 메서드 전체를 기계어로 번역하여 Code Cache에 적재하고, 이후 호출부터는 압도적인 속도로 네이티브 코드를 직접 실행합니다.
  8. 종료 및 GC:
    • main 메서드가 끝나면 스택 프레임들이 모두 팝되어 비워집니다. (Frame 2, 1 Pop)
    • 더 이상 참조되지 않는 Heap 영역의 PaymentProcessor와 Payment 인스턴스는 이후 Garbage Collector의 대상이 되어 메모리가 회수됩니다. 

고성능 백엔드 아키텍처에서는 "동시성 문제를 막기 위해 매번 new를 한다"가 아니라, "객체는 싱글톤으로 하나만 띄워 성능을 극대화하되, 동시성 문제가 발생하지 않도록 객체 내부에 상태(공유되는 멤버 변수)를 두지 않는다(Stateless)"가 늘 고려되어야 한다.

 

5. Visual VM 실행을 통한 성능 확인

 

Visual VM?

VisualVM은 명령줄(CLI) 기반의 JDK 도구들과 가벼운 프로파일링(Profiling) 기능을 하나로 통합하여 보여주는 시각화 도구입니다. 개발 단계는 물론, 실제 운영(Production) 환경에서도 모두 사용할 수 있도록 설계되었습니다.

 

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

 

JDK 8이하 버전에서는 jdk에 포함이 되어 있었지만, 

JDK 9 이상의 버전에서는 별도로 다운을 받아야 한다.

 

해당 사이트에서 파일을 받아 압출을 푼 후 bin 폴더에 들어가면 visualvm.exe가 있다.

 

 

 

 

그런데 이미 현재 실행중인 Local 서버의 목록이 보인다.

 

자세하 봐보자.

 

 

현재 LoadTestApplication과 NettyEchoServer를 각각 8080포트와 8082 포트 둘 다에서 실행중이다.

 

pid로 치면 17044와 11688이다.

 

사실 OS 관점에서 서버를 띄운다는 것은 하나의 JVM이 하나의 프로세스 아이디(PID)랑 동일시해서 실행하는것이고

 

그 안에서 스레드와 메모리를 효율적으로 분배하게 된다.

 

그리고 각각을 더블클릭해서 선택하고 monitor를 보면 현재 JVM 현황을 모니터링 할 수 있다.

 

LoadTestApplication의 Monitor

 

NettyEchoServer의 Monitor

 

그러면 또 실행을 해보도록 하겠다.

 

Tomcat 실행시 JVM 현황

1. Heap 메모리  & CPU 사용량

Tomcat - Heap 메모리 사용량이 2GB에 육박한다.

 

Heap메모리 사용량이 Heap 메모리 크기인 2GB의 한계에 접근한다.

 

얼핏 봐서는 1.7~1.83GB를 사용한다. 

 

CPU 사용량은 36% 정도 되는걸로 보인다.

 

2. Metaspace

Tomcat - Metaspace 사용량이 한계에 근접한다.

 

Metaspace 는 한계에 수렴하는것처럼 보인다.

 

25MB 이상을 사용중이다.

 

Netty 실행시 JVM 현황

1. Heap 메모리 & CPU 사용량

Netty - Heap 메모리 사용량이 Tomcat에 비해서는 굉장히 적다.

 

재미있는 부분이 Heap 메모리 사용량이 파도가 치듯이 보인다. 

 

tomcat 실행시 최대용량과 비교해보면 물론 작을 뿐더러, 1.7~1.8GB의 Heap메모리 용량을 사용하지도 않는다.

 

CPU 사용량은 초기에 36% 정도까지 육박했다가, 그 이후로는 10% 언저리에 머무르고 종료시에만 

 

약간의 증가를 보인다. 

 

Tomcat - Metaspace 사용량이 한계에 근접한다.

 

Metaspace도 마찬가지로 한계에 수렴하는것처럼 보인다.


10MB 이상을 사용중으로 Tomcat보다는 절반 이상을 효율적으로 사용중이다.

 

4. Tomcat 과 Netty JVM 을 분석한 결론은?

어째서 이러한 현상이 일어난건지 Gemini에게 분석을 요청했다.

 

사실 실제 구성과 실행은 내가 하는게 편하지만 완벽한 결과가 있다면 분석은 AI에게 맞기고 확인하는게 나은 것 같다.

 

Heap 메모리: '파도치는 패턴'의 정체와 Zero-Copy

Netty의 힙 메모리가 200MB 선에서 파도치듯(Sawtooth, 톱니바퀴 패턴) 안정적으로 유지되는 반면,

Tomcat이 2GB 한계치까지 치솟아 병목을 일으키는 이유는 데이터가 저장되는 물리적 위치가 다르기 때문입니다.

  • Tomcat의 Heap 메모리 폭발: Tomcat(Spring Boot)은 10MB의 HTTP Payload가 들어오면, 이를 처리하기 위해 JVM Heap 영역에 정확히 10MB 크기의 byte[] 객체를 생성합니다. 100명의 유저가 동시에 요청하면 순식간에 1GB의 객체가 Heap의 Young Generation(Eden) 영역에 쌓입니다. 메모리가 즉시 고갈되므로 GC(Garbage Collector)가 쉴 새 없이 동작하지만, 들어오는 속도가 비우는 속도보다 빨라 Heap 메모리가 2GB 한계치에 수렴하게 됩니다.
  • Netty의 파도치는 패턴 (건강한 지표): Netty는 10MB Payload를 JVM Heap이 아닌 OS 커널 레벨의 Off-Heap(Direct Buffer) 영역에 할당합니다. 화면에서 보이는 50MB ~ 200MB 사이를 오가는 파도 형태의 Heap 사용량은 10MB 데이터 자체가 아니라, 요청을 제어하기 위해 생성되는 껍데기 객체들(예: HttpRequest, ByteBuf의 메타데이터 등)입니다. 이 객체들은 크기가 매우 작고 수명이 짧아, 가벼운 Minor GC만으로도 즉각 청소됩니다. 이 규칙적인 톱니바퀴 패턴은 메모리 누수 없이 Young GC가 가장 이상적이고 건강하게 동작하고 있다는 증거입니다.

 

CPU 사용량: 컨텍스트 스위칭과 GC 오버헤드

Netty의 CPU 사용량이 초기 구동 시에만 36%로 튀고 이후 10% 언저리로 안정화되는 현상 역시 아키텍처의 차이에서 기인합니다.

  • Netty의 Low CPU (NIO Event Loop): 초기 36% 스파이크는 JVM 웜업(클래스 로딩, JIT 컴파일) 및 쓰레드 풀 초기화 과정에서 발생하는 자연스러운 현상입니다. 이후 10%대로 떨어지는 이유는 Netty가 Event Loop 기반의 Non-Blocking I/O를 사용하기 때문입니다. 소수의 쓰레드(보통 CPU 코어 수 * 2)만으로 수많은 커넥션을 통제하므로 쓰레드 간의 컨텍스트 스위칭(Context Switching) 비용이 극히 적습니다. 또한, 무거운 GC 작업이 없으므로 CPU 자원을 순수하게 네트워크 I/O 처리에만 집중할 수 있습니다.
  • Tomcat의 High CPU (Thread-per-Request & GC): Tomcat 환경에서는 초당 기가바이트 단위로 쌓이는 10MB byte[] 객체들을 치우기 위해 GC 쓰레드가 CPU를 강하게 점유합니다. 게다가 요청을 처리하기 위해 다수의 Worker 쓰레드가 활성화되어 동작하므로 컨텍스트 스위칭 오버헤드까지 겹쳐 높은 CPU 사용률을 지속하게 됩니다. (첫 번째 캡처의 'Threads' 그래프를 보면 Tomcat은 쓰레드가 100개 이상으로 치솟지만, Netty는 30개 수준에서 평행선을 달리는 것을 확인할 수 있습니다.)

 

Metaspace 사용량: 로드된 클래스의 무게 차이

Metaspace는 Java 8 이후 클래스의 메타데이터(구조, 메서드 정보 등)가 저장되는 Native Memory 영역입니다.

이 사용량이 적다는 것은 애플리케이션 구동을 위해 JVM이 메모리에 읽어 들인 .class 파일의 절대적인 개수가 적다는 것을 의미합니다.

  • Tomcat (Spring Boot): 약 25MB 사용. Spring 컨테이너, DispatcherServlet, Jackson ObjectMapper 등 거대한 프레임워크 생태계의 수많은 클래스들이 로드됩니다.
  • Netty (순수 구현): 약 10MB 사용. Spring 프레임워크 없이 Raw Level의 Netty 라이브러리 핸들러 클래스들만 로드되었기 때문에 구조적으로 훨씬 가볍습니다.

결론 및 성능 튜닝 관점의 요약

이 테스트를 통해 대용량 데이터 전송 환경에서 전통적인 서블릿 기반 서버가 겪는 Heap Memory 할당 비용과 GC 오버헤드의 한계를 명확히 확인하셨습니다. 시스템 아키텍처를 설계할 때, 단순 API 서버는 Spring Boot로 가되, 대용량 파일 업/다운로드나 실시간 대용량 스트리밍을 전담하는 게이트웨이/중계 서버는 Netty 기반(혹은 Spring WebFlux)으로 분리하는 것이 인프라 비용(EC2 인스턴스 크기)을 획기적으로 낮추는 방법입니다.

 

 

어떤가? 정말 대단하지 않은가?

 

이러한 세계가 있었다니... Netty의 미친듯한 퍼포먼스에 정말 놀랐다.

 

도대체 이러한 생각을 어떻게 하게 된걸까? 하는 생각도 든다.


 

이번에는 지난 글에 이어서, Zero-copy 가 도대체 어떻게! 실제로 프로그램 실행시 성능에 긍정적인 영향을 미치는지를

Visual VM을 통해서 알아보았다.

 

요즘 드는 생각은 AI를 활용해서 분석하고 공부하고 긁어서 복붙하는데 사용하지만

 

많은 사람들이 AI 를 활용해서 무언가를 만들고자 노력하고 심지어 바이브코드도 점점 표준으로 자리잡혀가고 있는 듯 하다.

 

바이브 코딩을 한다고 해도 결국에는 공부를 해야하는게 맞지만 좀 더 분발해야겠다는 생각이다.

 

 

물론, AI는 모든것을 해주지 않기에 이렇게 직접 손으로 글을 적지만... 또 한편으로 글을 찾아서 읽어보고 그대로 적는 식으로 

 

가끔(?)은 글을 정리하고 있다.

 

대 해적의 시대가 아닌 대 AI의 시대!

 

 

다음 글에서는, work을 이제 custom 해서 netty의 기능을 확장하는 법에 대해 알아보고자 한다.